From b0f17ec84f073b4c1c457086535407d13a92384d Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 2 Sep 2026 10:43:22 +0530 Subject: [PATCH] Revert "feat(browser): support custom Chromium binaries with isolated profiles (#462)" This reverts commit fde64e295575b02ccfaed15dfd78156d642c3806. --- CHANGELOG.md | 2 - NOTICE | 54 - PRIVACY.md | 6 +- TESTING.md | 21 +- docs/cli-reference.mdx | 6 +- docs/troubleshooting.mdx | 16 - src/browser.test.ts | 46 +- src/browser/browser-binary.test.ts | 50 - src/browser/browser-binary.ts | 64 - src/browser/command-catalog.test.ts | 8 +- src/browser/command-catalog.ts | 13 +- src/browser/daemon-lifecycle.ts | 28 +- src/browser/errors.ts | 2 +- src/browser/google-chrome.test.ts | 58 - src/browser/google-chrome.ts | 68 - src/browser/humanizer/actionability.ts | 343 ----- src/browser/humanizer/config.ts | 254 ---- src/browser/humanizer/elementhandle.ts | 541 ------- src/browser/humanizer/index.ts | 937 ------------ src/browser/humanizer/keyboard.ts | 214 --- src/browser/humanizer/mouse.ts | 213 --- src/browser/humanizer/page.test.ts | 367 ----- src/browser/humanizer/page.ts | 15 - src/browser/humanizer/scroll.ts | 190 --- src/browser/profile.test.ts | 4 +- src/browser/profile.ts | 5 +- src/browser/protocol.ts | 2 - .../runtime/configured-provider.test.ts | 74 - src/browser/runtime/configured-provider.ts | 22 - .../runtime/local-cloak/browser-run.test.ts | 24 +- .../runtime/local-cloak/profiles.test.ts | 7 - src/browser/runtime/local-cloak/profiles.ts | 3 +- .../runtime/local-cloak/provider.test.ts | 5 - src/browser/runtime/local-cloak/provider.ts | 11 +- .../local-cloak/session-manager.test.ts | 51 - .../runtime/local-cloak/session-manager.ts | 21 +- .../__fixtures__/attach.response.json | 23 - .../local-slab/__fixtures__/errors.json | 110 -- .../__fixtures__/hello.response.json | 20 - .../__fixtures__/release.response.json | 30 - src/browser/runtime/local-slab/actions.ts | 559 ------- .../runtime/local-slab/attachment.test.ts | 107 -- src/browser/runtime/local-slab/attachment.ts | 60 - .../local-slab/dependency-boundary.test.ts | 46 - src/browser/runtime/local-slab/downloads.ts | 29 - src/browser/runtime/local-slab/network.ts | 150 -- src/browser/runtime/local-slab/profiles.ts | 24 - src/browser/runtime/local-slab/provider.ts | 204 --- .../local-slab/runtime-selection.test.ts | 169 --- .../local-slab/session-manager.test.ts | 224 --- .../runtime/local-slab/session-manager.ts | 1308 ----------------- src/cli.test.ts | 8 +- src/cli.ts | 16 +- src/daemon.ts | 6 +- src/doctor.test.ts | 399 ++--- src/doctor.ts | 111 +- src/errors.test.ts | 2 +- src/hosted/browser-args.test.ts | 10 +- src/hosted/browser-args.ts | 16 +- src/hosted/config.test.ts | 37 - src/hosted/config.ts | 33 +- src/hosted/setup.test.ts | 454 +----- src/hosted/setup.ts | 187 +-- src/slab/bridge-client.test.ts | 371 ----- src/slab/bridge-client.ts | 201 --- src/slab/cdp-ipc-transport.test.ts | 229 --- src/slab/cdp-ipc-transport.ts | 222 --- src/slab/contract-parity.test.ts | 134 -- src/slab/control-bridge.test.ts | 43 - src/slab/control-bridge.ts | 39 - src/slab/install.test.ts | 222 --- src/slab/install.ts | 208 --- src/slab/installation.test.ts | 35 - src/slab/installation.ts | 36 - src/slab/launch.test.ts | 107 -- src/slab/launch.ts | 69 - src/slab/protocol.ts | 198 --- src/slab/release-key.ts | 24 - src/slab/status.test.ts | 44 - src/slab/status.ts | 41 - src/update-check.ts | 2 +- src/update.ts | 2 +- tests/e2e/slab-alpha-install.test.ts | 54 - vitest.config.ts | 2 +- 84 files changed, 262 insertions(+), 10108 deletions(-) delete mode 100644 src/browser/browser-binary.test.ts delete mode 100644 src/browser/browser-binary.ts delete mode 100644 src/browser/google-chrome.test.ts delete mode 100644 src/browser/google-chrome.ts delete mode 100644 src/browser/humanizer/actionability.ts delete mode 100644 src/browser/humanizer/config.ts delete mode 100644 src/browser/humanizer/elementhandle.ts delete mode 100644 src/browser/humanizer/index.ts delete mode 100644 src/browser/humanizer/keyboard.ts delete mode 100644 src/browser/humanizer/mouse.ts delete mode 100644 src/browser/humanizer/page.test.ts delete mode 100644 src/browser/humanizer/page.ts delete mode 100644 src/browser/humanizer/scroll.ts delete mode 100644 src/browser/runtime/configured-provider.test.ts delete mode 100644 src/browser/runtime/configured-provider.ts delete mode 100644 src/browser/runtime/local-slab/__fixtures__/attach.response.json delete mode 100644 src/browser/runtime/local-slab/__fixtures__/errors.json delete mode 100644 src/browser/runtime/local-slab/__fixtures__/hello.response.json delete mode 100644 src/browser/runtime/local-slab/__fixtures__/release.response.json delete mode 100644 src/browser/runtime/local-slab/actions.ts delete mode 100644 src/browser/runtime/local-slab/attachment.test.ts delete mode 100644 src/browser/runtime/local-slab/attachment.ts delete mode 100644 src/browser/runtime/local-slab/dependency-boundary.test.ts delete mode 100644 src/browser/runtime/local-slab/downloads.ts delete mode 100644 src/browser/runtime/local-slab/network.ts delete mode 100644 src/browser/runtime/local-slab/profiles.ts delete mode 100644 src/browser/runtime/local-slab/provider.ts delete mode 100644 src/browser/runtime/local-slab/runtime-selection.test.ts delete mode 100644 src/browser/runtime/local-slab/session-manager.test.ts delete mode 100644 src/browser/runtime/local-slab/session-manager.ts delete mode 100644 src/slab/bridge-client.test.ts delete mode 100644 src/slab/bridge-client.ts delete mode 100644 src/slab/cdp-ipc-transport.test.ts delete mode 100644 src/slab/cdp-ipc-transport.ts delete mode 100644 src/slab/contract-parity.test.ts delete mode 100644 src/slab/control-bridge.test.ts delete mode 100644 src/slab/control-bridge.ts delete mode 100644 src/slab/install.test.ts delete mode 100644 src/slab/install.ts delete mode 100644 src/slab/installation.test.ts delete mode 100644 src/slab/installation.ts delete mode 100644 src/slab/launch.test.ts delete mode 100644 src/slab/launch.ts delete mode 100644 src/slab/protocol.ts delete mode 100644 src/slab/release-key.ts delete mode 100644 src/slab/status.test.ts delete mode 100644 src/slab/status.ts delete mode 100644 tests/e2e/slab-alpha-install.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 295d0c47..5e3de9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ ### 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. @@ -12,7 +11,6 @@ ### 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 a7992cf0..ad99d791 100644 --- a/NOTICE +++ b/NOTICE @@ -10,57 +10,3 @@ 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 b4b18bcd..fa1d163e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,12 +1,10 @@ # webcmd Privacy -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 webcmd-managed CloakBrowser runtime communicates only with the local Webcmd daemon on `localhost:9777`. 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`. Custom browser selections keep their own local profile directories and do not overwrite the managed Cloak profiles. +Trace artifacts, cache files, plugins, user adapters, and site memory are stored under `~/.webcmd`. ## Local site-memory seed lookup diff --git a/TESTING.md b/TESTING.md index 8f305342..fb53b2f2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,10 +11,6 @@ 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 @@ -33,23 +29,12 @@ 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 ``` -## 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 +## Cloak Runtime Smoke Run: ```bash -npx vitest run --project unit src/doctor.test.ts src/hosted/setup.test.ts +npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts ``` -These checks cover bundled Cloak fallback, explicit Cloak, custom absolute -browser paths, `setup --status`, and doctor output for the selected browser. +The first run may download the CloakBrowser Chromium binary. Browser-backed tests no longer require a Chrome extension. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 80e8091d..3e12bb9b 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 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. +Local browser commands use Cloak. 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 the selected local browser runtime and daemon connectivity. | +| `doctor` | Diagnose browser bridge 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,8 +185,6 @@ 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 b1c2afa0..07502fbc 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -7,8 +7,6 @@ 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 @@ -144,17 +142,3 @@ 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 bbf8c23e..7b2549f3 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,6 +1,3 @@ -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'; @@ -9,20 +6,11 @@ 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'); @@ -162,7 +150,6 @@ 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', @@ -181,38 +168,7 @@ describe('BrowserBridge state', () => { const bridge = new BrowserBridge(); - 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 }); - } + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready'); }); 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 deleted file mode 100644 index 86344032..00000000 --- a/src/browser/browser-binary.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 2f997496..00000000 --- a/src/browser/browser-binary.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 076d77df..99ed85c9 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -56,11 +56,10 @@ describe('browserCommandCatalog', () => { expect(() => browserOptionValueParser('verify', 'trace')?.('invalid')).toThrow(/off, on, retain-on-failure/); }); - it('allows either stable page selector for bind and limits run to program options', () => { + it('requires a stable page id 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: false }), - expect.objectContaining({ name: 'targetId', required: false }), + expect.objectContaining({ name: 'page', required: true }), expect.objectContaining({ name: 'verbose', type: 'boolean' }), ]); expect(commands.get('run')?.options.map(option => option.name)).toEqual([ @@ -72,9 +71,6 @@ 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 47d2ac3b..a4443248 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' || option.name === 'targetId' ? 'id' + const valueName = option.name === 'page' ? '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' || optionName === 'targetId')) { + if (commandPath === 'bind' && optionName === 'page') { return (value: string): string => { - const id = value.trim(); - if (!id) throw new InvalidArgumentError(`--${optionName === 'targetId' ? 'target-id' : 'page'} must be a non-empty id`); - return id; + const page = value.trim(); + if (!page) throw new InvalidArgumentError('--page must be a non-empty stable page id'); + return page; }; } if (optionName === 'snapshotMode' && commandPath === 'run') return runSnapshotModeParser; @@ -180,8 +180,7 @@ 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'), - option('targetId', 'Native CDP target id for an explicitly acquired page'), + option('page', 'Stable page id returned by tabs', { required: true }), 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 197cb263..214f3380 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -6,7 +6,6 @@ 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'; @@ -128,7 +127,6 @@ 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; @@ -173,29 +171,14 @@ 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 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); - } + 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'); } const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); @@ -203,11 +186,6 @@ 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( @@ -228,7 +206,7 @@ function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string) if (health.state === 'no-runtime') { return new BrowserConnectError( 'Browser runtime is not ready', - 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', + 'Run `webcmd daemon restart`. If CloakBrowser is downloading its browser binary, wait for it to finish and retry.', 'runtime-not-ready', ); } diff --git a/src/browser/errors.ts b/src/browser/errors.ts index fb68ad70..faa55a0d 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}` : ''), - 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', + 'Run `webcmd daemon restart`. If this is the first browser-backed command, wait for CloakBrowser to finish installing its browser binary, then retry.', 'runtime-not-ready', ); case 'command-failed': diff --git a/src/browser/google-chrome.test.ts b/src/browser/google-chrome.test.ts deleted file mode 100644 index 42d21a78..00000000 --- a/src/browser/google-chrome.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 3be2c357..00000000 --- a/src/browser/google-chrome.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index dec45b19..00000000 --- a/src/browser/humanizer/actionability.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * 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 deleted file mode 100644 index 55c81ecb..00000000 --- a/src/browser/humanizer/config.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * 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 deleted file mode 100644 index d87ce00a..00000000 --- a/src/browser/humanizer/elementhandle.ts +++ /dev/null @@ -1,541 +0,0 @@ -/** - * 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 deleted file mode 100644 index b3c42887..00000000 --- a/src/browser/humanizer/index.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * 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 deleted file mode 100644 index cb1d6ff5..00000000 --- a/src/browser/humanizer/keyboard.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * 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 deleted file mode 100644 index 277f0ac1..00000000 --- a/src/browser/humanizer/mouse.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * 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 deleted file mode 100644 index 15946256..00000000 --- a/src/browser/humanizer/page.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -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 deleted file mode 100644 index cd7684ce..00000000 --- a/src/browser/humanizer/page.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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 deleted file mode 100644 index d9594693..00000000 --- a/src/browser/humanizer/scroll.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * 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 354accaa..52020797 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(false); + expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(true); }); 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 browser profiles are available.', + 'No profile matches "__audit_nope__". No Cloak 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 b4bd31c0..857a3dec 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 } from './runtime/local-cloak/profiles.js'; +import { normalizeProfileId, resolveCloakProfileDir } from './runtime/local-cloak/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -121,6 +121,7 @@ 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 }; } @@ -172,7 +173,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 browser profiles are available.`, + `No profile matches "${name}". No Cloak 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 00010ab1..0dde149e 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -35,8 +35,6 @@ 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 deleted file mode 100644 index 93a3548e..00000000 --- a/src/browser/runtime/configured-provider.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 92ab9ae5..00000000 --- a/src/browser/runtime/configured-provider.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 37449dfe..125f5984 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-cloak/browser-run.test.ts @@ -1,7 +1,4 @@ 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'; @@ -10,10 +7,8 @@ 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, @@ -24,36 +19,30 @@ 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, + baseDir: '/tmp/webcmd-browser-run-test', launchPersistentContext, }); - const initialLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - initialPage = initialLease.page; - initialPageId = initialLease.pageId; + initialPage = (await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' })).page; }); 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')); @@ -235,13 +224,14 @@ afterAll(async () => { const original = await dispatchCloakAction(manager, command('run-original', 'run', { source: "await page.setContent('

