diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3de9af..295d0c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `webcmd setup --mode local --browser chrome` detects and reuses an installed normal Google Chrome with an isolated `~/.webcmd/chrome/profiles` directory. - Hosted mode can negotiate and run core validation, diagnostics, adapter lifecycle, profile lifecycle, and catalog-list commands advertised by Webcmd Cloud. - `@agentrhq/webcmd/adapter-analysis` exposes platform-neutral validation and convention-audit rules for trusted hosted command inventories. - `@agentrhq/webcmd/hosted/core-commands` exposes the `hosted-core-commands-v1` capability contract and canonical command IDs. @@ -11,6 +12,7 @@ ### Changed +- `WEBCMD_BROWSER_BINARY_PATH` can select a compatible Chromium executable for local browser Sessions; it takes precedence over the existing `CLOAKBROWSER_BINARY_PATH` override and isolates each browser build's profile data from managed Cloak profiles. - Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them. - Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance. - Local auth commands initialize user CLI compatibility shims, and hosted auth uses the same native grammar, flags, choices, and help as local mode. diff --git a/NOTICE b/NOTICE index ad99d791..a7992cf0 100644 --- a/NOTICE +++ b/NOTICE @@ -10,3 +10,57 @@ https://github.com/microsoft/playwright, licensed under Apache-2.0. The browser snapshot capture, model, renderer, diff, and page-stability modules in src/browser/snapshot/capture.ts, types.ts, render.ts, diff.ts, and wait-for-page-stable.ts are derived from https://github.com/hamr0/barebrowse. + +The browser snapshot capture, model, renderer, diff, and page-stability modules +in src/browser/snapshot/capture.ts, types.ts, render.ts, diff.ts, and +wait-for-page-stable.ts are also derived from libretto-browser-tools +(https://github.com/Skyvern-AI/libretto). + +MIT License + +Copyright (c) 2026 Libretto contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The humanizer sources in src/browser/humanizer/actionability.ts, config.ts, +elementhandle.ts, index.ts, keyboard.ts, mouse.ts, and scroll.ts are derived +from CloakHQ/cloakbrowser@0.4.5, git commit +5176971f45d02845d3d1c0adbbda0bc93addf747. + +MIT License + +Copyright (c) 2026 CloakHQ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md index fa1d163e..b4b18bcd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,10 +1,12 @@ # webcmd Privacy -The webcmd-managed CloakBrowser runtime communicates only with the local Webcmd daemon on `localhost:9777`. +Local mode keeps Cloak as the bundled default browser. SLAB is a macOS alpha opt-in selected with `webcmd setup --mode local --browser slab`, and a compatible local Chromium fork can be selected with `webcmd setup --mode local --browser /absolute/path/to/browser`. + +The SLAB browser communicates with webcmd through owner-scoped local IPC. webcmd does not expose a raw TCP debugging endpoint. The runtime can access browser pages and cookies because browser automation requires those permissions. Webcmd does not send page contents or cookies to AgentR. Except for the site-memory seed lookup and the optional candidate public-IP lookup below, Webcmd does not send browser data to AgentR. Commands run locally, and command output is printed to the local CLI process. -Trace artifacts, cache files, plugins, user adapters, and site memory are stored under `~/.webcmd`. +Trace artifacts, cache files, plugins, user adapters, and site memory are stored under `~/.webcmd`. Custom browser selections keep their own local profile directories and do not overwrite the managed Cloak profiles. ## Local site-memory seed lookup diff --git a/TESTING.md b/TESTING.md index fb53b2f2..8f305342 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,6 +11,10 @@ npm test The core package contains no site adapters; `npm test` runs the unit project. Public adapter tests live in `agentrhq/webcmd-plugins`. +Local browser coverage stays split on purpose: Cloak remains the default and +bundled runtime, SLAB is the macOS alpha opt-in path, and custom absolute +executables reuse the Cloak-compatible runtime with separate profile data. + ## Skill Sources Bundled skills are generated from `skill-src/` with litprompt. After editing a @@ -29,12 +33,23 @@ npx vitest run --project unit src/package-exports.test.ts npx vitest run --project unit src/convention-audit.test.ts src/runtime-copy.test.ts ``` -## Cloak Runtime Smoke +## SLAB Runtime Smoke + +Run: + +```bash +npx vitest run --project unit src/slab src/browser/runtime/local-slab +``` + +These tests use the local SLAB control contract and do not download or launch a browser. + +## Browser Selection Checks Run: ```bash -npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts +npx vitest run --project unit src/doctor.test.ts src/hosted/setup.test.ts ``` -The first run may download the CloakBrowser Chromium binary. Browser-backed tests no longer require a Chrome extension. +These checks cover bundled Cloak fallback, explicit Cloak, custom absolute +browser paths, `setup --status`, and doctor output for the selected browser. diff --git a/docs/agents/aider.md b/docs/agents/aider.md index 5075daf1..34612422 100644 --- a/docs/agents/aider.md +++ b/docs/agents/aider.md @@ -30,11 +30,10 @@ webcmd skills add When `webcmd skills add` prompts, choose the `agents` provider. It installs into `~/.agents/skills/` (user) or `.agents/skills/` (project). -Then add those skill files to Aider's `read` list in `.aider.conf.yml` at your repo root or home directory: +Then add the skill file to Aider's `read` list in `.aider.conf.yml` at your repo root or home directory: ```yaml read: - - ~/.agents/skills/webcmd-usage/SKILL.md - ~/.agents/skills/webcmd-browser/SKILL.md ``` @@ -44,7 +43,6 @@ For a project-scoped setup, use `.agents/skills/` paths relative to the repo: ```yaml read: - - .agents/skills/webcmd-usage/SKILL.md - .agents/skills/webcmd-browser/SKILL.md ``` @@ -93,4 +91,3 @@ Aider has no search index. When you need to discover URLs, find them yourself or * [Aider documentation](https://aider.chat/docs/) — installation, usage, LLM configuration, and in-chat commands. * [`start.md`](../../start.md) — common setup, [auth profiles and human handoff](../../start.md#auth-profiles-and-human-handoff), and [security](../../start.md#security). * [`webcmd-browser`](../../skills/webcmd-browser/SKILL.md) — the raw browser session surface. -* [`webcmd-usage`](../../skills/webcmd-usage/SKILL.md) — adapter-first usage rules. diff --git a/docs/agents/cline.md b/docs/agents/cline.md index 922d5d11..de4e1afe 100644 --- a/docs/agents/cline.md +++ b/docs/agents/cline.md @@ -34,7 +34,7 @@ For a project-scoped setup that travels with the repo: webcmd skills add --path .cline/skills --scope project ``` -Cline also discovers skills in `~/.cline/skills/`, `.cline/skills/`, and `.claude/skills/`. It loads skill metadata at startup and activates `webcmd-usage` and `webcmd-browser` on demand through its `use_skill` tool. +Cline also discovers skills in `~/.cline/skills/`, `.cline/skills/`, and `.claude/skills/`. It loads skill metadata at startup and activates `webcmd-browser` on demand through its `use_skill` tool. Restart Cline (or start a new task) after installing skills. In the extension, confirm they appear under the Skills tab (scale icon in the Cline panel). @@ -92,4 +92,3 @@ Check for browser or scraping MCP servers in `.cline/mcp.json` — they overlap * [Cline documentation](https://docs.cline.bot/cline-overview) — installation, providers, and the core workflow. * [`start.md`](../../start.md) — common setup, [auth profiles and human handoff](../../start.md#auth-profiles-and-human-handoff), and [security](../../start.md#security). * [`webcmd-browser`](../../skills/webcmd-browser/SKILL.md) — the raw browser session surface. -* [`webcmd-usage`](../../skills/webcmd-usage/SKILL.md) — adapter-first usage rules. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 3e12bb9b..80e8091d 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -70,7 +70,7 @@ webcmd --profile work \ webcmd --profile work session close work-project-k7 ``` -Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser chrome` reuses an installed Google Chrome, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. If Google Chrome is unavailable, setup links to the official installer and leaves the existing selection unchanged. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs @@ -140,7 +140,7 @@ Ordinary `curl` is neither required nor automatically authenticated. | --- | --- | | `list` | Show registered core, legacy user, plugin, and external commands. | | `setup` | Choose local or hosted mode interactively. | -| `doctor` | Diagnose browser bridge and daemon connectivity. | +| `doctor` | Diagnose the selected local browser runtime and daemon connectivity. | | `daemon` | Manage the local Webcmd daemon: status, stop, and restart. | | `artifact` | Download a hosted execution artifact to `--output`. | | `browser` | Agent-facing browser runtime for exploration and verification. | @@ -185,6 +185,8 @@ webcmd profile list -f json Each keeps its human-readable report as the `table` rendering, which stays the default. Pass another format to get the underlying result object instead — the validation report for `validate`, the verify report for `verify`, the diagnostic report for `doctor`, and a row set for `profile list`. +`webcmd setup --status` returns the configured local `browser` selection in JSON. `webcmd doctor` reports the live `Runtime` plus a `Selected browser` line so you can tell whether local mode is using bundled Cloak, installed Google Chrome, macOS-alpha SLAB, or a custom absolute executable path. + `daemon status -f json` returns `{ "running": false }` when no daemon is reachable, and otherwise reports `running`, `stale`, `pid`, `version`, `uptimeMs`, `runtimeConnected`, `profiles`, `memoryMB`, and `port`. `profile list` returns one row per profile with `contextId`, `alias`, `default`, `connected`, and `runtimeVersion`, covering both connected profiles and saved aliases that are not currently connected. If the daemon is unreachable or stale, `profile list -f json`/`-f yaml` fails with a `DAEMON_UNAVAILABLE` error (exit 1) and a restart hint instead of returning `[]` — an empty list and an unreadable runtime are different facts. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 07502fbc..b1c2afa0 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -7,6 +7,8 @@ description: Prompt-based fixes for common Webcmd install, profile, auth, plugin Most troubleshooting should be done by an agent. Give the symptom, the command, the expected output, and permission to diagnose. +For local browser selection, use `webcmd setup --mode local --browser cloak`, `webcmd setup --mode local --browser chrome`, `webcmd setup --mode local --browser slab`, or `webcmd setup --mode local --browser /absolute/path/to/browser`. Cloak remains the bundled default; Chrome reuses an installed normal Google Chrome, and SLAB is the macOS alpha opt-in. + ## Basic Diagnosis ```text @@ -142,3 +144,17 @@ Useful environment variables: | `WEBCMD_CDP_TARGET` | Filter CDP targets by URL substring. | | `WEBCMD_CACHE_DIR` | Browser state and network cache directory. | | `WEBCMD_VERBOSE` | Enable verbose logs. | + +`webcmd setup --mode local --browser chrome` uses an existing normal Google +Chrome installation and keeps its profiles under `~/.webcmd/chrome/profiles`. +Interactive setup labels Chrome as `installed` or `install required`. Webcmd +does not install Chrome automatically; when it is missing and selected, setup +links to `https://www.google.com/chrome/` and preserves the current browser +selection. + +After changing the local browser with `webcmd setup --mode local --browser ...`, +restart the daemon and run `webcmd doctor`. `webcmd doctor` reports the live +runtime plus a `Selected browser` line, and `webcmd setup --status` returns the +configured `browser` object. Custom browser builds use their own profile +directory under `~/.webcmd//profiles`, so their cookies and browser +state do not modify the managed Cloak profiles in `~/.webcmd/cloak/profiles`. diff --git a/src/browser.test.ts b/src/browser.test.ts index 7b2549f3..bbf8c23e 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { afterEach, describe, it, expect, vi } from 'vitest'; import { BrowserBridge, generateStealthJs } from './browser/index.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js'; @@ -6,11 +9,20 @@ import { __test__ as cdpTest } from './browser/cdp.js'; import { classifyBrowserError } from './browser/errors.js'; import * as daemonTransport from './browser/daemon-transport.js'; import * as daemonLifecycle from './browser/daemon-lifecycle.js'; +import { makeLocalConfig, saveWebcmdConfig } from './hosted/config.js'; afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); +function useBrowserConfig(browser: Parameters[1]): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), browser), { env: { WEBCMD_CONFIG_DIR: configDir } }); + return configDir; +} + describe('browser helpers', () => { it('extracts tab entries from string snapshots', () => { const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension'); @@ -150,6 +162,7 @@ describe('BrowserBridge state', () => { }); it('fails fast when daemon is running but runtime is disconnected (same version)', async () => { + const configDir = useBrowserConfig({ kind: 'cloak' }); const { PKG_VERSION } = await import('./version.js'); vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({ state: 'no-runtime', @@ -168,7 +181,38 @@ describe('BrowserBridge state', () => { const bridge = new BrowserBridge(); - await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready'); + try { + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready'); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('lets selected SLAB commands reach dispatch when the daemon is running but SLAB is not attached yet', async () => { + const configDir = useBrowserConfig({ kind: 'slab' }); + const { PKG_VERSION } = await import('./version.js'); + vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({ + state: 'no-runtime', + status: { + ok: true, + pid: 999999, + uptime: 0, + daemonVersion: PKG_VERSION, + runtimeConnected: false, + runtimeName: 'SLAB', + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + + const bridge = new BrowserBridge(); + + try { + await expect(bridge.connect({ timeout: 0.1, session: 's1' })).resolves.toBeDefined(); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } }); it('attempts stale daemon replacement when daemonVersion is missing', async () => { diff --git a/src/browser/browser-binary.test.ts b/src/browser/browser-binary.test.ts new file mode 100644 index 00000000..86344032 --- /dev/null +++ b/src/browser/browser-binary.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as browserBinary from './browser-binary.js'; + +describe('browser binary configuration', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('uses explicit paths for custom namespaces and never reads the retired Webcmd environment variable', () => { + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/Applications/Ignored Browser.app/Contents/MacOS/Ignored Browser'); + expect(browserBinary.resolveBrowserProfileNamespace('/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish')) + .toBe('chromiumfish'); + expect(browserBinary.resolveBrowserProfileNamespace('/Users/test/.clarkbrowser/chromium-148/Chromium.app/Contents/MacOS/Chromium')) + .toBe('clark'); + expect(browserBinary.resolveBrowserProfileNamespace('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser')) + .toBe('brave'); + expect(browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome')) + .toMatch(/^custom-chromium-[a-f0-9]{8}$/); + }); + + it('sets the legacy Cloak executable only from persisted configuration and clears it for managed Cloak', () => { + const configure = (browserBinary as typeof browserBinary & { + configureCloakBrowserBinary?: (executablePath: string | undefined, env: NodeJS.ProcessEnv) => void; + }).configureCloakBrowserBinary; + expect(configure).toBeTypeOf('function'); + const env = { + WEBCMD_BROWSER_BINARY_PATH: '/opt/ignored/chrome', + CLOAKBROWSER_BINARY_PATH: '/opt/inherited/cloak', + } as NodeJS.ProcessEnv; + + configure?.('/opt/configured/chrome', env); + expect(env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/configured/chrome'); + + configure?.(undefined, env); + expect(env.CLOAKBROWSER_BINARY_PATH).toBeUndefined(); + }); + + it('keeps unknown custom binaries separate with deterministic namespaces', () => { + const first = browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome'); + const second = browserBinary.resolveBrowserProfileNamespace('/opt/fork-two/chrome'); + + expect(first).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + expect(second).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + expect(first).not.toBe(second); + expect(browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome')).toBe(first); + }); + + it('never lets a custom binary reuse the reserved managed Cloak namespace', () => { + expect(browserBinary.resolveBrowserProfileNamespace('/Applications/Cloak.app/Contents/MacOS/Cloak')) + .toMatch(/^custom-cloak-[a-f0-9]{8}$/); + }); +}); diff --git a/src/browser/browser-binary.ts b/src/browser/browser-binary.ts new file mode 100644 index 00000000..2f997496 --- /dev/null +++ b/src/browser/browser-binary.ts @@ -0,0 +1,64 @@ +import { createHash } from 'node:crypto'; + +export const CLOAKBROWSER_BINARY_PATH_ENV = 'CLOAKBROWSER_BINARY_PATH'; + +function normalizeBrowserNamespace(value: string): string { + return value + .replace(/\.app$/i, '') + .replace(/[._\s]+/g, '-') + .replace(/-?browser$/i, '') + .replace(/[^a-zA-Z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +function browserPathHash(binaryPath: string): string { + return createHash('sha256').update(binaryPath).digest('hex').slice(0, 8); +} + +function safeCustomNamespace(candidate: string, binaryPath: string): string { + return candidate === 'cloak' + ? `custom-cloak-${browserPathHash(binaryPath)}` + : candidate; +} + +/** + * Select the on-disk namespace that owns local Chromium profile data. + * Managed Cloak retains the historical `cloak` path. + */ +export function resolveBrowserProfileNamespace( + executablePath?: string, +): string { + const binaryPath = executablePath?.trim(); + if (!binaryPath) return 'cloak'; + + const components = binaryPath.split(/[\\/]+/).filter(Boolean); + const appBundle = [...components].reverse().find(component => /\.app$/i.test(component)); + const executable = normalizeBrowserNamespace(components.at(-1) ?? ''); + const appNamespace = normalizeBrowserNamespace(appBundle ?? ''); + if (appNamespace === 'chromiumfish' || executable === 'chromiumfish') return 'chromiumfish'; + if ( + appNamespace === 'clark' + || executable === 'clark' + || components.some(component => component.toLowerCase() === '.clarkbrowser') + ) return 'clark'; + + if (appBundle) { + if (appNamespace && appNamespace !== 'chromium') { + return safeCustomNamespace(appNamespace, binaryPath); + } + } + + if (executable && !['chrome', 'chromium'].includes(executable)) { + return safeCustomNamespace(executable, binaryPath); + } + return `custom-chromium-${browserPathHash(binaryPath)}`; +} + +export function configureCloakBrowserBinary( + executablePath: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): void { + if (executablePath) env[CLOAKBROWSER_BINARY_PATH_ENV] = executablePath; + else delete env[CLOAKBROWSER_BINARY_PATH_ENV]; +} diff --git a/src/browser/command-catalog.test.ts b/src/browser/command-catalog.test.ts index 99ed85c9..076d77df 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -56,10 +56,11 @@ describe('browserCommandCatalog', () => { expect(() => browserOptionValueParser('verify', 'trace')?.('invalid')).toThrow(/off, on, retain-on-failure/); }); - it('requires a stable page id for bind and limits run to program options', () => { + it('allows either stable page selector for bind and limits run to program options', () => { const commands = new Map(browserCommandCatalog.map(command => [command.command, command])); expect(commands.get('bind')?.options).toEqual([ - expect.objectContaining({ name: 'page', required: true }), + expect.objectContaining({ name: 'page', required: false }), + expect.objectContaining({ name: 'targetId', required: false }), expect.objectContaining({ name: 'verbose', type: 'boolean' }), ]); expect(commands.get('run')?.options.map(option => option.name)).toEqual([ @@ -71,6 +72,9 @@ describe('browserCommandCatalog', () => { 'noSnapshotDiff', 'verbose', ]); + expect(browserOptionFlags(commands.get('bind')!.options[1]!, 'bind')).toBe('--target-id '); + expect(browserOptionValueParser('bind', 'targetId')?.(' target-123 ')).toBe('target-123'); + expect(() => browserOptionValueParser('bind', 'targetId')?.(' ')).toThrow(/non-empty id/); }); it('includes snapshot as the read-only browser inspection command', () => { diff --git a/src/browser/command-catalog.ts b/src/browser/command-catalog.ts index a4443248..47d2ac3b 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -135,7 +135,7 @@ export function browserOptionFlags(option: HostedArgumentContract, commandPath?: const longName = option.name.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`); if (option.name === 'verbose') return '-v, --verbose'; if (option.type === 'boolean') return `--${longName}`; - const valueName = option.name === 'page' ? 'id' + const valueName = option.name === 'page' || option.name === 'targetId' ? 'id' : option.name === 'file' ? 'path' : option.name === 'timeout' && commandPath === 'run' ? 'seconds' : option.name === 'maxOutput' ? 'characters' @@ -152,11 +152,11 @@ export function browserOptionValueParser( commandPath: string, optionName: string, ): ((value: string) => unknown) | undefined { - if (commandPath === 'bind' && optionName === 'page') { + if (commandPath === 'bind' && (optionName === 'page' || optionName === 'targetId')) { return (value: string): string => { - const page = value.trim(); - if (!page) throw new InvalidArgumentError('--page must be a non-empty stable page id'); - return page; + const id = value.trim(); + if (!id) throw new InvalidArgumentError(`--${optionName === 'targetId' ? 'target-id' : 'page'} must be a non-empty id`); + return id; }; } if (optionName === 'snapshotMode' && commandPath === 'run') return runSnapshotModeParser; @@ -180,7 +180,8 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [ ], 'require-existing'), command('init', 'Generate an adapter scaffold. Does not take --session.', 'init', [adapterNamePositional], [], 'sessionless'), command('bind', 'Bind this session to an existing page', 'bind', [], [ - option('page', 'Stable page id returned by tabs', { required: true }), + option('page', 'Stable page id returned by tabs'), + option('targetId', 'Native CDP target id for an explicitly acquired page'), verboseFlag(), ], 'require-existing'), command('verify', 'Verify an adapter against its fixture. Does not take --session.', 'verify', [adapterNamePositional], [ diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 214f3380..197cb263 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -6,6 +6,7 @@ import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; import { PKG_VERSION } from '../version.js'; import { isVerbose } from '../logger.js'; +import { loadWebcmdConfig } from '../hosted/config.js'; import { waitForBridgeReady } from './bridge-readiness.js'; import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type DaemonHealth, type DaemonStatus } from './daemon-transport.js'; @@ -127,6 +128,7 @@ export async function ensureBrowserBridgeReady( const health = await getDaemonHealth({ contextId }); const daemonVersion = health.status?.daemonVersion; const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION); + const selectedSlab = selectedLocalBrowserKind() === 'slab'; let staleDaemonReplaced = false; let spawnedProcess: ChildProcess | null = null; @@ -171,14 +173,29 @@ export async function ensureBrowserBridgeReady( throw browserConnectErrorFromHealth(health, contextId); } + if (!staleDaemonReplaced && selectedSlab && health.state !== 'stopped') { + return { health, spawnedProcess }; + } + if (staleDaemonReplaced || health.state === 'stopped') { if (verbose && (isVerbose() || process.stderr.isTTY)) { process.stderr.write('⏳ Starting daemon...\n'); } spawnedProcess = daemonLifecycleHooks.spawnDaemonProcess(); } else if (verbose && (isVerbose() || process.stderr.isTTY)) { - process.stderr.write('⏳ Waiting for Cloak runtime to connect...\n'); - process.stderr.write(' Make sure Chrome or Chromium is open and Cloak is enabled.\n'); + process.stderr.write('⏳ Waiting for Cloak to connect...\n'); + process.stderr.write(' Make sure Chrome/Chromium is open and Cloak is enabled.\n'); + } + + if (selectedSlab) { + const status = await waitForDaemonStatus(timeoutMs); + if (status) { + const finalHealth = await getDaemonHealth({ contextId }); + if (finalHealth.state !== 'profile-required' && finalHealth.state !== 'stopped') { + return { health: finalHealth, spawnedProcess }; + } + throw browserConnectErrorFromHealth(finalHealth, contextId); + } } const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); @@ -186,6 +203,11 @@ export async function ensureBrowserBridgeReady( throw browserConnectErrorFromHealth(finalHealth, contextId); } +function selectedLocalBrowserKind(): 'cloak' | 'chrome' | 'slab' | 'custom' { + const config = loadWebcmdConfig(); + return config.mode === 'local' ? config.browser.kind : 'cloak'; +} + function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string): BrowserConnectError { if (health.state === 'profile-required') { return new BrowserConnectError( @@ -206,7 +228,7 @@ function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string) if (health.state === 'no-runtime') { return new BrowserConnectError( 'Browser runtime is not ready', - 'Run `webcmd daemon restart`. If CloakBrowser is downloading its browser binary, wait for it to finish and retry.', + 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); } diff --git a/src/browser/errors.ts b/src/browser/errors.ts index faa55a0d..fb68ad70 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -127,7 +127,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str case 'extension-not-connected': return new BrowserConnectError( 'Browser runtime is not ready.' + (detail ? `\n\n${detail}` : ''), - 'Run `webcmd daemon restart`. If this is the first browser-backed command, wait for CloakBrowser to finish installing its browser binary, then retry.', + 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); case 'command-failed': diff --git a/src/browser/google-chrome.test.ts b/src/browser/google-chrome.test.ts new file mode 100644 index 00000000..42d21a78 --- /dev/null +++ b/src/browser/google-chrome.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { findInstalledGoogleChrome, googleChromeCandidates } from './google-chrome.js'; + +describe('Google Chrome discovery', () => { + it('checks system and user application folders on macOS', () => { + expect(googleChromeCandidates({ platform: 'darwin', homeDir: '/Users/test', env: {} })).toEqual([ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ]); + }); + + it('checks Google Chrome installation locations on Windows', () => { + expect(googleChromeCandidates({ + platform: 'win32', + homeDir: 'C:\\Users\\test', + env: { + PROGRAMFILES: 'C:\\Program Files', + 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)', + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', + }, + })).toEqual([ + path.win32.join('C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.win32.join('C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.win32.join('C:\\Users\\test\\AppData\\Local', 'Google', 'Chrome', 'Application', 'chrome.exe'), + ]); + }); + + it('checks PATH and standard Google Chrome locations on Linux', () => { + expect(googleChromeCandidates({ + platform: 'linux', + homeDir: '/home/test', + env: { PATH: '/custom/bin:/usr/local/bin' }, + })).toContain('/custom/bin/google-chrome'); + expect(googleChromeCandidates({ platform: 'linux', homeDir: '/home/test', env: {} })) + .toContain('/opt/google/chrome/google-chrome'); + }); + + it('reuses the first launchable Google Chrome installation', async () => { + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const userChrome = '/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const canonicalUserChrome = '/private/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const resolveRealpath = vi.fn(async (candidate: string) => { + if (candidate === systemChrome) throw new Error('missing'); + if (candidate === userChrome) return canonicalUserChrome; + throw new Error('unexpected candidate'); + }); + + await expect(findInstalledGoogleChrome({ + platform: 'darwin', + homeDir: '/Users/test', + env: {}, + realpath: resolveRealpath as never, + stat: (async () => ({ isFile: () => true })) as never, + access: (async () => undefined) as never, + })).resolves.toBe(canonicalUserChrome); + }); +}); diff --git a/src/browser/google-chrome.ts b/src/browser/google-chrome.ts new file mode 100644 index 00000000..3be2c357 --- /dev/null +++ b/src/browser/google-chrome.ts @@ -0,0 +1,68 @@ +import { constants } from 'node:fs'; +import { access, realpath, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export interface GoogleChromeDiscoveryOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + realpath?: typeof realpath; + stat?: typeof stat; + access?: typeof access; +} + +export function googleChromeCandidates(opts: GoogleChromeDiscoveryOptions = {}): string[] { + const platform = opts.platform ?? process.platform; + const env = opts.env ?? process.env; + const homeDir = opts.homeDir ?? os.homedir(); + + if (platform === 'darwin') { + const executable = ['Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome']; + return [ + path.posix.join('/Applications', ...executable), + path.posix.join(homeDir, 'Applications', ...executable), + ]; + } + + if (platform === 'win32') { + return [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA] + .filter((root): root is string => Boolean(root)) + .map(root => path.win32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe')); + } + + const pathCandidates = (env.PATH ?? '') + .split(':') + .filter(Boolean) + .flatMap(directory => [ + path.posix.join(directory, 'google-chrome'), + path.posix.join(directory, 'google-chrome-stable'), + ]); + return [...new Set([ + ...pathCandidates, + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/opt/google/chrome/google-chrome', + ])]; +} + +export async function findInstalledGoogleChrome( + opts: GoogleChromeDiscoveryOptions = {}, +): Promise { + const resolveRealpath = opts.realpath ?? realpath; + const inspect = opts.stat ?? stat; + const checkAccess = opts.access ?? access; + for (const candidate of googleChromeCandidates(opts)) { + try { + const executablePath = await resolveRealpath(candidate); + if (!(await inspect(executablePath)).isFile()) continue; + if ((opts.platform ?? process.platform) !== 'win32') { + await checkAccess(executablePath, constants.X_OK); + } + return executablePath; + } catch { + // Try the next standard Google Chrome installation location. + } + } + return undefined; +} diff --git a/src/browser/humanizer/actionability.ts b/src/browser/humanizer/actionability.ts new file mode 100644 index 00000000..dec45b19 --- /dev/null +++ b/src/browser/humanizer/actionability.ts @@ -0,0 +1,343 @@ +/** + * Playwright-style actionability checks for the humanize layer. + * + * Checks: attached, visible, stable, enabled, editable, receives pointer events. + * Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms. + */ + +import type { Page, Frame, ElementHandle } from 'playwright-core'; + +// --------------------------------------------------------------------------- +// Error hierarchy +// --------------------------------------------------------------------------- + +export class ActionabilityError extends Error { + selector: string; + check: string; + + constructor(selector: string, check: string, message: string) { + super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`); + this.name = 'ActionabilityError'; + this.selector = selector; + this.check = check; + } +} + +export class ElementNotAttachedError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'attached', 'element not found in DOM'); + this.name = 'ElementNotAttachedError'; + } +} + +export class ElementNotVisibleError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'visible', 'element is not visible'); + this.name = 'ElementNotVisibleError'; + } +} + +export class ElementNotStableError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'stable', 'element position is still changing'); + this.name = 'ElementNotStableError'; + } +} + +export class ElementNotEnabledError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'enabled', 'element is disabled'); + this.name = 'ElementNotEnabledError'; + } +} + +export class ElementNotEditableError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'editable', 'element is not editable'); + this.name = 'ElementNotEditableError'; + } +} + +export class ElementNotReceivingEventsError extends ActionabilityError { + coveringTag: string; + constructor(selector: string, coveringTag: string = 'unknown') { + super(selector, 'pointer_events', `element is covered by <${coveringTag}>`); + this.name = 'ElementNotReceivingEventsError'; + this.coveringTag = coveringTag; + } +} + +// --------------------------------------------------------------------------- +// Check-set constants +// --------------------------------------------------------------------------- + +export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events'; + +export const CHECKS_CLICK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); +export const CHECKS_HOVER: ReadonlySet = new Set(['attached', 'visible', 'pointer_events']); +export const CHECKS_INPUT: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']); +export const CHECKS_FOCUS: ReadonlySet = new Set(['attached', 'visible', 'enabled']); +export const CHECKS_CHECK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); + +const BACKOFF_MS = [100, 250, 500, 1000]; + +function backoffSleep(attempt: number): Promise { + const idx = Math.min(attempt, BACKOFF_MS.length - 1); + return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx])); +} + +// --------------------------------------------------------------------------- +// Pre-scroll actionability +// --------------------------------------------------------------------------- + +export async function ensureActionable( + pageOrFrame: Page | Frame, + selector: string, + checks: ReadonlySet, + timeout: number = 30000, + force: boolean = false, +): Promise { + if (force) return; + + const deadline = Date.now() + timeout; + let attempt = 0; + let lastError: ActionabilityError | null = null; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) { + if (lastError) throw lastError; + throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check'); + } + + try { + const loc = pageOrFrame.locator(selector).first(); + + if (checks.has('attached')) { + try { + await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotAttachedError(selector); + } + } + + if (checks.has('visible')) { + if (!await loc.isVisible()) throw new ElementNotVisibleError(selector); + } + + if (checks.has('enabled')) { + if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector); + } + + if (checks.has('editable')) { + if (!await loc.isEditable()) throw new ElementNotEditableError(selector); + } + + return; + } catch (e) { + if (e instanceof ActionabilityError) { + lastError = e; + if (Date.now() >= deadline) throw lastError; + await backoffSleep(attempt); + attempt++; + } else { + throw e; + } + } + } +} + +// --------------------------------------------------------------------------- +// Post-scroll stability check +// --------------------------------------------------------------------------- + +function boxesDiffer( + a: { x: number; y: number; width: number; height: number }, + b: { x: number; y: number; width: number; height: number }, +): boolean { + return ( + Math.abs(a.x - b.x) > 1 || + Math.abs(a.y - b.y) > 1 || + Math.abs(a.width - b.width) > 1 || + Math.abs(a.height - b.height) > 1 + ); +} + +export async function ensureStable( + pageOrFrame: Page | Frame, + selector: string, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) throw new ElementNotStableError(selector); + + const loc = pageOrFrame.locator(selector).first(); + const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); + if (!box1) throw new ElementNotAttachedError(selector); + + await new Promise(r => setTimeout(r, 100)); + + const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); + if (!box2) throw new ElementNotAttachedError(selector); + + if (!boxesDiffer(box1, box2)) return; + + if (Date.now() >= deadline) throw new ElementNotStableError(selector); + + await backoffSleep(attempt); + attempt++; + } +} + +// --------------------------------------------------------------------------- +// Pointer-events check (post-scroll, at actual click coordinates) +// --------------------------------------------------------------------------- + +const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}`; + +const POINTER_EVENTS_HANDLE_JS = `(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}`; + +export async function checkPointerEvents( + pageOrFrame: Page | Frame, + selector: string, + x: number, + y: number, + stealth?: { evaluate(expression: string): Promise } | null, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + let result: any = null; + try { + const loc = pageOrFrame.locator(selector).first(); + const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) }); + result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box }); + } catch { + result = null; + } + + if (!result || result.hit) return; + const covering = (result as any)?.covering ?? 'unknown'; + if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering); + + await backoffSleep(attempt); + attempt++; + } +} + +// --------------------------------------------------------------------------- +// ElementHandle variant +// --------------------------------------------------------------------------- + +export async function ensureActionableHandle( + el: ElementHandle, + checks: ReadonlySet, + timeout: number = 30000, + force: boolean = false, +): Promise { + if (force) return; + + const deadline = Date.now() + timeout; + let attempt = 0; + let lastError: ActionabilityError | null = null; + const label = ''; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) { + if (lastError) throw lastError; + throw new ActionabilityError(label, 'timeout', 'timeout expired before first check'); + } + + try { + if (checks.has('visible')) { + try { + await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotVisibleError(label); + } + } + + if (checks.has('enabled')) { + try { + await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotEnabledError(label); + } + } + + if (checks.has('editable')) { + try { + await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotEditableError(label); + } + } + + return; + } catch (e) { + if (e instanceof ActionabilityError) { + lastError = e; + if (Date.now() >= deadline) throw lastError; + await backoffSleep(attempt); + attempt++; + } else { + throw e; + } + } + } +} + +export async function checkPointerEventsHandle( + el: ElementHandle, + x: number, + y: number, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + let result: any; + try { + const box = await el.boundingBox(); + result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box }); + } catch { + result = null; + } + + if (!result || result.hit) return; + + const covering = (result as any)?.covering ?? 'unknown'; + if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('', covering); + + await backoffSleep(attempt); + attempt++; + } +} diff --git a/src/browser/humanizer/config.ts b/src/browser/humanizer/config.ts new file mode 100644 index 00000000..55c81ecb --- /dev/null +++ b/src/browser/humanizer/config.ts @@ -0,0 +1,254 @@ +/** + * cloakbrowser-human — Configuration and presets. + * + * All numeric parameters for human-like behavior are centralized here. + * Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HumanConfig { + // Keyboard + typing_delay: number; + typing_delay_spread: number; + typing_pause_chance: number; + typing_pause_range: [number, number]; + shift_down_delay: [number, number]; + shift_up_delay: [number, number]; + key_hold: [number, number]; + field_switch_delay: [number, number]; + mistype_chance: number; + mistype_delay_notice: [number, number]; + mistype_delay_correct: [number, number]; + + + // Mouse — movement + mouse_steps_divisor: number; + mouse_min_steps: number; + mouse_max_steps: number; + mouse_wobble_max: number; + mouse_overshoot_chance: number; + mouse_overshoot_px: [number, number]; + mouse_burst_size: [number, number]; + mouse_burst_pause: [number, number]; + + // Mouse — clicks + click_aim_delay_input: [number, number]; + click_aim_delay_button: [number, number]; + click_hold_input: [number, number]; + click_hold_button: [number, number]; + click_input_x_range: [number, number]; + + // Mouse — idle + idle_drift_px: number; + idle_pause_range: [number, number]; + + // Scroll + scroll_delta_base: [number, number]; + scroll_delta_variance: number; + scroll_pause_fast: [number, number]; + scroll_pause_slow: [number, number]; + scroll_accel_steps: [number, number]; + scroll_decel_steps: [number, number]; + scroll_overshoot_chance: number; + scroll_overshoot_px: [number, number]; + scroll_settle_delay: [number, number]; + scroll_target_zone: [number, number]; + scroll_pre_move_delay: [number, number]; + + // Initial cursor position + initial_cursor_x: [number, number]; + initial_cursor_y: [number, number]; + + + // Idle micro-movements between actions (opt-in, adds latency) + idle_between_actions: boolean; + idle_between_duration: [number, number]; +} + +export type HumanPreset = 'default' | 'careful'; + +export type HumanActionOptions = Partial & { + timeout?: number; + force?: boolean; + human_config?: Partial; +}; + +// --------------------------------------------------------------------------- +// Default preset +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: HumanConfig = { + // Keyboard + typing_delay: 70, + typing_delay_spread: 40, + typing_pause_chance: 0.1, + typing_pause_range: [400, 1000], + shift_down_delay: [30, 70], + shift_up_delay: [20, 50], + key_hold: [15, 35], + field_switch_delay: [800, 1500], + // Mistype (typo simulation) + mistype_chance: 0.02, + mistype_delay_notice: [100, 300], + mistype_delay_correct: [50, 150], + + // Mouse — movement + mouse_steps_divisor: 8, + mouse_min_steps: 25, + mouse_max_steps: 80, + mouse_wobble_max: 1.5, + mouse_overshoot_chance: 0.15, + mouse_overshoot_px: [3, 6], + mouse_burst_size: [3, 5], + mouse_burst_pause: [8, 18], + + // Mouse — clicks + click_aim_delay_input: [60, 140], + click_aim_delay_button: [80, 200], + click_hold_input: [40, 100], + click_hold_button: [60, 150], + click_input_x_range: [0.05, 0.30], + + // Mouse — idle + idle_drift_px: 3, + idle_pause_range: [300, 1000], + + // Scroll + scroll_delta_base: [80, 130], + scroll_delta_variance: 0.2, + scroll_pause_fast: [30, 80], + scroll_pause_slow: [80, 200], + scroll_accel_steps: [2, 3], + scroll_decel_steps: [2, 3], + scroll_overshoot_chance: 0.1, + scroll_overshoot_px: [50, 150], + scroll_settle_delay: [300, 600], + scroll_target_zone: [0.20, 0.80], + scroll_pre_move_delay: [100, 300], + + // Initial cursor position (as if coming from the address bar area) + initial_cursor_x: [400, 700], + initial_cursor_y: [45, 60], + + // Idle micro-movements between actions (off by default) + idle_between_actions: false, + idle_between_duration: [0.3, 0.8], +}; + +// --------------------------------------------------------------------------- +// Careful preset — everything slower and more deliberate +// --------------------------------------------------------------------------- + +const CAREFUL_CONFIG: HumanConfig = { + ...DEFAULT_CONFIG, + + // Keyboard — slower typing + typing_delay: 100, + typing_delay_spread: 50, + typing_pause_chance: 0.15, + typing_pause_range: [500, 1200], + shift_down_delay: [40, 90], + shift_up_delay: [30, 70], + key_hold: [20, 45], + field_switch_delay: [1000, 2000], + mistype_chance: 0.03, + mistype_delay_notice: [150, 400], + mistype_delay_correct: [80, 200], + + // Mouse — slower, more precise + mouse_overshoot_chance: 0.10, + mouse_burst_pause: [12, 25], + + // Mouse — clicks (longer aiming and holding) + click_aim_delay_input: [80, 180], + click_aim_delay_button: [120, 280], + click_hold_input: [60, 140], + click_hold_button: [80, 200], + + // Scroll — slower + scroll_pause_fast: [100, 200], + scroll_pause_slow: [250, 600], + scroll_settle_delay: [400, 800], + scroll_pre_move_delay: [150, 400], + + // Idle between actions enabled for careful preset + idle_between_actions: true, + idle_between_duration: [0.4, 1.0], +}; + +// --------------------------------------------------------------------------- +// Preset map +// --------------------------------------------------------------------------- + +const PRESETS: Record = { + default: DEFAULT_CONFIG, + careful: CAREFUL_CONFIG, +}; + +/** + * Resolve a preset name or partial config into a full HumanConfig. + * If `preset` is a string, returns the corresponding built-in config. + * Any keys in `overrides` replace the preset values. + */ +export function resolveConfig( + preset: HumanPreset = 'default', + overrides?: Partial, +): HumanConfig { + const base = PRESETS[preset]; + if (!base) { + throw new Error( + `Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}` + ); + } + if (!overrides) return { ...base }; + return { ...base, ...overrides }; +} + +/** + * Merge a partial overrides object on top of an existing HumanConfig. + * Returns a new object — the original ``cfg`` is never mutated. + * + * Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })`` + * so the same patched page can type different fields at different speeds + * without re-patching. + */ +export function mergeConfig( + cfg: HumanConfig, + overrides?: Partial | null, +): HumanConfig { + if (!overrides) return cfg; + return { ...cfg, ...overrides }; +} + + +// --------------------------------------------------------------------------- +// Utility: random number in range +// --------------------------------------------------------------------------- + +/** Random float in [min, max]. */ +export function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +/** Random integer in [min, max] (inclusive). */ +export function randInt(min: number, max: number): number { + return Math.floor(rand(min, max + 1)); +} + +/** Random value from a [min, max] tuple. */ +export function randRange(range: [number, number]): number { + return rand(range[0], range[1]); +} + +/** Random integer from a [min, max] tuple. */ +export function randIntRange(range: [number, number]): number { + return randInt(range[0], range[1]); +} + +/** Sleep for `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/browser/humanizer/elementhandle.ts b/src/browser/humanizer/elementhandle.ts new file mode 100644 index 00000000..d87ce00a --- /dev/null +++ b/src/browser/humanizer/elementhandle.ts @@ -0,0 +1,541 @@ +/** + * ElementHandle humanization for Playwright. + * + * Mirrors Puppeteer's ElementHandle patching architecture. + * Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles, + * and patches all interaction methods on each ElementHandle instance. + * + * Playwright ElementHandle methods patched: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * Stealth-aware: + * - Uses CDP DOM.describeNode when available to check element type + * (no main-world JS execution) + * - Falls back to el.evaluate() only when CDP is unavailable + */ + +import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core'; +import type { HumanConfig, HumanActionOptions } from './config.js'; +import { rand, randRange, sleep, mergeConfig } from './config.js'; +import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +import { humanType } from './keyboard.js'; +import { humanScrollIntoView } from './scroll.js'; +import { + ensureActionableHandle, checkPointerEventsHandle, + CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, +} from './actionability.js'; + +// --- Platform-aware select-all shortcut --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + +// ============================================================================ +// Stealth ElementHandle input check — uses CDP DOM.describeNode +// ============================================================================ + +async function isInputElementHandle( + stealth: any, // StealthEval from index.ts + el: ElementHandle, +): Promise { + // Try CDP DOM.describeNode first (no main-world JS execution) + if (stealth) { + try { + const cdp: CDPSession = await stealth.getCdpSession(); + // Playwright exposes the JSHandle's internal preview via _objectId or similar + // We need the remote object ID. Try to get it via internal API. + const impl = (el as any)._impl ?? (el as any)._object ?? el; + const guid = (impl as any)._guid; + + // Use el.evaluate as a reliable fallback within stealth context + // Playwright doesn't expose remoteObject directly like Puppeteer + } catch { /* fallthrough */ } + } + + // Fallback: el.evaluate (works reliably in Playwright) + try { + return await el.evaluate((node: any) => { + const tag = node.tagName?.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || node.getAttribute?.('contenteditable') === 'true'; + }); + } catch { + return false; + } +} + + +// ============================================================================ +// CursorState type (matches index.ts) +// ============================================================================ + +interface CursorState { + x: number; + y: number; + initialized: boolean; +} + + +// ============================================================================ +// Patch a single Playwright ElementHandle +// ============================================================================ + +export function patchSingleElementHandle( + el: ElementHandle, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + if ((el as any)._humanPatched) return; + (el as any)._humanPatched = true; + + // Save originals + const origElClick = el.click.bind(el); + const origElDblclick = el.dblclick.bind(el); + const origElHover = el.hover.bind(el); + const origElType = el.type.bind(el); + const origElFill = el.fill.bind(el); + const origElPress = el.press.bind(el); + const origElSelectOption = el.selectOption.bind(el); + const origElCheck = el.check.bind(el); + const origElUncheck = el.uncheck.bind(el); + const origElSetChecked = (el as any).setChecked?.bind(el); + const origElTap = el.tap.bind(el); + const origElFocus = el.focus.bind(el); + const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el); + + // Nested selectors + const origEl$ = el.$.bind(el); + const origEl$$ = el.$$.bind(el); + const origElWaitForSelector = el.waitForSelector.bind(el); + + // --- Nested elements are also patched --- + (el as any).$ = async (selector: string) => { + const child = await origEl$(selector); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + (el as any).$$ = async (selector: string) => { + const children = await origEl$$(selector); + for (const child of children) { + patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return children; + }; + + (el as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const child = await origElWaitForSelector(selector, options ?? {}); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + // --- Helper: get bounding box and move cursor to element --- + // Accepts a per-call ``callCfg`` so type/fill overrides like + // ``el.type(text, { human_config: { typing_delay: 30 } })`` or + // ``el.type(text, { typing_delay: 30 })`` carry through to mouse movement + // & idle timing for that single call. + // Also scrolls the element into view first so off-screen elements work + // (#129, #172 follow-up): otherwise boundingBox() returns null and we'd + // silently fall back to the unpatched native method. + const moveToElement = async (callCfg: HumanConfig = cfg) => { + // Ensure cursor is initialized + const ensureCursorInit = (page as any)._ensureCursorInit; + if (ensureCursorInit) await ensureCursorInit(); + + // Scroll into view first so boundingBox() returns coordinates even when + // the element starts below the fold. Best-effort — if humanScrollIntoView + // throws (e.g. detached element), we let boundingBox() decide whether to + // proceed or fall back to the original method. + try { + const { cursorX, cursorY } = await humanScrollIntoView( + page, raw, + () => el.boundingBox(), + cursor.x, cursor.y, callCfg, + ); + cursor.x = cursorX; + cursor.y = cursorY; + } catch { /* let boundingBox() decide */ } + + const box = await el.boundingBox(); + if (!box) return null; + + const isInp = await isInputElementHandle(stealth, el); + const target = clickTarget(box, isInp, callCfg); + + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + return { box, isInp }; + }; + + // --- el.click() --- + (el as any).click = async (options?: HumanActionOptions & { + button?: 'left' | 'right' | 'middle'; + clickCount?: number; + delay?: number; + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElClick(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + }; + + // --- el.dblclick() --- + (el as any).dblclick = async (options?: HumanActionOptions & { + button?: 'left' | 'right' | 'middle'; + delay?: number; + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElDblclick(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- el.hover() --- + (el as any).hover = async (options?: HumanActionOptions & { + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElHover(options); + }; + + // --- el.type() --- + (el as any).type = async (text: string, options?: HumanActionOptions & { + delay?: number; + noWaitAfter?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = (options as any)?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElType(text, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + await sleep(rand(100, 250)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, text, callCfg, cdpSession); + }; + + // --- el.fill() --- + (el as any).fill = async (value: string, options?: HumanActionOptions & { + force?: boolean; + noWaitAfter?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElFill(value, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, value, callCfg, cdpSession); + }; + + // --- el.press() --- + (el as any).press = async (key: string, options?: { delay?: number; noWaitAfter?: boolean; timeout?: number }) => { + await sleep(rand(20, 60)); + await originals.keyboardDown(key); + await sleep(randRange(cfg.key_hold)); + await originals.keyboardUp(key); + }; + + // --- el.selectOption() --- + (el as any).selectOption = async (values: any, options?: { + force?: boolean; + noWaitAfter?: boolean; + timeout?: number; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force); + const info = await moveToElement(); + if (!info) return origElSelectOption(values, options); + await humanClick(raw, false, cfg); + await sleep(rand(100, 300)); + return origElSelectOption(values, options); + }; + + // --- el.check() --- + (el as any).check = async (options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const checked = await el.isChecked(); + if (checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElCheck(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.uncheck() --- + (el as any).uncheck = async (options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const checked = await el.isChecked(); + if (!checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElUncheck(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.setChecked() --- + if (origElSetChecked) { + (el as any).setChecked = async (checked: boolean, options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const current = await el.isChecked(); + if (current === checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElSetChecked(checked, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + } + + // --- el.tap() --- + (el as any).tap = async (options?: { + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const info = await moveToElement(); + if (!info) return origElTap(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.focus() --- + // Move cursor humanly but use programmatic focus (no click side-effects). + // Stock Playwright el.focus() never clicks — clicking would trigger onclick, + // submit forms, navigate links, etc. + (el as any).focus = async () => { + await moveToElement(); // human-like Bézier cursor movement + await origElFocus(); // programmatic focus, no click + }; + + // --- el.scrollIntoViewIfNeeded() --- + // Playwright's native version snaps the page — a strong bot signal. + // Replace with the same accelerate → cruise → decelerate → overshoot + // wheel sequence used by page.click() etc. Falls back to the native + // method if the element is detached or scrolling fails. + if (origElScrollIntoViewIfNeeded) { + (el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const ensureCursorInit = (page as any)._ensureCursorInit; + if (ensureCursorInit) await ensureCursorInit(); + try { + const { cursorX, cursorY } = await humanScrollIntoView( + page, raw, + () => el.boundingBox(), + cursor.x, cursor.y, callCfg, + ); + cursor.x = cursorX; + cursor.y = cursorY; + } catch { + return origElScrollIntoViewIfNeeded(options); + } + }; + } +} + + +// ============================================================================ +// Page-level ElementHandle patching +// ============================================================================ + +export function patchPageElementHandles( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch page.$() — only if the method exists + if (typeof page.$ === 'function') { + const orig$ = page.$.bind(page); + (page as any).$ = async (selector: string) => { + const el = await orig$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch page.$$() + if (typeof page.$$ === 'function') { + const orig$$ = page.$$.bind(page); + (page as any).$$ = async (selector: string) => { + const els = await orig$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch page.waitForSelector() + if (typeof page.waitForSelector === 'function') { + const origWaitForSelector = page.waitForSelector.bind(page); + (page as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const el = await origWaitForSelector(selector, options ?? {}); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} + + +// ============================================================================ +// Frame-level ElementHandle patching +// ============================================================================ + +export function patchFrameElementHandles( + frame: Frame, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch frame.$() — only if the method exists + if (typeof frame.$ === 'function') { + const origFrame$ = frame.$.bind(frame); + (frame as any).$ = async (selector: string) => { + const el = await origFrame$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch frame.$$() + if (typeof frame.$$ === 'function') { + const origFrame$$ = frame.$$.bind(frame); + (frame as any).$$ = async (selector: string) => { + const els = await origFrame$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch frame.waitForSelector() + if (typeof frame.waitForSelector === 'function') { + const origFrameWaitForSelector = frame.waitForSelector.bind(frame); + (frame as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const el = await origFrameWaitForSelector(selector, options ?? {}); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} diff --git a/src/browser/humanizer/index.ts b/src/browser/humanizer/index.ts new file mode 100644 index 00000000..b3c42887 --- /dev/null +++ b/src/browser/humanizer/index.ts @@ -0,0 +1,937 @@ +/** + * Human-like behavioral layer for cloakbrowser (JS/TS). + * + * Activated via humanize: true in launch() / launchContext(). + * Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. + * + * Stealth-aware (fixes #110): + * - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate + * - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events + * - Falls back to page.evaluate only when CDP session is unavailable + * + * Patches all interaction methods: + * click, dblclick, hover, type, fill, check, uncheck, selectOption, + * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. + * + * ELEMENTHANDLE-LEVEL: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * page.$(), page.$$(), page.waitForSelector() and Frame equivalents + * return patched ElementHandles automatically. + */ + +import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; +import type { HumanConfig, HumanActionOptions } from './config.js'; +import { resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js'; +import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +import { humanType } from './keyboard.js'; +import { scrollToElement, humanScrollIntoView } from './scroll.js'; +import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js'; +import { + ensureActionable, ensureStable, checkPointerEvents, + CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, + type CheckName, +} from './actionability.js'; + +export type { HumanConfig } from './config.js'; +export { resolveConfig, mergeConfig } from './config.js'; +export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +export { humanType } from './keyboard.js'; +export { scrollToElement, humanScrollIntoView } from './scroll.js'; +export { patchSingleElementHandle } from './elementhandle.js'; + +// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + +// ============================================================================ +// CDP Isolated World — stealth DOM evaluation +// ============================================================================ + +/** + * Manages a CDP isolated execution context for DOM reads. + * Produces clean Error.stack traces (no 'eval at evaluate :302:') + * and is invisible to querySelector monkey-patches in the main world. + * + * Context ID is invalidated on navigation and auto-recreated on next call. + */ +class StealthEval { + private cdp: CDPSession | null = null; + private contextId: number | null = null; + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + private async ensureCdp(): Promise { + if (!this.cdp) { + this.cdp = await this.page.context().newCDPSession(this.page); + } + return this.cdp; + } + + private async createWorld(): Promise { + const cdp = await this.ensureCdp(); + const tree = await cdp.send('Page.getFrameTree'); + const frameId = tree.frameTree.frame.id; + const result = await cdp.send('Page.createIsolatedWorld', { + frameId, + worldName: '', + grantUniveralAccess: true, + }); + const ctxId = result.executionContextId; + this.contextId = ctxId; + return ctxId; + } + + /** + * Evaluate a JS expression in the isolated world. + * Auto-recreates the world if the context was invalidated (navigation). + * Returns the result value, or undefined on failure. + */ + async evaluate(expression: string): Promise { + if (this.contextId === null) { + await this.createWorld(); + } + + for (let attempt = 0; attempt < 2; attempt++) { + try { + const cdp = await this.ensureCdp(); + const result = await cdp.send('Runtime.evaluate', { + expression, + contextId: this.contextId!, + returnByValue: true, + }); + + if (result.exceptionDetails) { + // Context was likely invalidated by navigation + if (attempt === 0) { + await this.createWorld(); + continue; + } + return undefined; + } + + return result.result?.value; + } catch { + if (attempt === 0) { + this.contextId = null; + try { + await this.createWorld(); + } catch { + return undefined; + } + continue; + } + return undefined; + } + } + return undefined; + } + + /** Mark context as stale — call after navigation. */ + invalidate(): void { + this.contextId = null; + } + + /** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */ + async getCdpSession(): Promise { + return this.ensureCdp(); + } +} + + +// ============================================================================ +// Cursor state +// ============================================================================ + +class CursorState { + x = 0; + y = 0; + initialized = false; +} + +export function createCursorState(): CursorState { + return new CursorState(); +} + + +// ============================================================================ +// Stealth DOM queries — isolated world with evaluate fallback +// ============================================================================ + +/** + * Check if selector matches an input/textarea/contenteditable element. + * Uses CDP Isolated World when available — invisible to main world. + */ +async function isInputElement( + stealth: StealthEval | null, + page: Page, + selector: string, +): Promise { + if (stealth) { + try { + const escaped = JSON.stringify(selector); + const result = await stealth.evaluate(` + (() => { + const el = document.querySelector(${escaped}); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + })() + `); + return !!result; + } catch { + // Fall through to page.evaluate + } + } + + // Fallback: page.evaluate (detectable — should only happen if CDP fails) + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }, selector).catch(() => false); +} + +/** + * Check if the element matching selector is currently focused. + * Uses CDP Isolated World when available — invisible to main world. + */ +async function isSelectorFocused( + stealth: StealthEval | null, + page: Page, + selector: string, +): Promise { + if (stealth) { + try { + const escaped = JSON.stringify(selector); + const result = await stealth.evaluate(` + (() => { + const el = document.querySelector(${escaped}); + return el === document.activeElement; + })() + `); + return !!result; + } catch { + // Fall through to page.evaluate + } + } + + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }, selector).catch(() => false); +} + + +// ============================================================================ +// Page-level patching +// ============================================================================ + +/** + * Replace page methods with human-like implementations. + */ +export function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { + const originals = { + click: page.click.bind(page), + dblclick: page.dblclick.bind(page), + hover: page.hover.bind(page), + type: page.type.bind(page), + fill: page.fill.bind(page), + check: page.check.bind(page), + uncheck: page.uncheck.bind(page), + selectOption: page.selectOption.bind(page), + press: page.press.bind(page), + goto: page.goto.bind(page), + isChecked: page.isChecked.bind(page), + mouseMove: page.mouse.move.bind(page.mouse), + mouseClick: page.mouse.click.bind(page.mouse), + mouseDblclick: page.mouse.dblclick.bind(page.mouse), + mouseWheel: page.mouse.wheel.bind(page.mouse), + mouseDown: page.mouse.down.bind(page.mouse), + mouseUp: page.mouse.up.bind(page.mouse), + keyboardType: page.keyboard.type.bind(page.keyboard), + keyboardDown: page.keyboard.down.bind(page.keyboard), + keyboardUp: page.keyboard.up.bind(page.keyboard), + keyboardPress: page.keyboard.press.bind(page.keyboard), + keyboardInsertText: page.keyboard.insertText.bind(page.keyboard), + }; + + (page as any)._original = originals; + (page as any)._humanCfg = cfg; + + // --- Stealth infrastructure --- + const stealth = new StealthEval(page); + (page as any)._stealth = stealth; + + // CDP session for shift symbol typing (lazy-initialized, reuses stealth's session) + let cdpSession: CDPSession | null = null; + const ensureCdp = async (): Promise => { + if (!cdpSession) { + try { + cdpSession = await stealth.getCdpSession(); + } catch {} + } + return cdpSession; + }; + + const raw: RawMouse = { + move: originals.mouseMove, + down: originals.mouseDown, + up: originals.mouseUp, + wheel: originals.mouseWheel, + }; + + const rawKb: RawKeyboard = { + down: originals.keyboardDown, + up: originals.keyboardUp, + type: originals.keyboardType, + insertText: originals.keyboardInsertText, + }; + + async function ensureCursorInit(): Promise { + if (!cursor.initialized) { + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]); + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]); + await originals.mouseMove(cursor.x, cursor.y); + cursor.initialized = true; + } + } + + // --- goto (invalidate isolated world on navigation) --- + const humanGoto = async (url: string, options?: { + referer?: string; + timeout?: number; + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; + }) => { + const response = await originals.goto(url, options); + stealth.invalidate(); + patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); + return response; + }; + + // --- click --- + const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const skipChecks = (options as any)?._skipChecks ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force && !skipChecks) { + await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force); + } + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(stealth, page, selector); + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, isInput, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + await humanClick(raw, isInput, callCfg); + }; + + // --- dblclick --- + const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(stealth, page, selector); + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, isInput, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- hover --- + const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const skipChecks = (options as any)?._skipChecks ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, false, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + }; + + // --- type --- + const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force); + await sleep(randRange(callCfg.field_switch_delay)); + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 250)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, callCfg, cdp); + }; + + // --- fill (clears existing content first) --- + const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force); + await sleep(randRange(callCfg.field_switch_delay)); + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, value, callCfg, cdp); + }; + + // --- clear --- + const humanClearFn = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + // --- check --- + const humanCheckFn = async (selector: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const checked = await originals.isChecked(selector).catch(() => false); + if (!checked) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + }; + + // --- uncheck --- + const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const checked = await originals.isChecked(selector).catch(() => true); + if (checked) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + }; + + // --- selectOption --- + const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 300)); + return originals.selectOption(selector, values, options); + }; + + // --- press (checks focus first — avoids redundant mouse moves) --- + const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(key); + }; + + // --- pressSequentially --- + const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(100, 250)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, callCfg, cdp); + }; + + // --- tap --- + const humanTapFn = async (selector: string, options?: HumanActionOptions) => { + await humanClickFn(selector, options); + }; + + // Assign page-level patches + (page as any).goto = humanGoto; + (page as any).click = humanClickFn; + (page as any).dblclick = humanDblclickFn; + (page as any).hover = humanHoverFn; + (page as any).type = humanTypeFn; + (page as any).fill = humanFillFn; + (page as any).check = humanCheckFn; + (page as any).uncheck = humanUncheckFn; + (page as any).selectOption = humanSelectOptionFn; + (page as any).press = humanPressFn; + (page as any).pressSequentially = humanPressSequentiallyFn; + (page as any).tap = humanTapFn; + (page as any).clear = humanClearFn; + + // --- mouse patches --- + page.mouse.move = async (x: number, y: number, options?: { + steps?: number; + }) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + }; + + page.mouse.click = async (x: number, y: number, options?: { + button?: 'left' | 'right' | 'middle'; + clickCount?: number; + delay?: number; + }) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + await humanClick(raw, false, cfg); + }; + + // --- keyboard patches --- + page.keyboard.type = async (text: string, options?: { delay?: number }) => { + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, cfg, cdp); + }; + + // Store helpers for frame patching + (page as any)._humanCursor = cursor; + (page as any)._humanRaw = raw; + (page as any)._humanRawKb = rawKb; + (page as any)._humanOriginals = originals; + (page as any)._humanClickFn = humanClickFn; + (page as any)._humanHoverFn = humanHoverFn; + (page as any)._humanClearFn = humanClearFn; + (page as any)._humanPressFn = humanPressFn; + (page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn; + (page as any)._humanTapFn = humanTapFn; + (page as any)._ensureCursorInit = ensureCursorInit; + + // Initialize cursor immediately so it doesn't visibly jump from (0,0) + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]); + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]); + originals.mouseMove(cursor.x, cursor.y).then(() => { + cursor.initialized = true; + }).catch(() => {}); + + // --- Patch Frame-level methods (for sub-frames) --- + patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); + + // --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) --- + patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth); +} + + +// ============================================================================ +// Frame-level patching +// ============================================================================ + +/** + * Patch Frame methods so Locator-based calls go through humanization. + * All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck, + * selectOption, press, pressSequentially, tap, clear, dragAndDrop. + */ +function patchFrames( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: StealthEval, +): void { + for (const frame of iterFrames(page)) { + patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth); + // Patch frame-level ElementHandle selectors ($, $$, waitForSelector) + patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth); + } +} + +function firstFrameLocator(frame: Frame, selector: string): any { + const locator = frame.locator(selector) as any; + return typeof locator.first === 'function' ? locator.first() : locator; +} + +async function isFrameInputElement(frame: Frame, selector: string): Promise { + return firstFrameLocator(frame, selector).evaluate((el: Element) => { + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }).catch(() => false); +} + +async function isFrameSelectorFocused(frame: Frame, selector: string): Promise { + return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement) + .catch(() => false); +} + +function patchSingleFrame( + frame: Frame, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: StealthEval, +): void { + if ((frame as any)._humanPatched) return; + (frame as any)._humanPatched = true; + + // Save originals for methods that need fallback + const origFrameClick = frame.click.bind(frame); + const origFrameDblclick = frame.dblclick.bind(frame); + const origFrameHover = frame.hover.bind(frame); + const origFrameType = frame.type.bind(frame); + const origFrameFill = frame.fill.bind(frame); + const origFrameCheck = frame.check.bind(frame); + const origFrameUncheck = frame.uncheck.bind(frame); + const origFrameSelectOption = frame.selectOption.bind(frame); + const origFramePress = frame.press.bind(frame); + const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame); + const origFrameTap = (frame as any).tap?.bind(frame); + const origFrameDragAndDrop = frame.dragAndDrop.bind(frame); + + const moveToFrameSelector = async ( + selector: string, + options: HumanActionOptions | undefined, + inputBias: boolean, + remainingMs: () => number, + ) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + + const locator = firstFrameLocator(frame, selector); + if (typeof locator.scrollIntoViewIfNeeded === 'function') { + await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined); + } + const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null); + if (!box) return null; + + const isInput = inputBias || await isFrameInputElement(frame, selector); + const target = clickTarget(box, isInput, callCfg); + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + return { callCfg, isInput }; + }; + + const frameClick = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + await humanClick(raw, moved.isInput, moved.callCfg); + }; + + const getFrameCdp = async () => stealth.getCdpSession().catch(() => null); + + const frameHover = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + }; + + (frame as any).click = frameClick; + + (frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + (frame as any).hover = frameHover; + + (frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + await sleep(randRange(callCfg.field_switch_delay)); + await frameClick(selector, options); + await sleep(rand(100, 250)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options)); + }; + + (frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + await sleep(randRange(callCfg.field_switch_delay)); + await frameClick(selector, options); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options)); + }; + + (frame as any).check = async (selector: string, options?: HumanActionOptions) => { + const locator = firstFrameLocator(frame, selector); + if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options); + const checked = await locator.isChecked(); + if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options)); + }; + + (frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => { + const locator = firstFrameLocator(frame, selector); + if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options); + const checked = await locator.isChecked(); + if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options)); + }; + + (frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => { + await frameHover(selector, options); + await sleep(rand(100, 300)); + return origFrameSelectOption(selector, values, options); + }; + + (frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => { + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(key); + }; + + (frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(100, 250)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options)); + }; + + (frame as any).tap = async (selector: string, options?: HumanActionOptions) => { + await frameClick(selector, options).catch(() => origFrameTap?.(selector, options)); + }; + + (frame as any).clear = async (selector: string, options?: HumanActionOptions) => { + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + (frame as any).dragAndDrop = async (source: string, target: string, options?: { + force?: boolean; + noWaitAfter?: boolean; + sourcePosition?: { x: number; y: number }; + strict?: boolean; + targetPosition?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(1, deadline - Date.now()); + const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null); + const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null); + + if (srcBox && tgtBox) { + const sx = srcBox.x + srcBox.width / 2; + const sy = srcBox.y + srcBox.height / 2; + const tx = tgtBox.x + tgtBox.width / 2; + const ty = tgtBox.y + tgtBox.height / 2; + + await page.mouse.move(sx, sy); + await sleep(rand(100, 200)); + await originals.mouseDown(); + await sleep(rand(80, 150)); + await page.mouse.move(tx, ty); + await sleep(rand(80, 150)); + await originals.mouseUp(); + } else { + return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) }); + } + }; +} + + +function* iterFrames(page: Page): Generator { + try { + const mainFrame = page.mainFrame(); + yield mainFrame; + for (const child of mainFrame.childFrames()) { + yield child; + } + } catch {} +} + + +// ============================================================================ +// Context-level patching +// ============================================================================ + +function patchContext(context: BrowserContext, cfg: HumanConfig): void { + const cursor = new CursorState(); + for (const page of context.pages()) { + patchPage(page, cfg, cursor); + } + context.on('page', (page: Page) => { + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + }); + + const origNewPage = context.newPage.bind(context); + (context as any).newPage = async () => { + const page = await origNewPage(); + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + + +// ============================================================================ +// Browser-level patching +// ============================================================================ + +function patchBrowser(browser: Browser, cfg: HumanConfig): void { + for (const context of browser.contexts()) { + patchContext(context, cfg); + } + + const origNewContext = browser.newContext.bind(browser); + (browser as any).newContext = async (options?: Parameters[0]) => { + const context = await origNewContext(options); + patchContext(context, cfg); + return context; + }; + + const origNewPage = browser.newPage.bind(browser); + (browser as any).newPage = async (options?: Parameters[0]) => { + const page = await origNewPage(options); + if (!(page as any)._original) { + const ctx = page.context(); + if (!(ctx as any)._humanPatched) { + patchContext(ctx, cfg); + (ctx as any)._humanPatched = true; + } + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + +export { humanizePage } from './page.js'; diff --git a/src/browser/humanizer/keyboard.ts b/src/browser/humanizer/keyboard.ts new file mode 100644 index 00000000..cb1d6ff5 --- /dev/null +++ b/src/browser/humanizer/keyboard.ts @@ -0,0 +1,214 @@ +/** + * cloakbrowser-human — Human-like keyboard input. + * + * Stealth-aware: when a CDPSession is provided, shift symbols are typed + * via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace). + * Falls back to page.evaluate when no CDPSession is available. + */ + +import type { Page, CDPSession } from 'playwright-core'; +import { RawKeyboard } from './mouse.js'; +import { HumanConfig, rand, randRange, sleep } from './config.js'; + +const SHIFT_SYMBOLS = new Set([ + '@', '#', '!', '$', '%', '^', '&', '*', '(', ')', + '_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~', +]); + +const NEARBY_KEYS: Record = { + a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf', + f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm', + k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp', + p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy', + u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu', + z: 'asx', + '1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt', + '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', +}; + +/** + * CDP key code for each shift symbol's physical key. + * Used by Input.dispatchKeyEvent to produce isTrusted=true events. + */ +const SHIFT_SYMBOL_CODES: Record = { + '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', + '%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', + '(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal', + '{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash', + ':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period', + '?': 'Slash', '~': 'Backquote', +}; + +/** + * Windows virtual key codes for shift symbols. + * Input.dispatchKeyEvent uses these to match real keyboard behavior. + */ +const SHIFT_SYMBOL_KEYCODES: Record = { + '!': 49, '@': 50, '#': 51, '$': 52, '%': 53, + '^': 54, '&': 55, '*': 56, '(': 57, ')': 48, + '_': 189, '+': 187, '{': 219, '}': 221, '|': 220, + ':': 186, '"': 222, '<': 188, '>': 190, '?': 191, + '~': 192, +}; + +function isAscii(ch: string): boolean { + const code = ch.codePointAt(0); + return code !== undefined && code < 128; +} + +function getNearbyKey(ch: string): string { + const lower = ch.toLowerCase(); + if (lower in NEARBY_KEYS) { + const neighbors = NEARBY_KEYS[lower]; + const wrong = neighbors[Math.floor(Math.random() * neighbors.length)]; + return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong; + } + return ch; +} + +function isUpperCase(ch: string): boolean { + return ch.length === 1 && ch >= 'A' && ch <= 'Z'; +} + +/** + * Type text with human-like per-character timing, mistype simulation, + * and realistic shift handling. + * + * @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent + * producing isTrusted=true events with no evaluate stack trace. + * If null/undefined, falls back to page.evaluate (detectable). + */ +export async function humanType( + page: Page, + raw: RawKeyboard, + text: string, + cfg: HumanConfig, + cdpSession?: CDPSession | null, +): Promise { + const chars = [...text]; // Handle emoji surrogate pairs correctly + + for (let i = 0; i < chars.length; i++) { + const ch = chars[i]; + + // Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText + if (!isAscii(ch)) { + await sleep(randRange(cfg.key_hold)); + await raw.insertText(ch); + if (i < chars.length - 1) { + await interCharDelay(cfg); + } + continue; + } + + // Mistype chance — only for ASCII alphanumeric + if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) { + const wrong = getNearbyKey(ch); + await typeNormalChar(raw, wrong, cfg); + await sleep(randRange(cfg.mistype_delay_notice)); + await raw.down('Backspace'); + await sleep(randRange(cfg.key_hold)); + await raw.up('Backspace'); + await sleep(randRange(cfg.mistype_delay_correct)); + } + + if (isUpperCase(ch)) { + await typeShiftedChar(raw, ch, cfg); + } else if (SHIFT_SYMBOLS.has(ch)) { + await typeShiftSymbol(page, raw, ch, cfg, cdpSession); + } else { + await typeNormalChar(raw, ch, cfg); + } + + if (i < chars.length - 1) { + await interCharDelay(cfg); + } + } +} + +async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); +} + +async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); +} + +/** + * Type a shift symbol character. + * + * Stealth path (cdpSession provided): + * Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack. + * + * Fallback path (no cdpSession): + * Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent. + * Detectable via isTrusted=false and evaluate stack frame. + */ +async function typeShiftSymbol( + page: Page, + raw: RawKeyboard, + ch: string, + cfg: HumanConfig, + cdpSession?: CDPSession | null, +): Promise { + if (cdpSession) { + // --- Stealth path: CDP Input.dispatchKeyEvent --- + const code = SHIFT_SYMBOL_CODES[ch] || ''; + const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0; + + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + + await cdpSession.send('Input.dispatchKeyEvent', { + type: 'keyDown', + modifiers: 8, // Shift modifier flag + key: ch, + code, + windowsVirtualKeyCode: keyCode, + text: ch, + unmodifiedText: ch, + }); + await sleep(randRange(cfg.key_hold)); + + await cdpSession.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: 8, + key: ch, + code, + windowsVirtualKeyCode: keyCode, + }); + + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); + } else { + // --- Fallback path: page.evaluate (detectable) --- + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.insertText(ch); + await page.evaluate((key: string) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }, ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); + } +} + +async function interCharDelay(cfg: HumanConfig): Promise { + if (Math.random() < cfg.typing_pause_chance) { + await sleep(randRange(cfg.typing_pause_range)); + } else { + const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread; + await sleep(Math.max(10, delay)); + } +} diff --git a/src/browser/humanizer/mouse.ts b/src/browser/humanizer/mouse.ts new file mode 100644 index 00000000..277f0ac1 --- /dev/null +++ b/src/browser/humanizer/mouse.ts @@ -0,0 +1,213 @@ +/** + * cloakbrowser-human — Human-like mouse movement and clicking. + */ + +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; + +// --------------------------------------------------------------------------- +// Raw interface — original Playwright methods, bypassing the wrapper +// --------------------------------------------------------------------------- + +export interface RawMouse { + move: (x: number, y: number) => Promise; + down: (options?: any) => Promise; + up: (options?: any) => Promise; + wheel: (deltaX: number, deltaY: number) => Promise; +} + +export interface RawKeyboard { + down: (key: string) => Promise; + up: (key: string) => Promise; + type: (text: string) => Promise; + insertText: (text: string) => Promise; +} + +// --------------------------------------------------------------------------- +// Easing +// --------------------------------------------------------------------------- + +function easeInOut(t: number): number { + return t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// --------------------------------------------------------------------------- +// Bezier +// --------------------------------------------------------------------------- + +interface Point { + x: number; + y: number; +} + +function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + }; +} + +function randomControlPoints(start: Point, end: Point): [Point, Point] { + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.hypot(dx, dy); + const px = -dy / (dist || 1); + const py = dx / (dist || 1); + const bias1 = rand(-0.3, 0.3) * dist; + const bias2 = rand(-0.3, 0.3) * dist; + return [ + { x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 }, + { x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 }, + ]; +} + +// --------------------------------------------------------------------------- +// Human mouse movement +// --------------------------------------------------------------------------- + +export async function humanMove( + raw: RawMouse, + startX: number, + startY: number, + endX: number, + endY: number, + cfg: HumanConfig, +): Promise { + const dist = Math.hypot(endX - startX, endY - startY); + if (dist < 1) return; + + const steps = Math.max( + cfg.mouse_min_steps, + Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)), + ); + + const start: Point = { x: startX, y: startY }; + const end: Point = { x: endX, y: endY }; + const [cp1, cp2] = randomControlPoints(start, end); + + let burstCounter = 0; + const burstSize = randIntRange(cfg.mouse_burst_size); + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const easedT = easeInOut(progress); + const pt = bezier(start, cp1, cp2, end, easedT); + + const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max; + const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp; + const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp; + + await raw.move(Math.round(wx), Math.round(wy)); + + burstCounter++; + if (burstCounter >= burstSize && i < steps) { + await sleep(randRange(cfg.mouse_burst_pause)); + burstCounter = 0; + } + } + + if (Math.random() < cfg.mouse_overshoot_chance) { + const overshootDist = randRange(cfg.mouse_overshoot_px); + const angle = Math.atan2(endY - startY, endX - startX); + const ovX = Math.round(endX + Math.cos(angle) * overshootDist); + const ovY = Math.round(endY + Math.sin(angle) * overshootDist); + await raw.move(ovX, ovY); + await sleep(rand(30, 70)); + const corrX = Math.round(endX + (Math.random() - 0.5) * 4); + const corrY = Math.round(endY + (Math.random() - 0.5) * 4); + await raw.move(corrX, corrY); + } +} + +// --------------------------------------------------------------------------- +// Human click +// --------------------------------------------------------------------------- + +export function clickTarget( + box: { x: number; y: number; width: number; height: number }, + isInput: boolean, + cfg: HumanConfig, +): Point { + if (isInput) { + const xFrac = randRange(cfg.click_input_x_range); + const yFrac = rand(0.30, 0.70); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; + } + const xFrac = rand(0.35, 0.65); + const yFrac = rand(0.35, 0.65); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; +} + +export async function humanClick( + raw: RawMouse, + isInput: boolean, + cfg: HumanConfig, +): Promise { + const aimDelay = isInput + ? randRange(cfg.click_aim_delay_input) + : randRange(cfg.click_aim_delay_button); + await sleep(aimDelay); + + const holdTime = isInput + ? randRange(cfg.click_hold_input) + : randRange(cfg.click_hold_button); + await raw.down(); + await sleep(holdTime); + await raw.up(); +} + +// --------------------------------------------------------------------------- +// Human idle / drift +// --------------------------------------------------------------------------- + +export function humanIdle( + raw: RawMouse, + cx: number, + cy: number, + cfg: HumanConfig, +): Promise; +export function humanIdle( + raw: RawMouse, + seconds: number, + cx: number, + cy: number, + cfg: HumanConfig, +): Promise; +export async function humanIdle( + raw: RawMouse, + secondsOrCx: number, + cxOrCy: number, + cyOrCfg: number | HumanConfig, + maybeCfg?: HumanConfig, +): Promise { + const hasExplicitSeconds = maybeCfg !== undefined; + const seconds = hasExplicitSeconds + ? secondsOrCx + : rand((cyOrCfg as HumanConfig).idle_between_duration[0], (cyOrCfg as HumanConfig).idle_between_duration[1]); + const cx = hasExplicitSeconds ? cxOrCy : secondsOrCx; + const cy = hasExplicitSeconds ? (cyOrCfg as number) : cxOrCy; + const cfg = hasExplicitSeconds ? maybeCfg! : (cyOrCfg as HumanConfig); + const endTime = Date.now() + seconds * 1000; + let x = cx; + let y = cy; + while (Date.now() < endTime) { + const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + x += dx; + y += dy; + await raw.move(Math.round(x), Math.round(y)); + await sleep(randRange(cfg.idle_pause_range)); + } +} diff --git a/src/browser/humanizer/page.test.ts b/src/browser/humanizer/page.test.ts new file mode 100644 index 00000000..15946256 --- /dev/null +++ b/src/browser/humanizer/page.test.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { HumanConfig } from './config.js'; +import * as humanizer from './index.js'; +import { humanizePage } from './page.js'; + +const FAST_HUMAN_CONFIG: Partial = { + initial_cursor_x: [0, 0], + initial_cursor_y: [0, 0], + mouse_min_steps: 6, + mouse_max_steps: 6, + mouse_steps_divisor: 1, + mouse_wobble_max: 0, + mouse_overshoot_chance: 0, + mouse_burst_size: [100, 100], + mouse_burst_pause: [0, 0], + click_aim_delay_input: [0, 0], + click_aim_delay_button: [0, 0], + click_hold_input: [0, 0], + click_hold_button: [0, 0], + key_hold: [0, 0], + field_switch_delay: [0, 0], + shift_down_delay: [0, 0], + shift_up_delay: [0, 0], + typing_delay: 20, + typing_delay_spread: 10, + typing_pause_chance: 0, + typing_pause_range: [0, 0], + mistype_chance: 0, + scroll_delta_base: [100, 100], + scroll_delta_variance: 0, + scroll_pause_fast: [0, 0], + scroll_pause_slow: [0, 0], + scroll_accel_steps: [1, 1], + scroll_decel_steps: [1, 1], + scroll_overshoot_chance: 0, + scroll_settle_delay: [0, 0], + scroll_pre_move_delay: [0, 0], + scroll_target_zone: [0.5, 0.5], + idle_between_actions: false, +}; + +function fakeCdpSession() { + return { + send: vi.fn(async (method: string) => { + if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'frame-1' } } }; + if (method === 'Page.createIsolatedWorld') return { executionContextId: 7 }; + if (method === 'Runtime.evaluate') return { result: { value: false } }; + return {}; + }), + }; +} + +function fakeLocator(boxes: Array<{ x: number; y: number; width: number; height: number }> = [{ x: 10, y: 10, width: 20, height: 10 }]) { + let boxIndex = 0; + const node = { + tagName: 'BUTTON', + getAttribute: vi.fn(() => null), + }; + const locator: any = { + first: vi.fn(() => locator), + waitFor: vi.fn().mockResolvedValue(undefined), + isVisible: vi.fn().mockResolvedValue(true), + isEnabled: vi.fn().mockResolvedValue(true), + isEditable: vi.fn().mockResolvedValue(true), + isChecked: vi.fn().mockResolvedValue(false), + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + boundingBox: vi.fn(async () => boxes[Math.min(boxIndex++, boxes.length - 1)]), + evaluate: vi.fn(async (fn: unknown) => { + if (typeof fn === 'string') return { hit: true }; + return (fn as (el: typeof node) => unknown)(node); + }), + }; + return locator; +} + +function fakeElement(box = { x: 10, y: 10, width: 20, height: 10 }) { + const node = { + tagName: 'BUTTON', + getAttribute: vi.fn(() => null), + }; + const child = { + boundingBox: vi.fn().mockResolvedValue(box), + evaluate: vi.fn(async (fn: unknown) => { + if (typeof fn === 'string') return { hit: true }; + return (fn as (el: typeof node) => unknown)(node); + }), + waitForElementState: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + press: vi.fn(), + selectOption: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + setChecked: vi.fn(), + tap: vi.fn(), + focus: vi.fn(), + scrollIntoViewIfNeeded: vi.fn(), + isChecked: vi.fn().mockResolvedValue(false), + $: vi.fn().mockResolvedValue(null), + $$: vi.fn().mockResolvedValue([]), + waitForSelector: vi.fn().mockResolvedValue(null), + }; + return child; +} + +function fakeFrame(locator = fakeLocator()): any { + return { + childFrames: vi.fn(() => []), + locator: vi.fn(() => locator), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + pressSequentially: vi.fn(), + tap: vi.fn(), + clear: vi.fn(), + dragAndDrop: vi.fn(), + $: vi.fn().mockResolvedValue(null), + $$: vi.fn().mockResolvedValue([]), + waitForSelector: vi.fn().mockResolvedValue(null), + }; +} + +function fakePage(input?: { locator?: any; mainFrame?: any; cdp?: ReturnType }) { + const cdp = input?.cdp ?? fakeCdpSession(); + const locator = input?.locator ?? fakeLocator(); + const mainFrame = input?.mainFrame; + const browser = { + contexts: vi.fn(), + newContext: vi.fn(), + newPage: vi.fn(), + }; + const context = { + browser: vi.fn(() => browser), + pages: vi.fn(), + on: vi.fn(), + newPage: vi.fn(), + newCDPSession: vi.fn().mockResolvedValue(cdp), + }; + const page = { + context: vi.fn(() => context), + mainFrame: mainFrame ? vi.fn(() => mainFrame) : vi.fn(() => { throw new Error('no frames'); }), + locator: vi.fn(() => locator), + viewportSize: vi.fn(() => ({ width: 100, height: 100 })), + evaluate: vi.fn().mockResolvedValue({ width: 100, height: 100 }), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + pressSequentially: vi.fn(), + tap: vi.fn(), + clear: vi.fn(), + goto: vi.fn(), + isChecked: vi.fn(), + $: vi.fn(), + $$: vi.fn(), + waitForSelector: vi.fn(), + mouse: { + move: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + wheel: vi.fn(), + down: vi.fn(), + up: vi.fn(), + }, + keyboard: { + type: vi.fn(), + down: vi.fn(), + up: vi.fn(), + press: vi.fn(), + insertText: vi.fn(), + }, + }; + return { browser, context, cdp, locator, page }; +} + +function mockRandom(values: number[]) { + let index = 0; + return vi.spyOn(Math, 'random').mockImplementation(() => { + const value = values[Math.min(index, values.length - 1)] ?? 0.5; + index += 1; + return value; + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('humanizePage', () => { + it('patches only the supplied Page once without touching its context, browser, or other pages', () => { + const owned = fakePage(); + const human = fakePage(); + const actionMethods = ['click', 'dblclick', 'hover', 'type', 'fill', 'check', 'uncheck', 'selectOption', 'press', 'pressSequentially', 'tap', 'clear'] as const; + const original = { + ownedPrototype: Object.getPrototypeOf(owned.page), + unrelatedPrototype: Object.getPrototypeOf(human.page), + actions: Object.fromEntries(actionMethods.map(method => [method, owned.page[method]])), + mouse: { move: owned.page.mouse.move, click: owned.page.mouse.click }, + keyboard: { type: owned.page.keyboard.type }, + contextPages: owned.context.pages, + browserContexts: owned.browser.contexts, + unrelatedActions: Object.fromEntries(actionMethods.map(method => [method, human.page[method]])), + unrelatedMouse: { move: human.page.mouse.move, click: human.page.mouse.click }, + unrelatedKeyboard: { type: human.page.keyboard.type }, + }; + + expect(humanizePage(owned.page as any)).toBe(owned.page); + for (const method of actionMethods) expect(owned.page[method]).not.toBe(original.actions[method]); + expect(owned.page.mouse.move).not.toBe(original.mouse.move); + expect(owned.page.mouse.click).not.toBe(original.mouse.click); + expect(owned.page.keyboard.type).not.toBe(original.keyboard.type); + expect(Object.getPrototypeOf(owned.page)).toBe(original.ownedPrototype); + expect(owned.context.pages).toBe(original.contextPages); + expect(owned.browser.contexts).toBe(original.browserContexts); + expect(owned.context.pages).not.toHaveBeenCalled(); + expect(owned.context.on).not.toHaveBeenCalled(); + expect(owned.context.newPage).not.toHaveBeenCalled(); + expect(owned.browser.contexts).not.toHaveBeenCalled(); + expect(owned.browser.newContext).not.toHaveBeenCalled(); + expect(owned.browser.newPage).not.toHaveBeenCalled(); + for (const method of actionMethods) expect(human.page[method]).toBe(original.unrelatedActions[method]); + expect(human.page.mouse.move).toBe(original.unrelatedMouse.move); + expect(human.page.mouse.click).toBe(original.unrelatedMouse.click); + expect(human.page.keyboard.type).toBe(original.unrelatedKeyboard.type); + expect(Object.getPrototypeOf(human.page)).toBe(original.unrelatedPrototype); + + const patched = { click: owned.page.click, mouseMove: owned.page.mouse.move, keyboardType: owned.page.keyboard.type }; + expect(humanizePage(owned.page as any)).toBe(owned.page); + expect(owned.page.click).toBe(patched.click); + expect(owned.page.mouse.move).toBe(patched.mouseMove); + expect(owned.page.keyboard.type).toBe(patched.keyboardType); + }); + + it('does not export context or browser patch helpers', () => { + expect(humanizer).not.toHaveProperty('patchContext'); + expect(humanizer).not.toHaveProperty('patchBrowser'); + }); + + it('moves the registered Page mouse through a non-collinear path instead of a direct jump', async () => { + mockRandom([1]); + const owned = fakePage(); + const rawMove = owned.page.mouse.move; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await Promise.resolve(); + rawMove.mockClear(); + await owned.page.mouse.move(120, 0); + + const path = rawMove.mock.calls.map(([x, y]) => [x, y]); + expect(path).toHaveLength(7); + expect(path.at(0)).toEqual([0, 0]); + expect(path.at(-1)).toEqual([120, 0]); + expect(path.slice(1, -1).some(([, y]) => y !== 0)).toBe(true); + }); + + it('types through the registered Page keyboard with nonconstant inter-character cadence', async () => { + mockRandom([ + 0, 0, + 0.9, 0.5, 0.9, 0, + 0.9, 0.5, 0.9, 1, + 0.9, 0.5, + ]); + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const owned = fakePage(); + + humanizePage(owned.page as any, { ...FAST_HUMAN_CONFIG, key_hold: [1, 1] }); + await owned.page.keyboard.type('abc'); + + const interCharacterDelays = timeoutSpy.mock.calls + .map(([, ms]) => Number(ms)) + .filter(ms => ms >= 10); + expect(interCharacterDelays).toEqual([10, 30]); + expect(owned.page.keyboard.down.mock.calls.map(([key]) => key)).toEqual(['a', 'b', 'c']); + expect(owned.page.keyboard.up.mock.calls.map(([key]) => key)).toEqual(['a', 'b', 'c']); + }); + + it('uses CDP dispatchKeyEvent for shift symbols on the trusted-event path', async () => { + const owned = fakePage(); + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await owned.page.keyboard.type('@'); + + expect(owned.cdp.send).toHaveBeenCalledWith('Input.dispatchKeyEvent', expect.objectContaining({ + type: 'keyDown', + key: '@', + code: 'Digit2', + modifiers: 8, + })); + expect(owned.cdp.send).toHaveBeenCalledWith('Input.dispatchKeyEvent', expect.objectContaining({ + type: 'keyUp', + key: '@', + code: 'Digit2', + modifiers: 8, + })); + expect(owned.page.keyboard.insertText).not.toHaveBeenCalled(); + expect(owned.page.evaluate).not.toHaveBeenCalled(); + }); + + it('keeps Page click scrolling bounded when the target stays outside the viewport', async () => { + const locator = fakeLocator([{ x: 20, y: 250, width: 20, height: 10 }]); + const owned = fakePage({ locator }); + const rawClick = owned.page.click; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await owned.page.click('#outside', { force: true, timeout: 1000 }); + + const totalWheelY = owned.page.mouse.wheel.mock.calls + .reduce((sum, [, deltaY]) => sum + Math.abs(Number(deltaY)), 0); + expect(totalWheelY).toBeGreaterThan(0); + expect(totalWheelY).toBeLessThanOrEqual(300); + expect(rawClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); + + it('humanizes existing child frame interactions on the registered Page', async () => { + const childLocator = fakeLocator([{ x: 25, y: 25, width: 30, height: 12 }]); + const childFrame = fakeFrame(childLocator); + const mainFrame = fakeFrame(); + mainFrame.childFrames.mockReturnValue([childFrame]); + const owned = fakePage({ mainFrame }); + const rawChildClick = childFrame.click; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await childFrame.click('#frame-button', { timeout: 1000 }); + + expect(childLocator.scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: expect.any(Number) }); + expect(rawChildClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); + + it('patches ElementHandles returned by the registered Page without touching handles from other pages', async () => { + const ownedHandle = fakeElement({ x: 30, y: 30, width: 20, height: 10 }); + const humanHandle = fakeElement({ x: 30, y: 30, width: 20, height: 10 }); + const owned = fakePage(); + const human = fakePage(); + const rawOwnedHandleClick = ownedHandle.click; + owned.page.$.mockResolvedValue(ownedHandle); + human.page.$.mockResolvedValue(humanHandle); + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + const patchedHandle = await owned.page.$('#owned'); + const untouchedHandle = await human.page.$('#human'); + await patchedHandle.click({ force: true, timeout: 1000 }); + + expect((patchedHandle as any)._humanPatched).toBe(true); + expect((untouchedHandle as any)._humanPatched).toBeUndefined(); + expect(rawOwnedHandleClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/browser/humanizer/page.ts b/src/browser/humanizer/page.ts new file mode 100644 index 00000000..cd7684ce --- /dev/null +++ b/src/browser/humanizer/page.ts @@ -0,0 +1,15 @@ +/** + * Page-only facade for the CloakHQ humanizer vendored from cloakbrowser@0.4.5 + * at git commit 5176971f45d02845d3d1c0adbbda0bc93addf747. See NOTICE. + */ +import type { Page } from 'playwright-core'; +import { createCursorState, patchPage, resolveConfig, type HumanConfig } from './index.js'; + +const humanizedPages = new WeakSet(); + +export function humanizePage(page: Page, config?: Partial): Page { + if (humanizedPages.has(page)) return page; + patchPage(page, resolveConfig('default', config), createCursorState()); + humanizedPages.add(page); + return page; +} diff --git a/src/browser/humanizer/scroll.ts b/src/browser/humanizer/scroll.ts new file mode 100644 index 00000000..d9594693 --- /dev/null +++ b/src/browser/humanizer/scroll.ts @@ -0,0 +1,190 @@ +/** + * cloakbrowser-human — Human-like scrolling via mouse wheel events. + */ + +import type { Page } from 'playwright-core'; +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; +import { RawMouse, humanMove } from './mouse.js'; + +interface ElementBounds { + x: number; + y: number; + width: number; + height: number; +} + +function isInViewport( + bounds: ElementBounds, + viewportHeight: number, + cfg: HumanConfig, +): boolean { + const topEdge = bounds.y; + const bottomEdge = bounds.y + bounds.height; + const zoneTop = viewportHeight * cfg.scroll_target_zone[0]; + const zoneBottom = viewportHeight * cfg.scroll_target_zone[1]; + return topEdge >= zoneTop && bottomEdge <= zoneBottom; +} + +async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise { + const absD = Math.abs(delta); + const sign = delta > 0 ? 1 : -1; + let sent = 0; + while (sent < absD) { + const stepSize = rand(20, 40); + const chunk = Math.min(stepSize, absD - sent); + await raw.wheel(0, Math.round(chunk) * sign); + sent += chunk; + await sleep(rand(8, 20)); + } +} + +/** + * Humanized scrolling that takes an arbitrary ``getBox`` callable. + * + * Used by both ``scrollToElement`` (selector-based) and the ElementHandle + * ``scrollIntoViewIfNeeded`` patch so the same accelerate → cruise → + * decelerate → overshoot behavior runs everywhere. + */ +export async function humanScrollIntoView( + page: Page, + raw: RawMouse, + getBox: () => Promise, + cursorX: number, + cursorY: number, + cfg: HumanConfig, +): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> { + // Headed launches default to no_viewport so the page tracks the real OS + // window; page.viewportSize() is then null. Fall back to the live window + // dimensions so humanize works headed (the stealth-relevant mode). + let viewport = page.viewportSize(); + if (!viewport) { + viewport = await page.evaluate( + () => ({ width: window.innerWidth, height: window.innerHeight }), + ); + } + if (!viewport || !viewport.height) throw new Error('Viewport size not available'); + + let box = await getBox(); + if (!box) throw new Error('Element not found while scrolling into view'); + + if (isInViewport(box, viewport.height, cfg)) { + return { box, cursorX, cursorY, didScroll: false }; + } + + // Move cursor into scroll area + const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7)); + const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7)); + await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg); + cursorX = scrollAreaX; + cursorY = scrollAreaY; + await sleep(randRange(cfg.scroll_pre_move_delay)); + + // Calculate scroll distance + const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]); + const elementCenter = box.y + box.height / 2; + const distanceToScroll = elementCenter - targetY; + + const direction = distanceToScroll > 0 ? 1 : -1; + const absDistance = Math.abs(distanceToScroll); + const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2; + const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta)); + const accelSteps = randIntRange(cfg.scroll_accel_steps); + const decelSteps = randIntRange(cfg.scroll_decel_steps); + + let scrolled = 0; + + // Scroll loop: accelerate → cruise → decelerate + for (let i = 0; i < totalClicks; i++) { + let delta: number; + let pause: number; + + if (i < accelSteps) { + delta = rand(80, 100); + pause = randRange(cfg.scroll_pause_slow); + } else if (i >= totalClicks - decelSteps) { + delta = rand(60, 90); + pause = randRange(cfg.scroll_pause_slow); + } else { + delta = randRange(cfg.scroll_delta_base); + pause = randRange(cfg.scroll_pause_fast); + } + + delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance; + delta = Math.round(delta) * direction; + + await smoothWheel(raw, delta, cfg); + scrolled += Math.abs(delta); + await sleep(pause); + + // Check visibility every 3 steps + if (i % 3 === 2 || i === totalClicks - 1) { + box = await getBox(); + if (box && isInViewport(box, viewport.height, cfg)) { + break; + } + } + + if (scrolled >= absDistance * 1.1) break; + } + + // Optional overshoot + correction + if (Math.random() < cfg.scroll_overshoot_chance) { + const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction; + await smoothWheel(raw, overshootPx, cfg); + await sleep(randRange(cfg.scroll_settle_delay)); + + const corrections = randIntRange([1, 2]); + for (let c = 0; c < corrections; c++) { + const corrDelta = Math.round(rand(40, 80)) * -direction; + await smoothWheel(raw, corrDelta, cfg); + await sleep(rand(100, 250)); + } + } + + // Settle + await sleep(randRange(cfg.scroll_settle_delay)); + + box = await getBox(); + if (!box) throw new Error('Element lost after scrolling into view'); + + return { box, cursorX, cursorY, didScroll: true }; +} + +/** + * Selector-based humanized scroll. + * + * ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so + * callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for + * slow-loading elements (#172). Default matches Playwright's 30000ms when not specified. + * + * Returns `{ box, cursorX, cursorY, didScroll }`. + */ +export async function scrollToElement( + page: Page, + raw: RawMouse, + selector: string, + cursorX: number, + cursorY: number, + cfg: HumanConfig, + timeout?: number, +): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> { + return humanScrollIntoView( + page, raw, + () => getElementBox(page, selector, timeout), + cursorX, cursorY, cfg, + ); +} + +async function getElementBox( + page: Page, + selector: string, + timeout: number = 30000, +): Promise { + const el = page.locator(selector).first(); + try { + const box = await el.boundingBox({ timeout: Math.max(1, timeout) }); + return box; + } catch { + return null; + } +} diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index 52020797..354accaa 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -116,7 +116,7 @@ describe('createProfile', () => { expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: true }); expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: false }); expect(loadProfileConfig().aliases['eval-a']).toBe('eval-a'); - expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(true); + expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(false); }); it('rejects an invalid alias', () => { @@ -174,7 +174,7 @@ describe('setDefaultProfile membership', () => { setDefaultProfile('__audit_nope__', []); } catch (err) { expect((err as ArgumentError).message).toBe( - 'No profile matches "__audit_nope__". No Cloak profiles are available.', + 'No profile matches "__audit_nope__". No browser profiles are available.', ); expect((err as ArgumentError).hint).toContain('webcmd profile list'); } diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 857a3dec..b4bd31c0 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -3,7 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; -import { normalizeProfileId, resolveCloakProfileDir } from './runtime/local-cloak/profiles.js'; +import { normalizeProfileId } from './runtime/local-cloak/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -121,7 +121,6 @@ export function createProfile(alias: string): { contextId: string; alias: string } config.aliases[name] = contextId; saveProfileConfig(config); - fs.mkdirSync(resolveCloakProfileDir(contextId), { recursive: true }); return { contextId, alias: name, created: true }; } @@ -173,7 +172,7 @@ export function setDefaultProfile(profile: string, rows: ProfileListRow[]): Prof const usage = `usage: ${CLI_COMMAND} profile use `; if (labels.length === 0) { throw new ArgumentError( - `No profile matches "${name}". No Cloak profiles are available.`, + `No profile matches "${name}". No browser profiles are available.`, `${usage}\nRun ${CLI_COMMAND} profile list, or create one with a browser-backed command.`, ); } diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index 0dde149e..00010ab1 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -35,6 +35,8 @@ export interface BrowserRuntimeCommand { id: string; action: BrowserRuntimeAction; page?: string; + /** Native CDP target id used only to explicitly acquire an observed SLAB page. */ + targetId?: string; code?: string; session?: string; /** Raw human Session name. Normalized only by the local Session store. */ diff --git a/src/browser/runtime/configured-provider.test.ts b/src/browser/runtime/configured-provider.test.ts new file mode 100644 index 00000000..93a3548e --- /dev/null +++ b/src/browser/runtime/configured-provider.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { makeLocalConfig } from '../../hosted/config.js'; +import { LocalCloakRuntimeProvider } from './local-cloak/provider.js'; +import { LocalSlabRuntimeProvider } from './local-slab/provider.js'; +import { createConfiguredLocalBrowserRuntimeProvider } from './configured-provider.js'; + +describe('configured local browser provider', () => { + afterEach(() => { + vi.doUnmock('./local-cloak/provider.js'); + vi.doUnmock('./local-slab/provider.js'); + vi.resetModules(); + }); + + it('selects Cloak or custom Cloak directly, and selects SLAB directly', async () => { + const cloak = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { kind: 'cloak' })); + const custom = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { + kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + })); + const slab = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { kind: 'slab' })); + + expect(cloak).toBeInstanceOf(LocalCloakRuntimeProvider); + expect(custom).toBeInstanceOf(LocalCloakRuntimeProvider); + expect(await custom.status()).toMatchObject({ runtimeName: 'custom' }); + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'); + expect(slab).toBeInstanceOf(LocalSlabRuntimeProvider); + }); + + it('passes the persisted custom path and canonical namespace to Cloak', async () => { + const LocalCloakRuntimeProvider = vi.fn(); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + const executablePath = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'; + + createProvider(makeLocalConfig(new Date(0), { kind: 'custom', executablePath })); + + expect(LocalCloakRuntimeProvider).toHaveBeenCalledWith({ + executablePath, + profileNamespace: 'brave', + runtimeName: 'custom', + }); + }); + + it('runs configured Google Chrome with the stable chrome profile namespace', async () => { + const LocalCloakRuntimeProvider = vi.fn(); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + + createProvider(makeLocalConfig(new Date(0), { kind: 'chrome', executablePath })); + + expect(LocalCloakRuntimeProvider).toHaveBeenCalledWith({ + executablePath, + profileNamespace: 'chrome', + runtimeName: 'chrome', + }); + }); + + it('does not fall back to Cloak when SLAB construction fails', async () => { + const cloak = vi.fn(); + const failure = new Error('SLAB startup failed'); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider: cloak })); + vi.doMock('./local-slab/provider.js', () => ({ + LocalSlabRuntimeProvider: class { + constructor() { + throw failure; + } + }, + })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + + expect(() => createProvider(makeLocalConfig(new Date(0), { kind: 'slab' }))).toThrow(failure); + expect(cloak).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/runtime/configured-provider.ts b/src/browser/runtime/configured-provider.ts new file mode 100644 index 00000000..92ab9ae5 --- /dev/null +++ b/src/browser/runtime/configured-provider.ts @@ -0,0 +1,22 @@ +import type { LocalWebcmdConfig } from '../../hosted/config.js'; +import { configureCloakBrowserBinary, resolveBrowserProfileNamespace } from '../browser-binary.js'; +import { LocalCloakRuntimeProvider } from './local-cloak/provider.js'; +import { LocalSlabRuntimeProvider } from './local-slab/provider.js'; +import type { BrowserRuntimeProvider } from './provider.js'; + +export function createConfiguredLocalBrowserRuntimeProvider( + config?: LocalWebcmdConfig, +): BrowserRuntimeProvider { + const browser = config?.browser ?? { kind: 'cloak' }; + if (browser.kind === 'slab') return new LocalSlabRuntimeProvider(); + + const executablePath = browser.kind === 'custom' || browser.kind === 'chrome' + ? browser.executablePath + : undefined; + configureCloakBrowserBinary(executablePath); + return new LocalCloakRuntimeProvider({ + executablePath, + profileNamespace: browser.kind === 'chrome' ? 'chrome' : resolveBrowserProfileNamespace(executablePath), + runtimeName: browser.kind, + }); +} diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-cloak/browser-run.test.ts index 125f5984..37449dfe 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-cloak/browser-run.test.ts @@ -1,4 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core'; import { dispatchCloakAction } from './actions.js'; import { CloakSessionManager, type LaunchPersistentContext } from './session-manager.js'; @@ -7,8 +10,10 @@ import * as snapshot from '../../snapshot/index.js'; let browser: Browser; let context: BrowserContext; let initialPage: Page; +let initialPageId: string; let manager: CloakSessionManager; let launchPersistentContext: ReturnType>; +let baseDir: string; const command = (id: string, action: 'run' | 'snapshot' | 'tabs' | 'bind' | 'close-window', extra: Record = {}) => ({ id, @@ -19,30 +24,36 @@ const command = (id: string, action: 'run' | 'snapshot' | 'tabs' | 'bind' | 'clo ...extra, }); +const describeWithPlaywrightChromium = fs.existsSync(chromium.executablePath()) ? describe : describe.skip; + +describeWithPlaywrightChromium('local Cloak browser run', () => { beforeAll(async () => { browser = await chromium.launch({ headless: true }); }); beforeEach(async () => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-run-test-')); context = await browser.newContext(); initialPage = await context.newPage(); launchPersistentContext = vi.fn().mockResolvedValue(context); manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-browser-run-test', + baseDir, launchPersistentContext, }); - initialPage = (await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' })).page; + const initialLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + initialPage = initialLease.page; + initialPageId = initialLease.pageId; }); afterEach(async () => { await context.close(); + fs.rmSync(baseDir, { recursive: true, force: true }); }); afterAll(async () => { await browser.close(); }); -describe('local Cloak browser run', () => { it('returns a bounded redacted snapshot for the current page', async () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); const result = await dispatchCloakAction(manager, command('snapshot-1', 'snapshot')); @@ -224,14 +235,13 @@ describe('local Cloak browser run', () => { const original = await dispatchCloakAction(manager, command('run-original', 'run', { source: "await page.setContent('