original

'); return 'original';", })); - const bound = await dispatchCloakAction(manager, command('bind', 'bind', { - page: initialPageId, + const created = await dispatchCloakAction(manager, command('new-tab', 'tabs', { + op: 'new', 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(initialPageId).toEqual(expect.any(String)); + expect(created).toMatchObject({ ok: true, page: expect.any(String) }); }); }); diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-cloak/profiles.test.ts index 81a0c72c..472c9c1d 100644 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ b/src/browser/runtime/local-cloak/profiles.test.ts @@ -19,11 +19,4 @@ 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 9e2df423..0f08e9aa 100644 --- a/src/browser/runtime/local-cloak/profiles.ts +++ b/src/browser/runtime/local-cloak/profiles.ts @@ -4,7 +4,6 @@ import os from 'node:os'; export interface CloakProfileDirOptions { baseDir?: string; - profileNamespace?: string; } export function normalizeProfileId(value: string | undefined | null): string { @@ -21,5 +20,5 @@ export function getWebcmdConfigDir(): string { export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { const safeProfileId = normalizeProfileId(profileId); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), opts.profileNamespace ?? 'cloak', 'profiles', safeProfileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'cloak', 'profiles', safeProfileId); } diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 13a98318..bd436bad 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -165,11 +165,6 @@ 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 35ced9c2..6b6d599a 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -10,9 +10,6 @@ import { export interface LocalCloakRuntimeProviderOptions { baseDir?: string; - profileNamespace?: string; - executablePath?: string; - runtimeName?: 'cloak' | 'chrome' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } @@ -28,11 +25,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, }); this.manager = new CloakSessionManager({ - baseDir: opts.baseDir, - profileNamespace: opts.profileNamespace, - executablePath: opts.executablePath, - launchPersistentContext: opts.launchPersistentContext, - launchBackgroundPersistentContext: opts.launchBackgroundPersistentContext, + ...opts, hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() )), @@ -43,7 +36,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, - runtimeName: this.opts.runtimeName ?? 'cloak', + 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 b50b679e..ab1a9f21 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -171,7 +171,6 @@ describe('CloakSessionManager', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - vi.unstubAllEnvs(); }); it('launches one persistent context per profile and reuses named sessions', async () => { @@ -190,56 +189,6 @@ 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 9271dc64..8778add2 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -4,10 +4,7 @@ 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'; @@ -15,7 +12,6 @@ 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; @@ -157,8 +153,6 @@ export class SessionWindowConflictError extends CliError { export interface CloakSessionManagerOptions { baseDir?: string; - profileNamespace?: string; - executablePath?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; activateBackgroundContext?: typeof activateDarwinBackgroundContext; @@ -728,23 +722,14 @@ export class CloakSessionManager { } private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const userDataDir = resolveCloakProfileDir(profileId, { - baseDir: this.opts.baseDir, - profileNamespace: this.opts.profileNamespace, - }); + const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); fs.mkdirSync(userDataDir, { recursive: true }); - configureCloakBrowserBinary(this.opts.executablePath); const launchOptions = { userDataDir, headless: false, humanize: true, - ...(this.opts.executablePath ? { launchOptions: { executablePath: this.opts.executablePath } } : {}), }; - // 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 + const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' ? 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 deleted file mode 100644 index 9d71c4d5..00000000 --- a/src/browser/runtime/local-slab/__fixtures__/attach.response.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 38062206..00000000 --- a/src/browser/runtime/local-slab/__fixtures__/errors.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "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 deleted file mode 100644 index 33f302b1..00000000 --- a/src/browser/runtime/local-slab/__fixtures__/hello.response.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index 382453dd..00000000 --- a/src/browser/runtime/local-slab/__fixtures__/release.response.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 7e90e42c..00000000 --- a/src/browser/runtime/local-slab/actions.ts +++ /dev/null @@ -1,559 +0,0 @@ -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 deleted file mode 100644 index 3a818917..00000000 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 764af2b9..00000000 --- a/src/browser/runtime/local-slab/attachment.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index a920b63c..00000000 --- a/src/browser/runtime/local-slab/dependency-boundary.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 5cabb10a..00000000 --- a/src/browser/runtime/local-slab/downloads.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 05d4fa46..00000000 --- a/src/browser/runtime/local-slab/network.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index fca205c1..00000000 --- a/src/browser/runtime/local-slab/profiles.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 0829e860..00000000 --- a/src/browser/runtime/local-slab/provider.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 1d805cce..00000000 --- a/src/browser/runtime/local-slab/runtime-selection.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 1c34f8c0..00000000 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index 637aa4a8..00000000 --- a/src/browser/runtime/local-slab/session-manager.ts +++ /dev/null @@ -1,1308 +0,0 @@ -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 c9bd0fce..c845c0d7 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', 'targetId', 'verbose', 'format', 'json']); + expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', '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 an explicit stable page id or CDP target id', async () => { + it('binds only an explicit stable page id', async () => { const program = createProgram('', ''); await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', 'page-123']); @@ -2222,10 +2222,6 @@ 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 2960fa89..7ee521c3 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,20 +1217,12 @@ 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() : ''; - 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 }), - }); + 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 runCommand = withBrowserVerbose(new Command('run') diff --git a/src/daemon.ts b/src/daemon.ts index 49bef89c..f7c25310 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,11 +3,9 @@ import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; import { PKG_VERSION } from './version.js'; import { createDaemonServer } from './daemon/server.js'; -import { loadWebcmdConfig } from './hosted/config.js'; -import { createConfiguredLocalBrowserRuntimeProvider } from './browser/runtime/configured-provider.js'; +import { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js'; -const config = loadWebcmdConfig(); -const provider = createConfiguredLocalBrowserRuntimeProvider(config.mode === 'local' ? config : undefined); +const provider = new LocalCloakRuntimeProvider(); 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 de443c67..55f2568b 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -3,7 +3,6 @@ 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, @@ -33,6 +32,9 @@ 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, @@ -67,13 +69,6 @@ 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-')); @@ -85,15 +80,18 @@ 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: '1.0.0', - bundledVersion: '1.0.0', + version: '146.0.7680.177.5', + bundledVersion: '146.0.7680.177.5', tier: 'free', platform: 'linux-x64', binaryPath: managedBinaryPath, installed: true, - cacheDir: managedBinaryDir, - downloadUrl: 'https://example.test/download', + 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', }); // Doctor always runs live connectivity. Tests that want connect to fail override. mockConnect.mockResolvedValue({ @@ -184,11 +182,11 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - binary: { installed: true, path: '/Applications/Cloak Chromium.app' }, + binary: { installed: true, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, issues: [], })); - expect(text).toContain('[OK] Browser binary: installed at /Applications/Cloak Chromium.app'); + expect(text).toContain('[OK] Browser binary: installed at /home/test/.cloakbrowser/chromium-1.0.0/chrome'); }); it('renders the browser binary status line as MISSING when not installed', () => { @@ -196,78 +194,11 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - 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: [], + 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 ...'], })); - 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'); + expect(text).toContain('[MISSING] Browser binary: not installed (/home/test/.cloakbrowser/chromium-1.0.0/chrome)'); }); it('renders connectivity OK when live test succeeds', () => { @@ -361,7 +292,7 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Default browser profile is not active: work (profile-default)'), + expect.stringContaining('Default Cloak profile is not active: work (profile-default)'), ])); expect(report.issues.join('\n')).toContain('fall back to the only active profile: active-profile'); } finally { @@ -370,102 +301,6 @@ 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' } }); @@ -479,45 +314,7 @@ describe('doctor report rendering', () => { ])); }); - 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 () => { + it('uses runtime-neutral readiness hints when the runtime is disconnected', async () => { mockConnect.mockRejectedValueOnce(new Error('runtime unavailable')); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', @@ -747,22 +544,26 @@ describe('doctor report rendering', () => { expect(report.profiles).toHaveLength(2); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Multiple browser profiles are connected'), + expect.stringContaining('Multiple Chrome profiles are connected'), ])); }); - describe('Cloak browser binary status', () => { - it('reports an installed browser binary without altering a generic connectivity failure', async () => { + describe('#239 — missing browser binary', () => { + it('reports the binary as installed and does not alter the generic failure message when present', 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).toMatchObject({ installed: true, path: managedBinaryPath }); + expect(report.binary?.installed).toBe(true); expect(report.issues).toEqual(expect.arrayContaining([ expect.stringContaining('Browser connectivity test failed: page.goto: Target page, context or browser has been closed'), ])); - expect(report.issues.join('\n')).not.toContain('CloakBrowser Chromium is not installed'); + 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'); }); it('reports a missing binary without claiming a download was attempted', async () => { @@ -787,7 +588,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('Check network access to the download URL above.'); + expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH'); expect(issueText).not.toContain('could not be downloaded'); expect(issueText).not.toContain('download failed'); }); @@ -809,14 +610,74 @@ 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 failed browser binary checks as warnings', async () => { - mockBinaryInfo.mockImplementationOnce(() => { throw new Error('corrupt CloakBrowser metadata'); }); + 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'); + }); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); const report = await runBrowserDoctor(); @@ -829,13 +690,55 @@ describe('doctor report rendering', () => { expect(text).not.toContain('Everything looks good!'); }); - 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'); + 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', + }); - expect(checkBrowserBinary()).toMatchObject({ installed: true, path: managedBinaryPath }); - await checkConnectivity(); - expect(mockEnsureBinary).toHaveBeenCalledOnce(); + 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 }); + } }); it('rejects a managed non-executable binary on POSIX', () => { @@ -859,12 +762,9 @@ 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); @@ -873,6 +773,25 @@ 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 0b93da2b..695cac3c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -17,8 +17,6 @@ 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; @@ -38,6 +36,8 @@ 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,7 +50,6 @@ export type DoctorReport = { runtimeFlaky?: boolean; runtimeName?: string; runtimeVersion?: string; - selectedBrowser?: LocalBrowserConfig; binary?: BrowserBinaryStatus; connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; @@ -83,16 +82,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. + * `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). */ -export function checkBrowserBinary(browser: LocalBrowserConfig = { kind: 'cloak' }): BrowserBinaryStatus { - if (browser.kind === 'custom' || browser.kind === 'chrome') { - return { - installed: isLaunchableFile(browser.executablePath), - path: browser.executablePath, - }; +export function checkBrowserBinary(): BrowserBinaryStatus { + const override = process.env.CLOAKBROWSER_BINARY_PATH; + if (override) { + return { installed: isLaunchableFile(override), path: override, override: true }; } try { const info = binaryInfo(); @@ -100,25 +99,23 @@ export function checkBrowserBinary(browser: LocalBrowserConfig = { kind: 'cloak' installed: info.installed && isLaunchableFile(info.binaryPath), path: info.binaryPath, downloadUrl: info.downloadUrl, + override: false, }; } catch (err) { - return { installed: undefined, path: 'unknown', error: getErrorMessage(err) }; + return { installed: undefined, path: 'unknown', error: getErrorMessage(err), override: false }; } } /** * Test connectivity by attempting a real browser command. */ -export async function checkConnectivity( - browser: LocalBrowserConfig = { kind: 'cloak' }, - opts?: { timeout?: number }, -): Promise { +export async function checkConnectivity(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. - if (browser.kind === 'cloak') await ensureBinary(); + 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.'); @@ -152,18 +149,11 @@ export async function checkConnectivity( } export async function runBrowserDoctor(opts: DoctorOptions = {}): 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(selectedBrowser); - const binary = selectedBrowser.kind === 'slab' ? undefined : checkBrowserBinary(selectedBrowser); + const connectivity = await checkConnectivity(); + const binary = checkBrowserBinary(); // Single status read *after* connectivity side-effects settle. const health = await getDaemonHealth(); @@ -176,22 +166,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` + - (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.'), + ' Open that Chrome profile and make sure Cloak is enabled.', ); } else { issues.push( - `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') + + 'Daemon is running but the Cloak runtime is not connected.\n' + + ' Make sure Chrome/Chromium is open and Cloak is enabled.\n' + ' If Chrome is already open, try: webcmd daemon restart', ); } @@ -253,7 +231,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise.', ); @@ -272,7 +250,6 @@ 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-slab/session-manager.js'); + const { SessionWindowConflictError } = await import('./browser/runtime/local-cloak/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 721b6713..cc8241b0 100644 --- a/src/hosted/browser-args.test.ts +++ b/src/hosted/browser-args.test.ts @@ -38,23 +38,15 @@ describe('hosted browser argument surface', () => { expect(() => parse(['--session', 'session_work', 'browser', 'fork', 'linkedin/search'])).toThrow(); }); - it('requires exactly one supported page selector for bind', () => { + it('requires a stable page id 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 d73a1e24..eead1e27 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -138,25 +138,11 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost ); } - const result = parsed ?? { + return 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 1461136b..e4bbfada 100644 --- a/src/hosted/config.test.ts +++ b/src/hosted/config.test.ts @@ -29,7 +29,6 @@ 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' }, }); }); @@ -135,44 +134,8 @@ 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 38da3bd6..34a5dfa1 100644 --- a/src/hosted/config.ts +++ b/src/hosted/config.ts @@ -9,17 +9,10 @@ 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'; @@ -62,7 +55,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, browser: readLocalBrowser((parsed as { browser?: unknown }).browser) }; + return { mode: 'local', updatedAt: parsed.updatedAt }; } if ( parsed.mode === 'hosted' @@ -85,7 +78,7 @@ function parseConfig(raw: string): WebcmdConfig { }, }; } - return makeLocalConfig(new Date(0)); + return { mode: 'local', updatedAt: new Date(0).toISOString() }; } export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { @@ -93,7 +86,7 @@ export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { try { return parseConfig(readFileSync(getConfigPath(io), 'utf-8') as string); } catch { - return makeLocalConfig(new Date(0)); + return { mode: 'local', updatedAt: new Date(0).toISOString() }; } } @@ -114,14 +107,10 @@ export function saveWebcmdConfig(config: WebcmdConfig, io: ConfigIo = {}): void export type LocalWebcmdConfig = Extract; export type HostedWebcmdConfig = Extract; -export function makeLocalConfig( - now: Date = new Date(), - browser: LocalBrowserConfig = { kind: 'cloak' }, -): LocalWebcmdConfig { +export function makeLocalConfig(now: Date = new Date()): LocalWebcmdConfig { return { mode: 'local', updatedAt: now.toISOString(), - browser, }; } @@ -206,20 +195,6 @@ 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 e78c645f..0532dce4 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -5,10 +5,9 @@ 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, makeLocalConfig, saveWebcmdConfig } from './config.js'; +import { getConfigPath } 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)), '../..'); @@ -21,7 +20,7 @@ afterEach(async () => { describe('webcmd setup', () => { it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); - const answers = ['local', 'cloak']; + const answers = ['local']; const messages: string[] = []; const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; @@ -30,7 +29,6 @@ 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); }, }); @@ -38,63 +36,10 @@ 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']; @@ -157,7 +102,6 @@ describe('webcmd setup', () => { argv: ['--mode', 'local'], isTTY: false, question, - fetchDaemonStatus: async () => null, write: (message) => { messages.push(message); }, }); @@ -166,385 +110,10 @@ 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('reinstalls an existing SLAB app through the signed installer path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); - const events: string[] = []; - - await expect(runHostedSetup({ - env: { WEBCMD_CONFIG_DIR: tempDir }, - argv: ['--mode', 'local', '--browser', 'slab'], - isTTY: false, - platform: 'darwin', - 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('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', - 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', - 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', - 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', - 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', - 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[] = []; @@ -637,15 +206,11 @@ 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 () => answers.shift() ?? '', - fetchDaemonStatus: async () => null, - resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', - resolveGoogleChromeExecutable: async () => undefined, + question: async () => 'local', }).then(code => { settled = true; return code; @@ -706,19 +271,6 @@ 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 0f854801..1b102cd5 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,28 +1,15 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; -import { constants, existsSync } from 'node:fs'; -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 } from '../slab/install.js'; -import type { SlabInstallation } from '../slab/installation.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, @@ -40,23 +27,11 @@ 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; - 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 [--browser ] [--api-key ]`; +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--api-key ]`; const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; const SETUP_HELP = [ `${CLI_COMMAND} setup`, @@ -64,9 +39,7 @@ 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, @@ -93,10 +66,6 @@ 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; @@ -122,38 +91,11 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, ); } - 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; - } + saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io); 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) { @@ -209,113 +151,18 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } function canPrompt(io: SetupIo): boolean { - if (io.isTTY !== undefined) return io.isTTY; if (io.question) return true; + if (io.isTTY !== undefined) return io.isTTY; return process.stdin.isTTY === true && process.stdout.isTTY === true; } -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.'); - 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 } { +function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; 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') { @@ -340,32 +187,12 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; 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, --browser, --api-key, --status, --help\n${SETUP_USAGE}`, + `valid flags for \`setup\`: --mode, --api-key, --help\n${SETUP_USAGE}`, ); } - 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}`, - ); + return { mode, apiKey }; } function hostedAccountLabel(body: unknown): string | undefined { diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts deleted file mode 100644 index a98798da..00000000 --- a/src/slab/bridge-client.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -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 deleted file mode 100644 index 6dd9e099..00000000 --- a/src/slab/bridge-client.ts +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index 3bebd9fa..00000000 --- a/src/slab/cdp-ipc-transport.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index f59bd28c..00000000 --- a/src/slab/cdp-ipc-transport.ts +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index 7ffafb1a..00000000 --- a/src/slab/contract-parity.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index e15bbf90..00000000 --- a/src/slab/control-bridge.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 3f4f28f8..00000000 --- a/src/slab/control-bridge.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index ff3e88c3..00000000 --- a/src/slab/install.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index 31bf3ff9..00000000 --- a/src/slab/install.ts +++ /dev/null @@ -1,208 +0,0 @@ -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; -} - -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 io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); - if (await io.bundleId(stagingPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); - 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 deleted file mode 100644 index 6db86573..00000000 --- a/src/slab/installation.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 862a3caf..00000000 --- a/src/slab/installation.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 09185c74..00000000 --- a/src/slab/launch.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 418699b6..00000000 --- a/src/slab/launch.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index a0ee0ad2..00000000 --- a/src/slab/protocol.ts +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index d1046150..00000000 --- a/src/slab/release-key.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 3a925815..00000000 --- a/src/slab/status.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index f8d66edf..00000000 --- a/src/slab/status.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 49ecf14c..04dee7c5 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 official release artifacts.\n`; + ` Update the ${PRODUCT_DISPLAY_NAME} Cloak runtime from AgentR release artifacts.\n`; } return lines; } diff --git a/src/update.ts b/src/update.ts index b6e45215..6029e165 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 SLAB runtime, which `npm install -g` + * Notice for the separately-shipped Cloak runtime/extension, 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 deleted file mode 100644 index 31091f98..00000000 --- a/tests/e2e/slab-alpha-install.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 1e4a2f1b..3dbce496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.ts'], + exclude: ['src/browser/runtime/local-cloak/browser-run.test.ts'], sequence: { groupOrder: 0 }, }, }, @@ -31,7 +32,6 @@ 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',