original

'); return 'original';", })); - const created = await dispatchCloakAction(manager, command('new-tab', 'tabs', { - op: 'new', + const bound = await dispatchCloakAction(manager, command('bind', 'bind', { + page: initialPageId, session: 'manual', })); - const bound = await dispatchCloakAction(manager, command('bind', 'bind', { page: created.page })); expect(original).toMatchObject({ ok: true, page: expect.any(String) }); expect(bound).toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(created).toMatchObject({ ok: true, page: expect.any(String) }); + expect(initialPageId).toEqual(expect.any(String)); }); }); diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-cloak/profiles.test.ts index 472c9c1d..81a0c72c 100644 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ b/src/browser/runtime/local-cloak/profiles.test.ts @@ -19,4 +19,11 @@ describe('cloak profile resolution', () => { expect(resolveCloakProfileDir('work', { baseDir: '/tmp/webcmd' })) .toBe(path.join('/tmp/webcmd', 'cloak', 'profiles', 'work')); }); + + it('isolates profiles for a custom browser binary', () => { + expect(resolveCloakProfileDir('default', { + baseDir: '/tmp/webcmd', + profileNamespace: 'chromiumfish', + })).toBe(path.join('/tmp/webcmd', 'chromiumfish', 'profiles', 'default')); + }); }); diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-cloak/profiles.ts index 0f08e9aa..9e2df423 100644 --- a/src/browser/runtime/local-cloak/profiles.ts +++ b/src/browser/runtime/local-cloak/profiles.ts @@ -4,6 +4,7 @@ import os from 'node:os'; export interface CloakProfileDirOptions { baseDir?: string; + profileNamespace?: string; } export function normalizeProfileId(value: string | undefined | null): string { @@ -20,5 +21,5 @@ export function getWebcmdConfigDir(): string { export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { const safeProfileId = normalizeProfileId(profileId); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'cloak', 'profiles', safeProfileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), opts.profileNamespace ?? 'cloak', 'profiles', safeProfileId); } diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index bd436bad..13a98318 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -165,6 +165,11 @@ describe('LocalCloakRuntimeProvider', () => { }); }); + it('reports the configured custom runtime name', async () => { + const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test', runtimeName: 'custom' }); + await expect(provider.status()).resolves.toMatchObject({ runtimeName: 'custom' }); + }); + it('discards a temporary Session record after closing it', async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); try { diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 6b6d599a..35ced9c2 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -10,6 +10,9 @@ import { export interface LocalCloakRuntimeProviderOptions { baseDir?: string; + profileNamespace?: string; + executablePath?: string; + runtimeName?: 'cloak' | 'chrome' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } @@ -25,7 +28,11 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, }); this.manager = new CloakSessionManager({ - ...opts, + baseDir: opts.baseDir, + profileNamespace: opts.profileNamespace, + executablePath: opts.executablePath, + launchPersistentContext: opts.launchPersistentContext, + launchBackgroundPersistentContext: opts.launchBackgroundPersistentContext, hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() )), @@ -36,7 +43,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, - runtimeName: 'cloak', + runtimeName: this.opts.runtimeName ?? 'cloak', runtimeVersion: resolveCloakBrowserVersion(), profiles, pending: 0, diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index ab1a9f21..b50b679e 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -171,6 +171,7 @@ describe('CloakSessionManager', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it('launches one persistent context per profile and reuses named sessions', async () => { @@ -189,6 +190,56 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); }); + it('passes the configured executable and namespace through to Cloak', async () => { + const browserPath = '/opt/chromium-fork/chrome'; + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/opt/cloak/chrome'); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + executablePath: browserPath, + profileNamespace: 'custom-chromium-12345678', + launchPersistentContext, + }); + + await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + expect(launchPersistentContext).toHaveBeenCalledWith(expect.objectContaining({ + userDataDir: path.join( + '/tmp/webcmd-test', + 'custom-chromium-12345678', + 'profiles', + 'default', + ), + launchOptions: { executablePath: browserPath }, + })); + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe(browserPath); + }); + + it('uses the normal macOS launcher for a configured custom app-bundle executable', async () => { + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', ''); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + executablePath: '/Applications/ChromiumFork.app/Contents/MacOS/ChromiumFork', + platform: 'darwin', + launchPersistentContext, + launchBackgroundPersistentContext, + }); + + await manager.getPage({ + profileId: 'default', + session: 'work', + surface: 'browser', + windowMode: 'background', + }); + + expect(launchPersistentContext).toHaveBeenCalledOnce(); + expect(launchBackgroundPersistentContext).not.toHaveBeenCalled(); + }); + it('correlates created targets and isolates Sessions into owned windows', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 8778add2..9271dc64 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url'; import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; -import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js'; +import { + activateDarwinBackgroundContext, + launchDarwinBackgroundPersistentContext, +} from './darwin-background-launch.js'; import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; @@ -12,6 +15,7 @@ import { findExactCloakProfileProcesses } from './process-matcher.js'; import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; import { isClosedContextError } from '../../run/types.js'; +import { configureCloakBrowserBinary } from '../../browser-binary.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -153,6 +157,8 @@ export class SessionWindowConflictError extends CliError { export interface CloakSessionManagerOptions { baseDir?: string; + profileNamespace?: string; + executablePath?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; activateBackgroundContext?: typeof activateDarwinBackgroundContext; @@ -722,14 +728,23 @@ export class CloakSessionManager { } private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); + const userDataDir = resolveCloakProfileDir(profileId, { + baseDir: this.opts.baseDir, + profileNamespace: this.opts.profileNamespace, + }); fs.mkdirSync(userDataDir, { recursive: true }); + configureCloakBrowserBinary(this.opts.executablePath); const launchOptions = { userDataDir, headless: false, humanize: true, + ...(this.opts.executablePath ? { launchOptions: { executablePath: this.opts.executablePath } } : {}), }; - const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' + // The macOS background launcher depends on Cloak Chromium publishing a + // DevToolsActivePort file. Compatible Chromium forks may be app bundles but + // not implement that contract, so custom executables use Playwright's + // normal persistent launcher instead. + const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' && !this.opts.executablePath ? this.launchBackgroundPersistentContext : this.launchPersistentContext; let context: BrowserContext; diff --git a/src/browser/runtime/local-slab/__fixtures__/attach.response.json b/src/browser/runtime/local-slab/__fixtures__/attach.response.json new file mode 100644 index 00000000..9d71c4d5 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/attach.response.json @@ -0,0 +1,23 @@ +{ + "request": { + "id": "attach-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "attach-1", + "ok": true, + "result": { + "connectionId": "00000000-0000-4000-8000-000000000000", + "profile": { "id": "default", "displayName": "Default" }, + "transport": { + "kind": "cdp-ipc", + "endpoint": "/Users/test/.slab/run/AAAAAAAAAAA.sock", + "credential": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/errors.json b/src/browser/runtime/local-slab/__fixtures__/errors.json new file mode 100644 index 00000000..38062206 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/errors.json @@ -0,0 +1,110 @@ +{ + "INVALID_REQUEST": { + "request": { + "id": "error-invalid-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-invalid-1", + "ok": false, + "error": { + "code": "INVALID_REQUEST", + "message": "Invalid JSON, framing, shape, size, or params" + } + } + }, + "INCOMPATIBLE_PROTOCOL": { + "request": { + "id": "error-protocol-1", + "method": "hello", + "params": { + "protocolVersion": { "min": 2, "max": 2 }, + "clientVersion": "webcmd/0.7.3" + } + }, + "response": { + "id": "error-protocol-1", + "ok": false, + "error": { + "code": "INCOMPATIBLE_PROTOCOL", + "message": "Client range does not include v1" + } + } + }, + "PROFILE_NOT_FOUND": { + "request": { + "id": "error-profile-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "missing-profile" + } + }, + "response": { + "id": "error-profile-1", + "ok": false, + "error": { + "code": "PROFILE_NOT_FOUND", + "message": "Requested profile is unavailable" + } + } + }, + "ATTACH_FAILED": { + "request": { + "id": "error-attach-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-attach-1", + "ok": false, + "error": { + "code": "ATTACH_FAILED", + "message": "Browser could not create the attachment" + } + } + }, + "AUTHENTICATION_FAILED": { + "request": { + "id": "error-auth-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-auth-1", + "ok": false, + "error": { + "code": "AUTHENTICATION_FAILED", + "message": "CDP IPC credential was missing or wrong" + } + } + }, + "CONNECTION_NOT_FOUND": { + "request": { + "id": "error-connection-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-connection-1", + "ok": false, + "error": { + "code": "CONNECTION_NOT_FOUND", + "message": "A non-release operation referenced an unknown lease" + } + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/hello.response.json b/src/browser/runtime/local-slab/__fixtures__/hello.response.json new file mode 100644 index 00000000..33f302b1 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/hello.response.json @@ -0,0 +1,20 @@ +{ + "request": { + "id": "hello-1", + "method": "hello", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "clientVersion": "webcmd/0.7.3" + } + }, + "response": { + "id": "hello-1", + "ok": true, + "result": { + "protocolVersion": 1, + "browserVersion": "152.0.7977.65", + "browserPid": 1234, + "profiles": [{ "id": "default", "displayName": "Default" }] + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/release.response.json b/src/browser/runtime/local-slab/__fixtures__/release.response.json new file mode 100644 index 00000000..382453dd --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/release.response.json @@ -0,0 +1,30 @@ +{ + "request": { + "id": "release-1", + "method": "release", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "connectionId": "00000000-0000-4000-8000-000000000000" + } + }, + "response": { + "id": "release-1", + "ok": true, + "result": null + }, + "alreadyRevoked": { + "request": { + "id": "release-2", + "method": "release", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "connectionId": "00000000-0000-4000-8000-000000000000" + } + }, + "response": { + "id": "release-2", + "ok": true, + "result": null + } + } +} diff --git a/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts new file mode 100644 index 00000000..7e90e42c --- /dev/null +++ b/src/browser/runtime/local-slab/actions.ts @@ -0,0 +1,559 @@ +import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js'; +import { extractArticle, type ExtractedArticle } from '../../article-extract.js'; +import { + captureSnapshot, + boundSnapshotText, + MemorySnapshotBaselineStore, + renderSnapshotResult, + type SnapshotBaselineStore, +} from '../../snapshot/index.js'; +import { redactText, redactUrl } from '../../../observation/redaction.js'; +import { articleHtmlToMarkdown } from '../../../download/article-download.js'; +import { waitForDownload } from './downloads.js'; +import { SlabAttachmentLostError, type SlabSessionManager } from './session-manager.js'; +import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; +import { runBrowserProgram } from '../../run/runner.js'; +import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; + +const snapshotBaselines = new WeakMap(); + +function snapshotBaselineStore(manager: SlabSessionManager): SnapshotBaselineStore { + let baselineStore = snapshotBaselines.get(manager); + if (!baselineStore) { + baselineStore = new MemorySnapshotBaselineStore(); + snapshotBaselines.set(manager, baselineStore); + } + return baselineStore; +} + +class SlabActionError extends Error { + constructor( + readonly errorCode: string, + error: string, + readonly page?: string, + readonly errorHint?: string, + ) { + super(error); + } +} + +export function resolveSlabCommandProfileId(manager: SlabSessionManager, command: BrowserRuntimeCommand): string { + const requested = command.profileId ?? command.contextId; + if (requested?.trim()) return requested.trim(); + + const preferred = command.preferredContextId?.trim(); + if (!preferred) return 'default'; + + const active = manager.activeProfileIds(); + if (active.includes(preferred)) return preferred; + if (active.length === 1) return active[0]; + if (active.length > 1) { + throw new SlabActionError( + 'profile_required', + `Default SLAB profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, + undefined, + 'Run webcmd profile list, then update the default with webcmd profile use or pass --profile .', + ); + } + return preferred; +} + +function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserRuntimeResult { + return { id: command.id, ok: false, errorCode: 'invalid_request', error }; +} + +/** + * Translate the command vocabulary ('load' | 'none') into Playwright's for a + * `page.goto` call. 'none' maps to 'commit': sites that stream analytics forever + * never fire the load event, so adapters gating readiness on their own selector + * waits must be able to skip it. + * + * Every Playwright-backed navigation in this runtime goes through here, so a + * future waitUntil value reaches all of them at once — the hardcoded literal at + * the second call site is what left `tab new --url` hanging after #106/#107. + */ +function toGotoWaitUntil(waitUntil: BrowserRuntimeCommand['waitUntil']): 'load' | 'commit' { + return waitUntil === 'none' ? 'commit' : 'load'; +} + +async function resolveLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); + if (command.page) { + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + } + return manager.getPage({ + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }); +} + +async function resolveExistingLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); + if (command.page) { + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + } + const existing = await manager.findPage({ + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError( + 'session_not_found', + `Browser session not found: ${command.session ?? ''}`, + undefined, + 'Start the session with browser run or navigate before requesting a snapshot.', + ); +} + +function execTarget(page: PlaywrightPage, frameIndex: number | undefined, pageId: string): PlaywrightPage | Frame { + if (frameIndex == null) return page; + const frame = page.frames().slice(1)[frameIndex]; + if (!frame) throw new SlabActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId); + return frame; +} + +function readableSnapshotText(article: ExtractedArticle | null): { text: string; warnings: string[]; article: unknown } { + if (!article) { + return { + text: 'No readable article content found. Use --snapshot-mode tree to inspect the page structure.', + warnings: ['No readable article content found.'], + article: null, + }; + } + const meta = [ + article.title ? `# ${article.title}` : '', + article.byline ? `> Author: ${article.byline}` : '', + article.publishedTime ? `> Published: ${article.publishedTime}` : '', + article.siteName ? `> Site: ${article.siteName}` : '', + `> Source: ${article.source}`, + ].filter(Boolean); + return { + text: `${meta.join('\n')}\n\n${articleHtmlToMarkdown(article.html)}`.trim(), + warnings: [], + article: { + title: article.title, + byline: article.byline, + publishedTime: article.publishedTime, + siteName: article.siteName, + source: article.source, + }, + }; +} + +async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, command: BrowserRuntimeCommand): Promise { + const width = Number.isFinite(command.width) && command.width! > 0 ? Math.ceil(command.width!) : undefined; + const height = !command.fullPage && Number.isFinite(command.height) && command.height! > 0 ? Math.ceil(command.height!) : undefined; + const options = { + type: command.format ?? 'png', + quality: command.format === 'jpeg' ? command.quality : undefined, + fullPage: command.fullPage, + } as const; + if (width === undefined && height === undefined) return page.screenshot(options); + + const current = page.viewportSize(); + if (current) { + // Emulated viewport: override for the shot, then restore the prior fixed size. + await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height }); + try { + return await page.screenshot(options); + } finally { + await page.setViewportSize(current); + } + } + + // Real-window context (viewport: null): setViewportSize can't return to a windowed + // state, so override reversibly via CDP and clear it so the override is per-shot only. + const windowSize = width === undefined || height === undefined + ? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })) + : { width: 0, height: 0 }; + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: width ?? windowSize.width, + height: height ?? windowSize.height, + deviceScaleFactor: 0, + mobile: false, + }); + return await page.screenshot(options); + } finally { + await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); + await cdp.detach().catch(() => {}); + } +} + +export async function dispatchSlabAction(manager: SlabSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { + try { + switch (command.action) { + case 'navigate': { + if (!command.url) return invalidRequest(command, 'Missing url'); + const profileId = resolveSlabCommandProfileId(manager, command); + const lease = await manager.navigatePage( + { + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }, + command.url, + toGotoWaitUntil(command.waitUntil), + ); + return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; + } + case 'exec': { + if (!command.code) return invalidRequest(command, 'Missing code'); + const lease = await resolveLease(manager, command); + const target = execTarget(lease.page, command.frameIndex, lease.pageId); + const data = await target.evaluate(command.code); + return { id: command.id, ok: true, data, page: lease.pageId }; + } + case 'run': { + if (typeof command.source !== 'string' || !command.source.trim()) { + return invalidRequest(command, 'Missing source'); + } + if (Buffer.byteLength(command.source, 'utf8') > BROWSER_RUN_MAX_SOURCE_BYTES) { + return { + id: command.id, + ok: false, + errorCode: 'BROWSER_RUN_SOURCE_LIMIT', + error: `Browser-run source exceeds the ${BROWSER_RUN_MAX_SOURCE_BYTES}-byte limit.`, + }; + } + const lease = await resolveLease(manager, command); + const scope = await manager.browserRunScope({ + profileId: lease.profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + }, lease.page); + const data = await runBrowserProgram({ + ...scope, + pageId: lease.pageId, + }, command.source, { + timeoutMs: command.timeoutMs, + maxOutputChars: command.maxOutputChars, + memoryLimitBytes: command.memoryLimitBytes, + snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff, + snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act', + snapshotBaselineStore: snapshotBaselineStore(manager), + onStaleContext: () => manager.invalidateIfClosedContext(lease.profileId, scope.context), + ...(signal ? { signal } : {}), + }); + return { + id: command.id, + ok: true, + data, + page: lease.pageId, + }; + } + case 'snapshot': { + const lease = await resolveExistingLease(manager, command); + if (command.snapshotMode === 'read') { + const readable = readableSnapshotText(await extractArticle(lease.page, { force: true })); + const redacted = redactUrl(redactText(readable.text, { maxStringLength: Number.MAX_SAFE_INTEGER })); + const bounded = Number.isFinite(command.maxOutputChars) + ? boundSnapshotText(redacted, command.maxOutputChars!) + : { value: redacted, truncated: false }; + return { + id: command.id, + ok: true, + data: { + ok: true, + tree: bounded.value, + article: readable.article, + page: { + id: lease.pageId, + url: redactUrl(lease.page.url()), + title: redactText(await lease.page.title().catch(() => '')), + }, + warnings: readable.warnings, + limits: { snapshotTruncated: bounded.truncated }, + }, + page: lease.pageId, + }; + } + const snapshot = await captureSnapshot(lease.page); + const rendered = renderSnapshotResult(snapshot, { + mode: command.snapshotMode === 'tree' ? 'tree' : 'act', + ref: command.ref, + maxChars: command.maxOutputChars, + }); + const redacted = redactUrl(redactText(rendered.value, { maxStringLength: Number.MAX_SAFE_INTEGER })); + const bounded = Number.isFinite(command.maxOutputChars) + ? boundSnapshotText(redacted, command.maxOutputChars!) + : { value: redacted, truncated: false }; + const warnings = [...rendered.warnings]; + if (bounded.truncated) warnings.push('Snapshot output was truncated after redaction.'); + snapshotBaselineStore(manager).set(lease.pageId, snapshot); + return { + id: command.id, + ok: true, + data: { + ok: true, + tree: bounded.value, + page: { + id: lease.pageId, + url: redactUrl(lease.page.url()), + title: redactText(await lease.page.title().catch(() => '')), + }, + warnings, + limits: { snapshotTruncated: rendered.truncated || bounded.truncated }, + }, + page: lease.pageId, + }; + } + case 'cookies': { + const lease = await resolveLease(manager, command); + const cookies = await lease.context.cookies(command.url ? [command.url] : undefined); + const data = command.domain ? cookies.filter((cookie) => cookie.domain.includes(command.domain!)) : cookies; + return { id: command.id, ok: true, data }; + } + case 'screenshot': { + const lease = await resolveLease(manager, command); + const buffer = await captureScreenshot(lease.page, lease.context, command); + return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId }; + } + case 'close-window': { + if (command.page) { + const closed = await manager.closePage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + }); + return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; + } else { + await manager.release({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + }); + return { id: command.id, ok: true, data: { closed: true, session: command.session } }; + } + } + case 'tabs': { + switch (command.op ?? 'list') { + case 'list': { + const tabs = await manager.listPages({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + }); + return { id: command.id, ok: true, data: tabs }; + } + case 'new': { + const lease = await manager.newPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + url: command.url, + waitUntil: toGotoWaitUntil(command.waitUntil), + windowMode: command.windowMode, + }); + return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; + } + case 'select': { + const lease = await manager.selectPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + windowMode: command.windowMode, + }); + if (!lease) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; + return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId }; + } + case 'close': { + const closed = await manager.closePage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + }); + if (!closed) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; + return { id: command.id, ok: true, data: { closed } }; + } + default: + return invalidRequest(command, `Unknown tabs op: ${command.op}`); + } + } + case 'set-file-input': { + if (!command.files?.length) return invalidRequest(command, 'Missing or empty files array'); + const lease = await resolveLease(manager, command); + const locator = lease.page.locator(command.selector ?? 'input[type="file"]').first(); + await locator.setInputFiles(command.files); + return { id: command.id, ok: true, data: { count: command.files.length }, page: lease.pageId }; + } + case 'insert-text': { + if (typeof command.text !== 'string') return invalidRequest(command, 'Missing text payload'); + const lease = await resolveLease(manager, command); + await lease.page.keyboard.insertText(command.text); + return { id: command.id, ok: true, data: { inserted: true }, page: lease.pageId }; + } + case 'network-capture-start': { + const lease = await resolveLease(manager, command); + manager.networkCapture.start(command.pattern ?? '', lease.page); + return { id: command.id, ok: true, data: { started: true }, page: lease.pageId }; + } + case 'network-capture-read': { + const lease = await resolveLease(manager, command); + return { id: command.id, ok: true, data: await manager.networkCapture.read(lease.page), page: lease.pageId }; + } + case 'wait-download': { + const lease = await resolveLease(manager, command); + const result = await waitForDownload(lease.page, command.pattern ?? '', command.timeoutMs ?? 30_000); + return { id: command.id, ok: true, data: result, page: lease.pageId }; + } + case 'cdp': { + if (!command.cdpMethod) return invalidRequest(command, 'Missing cdpMethod'); + const lease = await resolveLease(manager, command); + const session = await lease.context.newCDPSession(lease.page); + const data = await session.send(command.cdpMethod as any, command.cdpParams ?? {}); + return { id: command.id, ok: true, data, page: lease.pageId }; + } + case 'frames': { + const lease = await resolveLease(manager, command); + const frames = lease.page.frames().slice(1).map((frame, index) => ({ + index, + frameId: frame.name() || String(index), + url: frame.url(), + name: frame.name(), + })); + return { id: command.id, ok: true, data: frames, page: lease.pageId }; + } + case 'bind': + if (!command.page && !command.targetId && command.index == null) { + return { + id: command.id, + ok: false, + errorCode: 'invalid_request', + error: 'Bind requires --page, --target-id, or --index for a SLAB runtime tab', + errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page ` or `--target-id `.', + }; + } + { + const lease = await manager.bindPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + pageId: command.page, + targetId: command.targetId, + index: command.index, + }); + if (!lease) { + return { + id: command.id, + ok: false, + errorCode: 'bound_tab_not_found', + error: 'SLAB tab not found for bind target', + errorHint: 'Run `webcmd --session browser tab list` and choose a current SLAB tab id or index, or provide a known CDP target id with `--target-id`.', + }; + } + return { + id: command.id, + ok: true, + page: lease.pageId, + data: { + bound: true, + session: command.session, + page: lease.pageId, + url: lease.page.url(), + title: await lease.page.title().catch(() => ''), + }, + }; + } + default: + return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: `Unknown action: ${command.action}` }; + } + } catch (err) { + if (err instanceof SlabActionError) { + return { id: command.id, ok: false, errorCode: err.errorCode, error: err.message, ...(err.page && { page: err.page }), ...(err.errorHint && { errorHint: err.errorHint }) }; + } + if (err instanceof SlabAttachmentLostError) { + return { id: command.id, ok: false, errorCode: 'slab_attachment_lost', error: err.message }; + } + if ( + err instanceof Error + && 'code' in err + && typeof err.code === 'string' + && (err.code.startsWith('BROWSER_RUN_') || err.code === 'SESSION_WINDOW_CONFLICT') + ) { + const hint = 'hint' in err && typeof err.hint === 'string' + ? err.hint + : undefined; + const details = 'details' in err ? err.details : undefined; + return { + id: command.id, + ok: false, + errorCode: err.code, + error: err.message, + ...(hint && { errorHint: hint }), + ...(details !== undefined ? { details } : {}), + }; + } + return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts new file mode 100644 index 00000000..3a818917 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -0,0 +1,107 @@ +import type { Browser, BrowserContext, ConnectOverCDPTransport } from 'playwright-core'; +import { describe, expect, it, vi } from 'vitest'; +import { SlabCredential } from '../../../slab/protocol.js'; +import { attachSlabProfile } from './attachment.js'; + +function attachment() { + return { + connectionId: 'connection-1', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc' as const, + endpoint: '/tmp/slab-attachment.sock', + credential: new SlabCredential('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), + }, + }; +} + +function transport(): ConnectOverCDPTransport { + return { open: vi.fn(), send: vi.fn(), close: vi.fn() }; +} + +describe('attachSlabProfile', () => { + it('connects Playwright through the authenticated IPC transport', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const context = {} as any; + const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; + const connectTransport = vi.fn().mockResolvedValue(cdpTransport); + const connectOverCDP = vi.fn().mockResolvedValue(browser); + + const result = await attachSlabProfile('default', { + bridge, + connectTransport, + connectOverCDP, + attachTimeoutMs: 123, + }); + + expect(connectTransport).toHaveBeenCalledWith({ ...lease.transport, timeoutMs: 123 }); + expect(connectOverCDP).toHaveBeenCalledWith(cdpTransport, { timeout: 123 }); + expect(result.context).toBe(context); + }); + + it('creates a launch-aware control bridge when a caller does not provide one', async () => { + const lease = attachment(); + const bridge = { + attach: vi.fn().mockResolvedValue(lease), + release: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + const connectBridge = vi.fn().mockResolvedValue(bridge); + const context = {} as BrowserContext; + const browser = { contexts: () => [context], version: () => '1' } as unknown as Browser; + + await attachSlabProfile('default', { + connectBridge, + connectTransport: vi.fn().mockResolvedValue({ close: vi.fn() }), + connectOverCDP: vi.fn().mockResolvedValue(browser), + }); + + expect(connectBridge).toHaveBeenCalledOnce(); + expect(bridge.attach).toHaveBeenCalledWith('default'); + }); + + it('closes the transport and releases the lease when Playwright setup fails', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; + const connectTransport = vi.fn().mockResolvedValue(cdpTransport); + const connectOverCDP = vi.fn().mockRejectedValue(new Error('Playwright refused the connection')); + + await expect(attachSlabProfile('default', { bridge, connectTransport, connectOverCDP })).rejects.toThrow('Playwright refused'); + expect(cdpTransport.close).toHaveBeenCalledOnce(); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + }); + + it('closes the bridge when native attach fails before a lease exists', async () => { + const bridge = { + attach: vi.fn().mockRejectedValue(new Error('profile missing')), + release: vi.fn().mockResolvedValue(null), + close: vi.fn().mockResolvedValue(null), + }; + + await expect(attachSlabProfile('default', { bridge })).rejects.toThrow('profile missing'); + expect(bridge.close).toHaveBeenCalledOnce(); + expect(bridge.release).not.toHaveBeenCalled(); + }); + + it('closes its local transport before releasing the native lease', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const context = {} as any; + const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; + const result = await attachSlabProfile('default', { + bridge, + connectTransport: vi.fn().mockResolvedValue(cdpTransport), + connectOverCDP: vi.fn().mockResolvedValue(browser), + }); + + await result.release(); + + expect(cdpTransport.close).toHaveBeenCalledOnce(); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + expect(vi.mocked(cdpTransport.close).mock.invocationCallOrder[0]).toBeLessThan(bridge.release.mock.invocationCallOrder[0]!); + }); +}); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts new file mode 100644 index 00000000..764af2b9 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.ts @@ -0,0 +1,60 @@ +import { chromium, type Browser, type BrowserContext, type ConnectOverCDPTransport } from 'playwright-core'; +import { CdpIpcTransport } from '../../../slab/cdp-ipc-transport.js'; +import type { SlabAttachResult } from '../../../slab/protocol.js'; +import { connectSlabControlBridge, type SlabControlBridge } from '../../../slab/control-bridge.js'; + +export interface AttachedSlabProfile { + profileId: string; + browserVersion: string; + context: BrowserContext; + browser: Browser; + closeTransport(): void; + release(): Promise; +} + +export type SlabAttachment = SlabAttachResult; + +export type SlabBridge = SlabControlBridge; + +export interface AttachSlabProfileOptions { + bridge?: SlabBridge; + connectBridge?: () => Promise; + connectOverCDP?: typeof chromium.connectOverCDP; + connectTransport?: typeof CdpIpcTransport.connect; + attachTimeoutMs?: number; +} + +export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { + const bridge = options.bridge ?? await (options.connectBridge ?? connectSlabControlBridge)(); + let attachment: SlabAttachResult; + try { + attachment = await bridge.attach(profileId); + } catch (error) { + await bridge.close().catch(() => {}); + throw error; + } + const attachTimeoutMs = options.attachTimeoutMs ?? 30_000; + let transport: ConnectOverCDPTransport | undefined; + try { + transport = await (options.connectTransport ?? CdpIpcTransport.connect)({ ...attachment.transport, timeoutMs: attachTimeoutMs }); + const browser = await (options.connectOverCDP ?? chromium.connectOverCDP.bind(chromium))(transport, { timeout: attachTimeoutMs }); + const context = browser.contexts()[0]; + if (!context) throw new Error('SLAB attachment returned no persistent browser context.'); + const connectedTransport = transport; + return { + profileId: attachment.profile.id, + browserVersion: browser.version(), + context, + browser, + closeTransport: () => connectedTransport.close(), + release: async () => { + connectedTransport.close(); + await bridge.release(attachment.connectionId); + }, + }; + } catch (error) { + transport?.close(); + await bridge.release(attachment.connectionId).catch(() => {}); + throw error; + } +} diff --git a/src/browser/runtime/local-slab/dependency-boundary.test.ts b/src/browser/runtime/local-slab/dependency-boundary.test.ts new file mode 100644 index 00000000..a920b63c --- /dev/null +++ b/src/browser/runtime/local-slab/dependency-boundary.test.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const SOURCE_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const REPOSITORY_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); + +function productionTypeScriptFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return productionTypeScriptFiles(target); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [target] : []; + }); +} + +describe('local SLAB dependency boundary', () => { + it('has no production imports of cloakbrowser', () => { + const retiredImports = productionTypeScriptFiles(SOURCE_ROOT) + .filter(file => /(?:from|import)\s*\(?\s*['"]cloakbrowser['"]|import\.meta\.resolve\(\s*['"]cloakbrowser['"]/.test(fs.readFileSync(file, 'utf8'))) + .map(file => path.relative(SOURCE_ROOT, file)); + + expect(retiredImports).toEqual([]); + }); + + it('has no production routing through local-cloak', () => { + const staleRoutes = productionTypeScriptFiles(SOURCE_ROOT) + .filter(file => fs.readFileSync(file, 'utf8').includes('runtime/local-cloak')) + .map(file => path.relative(SOURCE_ROOT, file)); + + expect(staleRoutes).toEqual([]); + }); + + it('keeps Cloak available outside the SLAB runtime boundary', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + expect(packageJson.dependencies?.cloakbrowser).toBe('0.4.5'); + for (const lockfile of ['package-lock.json', 'bun.lock']) { + const contents = fs.readFileSync(path.join(REPOSITORY_ROOT, lockfile), 'utf8'); + expect(contents).toContain('cloakbrowser'); + expect(contents).toContain('0.4.5'); + } + expect(fs.statSync(path.join(REPOSITORY_ROOT, 'src/browser/runtime/local-cloak')).isDirectory()).toBe(true); + }); +}); diff --git a/src/browser/runtime/local-slab/downloads.ts b/src/browser/runtime/local-slab/downloads.ts new file mode 100644 index 00000000..5cabb10a --- /dev/null +++ b/src/browser/runtime/local-slab/downloads.ts @@ -0,0 +1,29 @@ +import type { Page } from 'playwright-core'; +import type { BrowserDownloadWaitResult } from '../../../types.js'; + +export async function waitForDownload(page: Page, pattern: string, timeoutMs: number): Promise { + const startedAt = Date.now(); + try { + const download = await page.waitForEvent('download', { + timeout: timeoutMs, + predicate: (candidate) => { + if (!pattern) return true; + return candidate.url().includes(pattern) || candidate.suggestedFilename().includes(pattern); + }, + }); + const failure = await download.failure(); + return { + downloaded: !failure, + filename: download.suggestedFilename(), + url: download.url(), + error: failure ?? undefined, + elapsedMs: Date.now() - startedAt, + }; + } catch (err) { + return { + downloaded: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - startedAt, + }; + } +} diff --git a/src/browser/runtime/local-slab/network.ts b/src/browser/runtime/local-slab/network.ts new file mode 100644 index 00000000..05d4fa46 --- /dev/null +++ b/src/browser/runtime/local-slab/network.ts @@ -0,0 +1,150 @@ +import type { Page, Request, Response } from 'playwright-core'; + +export interface NetworkCaptureEntry { + kind: 'cdp'; + url: string; + method: string; + requestHeaders?: Record; + requestBodyKind?: string; + requestBodyPreview?: string; + requestBodyFullSize?: number; + requestBodyTruncated?: boolean; + responseStatus?: number; + responseContentType?: string; + responseHeaders?: Record; + responsePreview?: string; + responseBodyFullSize?: number; + responseBodyTruncated?: boolean; + timestamp: number; +} + +type CaptureState = { + pattern: string; + entries: NetworkCaptureEntry[]; + byRequest: WeakMap; + pending: Set>; + onRequest: (request: Request) => void; + onResponse: (response: Response) => void; +}; + +const BODY_LIMIT = 8 * 1024 * 1024; + +export class SlabNetworkCapture { + private readonly states = new WeakMap(); + + constructor(private readonly limit = 200) {} + + start(pattern: string, page: Page): void { + this.stop(page); + const entries: NetworkCaptureEntry[] = []; + const byRequest = new WeakMap(); + const pending = new Set>(); + const state: CaptureState = { + pattern, + entries, + byRequest, + pending, + onRequest: (request) => { + const url = request.url(); + if (pattern && !url.includes(pattern)) return; + const body = request.postData() ?? undefined; + const entry = { + kind: 'cdp', + url, + method: request.method(), + requestHeaders: request.headers(), + requestBodyKind: body === undefined ? undefined : 'text', + requestBodyPreview: body === undefined ? undefined : body.slice(0, BODY_LIMIT), + requestBodyFullSize: body?.length, + requestBodyTruncated: body ? body.length > BODY_LIMIT : undefined, + timestamp: Date.now(), + } satisfies NetworkCaptureEntry; + entries.push(entry); + byRequest.set(request, entry); + this.bound(entries); + }, + onResponse: (response) => { + const capture = this.captureResponse(response, pattern, entries, byRequest) + .finally(() => pending.delete(capture)); + pending.add(capture); + }, + }; + page.on('request', state.onRequest); + page.on('response', state.onResponse); + this.states.set(page, state); + } + + async read(page: Page): Promise { + const state = this.states.get(page); + if (!state) return []; + await Promise.allSettled([...state.pending]); + return [...state.entries]; + } + + stop(page: Page): void { + const state = this.states.get(page); + if (!state) return; + page.off('request', state.onRequest); + page.off('response', state.onResponse); + this.states.delete(page); + } + + private async captureResponse( + response: Response, + pattern: string, + entries: NetworkCaptureEntry[], + byRequest: WeakMap, + ): Promise { + const url = response.url(); + if (pattern && !url.includes(pattern)) return; + const headers = response.headers(); + const contentType = headers['content-type']; + let preview: string | undefined; + let fullSize: number | undefined; + let truncated: boolean | undefined; + const contentLength = Number(headers['content-length']); + if (Number.isFinite(contentLength) && contentLength >= 0) fullSize = contentLength; + if (isTextLikeContentType(contentType)) { + try { + const text = await response.text(); + fullSize = text.length; + truncated = text.length > BODY_LIMIT; + preview = text.slice(0, BODY_LIMIT); + } catch { + preview = undefined; + } + } + const responseRequest = typeof response.request === 'function' ? response.request() : undefined; + const existing = responseRequest + ? byRequest.get(responseRequest) + : [...entries].reverse().find((entry) => entry.url === url && entry.responseStatus === undefined); + const target = existing ?? { + kind: 'cdp' as const, + url, + method: 'GET', + timestamp: Date.now(), + }; + target.responseStatus = response.status(); + target.responseContentType = contentType; + target.responseHeaders = headers; + target.responsePreview = preview; + target.responseBodyFullSize = fullSize; + target.responseBodyTruncated = truncated; + if (!existing) entries.push(target); + this.bound(entries); + } + + private bound(entries: NetworkCaptureEntry[]): void { + while (entries.length > this.limit) entries.shift(); + } +} + +function isTextLikeContentType(contentType: string | undefined): boolean { + if (!contentType) return false; + const normalized = contentType.toLowerCase(); + return normalized.startsWith('text/') + || normalized.includes('json') + || normalized.includes('javascript') + || normalized.includes('xml') + || normalized.includes('x-www-form-urlencoded'); +} diff --git a/src/browser/runtime/local-slab/profiles.ts b/src/browser/runtime/local-slab/profiles.ts new file mode 100644 index 00000000..fca205c1 --- /dev/null +++ b/src/browser/runtime/local-slab/profiles.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; +import os from 'node:os'; + +export interface SlabProfileDirOptions { + baseDir?: string; +} + +export function normalizeProfileId(value: string | undefined | null): string { + const id = value?.trim() || 'default'; + if (!/^[A-Za-z0-9._-]+$/.test(id) || id === '.' || id === '..') { + throw new Error(`Invalid profile id: ${value ?? ''}`); + } + return id; +} + +export function getWebcmdConfigDir(): string { + return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); +} + +export function resolveSlabProfileDir(profileId: string, opts: SlabProfileDirOptions = {}): string { + const safeProfileId = normalizeProfileId(profileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'slab', 'profiles', safeProfileId); +} diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts new file mode 100644 index 00000000..0829e860 --- /dev/null +++ b/src/browser/runtime/local-slab/provider.ts @@ -0,0 +1,204 @@ +import { homedir } from 'node:os'; +import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../../protocol.js'; +import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.js'; +import { LocalBrowserSessionStore, type BrowserSessionListRow, type BrowserSessionRecord } from '../../sessions.js'; +import { SlabBridgeClient } from '../../../slab/bridge-client.js'; +import { slabControlEndpoint } from '../../../slab/installation.js'; +import type { SlabHelloResult } from '../../../slab/protocol.js'; +import { dispatchSlabAction, resolveSlabCommandProfileId } from './actions.js'; +import type { AttachSlabProfile } from './session-manager.js'; +import { SlabSessionManager } from './session-manager.js'; + +export interface LocalSlabRuntimeProviderOptions { + baseDir?: string; + attachProfile?: AttachSlabProfile; + statusBridge?: () => Promise>; +} + +export function createLocalBrowserRuntimeProvider(opts: LocalSlabRuntimeProviderOptions = {}): LocalSlabRuntimeProvider { + return new LocalSlabRuntimeProvider(opts); +} + +export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { + private managerInstance?: SlabSessionManager; + private readonly sessions: LocalBrowserSessionStore; + private readonly sessionQueues = new Map>(); + + constructor(private readonly opts: LocalSlabRuntimeProviderOptions = {}) { + this.sessions = new LocalBrowserSessionStore({ + baseDir: opts.baseDir, + isActive: session => this.managerInstance?.hasSession(session.profileId, session.id) ?? false, + }); + } + + private get manager(): SlabSessionManager { + return this.managerInstance ??= new SlabSessionManager({ + ...this.opts, + hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( + Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() + )), + }); + } + + async status(opts: RuntimeStatusOptions = {}): Promise { + const profiles = this.manager.profileStatuses(); + const hello = await this.nativeStatus().catch(() => undefined); + const profileById = new Map(profiles.map(profile => [profile.contextId, profile])); + if (hello) { + for (const profile of hello.profiles) { + if (!profileById.has(profile.id)) { + profileById.set(profile.id, { + contextId: profile.id, + runtimeConnected: true, + runtimeVersion: hello.browserVersion, + pending: 0, + lastSeenAt: Date.now(), + }); + } + } + } + const statusProfiles = [...profileById.values()]; + const requestedProfile = opts.contextId?.trim(); + const selectedProfile = requestedProfile + ? statusProfiles.find(profile => profile.contextId === requestedProfile) + : undefined; + const runtimeConnected = requestedProfile + ? Boolean(selectedProfile?.runtimeConnected) + : statusProfiles.some(profile => profile.runtimeConnected); + return { + runtimeConnected, + runtimeName: 'SLAB', + runtimeVersion: statusProfiles.find(profile => profile.runtimeVersion)?.runtimeVersion ?? hello?.browserVersion, + profiles: statusProfiles, + ...(requestedProfile && !selectedProfile?.runtimeConnected ? { profileDisconnected: true } : {}), + pending: 0, + commandResultUnknown: 0, + sessions: await this.listSessions({ profileId: opts.contextId }), + }; + } + + resolveProfileId(command: BrowserRuntimeCommand): string { + return resolveSlabCommandProfileId(this.manager, command); + } + + async createSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.create(this.resolveProfileId(command), command.sessionName ?? ''); + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.require(this.resolveProfileId(command), command.session); + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); + } + + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const profileId = this.resolveProfileId(command); + const sessionId = command.sessionId!; + const record = this.sessions.markHandoff(profileId, sessionId, { + site: command.site!, + expiresAt: command.expiresAt!, + }); + await this.manager.foregroundSession(profileId, sessionId); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); + } + + async listSessions(input: { profileId?: string; limit?: number }): Promise { + return this.sessions.list(input.profileId, input.limit).map((session) => ({ + ...session, + runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', + })); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const record = this.sessions.require(this.resolveProfileId(command), command.session); + const closedCount = await this.manager.closeSession(record.profileId, record.id); + if (command.force && command.discard === true && record.kind === 'explicit' && !record.handoff) this.sessions.remove(record.profileId, record.id); + else if (command.force && record.handoff) this.sessions.clearHandoff(record.profileId, record.id); + else this.sessions.touch(record.profileId, record.id); + return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; + } + + async dispatch(rawCommand: BrowserRuntimeCommand, signal?: AbortSignal): Promise { + // Every dispatched command runs in the background unless the caller asked + // for a window explicitly, so no command steals focus by default. Session + // handoff bypasses dispatch and still foregrounds through foregroundSession. + const command: BrowserRuntimeCommand = rawCommand.windowMode + ? rawCommand + : { ...rawCommand, windowMode: 'background' }; + const key = this.commandQueueKey(command); + const previous = this.sessionQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + this.sessionQueues.set(key, current); + + await previous.catch(() => {}); + try { + signal?.throwIfAborted(); + if (typeof command.deadlineAt === 'number' && command.deadlineAt > 0 && Date.now() >= command.deadlineAt) { + return { + id: command.id, + ok: false, + errorCode: 'command_result_unknown', + error: 'Command deadline expired before browser work started.', + }; + } + return await this.manager.runWithProfileActivity( + this.resolveProfileId(command), + () => dispatchSlabAction(this.manager, command, signal), + ); + } finally { + release(); + if (this.sessionQueues.get(key) === current) { + this.sessionQueues.delete(key); + } + } + } + + async shutdown(): Promise { + await this.manager.shutdown(); + } + + private async nativeStatus(): Promise { + const client = await (this.opts.statusBridge?.() + ?? SlabBridgeClient.connect(slabControlEndpoint(homedir()), { timeoutMs: 1_000 })); + try { + return await client.hello(); + } finally { + await client.close(); + } + } + + private commandQueueKey(command: BrowserRuntimeCommand): string { + if (command.page) { + const owner = this.manager.pageOwner(command.page); + if (owner) { + const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' + ? owner.adapterSite?.trim() + : undefined; + return `session\u0000${owner.profileId}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } + } + + let profileId: string; + try { + profileId = this.resolveProfileId(command); + } catch { + profileId = command.profileId + ?? command.contextId + ?? command.preferredContextId + ?? 'default'; + } + const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' + ? command.adapterSite?.trim() + : undefined; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } +} diff --git a/src/browser/runtime/local-slab/runtime-selection.test.ts b/src/browser/runtime/local-slab/runtime-selection.test.ts new file mode 100644 index 00000000..1d805cce --- /dev/null +++ b/src/browser/runtime/local-slab/runtime-selection.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; + +function fakeAttachedProfile() { + const listeners = new Map void>>(); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + const fakePage = () => { + let closed = false; + const page: any = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue('ok'), + title: vi.fn().mockResolvedValue('Title'), + url: vi.fn(() => 'https://example.com/'), + bringToFront: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => closed), + close: vi.fn(async () => { + closed = true; + }), + opener: vi.fn().mockResolvedValue(null), + on(event: string, listener: (...args: unknown[]) => void) { + const events = pageListeners.get(page) ?? new Map(); + const bucket = events.get(event) ?? new Set(); + bucket.add(listener); + events.set(event, bucket); + pageListeners.set(page, events); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + pageListeners.get(page)?.get(event)?.delete(listener); + }, + }; + targetIds.set(page, `target-${++targetCounter}`); + windowIds.set(targetIds.get(page)!, ++windowCounter); + return page; + }; + const page = fakePage(); + const allPages = [page]; + const cdp = { + send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { + if (command === 'Target.createTarget') { + const created = fakePage(); + allPages.push(created); + queueMicrotask(() => { + for (const listener of listeners.get('page') ?? []) listener(created); + }); + return { targetId: targetIds.get(created) }; + } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; + }), + on: vi.fn(), + detach: vi.fn().mockResolvedValue(undefined), + }; + const browser = { + close: vi.fn().mockResolvedValue(undefined), + newBrowserCDPSession: vi.fn().mockResolvedValue(cdp), + contexts: vi.fn(() => [context]), + version: vi.fn(() => '146.0'), + }; + context = { + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, + pages: vi.fn(() => allPages.filter((candidate) => !candidate.isClosed())), + newPage: vi.fn(async () => { + const created = fakePage(); + allPages.push(created); + return created; + }), + newCDPSession: vi.fn(async (target: object) => ({ + send: vi.fn(async (command: string, params?: { targetId?: string }) => { + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), + browser: vi.fn(() => browser), + close: vi.fn().mockResolvedValue(undefined), + }; + return { + profileId: 'default', + browserVersion: '146.0', + context, + browser, + closeTransport: vi.fn(), + release: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('local browser runtime selection', () => { + it('retains the SLAB provider factory', async () => { + const { createLocalBrowserRuntimeProvider, LocalSlabRuntimeProvider } = await import('./provider.js'); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), + }); + expect(provider).toBeInstanceOf(LocalSlabRuntimeProvider); + await provider.shutdown(); + }); + + it('reports disconnected when the native SLAB control endpoint is unavailable', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), + statusBridge: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')), + }); + + await expect(provider.status()).resolves.toMatchObject({ + runtimeConnected: false, + profiles: [], + }); + }); + + it('reports ready from native SLAB hello before the first profile attachment', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const close = vi.fn().mockResolvedValue(undefined); + const attachProfile = vi.fn().mockResolvedValue(fakeAttachedProfile()); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile, + statusBridge: vi.fn().mockResolvedValue({ + close, + hello: vi.fn().mockResolvedValue({ + protocolVersion: 1, + browserVersion: '152.0.7977.65', + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }), + }), + }); + + await expect(provider.status({ contextId: 'default' })).resolves.toMatchObject({ + runtimeConnected: true, + runtimeVersion: '152.0.7977.65', + profiles: [{ contextId: 'default', runtimeConnected: true, runtimeVersion: '152.0.7977.65', pending: 0 }], + }); + expect(close).toHaveBeenCalledOnce(); + expect(attachProfile).not.toHaveBeenCalled(); + }); + + it('reports a requested profile as disconnected when no active SLAB profile matches', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), + statusBridge: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')), + }); + + await expect(provider.status({ contextId: 'work' })).resolves.toMatchObject({ + runtimeConnected: false, + profileDisconnected: true, + profiles: [], + }); + }); +}); diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts new file mode 100644 index 00000000..1c34f8c0 --- /dev/null +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest'; +import { dispatchSlabAction } from './actions.js'; +import { SlabSessionManager } from './session-manager.js'; + +function fakeAttachedProfile() { + const listeners = new Map void>>(); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; + const makePage = (url = 'about:blank') => { + let closed = false; + const page: any = { + url: vi.fn(() => url), + title: vi.fn().mockResolvedValue('Page'), + context: vi.fn(() => context), + mainFrame: vi.fn(() => { throw new Error('no frames'); }), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + isChecked: vi.fn(), + $: vi.fn(), + $$: vi.fn(), + waitForSelector: vi.fn(), + mouse: { + move: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + wheel: vi.fn(), + down: vi.fn(), + up: vi.fn(), + }, + keyboard: { + type: vi.fn(), + down: vi.fn(), + up: vi.fn(), + press: vi.fn(), + insertText: vi.fn(), + }, + goto: vi.fn().mockResolvedValue(undefined), + bringToFront: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => closed), + opener: vi.fn().mockResolvedValue(null), + close: vi.fn(async () => { + closed = true; + for (const listener of pageListeners.get(page)?.get('close') ?? []) listener(); + }), + once(event: string, listener: (...args: unknown[]) => void) { + const bucket = pageListeners.get(page) ?? new Map(); + const once = (...args: unknown[]) => { + bucket.get(event)?.delete(once); + listener(...args); + }; + const handlers = bucket.get(event) ?? new Set(); + handlers.add(once); + bucket.set(event, handlers); + pageListeners.set(page, bucket); + }, + }; + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, ++windowCounter); + return page; + }; + + const humanBlank = makePage(); + const humanPage = makePage('https://human.example/'); + const pages = [humanBlank, humanPage]; + const cdp = { + send: vi.fn(async (method: string, params?: { targetId?: string }) => { + if (method === 'Target.createTarget') { + const page = makePage(); + pages.push(page); + queueMicrotask(() => emit('page', page)); + return { targetId: targetIds.get(page) }; + } + if (method === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (method === 'Target.closeTarget') return { success: true }; + return {}; + }), + on: vi.fn(), + detach: vi.fn().mockResolvedValue(undefined), + }; + const browser = { + close: vi.fn().mockResolvedValue(undefined), + newBrowserCDPSession: vi.fn().mockResolvedValue(cdp), + }; + context = { + pages: vi.fn(() => pages.filter(page => !page.isClosed())), + newPage: vi.fn(async () => { + const page = makePage(); + pages.push(page); + return page; + }), + newCDPSession: vi.fn(async (page: object) => ({ + send: vi.fn(async (method: string, params?: { targetId?: string }) => { + if (method === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(page) } }; + if (method === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), + browser: vi.fn(() => browser), + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, + }; + return { + attachment: { + profileId: 'default', + browserVersion: '152.0', + context, + browser, + closeTransport: vi.fn(), + release: vi.fn().mockResolvedValue(undefined), + }, + browser, + context, + humanBlank, + humanPage, + targetIdFor: (page: object) => targetIds.get(page), + emitPage: (page: object) => emit('page', page), + emitClose: () => emit('close'), + }; +} + +async function flushPageEvent(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + +describe('SlabSessionManager ownership', () => { + it('creates and releases only an agent-owned page', async () => { + const attached = fakeAttachedProfile(); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached.attachment) }); + const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const }; + + const lease = await manager.getPage(input); + + expect(lease.page).not.toBe(attached.humanBlank); + expect(lease.page).not.toBe(attached.humanPage); + expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); + expect(manager.pageIdFor(attached.humanPage)).toBeUndefined(); + expect(manager.pageIdFor(lease.page)).toBe(lease.pageId); + expect((lease.page as any)._original).toEqual(expect.any(Object)); + expect((attached.humanBlank as any)._original).toBeUndefined(); + expect((attached.humanPage as any)._original).toBeUndefined(); + + await manager.shutdown(); + await manager.shutdown(); + + expect(attached.humanBlank.close).not.toHaveBeenCalled(); + expect(attached.humanPage.close).not.toHaveBeenCalled(); + expect(attached.browser.close).not.toHaveBeenCalled(); + expect(attached.attachment.release).toHaveBeenCalledOnce(); + }); + + it('registers only an explicitly acquired attachment-time target', async () => { + const attached = fakeAttachedProfile(); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached.attachment) }); + const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const }; + await manager.getPage(input); + + const lease = await manager.bindPage({ ...input, targetId: attached.targetIdFor(attached.humanPage) }); + + expect(lease?.page).toBe(attached.humanPage); + expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); + expect(manager.pageIdFor(attached.humanPage)).toBe(lease?.pageId); + expect((attached.humanPage as any)._original).toEqual(expect.any(Object)); + expect((attached.humanBlank as any)._original).toBeUndefined(); + }); + + it('reports every detached session operation without reopening or closing SLAB', async () => { + const attached = fakeAttachedProfile(); + const attachProfile = vi.fn().mockResolvedValue(attached.attachment); + const manager = new SlabSessionManager({ attachProfile }); + const input = { + profileId: 'default', + session: 'agent', + sessionId: 'agent', + surface: 'browser' as const, + }; + await manager.getPage(input); + + attached.emitClose(); + await flushPageEvent(); + for (const command of [ + { ...input, id: 'tabs-after-loss', action: 'tabs' as const, op: 'list' as const }, + { ...input, id: 'select-after-loss', action: 'tabs' as const, op: 'select' as const, index: 0 }, + { ...input, id: 'close-after-loss', action: 'tabs' as const, op: 'close' as const, index: 0 }, + { ...input, id: 'release-after-loss', action: 'close-window' as const }, + ]) { + await expect(dispatchSlabAction(manager, command)).resolves.toMatchObject({ + ok: false, + errorCode: 'slab_attachment_lost', + }); + } + + expect(attachProfile).toHaveBeenCalledOnce(); + expect(attached.browser.close).not.toHaveBeenCalled(); + expect(attached.attachment.release).toHaveBeenCalledOnce(); + + await expect(manager.getPage({ ...input, session: 'replacement', sessionId: 'replacement' })) + .resolves.toMatchObject({ profileId: 'default' }); + expect(attachProfile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts new file mode 100644 index 00000000..637aa4a8 --- /dev/null +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -0,0 +1,1308 @@ +import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; +import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; +import { normalizeProfileId } from './profiles.js'; +import { SlabNetworkCapture } from './network.js'; +import { log } from '../../../logger.js'; +import { CliError, EXIT_CODES } from '../../../errors.js'; +import { isClosedContextError } from '../../run/types.js'; +import { humanizePage } from '../../humanizer/page.js'; +import { attachSlabProfile, type AttachedSlabProfile } from './attachment.js'; + +const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; +export const PROFILE_IDLE_TIMEOUT_MS = 60_000; +export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; + +export type AttachSlabProfile = typeof attachSlabProfile; + +export interface SessionKeyInput { + profileId?: string; + session?: string; + surface?: BrowserSurface; + siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + sessionId?: string; + adapterSite?: string; + runId?: string; + idleTimeout?: number; + windowMode?: BrowserWindowMode; + /** Discard the existing leased page (if any) and create a new one under the same lease. */ + freshPage?: boolean; +} + +export type NewPageInput = SessionKeyInput & { + url?: string; + /** + * Playwright `goto` readiness for `url`, already translated from the command + * vocabulary by the caller. Defaults to 'load'; 'commit' skips waiting for the + * load event on sites that never go idle. + */ + waitUntil?: 'load' | 'commit'; +}; + +type PageEntry = { + page: PlaywrightPage; + pageId: string; + targetId: string; + leaseKey: string; + sessionId?: string; + session: string; + surface: BrowserSurface; + siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + adapterSite?: string; + idleTimeout?: number; + idleTimer?: ReturnType; +}; + +export interface SlabPageLease { + profileId: string; + leaseKey: string; + context: BrowserContext; + page: PlaywrightPage; + pageId: string; +} + +export interface SlabTabInfo { + id: string; + page: string; + index: number; + title: string; + url: string; + profileId: string; + session: string; + sessionId: string; + surface: BrowserSurface; + selected: boolean; +} + +interface ProfileRuntime { + profileId: string; + attachment: AttachedSlabProfile; + context: BrowserContext; + cdp?: CDPSession; + sessions: Map; + windowOwners: Map; + targetPages: Map; + anchorTargetId?: string; + parkingPage?: PlaywrightPage; + useParkingKeeper: boolean; + keeperWarningLogged: boolean; + activeCommands: number; + idleTimer?: ReturnType; + handoffTimer?: ReturnType; + closing: boolean; + disposed: boolean; + releasePromise?: Promise; + lastSeenAt: number; +} + +interface SessionRuntime { + id: string; + windowIds: Set; + pages: Map; + selectedPageId?: string; +} + +export interface BrowserRunSessionScope { + browser: Browser; + context: BrowserContext; + page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export class SessionWindowConflictError extends CliError { + constructor(pageId: string, sessionId: string, owner?: string) { + super( + 'SESSION_WINDOW_CONFLICT', + `Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`, + undefined, + EXIT_CODES.TEMPFAIL, + ); + } +} + +export class SlabAttachmentLostError extends Error { + readonly code = 'SLAB_ATTACHMENT_LOST'; + + constructor() { + super('SLAB attachment was lost. Start a new browser session before retrying.'); + this.name = 'SlabAttachmentLostError'; + } +} + +export interface SlabSessionManagerOptions { + baseDir?: string; + attachProfile?: AttachSlabProfile; + hasActiveHandoff?: (profileId: string) => boolean; +} + +let pageCounter = 0; + +export function resolveLeaseKey(input: SessionKeyInput): string { + const surface = input.surface === 'adapter' ? 'adapter' : 'browser'; + const session = input.session?.trim(); + if (!session) throw new Error('Browser session is required.'); + const sessionId = input.sessionId?.trim() || session; + if (surface === 'adapter' && input.siteSession === 'persistent' && input.adapterSite) { + return `${sessionId}\u0000site:${input.adapterSite}`; + } + if (surface === 'adapter' && input.runId) { + return `${sessionId}\u0000ephemeral:${input.adapterSite ?? 'browser'}:${input.runId}`; + } + return `${surface}\u0000${encodeURIComponent(session)}`; +} + +function pageIsClosed(page: PlaywrightPage): boolean { + return page.isClosed?.() === true; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { + return Object.assign(new Error('The browser daemon is shutting down.'), { code: 'DAEMON_SHUTTING_DOWN' as const }); +} + +export class SlabSessionManager { + readonly networkCapture = new SlabNetworkCapture(); + + private readonly attachProfile: AttachSlabProfile; + private readonly hasActiveHandoff: (profileId: string) => boolean; + private readonly profiles = new Map(); + private readonly detachedSessions = new Map>(); + private readonly profileLaunches = new Map>(); + private readonly profileLifecycleQueues = new Map>(); + private readonly profileActivities = new Map(); + private readonly pageCreationQueues = new Map>(); + private readonly pageTargetIds = new WeakMap(); + private readonly pageTargetIdPromises = new WeakMap>(); + private readonly pageCdpSessions = new WeakMap(); + private readonly pageCdpDetaches = new WeakMap>(); + private readonly pendingTargetPages = new WeakMap>(); + private readonly targetPageWaiters = new WeakMap; + }>>(); + private readonly sessionPageListeners = new WeakMap void>>(); + private shuttingDown = false; + + constructor(private readonly opts: SlabSessionManagerOptions = {}) { + this.attachProfile = opts.attachProfile ?? attachSlabProfile; + this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); + } + + profileStatuses() { + return [...this.profiles.entries()].map(([contextId, runtime]) => ({ + contextId, + runtimeConnected: true, + runtimeVersion: runtime.attachment.browserVersion || undefined, + pending: 0, + lastSeenAt: runtime.lastSeenAt, + })); + } + + activeProfileIds(): string[] { + return [...this.profiles.keys()]; + } + + async runWithProfileActivity(profileIdInput: string | undefined, task: () => Promise): Promise { + const profileId = normalizeProfileId(profileIdInput); + await this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const count = (this.profileActivities.get(profileId) ?? 0) + 1; + this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.cancelProfileIdle(runtime); + } + }); + try { + return await task(); + } finally { + await this.withProfileLifecycleLock(profileId, async () => { + const count = Math.max(0, (this.profileActivities.get(profileId) ?? 1) - 1); + if (count === 0) this.profileActivities.delete(profileId); + else this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.scheduleProfileIdle(profileId, runtime); + } + }); + } + } + + async getPage(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const surface = normalizeSurface(input.surface); + const leaseKey = resolveLeaseKey(input); + const freshPage = input.freshPage === true; + return this.withPageCreationLock(profileId, async () => { + const runtime = await this.getProfileRuntime(profileId, input.windowMode); + const sessionRuntime = this.getSessionRuntime(runtime, sessionId); + const existing = sessionRuntime.pages.get(leaseKey); + if (existing && !pageIsClosed(existing.page) && !freshPage) { + try { + await this.assertOwnedWindow(runtime, sessionId, existing); + runtime.lastSeenAt = Date.now(); + existing.idleTimeout = input.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing); + return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; + } catch (error) { + if (!isClosedContextError(error)) throw error; + // isClosed() reported false, but the liveness probe above shows the + // underlying CDP connection is actually dead. Invalidate the Profile + // runtime and fall through to acquire a fresh page instead of handing + // the same broken lease back out (webcmd#314). + this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); + } + } + const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, { + leaseKey, + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); + this.selectEntry(acquired.session, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: entry.page, pageId: entry.pageId }; + }); + } + + async findPage(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const leaseKey = resolveLeaseKey(input); + const runtime = this.profiles.get(profileId); + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = sessionRuntime?.pages.get(leaseKey); + if (!runtime || !sessionRuntime || !entry || pageIsClosed(entry.page)) return null; + await this.assertOwnedWindow(runtime, sessionId, entry); + runtime.lastSeenAt = Date.now(); + entry.idleTimeout = input.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + + async findPageById(pageId: string, opts: Pick): Promise { + const expectedProfileId = normalizeProfileId(opts.profileId); + const sessionId = requireSessionId(opts); + this.assertSessionAttached(expectedProfileId, sessionId); + const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; + for (const [profileId, runtime] of this.profiles.entries()) { + if (expectedProfileId !== profileId) continue; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + for (const [leaseKey, entry] of sessionRuntime.pages.entries()) { + if ( + entry.pageId === pageId + && !pageIsClosed(entry.page) + && (!expectedSurface || entry.surface === expectedSurface) + ) { + await this.assertOwnedWindow(runtime, sessionId, entry); + entry.idleTimeout = opts.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + } + } + return null; + } + + pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface; sessionKind?: 'explicit' | 'adapter-default'; adapterSite?: string } | null { + for (const [profileId, runtime] of this.profiles.entries()) { + for (const entry of runtime.targetPages.values()) { + if (entry.pageId === pageId && !pageIsClosed(entry.page)) { + return { + profileId, + session: entry.session, + surface: entry.surface, + sessionKind: entry.sessionKind, + adapterSite: entry.adapterSite, + }; + } + } + } + return null; + } + + pageIdFor(page: PlaywrightPage): string | undefined { + for (const runtime of this.profiles.values()) { + for (const entry of runtime.targetPages.values()) { + if (entry.page === page) return entry.pageId; + } + } + return undefined; + } + + async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const runtime = this.profiles.get(profileId); + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); + if (!runtime || !sessionRuntime || !entry || entry.sessionId !== sessionId) { + throw new Error('Browser-run page is outside the selected Session.'); + } + await Promise.all(this.openEntries(sessionRuntime).map(([, candidate]) => ( + this.assertOwnedWindow(runtime, sessionId, candidate) + ))); + const browser = runtime.context.browser(); + if (!browser) throw new Error('The selected browser context is not attached to a browser.'); + return { + browser, + context: runtime.context, + page, + pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), + createPage: async () => (await this.newPage(input)).page, + onPage: (listener) => { + const listeners = this.sessionPageListeners.get(sessionRuntime) ?? new Set(); + listeners.add(listener); + this.sessionPageListeners.set(sessionRuntime, listeners); + return () => listeners.delete(listener); + }, + }; + } + + async listPages(input: Pick): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const surface = input.surface ? normalizeSurface(input.surface) : undefined; + const runtime = this.profiles.get(profileId); + if (!runtime) return []; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return []; + const entries = this.openEntries(sessionRuntime) + .filter(([, entry]) => !surface || entry.surface === surface); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + return Promise.all(entries.map(async ([, entry], index) => ({ + id: entry.pageId, + page: entry.pageId, + index, + title: await entry.page.title().catch(() => ''), + url: entry.page.url(), + profileId, + session: entry.session, + sessionId, + surface: entry.surface, + selected: sessionRuntime.selectedPageId === entry.pageId, + }))); + } + + async newPage(input: NewPageInput): Promise { + return this.newPageAttempt(input, 0); + } + + async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { + return this.navigatePageAttempt(input, url, waitUntil, 0); + } + + private async newPageAttempt(input: NewPageInput, attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + const surface = normalizeSurface(input.surface); + const acquired = await this.withPageCreationLock(profileId, async () => { + const result = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + return { runtime: result.runtime, sessionRuntime: result.session, page: result.page }; + }); + if (input.url) { + try { + await acquired.page.goto(input.url, { waitUntil: input.waitUntil ?? 'load' }); + } catch (error) { + if (attempt === 0 && isClosedContextError(error)) { + this.invalidateProfileRuntime(profileId, acquired.runtime); + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + return this.newPageAttempt(input, 1); + } + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + throw error; + } + } + if (this.profiles.get(profileId) !== acquired.runtime) { + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + throw new Error('Target page, context or browser has been closed'); + } + const entry = await this.registerOwnedPage(acquired.runtime, acquired.sessionRuntime, acquired.page, { + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + const leaseKey = entry.leaseKey; + this.refreshIdleTimer(acquired.runtime, acquired.sessionRuntime, leaseKey, entry); + this.selectEntry(acquired.sessionRuntime, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; + } + + private async navigatePageAttempt(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit', attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const lease = await this.getPage(input); + const runtime = this.profiles.get(profileId); + try { + await lease.page.goto(url, { waitUntil }); + return lease; + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {}); + return this.navigatePageAttempt(input, url, waitUntil, 1); + } + } + + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; + if (!match) return null; + const [leaseKey, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + if (input.windowMode !== 'background') { + await entry.page.bringToFront?.().catch(() => {}); + } + this.selectEntry(sessionRuntime, entry); + runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + + async foregroundSession(profileIdInput: string, sessionId: string): Promise { + const profileId = normalizeProfileId(profileIdInput); + const runtime = this.profiles.get(profileId); + const session = runtime?.sessions.get(sessionId); + if (!runtime || !session) return false; + const entries = this.openEntries(session); + const match = entries.find(([, entry]) => entry.pageId === session.selectedPageId) ?? entries[0]; + if (!match) return false; + const entry = match[1]; + await this.assertOwnedWindow(runtime, sessionId, entry); + await entry.page.bringToFront?.().catch(() => {}); + this.selectEntry(session, entry); + runtime.lastSeenAt = Date.now(); + return true; + } + + async bindPage(input: SessionKeyInput & { pageId?: string; targetId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const surface = normalizeSurface(input.surface); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const existingSession = runtime.sessions.get(sessionId); + const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); + const targetId = input.targetId?.trim(); + const existingEntry = input.pageId + ? this.findEntryByPageId(runtime, input.pageId)?.[1] + : targetId + ? runtime.targetPages.get(targetId) + : existingSession && this.openEntries(existingSession)[input.index ?? -1]?.[1]; + const page = existingEntry?.page ?? (targetId ? await this.findPageByTargetId(runtime, targetId) : undefined); + if (!page || pageIsClosed(page)) return null; + const canonicalKey = resolveLeaseKey(input); + const currentCanonical = sessionRuntime.pages.get(canonicalKey); + + if (input.windowMode !== 'background') { + await page.bringToFront?.().catch(() => {}); + } + + if (currentCanonical && currentCanonical.page !== page && !pageIsClosed(currentCanonical.page)) { + const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; + sessionRuntime.pages.delete(canonicalKey); + currentCanonical.leaseKey = preservedKey; + sessionRuntime.pages.set(preservedKey, currentCanonical); + this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); + } + + const owned = await this.registerOwnedPage(runtime, sessionRuntime, page, { + leaseKey: canonicalKey, + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + this.selectEntry(sessionRuntime, owned); + runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey: canonicalKey, context: runtime.context, page: owned.page, pageId: owned.pageId }; + } + + async closePage(input: Pick & { pageId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; + if (!match) return null; + const [, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + await this.removeEntry(runtime, sessionRuntime, entry, true); + runtime.lastSeenAt = Date.now(); + return entry.pageId; + } + + async release(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); + const runtime = this.profiles.get(profileId); + if (!runtime) return; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return; + const leaseKey = resolveLeaseKey(input); + const surface = normalizeSurface(input.surface); + const entries = this.openEntries(sessionRuntime).filter(([key, entry]) => ( + surface === 'adapter' + ? key === leaseKey + : entry.session === requireSession(input.session) && entry.surface === surface + )); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + for (const [, entry] of entries) { + if (entry.siteSession === 'persistent') continue; + await this.removeEntry(runtime, sessionRuntime, entry, true); + } + } + + hasSession(profileIdInput: string | undefined, sessionInput: string | undefined): boolean { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + return Boolean(runtime?.sessions.get(session) && this.openEntries(runtime.sessions.get(session)!).length > 0); + } + + async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + if (!runtime) return 0; + const sessionRuntime = runtime.sessions.get(session); + if (!sessionRuntime) return 0; + const entries = this.openEntries(sessionRuntime); + await Promise.all(entries.map(async ([, entry]) => { + try { + await this.assertOwnedWindow(runtime, session, entry); + } catch (error) { + if (!pageIsClosed(entry.page)) throw error; + } + })); + for (const [, entry] of entries) await this.removeEntry(runtime, sessionRuntime, entry, true); + if (entries.length > 0) runtime.lastSeenAt = Date.now(); + return entries.length; + } + + /** + * Invalidates the Profile runtime backing `context`, if it's still the active + * one, without evicting or retrying the command that observed it. Used by the + * `run` action (webcmd#314) when a post-run snapshot capture surfaces a + * closed-context signature: the run itself may have genuinely succeeded, but + * the connection is dying, so the next command on this Session shouldn't be + * handed the same lease. + */ + invalidateIfClosedContext(profileId: string, context: BrowserContext): void { + const runtime = this.profiles.get(profileId); + if (runtime?.context === context) this.invalidateProfileRuntime(profileId, runtime); + } + + async shutdown(): Promise { + this.shuttingDown = true; + while (this.profileLaunches.size > 0) { + await Promise.allSettled([...this.profileLaunches.values()]); + } + await Promise.all([...this.profiles.keys()].map(profileId => this.withProfileLifecycleLock(profileId, async () => { + const runtime = this.profiles.get(profileId); + if (!runtime) return; + this.profiles.delete(profileId); + runtime.closing = true; + await this.closeRuntime(runtime).catch(() => {}); + }))); + this.profiles.clear(); + this.detachedSessions.clear(); + this.profileLaunches.clear(); + this.profileActivities.clear(); + } + + private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { + return this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const existing = this.profiles.get(profileId); + if (existing && !existing.closing) { + this.cancelProfileIdle(existing); + return existing; + } + const launch = this.launchProfileRuntime(profileId, windowMode); + this.profileLaunches.set(profileId, launch); + try { + return await launch; + } finally { + if (this.profileLaunches.get(profileId) === launch) this.profileLaunches.delete(profileId); + } + }); + } + + private async launchProfileRuntime(profileId: string, _windowMode?: BrowserWindowMode): Promise { + const attachment = await this.attachProfile(profileId); + const { context, browser } = attachment; + let cdp: CDPSession | undefined; + let keeperError: unknown; + try { + cdp = await browser?.newBrowserCDPSession(); + } catch (error) { + keeperError = error; + } + const runtime: ProfileRuntime = { + profileId, + attachment, + context, + cdp, + sessions: new Map(), + windowOwners: new Map(), + targetPages: new Map(), + useParkingKeeper: !cdp, + keeperWarningLogged: false, + activeCommands: this.profileActivities.get(profileId) ?? 0, + closing: false, + disposed: false, + lastSeenAt: Date.now(), + }; + this.pendingTargetPages.set(runtime, new Map()); + this.targetPageWaiters.set(runtime, new Map()); + this.attachRuntimeLifecycle(profileId, runtime); + if (cdp) { + try { + runtime.anchorTargetId = (await cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } else { + this.warnKeeperFallback(profileId, runtime, keeperError ?? new Error('browser connection unavailable')); + } + if (this.shuttingDown) { + runtime.closing = true; + await this.closeRuntime(runtime).catch(() => {}); + throw daemonShuttingDownError(); + } + this.profiles.set(profileId, runtime); + return runtime; + } + + private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + const detached = this.detachedSessions.get(profileId) ?? new Set(); + for (const sessionId of runtime.sessions.keys()) detached.add(sessionId); + if (detached.size > 0) this.detachedSessions.set(profileId, detached); + void this.releaseRuntime(runtime, false).catch(error => { + log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); + }); + this.cleanupRuntime(runtime); + } + + private cleanupRuntime(runtime: ProfileRuntime): void { + if (runtime.disposed) return; + runtime.disposed = true; + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + this.networkCapture.stop(entry.page); + } + runtime.targetPages.clear(); + runtime.sessions.clear(); + runtime.windowOwners.clear(); + for (const waiter of this.targetPageWaiters.get(runtime)?.values() ?? []) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Target page, context or browser has been closed')); + } + this.targetPageWaiters.get(runtime)?.clear(); + } + + private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { + runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime)); + runtime.context.on('page', page => { + void this.handleContextPage(runtime, page).catch(() => {}); + }); + const onCdpEvent = (runtime.cdp as (CDPSession & { + on?: (event: string, listener: (payload: { targetId: string }) => void) => void; + }) | undefined)?.on; + onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { + this.queueAnchorRepair(profileId, runtime, targetId); + }); + } + + private queueAnchorRepair(profileId: string, runtime: ProfileRuntime, destroyedTargetId: string): void { + if (runtime.anchorTargetId !== destroyedTargetId) return; + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; + if (runtime.anchorTargetId !== undefined) return; + await this.repairAnchor(profileId, runtime); + }); + } + + private async repairAnchor(profileId: string, runtime: ProfileRuntime): Promise { + if (!runtime.cdp) return; + try { + runtime.anchorTargetId = (await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } + + private warnKeeperFallback(profileId: string, runtime: ProfileRuntime, error: unknown): void { + runtime.useParkingKeeper = true; + if (runtime.keeperWarningLogged) return; + runtime.keeperWarningLogged = true; + log.warn(`SLAB Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); + } + + private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer || runtime.handoffTimer) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.idleTimer = setTimeout(() => { + runtime.idleTimer = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.profiles.get(profileId) !== runtime || runtime.closing) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.closing = true; + this.profiles.delete(profileId); + await this.closeRuntime(runtime); + }); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.idleTimer.unref?.(); + } + + private scheduleHandoffWake(profileId: string, runtime: ProfileRuntime): void { + runtime.handoffTimer = setTimeout(() => { + runtime.handoffTimer = undefined; + this.scheduleProfileIdle(profileId, runtime); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.handoffTimer.unref?.(); + } + + private cancelProfileIdle(runtime: ProfileRuntime): void { + if (runtime.idleTimer) clearTimeout(runtime.idleTimer); + if (runtime.handoffTimer) clearTimeout(runtime.handoffTimer); + runtime.idleTimer = undefined; + runtime.handoffTimer = undefined; + } + + private hasVisiblePages(runtime: ProfileRuntime): boolean { + for (const session of runtime.sessions.values()) { + if (this.openEntries(session).length > 0) return true; + } + return false; + } + + private async closeRuntime(runtime: ProfileRuntime): Promise { + await this.releaseRuntime(runtime, true); + } + + private async releaseRuntime(runtime: ProfileRuntime, closePages: boolean, releaseNative = true): Promise { + if (runtime.releasePromise) return runtime.releasePromise; + runtime.releasePromise = (async () => { + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); + const pages = [...runtime.targetPages.values()].map(entry => entry.page); + try { + if (closePages) { + await Promise.all([...runtime.targetPages.values()].map(entry => ( + pageIsClosed(entry.page) ? undefined : entry.page.close().catch(() => {}) + ))); + } + await this.closeParkingPage(runtime); + if (runtime.anchorTargetId) { + await runtime.cdp?.send('Target.closeTarget', { targetId: runtime.anchorTargetId }).catch(() => {}); + runtime.anchorTargetId = undefined; + } + await Promise.all([ + ...pages.map(page => this.detachPageCdp(page)), + runtime.cdp?.detach().catch(() => {}), + ]); + if (releaseNative) await runtime.attachment.release(); + else runtime.attachment.closeTransport(); + } finally { + this.cleanupRuntime(runtime); + } + })(); + return runtime.releasePromise; + } + + private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { + const previous = this.profileLifecycleQueues.get(profileId); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = (previous ?? Promise.resolve()).then(() => released); + this.profileLifecycleQueues.set(profileId, queue); + if (previous) await previous.catch(() => {}); + try { + return await operation(); + } finally { + release(); + if (this.profileLifecycleQueues.get(profileId) === queue) this.profileLifecycleQueues.delete(profileId); + } + } + + private assertRunning(): void { + if (this.shuttingDown) throw daemonShuttingDownError(); + } + + private assertSessionAttached(profileId: string, sessionId: string): void { + if (this.detachedSessions.get(profileId)?.has(sessionId)) throw new SlabAttachmentLostError(); + } + + private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { + const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = previous.then(() => released); + this.pageCreationQueues.set(profileId, queue); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); + } + } + + private getSessionRuntime(runtime: ProfileRuntime, sessionId: string): SessionRuntime { + let session = runtime.sessions.get(sessionId); + if (!session) { + session = { id: sessionId, windowIds: new Set(), pages: new Map() }; + runtime.sessions.set(sessionId, session); + } + return session; + } + + private async createSessionPage( + runtime: ProfileRuntime, + session: SessionRuntime, + windowMode?: BrowserWindowMode, + ): Promise { + const openerEntry = this.openEntries(session)[0]?.[1]; + if (!openerEntry) return this.createWindowPage(runtime, windowMode); + await this.assertOwnedWindow(runtime, session.id, openerEntry); + const opener = openerEntry.page; + const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); + const targetUrl = `about:blank#webcmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const openedPage = this.waitForContextPageForSession(runtime, session.id, openerWindowId, targetUrl, TARGET_PAGE_MATCH_TIMEOUT_MS); + + try { + await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); + } catch (error) { + log.warn(`SLAB window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(error)}`); + } + const page = await openedPage; + if (page) return page; + return this.createWindowPage(runtime, windowMode); + } + + private async waitForContextPageForSession( + runtime: ProfileRuntime, + sessionId: string, + openerWindowId: number, + targetUrl: string, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (page: PlaywrightPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + runtime.context.off('page', onPage); + resolve(page); + }; + const tryPage = async (page: PlaywrightPage) => { + if (settled || pageIsClosed(page)) return; + const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); + if (!targetId) return; + const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); + if (actualWindowId === undefined) return; + const owner = runtime.windowOwners.get(actualWindowId); + if (owner !== undefined && owner !== sessionId) return; + if (page.url() !== targetUrl) return; + done(page); + }; + const onPage = (page: PlaywrightPage) => { void tryPage(page); }; + const timer = setTimeout(() => done(null), timeoutMs); + runtime.context.on('page', onPage); + for (const page of this.pendingTargetPages.get(runtime)?.values() ?? []) void tryPage(page); + }); + } + + private async acquireSessionPage( + profileId: string, + sessionId: string, + windowMode: BrowserWindowMode | undefined, + attempt = 0, + ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { + this.assertSessionAttached(profileId, sessionId); + const runtime = await this.getProfileRuntime(profileId, windowMode); + const session = this.getSessionRuntime(runtime, sessionId); + let page: PlaywrightPage; + try { + page = await this.createSessionPage(runtime, session, windowMode); + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + this.invalidateProfileRuntime(profileId, runtime); + return this.acquireSessionPage(profileId, sessionId, windowMode, 1); + } + if (this.profiles.get(profileId) !== runtime) { + if (!pageIsClosed(page)) await page.close().catch(() => {}); + throw new Error('Target page, context or browser has been closed'); + } + return { runtime, session, page }; + } + + private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode, newWindow = true): Promise { + if (!runtime.cdp) return runtime.context.newPage(); + const result = await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + newWindow, + background: windowMode === 'background', + focus: windowMode !== 'background', + }) as { targetId: string }; + return this.waitForTargetPage(runtime, result.targetId); + } + + private async waitForTargetPage(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)!; + const page = pending.get(targetId); + if (page) { + pending.delete(targetId); + pending.clear(); + return page; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.targetPageWaiters.get(runtime)?.delete(targetId); + reject(new Error(`Timed out waiting for SLAB target ${targetId}`)); + }, TARGET_PAGE_MATCH_TIMEOUT_MS); + this.targetPageWaiters.get(runtime)!.set(targetId, { resolve, reject, timer }); + }); + } + + private async findPageByTargetId(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)?.get(targetId); + if (pending && !pageIsClosed(pending)) return pending; + + // Resolve only the caller-supplied identity. This never makes visible pages + // owned unless bindPage subsequently registers the exact matching target. + for (const page of runtime.context.pages()) { + if (pageIsClosed(page)) continue; + if (await this.targetIdForPage(runtime, page).catch(() => undefined) === targetId) return page; + } + return undefined; + } + + private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const targetId = await this.targetIdForPage(runtime, page); + if (targetId === runtime.anchorTargetId) { + page.once('close', () => { + this.queueAnchorRepair(runtime.profileId, runtime, targetId); + }); + return; + } + const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); + if (waiter) { + this.targetPageWaiters.get(runtime)!.delete(targetId); + this.pendingTargetPages.get(runtime)?.clear(); + clearTimeout(waiter.timer); + waiter.resolve(page); + } else { + this.pendingTargetPages.get(runtime)?.set(targetId, page); + } + + const opener = await page.opener().catch(() => null); + const openerEntry = opener && [...runtime.targetPages.values()].find(entry => entry.page === opener); + if (!openerEntry?.sessionId) return; + const session = runtime.sessions.get(openerEntry.sessionId); + if (!session) return; + this.pendingTargetPages.get(runtime)?.delete(targetId); + await this.registerOwnedPage(runtime, session, page, { + session: openerEntry.session, + surface: openerEntry.surface, + siteSession: openerEntry.siteSession, + sessionKind: openerEntry.sessionKind, + adapterSite: openerEntry.adapterSite, + idleTimeout: openerEntry.idleTimeout, + }); + } + + private async registerOwnedPage( + runtime: ProfileRuntime, + session: SessionRuntime, + page: PlaywrightPage, + input: Pick & { leaseKey?: string }, + ): Promise { + const targetId = await this.targetIdForPage(runtime, page); + this.pendingTargetPages.get(runtime)?.delete(targetId); + const windowId = await this.windowIdForTarget(runtime, targetId, page); + const owner = runtime.windowOwners.get(windowId); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); + } + runtime.windowOwners.set(windowId, session.id); + session.windowIds.add(windowId); + + let entry = runtime.targetPages.get(targetId); + const wasOwned = Boolean(entry?.sessionId); + if (entry?.sessionId && entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + if (!entry) { + const pageId = nextPageId(); + entry = { + page, + pageId, + targetId, + leaseKey: input.leaseKey ?? `page\u0000${pageId}`, + sessionId: session.id, + session: input.session, + surface: input.surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }; + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } else { + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + entry.sessionId = session.id; + entry.session = input.session; + entry.surface = input.surface; + entry.siteSession = input.siteSession; + entry.sessionKind = input.sessionKind; + entry.adapterSite = input.adapterSite; + entry.idleTimeout = input.idleTimeout; + entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); + } + session.pages.set(entry.leaseKey, entry); + humanizePage(page); + this.cancelProfileIdle(runtime); + this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); + if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); + await this.closeParkingPage(runtime); + return entry; + } + + private attachPageLifecycle(runtime: ProfileRuntime, entry: PageEntry): void { + entry.page.once('close', () => { + runtime.targetPages.delete(entry.targetId); + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + this.clearIdleTimer(entry); + if (runtime.parkingPage === entry.page) runtime.parkingPage = undefined; + this.scheduleProfileIdle(runtime.profileId, runtime); + }); + } + + private async targetIdForPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const cached = this.pageTargetIds.get(page); + if (cached) return cached; + const pending = this.pageTargetIdPromises.get(page); + if (pending) return pending; + const correlation = (async () => { + const session = await runtime.context.newCDPSession(page); + const { targetInfo } = await session.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + this.pageTargetIds.set(page, targetInfo.targetId); + this.pageCdpSessions.set(page, session); + page.once('close', () => { + this.pageTargetIds.delete(page); + void this.detachPageCdp(page); + }); + return targetInfo.targetId; + })(); + this.pageTargetIdPromises.set(page, correlation); + try { + return await correlation; + } finally { + this.pageTargetIdPromises.delete(page); + } + } + + private async windowIdForTarget(runtime: ProfileRuntime, targetId: string, page?: PlaywrightPage): Promise { + const entry = runtime.targetPages.get(targetId); + const targetPage = page ?? entry?.page; + const cdp = runtime.cdp ?? (targetPage ? this.pageCdpSessions.get(targetPage) : undefined); + if (!cdp) throw new Error('SLAB page has no CDP session.'); + const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; + return windowId; + } + + private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + } + + private openEntries(runtime: SessionRuntime): [string, PageEntry][] { + return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); + } + + private findEntryByPageId(runtime: ProfileRuntime, pageId: string): [string, PageEntry] | null { + const entry = [...runtime.targetPages.values()].find(candidate => candidate.pageId === pageId && !pageIsClosed(candidate.page)); + return entry ? [entry.leaseKey, entry] : null; + } + + private sessionEntries(runtime: SessionRuntime, input: Pick): [string, PageEntry][] { + const session = requireSession(input.session); + const surface = normalizeSurface(input.surface); + return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); + } + + private selectEntry(runtime: SessionRuntime, entry: PageEntry): void { + runtime.selectedPageId = entry.pageId; + } + + private clearSelectedPage(runtime: SessionRuntime, entry: PageEntry): void { + if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + } + + private refreshIdleTimer(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): void { + this.clearIdleTimer(entry); + if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; + entry.idleTimer = setTimeout(() => { + void this.expireLease(runtime, session, leaseKey, entry); + }, entry.idleTimeout); + entry.idleTimer.unref?.(); + } + + private async expireLease(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): Promise { + if (session.pages.get(leaseKey) !== entry) return; + runtime.lastSeenAt = Date.now(); + if (entry.siteSession !== 'persistent') await this.removeEntry(runtime, session, entry, true); + } + + private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { + const shouldPark = close && runtime.useParkingKeeper + && [...runtime.targetPages.values()].every(candidate => candidate === entry || pageIsClosed(candidate.page)); + const parkingWindowId = shouldPark + ? await this.windowIdForTarget(runtime, entry.targetId, entry.page).catch(() => undefined) + : undefined; + if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + runtime.targetPages.delete(entry.targetId); + this.clearIdleTimer(entry); + this.clearSelectedPage(session, entry); + this.networkCapture.stop(entry.page); + if (close && !pageIsClosed(entry.page)) { + if (shouldPark) { + await entry.page.goto('about:blank', { waitUntil: 'load' }).catch(() => {}); + entry.sessionId = undefined; + runtime.parkingPage = pageIsClosed(entry.page) ? undefined : entry.page; + if (parkingWindowId !== undefined) { + runtime.windowOwners.delete(parkingWindowId); + session.windowIds.delete(parkingWindowId); + await runtime.cdp?.send('Browser.setWindowBounds', { + windowId: parkingWindowId, + bounds: { windowState: 'minimized' }, + }).catch(() => {}); + } + } else { + await runtime.cdp?.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + } + } + this.scheduleProfileIdle(runtime.profileId, runtime); + } + + private async closeParkingPage(runtime: ProfileRuntime): Promise { + const parkingPage = runtime.parkingPage; + if (!parkingPage) return; + runtime.parkingPage = undefined; + if (!pageIsClosed(parkingPage)) await parkingPage.close().catch(() => {}); + await this.detachPageCdp(parkingPage); + } + + private detachPageCdp(page: PlaywrightPage): Promise { + const existing = this.pageCdpDetaches.get(page); + if (existing) return existing; + const detach = this.pageCdpSessions.get(page)?.detach().catch(() => {}) ?? Promise.resolve(); + this.pageCdpDetaches.set(page, detach); + return detach; + } + + private clearIdleTimer(entry: PageEntry): void { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; + } +} + +function nextPageId(): string { + return `page-${Date.now()}-${++pageCounter}`; +} + +function normalizeSurface(surface: BrowserSurface | undefined): BrowserSurface { + return surface === 'adapter' ? 'adapter' : 'browser'; +} + +function requireSession(session: string | undefined): string { + const normalized = session?.trim(); + if (!normalized) throw new Error('Browser session is required.'); + return normalized; +} + +function requireSessionId(input: Pick): string { + return input.sessionId?.trim() || requireSession(input.session); +} diff --git a/src/cli.test.ts b/src/cli.test.ts index c845c0d7..c9bd0fce 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1253,7 +1253,7 @@ name: 'search', usage: 'webcmd browser bind [options]', positionals: [], }); - expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'verbose', 'format', 'json']); + expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'targetId', 'verbose', 'format', 'json']); expect(data.structured_help).toMatchObject({ formats: ['yaml', 'json'], usage: 'webcmd browser --help -f yaml', @@ -2214,7 +2214,7 @@ describe('browser raw session commands', () => { expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('work-k7', {}); }); - it('binds only an explicit stable page id', async () => { + it('binds an explicit stable page id or CDP target id', async () => { const program = createProgram('', ''); await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', 'page-123']); @@ -2222,6 +2222,10 @@ describe('browser raw session commands', () => { expect(mockSendCommand).toHaveBeenCalledWith('bind', { session: 'work-k7', surface: 'browser', page: 'page-123', }); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--target-id', 'target-123']); + expect(mockSendCommand).toHaveBeenLastCalledWith('bind', { + session: 'work-k7', surface: 'browser', targetId: 'target-123', + }); await expect(program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--index', '0'])) .rejects.toThrow(/process\.exit unexpectedly called/); await expect(program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', ' '])) diff --git a/src/cli.ts b/src/cli.ts index 7ee521c3..2960fa89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -731,7 +731,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi console.error('Hint: run "webcmd skills update" once the new version is active.'); } } - // The Cloak runtime/extension ships separately from npm; surface it if stale. + // The Cloak runtime/extension ships separately from npm; surface it if stale. const runtimeNotice = getRuntimeUpdateNotice(); if (runtimeNotice) process.stdout.write(runtimeNotice); console.log('Update complete.'); @@ -1217,12 +1217,20 @@ cli({ browser.addCommand(withBrowserVerbose(new Command('bind') .description('Bind this session to an existing page') .addOption(new Option('--page ', 'Stable page id returned by tabs') - .makeOptionMandatory() .argParser(browserOptionValueParser('bind', 'page')!)) + .addOption(new Option('--target-id ', 'Native CDP target id for an explicitly acquired page') + .argParser(browserOptionValueParser('bind', 'targetId')!)) .action(rawBrowserAction((session, routing, opts) => { const page = typeof opts.page === 'string' ? opts.page.trim() : ''; - if (!page) throw new BrowserCommandError('--page must be a non-empty stable page id', 'invalid_request'); - return sendCommand('bind', { session, surface: 'browser', ...routing, page }); + const targetId = typeof opts.targetId === 'string' ? opts.targetId.trim() : ''; + if (page && targetId) throw new BrowserCommandError('Use either --page or --target-id, not both', 'invalid_request'); + if (!page && !targetId) throw new BrowserCommandError('Bind requires a non-empty --page or --target-id', 'invalid_request'); + return sendCommand('bind', { + session, + surface: 'browser', + ...routing, + ...(page ? { page } : { targetId }), + }); })))); const runCommand = withBrowserVerbose(new Command('run') diff --git a/src/daemon.ts b/src/daemon.ts index f7c25310..49bef89c 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,9 +3,11 @@ import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; import { PKG_VERSION } from './version.js'; import { createDaemonServer } from './daemon/server.js'; -import { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js'; +import { loadWebcmdConfig } from './hosted/config.js'; +import { createConfiguredLocalBrowserRuntimeProvider } from './browser/runtime/configured-provider.js'; -const provider = new LocalCloakRuntimeProvider(); +const config = loadWebcmdConfig(); +const provider = createConfiguredLocalBrowserRuntimeProvider(config.mode === 'local' ? config : undefined); const daemon = createDaemonServer(provider, { port: DEFAULT_DAEMON_PORT, host: '127.0.0.1', version: PKG_VERSION }); daemon.listen().then(() => { diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 55f2568b..de443c67 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -3,6 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EXIT_CODES } from './errors.js'; +import { getConfigPath, makeLocalConfig, saveWebcmdConfig } from './hosted/config.js'; const { mockGetDaemonHealth, @@ -32,9 +33,6 @@ vi.mock('./browser/daemon-transport.js', async () => { }; }); -// Real binaryInfo() reads this machine's actual CloakBrowser cache dir, which -// varies by dev box/CI runner — mock it so doctor tests are hermetic and the -// #239 binary-missing path can be exercised deterministically. vi.mock('cloakbrowser', () => ({ binaryInfo: mockBinaryInfo, ensureBinary: mockEnsureBinary, @@ -69,6 +67,13 @@ fs.writeFileSync(managedBinaryPath, '#!/bin/sh\n'); if (process.platform !== 'win32') fs.chmodSync(managedBinaryPath, 0o755); afterAll(() => fs.rmSync(managedBinaryDir, { recursive: true, force: true })); +function writeLocalConfig(browser?: Parameters[1]): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), browser), { env: { WEBCMD_CONFIG_DIR: configDir } }); + return configDir; +} + describe('doctor report rendering', () => { const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); const isolatedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-render-')); @@ -80,18 +85,15 @@ describe('doctor report rendering', () => { vi.stubEnv('WEBCMD_CONFIG_DIR', isolatedConfigDir); mockFindShadowedUserAdapters.mockReturnValue([]); mockSetDaemonCommandTimeoutSeconds.mockClear(); - mockEnsureBinary.mockResolvedValue(managedBinaryPath); - // Installed by default so pre-existing tests exercise the generic - // connectivity-failure path, not the #239 binary-missing path. mockBinaryInfo.mockReturnValue({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', + version: '1.0.0', + bundledVersion: '1.0.0', tier: 'free', platform: 'linux-x64', binaryPath: managedBinaryPath, installed: true, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', + cacheDir: managedBinaryDir, + downloadUrl: 'https://example.test/download', }); // Doctor always runs live connectivity. Tests that want connect to fail override. mockConnect.mockResolvedValue({ @@ -182,11 +184,11 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - binary: { installed: true, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, + binary: { installed: true, path: '/Applications/Cloak Chromium.app' }, issues: [], })); - expect(text).toContain('[OK] Browser binary: installed at /home/test/.cloakbrowser/chromium-1.0.0/chrome'); + expect(text).toContain('[OK] Browser binary: installed at /Applications/Cloak Chromium.app'); }); it('renders the browser binary status line as MISSING when not installed', () => { @@ -194,11 +196,78 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - binary: { installed: false, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, - issues: ['CloakBrowser Chromium is not installed and could not be downloaded at ...'], + binary: { installed: false, path: '/Applications/Cloak Chromium.app' }, + issues: ['CloakBrowser Chromium is not installed.'], + })); + + expect(text).toContain('[MISSING] Browser binary: not installed (/Applications/Cloak Chromium.app)'); + }); + + it('renders the selected browser as bundled Cloak by default', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'Cloak', + selectedBrowser: { kind: 'cloak' }, + issues: [], + })); + + expect(text).toContain('[OK] Selected browser: Cloak (bundled default)'); + }); + + it('renders the selected browser as a custom executable path', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'custom', + selectedBrowser: { kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' }, + issues: [], + })); + + expect(text).toContain('[OK] Selected browser: custom (/Applications/Brave Browser.app/Contents/MacOS/Brave Browser)'); + }); + + it('renders the selected browser as Google Chrome', () => { + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'chrome', + selectedBrowser: { kind: 'chrome', executablePath }, + issues: [], + })); + + expect(text).toContain(`[OK] Selected browser: Google Chrome (${executablePath})`); + }); + + it('renders the selected browser as SLAB alpha', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'SLAB', + selectedBrowser: { kind: 'slab' }, + issues: [], })); - expect(text).toContain('[MISSING] Browser binary: not installed (/home/test/.cloakbrowser/chromium-1.0.0/chrome)'); + expect(text).toContain('[OK] Selected browser: SLAB (macOS alpha opt-in)'); + }); + + it('uses the selected browser label when runtime name is missing', () => { + const slab = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: false, + selectedBrowser: { kind: 'slab' }, + issues: [], + })); + const custom = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: false, + selectedBrowser: { kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' }, + issues: [], + })); + + expect(slab).toContain('[MISSING] Runtime: SLAB not connected'); + expect(custom).toContain('[MISSING] Runtime: custom not connected'); }); it('renders connectivity OK when live test succeeds', () => { @@ -292,7 +361,7 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Default Cloak profile is not active: work (profile-default)'), + expect.stringContaining('Default browser profile is not active: work (profile-default)'), ])); expect(report.issues.join('\n')).toContain('fall back to the only active profile: active-profile'); } finally { @@ -301,6 +370,102 @@ describe('doctor report rendering', () => { } }); + it('defaults an old local config to bundled Cloak', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-legacy-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + fs.writeFileSync( + getConfigPath({ env: { WEBCMD_CONFIG_DIR: configDir } }), + JSON.stringify({ mode: 'local', updatedAt: '2026-08-31T00:00:00.000Z' }), + ); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ kind: 'cloak' }); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('uses the configured custom executable for doctor checks', async () => { + const configDir = writeLocalConfig({ + kind: 'custom', + executablePath: managedBinaryPath, + }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'custom' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ + kind: 'custom', + executablePath: managedBinaryPath, + }); + expect(report.binary).toMatchObject({ + installed: true, + path: managedBinaryPath, + }); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('uses the configured Google Chrome executable for doctor checks', async () => { + const configDir = writeLocalConfig({ + kind: 'chrome', + executablePath: managedBinaryPath, + }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'chrome' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ + kind: 'chrome', + executablePath: managedBinaryPath, + }); + expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath }); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('checks only the configured SLAB runtime', async () => { + const configDir = writeLocalConfig({ kind: 'slab' }); + mockConnect.mockRejectedValueOnce(new Error('slab runtime unavailable')); + mockGetDaemonHealth.mockResolvedValueOnce({ + state: 'no-runtime', + status: { runtimeConnected: false, runtimeName: 'SLAB' }, + }); + + try { + const report = await runBrowserDoctor(); + const text = strip(renderBrowserDoctorReport(report)); + const issues = report.issues.join('\n'); + + expect(report.selectedBrowser).toEqual({ kind: 'slab' }); + expect(report.binary).toBeUndefined(); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); + expect(text).toContain('[OK] Selected browser: SLAB (macOS alpha opt-in)'); + expect(text).not.toContain('Browser binary:'); + expect(issues).toContain('SLAB runtime is not connected'); + expect(issues).not.toContain('Cloak runtime is not connected'); + expect(issues).not.toContain('Chrome/Chromium'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('reports flapping when live check succeeds but final status shows runtime disconnected', async () => { mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false, runtimeName: 'Cloak' } }); @@ -314,7 +479,45 @@ describe('doctor report rendering', () => { ])); }); - it('uses runtime-neutral readiness hints when the runtime is disconnected', async () => { + it('uses SLAB wording when the selected SLAB runtime flaps', async () => { + const configDir = writeLocalConfig({ kind: 'slab' }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false } }); + + try { + const report = await runBrowserDoctor(); + const issues = report.issues.join('\n'); + const text = strip(renderBrowserDoctorReport(report)); + + expect(report.runtimeFlaky).toBe(true); + expect(issues).toContain('SLAB runtime connection is unstable'); + expect(issues).not.toContain('Cloak runtime connection is unstable'); + expect(text).toContain('[WARN] Runtime: SLAB unstable'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('uses custom wording when the selected custom runtime flaps', async () => { + const configDir = writeLocalConfig({ kind: 'custom', executablePath: managedBinaryPath }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false } }); + + try { + const report = await runBrowserDoctor(); + const issues = report.issues.join('\n'); + const text = strip(renderBrowserDoctorReport(report)); + + expect(report.runtimeFlaky).toBe(true); + expect(issues).toContain('custom runtime connection is unstable'); + expect(issues).not.toContain('Cloak runtime connection is unstable'); + expect(text).toContain('[WARN] Runtime: custom unstable'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('uses Cloak readiness hints when the runtime is disconnected', async () => { mockConnect.mockRejectedValueOnce(new Error('runtime unavailable')); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', @@ -544,26 +747,22 @@ describe('doctor report rendering', () => { expect(report.profiles).toHaveLength(2); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Multiple Chrome profiles are connected'), + expect.stringContaining('Multiple browser profiles are connected'), ])); }); - describe('#239 — missing browser binary', () => { - it('reports the binary as installed and does not alter the generic failure message when present', async () => { + describe('Cloak browser binary status', () => { + it('reports an installed browser binary without altering a generic connectivity failure', async () => { mockConnect.mockRejectedValueOnce(new Error('page.goto: Target page, context or browser has been closed')); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); const report = await runBrowserDoctor(); - expect(report.binary?.installed).toBe(true); + expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath }); expect(report.issues).toEqual(expect.arrayContaining([ expect.stringContaining('Browser connectivity test failed: page.goto: Target page, context or browser has been closed'), ])); - const issueText = report.issues.join('\n'); - expect(issueText).not.toContain('CloakBrowser Chromium is not installed'); - expect(issueText).not.toContain('not launchable'); - expect(issueText).not.toContain('Download URL:'); - expect(issueText).not.toContain('CLOAKBROWSER_BINARY_PATH'); + expect(report.issues.join('\n')).not.toContain('CloakBrowser Chromium is not installed'); }); it('reports a missing binary without claiming a download was attempted', async () => { @@ -588,7 +787,7 @@ describe('doctor report rendering', () => { expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); expect(issueText).toContain('Browser connectivity test failed: fetch failed'); - expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH'); + expect(issueText).toContain('Check network access to the download URL above.'); expect(issueText).not.toContain('could not be downloaded'); expect(issueText).not.toContain('download failed'); }); @@ -610,74 +809,14 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); const issueText = report.issues.join('\n'); + expect(report.binary?.installed).toBe(false); expect(report.connectivity).toMatchObject({ ok: false, error: 'session-create refused' }); + expect(issueText).toContain('CloakBrowser Chromium is not installed'); expect(issueText).toContain('Browser connectivity test failed: session-create refused'); - expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); - expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); - expect(issueText).not.toContain('could not be downloaded'); - expect(issueText).not.toContain('download failed'); - expect(mockConnect).not.toHaveBeenCalled(); }); - it('reports the binary state after connectivity auto-installs Chromium', async () => { - let installed = false; - mockBinaryInfo.mockImplementation(() => ({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: managedBinaryPath, - installed, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', - })); - mockConnect.mockImplementationOnce(async () => { - installed = true; - return { - evaluate: vi.fn().mockResolvedValue(2), - closeWindow: vi.fn().mockResolvedValue(undefined), - }; - }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const text = strip(renderBrowserDoctorReport(report)); - - expect(report.binary?.installed).toBe(true); - expect(report.issues).toEqual([]); - expect(text).toContain('[OK] Browser binary: installed at'); - expect(text).not.toContain('[MISSING] Browser binary'); - expect(text).toContain('Everything looks good!'); - expect(mockBinaryInfo).toHaveBeenCalledTimes(1); - }); - - it('reports a final missing binary even when connectivity succeeds', async () => { - mockBinaryInfo.mockReturnValue({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome', - installed: false, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', - }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const text = strip(renderBrowserDoctorReport(report)); - - expect(report.connectivity?.ok).toBe(true); - expect(report.binary?.installed).toBe(false); - expect(report.issues.join('\n')).toContain('CloakBrowser Chromium is not installed'); - expect(text).toContain('[MISSING] Browser binary'); - expect(text).not.toContain('Everything looks good!'); - }); - - it('reports binary probe failures as unknown warnings', async () => { - mockBinaryInfo.mockImplementation(() => { - throw new Error('corrupt CloakBrowser metadata'); - }); + it('reports failed browser binary checks as warnings', async () => { + mockBinaryInfo.mockImplementationOnce(() => { throw new Error('corrupt CloakBrowser metadata'); }); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); const report = await runBrowserDoctor(); @@ -690,55 +829,13 @@ describe('doctor report rendering', () => { expect(text).not.toContain('Everything looks good!'); }); - it('treats CLOAKBROWSER_BINARY_PATH as the effective binary check, not the managed cache', async () => { - const overridePath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-override-')), - process.platform === 'win32' ? 'chrome.exe' : 'chrome', - ); - fs.writeFileSync(overridePath, '#!/bin/sh\n'); - if (process.platform !== 'win32') fs.chmodSync(overridePath, 0o755); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - // Managed cache would report "not installed" — the override should win. - mockBinaryInfo.mockReturnValue({ - version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'linux-x64', - binaryPath: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', installed: false, - cacheDir: '/home/test/.cloakbrowser/chromium-1.0.0', downloadUrl: 'https://example.test/download', - }); + it('ignores retired browser-binary environment variables and uses the managed binary', async () => { + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/does/not/exist/webcmd-chrome'); + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/does/not/exist/cloak-chrome'); - const binary = checkBrowserBinary(); - - expect(binary.installed).toBe(true); - expect(binary.override).toBe(true); - expect(binary.path).toBe(overridePath); - } finally { - vi.unstubAllEnvs(); - fs.rmSync(path.dirname(overridePath), { recursive: true, force: true }); - } - }); - - it('rejects a CLOAKBROWSER_BINARY_PATH directory', async () => { - const overridePath = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-directory-')); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - expect(checkBrowserBinary().installed).toBe(false); - } finally { - vi.unstubAllEnvs(); - fs.rmSync(overridePath, { recursive: true, force: true }); - } - }); - - it('rejects a non-executable CLOAKBROWSER_BINARY_PATH file on POSIX', async () => { - if (process.platform === 'win32') return; - const overridePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-non-executable-')), 'chrome'); - fs.writeFileSync(overridePath, '#!/bin/sh\n', { mode: 0o644 }); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - expect(checkBrowserBinary().installed).toBe(false); - } finally { - vi.unstubAllEnvs(); - fs.rmSync(path.dirname(overridePath), { recursive: true, force: true }); - } + expect(checkBrowserBinary()).toMatchObject({ installed: true, path: managedBinaryPath }); + await checkConnectivity(); + expect(mockEnsureBinary).toHaveBeenCalledOnce(); }); it('rejects a managed non-executable binary on POSIX', () => { @@ -762,9 +859,12 @@ describe('doctor report rendering', () => { const binaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-windows-binary-')); const binaryPath = path.join(binaryDir, 'chrome.txt'); fs.writeFileSync(binaryPath, 'not an executable'); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', binaryPath); try { Object.defineProperty(process, 'platform', { ...platformDescriptor, value: 'win32' }); + mockBinaryInfo.mockReturnValue({ + version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'win32-x64', + binaryPath, installed: true, cacheDir: binaryDir, downloadUrl: 'https://example.test/download', + }); expect(checkBrowserBinary().installed).toBe(false); } finally { if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor); @@ -773,25 +873,6 @@ describe('doctor report rendering', () => { } }); - it('reports override-specific guidance when CLOAKBROWSER_BINARY_PATH points nowhere', async () => { - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/does/not/exist/chrome'); - try { - mockConnect.mockRejectedValueOnce(new Error('spawn /does/not/exist/chrome ENOENT')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - - expect(report.binary?.installed).toBe(false); - expect(report.binary?.override).toBe(true); - const issueText = report.issues.join('\n'); - const text = strip(renderBrowserDoctorReport(report)); - expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH (/does/not/exist/chrome)'); - expect(issueText).toContain('compatible local Chromium executable'); - expect(text).toContain('[MISSING] Browser binary: not launchable (/does/not/exist/chrome)'); - } finally { - vi.unstubAllEnvs(); - } - }); }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 695cac3c..0b93da2b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -17,6 +17,8 @@ import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; +import { configureCloakBrowserBinary } from './browser/browser-binary.js'; +import { loadWebcmdConfig, type LocalBrowserConfig } from './hosted/config.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -36,8 +38,6 @@ export type BrowserBinaryStatus = { path: string; downloadUrl?: string; error?: string; - /** True when CLOAKBROWSER_BINARY_PATH is set — a different check than the managed cache. */ - override: boolean; }; export type DoctorReport = { @@ -50,6 +50,7 @@ export type DoctorReport = { runtimeFlaky?: boolean; runtimeName?: string; runtimeVersion?: string; + selectedBrowser?: LocalBrowserConfig; binary?: BrowserBinaryStatus; connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; @@ -82,16 +83,16 @@ function isLaunchableFile(binaryPath: string): boolean { /** * Check whether the CloakBrowser Chromium binary is actually installed. - * `runtimeConnected: true` only means the - * daemon/Cloak runtime process is healthy — it says nothing about whether the - * browser binary CloakBrowser needs to launch is present on disk, which is - * exactly the gap that made a missing-binary failure look like a generic - * connectivity problem (#239). + * `runtimeConnected: true` only means the daemon/Cloak runtime process is + * healthy — it says nothing about whether the browser binary CloakBrowser + * needs to launch is present on disk. */ -export function checkBrowserBinary(): BrowserBinaryStatus { - const override = process.env.CLOAKBROWSER_BINARY_PATH; - if (override) { - return { installed: isLaunchableFile(override), path: override, override: true }; +export function checkBrowserBinary(browser: LocalBrowserConfig = { kind: 'cloak' }): BrowserBinaryStatus { + if (browser.kind === 'custom' || browser.kind === 'chrome') { + return { + installed: isLaunchableFile(browser.executablePath), + path: browser.executablePath, + }; } try { const info = binaryInfo(); @@ -99,23 +100,25 @@ export function checkBrowserBinary(): BrowserBinaryStatus { installed: info.installed && isLaunchableFile(info.binaryPath), path: info.binaryPath, downloadUrl: info.downloadUrl, - override: false, }; } catch (err) { - return { installed: undefined, path: 'unknown', error: getErrorMessage(err), override: false }; + return { installed: undefined, path: 'unknown', error: getErrorMessage(err) }; } } /** * Test connectivity by attempting a real browser command. */ -export async function checkConnectivity(opts?: { timeout?: number }): Promise { +export async function checkConnectivity( + browser: LocalBrowserConfig = { kind: 'cloak' }, + opts?: { timeout?: number }, +): Promise { const start = Date.now(); const timeoutSeconds = opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS; let sessionId: string | undefined; try { // A first-use download can exceed doctor's deliberately short live-probe deadline. - await ensureBinary(); + if (browser.kind === 'cloak') await ensureBinary(); setDaemonCommandTimeoutSeconds(timeoutSeconds); const session = await sendCommand('session-create', { sessionName: 'Doctor Probe' }) as { id?: unknown }; if (typeof session.id !== 'string') throw new Error('Doctor could not create a browser Session.'); @@ -149,11 +152,18 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { + const config = loadWebcmdConfig(); + const selectedBrowser = config.mode === 'local' ? config.browser : { kind: 'cloak' } satisfies LocalBrowserConfig; + configureCloakBrowserBinary( + selectedBrowser.kind === 'custom' || selectedBrowser.kind === 'chrome' + ? selectedBrowser.executablePath + : undefined, + ); // Live connectivity check is the core of doctor — it doubles as auto-start // (bridge.connect spawns daemon) and validates // end-to-end browser bridge health. - const connectivity = await checkConnectivity(); - const binary = checkBrowserBinary(); + const connectivity = await checkConnectivity(selectedBrowser); + const binary = selectedBrowser.kind === 'slab' ? undefined : checkBrowserBinary(selectedBrowser); // Single status read *after* connectivity side-effects settle. const health = await getDaemonHealth(); @@ -166,22 +176,22 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise, or pass --profile .', ); } else if (health.state === 'profile-disconnected') { issues.push( `Selected browser profile is not connected: ${health.status?.contextId ?? 'unknown'}.\n` + - ' Open that Chrome profile and make sure Cloak is enabled.', + (selectedBrowser.kind === 'slab' + ? ' Open SLAB and reconnect that profile.' + : selectedBrowser.kind === 'custom' || selectedBrowser.kind === 'chrome' + ? ' Open that browser profile and make sure the selected browser is running.' + : ' Open that Chrome profile and make sure Cloak is enabled.'), ); } else { issues.push( - 'Daemon is running but the Cloak runtime is not connected.\n' + - ' Make sure Chrome/Chromium is open and Cloak is enabled.\n' + + `Daemon is running but the ${expectedRuntimeLabel} runtime is not connected.\n` + + (selectedBrowser.kind === 'slab' + ? ' Make sure SLAB is open.\n' + : selectedBrowser.kind === 'custom' || selectedBrowser.kind === 'chrome' + ? ' Make sure the selected browser executable can launch and the browser is open.\n' + : ' Make sure Chrome/Chromium is open and Cloak is enabled.\n') + ' If Chrome is already open, try: webcmd daemon restart', ); } @@ -231,7 +253,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise.', ); @@ -250,6 +272,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { }); it('keeps Session window conflicts on the structured temporary-failure contract', async () => { - const { SessionWindowConflictError } = await import('./browser/runtime/local-cloak/session-manager.js'); + const { SessionWindowConflictError } = await import('./browser/runtime/local-slab/session-manager.js'); expect(toEnvelope(new SessionWindowConflictError('page_1', 'session_a', 'session_b')).error) .toMatchObject({ code: 'SESSION_WINDOW_CONFLICT', exitCode: 75 }); diff --git a/src/hosted/browser-args.test.ts b/src/hosted/browser-args.test.ts index cc8241b0..721b6713 100644 --- a/src/hosted/browser-args.test.ts +++ b/src/hosted/browser-args.test.ts @@ -38,15 +38,23 @@ describe('hosted browser argument surface', () => { expect(() => parse(['--session', 'session_work', 'browser', 'fork', 'linkedin/search'])).toThrow(); }); - it('requires a stable page id for bind', () => { + it('requires exactly one supported page selector for bind', () => { expect(parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123'])).toMatchObject({ commandName: 'bind', session: 'session_work', options: { page: 'page-123' }, }); + expect(parse(['--session', 'session_work', 'browser', 'bind', '--target-id', 'target-123'])).toMatchObject({ + commandName: 'bind', + session: 'session_work', + options: { targetId: 'target-123' }, + }); expect(() => parse(['--session', 'session_work', 'browser', 'bind'])).toThrow(CommanderStructuralError); expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--target-id', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123', '--target-id', 'target-123'])) + .toThrow(CommanderStructuralError); }); it('accepts only run program options', () => { diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index eead1e27..d73a1e24 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -138,11 +138,25 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost ); } - return parsed ?? { + const result = parsed ?? { positionals: [], options: {}, ...readBrowserGlobals(root, browser), }; + validateBindSelectors(result); + return result; +} + +function validateBindSelectors(result: ParsedHostedBrowserStructure): void { + if (result.commandName !== 'bind') return; + const page = typeof result.options.page === 'string' ? result.options.page : ''; + const targetId = typeof result.options.targetId === 'string' ? result.options.targetId : ''; + if (Boolean(page) === Boolean(targetId)) { + throw new CommanderStructuralError( + `error: Bind requires exactly one of --page or --target-id\n`, + EXIT_CODES.USAGE_ERROR, + ); + } } function normalizeBrowserOptions(command: string, options: Record): Record { diff --git a/src/hosted/config.test.ts b/src/hosted/config.test.ts index e4bbfada..1461136b 100644 --- a/src/hosted/config.test.ts +++ b/src/hosted/config.test.ts @@ -29,6 +29,7 @@ describe('hosted config', () => { expect(loadWebcmdConfig({ env: { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv })).toEqual({ mode: 'local', updatedAt: '1970-01-01T00:00:00.000Z', + browser: { kind: 'cloak' }, }); }); @@ -134,8 +135,44 @@ describe('hosted config', () => { expect(makeLocalConfig(new Date('2026-07-08T00:00:00.000Z'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'cloak' }, }); expect(defaultHostedApiBaseUrl({ WEBCMD_CLOUD_API_URL: 'https://cloud.example.com/' } as NodeJS.ProcessEnv)) .toBe('https://cloud.example.com'); }); + + it('round-trips each local browser choice', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-config-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const updatedAt = '2026-08-31T00:00:00.000Z'; + + for (const browser of [ + { kind: 'cloak' } as const, + { kind: 'chrome', executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' } as const, + { kind: 'slab' } as const, + { kind: 'custom', executablePath: '/Applications/Chrome.app/Contents/MacOS/Google Chrome' } as const, + ]) { + saveWebcmdConfig({ mode: 'local', updatedAt, browser }, { env }); + expect(loadWebcmdConfig({ env })).toEqual({ mode: 'local', updatedAt, browser }); + } + }); + + it('defaults old or invalid local browser data to Cloak', () => { + expect(loadWebcmdConfig({ + readFileSync: (() => '{"mode":"local","updatedAt":"2026-08-31T00:00:00.000Z"}') as never, + })).toMatchObject({ mode: 'local', browser: { kind: 'cloak' } }); + + for (const browser of [ + { kind: 'custom' }, + { kind: 'custom', executablePath: '' }, + { kind: 'custom', executablePath: 'relative/browser' }, + { kind: 'chrome' }, + { kind: 'chrome', executablePath: 'relative/browser' }, + { kind: 'other' }, + ]) { + expect(loadWebcmdConfig({ + readFileSync: (() => JSON.stringify({ mode: 'local', updatedAt: '2026-08-31T00:00:00.000Z', browser })) as never, + })).toMatchObject({ mode: 'local', browser: { kind: 'cloak' } }); + } + }); }); diff --git a/src/hosted/config.ts b/src/hosted/config.ts index 34a5dfa1..38da3bd6 100644 --- a/src/hosted/config.ts +++ b/src/hosted/config.ts @@ -9,10 +9,17 @@ export interface HostedManifestCache { manifest: unknown; } +export type LocalBrowserConfig = + | { kind: 'cloak' } + | { kind: 'chrome'; executablePath: string } + | { kind: 'slab' } + | { kind: 'custom'; executablePath: string }; + export type WebcmdConfig = | { mode: 'local'; updatedAt: string; + browser: LocalBrowserConfig; } | { mode: 'hosted'; @@ -55,7 +62,7 @@ export function getConfigPath(io: Pick = {}): strin function parseConfig(raw: string): WebcmdConfig { const parsed = JSON.parse(raw) as Partial; if (parsed.mode === 'local' && typeof parsed.updatedAt === 'string') { - return { mode: 'local', updatedAt: parsed.updatedAt }; + return { mode: 'local', updatedAt: parsed.updatedAt, browser: readLocalBrowser((parsed as { browser?: unknown }).browser) }; } if ( parsed.mode === 'hosted' @@ -78,7 +85,7 @@ function parseConfig(raw: string): WebcmdConfig { }, }; } - return { mode: 'local', updatedAt: new Date(0).toISOString() }; + return makeLocalConfig(new Date(0)); } export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { @@ -86,7 +93,7 @@ export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { try { return parseConfig(readFileSync(getConfigPath(io), 'utf-8') as string); } catch { - return { mode: 'local', updatedAt: new Date(0).toISOString() }; + return makeLocalConfig(new Date(0)); } } @@ -107,10 +114,14 @@ export function saveWebcmdConfig(config: WebcmdConfig, io: ConfigIo = {}): void export type LocalWebcmdConfig = Extract; export type HostedWebcmdConfig = Extract; -export function makeLocalConfig(now: Date = new Date()): LocalWebcmdConfig { +export function makeLocalConfig( + now: Date = new Date(), + browser: LocalBrowserConfig = { kind: 'cloak' }, +): LocalWebcmdConfig { return { mode: 'local', updatedAt: now.toISOString(), + browser, }; } @@ -195,6 +206,20 @@ function readCredentialBackend(value: unknown): HostedCredentialBackend | undefi return value === 'os' || value === 'file-fallback' ? value : undefined; } +function readLocalBrowser(value: unknown): LocalBrowserConfig { + if (value && typeof value === 'object') { + const browser = value as { kind?: unknown; executablePath?: unknown }; + if (browser.kind === 'cloak' || browser.kind === 'slab') return { kind: browser.kind }; + if (browser.kind === 'chrome' && typeof browser.executablePath === 'string' && path.isAbsolute(browser.executablePath)) { + return { kind: 'chrome', executablePath: browser.executablePath }; + } + if (browser.kind === 'custom' && typeof browser.executablePath === 'string' && path.isAbsolute(browser.executablePath)) { + return { kind: 'custom', executablePath: browser.executablePath }; + } + } + return { kind: 'cloak' }; +} + function persistableConfig(config: WebcmdConfig): WebcmdConfig { if (config.mode !== 'hosted' || !config.hosted.apiKeyRef) return config; return { diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 0532dce4..13cf76f4 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -5,9 +5,10 @@ import path, { join } from 'node:path'; import { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getConfigPath } from './config.js'; +import { getConfigPath, makeLocalConfig, saveWebcmdConfig } from './config.js'; import { getHostedCredentialPath } from './credentials.js'; import { runHostedSetup } from './setup.js'; +import type { SlabSetupStatus } from '../slab/status.js'; let tempDir: string | undefined; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -20,7 +21,7 @@ afterEach(async () => { describe('webcmd setup', () => { it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); - const answers = ['local']; + const answers = ['local', 'cloak']; const messages: string[] = []; const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; @@ -29,6 +30,7 @@ describe('webcmd setup', () => { platform: 'linux', now: () => new Date('2026-07-08T00:00:00.000Z'), question: async () => answers.shift() ?? '', + fetchDaemonStatus: async () => null, write: (message) => { messages.push(message); }, }); @@ -36,10 +38,63 @@ describe('webcmd setup', () => { expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'cloak' }, }); expect(messages.join('')).toContain('local mode'); }); + it('shows installed Chrome in the interactive browser prompt and reuses its detection', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-interactive-chrome-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const answers = ['local', 'chrome']; + const prompts: string[] = []; + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const resolveGoogleChromeExecutable = vi.fn(async () => executablePath); + + await expect(runHostedSetup({ + env, + question: async prompt => { + prompts.push(prompt); + return answers.shift() ?? ''; + }, + resolveGoogleChromeExecutable, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(prompts).toContain('Local browser [cloak/chrome (installed)/slab/absolute path] (cloak): '); + expect(resolveGoogleChromeExecutable).toHaveBeenCalledOnce(); + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + browser: { kind: 'chrome', executablePath }, + }); + }); + + it('shows install required and the official link when unavailable Chrome is selected', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-interactive-chrome-missing-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z')), { env }); + const answers = ['local', 'chrome']; + const prompts: string[] = []; + const messages: string[] = []; + const resolveGoogleChromeExecutable = vi.fn(async () => undefined); + + await expect(runHostedSetup({ + env, + question: async prompt => { + prompts.push(prompt); + return answers.shift() ?? ''; + }, + resolveGoogleChromeExecutable, + fetchDaemonStatus: async () => null, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(prompts).toContain('Local browser [cloak/chrome (install required)/slab/absolute path] (cloak): '); + expect(resolveGoogleChromeExecutable).toHaveBeenCalledOnce(); + expect(messages.join('')).toContain('https://www.google.com/chrome/'); + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + }); + it('writes hosted mode and validates with /v1/me', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); const answers = ['hosted', 'wcmd_live_test']; @@ -102,6 +157,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local'], isTTY: false, question, + fetchDaemonStatus: async () => null, write: (message) => { messages.push(message); }, }); @@ -110,10 +166,396 @@ describe('webcmd setup', () => { expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'cloak' }, }); expect(messages.join('')).toContain('local mode'); }); + it('validates Cloak before persisting the selection', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const events: string[] = []; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'cloak'], + isTTY: false, + now: () => new Date('2026-08-31T00:00:00.000Z'), + resolveCloakPackage: async () => { events.push('validate'); return 'file:///cloakbrowser/index.js'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { + events.push('save'); + saveWebcmdConfig(config, configIo); + }, + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser: { kind: 'cloak' }, + }); + expect(events).toEqual(['validate', 'save']); + }); + + it('persists a canonical custom executable path', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-custom-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', '/Applications/Chrome.app/Contents/MacOS/Google Chrome'], + isTTY: false, + realpath: async () => '/private/Applications/Chrome.app/Contents/MacOS/Google Chrome', + stat: async () => ({ isFile: () => true }), + access: async () => undefined, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser: { kind: 'custom', executablePath: '/private/Applications/Chrome.app/Contents/MacOS/Google Chrome' }, + }); + }); + + it('detects and persists an installed Google Chrome executable', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-chrome-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'chrome'], + isTTY: false, + resolveGoogleChromeExecutable: async () => executablePath, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser: { kind: 'chrome', executablePath }, + }); + }); + + it('explains how to install Google Chrome when it is unavailable', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-chrome-missing-')); + const messages: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'chrome'], + isTTY: false, + resolveGoogleChromeExecutable: async () => undefined, + fetchDaemonStatus: async () => null, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(messages.join('')).toContain('Google Chrome is not installed'); + expect(messages.join('')).toContain('https://www.google.com/chrome/'); + }); + + it('reuses an existing SLAB app without downloading it again', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); + const events: string[] = []; + const installSlabMacos = vi.fn(); + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + homeDir: '/Users/me', + existsSync: candidate => candidate === '/Applications/SLAB.app/Contents/MacOS/SLAB', + installSlabMacos, + verifySlabApp: async () => { events.push('verify'); }, + launchSlabApp: async () => { events.push('launch'); }, + inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, + write: () => undefined, + })).resolves.toBe(0); + + expect(installSlabMacos).not.toHaveBeenCalled(); + expect(events).toEqual(['verify', 'launch', 'hello', 'save']); + }); + + it('rejects SLAB when it does not answer its control protocol after install', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-not-ready-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-30T00:00:00.000Z')), { env }); + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + existsSync: () => false, + installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + inspectSlabStatus: async () => 'installed-not-running', + wait: async () => undefined, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + }); + + it('installs SLAB and probes its control protocol before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-install-')); + const events: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + existsSync: () => false, + installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, + inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, + write: () => undefined, + })).resolves.toBe(0); + + expect(events).toEqual(['install', 'hello', 'save']); + }); + + it('polls for SLAB control readiness before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-ready-poll-')); + const events: string[] = []; + const statuses: SlabSetupStatus[] = ['installed-not-running', 'installed-running']; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + existsSync: () => false, + installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, + inspectSlabStatus: async () => { events.push('hello'); return statuses.shift() ?? 'installed-running'; }, + wait: async () => { events.push('wait'); }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, + write: () => undefined, + })).resolves.toBe(0); + + expect(events).toEqual(['install', 'hello', 'wait', 'hello', 'save']); + }); + + it('allows slow first SLAB launch before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-slow-ready-')); + const statuses: SlabSetupStatus[] = [ + ...Array.from({ length: 41 }).fill('installed-not-running'), + 'installed-running', + ]; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + existsSync: () => false, + installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + inspectSlabStatus: async () => statuses.shift() ?? 'installed-running', + wait: async () => undefined, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + }); + + it('leaves config and daemon unchanged when SLAB installation fails', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-failure-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-30T00:00:00.000Z')), { env }); + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + existsSync: () => false, + installSlabMacos: async () => { throw new Error('download failed'); }, + fetchDaemonStatus: async () => daemonStatus('cloak'), + restartDaemon, + write: () => undefined, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('does not restart a daemon when config persistence fails', async () => { + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('cloak'), + saveConfig: () => { throw new Error('disk full'); }, + restartDaemon, + write: () => undefined, + })).resolves.toBe(1); + + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('restarts a running daemon and requires the selected runtime', async () => { + const restartDaemon = vi.fn(async () => ({ previousStatus: daemonStatus('cloak'), status: daemonStatus('custom'), stopped: true, spawned: true })); + + await expect(runHostedSetup({ + argv: ['--mode', 'local', '--browser', '/custom/browser'], + isTTY: false, + realpath: async () => '/custom/browser', + stat: async () => ({ isFile: () => true }), + access: async () => undefined, + fetchDaemonStatus: async () => daemonStatus('cloak'), + restartDaemon, + write: () => undefined, + })).resolves.toBe(0); + + expect(restartDaemon).toHaveBeenCalledOnce(); + }); + + it('does not start a stopped daemon during setup', async () => { + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => null, + restartDaemon, + write: () => undefined, + })).resolves.toBe(0); + + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('keeps a valid selection when the restarted daemon reports the wrong runtime', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-runtime-mismatch-')); + const messages: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('custom'), + restartDaemon: async () => ({ previousStatus: daemonStatus('custom'), status: daemonStatus('custom'), stopped: true, spawned: true }), + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env: { WEBCMD_CONFIG_DIR: tempDir } }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(messages.join('')).toContain('webcmd daemon restart'); + }); + + it('keeps a valid selection and prints restart guidance when daemon restart fails', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-restart-failure-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const messages: string[] = []; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('custom'), + restartDaemon: async () => { throw new Error('port still busy'); }, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(messages.join('')).toContain('webcmd daemon restart'); + }); + + it('rejects SLAB setup off macOS before changing config', async () => { + const installSlabMacos = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'linux', + installSlabMacos, + write: () => undefined, + })).resolves.toBe(1); + + expect(installSlabMacos).not.toHaveBeenCalled(); + }); + + it.each([ + [['--mode', 'local', '--browser'], '--browser requires a value.'], + [['--mode', 'local', '--browser', 'relative/browser'], '--browser must be cloak, chrome, slab, or an absolute path'], + [['--mode', 'hosted', '--browser', 'slab', '--api-key', 'wcmd_live_test'], '--browser is only valid with --mode local.'], + ])('rejects invalid browser arguments from %j', async (argv, message) => { + const stderr = collectStderr(); + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: join(tmpdir(), `webcmd-setup-browser-error-${Date.now()}`) }, + argv, + isTTY: false, + stderr: stderr.stream, + write: () => undefined, + })).resolves.toBe(2); + + expect(stderr.text()).toContain(message); + }); + + it('shows browser usage in setup help', async () => { + const messages: string[] = []; + + await expect(runHostedSetup({ + argv: ['--help'], + write: message => { messages.push(message); }, + })).resolves.toBe(0); + + expect(messages.join('')).toContain('--browser '); + expect(messages.join('')).toContain('Cloak is default, Chrome reuses an installed Google Chrome'); + }); + + it('reports the configured custom browser without probing SLAB', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-status-custom-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const inspectSlabStatus = vi.fn(); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), { kind: 'custom', executablePath: '/custom/browser' }), { env }); + const messages: string[] = []; + + await expect(runHostedSetup({ env, argv: ['--status'], inspectSlabStatus, write: message => { messages.push(message); } })).resolves.toBe(0); + + expect(JSON.parse(messages.join(''))).toEqual({ + configured: true, + mode: 'local', + browser: { kind: 'custom', executablePath: '/custom/browser' }, + }); + expect(inspectSlabStatus).not.toHaveBeenCalled(); + }); + + it('reports SLAB runtime status without installing or launching it', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-status-slab-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const installSlabMacos = vi.fn(); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), { kind: 'slab' }), { env }); + const messages: string[] = []; + + await expect(runHostedSetup({ + env, + argv: ['--status'], + inspectSlabStatus: async () => 'installed-not-running', + installSlabMacos, + write: message => { messages.push(message); }, + })).resolves.toBe(0); + + expect(JSON.parse(messages.join(''))).toEqual({ + configured: true, + mode: 'local', + browser: { kind: 'slab' }, + runtime: 'installed-not-running', + }); + expect(installSlabMacos).not.toHaveBeenCalled(); + }); + it('rejects non-TTY setup without --mode and never prompts', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-nontty-')); const messages: string[] = []; @@ -206,11 +648,15 @@ describe('webcmd setup', () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slow-output-')); const output = new SetupControlledWritable(); let settled = false; + const answers = ['local', 'cloak']; const run = runHostedSetup({ env: { WEBCMD_CONFIG_DIR: tempDir }, output, - question: async () => 'local', + question: async () => answers.shift() ?? '', + fetchDaemonStatus: async () => null, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + resolveGoogleChromeExecutable: async () => undefined, }).then(code => { settled = true; return code; @@ -271,6 +717,19 @@ function collectStderr(): { stream: Writable; text: () => string } { return { stream, text: () => Buffer.concat(chunks).toString('utf8') }; } +function daemonStatus(runtimeName: string) { + return { + ok: true, + pid: 1234, + uptime: 1, + runtimeConnected: true, + runtimeName, + pending: 0, + memoryMB: 1, + port: 9777, + }; +} + async function within(promise: Promise, milliseconds = 500): Promise { let timer: ReturnType | undefined; try { diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 1b102cd5..1f734dd8 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,15 +1,30 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; +import { constants, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { access, realpath, stat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; import { CLI_COMMAND } from '../brand.js'; import { ArgumentError, toEnvelope } from '../errors.js'; import { formatErrorEnvelope } from '../output.js'; import { writeToStream } from '../stream-write.js'; +import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; +import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; +import { findInstalledGoogleChrome } from '../browser/google-chrome.js'; +import { createSlabInstallerIo, installSlabMacos, verifySlabApp } from '../slab/install.js'; +import { findSlabInstallation, type SlabInstallation } from '../slab/installation.js'; +import { createSlabLaunchIo } from '../slab/launch.js'; +import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; import { HostedClient } from './client.js'; import { defaultHostedApiBaseUrl, + getConfigPath, + loadWebcmdConfig, makeLocalConfig, saveWebcmdConfig, type ConfigIo, + type LocalBrowserConfig, + type WebcmdConfig, } from './config.js'; import { makeStoredHostedConfig, @@ -27,11 +42,25 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { write?: (message: string) => void | Promise; argv?: readonly string[]; isTTY?: boolean; + resolveCloakPackage?: () => string | Promise; + resolveGoogleChromeExecutable?: () => Promise; + realpath?: (path: string) => Promise; + stat?: (path: string) => Promise<{ isFile(): boolean }>; + access?: (path: string, mode: number) => Promise; + installSlabMacos?: () => Promise; + verifySlabApp?: (appPath: string) => Promise; + launchSlabApp?: (appPath: string) => Promise; + inspectSlabStatus?: () => Promise; + wait?: (ms: number) => Promise; + fetchDaemonStatus?: () => Promise; + restartDaemon?: () => Promise; + saveConfig?: (config: WebcmdConfig, io: ConfigIo) => void; } type SetupMode = 'local' | 'hosted'; +type LocalBrowserSelection = LocalBrowserConfig | { kind: 'chrome' }; -const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--api-key ]`; +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--browser ] [--api-key ]`; const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; const SETUP_HELP = [ `${CLI_COMMAND} setup`, @@ -39,7 +68,9 @@ const SETUP_HELP = [ 'Configure local or hosted mode.', '', ' --mode Required when stdin is not a TTY', + ' --browser Local browser; Cloak is default, Chrome reuses an installed Google Chrome, SLAB is macOS alpha', ' --api-key Required for --mode hosted when stdin is not a TTY', + ' --status Show the configured mode and local browser', ' -h, --help', '', SETUP_EXAMPLE, @@ -66,6 +97,10 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { await write(SETUP_HELP); return 0; } + if (parsed.status) { + await write(`${JSON.stringify(await getSetupStatus(io))}\n`); + return 0; + } const interactive = canPrompt(io); let mode = parsed.mode; @@ -91,11 +126,38 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, ); } - saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io); + let chromeDiscovery: { executablePath: string | undefined } | undefined; + let browser = parsed.browser; + if (!browser && interactive) { + chromeDiscovery = { executablePath: await resolveGoogleChromeExecutable(io) }; + const chromeStatus = chromeDiscovery.executablePath ? 'installed' : 'install required'; + browser = parseLocalBrowser( + (await ask(`Local browser [cloak/chrome (${chromeStatus})/slab/absolute path] (cloak): `)).trim() || 'cloak', + ); + } + browser ??= { kind: 'cloak' }; + const before = await (io.fetchDaemonStatus ?? fetchDaemonStatus)(); + try { + const selected = await validateLocalBrowser(browser, io, chromeDiscovery); + (io.saveConfig ?? saveWebcmdConfig)(makeLocalConfig(io.now?.() ?? new Date(), selected), io); + if (before) await restartConfiguredDaemon(selected, io); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await write(`Local browser setup failed: ${message}\n`); + if (err instanceof DaemonRestartError) await write('Run `webcmd daemon restart` to apply the selected browser.\n'); + return 1; + } await write('Webcmd is now configured for local mode.\n'); return 0; } + if (parsed.browser) { + throw new ArgumentError( + '--browser is only valid with --mode local.', + `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, + ); + } + let apiKey = parsed.apiKey?.trim(); if (!apiKey) { if (!interactive) { @@ -151,18 +213,123 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } function canPrompt(io: SetupIo): boolean { - if (io.question) return true; if (io.isTTY !== undefined) return io.isTTY; + if (io.question) return true; return process.stdin.isTTY === true && process.stdout.isTTY === true; } -function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; apiKey?: string } { +async function validateLocalBrowser( + browser: LocalBrowserSelection, + io: SetupIo, + chromeDiscovery?: { executablePath: string | undefined }, +): Promise { + if (browser.kind === 'cloak') { + await (io.resolveCloakPackage ?? (() => import.meta.resolve('cloakbrowser')))(); + return browser; + } + if (browser.kind === 'custom') { + const executablePath = await (io.realpath ?? realpath)(browser.executablePath); + if (!(await (io.stat ?? stat)(executablePath)).isFile()) throw new Error(`Browser executable is not a file: ${executablePath}`); + await (io.access ?? access)(executablePath, constants.X_OK); + return { kind: 'custom', executablePath }; + } + if (browser.kind === 'chrome') { + const executablePath = 'executablePath' in browser + ? browser.executablePath + : chromeDiscovery + ? chromeDiscovery.executablePath + : await resolveGoogleChromeExecutable(io); + if (!executablePath) { + throw new Error( + `Google Chrome is not installed. Install it from https://www.google.com/chrome/, then rerun ${CLI_COMMAND} setup --mode local --browser chrome.`, + ); + } + return { kind: 'chrome', executablePath }; + } + if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); + const installation = findSlabInstallation({ + platform: io.platform ?? process.platform, + homeDir: io.homeDir ?? homedir(), + existsSync: io.existsSync ?? existsSync, + }); + if (installation) { + await (io.verifySlabApp ?? (appPath => verifySlabApp(createSlabInstallerIo(), appPath)))(installation.appPath); + await (io.launchSlabApp ?? createSlabLaunchIo().launch)(installation.appPath); + } else { + await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + } + if (!await waitForSlabHello(io)) throw new Error('SLAB did not report its control protocol after launch.'); + return browser; +} + +function resolveGoogleChromeExecutable(io: SetupIo): Promise { + return (io.resolveGoogleChromeExecutable ?? (() => findInstalledGoogleChrome({ + platform: io.platform ?? process.platform, + env: io.env ?? process.env, + homeDir: io.homeDir, + })))(); +} + +async function waitForSlabHello(io: SetupIo, timeoutMs = 60_000): Promise { + const inspect = io.inspectSlabStatus ?? inspectSlabStatus; + const wait = io.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const intervalMs = 250; + const attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs)); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + if (slabStatusHasHello(await inspect())) return true; + if (attempt === attempts) return false; + await wait(intervalMs); + } + return false; +} + +async function restartConfiguredDaemon(browser: LocalBrowserConfig, io: SetupIo): Promise { + let result: DaemonRestartResult; + try { + result = await (io.restartDaemon ?? restartDaemon)(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new DaemonRestartError(message); + } + const expected = browser.kind === 'slab' ? 'SLAB' : browser.kind; + if (!result.stopped || result.status?.runtimeName !== expected) { + throw new DaemonRestartError(`Daemon restarted without the selected ${expected} runtime.`); + } +} + +class DaemonRestartError extends Error {} + +export interface SetupStatus { + configured: boolean; + mode: SetupMode; + browser: LocalBrowserConfig | null; + runtime?: SlabSetupStatus; +} + +export async function getSetupStatus(io: SetupIo = {}): Promise { + const config = loadWebcmdConfig(io); + const browser = config.mode === 'local' ? config.browser : null; + const status: SetupStatus = { + configured: (io.existsSync ?? existsSync)(getConfigPath(io)), + mode: config.mode, + browser, + }; + if (browser?.kind === 'slab') status.runtime = await (io.inspectSlabStatus ?? inspectSlabStatus)(); + return status; +} + +function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; mode?: SetupMode; browser?: LocalBrowserSelection; apiKey?: string } { let mode: SetupMode | undefined; + let browser: LocalBrowserSelection | undefined; let apiKey: string | undefined; + let status: true | undefined; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; if (token === '--help' || token === '-h') return { help: true }; - + if (token === '--status') { + status = true; + continue; + } if (token === '--mode' || token.startsWith('--mode=')) { const value = token.startsWith('--mode=') ? token.slice('--mode='.length) : argv[++i]; if (value !== 'local' && value !== 'hosted') { @@ -187,12 +354,32 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMod continue; } + if (token === '--browser' || token.startsWith('--browser=')) { + const value = token.startsWith('--browser=') ? token.slice('--browser='.length) : argv[++i]; + browser = parseLocalBrowser(value); + continue; + } + throw new ArgumentError( `unknown flag ${token} for \`setup\``, - `valid flags for \`setup\`: --mode, --api-key, --help\n${SETUP_USAGE}`, + `valid flags for \`setup\`: --mode, --browser, --api-key, --status, --help\n${SETUP_USAGE}`, ); } - return { mode, apiKey }; + return { ...(status ? { status } : {}), mode, browser, apiKey }; +} + +function parseLocalBrowser(value: string | undefined): LocalBrowserSelection { + if (!value || value.startsWith('-')) { + throw new ArgumentError('--browser requires a value.', `${SETUP_USAGE}\n${SETUP_EXAMPLE}`); + } + if (value === 'cloak') return { kind: 'cloak' }; + if (value === 'chrome') return { kind: 'chrome' }; + if (value === 'slab') return { kind: 'slab' }; + if (isAbsolute(value)) return { kind: 'custom', executablePath: value }; + throw new ArgumentError( + `--browser must be cloak, chrome, slab, or an absolute path (got: "${value}").`, + `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, + ); } function hostedAccountLabel(body: unknown): string | undefined { diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts new file mode 100644 index 00000000..a98798da --- /dev/null +++ b/src/slab/bridge-client.test.ts @@ -0,0 +1,371 @@ +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SlabBridgeClient, SlabProtocolError } from './bridge-client.js'; +import { SlabCredential, SLAB_MAX_CONTROL_LINE_BYTES } from './protocol.js'; + +const CREDENTIAL = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +interface ScriptedServer { + endpoint: string; + requests: unknown[]; + close(): Promise; +} + +const servers: Array<{ server: Server; dir: string }> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async ({ server, dir }) => { + await new Promise((resolve) => server.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + })); +}); + +async function listen(onConnection: (socket: Socket) => void): Promise { + const dir = await mkdtemp(join(tmpdir(), 'slab-control-')); + const endpoint = join(dir, 'slab-bridge.sock'); + const requests: unknown[] = []; + const server = createServer(onConnection); + servers.push({ server, dir }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, () => resolve()); + }); + return { + endpoint, + requests, + async close() { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function collectRequests(socket: Socket, requests: unknown[], onRequest: (req: { id: string; method: string; params: unknown }) => void): void { + let buf = ''; + socket.on('data', (chunk) => { + buf += chunk.toString('utf8'); + let idx = buf.indexOf('\n'); + while (idx !== -1) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + const req = JSON.parse(line) as { id: string; method: string; params: unknown }; + requests.push(req); + onRequest(req); + idx = buf.indexOf('\n'); + } + }); +} + +function helloOk(id: string, browserVersion = '152.0.7977.65'): string { + return `${JSON.stringify({ + id, + ok: true, + result: { + protocolVersion: 1, + browserVersion, + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }, + })}\n`; +} + +function attachOk(id: string, credential = CREDENTIAL): string { + return `${JSON.stringify({ + id, + ok: true, + result: { + connectionId: '00000000-0000-4000-8000-000000000000', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc', + endpoint: '/Users/test/.slab/run/AAAAAAAAAAA.sock', + credential, + }, + }, + })}\n`; +} + +function releaseOk(id: string): string { + return `${JSON.stringify({ id, ok: true, result: null })}\n`; +} + +function errorLine(id: string, code: string, message: string): string { + return `${JSON.stringify({ id, ok: false, error: { code, message } })}\n`; +} + +function sizedHello(id: string, targetBytes: number): string { + const make = (pad: string) => JSON.stringify({ + id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: pad, + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }, + }); + const pad = 'x'.repeat(targetBytes - Buffer.byteLength(make(''))); + return `${make(pad)}\n`; +} + +describe.skipIf(process.platform === 'win32')('SlabBridgeClient', () => { + it('reassembles fragmented JSONL responses', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + const line = helloOk(req.id); + socket.write(line.slice(0, 8)); + socket.write(line.slice(8, 20)); + socket.write(line.slice(20)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).resolves.toMatchObject({ protocolVersion: 1, browserPid: 1234 }); + await client.close(); + }); + + it('splits coalesced JSONL responses', async () => { + const harness = await listen((socket) => { + const pending: string[] = []; + collectRequests(socket, harness.requests, (req) => { + pending.push(helloOk(req.id)); + if (pending.length === 2) socket.write(pending.join('')); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const [first, second] = await Promise.all([client.hello(), client.hello()]); + expect(first.protocolVersion).toBe(1); + expect(second.protocolVersion).toBe(1); + expect(new Set(harness.requests.map((req) => (req as { id: string }).id)).size).toBe(2); + await client.close(); + }); + + it('accepts a response line of exactly 64 KiB', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + const line = sizedHello(req.id, SLAB_MAX_CONTROL_LINE_BYTES); + expect(Buffer.byteLength(line.slice(0, -1))).toBe(SLAB_MAX_CONTROL_LINE_BYTES); + socket.write(line); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const result = await client.hello(); + expect(result.protocolVersion).toBe(1); + expect(result.browserVersion.length).toBeGreaterThan(64 * 1024 - 200); + await client.close(); + }); + + it('rejects an oversized line and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(sizedHello(req.id, SLAB_MAX_CONTROL_LINE_BYTES + 1)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/64 KiB|oversized|control/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects malformed JSON and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write('{not-json}\n'); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/json|invalid|malformed/i); + await expect(client.release('x')).rejects.toThrow(); + }); + + it('rejects invalid UTF-8 and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write(Buffer.from([0xff, 0xfe, 0x0a])); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/utf-8|utf8|invalid/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('times out a pending request and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => {}); + }); + const client = await SlabBridgeClient.connect(harness.endpoint, { timeoutMs: 40 }); + await expect(client.hello()).rejects.toThrow(/timeout/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects pending requests when the socket closes', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.destroy(); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/close|closed|disconnect/i); + }); + + it('rejects an unexpected response id and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write(helloOk('not-the-request-id')); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/id|unexpected/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects a duplicate response and closes the control connection', async () => { + let socketRef: Socket | undefined; + const harness = await listen((socket) => { + socketRef = socket; + collectRequests(socket, harness.requests, (req) => { + socket.write(helloOk(req.id)); + socket.write(helloOk(req.id)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).resolves.toMatchObject({ protocolVersion: 1 }); + await expect(client.attach('default')).rejects.toThrow(/duplicate|closed|id/i); + await new Promise((resolve) => { + if (!socketRef || socketRef.destroyed) { + resolve(); + return; + } + socketRef.once('close', () => resolve()); + }); + expect(socketRef?.destroyed || socketRef?.readableEnded).toBeTruthy(); + }); + + it('rejects unknown response fields and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: '1', + browserPid: 1, + profiles: [], + }, + extra: true, + })}\n`); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/unknown|field|unexpected/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects extra result fields and destroys the control socket', async () => { + let socketRef: Socket | undefined; + const harness = await listen((socket) => { + socketRef = socket; + collectRequests(socket, harness.requests, (req) => { + if (req.method === 'attach') { + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + connectionId: '00000000-0000-4000-8000-000000000000', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc', + endpoint: '/tmp/x.sock', + credential: CREDENTIAL, + }, + extra: true, + }, + })}\n`); + return; + } + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: '1', + browserPid: 1, + profiles: [], + extra: true, + }, + })}\n`); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/unknown|field/i); + await new Promise((resolve, reject) => { + if (!socketRef) { + reject(new Error('missing server socket')); + return; + } + if (socketRef.destroyed) { + resolve(); + return; + } + socketRef.once('close', () => resolve()); + setTimeout(() => reject(new Error('socket stayed open')), 100); + }); + expect(socketRef?.destroyed || socketRef?.readableEnded).toBeTruthy(); + await expect(client.attach('default')).rejects.toThrow(); + }); + + it('maps stable protocol errors without leaking credentials or raw responses', async () => { + const codes = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', + ] as const; + for (const code of codes) { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(errorLine(req.id, code, `secret ${CREDENTIAL} raw={"ok":false}`)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const error = await client.attach('default').then( + () => { + throw new Error(`expected ${code}`); + }, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(SlabProtocolError); + expect((error as SlabProtocolError).code).toBe(code); + expect(String(error)).not.toContain(CREDENTIAL); + expect(String(error)).not.toContain('raw='); + expect((error as Error).message).not.toContain(CREDENTIAL); + await client.close().catch(() => {}); + } + }); + + it('redacts attachment credentials in inspection and stringification', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + if (req.method === 'attach') socket.write(attachOk(req.id)); + else if (req.method === 'release') socket.write(releaseOk(req.id)); + else socket.write(helloOk(req.id)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const lease = await client.attach('default'); + expect(lease.transport.kind).toBe('cdp-ipc'); + expect(lease.transport.credential).toBeInstanceOf(SlabCredential); + expect(String(lease.transport.credential)).toBe('[REDACTED]'); + expect(JSON.stringify(lease.transport.credential)).toBe('"[REDACTED]"'); + expect(inspect(lease.transport.credential)).toContain('[REDACTED]'); + expect(inspect(lease)).not.toContain(CREDENTIAL); + expect(JSON.stringify(lease)).not.toContain(CREDENTIAL); + expect(lease.transport.credential.reveal()).toBe(CREDENTIAL); + await expect(client.release(lease.connectionId)).resolves.toBeNull(); + await client.close(); + }); +}); diff --git a/src/slab/bridge-client.ts b/src/slab/bridge-client.ts new file mode 100644 index 00000000..6dd9e099 --- /dev/null +++ b/src/slab/bridge-client.ts @@ -0,0 +1,201 @@ +import { createConnection, type Socket } from 'node:net'; +import { StringDecoder } from 'node:string_decoder'; +import { PKG_VERSION } from '../version.js'; +import { + parseAttachResult, + parseControlResponse, + parseHelloResult, + parseReleaseResult, + isValidUtf8, + SLAB_ERROR_MESSAGES, + SLAB_MAX_CONTROL_LINE_BYTES, + SLAB_PROTOCOL_VERSION, + type SlabAttachResult, + type SlabErrorCode, + type SlabHelloResult, +} from './protocol.js'; + +export interface SlabBridgeClientOptions { + timeoutMs?: number; + clientVersion?: string; +} + +export class SlabProtocolError extends Error { + readonly code: SlabErrorCode; + + constructor(code: SlabErrorCode) { + super(SLAB_ERROR_MESSAGES[code]); + this.name = 'SlabProtocolError'; + this.code = code; + } +} + +interface PendingRequest { + method: string; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer?: ReturnType; +} + +function parseResultForMethod(method: string, result: unknown): unknown { + if (method === 'hello') return parseHelloResult(result); + if (method === 'attach') return parseAttachResult(result); + if (method === 'release') return parseReleaseResult(result); + throw new Error('SLAB control response has unknown fields'); +} + +export class SlabBridgeClient { + private readonly socket: Socket; + private readonly decoder = new StringDecoder('utf8'); + private readonly pending = new Map(); + private readonly timeoutMs: number; + private readonly clientVersion: string; + private pendingBytes = Buffer.alloc(0); + private nextId = 0; + private closed = false; + + private constructor(socket: Socket, options: SlabBridgeClientOptions = {}) { + this.socket = socket; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.clientVersion = options.clientVersion ?? `webcmd/${PKG_VERSION}`; + socket.on('data', (chunk) => this.onData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('close', () => this.failOpen('connection closed')); + socket.on('error', () => this.failOpen('connection closed')); + } + + static connect(endpoint: string, options: SlabBridgeClientOptions = {}): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path: endpoint }); + const client = new SlabBridgeClient(socket, options); + const onError = (error: Error) => reject(error); + socket.once('error', onError); + socket.once('connect', () => { + socket.off('error', onError); + resolve(client); + }); + }); + } + + hello(): Promise { + return this.request('hello', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + clientVersion: this.clientVersion, + }) as Promise; + } + + attach(profile: string | { id: string }): Promise { + const profileId = typeof profile === 'string' ? profile : profile.id; + return this.request('attach', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + profileId, + }) as Promise; + } + + release(connectionId: string): Promise { + return this.request('release', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + connectionId, + }) as Promise; + } + + async close(): Promise { + this.failOpen('connection closed'); + } + + private request(method: string, params: Record): Promise { + if (this.closed) return Promise.reject(new Error('SLAB control connection closed')); + const id = String(++this.nextId); + if (this.pending.has(id)) return Promise.reject(new Error('SLAB control request id is already pending')); + return new Promise((resolve, reject) => { + const timer = this.timeoutMs > 0 + ? setTimeout(() => this.failOpen('request timeout'), this.timeoutMs) + : undefined; + this.pending.set(id, { method, resolve, reject, timer }); + this.socket.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + + private onData(chunk: Buffer): void { + if (this.closed) return; + let offset = 0; + while (offset < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, offset); + if (newline === -1) { + this.appendPending(chunk.subarray(offset)); + return; + } + const line = Buffer.concat([this.pendingBytes, chunk.subarray(offset, newline)]); + this.pendingBytes = Buffer.alloc(0); + offset = newline + 1; + this.handleLine(line); + if (this.closed) return; + } + } + + private appendPending(piece: Buffer): void { + this.pendingBytes = Buffer.concat([this.pendingBytes, piece]); + if (this.pendingBytes.byteLength > SLAB_MAX_CONTROL_LINE_BYTES) { + this.failOpen('line exceeds 64 KiB'); + } + } + + private handleLine(line: Buffer): void { + if (line.byteLength > SLAB_MAX_CONTROL_LINE_BYTES) { + this.failOpen('line exceeds 64 KiB'); + return; + } + if (!isValidUtf8(line)) { + this.failOpen('response is invalid UTF-8'); + return; + } + const text = this.decoder.write(Buffer.concat([line, Buffer.from([0x0a])])).replace(/\n$/, ''); + let response; + try { + response = parseControlResponse(text); + } catch (error) { + this.failOpen(error instanceof Error ? error.message.replace(/^SLAB control /, '') : 'response is invalid JSON'); + return; + } + const pending = this.pending.get(response.id); + if (!pending) { + const kind = [...this.pending.keys()].length === 0 && this.nextId > 0 + ? 'response is a duplicate' + : 'response id is unexpected'; + this.failOpen(kind); + return; + } + if (!response.ok) { + this.finish(response.id); + pending.reject(new SlabProtocolError(response.error.code)); + return; + } + let result: unknown; + try { + result = parseResultForMethod(pending.method, response.result); + } catch (error) { + this.failOpen(error instanceof Error ? error.message.replace(/^SLAB control /, '') : 'response has unknown fields'); + return; + } + this.finish(response.id); + pending.resolve(result); + } + + private finish(id: string): void { + const pending = this.pending.get(id); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(id); + } + + private failOpen(kind: string): void { + if (this.closed) return; + this.closed = true; + const error = kind.startsWith('SLAB control ') ? new Error(kind) : new Error(`SLAB control ${kind}`); + for (const [id, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + this.socket.destroy(); + } +} diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts new file mode 100644 index 00000000..3bebd9fa --- /dev/null +++ b/src/slab/cdp-ipc-transport.test.ts @@ -0,0 +1,229 @@ +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ConnectOverCDPTransport } from 'playwright-core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CdpIpcTransport } from './cdp-ipc-transport.js'; +import { SlabCredential } from './protocol.js'; + +const CREDENTIAL = new SlabCredential('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); +const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +interface Harness { + endpoint: string; + frames: unknown[]; + socket(): Socket; +} + +const servers: Array<{ server: Server; dir: string; sockets: Set }> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async ({ server, dir, sockets }) => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + })); +}); + +async function listen(onConnection?: (socket: Socket) => void): Promise { + const dir = await mkdtemp(join(tmpdir(), 'slab-cdp-')); + const endpoint = join(dir, 'attachment.sock'); + const frames: unknown[] = []; + let serverSocket: Socket | undefined; + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + serverSocket = socket; + collectFrames(socket, frames); + onConnection?.(socket); + }); + servers.push({ server, dir, sockets }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, () => resolve()); + }); + return { + endpoint, + frames, + socket() { + if (!serverSocket) throw new Error('server did not accept a connection'); + return serverSocket; + }, + }; +} + +function frame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value)); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + return Buffer.concat([header, body]); +} + +function collectFrames(socket: Socket, frames: unknown[]): void { + let pending = Buffer.alloc(0); + socket.on('data', (chunk) => { + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + while (pending.byteLength >= 4) { + const length = pending.readUInt32BE(0); + if (pending.byteLength < length + 4) return; + frames.push(JSON.parse(pending.subarray(4, length + 4).toString('utf8'))); + pending = pending.subarray(length + 4); + } + }); +} + +function blockFor(milliseconds: number): void { + const deadline = performance.now() + milliseconds; + while (performance.now() < deadline) { + // Keep the connection callback pending long enough to consume its budget. + } +} + +async function connectAuthenticated(harness: Harness, timeoutMs = 100): Promise { + const transportPromise = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs }); + await expect.poll(() => harness.frames.length).toBe(1); + expect(harness.frames).toEqual([{ type: 'authenticate', credential: CREDENTIAL.reveal() }]); + harness.socket().write(frame({ type: 'authenticated' })); + return transportPromise; +} + +function closed(transport: ConnectOverCDPTransport): Promise { + return new Promise((resolve) => { + transport.onclose = resolve; + }); +} + +describe.skipIf(process.platform === 'win32')('CdpIpcTransport', () => { + it('authenticates, reassembles split frames, and delivers multiple CDP objects', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const messages: object[] = []; + transport.onmessage = (message) => messages.push(message); + transport.open?.(); + + const payload = Buffer.concat([frame({ id: 1, result: {} }), frame({ method: 'Target.attachedToTarget', params: {} })]); + harness.socket().write(payload.subarray(0, 2)); + harness.socket().write(payload.subarray(2, 9)); + harness.socket().write(payload.subarray(9)); + + await expect.poll(() => messages).toEqual([{ id: 1, result: {} }, { method: 'Target.attachedToTarget', params: {} }]); + }); + + it('frames raw CDP messages after open', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + transport.open?.(); + transport.send({ id: 1, method: 'Browser.getVersion' }); + + await expect.poll(() => harness.frames).toEqual([ + { type: 'authenticate', credential: CREDENTIAL.reveal() }, + { id: 1, method: 'Browser.getVersion' }, + ]); + }); + + it('allows CDP sends immediately after authentication', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + transport.send({ id: 1, method: 'Browser.getVersion' }); + + await expect.poll(() => harness.frames).toEqual([ + { type: 'authenticate', credential: CREDENTIAL.reveal() }, + { id: 1, method: 'Browser.getVersion' }, + ]); + }); + + it('delivers CDP frames received before onmessage is assigned', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + harness.socket().write(frame({ id: 1, result: {} })); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const messages: object[] = []; + transport.onmessage = (message) => messages.push(message); + + await expect.poll(() => messages).toEqual([{ id: 1, result: {} }]); + }); + + it('rejects an advertised frame over 64 MiB before waiting for its body', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(MAX_FRAME_BYTES + 1); + harness.socket().write(header); + + await expect(close).resolves.toMatch(/64 MiB/i); + }); + + it.each([ + ['invalid JSON', Buffer.from('{not-json}')], + ['a JSON array', Buffer.from('[]')], + ])('closes on %s CDP frames', async (_name, body) => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + harness.socket().write(Buffer.concat([header, body])); + + await expect(close).resolves.toMatch(/JSON|object/i); + }); + + it('rejects authentication failures', async () => { + const harness = await listen(); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 100 }); + await expect.poll(() => harness.frames.length).toBe(1); + harness.socket().write(frame({ type: 'authentication_failed' })); + + await expect(connection).rejects.toThrow(/authentication/i); + }); + + it('rejects when authentication times out', async () => { + const harness = await listen(); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 10 }); + await expect(connection).rejects.toThrow(/timeout/i); + }); + + it('uses one timeout budget for connection and authentication', async () => { + const harness = await listen(() => blockFor(40)); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 100 }); + await expect.poll(() => harness.frames.length).toBe(1); + setTimeout(() => harness.socket().write(frame({ type: 'authenticated' })), 80); + + await expect(connection).rejects.toThrow(/timeout/i); + }); + + it('rejects connection errors', async () => { + await expect(CdpIpcTransport.connect({ endpoint: join(tmpdir(), 'missing-slab-cdp.sock'), credential: CREDENTIAL, timeoutMs: 100 })) + .rejects.toThrow(); + }); + + it('notifies Playwright when the peer closes', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + harness.socket().destroy(); + + await expect(close).resolves.toMatch(/closed/i); + }); + + it('closes locally only once', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + transport.close(); + transport.close(); + + await expect(close).resolves.toMatch(/closed/i); + await expect.poll(() => harness.socket().destroyed || harness.socket().readableEnded).toBe(true); + }); + + it('rejects sends after close', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + transport.close(); + expect(() => transport.send({ id: 2 })).toThrow(/closed/i); + }); +}); diff --git a/src/slab/cdp-ipc-transport.ts b/src/slab/cdp-ipc-transport.ts new file mode 100644 index 00000000..f59bd28c --- /dev/null +++ b/src/slab/cdp-ipc-transport.ts @@ -0,0 +1,222 @@ +import { createConnection, type Socket } from 'node:net'; +import type { ConnectOverCDPTransport } from 'playwright-core'; +import { type SlabCredential } from './protocol.js'; + +const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +export interface CdpIpcTransportOptions { + endpoint: string; + credential: SlabCredential; + timeoutMs?: number; +} + +type State = 'ready' | 'open' | 'closed'; + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function encodeFrame(value: object): Buffer { + const body = Buffer.from(JSON.stringify(value)); + if (body.byteLength === 0 || body.byteLength > MAX_FRAME_BYTES) { + throw new Error('SLAB CDP IPC frame exceeds 64 MiB'); + } + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + return Buffer.concat([header, body]); +} + +export class CdpIpcTransport implements ConnectOverCDPTransport { + onclose?: (reason?: string) => void; + + private readonly chunks: Buffer[] = []; + private messageHandler?: (message: object) => void; + private pendingMessages: object[] = []; + private bufferedBytes = 0; + private expectedLength?: number; + private state: State = 'ready'; + private authenticated = false; + private resolveAuthentication?: () => void; + private rejectAuthentication?: (error: Error) => void; + private timeoutTimer?: ReturnType; + + private constructor(private readonly socket: Socket) { + socket.on('data', (chunk) => this.onData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('error', () => this.fail('connection closed')); + socket.on('close', () => this.fail('connection closed')); + } + + get onmessage(): ((message: object) => void) | undefined { + return this.messageHandler; + } + + set onmessage(handler: ((message: object) => void) | undefined) { + this.messageHandler = handler; + this.flushPendingMessages(); + } + + static connect(options: CdpIpcTransportOptions): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path: options.endpoint }); + const transport = new CdpIpcTransport(socket); + const timeoutMs = options.timeoutMs ?? 30_000; + const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined; + let settled = false; + + const finishReject = (error: Error) => { + if (settled) return; + settled = true; + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); + reject(error); + }; + const finishResolve = () => { + if (settled) return; + settled = true; + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); + resolve(transport); + }; + const onConnectError = (error: Error) => finishReject(error); + const scheduleTimeout = (reason: string) => { + if (deadline === undefined) return; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + transport.fail(reason); + return; + } + transport.timeoutTimer = setTimeout(() => transport.fail(reason), remainingMs); + }; + + transport.rejectAuthentication = finishReject; + socket.once('error', onConnectError); + socket.once('connect', () => { + if (settled) return; + socket.off('error', onConnectError); + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); + transport.resolveAuthentication = finishResolve; + transport.rejectAuthentication = finishReject; + scheduleTimeout('authentication timeout'); + try { + socket.write(encodeFrame({ type: 'authenticate', credential: options.credential.reveal() })); + } catch (error) { + transport.fail(error instanceof Error ? error.message.replace(/^SLAB CDP IPC /, '') : 'authentication failed'); + } + }); + scheduleTimeout('connection timeout'); + }); + } + + open(): void { + if (this.state !== 'ready') return; + this.state = 'open'; + this.flushPendingMessages(); + } + + send(message: object): void { + if (this.state === 'closed') throw new Error('SLAB CDP IPC transport is closed'); + if (!this.authenticated) throw new Error('SLAB CDP IPC transport is not authenticated'); + if (!isObject(message)) throw new Error('SLAB CDP IPC message must be an object'); + this.socket.write(encodeFrame(message)); + } + + close(): void { + this.fail('connection closed'); + } + + private onData(chunk: Buffer): void { + if (this.state === 'closed') return; + this.chunks.push(chunk); + this.bufferedBytes += chunk.byteLength; + this.parseFrames(); + } + + private parseFrames(): void { + while (this.state !== 'closed') { + if (this.expectedLength === undefined) { + if (this.bufferedBytes < 4) return; + const header = this.read(4); + const length = header.readUInt32BE(0); + if (length === 0 || length > MAX_FRAME_BYTES) { + this.fail('frame exceeds 64 MiB'); + return; + } + this.expectedLength = length; + } + if (this.bufferedBytes < this.expectedLength) return; + const body = this.read(this.expectedLength); + this.expectedLength = undefined; + this.handleFrame(body); + } + } + + private read(length: number): Buffer { + const first = this.chunks[0]; + if (first && first.byteLength >= length) { + const value = first.subarray(0, length); + if (first.byteLength === length) this.chunks.shift(); + else this.chunks[0] = first.subarray(length); + this.bufferedBytes -= length; + return value; + } + + const value = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const chunk = this.chunks.shift(); + if (!chunk) throw new Error('SLAB CDP IPC parser lost buffered data'); + const size = Math.min(chunk.byteLength, length - offset); + chunk.copy(value, offset, 0, size); + offset += size; + if (size < chunk.byteLength) this.chunks.unshift(chunk.subarray(size)); + } + this.bufferedBytes -= length; + return value; + } + + private handleFrame(body: Buffer): void { + let message: unknown; + try { + message = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); + } catch { + this.fail('frame contains invalid JSON'); + return; + } + if (!isObject(message)) { + this.fail('frame must contain a JSON object'); + return; + } + if (!this.authenticated) { + if (Object.keys(message).length !== 1 || !Object.hasOwn(message, 'type') || (message as { type?: unknown }).type !== 'authenticated') { + this.fail('authentication failed'); + return; + } + this.authenticated = true; + this.state = 'open'; + const resolve = this.resolveAuthentication; + this.resolveAuthentication = undefined; + this.rejectAuthentication = undefined; + if (this.timeoutTimer) clearTimeout(this.timeoutTimer); + resolve?.(); + return; + } + if (this.state === 'open' && this.messageHandler) this.messageHandler(message); + else this.pendingMessages.push(message); + } + + private flushPendingMessages(): void { + if (this.state !== 'open' || !this.messageHandler) return; + for (const message of this.pendingMessages) this.messageHandler(message); + this.pendingMessages = []; + } + + private fail(reason: string): void { + if (this.state === 'closed') return; + this.state = 'closed'; + if (this.timeoutTimer) clearTimeout(this.timeoutTimer); + const reject = this.rejectAuthentication; + this.resolveAuthentication = undefined; + this.rejectAuthentication = undefined; + reject?.(new Error(`SLAB CDP IPC ${reason}`)); + this.socket.destroy(); + this.onclose?.(`SLAB CDP IPC ${reason}`); + } +} diff --git a/src/slab/contract-parity.test.ts b/src/slab/contract-parity.test.ts new file mode 100644 index 00000000..7ffafb1a --- /dev/null +++ b/src/slab/contract-parity.test.ts @@ -0,0 +1,134 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const LOCAL_FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../browser/runtime/local-slab/__fixtures__'); +const FIXTURE_FILES = [ + 'hello.response.json', + 'attach.response.json', + 'release.response.json', + 'errors.json', +] as const; +const CONNECTION_ID = '00000000-0000-4000-8000-000000000000'; +const PROFILE_ID = 'default'; +const ENDPOINT = '/Users/test/.slab/run/AAAAAAAAAAA.sock'; +const CREDENTIAL = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const STABLE_ERRORS = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', +] as const; + +function keysOf(value: unknown): string[] { + return Object.keys(value as object).sort(); +} + +function findSiblingFixtures(): string | null { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const parent = dirname(dir); + const candidate = join(parent, 'slab-browser/.worktrees/slab-macos-first-alpha/protocol/fixtures'); + if (existsSync(join(candidate, 'hello.response.json'))) return candidate; + if (parent === dir) break; + dir = parent; + } + return null; +} + +async function loadLocal(name: (typeof FIXTURE_FILES)[number]): Promise<{ bytes: Buffer; parsed: unknown }> { + const bytes = await readFile(join(LOCAL_FIXTURES, name)); + return { bytes, parsed: JSON.parse(bytes.toString('utf8')) }; +} + +describe('SLAB protocol fixture parity', () => { + it('validates committed local copies against the frozen contract', async () => { + const hello = await loadLocal('hello.response.json'); + const attach = await loadLocal('attach.response.json'); + const release = await loadLocal('release.response.json'); + const errors = await loadLocal('errors.json'); + + expect(keysOf(hello.parsed)).toEqual(['request', 'response']); + const helloDoc = hello.parsed as { + request: { id: string; method: string; params: { protocolVersion: { min: number; max: number }; clientVersion: string } }; + response: { + id: string; + ok: boolean; + result: { + protocolVersion: number; + browserVersion: string; + browserPid: number; + profiles: Array<{ id: string; displayName: string }>; + }; + }; + }; + expect(helloDoc.request.method).toBe('hello'); + expect(helloDoc.request.params.protocolVersion).toEqual({ min: 1, max: 1 }); + expect(helloDoc.response.id).toBe(helloDoc.request.id); + expect(helloDoc.response.ok).toBe(true); + expect(keysOf(helloDoc.response.result)).toEqual(['browserPid', 'browserVersion', 'profiles', 'protocolVersion']); + expect(helloDoc.response.result.protocolVersion).toBe(1); + expect(helloDoc.response.result.profiles).toEqual([{ id: PROFILE_ID, displayName: 'Default' }]); + + const attachDoc = attach.parsed as { + request: { id: string; params: { profileId: string } }; + response: { + id: string; + result: { + connectionId: string; + profile: { id: string; displayName: string }; + transport: { kind: string; endpoint: string; credential: string }; + expiresAt?: unknown; + cdpUrl?: unknown; + }; + }; + }; + expect(attachDoc.request.params.profileId).toBe(PROFILE_ID); + expect(attachDoc.response.id).toBe(attachDoc.request.id); + expect(attachDoc.response.result.connectionId).toBe(CONNECTION_ID); + expect(attachDoc.response.result.expiresAt).toBeUndefined(); + expect(attachDoc.response.result.cdpUrl).toBeUndefined(); + expect(attachDoc.response.result.transport).toEqual({ + kind: 'cdp-ipc', + endpoint: ENDPOINT, + credential: CREDENTIAL, + }); + expect(attachDoc.response.result.transport.credential).toHaveLength(43); + expect(Buffer.byteLength(`${JSON.stringify(attachDoc.response)}\n`)).toBeLessThanOrEqual(64 * 1024); + + const releaseDoc = release.parsed as { + request: { params: { connectionId: string } }; + response: { result: unknown }; + alreadyRevoked: { response: { result: unknown } }; + }; + expect(releaseDoc.request.params.connectionId).toBe(CONNECTION_ID); + expect(releaseDoc.response.result).toBeNull(); + expect(releaseDoc.alreadyRevoked.response.result).toBeNull(); + + const errorDoc = errors.parsed as Record; + expect(Object.keys(errorDoc).sort()).toEqual([...STABLE_ERRORS].sort()); + for (const code of STABLE_ERRORS) { + expect(errorDoc[code].response.error.code).toBe(code); + expect(errorDoc[code].response.error.message).not.toContain(CREDENTIAL); + } + }); + + it('matches sibling fixture bytes and parsed values when the worktree is present', async () => { + const sibling = findSiblingFixtures(); + for (const name of FIXTURE_FILES) { + const local = await loadLocal(name); + const localHash = createHash('sha256').update(local.bytes).digest('hex'); + expect(localHash).toMatch(/^[0-9a-f]{64}$/); + if (!sibling) continue; + const remoteBytes = await readFile(join(sibling, name)); + expect(createHash('sha256').update(remoteBytes).digest('hex'), name).toBe(localHash); + expect(JSON.parse(remoteBytes.toString('utf8')), name).toEqual(local.parsed); + } + if (sibling) expect(existsSync(sibling)).toBe(true); + }); +}); diff --git a/src/slab/control-bridge.test.ts b/src/slab/control-bridge.test.ts new file mode 100644 index 00000000..e15bbf90 --- /dev/null +++ b/src/slab/control-bridge.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { connectSlabControlBridge } from './control-bridge.js'; + +describe('SLAB control bridge', () => { + it('probes or opens SLAB before creating the lease-owning control client', async () => { + const calls: string[] = []; + const client = { + attach: vi.fn(async () => ({ connectionId: 'connection-1' })), + release: vi.fn(async () => null), + close: vi.fn(async () => { calls.push('close'); }), + }; + const bridge = await connectSlabControlBridge({ + ensureLaunched: async () => { calls.push('launch'); }, + connect: async () => { + calls.push('connect'); + return client as never; + }, + }); + + await bridge.attach('default'); + await bridge.release('connection-1'); + + expect(calls).toEqual(['launch', 'connect', 'close']); + expect(client.attach).toHaveBeenCalledWith('default'); + expect(client.release).toHaveBeenCalledWith('connection-1'); + }); + + it('can close the control client before a lease exists', async () => { + const client = { + attach: vi.fn(), + release: vi.fn(), + close: vi.fn(async () => null), + }; + const bridge = await connectSlabControlBridge({ + ensureLaunched: async () => undefined, + connect: async () => client as never, + }); + + await bridge.close(); + + expect(client.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/slab/control-bridge.ts b/src/slab/control-bridge.ts new file mode 100644 index 00000000..3f4f28f8 --- /dev/null +++ b/src/slab/control-bridge.ts @@ -0,0 +1,39 @@ +import { homedir } from 'node:os'; +import { SlabBridgeClient } from './bridge-client.js'; +import { slabControlEndpoint } from './installation.js'; +import { launchSlab } from './launch.js'; +import type { SlabAttachResult } from './protocol.js'; + +export interface SlabControlBridge { + attach(profileId: string): Promise; + release(connectionId: string): Promise; + close(): Promise; +} + +export interface SlabControlBridgeIo { + ensureLaunched(): Promise; + connect(): Promise>; +} + +export async function connectSlabControlBridge(io: SlabControlBridgeIo = createSlabControlBridgeIo()): Promise { + await io.ensureLaunched(); + const client = await io.connect(); + return { + attach: profileId => client.attach(profileId), + release: async connectionId => { + try { + await client.release(connectionId); + } finally { + await client.close(); + } + }, + close: () => client.close(), + }; +} + +export function createSlabControlBridgeIo(): SlabControlBridgeIo { + return { + ensureLaunched: launchSlab, + connect: () => SlabBridgeClient.connect(slabControlEndpoint(homedir())), + }; +} diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts new file mode 100644 index 00000000..ff3e88c3 --- /dev/null +++ b/src/slab/install.test.ts @@ -0,0 +1,222 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { installSlabMacos, replaceSlabAppAtomically, type SlabInstallerIo } from './install.js'; + +const releaseBytes = Buffer.from('signed-slab-dmg'); +const releaseSha256 = createHash('sha256').update(releaseBytes).digest('hex'); + +function fakeInstaller(options: { + expectedSha256?: string; + downloadedBytes?: Buffer; + bundleId?: string; + verifyManifest?: boolean; + failCommand?: 'codesign' | 'xattr'; + xattrAbsent?: boolean; +} = {}) { + const operations: string[] = []; + const writes: string[] = []; + const execFile = vi.fn(async (command: string, args: string[]) => { + if (command === options.failCommand) throw new Error(`${command} rejected the staged app`); + if (command === 'hdiutil' && args[0] === 'attach') operations.push('mount-readonly'); + if (command === 'hdiutil' && args[0] === 'detach') operations.push('detach'); + if (command === 'ditto') operations.push('copy-to-staging'); + if (command === 'codesign') operations.push('codesign-verify'); + if (command === 'xattr') { + operations.push('clear-quarantine'); + if (options.xattrAbsent) { + const error = new Error('No such xattr: com.apple.quarantine') as Error & { stderr?: string }; + error.stderr = 'xattr: No such xattr: com.apple.quarantine'; + throw error; + } + } + }); + const replaceApp = vi.fn(async () => { operations.push('replace-app'); }); + const access = vi.fn(async () => {}); + const io: SlabInstallerIo & { operations(): string[]; execFile: typeof execFile; replaceApp: typeof replaceApp; access: typeof access } = { + homeDir: '/Users/me', + tempDir: '/tmp', + fetch: async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: options.expectedSha256 ?? releaseSha256, signature: 'release-signature' }) } + : { ok: true, arrayBuffer: async () => { + const bytes = options.downloadedBytes ?? releaseBytes; + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } }, + execFile, + mkdtemp: async () => '/tmp/slab-install', + writeFile: async () => { operations.push('download'); }, + sha256: async bytes => { + operations.push('checksum'); + return createHash('sha256').update(bytes).digest('hex'); + }, + mkdir: async () => {}, + rm: async () => { operations.push('cleanup'); }, + access, + replaceApp, + verifyManifest: () => options.verifyManifest ?? true, + bundleId: async () => options.bundleId ?? 'dev.webcmd.slab', + write: async message => { writes.push(message); }, + operations: () => operations.filter(operation => operation !== 'cleanup'), + }; + return { ...io, writes }; +} + +describe('SLAB macOS installer', () => { + it('rejects an invalid manifest signature before download', async () => { + const io = fakeInstaller({ verifyManifest: false }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer release signature verification failed'); + expect(io.execFile).not.toHaveBeenCalled(); + }); + + it('verifies SHA-256 before mounting the DMG', async () => { + const io = fakeInstaller({ expectedSha256: '00'.repeat(32), downloadedBytes: Buffer.from('not-the-release') }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer checksum mismatch'); + expect(io.execFile).not.toHaveBeenCalled(); + }); + + it('mounts the downloaded DMG read-only and stages it before replacement', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.execFile).toHaveBeenCalledWith('hdiutil', expect.arrayContaining(['attach', '-readonly', '-nobrowse', '-mountpoint'])); + expect(io.operations()).toEqual([ + 'download', 'checksum', 'mount-readonly', 'copy-to-staging', + 'codesign-verify', 'clear-quarantine', 'replace-app', 'detach', + ]); + expect(io.replaceApp).toHaveBeenCalledWith('/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); + }); + + it('rejects a staged app with the wrong bundle identifier', async () => { + await expect(installSlabMacos(fakeInstaller({ bundleId: 'com.example.other' }))) + .rejects.toThrow('SLAB installer bundle identifier mismatch'); + }); + + it.each(['codesign', 'xattr'] as const)('does not replace the app when %s verification fails', async (failCommand) => { + const io = fakeInstaller({ failCommand }); + + await expect(installSlabMacos(io)).rejects.toThrow(`${failCommand} rejected the staged app`); + expect(io.replaceApp).not.toHaveBeenCalled(); + }); + + it('treats an absent quarantine attribute as harmless', async () => { + const io = fakeInstaller({ xattrAbsent: true }); + + await expect(installSlabMacos(io)).resolves.toMatchObject({ + appPath: '/Applications/SLAB.app', + }); + expect(io.replaceApp).toHaveBeenCalled(); + }); + + it('removes only the quarantine xattr after verification and before replacement', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.execFile).toHaveBeenCalledWith('xattr', ['-dr', 'com.apple.quarantine', '/Applications/.SLAB.app.webcmd-staging']); + expect(io.operations()).toEqual([ + 'download', 'checksum', 'mount-readonly', 'copy-to-staging', + 'codesign-verify', 'clear-quarantine', 'replace-app', 'detach', + ]); + }); + + it('reports percent and byte progress when Content-Length is known', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: (name: string) => name.toLowerCase() === 'content-length' ? String(bytes.length) : null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 4)); + controller.enqueue(bytes.subarray(4)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.join('')).toContain('100%'); + expect(io.writes.join('')).toContain(`${bytes.length.toFixed(1)} B / ${bytes.length.toFixed(1)} B`); + }); + + it('throttles repeated percent progress updates', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: (name: string) => name.toLowerCase() === 'content-length' ? '1000' : null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 1)); + controller.enqueue(bytes.subarray(1, 2)); + controller.enqueue(bytes.subarray(2)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.filter(message => message.includes('0%'))).toHaveLength(1); + }); + + it('reports downloaded bytes when Content-Length is unknown', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: () => null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 4)); + controller.enqueue(bytes.subarray(4)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.join('')).not.toContain('%'); + expect(io.writes.join('')).toContain(`${bytes.length.toFixed(1)} B`); + }); + + it('checks system Applications write access before choosing its staging directory', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.access).toHaveBeenCalledWith('/Applications', constants.W_OK); + }); + + it('rolls the existing app back when the staging rename fails', async () => { + const rename = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('destination busy')) + .mockResolvedValueOnce(undefined); + const rm = vi.fn(async () => {}); + + await expect(replaceSlabAppAtomically({ rename, rm }, '/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app')) + .rejects.toThrow('destination busy'); + expect(rename).toHaveBeenNthCalledWith(1, '/Applications/SLAB.app', '/Applications/SLAB.app.previous'); + expect(rename).toHaveBeenNthCalledWith(2, '/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); + expect(rename).toHaveBeenNthCalledWith(3, '/Applications/SLAB.app.previous', '/Applications/SLAB.app'); + expect(rm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/slab/install.ts b/src/slab/install.ts new file mode 100644 index 00000000..e29e17fa --- /dev/null +++ b/src/slab/install.ts @@ -0,0 +1,215 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { access, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { posix } from 'node:path'; +import { promisify } from 'node:util'; +import { execFile as execFileCallback } from 'node:child_process'; +import { formatBytes } from '../download/progress.js'; +import { verifySlabReleaseManifest, type SlabReleaseManifest } from './release-key.js'; +import type { SlabInstallation } from './installation.js'; + +const execFile = promisify(execFileCallback); + +export const SLAB_BUNDLE_ID = 'dev.webcmd.slab'; +export const SLAB_RELEASE_MANIFEST_URL = 'https://downloads.webcmd.dev/slab/macos-arm64.json'; + +export interface InstallSlabOptions { + launchAfterInstall?: boolean; + manifestUrl?: string; +} + +export interface SlabInstallerIo { + homeDir: string; + tempDir: string; + fetch(url: string): Promise<{ + ok: boolean; + json?(): Promise; + arrayBuffer?(): Promise; + body?: ReadableStream | null; + headers?: { get(name: string): string | null | undefined }; + }>; + execFile(command: string, args: string[]): Promise; + mkdtemp(prefix: string): Promise; + writeFile(path: string, bytes: Uint8Array): Promise; + sha256?(bytes: Uint8Array): Promise; + mkdir(path: string): Promise; + rm(path: string): Promise; + access(path: string, mode: number): Promise; + bundleId(appPath: string): Promise; + replaceApp(source: string, destination: string): Promise; + verifyManifest(manifest: SlabReleaseManifest): boolean | Promise; + write?(message: string): void | Promise; +} + +export interface SlabReplacementIo { + rename(source: string, destination: string): Promise; + rm(path: string): Promise; +} + +export async function verifySlabApp( + io: Pick, + appPath: string, +): Promise { + await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, appPath]); + if (await io.bundleId(appPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); +} + +function parseManifest(value: unknown): SlabReleaseManifest { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('SLAB installer release manifest is invalid'); + const manifest = value as Partial; + if (typeof manifest.url !== 'string' || typeof manifest.sha256 !== 'string' || typeof manifest.signature !== 'string') { + throw new Error('SLAB installer release manifest is invalid'); + } + return { url: manifest.url, sha256: manifest.sha256, signature: manifest.signature }; +} + +async function responseJson(response: { ok: boolean; json?(): Promise }): Promise { + if (!response.ok || !response.json) throw new Error('SLAB installer release manifest download failed'); + return response.json(); +} + +function progressBucket(received: number, total?: number): string { + return total && total > 0 ? String(Math.round((received / total) * 100)) : String(Math.floor(received / (1024 * 1024))); +} + +async function writeProgress(io: SlabInstallerIo, received: number, total?: number, done: boolean = false, lastBucket?: string): Promise { + if (!io.write) return lastBucket; + const bucket = progressBucket(received, total); + if (!done && bucket === lastBucket) return lastBucket; + const summary = total && total > 0 + ? `${Math.round((received / total) * 100)}% ${formatBytes(received)} / ${formatBytes(total)}` + : formatBytes(received); + await io.write(`\rDownloading SLAB DMG: ${summary}${done ? '\n' : ''}`); + return bucket; +} + +async function responseBytes(response: { + ok: boolean; + arrayBuffer?(): Promise; + body?: ReadableStream | null; + headers?: { get(name: string): string | null | undefined }; +}, io: SlabInstallerIo): Promise { + if (!response.ok) throw new Error('SLAB installer download failed'); + const total = Number(response.headers?.get('content-length') ?? ''); + const expectedBytes = Number.isFinite(total) && total > 0 ? total : undefined; + if (response.body) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + let lastBucket: string | undefined; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + received += value.byteLength; + lastBucket = await writeProgress(io, received, expectedBytes, false, lastBucket); + } + await writeProgress(io, received, expectedBytes, true); + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))); + } + if (!response.arrayBuffer) throw new Error('SLAB installer download failed'); + const bytes = Buffer.from(await response.arrayBuffer()); + await writeProgress(io, bytes.byteLength, expectedBytes ?? bytes.byteLength, true); + return bytes; +} + +async function clearQuarantine(io: SlabInstallerIo, appPath: string): Promise { + try { + await io.execFile('xattr', ['-dr', 'com.apple.quarantine', appPath]); + } catch (error) { + const details = [ + error instanceof Error ? error.message : String(error), + typeof error === 'object' && error && 'stderr' in error ? String((error as { stderr?: unknown }).stderr ?? '') : '', + ].join('\n'); + if (details.includes('No such xattr') && details.includes('com.apple.quarantine')) return; + throw error; + } +} + +export async function replaceSlabAppAtomically(io: SlabReplacementIo, source: string, destination: string): Promise { + const previous = `${destination}.previous`; + await io.rm(previous); + try { + await io.rename(destination, previous); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + await io.rename(source, destination); + } catch (error) { + await io.rename(previous, destination).catch(() => undefined); + throw error; + } + await io.rm(previous); +} + +export async function installSlabMacos(io: SlabInstallerIo = createSlabInstallerIo(), options: InstallSlabOptions = {}): Promise { + const manifestResponse = await io.fetch(options.manifestUrl ?? SLAB_RELEASE_MANIFEST_URL); + const manifest = parseManifest(await responseJson(manifestResponse)); + if (!await io.verifyManifest(manifest)) throw new Error('SLAB installer release signature verification failed'); + + const tempPath = await io.mkdtemp(posix.join(io.tempDir, 'webcmd-slab-')); + const dmgPath = posix.join(tempPath, 'SLAB.dmg'); + const mountPath = posix.join(tempPath, 'mount'); + let stagingPath: string | undefined; + let mounted = false; + + try { + const bytes = await responseBytes(await io.fetch(manifest.url), io); + await io.writeFile(dmgPath, bytes); + const checksum = await (io.sha256?.(bytes) ?? Promise.resolve(createHash('sha256').update(bytes).digest('hex'))); + if (checksum.toLowerCase() !== manifest.sha256.toLowerCase()) throw new Error('SLAB installer checksum mismatch'); + + await io.mkdir(mountPath); + await io.execFile('hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountPath, dmgPath]); + mounted = true; + + let applicationsDir = '/Applications'; + try { + await io.access(applicationsDir, constants.W_OK); + } catch { + applicationsDir = posix.join(io.homeDir, 'Applications'); + await io.mkdir(applicationsDir); + } + const appPath = posix.join(applicationsDir, 'SLAB.app'); + stagingPath = posix.join(applicationsDir, '.SLAB.app.webcmd-staging'); + await io.rm(stagingPath); + await io.execFile('ditto', [posix.join(mountPath, 'SLAB.app'), stagingPath]); + await verifySlabApp(io, stagingPath); + await clearQuarantine(io, stagingPath); + await io.replaceApp(stagingPath, appPath); + stagingPath = undefined; + if (options.launchAfterInstall) await io.execFile('open', [appPath]); + return { platform: 'darwin', appPath, executablePath: posix.join(appPath, 'Contents', 'MacOS', 'SLAB') }; + } finally { + try { + if (mounted) await io.execFile('hdiutil', ['detach', mountPath]); + } finally { + if (stagingPath) await io.rm(stagingPath); + await io.rm(tempPath); + } + } +} + +export function createSlabInstallerIo(): SlabInstallerIo { + return { + homeDir: homedir(), + tempDir: tmpdir(), + fetch: globalThis.fetch, + execFile: async (command, args) => execFile(command, args), + mkdtemp, + writeFile, + mkdir: async path => { await mkdir(path, { recursive: true }); }, + rm: async path => { await rm(path, { recursive: true, force: true }); }, + access, + bundleId: async appPath => { + const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', posix.join(appPath, 'Contents', 'Info.plist')]); + return result.stdout.trim(); + }, + replaceApp: (source, destination) => replaceSlabAppAtomically({ rename, rm: async path => { await rm(path, { recursive: true, force: true }); } }, source, destination), + verifyManifest: verifySlabReleaseManifest, + write: async message => { process.stderr.write(message); }, + }; +} diff --git a/src/slab/installation.test.ts b/src/slab/installation.test.ts new file mode 100644 index 00000000..6db86573 --- /dev/null +++ b/src/slab/installation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findSlabInstallation, isSlabInstalled, slabControlEndpoint } from './installation.js'; + +describe('SLAB installation discovery', () => { + it('finds the first installed normal macOS app bundle', () => { + const existsSync = vi.fn((candidate: string) => candidate === '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB'); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync })).toEqual({ + platform: 'darwin', + appPath: '/Users/me/Applications/SLAB.app', + executablePath: '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB', + }); + }); + + it('does not fall back to a standalone browser executable', () => { + const existsSync = vi.fn(() => false); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync })).toBeNull(); + expect(existsSync.mock.calls.flat().join(' ')).not.toContain('slab-browser'); + }); + + it('reports only normal macOS app bundle availability', () => { + expect(isSlabInstalled({ + platform: 'darwin', + homeDir: '/Users/me', + existsSync: candidate => candidate === '/Applications/SLAB.app/Contents/MacOS/SLAB', + })).toBe(true); + expect(isSlabInstalled({ platform: 'linux', homeDir: '/Users/me', existsSync: () => true })).toBe(false); + }); + + it('uses the owner-scoped control socket path', () => { + expect(slabControlEndpoint('/Users/me')).toBe('/Users/me/.slab/run/slab-bridge.sock'); + }); + +}); diff --git a/src/slab/installation.ts b/src/slab/installation.ts new file mode 100644 index 00000000..862a3caf --- /dev/null +++ b/src/slab/installation.ts @@ -0,0 +1,36 @@ +import { posix } from 'node:path'; + +export interface SlabInstallation { + platform: NodeJS.Platform; + appPath: string; + executablePath: string; + version?: string; +} + +export interface SlabInstallationIo { + platform: NodeJS.Platform; + homeDir: string; + existsSync(path: string): boolean; +} + +export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | null { + if (io.platform !== 'darwin') return null; + + for (const appPath of [ + '/Applications/SLAB.app', + posix.join(io.homeDir, 'Applications', 'SLAB.app'), + ]) { + const executablePath = posix.join(appPath, 'Contents', 'MacOS', 'SLAB'); + if (io.existsSync(executablePath)) return { platform: io.platform, appPath, executablePath }; + } + + return null; +} + +export function isSlabInstalled(io: SlabInstallationIo): boolean { + return findSlabInstallation(io) !== null; +} + +export function slabControlEndpoint(homeDir: string): string { + return posix.join(homeDir, '.slab', 'run', 'slab-bridge.sock'); +} diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts new file mode 100644 index 00000000..09185c74 --- /dev/null +++ b/src/slab/launch.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConfigError } from '../errors.js'; +import { launchSlab } from './launch.js'; + +const helloResult = { protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }; +const app = { + platform: 'darwin' as const, + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', +}; + +describe('SLAB launch', () => { + it('uses an already-running preliminary app without requiring installation discovery', async () => { + const io = { + findInstallation: vi.fn(() => { throw new Error('must not discover'); }), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockResolvedValue(helloResult), + wait: vi.fn(), + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.findInstallation).not.toHaveBeenCalled(); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('fails closed when no installed app is available after the control socket is unavailable', async () => { + const io = { + findInstallation: vi.fn(() => null), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockRejectedValue(new Error('control socket unavailable')), + wait: vi.fn(), + now: vi.fn(), + }; + + await expect(launchSlab(io)).rejects.toBeInstanceOf(ConfigError); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('does not launch when hello reports an invalid control response', async () => { + const responseError = new Error('SLAB control response is invalid'); + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockRejectedValue(responseError), + wait: vi.fn(), + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).rejects.toBe(responseError); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('opens only the installed normal app and waits for hello', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => false), + launch: vi.fn(async () => {}), + hello: vi.fn() + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockResolvedValue(helloResult), + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.launch).toHaveBeenCalledWith('/Applications/SLAB.app'); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('does not relaunch an already-running installed app while waiting for its control socket', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => true), + launch: vi.fn(async () => {}), + hello: vi.fn() + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockResolvedValue(helloResult), + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.launch).not.toHaveBeenCalled(); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('times out after five seconds when the owner-scoped control socket never becomes ready', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => true), + launch: vi.fn(async () => {}), + hello: vi.fn(async () => { throw new Error('control socket unavailable'); }), + wait: vi.fn(async () => {}), + now: vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).rejects.toMatchObject({ hint: 'Open SLAB and retry the browser command.' }); + expect(io.launch).not.toHaveBeenCalled(); + expect(io.wait).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/slab/launch.ts b/src/slab/launch.ts new file mode 100644 index 00000000..418699b6 --- /dev/null +++ b/src/slab/launch.ts @@ -0,0 +1,69 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { promisify } from 'node:util'; +import { ConfigError } from '../errors.js'; +import { SlabBridgeClient, SlabProtocolError } from './bridge-client.js'; +import { findSlabInstallation, slabControlEndpoint, type SlabInstallation } from './installation.js'; +import type { SlabHelloResult } from './protocol.js'; + +const execFile = promisify(execFileCallback); +const CONTROL_READY_TIMEOUT_MS = 5_000; + +export interface SlabLaunchIo { + findInstallation(): SlabInstallation | null; + isRunning(appPath: string): boolean | Promise; + launch(appPath: string): Promise; + hello(): Promise; + wait(): Promise; + now(): number; +} + +function isControlUnavailable(error: unknown): boolean { + if (error instanceof SlabProtocolError) return false; + return !(error instanceof Error && error.message.startsWith('SLAB control response')); +} + +export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { + try { + return await io.hello(); + } catch (error) { + if (!isControlUnavailable(error)) throw error; + } + + const installation = io.findInstallation(); + if (!installation) { + throw new ConfigError('SLAB is not installed and its control endpoint is unavailable.', 'Open a preliminary SLAB build, or install the official SLAB.app.'); + } + if (!await io.isRunning(installation.appPath)) await io.launch(installation.appPath); + + const deadline = io.now() + CONTROL_READY_TIMEOUT_MS; + for (;;) { + try { + return await io.hello(); + } catch (error) { + if (!isControlUnavailable(error)) throw error; + if (io.now() >= deadline) throw new ConfigError('SLAB control endpoint did not become ready within five seconds.', 'Open SLAB and retry the browser command.'); + await io.wait(); + } + } +} + +export function createSlabLaunchIo(): SlabLaunchIo { + const endpoint = slabControlEndpoint(homedir()); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + isRunning: async () => execFile('pgrep', ['-x', 'SLAB']).then(() => true, () => false), + launch: async appPath => { await execFile('open', [appPath]); }, + hello: async () => { + const client = await SlabBridgeClient.connect(endpoint, { timeoutMs: 1_000 }); + try { + return await client.hello(); + } finally { + await client.close(); + } + }, + wait: () => new Promise(resolve => setTimeout(resolve, 100)), + now: Date.now, + }; +} diff --git a/src/slab/protocol.ts b/src/slab/protocol.ts new file mode 100644 index 00000000..a0ee0ad2 --- /dev/null +++ b/src/slab/protocol.ts @@ -0,0 +1,198 @@ +import { inspect } from 'node:util'; + +export const SLAB_PROTOCOL_VERSION = 1; +export const SLAB_MAX_CONTROL_LINE_BYTES = 64 * 1024; + +export const SLAB_ERROR_CODES = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', +] as const; + +export type SlabErrorCode = (typeof SLAB_ERROR_CODES)[number]; + +export const SLAB_ERROR_MESSAGES: Record = { + INVALID_REQUEST: 'Invalid JSON, framing, shape, size, or params', + INCOMPATIBLE_PROTOCOL: 'Client range does not include v1', + PROFILE_NOT_FOUND: 'Requested profile is unavailable', + ATTACH_FAILED: 'Browser could not create the attachment', + AUTHENTICATION_FAILED: 'CDP IPC credential was missing or wrong', + CONNECTION_NOT_FOUND: 'A non-release operation referenced an unknown lease', +}; + +const SUCCESS_KEYS = ['id', 'ok', 'result'] as const; +const ERROR_KEYS = ['id', 'ok', 'error'] as const; +const ERROR_OBJ_KEYS = ['code', 'message'] as const; +const HELLO_RESULT_KEYS = ['protocolVersion', 'browserVersion', 'browserPid', 'profiles'] as const; +const ATTACH_RESULT_KEYS = ['connectionId', 'profile', 'transport'] as const; +const TRANSPORT_KEYS = ['kind', 'endpoint', 'credential'] as const; +const PROFILE_KEYS = ['id', 'displayName'] as const; + +export class SlabCredential { + readonly #value: string; + + constructor(value: string) { + this.#value = value; + } + + reveal(): string { + return this.#value; + } + + toString(): string { + return '[REDACTED]'; + } + + toJSON(): string { + return '[REDACTED]'; + } + + [inspect.custom](): string { + return '[REDACTED]'; + } +} + +export interface SlabProfileInfo { + id: string; + displayName: string; +} + +export interface SlabHelloResult { + protocolVersion: number; + browserVersion: string; + browserPid: number; + profiles: SlabProfileInfo[]; +} + +export interface SlabAttachTransport { + kind: 'cdp-ipc'; + endpoint: string; + credential: SlabCredential; +} + +export interface SlabAttachResult { + connectionId: string; + profile: SlabProfileInfo; + transport: SlabAttachTransport; +} + +export type SlabControlSuccess = { id: string; ok: true; result: unknown }; +export type SlabControlFailure = { id: string; ok: false; error: { code: SlabErrorCode; message: string } }; +export type SlabControlResponse = SlabControlSuccess | SlabControlFailure; + +export class SlabProtocolShapeError extends Error { + constructor(kind: string) { + super(`SLAB control ${kind}`); + this.name = 'SlabProtocolShapeError'; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertExactKeys(obj: Record, allowed: readonly string[]): void { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) throw new SlabProtocolShapeError('response has unknown fields'); + } + for (const key of allowed) { + if (!Object.hasOwn(obj, key)) throw new SlabProtocolShapeError('response has unknown fields'); + } +} + +export function isValidUtf8(bytes: Buffer): boolean { + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return true; + } catch { + return false; + } +} + +export function parseControlResponse(line: string): SlabControlResponse { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new SlabProtocolShapeError('response is invalid JSON'); + } + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + if (typeof value.id !== 'string') throw new SlabProtocolShapeError('response id is unexpected'); + if (value.ok === true) { + assertExactKeys(value, SUCCESS_KEYS); + return { id: value.id, ok: true, result: value.result }; + } + if (value.ok === false) { + assertExactKeys(value, ERROR_KEYS); + if (!isPlainObject(value.error)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value.error, ERROR_OBJ_KEYS); + if (typeof value.error.code !== 'string' || typeof value.error.message !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (!SLAB_ERROR_CODES.includes(value.error.code as SlabErrorCode)) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { + id: value.id, + ok: false, + error: { code: value.error.code as SlabErrorCode, message: value.error.message }, + }; + } + throw new SlabProtocolShapeError('response has unknown fields'); +} + +function parseProfile(value: unknown): SlabProfileInfo { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, PROFILE_KEYS); + if (typeof value.id !== 'string' || typeof value.displayName !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { id: value.id, displayName: value.displayName }; +} + +export function parseHelloResult(value: unknown): SlabHelloResult { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, HELLO_RESULT_KEYS); + if (value.protocolVersion !== SLAB_PROTOCOL_VERSION) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (typeof value.browserVersion !== 'string' || typeof value.browserPid !== 'number' || !Number.isInteger(value.browserPid)) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (!Array.isArray(value.profiles)) throw new SlabProtocolShapeError('response has unknown fields'); + return { + protocolVersion: SLAB_PROTOCOL_VERSION, + browserVersion: value.browserVersion, + browserPid: value.browserPid, + profiles: value.profiles.map(parseProfile), + }; +} + +export function parseAttachResult(value: unknown): SlabAttachResult { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, ATTACH_RESULT_KEYS); + if (typeof value.connectionId !== 'string') throw new SlabProtocolShapeError('response has unknown fields'); + if (!isPlainObject(value.transport)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value.transport, TRANSPORT_KEYS); + if (value.transport.kind !== 'cdp-ipc') throw new SlabProtocolShapeError('response has unknown fields'); + if (typeof value.transport.endpoint !== 'string' || typeof value.transport.credential !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { + connectionId: value.connectionId, + profile: parseProfile(value.profile), + transport: { + kind: 'cdp-ipc', + endpoint: value.transport.endpoint, + credential: new SlabCredential(value.transport.credential), + }, + }; +} + +export function parseReleaseResult(value: unknown): null { + if (value !== null) throw new SlabProtocolShapeError('response has unknown fields'); + return null; +} diff --git a/src/slab/release-key.ts b/src/slab/release-key.ts new file mode 100644 index 00000000..d1046150 --- /dev/null +++ b/src/slab/release-key.ts @@ -0,0 +1,24 @@ +import { verify } from 'node:crypto'; + +// Trust anchor for the signed SLAB release manifest. This public key is safe to +// embed; the matching private key stays in the official release environment. +export const SLAB_RELEASE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAR0ZysgfDP6qRNlsKV3AZBsNnV78ZhD55RAhWDYykmeg= +-----END PUBLIC KEY----- +`; + +export interface SlabReleaseManifest { + url: string; + sha256: string; + signature: string; +} + +export function verifySlabReleaseManifest(manifest: SlabReleaseManifest): boolean { + if (!SLAB_RELEASE_PUBLIC_KEY) return false; + return verify( + null, + Buffer.from(`${manifest.url}\n${manifest.sha256}`), + SLAB_RELEASE_PUBLIC_KEY, + Buffer.from(manifest.signature, 'base64'), + ); +} diff --git a/src/slab/status.test.ts b/src/slab/status.test.ts new file mode 100644 index 00000000..3a925815 --- /dev/null +++ b/src/slab/status.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { inspectSlabStatus, slabStatusHasHello } from './status.js'; + +const hello = { protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }; +const installation = { + platform: 'darwin' as const, + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', +}; + +describe('SLAB setup status', () => { + it.each([ + ['preliminary-running', true], + ['installed-running', true], + ['installed-not-running', false], + ['not-installed', false], + ] as const)('recognizes whether %s completed the control hello', (status, expected) => { + expect(slabStatusHasHello(status)).toBe(expected); + }); + + it('reports a control-ready app without requiring a signed installation', async () => { + const io = { + findInstallation: vi.fn(() => null), + hello: vi.fn().mockResolvedValue(hello), + }; + + await expect(inspectSlabStatus(io)).resolves.toBe('preliminary-running'); + expect(io.findInstallation).toHaveBeenCalledOnce(); + }); + + it('reports an installed normal app that is already running', async () => { + await expect(inspectSlabStatus({ + findInstallation: () => installation, + hello: async () => hello, + })).resolves.toBe('installed-running'); + }); + + it('reports a missing app when its control socket is unavailable', async () => { + await expect(inspectSlabStatus({ + findInstallation: () => null, + hello: async () => { throw new Error('control socket unavailable'); }, + })).resolves.toBe('not-installed'); + }); +}); diff --git a/src/slab/status.ts b/src/slab/status.ts new file mode 100644 index 00000000..f8d66edf --- /dev/null +++ b/src/slab/status.ts @@ -0,0 +1,41 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { SlabBridgeClient } from './bridge-client.js'; +import { findSlabInstallation, slabControlEndpoint, type SlabInstallation } from './installation.js'; +import type { SlabHelloResult } from './protocol.js'; + +export type SlabSetupStatus = 'preliminary-running' | 'installed-running' | 'installed-not-running' | 'not-installed'; + +export interface SlabStatusIo { + findInstallation(): SlabInstallation | null; + hello(): Promise; +} + +export async function inspectSlabStatus(io: SlabStatusIo = createSlabStatusIo()): Promise { + const installation = io.findInstallation(); + try { + await io.hello(); + return installation ? 'installed-running' : 'preliminary-running'; + } catch { + return installation ? 'installed-not-running' : 'not-installed'; + } +} + +export function slabStatusHasHello(status: SlabSetupStatus): boolean { + return status === 'preliminary-running' || status === 'installed-running'; +} + +export function createSlabStatusIo(): SlabStatusIo { + const endpoint = slabControlEndpoint(homedir()); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + hello: async () => { + const client = await SlabBridgeClient.connect(endpoint, { timeoutMs: 1_000 }); + try { + return await client.hello(); + } finally { + await client.close(); + } + }, + }; +} diff --git a/src/update-check.ts b/src/update-check.ts index 04dee7c5..49ecf14c 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -117,7 +117,7 @@ function buildUpdateNotices({ cliVersion, cache, now }: NoticeInputs): NoticeLin ) { lines.extension = `\n Runtime update available: v${currentExtensionVersion} → v${latestExtensionVersion}\n` + - ` Update the ${PRODUCT_DISPLAY_NAME} Cloak runtime from AgentR release artifacts.\n`; + ` Update the ${PRODUCT_DISPLAY_NAME} Cloak runtime from official release artifacts.\n`; } return lines; } diff --git a/src/update.ts b/src/update.ts index 6029e165..b6e45215 100644 --- a/src/update.ts +++ b/src/update.ts @@ -25,7 +25,7 @@ export function buildUpgradeCommand(spec: string = `${PACKAGE_NAME}@latest`): Up } /** - * Notice for the separately-shipped Cloak runtime/extension, which `npm install -g` + * Notice for the separately-shipped SLAB runtime, which `npm install -g` * does NOT update. Returns the notice text when a newer runtime is known, else * undefined. Currently dormant until update-check URLs are enabled upstream. */ diff --git a/tests/e2e/slab-alpha-install.test.ts b/tests/e2e/slab-alpha-install.test.ts new file mode 100644 index 00000000..31091f98 --- /dev/null +++ b/tests/e2e/slab-alpha-install.test.ts @@ -0,0 +1,54 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { constants } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SlabBridgeClient } from '../../src/slab/bridge-client.js'; +import { createSlabInstallerIo, installSlabMacos } from '../../src/slab/install.js'; +import { findSlabInstallation, slabControlEndpoint } from '../../src/slab/installation.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); +}); + +describe.skipIf(process.env.WEBCMD_LIVE_SLAB_ALPHA !== '1')('SLAB alpha installer live gate', () => { + it('installs from the explicit alpha manifest into an isolated home and answers hello', async () => { + const manifestUrl = process.env.WEBCMD_SLAB_ALPHA_MANIFEST_URL; + if (!manifestUrl) throw new Error('WEBCMD_SLAB_ALPHA_MANIFEST_URL is required when WEBCMD_LIVE_SLAB_ALPHA=1'); + if (findSlabInstallation({ platform: 'darwin', homeDir: homedir(), existsSync })) { + throw new Error('Refusing to run live alpha installer against an existing daily SLAB app'); + } + + const testHome = await mkdtemp(join(tmpdir(), 'webcmd-slab-alpha-home-')); + tempDirs.push(testHome); + + const installation = await installSlabMacos({ + ...createSlabInstallerIo(), + homeDir: testHome, + access: async (path, mode) => { + if (path === '/Applications' && mode === constants.W_OK) { + const error = new Error('permission denied') as Error & { code?: string }; + error.code = 'EACCES'; + throw error; + } + await access(path, mode); + }, + }, { + manifestUrl, + launchAfterInstall: true, + }); + + expect(installation.appPath).toBe(join(testHome, 'Applications', 'SLAB.app')); + + const client = await SlabBridgeClient.connect(slabControlEndpoint(testHome), { timeoutMs: 5_000 }); + try { + const hello = await client.hello(); + expect(hello.protocolVersion).toBe(1); + } finally { + await client.close(); + } + }, 180_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3dbce496..1e4a2f1b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.ts'], - exclude: ['src/browser/runtime/local-cloak/browser-run.test.ts'], sequence: { groupOrder: 0 }, }, }, @@ -32,6 +31,7 @@ export default defineConfig({ 'tests/e2e/plugin-management.test.ts', 'tests/e2e/adapter-authoring-parity.test.ts', 'tests/e2e/article-download-pipeline.test.ts', + 'tests/e2e/slab-alpha-install.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/cloak-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts',