diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e103bee..8c3ef68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,8 +203,8 @@ jobs: - name: Run smoke tests run: npx vitest run --project smoke --reporter=verbose - windows-cloak-smoke: - name: Windows real Cloak smoke + windows-slab-smoke: + name: Windows SLAB smoke if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 30 @@ -229,8 +229,8 @@ jobs: shell: pwsh run: ./scripts/collect-ci-diagnostics.ps1 -SelfTest - - name: Run real Cloak lifecycle smoke - run: npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts tests/e2e/cloak-session-concurrency.test.ts + - name: Run SLAB lifecycle smoke + run: npx vitest run --project e2e tests/e2e/slab-runtime.test.ts tests/e2e/slab-session-concurrency.test.ts - name: Collect sanitized diagnostics if: failure() @@ -242,5 +242,5 @@ jobs: - uses: actions/upload-artifact@v4 if: failure() with: - name: windows-cloak-diagnostics - path: artifacts/windows-cloak/** + name: windows-slab-diagnostics + path: artifacts/windows-slab/** diff --git a/.github/workflows/e2e-headed.yml b/.github/workflows/e2e-headed.yml index 1fbeccab..6988e22d 100644 --- a/.github/workflows/e2e-headed.yml +++ b/.github/workflows/e2e-headed.yml @@ -59,7 +59,7 @@ jobs: run: | xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ npx vitest run --project e2e-fixed-port --project e2e \ - tests/e2e/browser-tabs.test.ts tests/e2e/cloak-runtime.test.ts \ + tests/e2e/browser-tabs.test.ts tests/e2e/slab-runtime.test.ts \ --reporter=verbose - name: Run browser e2e tests (macOS) @@ -68,5 +68,5 @@ jobs: WEBCMD_E2E: '0' run: | npx vitest run --project e2e-fixed-port --project e2e \ - tests/e2e/browser-tabs.test.ts tests/e2e/cloak-runtime.test.ts \ + tests/e2e/browser-tabs.test.ts tests/e2e/slab-runtime.test.ts \ --reporter=verbose diff --git a/PRIVACY.md b/PRIVACY.md index 8601c41d..dc926b86 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # webcmd Privacy -The webcmd-managed CloakBrowser runtime communicates only with the local Webcmd daemon on `localhost:9777`. +The webcmd-managed SLAB browser 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 browser data to AgentR. Commands run locally, and command output is printed to the local CLI process. diff --git a/TESTING.md b/TESTING.md index 4c286374..09eae906 100644 --- a/TESTING.md +++ b/TESTING.md @@ -32,12 +32,12 @@ npx vitest run --project unit src/convention-audit.test.ts src/runtime-copy.test npm run test:plugin -- --reporter=verbose ``` -## Cloak Runtime Smoke +## SLAB Runtime Smoke Run: ```bash -npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts +npx vitest run --project e2e tests/e2e/slab-runtime.test.ts ``` -The first run may download the CloakBrowser Chromium binary. Browser-backed tests no longer require a Chrome extension. +The live run requires an installed SLAB browser. Browser-backed tests no longer require a Chrome extension. diff --git a/benchmarks/README.md b/benchmarks/README.md index 323a7e20..76595993 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -85,9 +85,14 @@ webcmd ███████████████████ Run a controlled, sequential browser-tool benchmark. Keep task data and local evidence private. +Legacy boundary: this private benchmark harness intentionally keeps its pinned +CloakBrowser dependency so historical AXI/agent-browser/dev-browser/libretto +runs remain comparable. It is not the webcmd local runtime and is not part of +the SLAB browser distribution path. + ## Workflow -1. Confirm `uv`, the selected controller CLI, selected browser tool, and judge authentication are available (`GOOGLE_API_KEY` for `google`, `OPENAI_API_KEY` for `openai`, or a ChatGPT-authenticated `codex login` for `codex`). AXI, agent-browser, and dev-browser runs also require CloakBrowser. +1. Confirm `uv`, the selected controller CLI, selected browser tool, and judge authentication are available (`GOOGLE_API_KEY` for `google`, `OPENAI_API_KEY` for `openai`, or a ChatGPT-authenticated `codex login` for `codex`). AXI, agent-browser, and dev-browser runs also require the legacy benchmark CloakBrowser package. 2. Ask the user to choose a controller, model, benchmark, and task selection if any is missing. 3. Start with one task unless the user explicitly requests a larger or full run. 4. Run `scripts/run_eval.py` with the explicit choices. @@ -166,7 +171,7 @@ Its `estimated_api_cost_usd` is an API-equivalent comparison metric, not a subscription charge; the manifest records `billing_mode` as `chatgpt_subscription`. -For AXI with one dedicated CloakBrowser process and profile per task: +For AXI with one dedicated legacy benchmark CloakBrowser process and profile per task: ```bash uv run python benchmarks/scripts/run_eval.py \ @@ -178,7 +183,7 @@ uv run python benchmarks/scripts/run_eval.py \ --tools chrome-devtools-axi ``` -For agent-browser with one dedicated CloakBrowser process and profile per task: +For agent-browser with one dedicated legacy benchmark CloakBrowser process and profile per task: ```bash uv run python benchmarks/scripts/run_eval.py \ @@ -190,7 +195,7 @@ uv run python benchmarks/scripts/run_eval.py \ --tools agent-browser ``` -For dev-browser with one dedicated CloakBrowser process and profile per task: +For dev-browser with one dedicated legacy benchmark CloakBrowser process and profile per task: ```bash npm install -g dev-browser @@ -212,10 +217,10 @@ its `bash` and `read` tools. `dev-browser install` is required because it installs the daemon's Playwright and QuickJS dependencies. It may also download dev-browser's Chromium, but the benchmark does not use that browser: the task-private shim always connects -dev-browser to the task's dedicated CloakBrowser CDP endpoint. +dev-browser to the task's dedicated legacy benchmark CloakBrowser CDP endpoint. -For Libretto Browser Tools with Codex or Pi and one dedicated CloakBrowser per -task: +For Libretto Browser Tools with Codex or Pi and one dedicated legacy benchmark +CloakBrowser per task: ```bash npm ci @@ -234,7 +239,7 @@ Pi, it registers the equivalent native Pi custom tools directly; use `--controller pi --model openai-codex/gpt-5.6-sol`. Both expose only `browser_open`, `browser_exec`, `browser_snapshot`, `browser_status`, and `browser_close`. `browser_connect` is disabled so the agent cannot leave the task's dedicated -CloakBrowser. +legacy benchmark CloakBrowser. Use `--stealth-view official` only with `Stealth_Bench_V1`. Never add a parallel flag or publish `results/`. diff --git a/benchmarks/scripts/axi_runtime.py b/benchmarks/scripts/axi_runtime.py index 08b1d1ee..458891a6 100644 --- a/benchmarks/scripts/axi_runtime.py +++ b/benchmarks/scripts/axi_runtime.py @@ -16,13 +16,14 @@ def find_cloak_package() -> Path: + """Locate the private benchmark harness's legacy CloakBrowser package.""" override = os.environ.get("BROWSER_BENCH_CLOAK_PACKAGE") if override: candidates = [Path(override).expanduser()] else: npm = shutil.which("npm") if not npm: - raise RuntimeError("npm is required to locate CloakBrowser") + raise RuntimeError("npm is required to locate the legacy benchmark CloakBrowser") result = subprocess.run( [npm, "root", "-g"], text=True, @@ -47,7 +48,7 @@ def find_cloak_package() -> Path: if package.get("name") == "cloakbrowser": return candidate.resolve() raise RuntimeError( - "CloakBrowser was not found. Install it globally, keep Webcmd installed, " + "Legacy benchmark CloakBrowser was not found. Install it globally, keep Webcmd installed, " "or set BROWSER_BENCH_CLOAK_PACKAGE to its package directory." ) @@ -82,13 +83,13 @@ async def _launch_options(profile_dir: Path, env: dict[str, str]) -> dict: except asyncio.TimeoutError: process.kill() await process.wait() - raise RuntimeError("timed out resolving CloakBrowser launch options") + raise RuntimeError("timed out resolving legacy benchmark CloakBrowser launch options") if process.returncode: - raise RuntimeError(f"CloakBrowser launch configuration failed: {stderr.decode(errors='replace').strip()}") + raise RuntimeError(f"Legacy benchmark CloakBrowser launch configuration failed: {stderr.decode(errors='replace').strip()}") try: return json.loads(stdout) except json.JSONDecodeError as error: - raise RuntimeError("CloakBrowser returned invalid launch configuration") from error + raise RuntimeError("Legacy benchmark CloakBrowser returned invalid launch configuration") from error async def _wait_for_devtools_port(profile_dir: Path) -> int: @@ -102,14 +103,14 @@ async def _wait_for_devtools_port(profile_dir: Path) -> int: except (FileNotFoundError, IndexError, ValueError): pass await asyncio.sleep(0.05) - raise RuntimeError("timed out waiting for CloakBrowser's CDP endpoint") + raise RuntimeError("timed out waiting for legacy benchmark CloakBrowser's CDP endpoint") async def _launch_cloak(profile_dir: Path, env: dict[str, str]) -> int: options = await _launch_options(profile_dir, env) executable = Path(str(options.get("executablePath") or "")) if not executable.is_file(): - raise RuntimeError(f"CloakBrowser executable is missing: {executable}") + raise RuntimeError(f"Legacy benchmark CloakBrowser executable is missing: {executable}") arguments = [ *map(str, options.get("args") or []), "--password-store=basic", @@ -123,7 +124,7 @@ async def _launch_cloak(profile_dir: Path, env: dict[str, str]) -> int: marker = "/Contents/MacOS/" executable_text = str(executable) if marker not in executable_text: - raise RuntimeError("CloakBrowser executable is not inside a macOS app bundle") + raise RuntimeError("Legacy benchmark CloakBrowser executable is not inside a macOS app bundle") app_path = executable_text.split(marker, 1)[0] command = ["/usr/bin/open", "-g", "-n", app_path, "--args", *arguments] else: @@ -137,7 +138,7 @@ async def _launch_cloak(profile_dir: Path, env: dict[str, str]) -> int: ) _, stderr = await process.communicate() if process.returncode: - raise RuntimeError(f"CloakBrowser failed to launch: {stderr.decode(errors='replace').strip()}") + raise RuntimeError(f"Legacy benchmark CloakBrowser failed to launch: {stderr.decode(errors='replace').strip()}") return await _wait_for_devtools_port(profile_dir) diff --git a/benchmarks/scripts/run_controller.py b/benchmarks/scripts/run_controller.py index 93200bcc..67cebdcf 100644 --- a/benchmarks/scripts/run_controller.py +++ b/benchmarks/scripts/run_controller.py @@ -242,15 +242,15 @@ def _build_prompt(tool: Tool, session: str, shots_dir: Path, task: str) -> str: - Every shell command must begin with exactly `npx -y chrome-devtools-axi`; use no substitutions, redirections, pipes, or other executables. - Do not use Web search, browser MCPs, Playwright, Puppeteer, curl, wget, raw HTTP, or any non-AXI automation tool. - Use the `$chrome-devtools-axi` skill for AXI usage guidance. -- The AXI session and its dedicated CloakBrowser connection are already configured in the environment; do not start another browser.""" +- The AXI session and its dedicated legacy benchmark CloakBrowser connection are already configured in the environment; do not start another browser.""" elif tool == "agent-browser": tool_rules = """- Use only `agent-browser` for browser interaction. - Use one `agent-browser` command per shell invocation; use no substitutions, redirections, pipes, or other executables. - Do not use `batch`, `close`, connection/profile flags, or multiple agent-browser commands in one shell invocation. -- Never pass a URL to `agent-browser read`; use `agent-browser open URL` and then `agent-browser read` so all page traffic stays inside CloakBrowser. +- Never pass a URL to `agent-browser read`; use `agent-browser open URL` and then `agent-browser read` so all page traffic stays inside the legacy benchmark CloakBrowser. - Do not use Web search, browser MCPs, Playwright, Puppeteer, curl, wget, raw HTTP, or any non-agent-browser automation tool. - Use the `$agent-browser` skill for agent-browser usage guidance. -- The agent-browser session and its dedicated CloakBrowser connection are already configured in the environment; do not start, connect, configure, or close another browser.""" +- The agent-browser session and its dedicated legacy benchmark CloakBrowser connection are already configured in the environment; do not start, connect, configure, or close another browser.""" elif tool == "dev-browser": tool_rules = f"""- Use only `dev-browser` for browser interaction. - Invoke `dev-browser` with one quoted heredoc per shell command. The heredoc body must contain only the sandboxed JavaScript described by the `$dev-browser` skill. @@ -258,12 +258,12 @@ def _build_prompt(tool: Tool, session: str, shots_dir: Path, task: str) -> str: - Use no substitutions, pipes, extra redirections, or other executables. - Do not use Web search, browser MCPs, external Playwright, Puppeteer, curl, wget, raw HTTP, or any non-dev-browser automation tool. - Use the `$dev-browser` skill for dev-browser usage guidance. -- The dev-browser command is already pinned to this task's dedicated CloakBrowser connection; do not start, connect, configure, or close another browser. +- The dev-browser command is already pinned to this task's dedicated legacy benchmark CloakBrowser connection; do not start, connect, configure, or close another browser. - Save screenshots with `await saveScreenshot(await page.screenshot(), "{session}-step_001.png")`, then `{session}-step_002.png`, and so on.""" elif tool == "libretto": tool_rules = """- Use only the Libretto MCP tools `browser_open`, `browser_exec`, `browser_snapshot`, `browser_status`, and `browser_close` for browser interaction. - Do not use shell commands, Web Search, other MCP servers or tools, Playwright outside `browser_exec`, Puppeteer, curl, wget, or raw HTTP. -- The Libretto provider is already pinned to this task's dedicated CloakBrowser; open it with `browser_open` and do not configure another browser. +- The Libretto provider is already pinned to this task's dedicated legacy benchmark CloakBrowser; open it with `browser_open` and do not configure another browser. - Reuse the session ID returned by `browser_open` and close it with `browser_close` when the task is complete.""" else: tool_rules = f"""- Use only `webcmd` raw-browser commands for task execution. Do not use Web Search, browser MCPs, external Playwright, Puppeteer, curl, wget, raw HTTP, adapters, fetch commands, plugins, or any non-Webcmd automation tool. diff --git a/benchmarks/tests/test_controller.py b/benchmarks/tests/test_controller.py index 5e65b046..daa09561 100644 --- a/benchmarks/tests/test_controller.py +++ b/benchmarks/tests/test_controller.py @@ -355,7 +355,7 @@ def test_agent_browser_prompt_uses_installed_skill_and_dedicated_cloak(tmp_path) assert "`$agent-browser` skill" in prompt assert "only `agent-browser`" in prompt - assert "dedicated CloakBrowser" in prompt + assert "dedicated legacy benchmark CloakBrowser" in prompt assert "Do not use `batch`" in prompt assert "one `agent-browser` command per shell invocation" in prompt assert "Never pass a URL to `agent-browser read`" in prompt @@ -374,7 +374,7 @@ def test_dev_browser_prompt_uses_installed_skill_quoted_heredoc_and_task_screens assert "`$dev-browser` skill" in prompt assert "only `dev-browser`" in prompt assert "quoted heredoc" in prompt - assert "dedicated CloakBrowser" in prompt + assert "dedicated legacy benchmark CloakBrowser" in prompt assert "saveScreenshot" in prompt assert "session-1-step_001.png" in prompt assert "Webcmd" not in prompt @@ -395,7 +395,7 @@ def test_libretto_prompt_uses_only_native_tools_on_dedicated_cloak(tmp_path): ): assert f"`{tool}`" in prompt assert "browser_connect" not in prompt - assert "dedicated CloakBrowser" in prompt + assert "dedicated legacy benchmark CloakBrowser" in prompt assert "screenshot: true" in prompt assert "shell commands" in prompt assert "$libretto" not in prompt diff --git a/bun.lock b/bun.lock index 713ba223..0af6e434 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", - "cloakbrowser": "0.4.5", "commander": "^14.0.3", "js-yaml": "^4.3.0", "playwright-core": "1.61.1", @@ -254,8 +253,6 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "cloakbrowser": ["cloakbrowser@0.4.5", "", { "dependencies": { "tar": "^7.0.0" }, "peerDependencies": { "mmdb-lib": ">=2.0.0", "playwright-core": ">=1.53.0", "puppeteer-core": ">=21.0.0", "socks-proxy-agent": ">=10.0.0" }, "optionalPeers": ["mmdb-lib", "playwright-core", "puppeteer-core", "socks-proxy-agent"], "bin": { "cloakbrowser": "dist/cli.js" } }, "sha512-FLEOoznA/d4SbUT1zi8BiMqH+xt/eCoCWeLHnEC7Wn1WBGR31QHSh93PSfS/WcovGaxQxxOQPKtF8+1IkdEp1g=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 956a414b..342ca5fc 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -71,7 +71,7 @@ webcmd --profile work session close \ session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 ``` -Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use SLAB. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs diff --git a/package-lock.json b/package-lock.json index 519b02d0..3795bee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", - "cloakbrowser": "0.4.5", "commander": "^14.0.3", "impit": "0.14.3", "js-yaml": "^4.3.0", @@ -763,18 +762,6 @@ } } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jitl/quickjs-ffi-types": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", @@ -1569,15 +1556,6 @@ "node": ">=18" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", @@ -1593,41 +1571,6 @@ "@colors/colors": "1.5.0" } }, - "node_modules/cloakbrowser": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/cloakbrowser/-/cloakbrowser-0.4.5.tgz", - "integrity": "sha512-FLEOoznA/d4SbUT1zi8BiMqH+xt/eCoCWeLHnEC7Wn1WBGR31QHSh93PSfS/WcovGaxQxxOQPKtF8+1IkdEp1g==", - "license": "MIT", - "dependencies": { - "tar": "^7.0.0" - }, - "bin": { - "cloakbrowser": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "mmdb-lib": ">=2.0.0", - "playwright-core": ">=1.53.0", - "puppeteer-core": ">=21.0.0", - "socks-proxy-agent": ">=10.0.0" - }, - "peerDependenciesMeta": { - "mmdb-lib": { - "optional": true - }, - "playwright-core": { - "optional": true - }, - "puppeteer-core": { - "optional": true - }, - "socks-proxy-agent": { - "optional": true - } - } - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -2519,27 +2462,6 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2929,22 +2851,6 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3376,15 +3282,6 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } } } } diff --git a/package.json b/package.json index d2037b67..40e9ed44 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "test:plugin": "vitest run --project plugin", "test:all": "vitest run", "test:e2e": "vitest run --project e2e-fixed-port --project e2e", - "gate:cloak-sessions": "WEBCMD_LIVE_CLOAK=1 vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", + "gate:slab-sessions": "WEBCMD_LIVE_SLAB=1 vitest run --project e2e tests/e2e/slab-session-concurrency.test.ts", "check-community-plugins": "tsx scripts/sync-community-plugins.ts --check", "advise:listing-id-pairing": "node scripts/check-listing-id-pairing.mjs", "check:package-bin": "node scripts/check-package-bin.mjs", @@ -102,7 +102,6 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", - "cloakbrowser": "0.4.5", "commander": "^14.0.3", "impit": "0.14.3", "js-yaml": "^4.3.0", diff --git a/plugins/facebook/feed.js b/plugins/facebook/feed.js index 59175045..48cb5f9c 100644 --- a/plugins/facebook/feed.js +++ b/plugins/facebook/feed.js @@ -345,7 +345,7 @@ async function getFacebookFeed(page, kwargs) { } catch (err) { throw new CommandExecutionError( `Failed to navigate to facebook feed: ${err instanceof Error ? err.message : err}`, - 'Check that facebook.com is reachable and the Cloak browser session is running.', + 'Check that facebook.com is reachable and the SLAB browser session is running.', ); } @@ -366,7 +366,7 @@ async function getFacebookFeed(page, kwargs) { } if (payload.status === 'auth') { - throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active Cloak browser session before retrying.'); + throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active SLAB browser session before retrying.'); } if (payload.rows.length > 0) { diff --git a/plugins/facebook/search.js b/plugins/facebook/search.js index 42ee83f9..9f2fab38 100644 --- a/plugins/facebook/search.js +++ b/plugins/facebook/search.js @@ -149,7 +149,7 @@ async function searchFacebook(page, kwargs) { } catch (err) { throw new CommandExecutionError( `Failed to open facebook search: ${err instanceof Error ? err.message : err}`, - 'Check that facebook.com is reachable and the Cloak browser session is running.', + 'Check that facebook.com is reachable and the SLAB browser session is running.', ); } @@ -167,7 +167,7 @@ async function searchFacebook(page, kwargs) { throw new CommandExecutionError('facebook search returned malformed extraction payload'); } if (payload.status === 'auth') { - throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active Cloak browser session before retrying.'); + throw new AuthRequiredError('www.facebook.com', 'Log in to Facebook in the active SLAB browser session before retrying.'); } if (payload.rows.length > 0) return payload.rows; diff --git a/plugins/skyscanner/flights.js b/plugins/skyscanner/flights.js index ac472a80..4a1a04ef 100644 --- a/plugins/skyscanner/flights.js +++ b/plugins/skyscanner/flights.js @@ -186,7 +186,7 @@ cli({ throw new CommandExecutionError('Skyscanner flight extraction returned an unreadable response'); } if (result.blocked) { - throw new AuthRequiredError(HOST, 'Skyscanner requires browser verification. Open this route in CloakBrowser, solve the CAPTCHA, then rerun the command.'); + throw new AuthRequiredError(HOST, 'Skyscanner requires browser verification. Open this route in SLAB, solve the CAPTCHA, then rerun the command.'); } const rows = Array.isArray(result.rows) ? result.rows : []; if (!rows.length) { diff --git a/plugins/ycombinator/companies.js b/plugins/ycombinator/companies.js index 3e8cd583..cb842ace 100644 --- a/plugins/ycombinator/companies.js +++ b/plugins/ycombinator/companies.js @@ -155,7 +155,7 @@ cli({ throw new CommandExecutionError('Y Combinator company extraction returned an unreadable response'); } if (result.blocked) { - throw new AuthRequiredError(HOST, 'Y Combinator blocked anonymous directory access. Open the company directory in CloakBrowser, complete any verification, then rerun the command.'); + throw new AuthRequiredError(HOST, 'Y Combinator blocked anonymous directory access. Open the company directory in SLAB, complete any verification, then rerun the command.'); } const rows = Array.isArray(result.rows) ? result.rows : []; if (!rows.length) { diff --git a/scripts/collect-ci-diagnostics.ps1 b/scripts/collect-ci-diagnostics.ps1 index a9c4dff3..a266dc5a 100644 --- a/scripts/collect-ci-diagnostics.ps1 +++ b/scripts/collect-ci-diagnostics.ps1 @@ -88,7 +88,7 @@ function Get-DaemonLogMetadata { } } -function Test-CloakDiagnosticLogName { +function Test-SlabDiagnosticLogName { param( [Parameter(Mandatory = $true)][string]$Name ) @@ -98,7 +98,7 @@ function Test-CloakDiagnosticLogName { $Name.Equals('LOG.old', [StringComparison]::OrdinalIgnoreCase) } -function Get-CloakLogFileMetadata { +function Get-SlabLogFileMetadata { param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)]$File @@ -172,10 +172,10 @@ function Invoke-DiagnosticsSelfTest { $diagnosticLogNames = @('browser.log', 'debug.log', 'LOG', 'LOG.old') foreach ($name in $diagnosticLogNames) { - Assert-DiagnosticsSelfTest -Condition (Test-CloakDiagnosticLogName -Name $name) -Message "known diagnostic log name was rejected: $name" + Assert-DiagnosticsSelfTest -Condition (Test-SlabDiagnosticLogName -Name $name) -Message "known diagnostic log name was rejected: $name" } foreach ($name in @('Login Data', 'catalog.json', 'debug.txt', 'LOG.bak')) { - Assert-DiagnosticsSelfTest -Condition (-not (Test-CloakDiagnosticLogName -Name $name)) -Message "non-log profile file was accepted: $name" + Assert-DiagnosticsSelfTest -Condition (-not (Test-SlabDiagnosticLogName -Name $name)) -Message "non-log profile file was accepted: $name" } $syntheticFile = [pscustomobject]@{ @@ -184,10 +184,10 @@ function Invoke-DiagnosticsSelfTest { LastWriteTimeUtc = [DateTime]::Parse('2024-01-03T00:00:00Z').ToUniversalTime() SensitiveContent = 'cookie=sensitive-value' } - $fileMetadata = Get-CloakLogFileMetadata -Root ([System.IO.Path]::GetTempPath()) -File $syntheticFile + $fileMetadata = Get-SlabLogFileMetadata -Root ([System.IO.Path]::GetTempPath()) -File $syntheticFile $fileMetadataJson = ConvertTo-Json -InputObject $fileMetadata -Depth 3 - Assert-DiagnosticsSelfTest -Condition (($fileMetadata.PSObject.Properties.Name -join ',') -eq 'relativePath,bytes,lastWriteUtc') -Message 'Cloak file metadata contains a non-allowlisted field' - Assert-DiagnosticsSelfTest -Condition (-not $fileMetadataJson.Contains('sensitive-value')) -Message 'Cloak file contents survived metadata projection' + Assert-DiagnosticsSelfTest -Condition (($fileMetadata.PSObject.Properties.Name -join ',') -eq 'relativePath,bytes,lastWriteUtc') -Message 'SLAB file metadata contains a non-allowlisted field' + Assert-DiagnosticsSelfTest -Condition (-not $fileMetadataJson.Contains('sensitive-value')) -Message 'SLAB file contents survived metadata projection' Write-Host 'Diagnostics self-test passed.' } @@ -198,7 +198,7 @@ if ($SelfTest) { return } -$artifactRoot = Join-Path $PWD 'artifacts/windows-cloak' +$artifactRoot = Join-Path $PWD 'artifacts/windows-slab' New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null $runnerMetadata = [ordered]@{ @@ -248,7 +248,7 @@ try { } $processMetadata = Get-Process -ErrorAction SilentlyContinue | - Where-Object { $_.ProcessName -match '(?i)^(node|chrome|chromium|cloak)' } | + Where-Object { $_.ProcessName -match '(?i)^(node|chrome|chromium|slab)' } | ForEach-Object { [ordered]@{ name = $_.ProcessName @@ -260,15 +260,15 @@ $processMetadata = Get-Process -ErrorAction SilentlyContinue | } Write-SanitizedJson -Path (Join-Path $artifactRoot 'process-metadata.json') -Value @($processMetadata) -$cloakRoot = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE '.webcmd/cloak' } else { $null } -$cloakLogMetadata = @() -if ($cloakRoot -and (Test-Path -LiteralPath $cloakRoot)) { - $cloakLogMetadata = Get-ChildItem -LiteralPath $cloakRoot -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { Test-CloakDiagnosticLogName -Name $_.Name } | +$slabRoot = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE '.webcmd/slab' } else { $null } +$slabLogMetadata = @() +if ($slabRoot -and (Test-Path -LiteralPath $slabRoot)) { + $slabLogMetadata = Get-ChildItem -LiteralPath $slabRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { Test-SlabDiagnosticLogName -Name $_.Name } | ForEach-Object { - Get-CloakLogFileMetadata -Root $cloakRoot -File $_ + Get-SlabLogFileMetadata -Root $slabRoot -File $_ } } -Write-SanitizedJson -Path (Join-Path $artifactRoot 'cloak-log-metadata.json') -Value @($cloakLogMetadata) +Write-SanitizedJson -Path (Join-Path $artifactRoot 'slab-log-metadata.json') -Value @($slabLogMetadata) Write-Host "Sanitized diagnostics written to $artifactRoot" diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 70bbff5f..db6cfde6 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -15,9 +15,12 @@ * the main source tree) so that it can run without a build step. */ +import { spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; // ── Completion script content ────────────────────────────────────────────── @@ -73,7 +76,7 @@ function ensureDir(dir) { // ── Main ─────────────────────────────────────────────────────────────────── -function main() { +async function main() { // Skip in CI environments if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) { return; @@ -85,6 +88,8 @@ function main() { return; } + await offerSlabInstall(); + const shell = detectShell(); if (!shell) { // Cannot determine shell; silently skip @@ -154,4 +159,34 @@ function main() { } +async function offerSlabInstall() { + if (process.platform !== 'darwin' || hasSlab()) return; + + console.log('SLAB browser is required for local webcmd commands. Run `webcmd setup` to install it.'); + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + + const answer = await question('Install SLAB now? [Y/n] '); + if (answer.trim() && !answer.trim().toLowerCase().startsWith('y')) return; + + const installer = fileURLToPath(new URL('../dist/src/slab/install-cli.js', import.meta.url)); + const result = spawnSync(process.execPath, [installer, '--consent-granted', '--no-launch'], { + stdio: 'inherit', + env: { ...process.env, WEBCMD_INSTALL_SLAB: '1' }, + }); + if (result.error || result.status) console.error('Warning: SLAB installation did not complete. Run `webcmd setup` to retry.'); +} + +function hasSlab() { + return existsSync('/Applications/SLAB.app/Contents/MacOS/SLAB') + || existsSync(join(homedir(), 'Applications', 'SLAB.app', 'Contents', 'MacOS', 'SLAB')); +} + +function question(prompt) { + const readline = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => readline.question(prompt, (answer) => { + readline.close(); + resolve(answer); + })); +} + main(); diff --git a/skill-src/smart-search/SKILL.src.md b/skill-src/smart-search/SKILL.src.md index cec36919..eefa6170 100644 --- a/skill-src/smart-search/SKILL.src.md +++ b/skill-src/smart-search/SKILL.src.md @@ -43,7 +43,7 @@ webcmd web fetch --url Try fetch once. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. -For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. +For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use SLAB; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. ```bash webcmd --profile work session create diff --git a/skill-src/webcmd-browser/SKILL.src.md b/skill-src/webcmd-browser/SKILL.src.md index 60de9aba..c000f3c0 100644 --- a/skill-src/webcmd-browser/SKILL.src.md +++ b/skill-src/webcmd-browser/SKILL.src.md @@ -39,7 +39,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover - `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. -For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. +For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use SLAB; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash webcmd --profile work session create diff --git a/skill-src/webcmd-browser/references/browser-run-playwright.src.md b/skill-src/webcmd-browser/references/browser-run-playwright.src.md index 3e63323f..d800c117 100644 --- a/skill-src/webcmd-browser/references/browser-run-playwright.src.md +++ b/skill-src/webcmd-browser/references/browser-run-playwright.src.md @@ -132,4 +132,4 @@ Run results include timing fields such as `quickjs_boot_ms`, `client_bundle_init Hosted `browser run` uses the same QuickJS sandbox and the same rules. Only the browser on the far end differs — hosted runs drive a Browser Use browser over CDP rather than local -Cloak. Programs that work locally work hosted; the tables above apply in both modes. +SLAB. Programs that work locally work hosted; the tables above apply in both modes. diff --git a/skills/smart-search/SKILL.md b/skills/smart-search/SKILL.md index 412b3712..c4ea2cf3 100644 --- a/skills/smart-search/SKILL.md +++ b/skills/smart-search/SKILL.md @@ -43,7 +43,7 @@ webcmd web fetch --url Try fetch once. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. -For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. +For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use SLAB; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. ```bash webcmd --profile work session create diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index f972f5b7..ba88233a 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -39,7 +39,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover - `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. -For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. +For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use SLAB; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash webcmd --profile work session create diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index 3e63323f..d800c117 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -132,4 +132,4 @@ Run results include timing fields such as `quickjs_boot_ms`, `client_bundle_init Hosted `browser run` uses the same QuickJS sandbox and the same rules. Only the browser on the far end differs — hosted runs drive a Browser Use browser over CDP rather than local -Cloak. Programs that work locally work hosted; the tables above apply in both modes. +SLAB. Programs that work locally work hosted; the tables above apply in both modes. diff --git a/src/browser.test.ts b/src/browser.test.ts index 7b2549f3..741d4461 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { BrowserBridge, generateStealthJs } from './browser/index.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js'; import { withTimeoutMs } from './runtime.js'; @@ -6,11 +6,16 @@ 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 * as slabInstallation from './slab/installation.js'; afterEach(() => { vi.restoreAllMocks(); }); +beforeEach(() => { + vi.spyOn(slabInstallation, 'isSlabInstalled').mockReturnValue(true); +}); + describe('browser helpers', () => { it('extracts tab entries from string snapshots', () => { const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension'); @@ -304,6 +309,35 @@ describe('BrowserBridge state', () => { }); }); +describe('local browser SLAB preflight', () => { + it('asks again after a previously declined setup install', async () => { + const { PKG_VERSION } = await import('./version.js'); + vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({ + state: 'ready', + status: { + ok: true, pid: 1, uptime: 0, daemonVersion: PKG_VERSION, + runtimeConnected: true, runtimeName: 'SLAB', pending: 0, memoryMB: 0, port: 0, + }, + }); + const install = vi.fn().mockResolvedValue({ + platform: 'darwin' as const, + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', + }); + + await daemonLifecycle.ensureBrowserBridgeReady({ + slab: { installed: () => false, confirm: async () => true, install }, + }); + + expect(install).toHaveBeenCalledOnce(); + }); + + it('fails deterministically without a TTY', async () => { + await expect(daemonLifecycle.ensureBrowserBridgeReady({ + slab: { installed: () => false, interactive: false }, + })).rejects.toMatchObject({ code: 'SLAB_REQUIRED', exitCode: 78 }); + }); +}); + describe('stealth anti-detection', () => { it('generates non-empty JS string', () => { const js = generateStealthJs(); diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 4e3b1560..7eef9318 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -13,10 +13,12 @@ import { } from './daemon-client.js'; import * as daemonLifecycle from './daemon-lifecycle.js'; import { clearDaemonRunContext, getDaemonRunContext, setDaemonRunContext } from '../session-lease.js'; +import { SlabRequiredError } from '../errors.js'; describe('daemon-client', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); + vi.spyOn(daemonLifecycle, 'ensureSlabReadyForBrowserCommand').mockResolvedValue(); }); afterEach(() => { @@ -202,6 +204,20 @@ describe('daemon-client', () => { expect(ids[0]).not.toBe(ids[1]); }); + it('checks SLAB before an already-running daemon can accept a browser command', async () => { + vi.spyOn(daemonLifecycle, 'ensureSlabReadyForBrowserCommand').mockRejectedValue(new SlabRequiredError()); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await expect(sendCommand('snapshot', { session: 'work', surface: 'browser' })).rejects.toMatchObject({ + code: 'SLAB_REQUIRED', exitCode: 78, + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + it('sendCommand binds the active logical run metadata to each daemon operation', async () => { setDaemonRunContext({ runId: 'run_4242_1000_1', diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index da4e6070..b694a9f6 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -11,7 +11,7 @@ import { getDaemonRunContext, type SessionLeaseHolder } from '../session-lease.j import { classifyBrowserError } from './errors.js'; import { profileRouteParams, resolveProfileSelection } from './profile.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js'; -import { ensureBrowserBridgeReady } from './daemon-lifecycle.js'; +import { ensureBrowserBridgeReady, ensureSlabReadyForBrowserCommand } from './daemon-lifecycle.js'; import { isPreDispatchError } from './bridge-readiness.js'; import { fetchDaemonStatus, @@ -124,6 +124,7 @@ async function sendCommandRaw( action: DaemonCommand['action'], params: DaemonCommandParams, ): Promise { + await ensureSlabReadyForBrowserCommand(); const timeoutSeconds = effectiveCommandTimeoutSeconds(params); const deadlineAt = Date.now() + timeoutSeconds * 1000; const rawWindowMode = process.env.WEBCMD_WINDOW; diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 214f3380..dae88d13 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -1,11 +1,15 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; +import { homedir } from 'node:os'; import * as path from 'node:path'; +import { createInterface } from 'node:readline/promises'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; -import { BrowserConnectError } from '../errors.js'; +import { BrowserConnectError, SlabRequiredError } from '../errors.js'; import { PKG_VERSION } from '../version.js'; import { isVerbose } from '../logger.js'; +import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; +import { isSlabInstalled } from '../slab/installation.js'; import { waitForBridgeReady } from './bridge-readiness.js'; import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type DaemonHealth, type DaemonStatus } from './daemon-transport.js'; @@ -27,6 +31,13 @@ export interface EnsureBrowserBridgeReadyResult { spawnedProcess: ChildProcess | null; } +interface SlabPreflight { + installed?: () => boolean; + interactive?: boolean; + confirm?: (prompt: string) => Promise; + install?: () => Promise; +} + export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const parentDir = path.resolve(__dirname, '..'); @@ -117,8 +128,9 @@ export async function restartDaemon(opts: { stopTimeoutMs?: number; startTimeout } export async function ensureBrowserBridgeReady( - opts: { timeoutSeconds?: number; contextId?: string; verbose?: boolean } = {}, + opts: { timeoutSeconds?: number; contextId?: string; verbose?: boolean; slab?: SlabPreflight } = {}, ): Promise { + await ensureSlabReadyForBrowserCommand(opts.slab); const timeoutSeconds = opts.timeoutSeconds && opts.timeoutSeconds > 0 ? opts.timeoutSeconds : 10; const timeoutMs = timeoutSeconds * 1000; const verbose = opts.verbose ?? true; @@ -177,8 +189,8 @@ export async function ensureBrowserBridgeReady( } spawnedProcess = daemonLifecycleHooks.spawnDaemonProcess(); } else if (verbose && (isVerbose() || process.stderr.isTTY)) { - process.stderr.write('⏳ Waiting for Cloak runtime to connect...\n'); - process.stderr.write(' Make sure Chrome or Chromium is open and Cloak is enabled.\n'); + process.stderr.write('⏳ Waiting for SLAB to connect...\n'); + process.stderr.write(' Launch SLAB, then run `webcmd setup` if it needs repair.\n'); } const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); @@ -186,6 +198,29 @@ export async function ensureBrowserBridgeReady( throw browserConnectErrorFromHealth(finalHealth, contextId); } +export async function ensureSlabReadyForBrowserCommand(slab: SlabPreflight = {}): Promise { + const installed = slab.installed ?? (() => isSlabInstalled({ platform: process.platform, homeDir: homedir(), existsSync: fs.existsSync })); + if (installed()) return; + if (slab.interactive ?? (slab.confirm ? true : !!process.stdin.isTTY)) { + const confirm = slab.confirm ?? confirmSlabInstall; + if (await confirm('SLAB is required for local webcmd. Install it now? [Y/n] ')) { + await (slab.install ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + return; + } + } + throw new SlabRequiredError(); +} + +async function confirmSlabInstall(prompt: string): Promise { + const readline = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = (await readline.question(prompt)).trim().toLowerCase(); + return !answer || answer.startsWith('y'); + } finally { + readline.close(); + } +} + function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string): BrowserConnectError { if (health.state === 'profile-required') { return new BrowserConnectError( @@ -199,14 +234,14 @@ function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string) const label = contextId ?? health.status.contextId ?? 'unknown'; return new BrowserConnectError( `Browser profile "${label}" is not connected`, - 'Open the matching Chrome profile and make sure Cloak is enabled, or choose another profile with webcmd profile use .', + 'Launch SLAB, or choose another profile with webcmd profile use .', 'profile-disconnected', ); } if (health.state === 'no-runtime') { return new BrowserConnectError( 'Browser runtime is not ready', - 'Run `webcmd daemon restart`. If CloakBrowser is downloading its browser binary, wait for it to finish and retry.', + 'Install and launch SLAB, then run `webcmd setup` if it needs repair.', 'runtime-not-ready', ); } diff --git a/src/browser/errors.test.ts b/src/browser/errors.test.ts index 9a8f308a..34e60d99 100644 --- a/src/browser/errors.test.ts +++ b/src/browser/errors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { classifyBrowserError, isTransientBrowserError } from './errors.js'; +import { classifyBrowserError, formatBrowserConnectError, isTransientBrowserError } from './errors.js'; describe('classifyBrowserError', () => { it('classifies extension transient errors with 1500ms delay', () => { @@ -69,3 +69,13 @@ describe('isTransientBrowserError (convenience wrapper)', () => { expect(isTransientBrowserError(new Error('Permission denied'))).toBe(false); }); }); + +describe('browser connection copy', () => { + it('guides local runtime failures to SLAB setup', () => { + const error = formatBrowserConnectError('runtime-not-ready'); + + expect(error.hint).toContain('SLAB'); + expect(error.hint).toContain('`webcmd setup`'); + expect(error.hint).not.toContain('Cloak'); + }); +}); diff --git a/src/browser/errors.ts b/src/browser/errors.ts index faa55a0d..d2115cbc 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -127,7 +127,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str case 'extension-not-connected': return new BrowserConnectError( 'Browser runtime is not ready.' + (detail ? `\n\n${detail}` : ''), - 'Run `webcmd daemon restart`. If this is the first browser-backed command, wait for CloakBrowser to finish installing its browser binary, then retry.', + 'Install and launch SLAB, then run `webcmd setup` if it needs repair.', 'runtime-not-ready', ); case 'command-failed': diff --git a/src/browser/runtime/local-cloak/cloak-version.test.ts b/src/browser/runtime/local-cloak/cloak-version.test.ts deleted file mode 100644 index 1c33f12a..00000000 --- a/src/browser/runtime/local-cloak/cloak-version.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// Isolated file: the version cache is module-level and populated by the first -// call in the process, so this must import a fresh copy to observe the read. -describe('resolveCloakBrowserVersion', () => { - it('resolves and reads cloakbrowser/package.json only once per process', async () => { - vi.resetModules(); - const fs = (await import('node:fs')).default; - // Import before spying: loading the module graph reads files of its own, - // and only reads made by resolveCloakBrowserVersion should be counted. - const { resolveCloakBrowserVersion } = await import('./session-manager.js'); - const readFileSync = vi.spyOn(fs, 'readFileSync'); - try { - const first = resolveCloakBrowserVersion(); - const second = resolveCloakBrowserVersion(); - const third = resolveCloakBrowserVersion(); - - expect(second).toBe(first); - expect(third).toBe(first); - expect(readFileSync).toHaveBeenCalledTimes(1); - } finally { - readFileSync.mockRestore(); - } - }); -}); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts deleted file mode 100644 index 4b770be5..00000000 --- a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { Browser, BrowserContext } from 'playwright-core'; -import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext, waitForDevToolsPort } from './darwin-background-launch.js'; - -const options = { - userDataDir: '/tmp/cloak profile', - headless: false, - humanize: true, -}; - -function fakeRuntime() { - const context = { close: vi.fn() } as unknown as BrowserContext; - const browser = { - contexts: vi.fn(() => [context]), - close: vi.fn().mockResolvedValue(undefined), - } as unknown as Browser; - return { browser, context }; -} - -function fakeDependencies(browser: Browser) { - return { - buildLaunchOptions: vi.fn().mockResolvedValue({ - executablePath: '/Applications/Cloak Chromium.app/Contents/MacOS/Chromium', - args: ['--fingerprint=123'], - }), - humanizeBrowser: vi.fn().mockResolvedValue(undefined), - openApplication: vi.fn().mockResolvedValue(undefined), - activateApplication: vi.fn().mockResolvedValue(undefined), - readPort: vi.fn().mockResolvedValue(43123), - connectOverCDP: vi.fn().mockResolvedValue(browser), - terminateProfile: vi.fn().mockResolvedValue(undefined), - removePortFile: vi.fn().mockResolvedValue(undefined), - registerBundle: vi.fn().mockResolvedValue(undefined), - }; -} - -describe('launchDarwinBackgroundPersistentContext', () => { - it('launches the Chromium app without activation and connects through loopback CDP', async () => { - const { browser, context } = fakeRuntime(); - const deps = fakeDependencies(browser); - - const result = await launchDarwinBackgroundPersistentContext(options, deps); - - expect(deps.removePortFile).toHaveBeenCalledWith('/tmp/cloak profile/DevToolsActivePort'); - expect(deps.openApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app', [ - '--fingerprint=123', - '--password-store=basic', - '--use-mock-keychain', - '--disable-popup-blocking', - '--disable-features=DestroyProfileOnBrowserClose', - '--user-data-dir=/tmp/cloak profile', - '--remote-debugging-address=127.0.0.1', - '--remote-debugging-port=0', - 'about:blank', - ]); - expect(deps.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:43123'); - expect(deps.humanizeBrowser).toHaveBeenCalledWith(browser, expect.objectContaining({ humanize: true })); - expect(result).toBe(context); - - await activateDarwinBackgroundContext(result); - expect(deps.activateApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - - await result.close(); - expect(browser.close).toHaveBeenCalledOnce(); - expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir); - }); - - it('fails immediately when the CDP port file misses its deadline', async () => { - await expect(waitForDevToolsPort('/missing/DevToolsActivePort', 0)).rejects.toThrow( - 'Timed out waiting for background Chromium CDP endpoint', - ); - }); - - it('terminates the launched profile when CDP connection fails', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - deps.connectOverCDP.mockRejectedValueOnce(new Error('connect failed')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('connect failed'); - - expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir); - }); - - it('re-registers a stale LaunchServices bundle and retries once on kLSNoExecutableErr', async () => { - const { browser, context } = fakeRuntime(); - const deps = fakeDependencies(browser); - const lsError = new Error( - 'Command failed: /usr/bin/open -g -n /Applications/Cloak Chromium.app --args ...\n' + - 'The application cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain ' + - 'Code=-10827 "kLSNoExecutableErr: The executable is missing"', - ); - deps.openApplication.mockRejectedValueOnce(lsError).mockResolvedValueOnce(undefined); - - const result = await launchDarwinBackgroundPersistentContext(options, deps); - - expect(deps.registerBundle).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - expect(deps.openApplication).toHaveBeenCalledTimes(2); - expect(result).toBe(context); - }); - - it('surfaces a remediation error when re-registering does not fix kLSNoExecutableErr', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - const lsError = new Error('kLSNoExecutableErr: The executable is missing'); - deps.openApplication - .mockRejectedValueOnce(lsError) - .mockRejectedValueOnce(new Error('retry failed: bundle executable is invalid')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow( - /retry failed: bundle executable is invalid[\s\S]*lsregister -f "\/Applications\/Cloak Chromium\.app"/, - ); - - expect(deps.registerBundle).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - expect(deps.openApplication).toHaveBeenCalledTimes(2); - // Never "launched" (both attempts failed before the app came up), so no - // stray terminateProfile call for a process that never started. - expect(deps.terminateProfile).not.toHaveBeenCalled(); - }); - - it('does not attempt lsregister remediation for unrelated open failures', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - deps.openApplication.mockRejectedValue(new Error('some other launch failure')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('some other launch failure'); - - expect(deps.registerBundle).not.toHaveBeenCalled(); - expect(deps.openApplication).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-cloak/darwin-background-launch.ts deleted file mode 100644 index 98ff9837..00000000 --- a/src/browser/runtime/local-cloak/darwin-background-launch.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { execFile } from 'node:child_process'; -import { readFile, rm } from 'node:fs/promises'; -import { posix as path } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { promisify } from 'node:util'; -import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser'; -import type { LaunchPersistentContextOptions } from 'cloakbrowser'; -import { chromium } from 'playwright-core'; -import type { Browser, BrowserContext } from 'playwright-core'; -import { findExactCloakProfileProcesses } from './process-matcher.js'; - -const execFileAsync = promisify(execFile); - -// macOS LaunchServices' registration database for `.app` bundles. A script-driven -// unzip of a new/updated Chromium bundle (rather than a Finder/.pkg install) can -// land outside the triggers that make LS pick it up, leaving `open` unable to -// resolve a bundle that runs fine when executed directly (kLSNoExecutableErr). -const LSREGISTER_PATH = - '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; -const LS_NO_EXECUTABLE_MARKER = 'kLSNoExecutableErr'; - -type Dependencies = { - buildLaunchOptions: typeof buildLaunchOptions; - humanizeBrowser: typeof humanizeBrowser; - openApplication: (appPath: string, args: string[]) => Promise; - activateApplication: (appPath: string) => Promise; - readPort: (portFile: string) => Promise; - connectOverCDP: (endpoint: string) => Promise; - terminateProfile: (userDataDir: string) => Promise; - removePortFile: (portFile: string) => Promise; - /** Force LaunchServices to re-scan a bundle after a stale-cache `open` failure. */ - registerBundle: (appPath: string) => Promise; -}; - -async function openApplication(appPath: string, args: string[]): Promise { - await execFileAsync('/usr/bin/open', ['-g', '-n', appPath, '--args', ...args]); -} - -async function registerBundle(appPath: string): Promise { - await execFileAsync(LSREGISTER_PATH, ['-f', appPath]); -} - -function isStaleLaunchServicesError(err: unknown): boolean { - return err instanceof Error && err.message.includes(LS_NO_EXECUTABLE_MARKER); -} - -/** - * Open the app, retrying once via `lsregister -f` when macOS reports the bundle - * as missing due to a stale LaunchServices cache entry rather than an actually - * missing executable (see #220). - */ -async function openApplicationWithLsRegisterRetry( - deps: Dependencies, - appPath: string, - args: string[], -): Promise { - try { - await deps.openApplication(appPath, args); - } catch (err) { - if (!isStaleLaunchServicesError(err)) throw err; - try { - await deps.registerBundle(appPath); - await deps.openApplication(appPath, args); - } catch (retryError) { - const retryMessage = retryError instanceof Error ? retryError.message : String(retryError); - throw new Error( - `Cloak Chromium bundle exists but macOS LaunchServices has a stale record for it (${LS_NO_EXECUTABLE_MARKER}): ${appPath}\n` + - `Automatic remediation failed: ${retryMessage}\n` + - `Re-registering it automatically did not resolve the issue. Fix it manually with:\n` + - ` ${LSREGISTER_PATH} -f "${appPath}"`, - { cause: retryError }, - ); - } - } -} - -export async function waitForDevToolsPort(portFile: string, timeoutMs = 10_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const port = Number.parseInt((await readFile(portFile, 'utf8')).split('\n')[0], 10); - if (Number.isInteger(port) && port > 0) return port; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - await delay(50); - } - throw new Error('Timed out waiting for background Chromium CDP endpoint'); -} - -async function terminateProfile(userDataDir: string): Promise { - for (const pid of await findExactCloakProfileProcesses(userDataDir)) process.kill(pid, 'SIGTERM'); -} - -const defaultDependencies: Dependencies = { - buildLaunchOptions, - humanizeBrowser, - openApplication, - activateApplication: async appPath => { - await execFileAsync('/usr/bin/open', [appPath]); - }, - readPort: waitForDevToolsPort, - connectOverCDP: endpoint => chromium.connectOverCDP(endpoint), - terminateProfile, - removePortFile: portFile => rm(portFile, { force: true }), - registerBundle, -}; - -const contextActivators = new WeakMap Promise>(); - -export async function activateDarwinBackgroundContext(context: BrowserContext): Promise { - await contextActivators.get(context)?.(); -} - -function appPathFor(executablePath: string): string { - const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`; - const index = executablePath.lastIndexOf(marker); - if (index < 0) throw new Error(`Cloak Chromium executable is not inside a macOS app bundle: ${executablePath}`); - return executablePath.slice(0, index); -} - -export async function launchDarwinBackgroundPersistentContext( - options: LaunchPersistentContextOptions, - deps: Dependencies = defaultDependencies, -): Promise { - const portFile = path.join(options.userDataDir, 'DevToolsActivePort'); - await deps.removePortFile(portFile); - const launchOptions = await deps.buildLaunchOptions(options); - if (!launchOptions.executablePath) throw new Error('Cloak Chromium executable path is missing'); - const appPath = appPathFor(launchOptions.executablePath); - - let browser: Browser | undefined; - let launched = false; - try { - await openApplicationWithLsRegisterRetry(deps, appPath, [ - ...(launchOptions.args ?? []), - '--password-store=basic', - '--use-mock-keychain', - '--disable-popup-blocking', - '--disable-features=DestroyProfileOnBrowserClose', - `--user-data-dir=${options.userDataDir}`, - '--remote-debugging-address=127.0.0.1', - '--remote-debugging-port=0', - 'about:blank', - ]); - launched = true; - const port = await deps.readPort(portFile); - browser = await deps.connectOverCDP(`http://127.0.0.1:${port}`); - await deps.humanizeBrowser(browser, options); - const context = browser.contexts()[0]; - if (!context) throw new Error('Background Chromium did not expose a persistent context'); - contextActivators.set(context, () => deps.activateApplication(appPath)); - context.close = async () => { - try { - await browser!.close(); - } finally { - contextActivators.delete(context); - await deps.terminateProfile(options.userDataDir); - } - }; - return context; - } catch (error) { - await browser?.close().catch(() => {}); - if (launched) await deps.terminateProfile(options.userDataDir).catch(() => {}); - throw error; - } -} diff --git a/src/browser/runtime/local-cloak/process-matcher.test.ts b/src/browser/runtime/local-cloak/process-matcher.test.ts deleted file mode 100644 index 568d8e87..00000000 --- a/src/browser/runtime/local-cloak/process-matcher.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { matchCloakProfileCommand } from './process-matcher.js'; - -describe('matchCloakProfileCommand', () => { - it('matches only exact Cloak user-data-dir arguments', () => { - const cloak = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work'; - const cloakSeparate = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir /profiles/work'; - const cloakQuoted = '"/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/Chromium.app/Contents/MacOS/Chromium" "--user-data-dir=/profiles/work"'; - const cloakWork2 = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work-2'; - const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; - - expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand('C:\\Users\\me\\.cloakbrowser\\chromium-146.0.7680.177.4\\chrome.exe --user-data-dir=C:\\profiles\\work', 'C:\\profiles\\work')).toBe(true); - expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('node /tmp/.cloakbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('/tmp/.cloakbrowser/helper --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - }); - - it('accepts quotes around a separate or equals-form profile value', () => { - const executable = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome'; - expect(matchCloakProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); - expect(matchCloakProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); - }); -}); diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-slab/actions.ts similarity index 90% rename from src/browser/runtime/local-cloak/actions.ts rename to src/browser/runtime/local-slab/actions.ts index c12b35a5..27cd3c63 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-slab/actions.ts @@ -10,14 +10,14 @@ import { import { redactText, redactUrl } from '../../../observation/redaction.js'; import { articleHtmlToMarkdown } from '../../../download/article-download.js'; import { waitForDownload } from './downloads.js'; -import type { CloakSessionManager } from './session-manager.js'; +import 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(); +const snapshotBaselines = new WeakMap(); -function snapshotBaselineStore(manager: CloakSessionManager): SnapshotBaselineStore { +function snapshotBaselineStore(manager: SlabSessionManager): SnapshotBaselineStore { let baselineStore = snapshotBaselines.get(manager); if (!baselineStore) { baselineStore = new MemorySnapshotBaselineStore(); @@ -26,7 +26,7 @@ function snapshotBaselineStore(manager: CloakSessionManager): SnapshotBaselineSt return baselineStore; } -class CloakActionError extends Error { +class SlabActionError extends Error { constructor( readonly errorCode: string, error: string, @@ -37,7 +37,7 @@ class CloakActionError extends Error { } } -export function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string { +export function resolveSlabCommandProfileId(manager: SlabSessionManager, command: BrowserRuntimeCommand): string { const requested = command.profileId ?? command.contextId; if (requested?.trim()) return requested.trim(); @@ -48,9 +48,9 @@ export function resolveCloakCommandProfileId(manager: CloakSessionManager, comma if (active.includes(preferred)) return preferred; if (active.length === 1) return active[0]; if (active.length > 1) { - throw new CloakActionError( + throw new SlabActionError( 'profile_required', - `Default Cloak profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, + `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 .', ); @@ -62,8 +62,8 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR return { id: command.id, ok: false, errorCode: 'invalid_request', error }; } -async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); +async function resolveLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { profileId, @@ -73,7 +73,7 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim idleTimeout: command.idleTimeout, }); if (existing) return existing; - throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } return manager.getPage({ profileId, @@ -90,8 +90,8 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim }); } -async function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); +async function resolveExistingLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { profileId, @@ -101,7 +101,7 @@ async function resolveExistingLease(manager: CloakSessionManager, command: Brows idleTimeout: command.idleTimeout, }); if (existing) return existing; - throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } const existing = await manager.findPage({ profileId, @@ -114,7 +114,7 @@ async function resolveExistingLease(manager: CloakSessionManager, command: Brows idleTimeout: command.idleTimeout, }); if (existing) return existing; - throw new CloakActionError( + throw new SlabActionError( 'session_not_found', `Browser session not found: ${command.session ?? ''}`, undefined, @@ -125,7 +125,7 @@ async function resolveExistingLease(manager: CloakSessionManager, command: Brows 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 CloakActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId); + if (!frame) throw new SlabActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId); return frame; } @@ -198,12 +198,12 @@ async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, } } -export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { +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 = resolveCloakCommandProfileId(manager, command); + const profileId = resolveSlabCommandProfileId(manager, command); // 'none' maps to Playwright's '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. @@ -347,7 +347,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: case 'close-window': { if (command.page) { const closed = await manager.closePage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, pageId: command.page, @@ -355,7 +355,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; } else { await manager.release({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, siteSession: command.siteSession, @@ -371,7 +371,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: switch (command.op ?? 'list') { case 'list': { const tabs = await manager.listPages({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, }); @@ -379,7 +379,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'new': { const lease = await manager.newPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, siteSession: command.siteSession, @@ -395,7 +395,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'select': { const lease = await manager.selectPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, pageId: command.page, @@ -407,7 +407,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'close': { const closed = await manager.closePage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, pageId: command.page, @@ -470,13 +470,13 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: id: command.id, ok: false, errorCode: 'invalid_request', - error: 'Bind requires --page or --index for a Cloak runtime tab', + error: 'Bind requires --page or --index for a SLAB runtime tab', errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', }; } { const lease = await manager.bindPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, siteSession: command.siteSession, @@ -494,8 +494,8 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: id: command.id, ok: false, errorCode: 'bound_tab_not_found', - error: 'Cloak tab not found for bind target', - errorHint: 'Run `webcmd --session browser tab list` and choose a current Cloak tab id or index.', + error: 'SLAB tab not found for bind target', + errorHint: 'Run `webcmd --session browser tab list` and choose a current SLAB tab id or index.', }; } return { @@ -515,7 +515,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: `Unknown action: ${command.action}` }; } } catch (err) { - if (err instanceof CloakActionError) { + 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 ( diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts new file mode 100644 index 00000000..61750c9a --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { attachSlabProfile } from './attachment.js'; + +describe('attachSlabProfile', () => { + it('connects to the attached CDP endpoint and releases the bridge attachment', async () => { + const context = {}; + const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '146.0'), close: vi.fn() }; + const bridge = { + attach: vi.fn().mockResolvedValue({ + connectionId: 'connection-1', + profile: { id: 'work', displayName: 'Work' }, + cdpUrl: 'ws://127.0.0.1:9222/devtools/browser/1', + bearerToken: 'secret', + expiresAt: '2026-08-19T00:00:00.000Z', + }), + release: vi.fn().mockResolvedValue(undefined), + }; + const connectOverCDP = vi.fn().mockResolvedValue(browser); + + const attached = await attachSlabProfile('work', { bridge, connectOverCDP }); + + expect(attached).toMatchObject({ profileId: 'work', browserVersion: '146.0', context, browser }); + expect(connectOverCDP).toHaveBeenCalledWith('ws://127.0.0.1:9222/devtools/browser/1', { + headers: { Authorization: 'Bearer secret' }, + }); + await attached.release(); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + expect(browser.close).not.toHaveBeenCalled(); + }); + + it('releases an attachment when CDP connection setup fails', async () => { + const bridge = { + attach: vi.fn().mockResolvedValue({ + connectionId: 'connection-1', profile: { id: 'work', displayName: 'Work' }, cdpUrl: 'ws://127.0.0.1:9222', bearerToken: 'secret', expiresAt: '2026-08-19T00:00:00.000Z', + }), + release: vi.fn().mockResolvedValue(undefined), + }; + + await expect(attachSlabProfile('work', { bridge, connectOverCDP: vi.fn().mockRejectedValue(new Error('CDP refused')) })) + .rejects.toThrow('CDP refused'); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + }); +}); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts new file mode 100644 index 00000000..9eb25915 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.ts @@ -0,0 +1,37 @@ +import { chromium, type Browser, type BrowserContext } from 'playwright-core'; +import { SlabBridgeClient } from './bridge-client.js'; + +export interface AttachedSlabProfile { + profileId: string; + browserVersion: string; + context: BrowserContext; + browser: Browser; + release(): Promise; +} + +export interface AttachSlabProfileOptions { + bridge?: Pick; + connectOverCDP?: typeof chromium.connectOverCDP; +} + +export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { + const bridge = options.bridge ?? new SlabBridgeClient(); + const attachment = await bridge.attach(profileId); + try { + const browser = await (options.connectOverCDP ?? chromium.connectOverCDP)(attachment.cdpUrl, { + headers: { Authorization: `Bearer ${attachment.bearerToken}` }, + }); + const context = browser.contexts()[0]; + if (!context) throw new Error('SLAB attachment returned no persistent browser context.'); + return { + profileId: attachment.profile.id, + browserVersion: browser.version(), + context, + browser, + release: () => bridge.release(attachment.connectionId), + }; + } catch (error) { + await bridge.release(attachment.connectionId).catch(() => {}); + throw error; + } +} diff --git a/src/browser/runtime/local-slab/bridge-client.test.ts b/src/browser/runtime/local-slab/bridge-client.test.ts new file mode 100644 index 00000000..a1ce95be --- /dev/null +++ b/src/browser/runtime/local-slab/bridge-client.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SLAB_SOCKET_PATH, SlabBridgeClient, SlabBridgeUnavailableError } from './bridge-client.js'; + +function fakeSocket() { + const client = Object.assign(new EventEmitter(), { + destroy() {}, + write(_data: string) { return true; }, + }); + const server = { write: (line: string) => client.emit('data', Buffer.from(line)) }; + return { client, server }; +} + +describe('SLAB bridge client', () => { + it('uses the per-user SLAB bridge socket by default', () => { + if (process.platform === 'win32') expect(DEFAULT_SLAB_SOCKET_PATH).toMatch(/^\\\\\.\\pipe\\slab-bridge-/); + else expect(DEFAULT_SLAB_SOCKET_PATH).toMatch(/\/\.slab\/run\/slab-bridge\.sock$/); + expect(DEFAULT_SLAB_SOCKET_PATH).not.toBe('/tmp/slab-bridge.sock'); + }); + + it('round-trips one request per newline-delimited response', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + socket.server.write('{"id":"1","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'); + + await expect(hello).resolves.toMatchObject({ protocolVersion: 1 }); + }); + + it('sends the v1 protocol compatibility range', async () => { + const socket = fakeSocket(); + const write = vi.spyOn(socket.client, 'write'); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toMatchObject({ + params: { protocolVersion: { min: 1, max: 1 } }, + }); + socket.server.write('{"id":"1","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'); + await expect(hello).resolves.toMatchObject({ protocolVersion: 1 }); + }); + + it('rejects an incompatible endpoint without attaching', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + socket.server.write('{"id":"1","ok":true,"result":{"protocolVersion":2,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'); + + await expect(hello).rejects.toMatchObject({ code: 'SLAB_UPDATE_REQUIRED' }); + }); + + it('reconnects after an unavailable endpoint', async () => { + const first = fakeSocket(); + const second = fakeSocket(); + const connect = vi.fn().mockReturnValueOnce(first.client).mockReturnValueOnce(second.client); + const client = new SlabBridgeClient({ connect }); + const unavailable = client.hello('1.9.0'); + first.client.emit('error', new Error('offline')); + await expect(unavailable).rejects.toThrow('offline'); + + const hello = client.hello('1.9.0'); + second.server.write('{"id":"2","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'); + await expect(hello).resolves.toMatchObject({ protocolVersion: 1 }); + expect(connect).toHaveBeenCalledTimes(2); + }); + + it('ignores stale socket data after reconnecting', async () => { + const first = fakeSocket(); + const second = fakeSocket(); + const client = new SlabBridgeClient({ connect: vi.fn().mockReturnValueOnce(first.client).mockReturnValueOnce(second.client) }); + const unavailable = client.hello('1.9.0'); + first.client.emit('error', new Error('offline')); + await expect(unavailable).rejects.toBeInstanceOf(SlabBridgeUnavailableError); + + const hello = client.hello('1.9.0'); + first.server.write('{"id":"2","ok":true,"result":{"protocolVersion":1,"browserVersion":"stale","browserPid":1234,"profiles":[]}}\n'); + second.server.write('{"id":"2","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'); + await expect(hello).resolves.toMatchObject({ browserVersion: '1' }); + }); + + it('rejects oversized responses', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + socket.server.write(`${'x'.repeat(64 * 1024 + 1)}\n`); + + await expect(hello).rejects.toThrow('exceeds 64 KiB'); + }); + + it('rejects duplicate response IDs', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + const attachment = client.attach('profile-1'); + const response = '{"id":"1","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","browserPid":1234,"profiles":[]}}\n'; + socket.server.write(response); + socket.server.write(response); + + await expect(hello).resolves.toMatchObject({ protocolVersion: 1 }); + await expect(attachment).rejects.toThrow('duplicate response ID'); + }); + + it('rejects invalid result shapes', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + socket.server.write('{"id":"1","ok":true,"result":{"protocolVersion":1,"browserVersion":"1","profiles":[]}}\n'); + + await expect(hello).rejects.toThrow('invalid hello result'); + }); + + it('rejects endpoint errors', async () => { + const socket = fakeSocket(); + const client = new SlabBridgeClient({ connect: () => socket.client }); + const hello = client.hello('1.9.0'); + socket.server.write('{"id":"1","ok":false,"error":{"message":"bridge denied request"}}\n'); + + await expect(hello).rejects.toThrow('bridge denied request'); + }); + + it('times out requests after five seconds', async () => { + vi.useFakeTimers(); + const client = new SlabBridgeClient({ connect: () => fakeSocket().client }); + const hello = client.hello('1.9.0'); + const rejected = expect(hello).rejects.toBeInstanceOf(SlabBridgeUnavailableError); + await vi.advanceTimersByTimeAsync(5_000); + await rejected; + vi.useRealTimers(); + }); +}); diff --git a/src/browser/runtime/local-slab/bridge-client.ts b/src/browser/runtime/local-slab/bridge-client.ts new file mode 100644 index 00000000..a4cddb0e --- /dev/null +++ b/src/browser/runtime/local-slab/bridge-client.ts @@ -0,0 +1,172 @@ +import { createConnection } from 'node:net'; +import { homedir, platform, userInfo } from 'node:os'; +import { join } from 'node:path'; +import { SlabUpdateRequiredError } from '../../../errors.js'; +import type { SlabAttachment, SlabHelloResult, SlabProfile } from './protocol.js'; + +const MAX_RESPONSE_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 5_000; +const pipeUser = (process.env.USERNAME ?? userInfo().username).replace(/[^a-zA-Z0-9_.-]/g, '_'); +export const DEFAULT_SLAB_SOCKET_PATH = platform() === 'win32' + ? `\\\\.\\pipe\\slab-bridge-${pipeUser}` + : join(homedir(), '.slab', 'run', 'slab-bridge.sock'); +const PROTOCOL_MIN_VERSION = 1; +const PROTOCOL_MAX_VERSION = 1; + +export class SlabBridgeUnavailableError extends Error {} + +interface BridgeSocket { + destroy(): unknown; + on(event: string, listener: (...args: any[]) => void): unknown; + write(data: string): unknown; +} + +export interface SlabBridgeClientOptions { + connect?: () => BridgeSocket; + socketPath?: string; +} + +type PendingRequest = { + reject(error: Error): void; + resolve(result: unknown): void; + timer: NodeJS.Timeout; + validate(result: unknown): unknown; +}; + +const protocolVersionRange = { min: PROTOCOL_MIN_VERSION, max: PROTOCOL_MAX_VERSION } as const; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function profile(value: unknown): value is SlabProfile { + return isRecord(value) && typeof value.id === 'string' && typeof value.displayName === 'string'; +} + +function helloResult(value: unknown): value is SlabHelloResult { + return isRecord(value) + && value.protocolVersion === 1 + && typeof value.browserVersion === 'string' + && typeof value.browserPid === 'number' + && Array.isArray(value.profiles) + && value.profiles.every(profile); +} + +function attachment(value: unknown): value is SlabAttachment { + return isRecord(value) + && typeof value.connectionId === 'string' + && profile(value.profile) + && typeof value.cdpUrl === 'string' + && typeof value.bearerToken === 'string' + && typeof value.expiresAt === 'string'; +} + +export class SlabBridgeClient { + readonly #connect: () => BridgeSocket; + #socket: BridgeSocket | undefined; + #buffer = ''; + #nextId = 1; + #pending = new Map(); + #seenIds = new Set(); + + constructor(options: SlabBridgeClientOptions = {}) { + const socketPath = options.socketPath ?? DEFAULT_SLAB_SOCKET_PATH; + this.#connect = options.connect ?? (() => createConnection(socketPath)); + } + + hello(clientVersion: string): Promise { + return this.#request('hello', { + clientVersion, + protocolVersion: protocolVersionRange, + }, (result) => { + if (!isRecord(result) || typeof result.protocolVersion !== 'number' + || result.protocolVersion < PROTOCOL_MIN_VERSION || result.protocolVersion > PROTOCOL_MAX_VERSION) { + const installed = isRecord(result) && typeof result.browserVersion === 'string' ? result.browserVersion : 'unknown'; + throw new SlabUpdateRequiredError(installed, 'protocol v1'); + } + if (!helloResult(result)) throw new Error('SLAB bridge returned an invalid hello result'); + return result; + }); + } + + attach(profileId: string): Promise { + return this.#request('attach', { protocolVersion: protocolVersionRange, profileId }, (result) => { + if (!attachment(result)) throw new Error('SLAB bridge returned an invalid attachment'); + return result; + }); + } + + async release(connectionId: string): Promise { + await this.#request('release', { protocolVersion: protocolVersionRange, connectionId }, (result) => { + if (result !== null && result !== undefined) throw new Error('SLAB bridge returned an invalid release result'); + }); + } + + #request(method: string, params: Record, validate: (result: unknown) => T): Promise { + this.#ensureSocket(); + const id = String(this.#nextId++); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#failAll(new SlabBridgeUnavailableError(`SLAB bridge ${method} request timed out`), true); + }, REQUEST_TIMEOUT_MS); + this.#pending.set(id, { resolve, reject, timer, validate }); + this.#socket!.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + + #ensureSocket(): void { + if (this.#socket) return; + const socket = this.#connect(); + this.#socket = socket; + socket.on('data', (chunk: Buffer | string) => { if (this.#socket === socket) this.#onData(chunk.toString()); }); + socket.on('error', (error: Error) => { if (this.#socket === socket) this.#failAll(error, true); }); + socket.on('close', () => { if (this.#socket === socket) this.#failAll(new Error('SLAB bridge connection closed'), true); }); + } + + #onData(chunk: string): void { + this.#buffer += chunk; + let newline: number; + while ((newline = this.#buffer.indexOf('\n')) >= 0) { + const line = this.#buffer.slice(0, newline); + this.#buffer = this.#buffer.slice(newline + 1); + if (Buffer.byteLength(line) > MAX_RESPONSE_BYTES) return this.#failAll(new Error('SLAB bridge response exceeds 64 KiB')); + this.#onResponse(line); + } + if (Buffer.byteLength(this.#buffer) > MAX_RESPONSE_BYTES) this.#failAll(new Error('SLAB bridge response exceeds 64 KiB')); + } + + #onResponse(line: string): void { + let response: unknown; + try { response = JSON.parse(line); } catch { return this.#failAll(new Error('SLAB bridge returned invalid JSON')); } + if (!isRecord(response) || typeof response.id !== 'string' || typeof response.ok !== 'boolean') { + return this.#failAll(new Error('SLAB bridge returned an invalid response')); + } + if (this.#seenIds.has(response.id)) return this.#failAll(new Error('SLAB bridge returned a duplicate response ID')); + this.#seenIds.add(response.id); + const pending = this.#pending.get(response.id); + if (!pending) return this.#failAll(new Error('SLAB bridge returned an unknown response ID')); + this.#pending.delete(response.id); + clearTimeout(pending.timer); + if (!response.ok) { + const message = isRecord(response.error) && typeof response.error.message === 'string' ? response.error.message : 'SLAB bridge request failed'; + pending.reject(new Error(message)); + return; + } + try { pending.resolve(pending.validate(response.result)); } catch (error) { pending.reject(error instanceof Error ? error : new Error('SLAB bridge returned an invalid result')); } + } + + #failAll(error: Error, unavailable = false): void { + const socket = this.#socket; + this.#socket = undefined; + this.#buffer = ''; + socket?.destroy(); + const reason = unavailable && !(error instanceof SlabBridgeUnavailableError) + ? new SlabBridgeUnavailableError(error.message) + : error; + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.reject(reason); + } + this.#pending.clear(); + } +} diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-slab/browser-run.test.ts similarity index 83% rename from src/browser/runtime/local-cloak/browser-run.test.ts rename to src/browser/runtime/local-slab/browser-run.test.ts index 125f5984..0058e48c 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-slab/browser-run.test.ts @@ -1,13 +1,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core'; -import { dispatchCloakAction } from './actions.js'; -import { CloakSessionManager, type LaunchPersistentContext } from './session-manager.js'; +import { dispatchSlabAction } from './actions.js'; +import { SlabSessionManager, type LaunchPersistentContext } from './session-manager.js'; import * as snapshot from '../../snapshot/index.js'; let browser: Browser; let context: BrowserContext; let initialPage: Page; -let manager: CloakSessionManager; +let manager: SlabSessionManager; let launchPersistentContext: ReturnType>; const command = (id: string, action: 'run' | 'snapshot' | 'tabs' | 'bind' | 'close-window', extra: Record = {}) => ({ @@ -27,7 +27,7 @@ beforeEach(async () => { context = await browser.newContext(); initialPage = await context.newPage(); launchPersistentContext = vi.fn().mockResolvedValue(context); - manager = new CloakSessionManager({ + manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-browser-run-test', launchPersistentContext, }); @@ -42,10 +42,10 @@ afterAll(async () => { await browser.close(); }); -describe('local Cloak browser run', () => { +describe('local SLAB 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')); + const result = await dispatchSlabAction(manager, command('snapshot-1', 'snapshot')); expect(result).toMatchObject({ ok: true, @@ -66,7 +66,7 @@ describe('local Cloak browser run', () => { await initialPage.goto('https://example.test/page?ok=1&key=page-secret&auth=page-auth'); await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-redacted', 'snapshot', { + const result = await dispatchSlabAction(manager, command('snapshot-redacted', 'snapshot', { maxOutputChars: 500, })); const tree = (result.data as { tree: string }).tree; @@ -83,7 +83,7 @@ describe('local Cloak browser run', () => { ``).join('')}`); await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-critical', 'snapshot', { + const result = await dispatchSlabAction(manager, command('snapshot-critical', 'snapshot', { maxOutputChars: 220, })); @@ -109,7 +109,7 @@ describe('local Cloak browser run', () => { `); await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-readable', 'snapshot', { + const result = await dispatchSlabAction(manager, command('snapshot-readable', 'snapshot', { snapshotMode: 'read', })); const data = result.data as { tree: string; article: { source: string } | null }; @@ -129,7 +129,7 @@ describe('local Cloak browser run', () => { await initialPage.setContent(''); await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-read-miss', 'snapshot', { + const result = await dispatchSlabAction(manager, command('snapshot-read-miss', 'snapshot', { snapshotMode: 'read', })); @@ -147,19 +147,19 @@ describe('local Cloak browser run', () => { it('does not create a browser session for snapshot inspection', async () => { const launch = vi.fn().mockResolvedValue(context); - const unstarted = new CloakSessionManager({ + const unstarted = new SlabSessionManager({ baseDir: '/tmp/webcmd-browser-snapshot-unstarted', launchPersistentContext: launch, }); - const result = await dispatchCloakAction(unstarted, command('snapshot-cold', 'snapshot')); + const result = await dispatchSlabAction(unstarted, command('snapshot-cold', 'snapshot')); expect(result).toMatchObject({ ok: false, errorCode: 'session_not_found' }); expect(launch).not.toHaveBeenCalled(); }); it('omits snapshotDiff when noSnapshotDiff is requested', async () => { - const result = await dispatchCloakAction(manager, command('run-no-diff', 'run', { + const result = await dispatchSlabAction(manager, command('run-no-diff', 'run', { source: "return 'ok';", noSnapshotDiff: true, })); @@ -169,7 +169,7 @@ describe('local Cloak browser run', () => { }); it('reuses the lease and keeps page state without keeping sandbox variables', async () => { - const first = await dispatchCloakAction(manager, command('run-1', 'run', { + const first = await dispatchSlabAction(manager, command('run-1', 'run', { source: ` globalThis.onlyThisRun = 'gone'; await page.setContent('

persisted

'); @@ -177,7 +177,7 @@ describe('local Cloak browser run', () => { `, snapshotDiff: true, })); - const second = await dispatchCloakAction(manager, command('run-2', 'run', { + const second = await dispatchSlabAction(manager, command('run-2', 'run', { source: ` return { state: await page.locator('#state').innerText(), @@ -210,25 +210,25 @@ describe('local Cloak browser run', () => { it('lists without creating a runtime', async () => { const unstartedLaunch = vi.fn(); - const unstarted = new CloakSessionManager({ + const unstarted = new SlabSessionManager({ baseDir: '/tmp/webcmd-browser-run-test-unstarted', launchPersistentContext: unstartedLaunch, }); - await expect(dispatchCloakAction(unstarted, command('tabs', 'tabs', { op: 'list' }))) + await expect(dispatchSlabAction(unstarted, command('tabs', 'tabs', { op: 'list' }))) .resolves.toMatchObject({ ok: true, data: [] }); expect(unstartedLaunch).not.toHaveBeenCalled(); }); it('does not bind a page owned by another Session', async () => { - const original = await dispatchCloakAction(manager, command('run-original', 'run', { + const original = await dispatchSlabAction(manager, command('run-original', 'run', { source: "await page.setContent('

original

'); return 'original';", })); - const created = await dispatchCloakAction(manager, command('new-tab', 'tabs', { + const created = await dispatchSlabAction(manager, command('new-tab', 'tabs', { op: 'new', session: 'manual', })); - const bound = await dispatchCloakAction(manager, command('bind', 'bind', { page: created.page })); + const bound = await dispatchSlabAction(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' }); diff --git a/src/browser/runtime/local-cloak/downloads.ts b/src/browser/runtime/local-slab/downloads.ts similarity index 100% rename from src/browser/runtime/local-cloak/downloads.ts rename to src/browser/runtime/local-slab/downloads.ts diff --git a/src/browser/runtime/local-cloak/network.test.ts b/src/browser/runtime/local-slab/network.test.ts similarity index 93% rename from src/browser/runtime/local-cloak/network.test.ts rename to src/browser/runtime/local-slab/network.test.ts index 6e7d58c0..079daf96 100644 --- a/src/browser/runtime/local-cloak/network.test.ts +++ b/src/browser/runtime/local-slab/network.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { CloakNetworkCapture } from './network.js'; +import { SlabNetworkCapture } from './network.js'; class FakePage extends EventEmitter { off(event: string, listener: (...args: any[]) => void) { @@ -9,10 +9,10 @@ class FakePage extends EventEmitter { } } -describe('CloakNetworkCapture', () => { +describe('SlabNetworkCapture', () => { it('captures matching request and response metadata with a bounded buffer', async () => { const page = new FakePage(); - const capture = new CloakNetworkCapture(2); + const capture = new SlabNetworkCapture(2); capture.start('api.example', page as any); const req = { @@ -42,7 +42,7 @@ describe('CloakNetworkCapture', () => { it('matches same-url responses to their exact request identity', async () => { const page = new FakePage(); - const capture = new CloakNetworkCapture(10); + const capture = new SlabNetworkCapture(10); capture.start('api.example', page as any); const firstReq = { @@ -88,7 +88,7 @@ describe('CloakNetworkCapture', () => { it('skips response body previews for non-text content types', async () => { const page = new FakePage(); - const capture = new CloakNetworkCapture(10); + const capture = new SlabNetworkCapture(10); capture.start('api.example', page as any); const req = { @@ -120,7 +120,7 @@ describe('CloakNetworkCapture', () => { it('evicts older entries when the bounded buffer limit is exceeded', async () => { const page = new FakePage(); - const capture = new CloakNetworkCapture(2); + const capture = new SlabNetworkCapture(2); capture.start('api.example', page as any); for (const id of ['one', 'two', 'three']) { diff --git a/src/browser/runtime/local-cloak/network.ts b/src/browser/runtime/local-slab/network.ts similarity index 99% rename from src/browser/runtime/local-cloak/network.ts rename to src/browser/runtime/local-slab/network.ts index d10f2aae..05d4fa46 100644 --- a/src/browser/runtime/local-cloak/network.ts +++ b/src/browser/runtime/local-slab/network.ts @@ -29,7 +29,7 @@ type CaptureState = { const BODY_LIMIT = 8 * 1024 * 1024; -export class CloakNetworkCapture { +export class SlabNetworkCapture { private readonly states = new WeakMap(); constructor(private readonly limit = 200) {} diff --git a/src/browser/runtime/local-slab/process-matcher.test.ts b/src/browser/runtime/local-slab/process-matcher.test.ts new file mode 100644 index 00000000..df147673 --- /dev/null +++ b/src/browser/runtime/local-slab/process-matcher.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { matchSlabProfileCommand } from './process-matcher.js'; + +describe('matchSlabProfileCommand', () => { + it('matches only exact SLAB user-data-dir arguments', () => { + const slab = '/Applications/SLAB.app/Contents/MacOS/SLAB --user-data-dir=/profiles/work'; + const slabSeparate = '/Users/me/.slabbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir /profiles/work'; + const slabQuoted = '"/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB" "--user-data-dir=/profiles/work"'; + const slabWork2 = '/Applications/SLAB.app/Contents/MacOS/SLAB --user-data-dir=/profiles/work-2'; + const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; + + expect(matchSlabProfileCommand(slab, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand(slabSeparate, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand(slabQuoted, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand(slabWork2, '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand(chromeWork, '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand('/tmp/slab --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand('/tmp/SLAB/slab --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand('node /tmp/.slabbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand('/tmp/.slabbrowser/helper --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + }); + + it('accepts quotes around a separate or equals-form profile value', () => { + const executable = '/Applications/SLAB.app/Contents/MacOS/SLAB'; + expect(matchSlabProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); + expect(matchSlabProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); + }); +}); diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-slab/process-matcher.ts similarity index 68% rename from src/browser/runtime/local-cloak/process-matcher.ts rename to src/browser/runtime/local-slab/process-matcher.ts index dc015819..21d85ea1 100644 --- a/src/browser/runtime/local-cloak/process-matcher.ts +++ b/src/browser/runtime/local-slab/process-matcher.ts @@ -1,13 +1,18 @@ import fs from 'node:fs'; import { execFile } from 'node:child_process'; -export function matchCloakProfileCommand(command: string, userDataDir: string): boolean { +export function matchSlabProfileCommand(command: string, userDataDir: string): boolean { const args = splitCommand(command); const executable = args[0]; const executableParts = executable?.split(/[\\/]/u) ?? []; - const cacheIndex = executableParts.lastIndexOf('.cloakbrowser'); - if (cacheIndex < 0 || !/^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '')) return false; - if (!['chrome', 'chrome.exe', 'chromium'].includes(executableParts.at(-1)?.toLowerCase() ?? '')) return false; + const basename = executableParts.at(-1)?.toLowerCase() ?? ''; + const hasMacBundle = executableParts.some(part => part.toLowerCase() === 'slab.app'); + const isSlabApp = basename === 'slab' && hasMacBundle; + const cacheIndex = executableParts.lastIndexOf('.slabbrowser'); + const isSlabCache = cacheIndex >= 0 + && /^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '') + && ['chrome', 'chrome.exe', 'chromium'].includes(basename); + if (!isSlabApp && !isSlabCache) return false; for (let index = 0; index < args.length; index += 1) { if (args[index] === '--user-data-dir' && args[index + 1] === userDataDir) return true; if (args[index] === `--user-data-dir=${userDataDir}`) return true; @@ -15,7 +20,7 @@ export function matchCloakProfileCommand(command: string, userDataDir: string): return false; } -export async function findExactCloakProfileProcesses(userDataDir: string): Promise { +export async function findExactSlabProfileProcesses(userDataDir: string): Promise { const aliases = new Set([userDataDir]); try { aliases.add(fs.realpathSync.native(userDataDir)); @@ -28,7 +33,7 @@ export async function findExactCloakProfileProcesses(userDataDir: string): Promi if (!match) return []; const pid = Number(match[1]); if (!Number.isInteger(pid) || pid === process.pid) return []; - return [...aliases].some(dir => matchCloakProfileCommand(match[2], dir)) ? [pid] : []; + return [...aliases].some(dir => matchSlabProfileCommand(match[2], dir)) ? [pid] : []; }); return [...new Set(pids)]; } diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-slab/profiles.test.ts similarity index 64% rename from src/browser/runtime/local-cloak/profiles.test.ts rename to src/browser/runtime/local-slab/profiles.test.ts index 472c9c1d..9abea7aa 100644 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ b/src/browser/runtime/local-slab/profiles.test.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; +import { normalizeProfileId, resolveSlabProfileDir } from './profiles.js'; -describe('cloak profile resolution', () => { +describe('SLAB profile resolution', () => { it('normalizes empty profile ids to default', () => { expect(normalizeProfileId(undefined)).toBe('default'); expect(normalizeProfileId('')).toBe('default'); @@ -15,8 +15,8 @@ describe('cloak profile resolution', () => { expect(() => normalizeProfileId('a\\b')).toThrow(/Invalid profile id/); }); - it('resolves under the webcmd cloak profiles directory', () => { - expect(resolveCloakProfileDir('work', { baseDir: '/tmp/webcmd' })) - .toBe(path.join('/tmp/webcmd', 'cloak', 'profiles', 'work')); + it('resolves under the webcmd SLAB profiles directory', () => { + expect(resolveSlabProfileDir('work', { baseDir: '/tmp/webcmd' })) + .toBe(path.join('/tmp/webcmd', 'slab', 'profiles', 'work')); }); }); diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-slab/profiles.ts similarity index 71% rename from src/browser/runtime/local-cloak/profiles.ts rename to src/browser/runtime/local-slab/profiles.ts index 0f08e9aa..fca205c1 100644 --- a/src/browser/runtime/local-cloak/profiles.ts +++ b/src/browser/runtime/local-slab/profiles.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; import os from 'node:os'; -export interface CloakProfileDirOptions { +export interface SlabProfileDirOptions { baseDir?: string; } @@ -18,7 +18,7 @@ export function getWebcmdConfigDir(): string { return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); } -export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { +export function resolveSlabProfileDir(profileId: string, opts: SlabProfileDirOptions = {}): string { const safeProfileId = normalizeProfileId(profileId); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'cloak', 'profiles', safeProfileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'slab', 'profiles', safeProfileId); } diff --git a/src/browser/runtime/local-slab/protocol.ts b/src/browser/runtime/local-slab/protocol.ts new file mode 100644 index 00000000..53723d04 --- /dev/null +++ b/src/browser/runtime/local-slab/protocol.ts @@ -0,0 +1,19 @@ +export interface SlabProfile { + id: string; + displayName: string; +} + +export interface SlabHelloResult { + protocolVersion: 1; + browserVersion: string; + browserPid: number; + profiles: SlabProfile[]; +} + +export interface SlabAttachment { + connectionId: string; + profile: SlabProfile; + cdpUrl: string; + bearerToken: string; + expiresAt: string; +} diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts similarity index 97% rename from src/browser/runtime/local-cloak/provider.test.ts rename to src/browser/runtime/local-slab/provider.test.ts index daf0187d..ac5d7529 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-slab/provider.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { LocalCloakRuntimeProvider } from './provider.js'; +import { LocalSlabRuntimeProvider } from './provider.js'; import { BrowserRunError } from '../../run/types.js'; const runBrowserProgram = vi.hoisted(() => vi.fn()); @@ -139,7 +139,7 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb if (command === 'Target.closeTarget') return { success: true }; return {}; }); - const provider = new LocalCloakRuntimeProvider({ + const provider = new LocalSlabRuntimeProvider({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(context), // Commands now default to background, which routes a darwin launch through @@ -149,25 +149,27 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb return { provider, browser, page: pages[0], pages, context, cdpSession, pageCdpSessions }; } -describe('LocalCloakRuntimeProvider', () => { +describe('LocalSlabRuntimeProvider', () => { beforeEach(() => { runBrowserProgram.mockReset(); }); - it('reports a runtime-named connected status before any profile launches', async () => { - const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test' }); + it('reports SLAB without changing session status shape', async () => { + const provider = new LocalSlabRuntimeProvider({ baseDir: '/tmp/webcmd-test' }); await expect(provider.status()).resolves.toMatchObject({ runtimeConnected: true, - runtimeName: 'cloak', - profiles: [], + runtimeName: 'SLAB', + profiles: expect.any(Array), pending: 0, + commandResultUnknown: 0, }); + await expect(provider.status()).resolves.toHaveProperty('runtimeVersion', undefined); }); it('discards a temporary Session record after closing it', async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); try { - const provider = new LocalCloakRuntimeProvider({ baseDir }); + const provider = new LocalSlabRuntimeProvider({ baseDir }); const session = await provider.createSession({ id: 'create-doctor-session', action: 'session-create', @@ -193,7 +195,7 @@ describe('LocalCloakRuntimeProvider', () => { it('does not discard a Session record unless close is forced', async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); try { - const provider = new LocalCloakRuntimeProvider({ baseDir }); + const provider = new LocalSlabRuntimeProvider({ baseDir }); const session = await provider.createSession({ id: 'create-user-session', action: 'session-create', @@ -309,7 +311,7 @@ describe('LocalCloakRuntimeProvider', () => { .resolves.toMatchObject({ id: 'exec', ok: true, data: { ok: true }, page: nav.page }); }); - it('runs Playwright-style source against the selected Cloak page', async () => { + it('runs Playwright-style source against the selected SLAB page', async () => { const { provider, browser, context, page } = makeProviderWithFakePage(); runBrowserProgram.mockResolvedValue(runOutput('https://example.com/')); @@ -499,7 +501,7 @@ describe('LocalCloakRuntimeProvider', () => { it('partitions the local queue by adapter site only for adapter-default Sessions', () => { const { provider } = makeProviderWithFakePage(); const queueKey = (provider as unknown as { - commandQueueKey(command: Parameters[0]): string; + commandQueueKey(command: Parameters[0]): string; }).commandQueueKey.bind(provider); expect(queueKey({ @@ -548,7 +550,7 @@ describe('LocalCloakRuntimeProvider', () => { it('keeps adapter-default page-scoped queue keys partitioned by site', async () => { const { provider } = makeProviderWithFakePage(); const queueKey = (provider as unknown as { - commandQueueKey(command: Parameters[0]): string; + commandQueueKey(command: Parameters[0]): string; }).commandQueueKey.bind(provider); const github = await provider.dispatch({ id: 'github-nav', @@ -808,14 +810,14 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true })); }); - it('requires an explicit Cloak tab target for bind', async () => { + it('requires an explicit SLAB tab target for bind', async () => { const { provider } = makeProviderWithFakePage(); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', profileId: 'default' })) .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'invalid_request', - error: 'Bind requires --page or --index for a Cloak runtime tab', + error: 'Bind requires --page or --index for a SLAB runtime tab', }); }); @@ -838,7 +840,7 @@ describe('LocalCloakRuntimeProvider', () => { expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); - it('returns a typed bind error when the requested Cloak tab is missing', async () => { + it('returns a typed bind error when the requested SLAB tab is missing', async () => { const { provider } = makeProviderWithFakePage(); await provider.dispatch({ id: 'nav', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); @@ -847,7 +849,7 @@ describe('LocalCloakRuntimeProvider', () => { id: 'bind', ok: false, errorCode: 'bound_tab_not_found', - error: 'Cloak tab not found for bind target', + error: 'SLAB tab not found for bind target', }); }); diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-slab/provider.ts similarity index 89% rename from src/browser/runtime/local-cloak/provider.ts rename to src/browser/runtime/local-slab/provider.ts index 8e8b6457..1332a903 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -1,30 +1,27 @@ 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 { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js'; +import { dispatchSlabAction, resolveSlabCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; -import { - CloakSessionManager, - resolveCloakBrowserVersion, -} from './session-manager.js'; +import { SlabSessionManager } from './session-manager.js'; -export interface LocalCloakRuntimeProviderOptions { +export interface LocalSlabRuntimeProviderOptions { baseDir?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } -export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { - private readonly manager: CloakSessionManager; +export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { + private readonly manager: SlabSessionManager; private readonly sessions: LocalBrowserSessionStore; private readonly sessionQueues = new Map>(); - constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { + constructor(private readonly opts: LocalSlabRuntimeProviderOptions = {}) { this.sessions = new LocalBrowserSessionStore({ baseDir: opts.baseDir, isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, }); - this.manager = new CloakSessionManager({ + this.manager = new SlabSessionManager({ ...opts, hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() @@ -36,8 +33,8 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, - runtimeName: 'cloak', - runtimeVersion: resolveCloakBrowserVersion(), + runtimeName: 'SLAB', + runtimeVersion: profiles.find(profile => profile.runtimeVersion)?.runtimeVersion, profiles, pending: 0, commandResultUnknown: 0, @@ -46,7 +43,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { } resolveProfileId(command: BrowserRuntimeCommand): string { - return resolveCloakCommandProfileId(this.manager, command); + return resolveSlabCommandProfileId(this.manager, command); } async createSession(command: BrowserRuntimeCommand): Promise { @@ -120,7 +117,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { } return await this.manager.runWithProfileActivity( this.resolveProfileId(command), - () => dispatchCloakAction(this.manager, command, signal), + () => dispatchSlabAction(this.manager, command, signal), ); } finally { release(); diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts similarity index 87% rename from src/browser/runtime/local-cloak/session-manager.test.ts rename to src/browser/runtime/local-slab/session-manager.test.ts index 82fc8d18..4025ecd2 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import path from 'node:path'; import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; -import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; +import { SlabSessionManager, resolveLeaseKey } from './session-manager.js'; import { log } from '../../../logger.js'; -import { dispatchCloakAction } from './actions.js'; +import { dispatchSlabAction } from './actions.js'; function fakeContext() { const listeners = new Map void>>(); const cdpListeners = new Map void>>(); const pageListeners = new WeakMap void>>>(); + const pageCdps: Array<{ detach: ReturnType }> = []; const targetIds = new WeakMap(); const windowIds = new Map(); let targetCounter = 0; @@ -135,14 +135,18 @@ function fakeContext() { allPages.push(created); return created; }), - newCDPSession: vi.fn(async (target: object) => ({ + newCDPSession: vi.fn(async (target: object) => { + const pageCdp = { 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), - })), + }; + pageCdps.push(pageCdp); + return pageCdp; + }), browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), @@ -159,39 +163,134 @@ function fakeContext() { for (const listener of cdpListeners.get(event) ?? []) listener(payload); }, pageListenerCount: (target: object, event: string) => pageListeners.get(target)?.get(event)?.size ?? 0, + pageCdps, makePage: fakePage, }; } +function fakeAttachedProfile(launched: ReturnType, profileId = 'default') { + return { + profileId, + browserVersion: '146.0', + context: launched.context, + browser: launched.context.browser(), + release: vi.fn().mockResolvedValue(undefined), + }; +} + function expectedProfileDir(profileId: string): string { - return path.join('/tmp/webcmd-test', 'cloak', 'profiles', profileId); + return profileId; } -describe('CloakSessionManager', () => { +describe('SlabSessionManager', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); - it('launches one persistent context per profile and reuses named sessions', async () => { + it('attaches once per active profile and releases without closing its context', async () => { const launched = fakeContext(); - const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, + const attached = fakeAttachedProfile(launched); + const attachProfile = vi.fn().mockResolvedValue(attached); + const manager = new SlabSessionManager({ + attachProfile, }); - const first = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const second = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + const first = await manager.getPage({ profileId: 'default', session: 'one', surface: 'browser' }); + const second = await manager.getPage({ profileId: 'default', session: 'two', surface: 'browser' }); - expect(first.page).toBe(second.page); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); + expect(first.page).not.toBe(second.page); + expect(attachProfile).toHaveBeenCalledOnce(); + await manager.shutdown(); + expect(attached.release).toHaveBeenCalledOnce(); + expect(launched.context.close).not.toHaveBeenCalled(); + }); + + it('does not adopt a pre-existing about:blank page from an attached profile', async () => { + const launched = fakeContext(); + await launched.page.goto('about:blank'); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile(launched)) }); + + const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + expect(lease.page).not.toBe(launched.page); + }); + + it('releases an invalidated attachment', async () => { + const launched = fakeContext(); + const attached = fakeAttachedProfile(launched); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached) }); + await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + launched.context.emit('close'); + + await vi.waitFor(() => expect(attached.release).toHaveBeenCalledOnce()); + }); + + it('handles an invalidated attachment release failure', async () => { + const launched = fakeContext(); + const attached = fakeAttachedProfile(launched); + attached.release.mockRejectedValue(new Error('bridge disconnected')); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached) }); + await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + launched.context.emit('close'); + + await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining('bridge disconnected'))); + }); + + it('closes keeper resources and detaches CDP before releasing an attachment', async () => { + const launched = fakeContext(); + const attached = fakeAttachedProfile(launched); + const order: string[] = []; + launched.cdp.detach.mockImplementation(async () => { order.push('detach'); }); + attached.release.mockImplementation(async () => { order.push('release'); }); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached) }); + await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + await manager.shutdown(); + + expect(launched.cdp.send).toHaveBeenCalledWith('Target.closeTarget', { targetId: launched.targetIdFor(launched.backgroundPages[0]!) }); + expect(order).toEqual(['detach', 'release']); + }); + + it('closes a parking keeper before releasing an attachment', async () => { + const launched = fakeContext(); + const attached = fakeAttachedProfile(launched); + attached.browser = {} as typeof attached.browser; + vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached) }); + const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + await manager.closeSession('default', 'work'); + + expect(lease.page.close).not.toHaveBeenCalled(); + await manager.shutdown(); + expect(lease.page.close).toHaveBeenCalledOnce(); + }); + + it('waits for a parking-page CDP detach before releasing an attachment', async () => { + const launched = fakeContext(); + const attached = fakeAttachedProfile(launched); + attached.browser = {} as typeof attached.browser; + vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached) }); + const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + await manager.closeSession('default', 'work'); + let finishDetach!: () => void; + launched.pageCdps[0]!.detach.mockImplementation(() => new Promise(resolve => { finishDetach = resolve; })); + + const shutdown = manager.shutdown(); + await vi.waitFor(() => expect(launched.pageCdps[0]!.detach).toHaveBeenCalled()); + expect(attached.release).not.toHaveBeenCalled(); + finishDetach(); + await shutdown; + expect(lease.page.close).toHaveBeenCalledOnce(); }); it('correlates created targets and isolates Sessions into owned windows', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -207,10 +306,10 @@ describe('CloakSessionManager', () => { .map(tab => tab.sessionId)).toEqual(['session_a']); }); - it('reuses the fresh launch about:blank page for the first Session window', async () => { + it('creates a Session window instead of adopting the launch about:blank page', async () => { const launched = fakeContext(); await launched.page.goto('about:blank'); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -218,9 +317,9 @@ describe('CloakSessionManager', () => { const lease = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); - expect(lease.page).toBe(launched.page); + expect(lease.page).not.toBe(launched.page); expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)) - .toHaveLength(0); + .toHaveLength(1); expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) .map(tab => tab.sessionId)).toEqual(['session_a']); }); @@ -233,7 +332,7 @@ describe('CloakSessionManager', () => { launched.emitPage(unrelated); return send(method, params as { targetId?: string } | undefined); }); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -248,7 +347,7 @@ describe('CloakSessionManager', () => { it('creates later Session pages with noopener and adopts the context page in the same window', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -271,7 +370,7 @@ describe('CloakSessionManager', () => { it('falls back to another owned window when Chromium does not create the requested tab', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -288,7 +387,7 @@ describe('CloakSessionManager', () => { it('logs when window.open fails before falling back to another owned window', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -308,7 +407,7 @@ describe('CloakSessionManager', () => { it('ignores an unmarked opener-less page when waiting for a Session tab', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -335,7 +434,7 @@ describe('CloakSessionManager', () => { it('keeps a site popup owned while noopener tab creation uses another page', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -359,7 +458,7 @@ describe('CloakSessionManager', () => { it('creates a later page in its Session window when another Session was used last', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -381,7 +480,7 @@ describe('CloakSessionManager', () => { ? { targetId: 'missing-target' } : send(method, params) )); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -389,7 +488,7 @@ describe('CloakSessionManager', () => { const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; const missing = manager.getPage(key); - const missingExpectation = expect(missing).rejects.toThrow('Timed out waiting for Cloak target missing-target'); + const missingExpectation = expect(missing).rejects.toThrow('Timed out waiting for SLAB target missing-target'); await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.any(Object))); await vi.advanceTimersByTimeAsync(1_000); await missingExpectation; @@ -402,7 +501,7 @@ describe('CloakSessionManager', () => { it('registers a child-window popup under its opener Session', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -419,7 +518,7 @@ describe('CloakSessionManager', () => { it('rejects every operation after a Session page moves into another owned window', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -440,7 +539,7 @@ describe('CloakSessionManager', () => { it('closes a Session when its page disappears during the ownership check', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -462,7 +561,7 @@ describe('CloakSessionManager', () => { it('does not let another Session bind an owned page moved to an unowned window', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -480,7 +579,7 @@ describe('CloakSessionManager', () => { it('checks opener window ownership before calling window.open', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -495,7 +594,7 @@ describe('CloakSessionManager', () => { it('binds an unowned context page without adopting another Session page', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -522,7 +621,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform, launchPersistentContext, @@ -531,18 +630,16 @@ describe('CloakSessionManager', () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', windowMode }); - expect(launchBackgroundPersistentContext).toHaveBeenCalledTimes(backgroundCalls); - expect(launchPersistentContext).toHaveBeenCalledTimes(normalCalls); + expect(launchBackgroundPersistentContext).toHaveBeenCalledTimes(0); + expect(launchPersistentContext).toHaveBeenCalledTimes(1); }); it('reactivates a background-launched context for foreground tab selection', async () => { const launched = fakeContext(); - const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchBackgroundPersistentContext: vi.fn().mockResolvedValue(launched.context), - activateBackgroundContext, }); const lease = await manager.getPage({ profileId: 'default', @@ -553,17 +650,15 @@ describe('CloakSessionManager', () => { await manager.selectPage({ profileId: 'default', session: 'work', surface: 'browser', pageId: lease.pageId, windowMode: 'foreground' }); - expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); + expect(lease.page.bringToFront).toHaveBeenCalledOnce(); }); it('foregrounds only the selected Session window during handoff', async () => { const launched = fakeContext(); - const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - activateBackgroundContext, }); const first = await manager.getPage({ profileId: 'work', session: 'session_a', sessionId: 'session_a', surface: 'adapter' }); const sibling = await manager.getPage({ profileId: 'work', session: 'session_b', sessionId: 'session_b', surface: 'adapter' }); @@ -572,12 +667,11 @@ describe('CloakSessionManager', () => { expect(first.page.bringToFront).toHaveBeenCalledOnce(); expect(sibling.page.bringToFront).not.toHaveBeenCalled(); - expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); }); it('creates a warm background lease tab without focusing Chromium', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -600,7 +694,7 @@ describe('CloakSessionManager', () => { it('creates an explicit background tab without focusing Chromium', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -623,7 +717,7 @@ describe('CloakSessionManager', () => { it('creates an explicit foreground tab in a new CDP window', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -645,7 +739,7 @@ describe('CloakSessionManager', () => { it('gives concurrent background tabs distinct pages', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -676,7 +770,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn(() => new Promise((resolve) => { resolveLaunch = resolve; })); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); @@ -700,7 +794,7 @@ describe('CloakSessionManager', () => { it('evicts a closed runtime and clears every tracked page resource', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -730,7 +824,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); first.context.emit('close'); @@ -754,7 +848,7 @@ describe('CloakSessionManager', () => { .mockImplementationOnce(() => new Promise((resolve) => { resolveReplacement = resolve; })); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); first.context.emit('close'); @@ -788,7 +882,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const pendingLease = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); await pageCreationStarted; @@ -818,7 +912,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); await expect(manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 })) .rejects.toThrow('Target page, context or browser has been closed'); @@ -840,7 +934,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); @@ -858,7 +952,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' }); @@ -878,7 +972,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); await expect(manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' })) .rejects.toBe(secondFailure); @@ -902,7 +996,7 @@ describe('CloakSessionManager', () => { }); }); launched.context.newPage.mockResolvedValue(launched.page); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -939,7 +1033,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(launched.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const lease = await manager.newPage({ profileId: 'default', @@ -956,13 +1050,13 @@ describe('CloakSessionManager', () => { expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); }); - it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => { + it('clears a stale SLAB profile owner and retries when Chromium reports an existing session', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn() .mockRejectedValueOnce(new Error('browserType.launchPersistentContext: Opening in existing browser session.')) .mockResolvedValueOnce(launched.context); const recoverLockedProfile = vi.fn().mockResolvedValue(true); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, recoverLockedProfile, @@ -977,7 +1071,7 @@ describe('CloakSessionManager', () => { it('freshPage closes the existing persistent lease page and creates a new one', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -996,7 +1090,7 @@ describe('CloakSessionManager', () => { it('keeps persistent adapter pages separate by Session and site', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -1038,7 +1132,7 @@ describe('CloakSessionManager', () => { const leftover = launched.page; const created = fakeContext().page; launched.context.newPage.mockResolvedValue(created); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -1050,7 +1144,7 @@ describe('CloakSessionManager', () => { it('closes ephemeral adapter sessions when released', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1063,7 +1157,7 @@ describe('CloakSessionManager', () => { it('releases only the owning ephemeral adapter site and run', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1083,7 +1177,7 @@ describe('CloakSessionManager', () => { it('keeps persistent adapter pages tracked when release is requested', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -1100,7 +1194,7 @@ describe('CloakSessionManager', () => { it('closes non-persistent leases when their idle timeout expires', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1118,7 +1212,7 @@ describe('CloakSessionManager', () => { it('refreshes an idle timeout when a lease is reused', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1138,7 +1232,7 @@ describe('CloakSessionManager', () => { it('does not close persistent site sessions when their idle timeout expires', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -1150,15 +1244,15 @@ describe('CloakSessionManager', () => { expect(await manager.listPages({ profileId: 'default', session: 'site:x:uuid' })).toHaveLength(1); }); - it('launches a preferred profile when no Cloak profile is active', async () => { + it('launches a preferred profile when no SLAB profile is active', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); - await dispatchCloakAction(manager, { + await dispatchSlabAction(manager, { id: 'cmd-preferred', action: 'navigate', session: 'work', @@ -1180,12 +1274,12 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); - const result = await dispatchCloakAction(manager, { + const result = await dispatchSlabAction(manager, { id: 'cmd-retry-navigation', action: 'navigate', session: 'work', @@ -1210,7 +1304,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn() .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const key = { profileId: 'default', session: 'work', sessionId: 'session_a', surface: 'browser' as const }; await manager.getPage(key); const navigation = manager.navigatePage(key, 'https://example.com/', 'load'); @@ -1227,12 +1321,12 @@ describe('CloakSessionManager', () => { it('falls back to the only active profile when the preferred profile is stale', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); - await dispatchCloakAction(manager, { + await dispatchSlabAction(manager, { id: 'cmd-active', action: 'navigate', session: 'work', @@ -1240,7 +1334,7 @@ describe('CloakSessionManager', () => { url: 'https://example.com/', contextId: 'active', }); - await dispatchCloakAction(manager, { + await dispatchSlabAction(manager, { id: 'cmd-stale-default', action: 'navigate', session: 'work', @@ -1256,12 +1350,12 @@ describe('CloakSessionManager', () => { it('asks for an explicit profile when a stale preferred profile meets multiple active profiles', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); - await dispatchCloakAction(manager, { + await dispatchSlabAction(manager, { id: 'cmd-a', action: 'navigate', session: 'work-a', @@ -1269,7 +1363,7 @@ describe('CloakSessionManager', () => { url: 'https://example.com/a', contextId: 'profile-a', }); - await dispatchCloakAction(manager, { + await dispatchSlabAction(manager, { id: 'cmd-b', action: 'navigate', session: 'work-b', @@ -1278,7 +1372,7 @@ describe('CloakSessionManager', () => { contextId: 'profile-b', }); - const result = await dispatchCloakAction(manager, { + const result = await dispatchSlabAction(manager, { id: 'cmd-stale', action: 'navigate', session: 'work', @@ -1302,7 +1396,7 @@ describe('CloakSessionManager', () => { launched.cdp.send.mockImplementationOnce(() => new Promise((resolve) => { resolveAnchor = () => resolve({ targetId: 'anchor-target' }); })); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1325,7 +1419,7 @@ describe('CloakSessionManager', () => { it('keeps an empty profile warm for sixty seconds before closing it', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'linux', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1348,7 +1442,7 @@ describe('CloakSessionManager', () => { const launchPersistentContext = vi.fn(() => new Promise((resolve) => { resolveLaunch = resolve; })); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); await vi.waitFor(() => expect(launchPersistentContext).toHaveBeenCalledOnce()); @@ -1373,7 +1467,7 @@ describe('CloakSessionManager', () => { : send(method, params) )); const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1395,7 +1489,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); launched.context.browser.mockReturnValue(null); const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1411,7 +1505,7 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform, launchPersistentContext, @@ -1436,7 +1530,7 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); let handoffActive = true; const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), hasActiveHandoff: () => handoffActive, @@ -1460,7 +1554,7 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); let handoffActive = true; const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), hasActiveHandoff: () => handoffActive, @@ -1486,7 +1580,7 @@ describe('CloakSessionManager', () => { const unref = vi.spyOn(Object.getPrototypeOf(timer), 'unref'); clearTimeout(timer); const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -1500,7 +1594,7 @@ describe('CloakSessionManager', () => { it('repairs one anchor for duplicate destruction and page-close notifications', async () => { const launched = fakeContext(); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1532,7 +1626,7 @@ describe('CloakSessionManager', () => { .mockResolvedValueOnce(first.context) .mockResolvedValueOnce(replacement.context); const recoverLockedProfile = vi.fn().mockResolvedValue(true); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'linux', launchPersistentContext, @@ -1547,7 +1641,7 @@ describe('CloakSessionManager', () => { await vi.advanceTimersByTimeAsync(3_000); const leases = await Promise.all([one, two]); - expect(recoverLockedProfile).toHaveBeenCalledOnce(); + expect(recoverLockedProfile).not.toHaveBeenCalled(); expect(leases[0].context).toBe(replacement.context); expect(leases[1].context).toBe(replacement.context); expect(launchPersistentContext).toHaveBeenCalledTimes(2); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts similarity index 85% rename from src/browser/runtime/local-cloak/session-manager.ts rename to src/browser/runtime/local-slab/session-manager.ts index f2e91dfe..a0aecd0b 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -1,45 +1,17 @@ -import fs from 'node:fs'; -import path from 'node:path'; -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 { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; -import { CloakNetworkCapture } from './network.js'; -import { findPackageRoot } from '../../../package-paths.js'; -import { findExactCloakProfileProcesses } from './process-matcher.js'; +import { normalizeProfileId } from './profiles.js'; +import { SlabNetworkCapture } from './network.js'; import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; +import { attachSlabProfile, type AttachedSlabProfile } from './attachment.js'; -const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; export const PROFILE_IDLE_TIMEOUT_MS = 60_000; export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; -let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; - -/** - * Installed `cloakbrowser` npm package version, for doctor/status display. - * - * Resolved once per process. The version cannot change while we are running, and - * `profileStatuses()` calls this per profile, so an uncached read meant N+1 - * synchronous resolve-read-parse cycles on every status poll. The sentinel keeps - * a genuine `undefined` (the catch path) cached too, so an unresolvable - * `cloakbrowser` is not retried on every call. - */ -export function resolveCloakBrowserVersion(): string | undefined { - if (cachedCloakBrowserVersion !== UNRESOLVED) return cachedCloakBrowserVersion; - try { - const entryPath = fileURLToPath(import.meta.resolve('cloakbrowser')); - const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8')) as { version?: unknown }; - cachedCloakBrowserVersion = typeof pkg.version === 'string' ? pkg.version : undefined; - } catch { - cachedCloakBrowserVersion = undefined; - } - return cachedCloakBrowserVersion; -} -export type LaunchPersistentContext = typeof cloakLaunchPersistentContext; +export type AttachSlabProfile = typeof attachSlabProfile; +export type LaunchPersistentContext = (options: { userDataDir: string; headless: boolean; humanize: boolean }) => Promise; export type RecoverLockedProfile = (userDataDir: string) => Promise; export interface SessionKeyInput { @@ -72,7 +44,7 @@ type PageEntry = { idleTimer?: ReturnType; }; -export interface CloakPageLease { +export interface SlabPageLease { profileId: string; leaseKey: string; context: BrowserContext; @@ -80,7 +52,7 @@ export interface CloakPageLease { pageId: string; } -export interface CloakTabInfo { +export interface SlabTabInfo { id: string; page: string; index: number; @@ -95,12 +67,12 @@ export interface CloakTabInfo { interface ProfileRuntime { profileId: string; + attachment: AttachedSlabProfile; context: BrowserContext; cdp?: CDPSession; sessions: Map; windowOwners: Map; targetPages: Map; - userDataDir: string; anchorTargetId?: string; parkingPage?: PlaywrightPage; useParkingKeeper: boolean; @@ -110,6 +82,7 @@ interface ProfileRuntime { handoffTimer?: ReturnType; closing: boolean; disposed: boolean; + releasePromise?: Promise; lastSeenAt: number; } @@ -140,13 +113,18 @@ export class SessionWindowConflictError extends CliError { } } -export interface CloakSessionManagerOptions { +export interface SlabSessionManagerOptions { + /** @deprecated Test-only migration compatibility; production attaches through SLAB. */ baseDir?: string; + /** @deprecated Test-only migration compatibility; production attaches through SLAB. */ launchPersistentContext?: LaunchPersistentContext; + /** @deprecated Test-only migration compatibility; production attaches through SLAB. */ launchBackgroundPersistentContext?: LaunchPersistentContext; - activateBackgroundContext?: typeof activateDarwinBackgroundContext; + /** @deprecated Test-only migration compatibility; production attaches through SLAB. */ recoverLockedProfile?: RecoverLockedProfile; + /** @deprecated Test-only migration compatibility; production attaches through SLAB. */ platform?: NodeJS.Platform; + attachProfile?: AttachSlabProfile; hasActiveHandoff?: (profileId: string) => boolean; } @@ -183,14 +161,10 @@ 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 CloakSessionManager { - readonly networkCapture = new CloakNetworkCapture(); +export class SlabSessionManager { + readonly networkCapture = new SlabNetworkCapture(); - private readonly launchPersistentContext: LaunchPersistentContext; - private readonly launchBackgroundPersistentContext: LaunchPersistentContext; - private readonly activateBackgroundContext: typeof activateDarwinBackgroundContext; - private readonly platform: NodeJS.Platform; - private readonly recoverLockedProfile: RecoverLockedProfile; + private readonly attachProfile: AttachSlabProfile; private readonly hasActiveHandoff: (profileId: string) => boolean; private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); @@ -200,6 +174,7 @@ export class CloakSessionManager { 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 void>>(); private shuttingDown = false; - constructor(private readonly opts: CloakSessionManagerOptions = {}) { - this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; - this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext; - this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; - this.platform = opts.platform ?? process.platform; - this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile; + constructor(private readonly opts: SlabSessionManagerOptions = {}) { + this.attachProfile = opts.attachProfile ?? this.legacyAttachProfile; this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); } + private async legacyAttachProfile(profileId: string): Promise { + const launch = this.opts.launchPersistentContext ?? this.opts.launchBackgroundPersistentContext; + if (!launch) return attachSlabProfile(profileId); + const options = { userDataDir: profileId, headless: false, humanize: true }; + let context: BrowserContext; + try { + context = await launch(options); + } catch (error) { + if (!(await this.opts.recoverLockedProfile?.(profileId))) throw error; + context = await launch(options); + } + const browser = context.browser(); + return { + profileId, + browserVersion: '', + context, + browser: browser ?? {} as Browser, + release: async () => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + context.close(), + new Promise(resolve => { timer = setTimeout(resolve, PROFILE_CLOSE_TIMEOUT_MS); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }, + }; + } + profileStatuses() { return [...this.profiles.entries()].map(([contextId, runtime]) => ({ contextId, runtimeConnected: true, - runtimeVersion: resolveCloakBrowserVersion(), + runtimeVersion: runtime.attachment.browserVersion || undefined, pending: 0, lastSeenAt: runtime.lastSeenAt, })); @@ -260,7 +262,7 @@ export class CloakSessionManager { } } - async getPage(input: SessionKeyInput): Promise { + async getPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -295,7 +297,7 @@ export class CloakSessionManager { }); } - async findPage(input: SessionKeyInput): Promise { + async findPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); const leaseKey = resolveLeaseKey(input); @@ -310,7 +312,7 @@ export class CloakSessionManager { return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - async findPageById(pageId: string, opts: Pick): Promise { + async findPageById(pageId: string, opts: Pick): Promise { const expectedProfileId = normalizeProfileId(opts.profileId); const sessionId = requireSessionId(opts); const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; @@ -372,10 +374,8 @@ export class CloakSessionManager { 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, + browser: runtime.attachment.browser, context: runtime.context, page, pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), @@ -389,7 +389,7 @@ export class CloakSessionManager { }; } - async listPages(input: Pick): Promise { + async listPages(input: Pick): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); const surface = input.surface ? normalizeSurface(input.surface) : undefined; @@ -414,15 +414,15 @@ export class CloakSessionManager { }))); } - async newPage(input: SessionKeyInput & { url?: string }): Promise { + async newPage(input: SessionKeyInput & { url?: string }): Promise { return this.newPageAttempt(input, 0); } - async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { + async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { return this.navigatePageAttempt(input, url, waitUntil, 0); } - private async newPageAttempt(input: SessionKeyInput & { url?: string }, attempt: number): Promise { + private async newPageAttempt(input: SessionKeyInput & { url?: string }, attempt: number): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -463,7 +463,7 @@ export class CloakSessionManager { 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 { + 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); @@ -478,7 +478,7 @@ export class CloakSessionManager { } } - async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); @@ -492,7 +492,6 @@ export class CloakSessionManager { await this.assertOwnedWindow(runtime, sessionId, entry); if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); } this.selectEntry(sessionRuntime, entry); runtime.lastSeenAt = Date.now(); @@ -510,13 +509,12 @@ export class CloakSessionManager { const entry = match[1]; await this.assertOwnedWindow(runtime, sessionId, entry); await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); this.selectEntry(session, entry); runtime.lastSeenAt = Date.now(); return true; } - async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { + async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -567,7 +565,6 @@ export class CloakSessionManager { if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); } if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { @@ -667,7 +664,7 @@ export class CloakSessionManager { if (!runtime) return; this.profiles.delete(profileId); runtime.closing = true; - await this.closeRuntime(runtime, false).catch(() => {}); + await this.closeRuntime(runtime).catch(() => {}); }))); this.profiles.clear(); this.profileLaunches.clear(); @@ -692,25 +689,9 @@ export class CloakSessionManager { }); } - private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); - fs.mkdirSync(userDataDir, { recursive: true }); - const launchOptions = { - userDataDir, - headless: false, - humanize: true, - }; - const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' - ? this.launchBackgroundPersistentContext - : this.launchPersistentContext; - let context: BrowserContext; - try { - context = await launchPersistentContext(launchOptions); - } catch (err) { - if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir))) throw err; - context = await launchPersistentContext(launchOptions); - } - const browser = context.browser(); + 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 { @@ -720,13 +701,13 @@ export class CloakSessionManager { } const runtime: ProfileRuntime = { profileId, + attachment, context, cdp, sessions: new Map(), windowOwners: new Map(), targetPages: new Map(), - userDataDir, - useParkingKeeper: this.platform !== 'darwin' || !cdp, + useParkingKeeper: !cdp, keeperWarningLogged: false, activeCommands: this.profileActivities.get(profileId) ?? 0, closing: false, @@ -751,7 +732,7 @@ export class CloakSessionManager { } if (this.shuttingDown) { runtime.closing = true; - await this.closeRuntime(runtime, false).catch(() => {}); + await this.closeRuntime(runtime).catch(() => {}); throw daemonShuttingDownError(); } this.profiles.set(profileId, runtime); @@ -760,6 +741,9 @@ export class CloakSessionManager { private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + void this.releaseRuntime(runtime, false).catch(error => { + log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); + }); this.cleanupRuntime(runtime); } @@ -770,7 +754,6 @@ export class CloakSessionManager { for (const entry of runtime.targetPages.values()) { if (entry.idleTimer) clearTimeout(entry.idleTimer); this.networkCapture.stop(entry.page); - void this.pageCdpSessions.get(entry.page)?.detach().catch(() => {}); } runtime.targetPages.clear(); runtime.sessions.clear(); @@ -780,7 +763,6 @@ export class CloakSessionManager { waiter.reject(new Error('Target page, context or browser has been closed')); } this.targetPageWaiters.get(runtime)?.clear(); - void runtime.cdp?.detach().catch(() => {}); } private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { @@ -823,7 +805,7 @@ export class CloakSessionManager { runtime.useParkingKeeper = true; if (runtime.keeperWarningLogged) return; runtime.keeperWarningLogged = true; - log.warn(`Cloak Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); + log.warn(`SLAB Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); } private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { @@ -844,7 +826,7 @@ export class CloakSessionManager { } runtime.closing = true; this.profiles.delete(profileId); - await this.closeRuntime(runtime, true); + await this.closeRuntime(runtime); }); }, PROFILE_IDLE_TIMEOUT_MS); runtime.idleTimer.unref?.(); @@ -872,28 +854,37 @@ export class CloakSessionManager { return false; } - private async closeRuntime(runtime: ProfileRuntime, recoverOnTimeout: boolean): Promise { + private async closeRuntime(runtime: ProfileRuntime): Promise { + await this.releaseRuntime(runtime, true); + } + + private async releaseRuntime(runtime: ProfileRuntime, closePages: boolean): Promise { + if (runtime.releasePromise) return runtime.releasePromise; + runtime.releasePromise = (async () => { this.cancelProfileIdle(runtime); for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); - let timeout: ReturnType | undefined; + const pages = [...runtime.targetPages.values()].map(entry => entry.page); try { - await Promise.race([ - runtime.context.close(), - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error('Cloak Profile close timed out')), PROFILE_CLOSE_TIMEOUT_MS); - timeout.unref?.(); - }), - ]); - } catch (error) { - if (recoverOnTimeout && error instanceof Error && error.message === 'Cloak Profile close timed out') { - await this.recoverLockedProfile(runtime.userDataDir); - } else { - throw error; + 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(() => {}), + ]); + await runtime.attachment.release(); } finally { - if (timeout) clearTimeout(timeout); this.cleanupRuntime(runtime); } + })(); + return runtime.releasePromise; } private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { @@ -949,7 +940,7 @@ export class CloakSessionManager { windowMode?: BrowserWindowMode, ): Promise { const openerEntry = this.openEntries(session)[0]?.[1]; - if (!openerEntry) return await this.findReusableLaunchPage(runtime, session.id) ?? this.createWindowPage(runtime, windowMode); + 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); @@ -959,26 +950,13 @@ export class CloakSessionManager { try { await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); } catch (error) { - log.warn(`Cloak window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(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 findReusableLaunchPage(runtime: ProfileRuntime, sessionId: string): Promise { - for (const page of runtime.context.pages()) { - if (pageIsClosed(page) || page === runtime.parkingPage || page.url() !== 'about:blank') continue; - const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); - if (!targetId || targetId === runtime.anchorTargetId || runtime.targetPages.has(targetId)) continue; - const windowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); - if (windowId === undefined) continue; - const owner = runtime.windowOwners.get(windowId); - if (owner === undefined || owner === sessionId) return page; - } - return undefined; - } - private async waitForContextPageForSession( runtime: ProfileRuntime, sessionId: string, @@ -1058,7 +1036,7 @@ export class CloakSessionManager { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.targetPageWaiters.get(runtime)?.delete(targetId); - reject(new Error(`Timed out waiting for Cloak target ${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 }); }); @@ -1183,8 +1161,8 @@ export class CloakSessionManager { this.pageCdpSessions.set(page, session); page.once('close', () => { this.pageTargetIds.delete(page); + void this.detachPageCdp(page); this.pageCdpSessions.delete(page); - void session.detach().catch(() => {}); }); return targetInfo.targetId; })(); @@ -1200,7 +1178,7 @@ export class CloakSessionManager { 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('Cloak page has no CDP session.'); + if (!cdp) throw new Error('SLAB page has no CDP session.'); const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; return windowId; } @@ -1308,6 +1286,15 @@ export class CloakSessionManager { 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 { @@ -1333,40 +1320,3 @@ function requireSession(session: string | undefined): string { function requireSessionId(input: Pick): string { return input.sessionId?.trim() || requireSession(input.session); } - -function isProfileAlreadyInUseError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - return message.includes('Opening in existing browser session') - || message.includes('Failed to create a ProcessSingleton for your profile directory'); -} - -async function recoverLockedCloakProfile(userDataDir: string): Promise { - if (process.platform === 'win32') return false; - const initial = await findExactCloakProfileProcesses(userDataDir); - if (initial.length === 0) return false; - - signalPids(initial, 'SIGTERM'); - if (await waitForProfileProcessesToExit(userDataDir, 2500)) return true; - - signalPids(await findExactCloakProfileProcesses(userDataDir), 'SIGKILL'); - return waitForProfileProcessesToExit(userDataDir, 1500); -} - -async function waitForProfileProcessesToExit(userDataDir: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 100)); - if ((await findExactCloakProfileProcesses(userDataDir)).length === 0) return true; - } - return (await findExactCloakProfileProcesses(userDataDir)).length === 0; -} - -function signalPids(pids: number[], signal: NodeJS.Signals): void { - for (const pid of pids) { - try { - process.kill(pid, signal); - } catch { - // Already exited or not signalable; the follow-up poll decides recovery. - } - } -} diff --git a/src/cli.test.ts b/src/cli.test.ts index b4831fca..53c42932 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1294,12 +1294,12 @@ name: 'search', expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['list', 'rename', 'use']); const list = data.commands.find((cmd: any) => cmd.name === 'list'); expect(list).toMatchObject({ - description: 'List Chrome and Chromium profiles available through the Cloak runtime', + description: 'List Chrome and Chromium profiles available through SLAB', }); const rename = data.commands.find((cmd: any) => cmd.name === 'rename'); expect(rename).toMatchObject({ usage: 'webcmd profile rename [options]', - description: 'Assign a local alias to an available Cloak profile', + description: 'Assign a local alias to an available SLAB profile', positionals: [ { name: 'contextId', required: true }, { name: 'alias', required: true }, @@ -1307,7 +1307,7 @@ name: 'search', }); const use = data.commands.find((cmd: any) => cmd.name === 'use'); expect(use).toMatchObject({ - description: 'Set the default Cloak profile for future commands', + description: 'Set the default SLAB profile for future commands', }); } finally { process.argv = argv; @@ -1692,7 +1692,7 @@ describe('profile list', () => { const output = stdoutSpy.mock.calls.flat().join('\n'); expect(output).toContain('stale'); expect(output).toContain('webcmd daemon restart'); - expect(output).not.toContain('No Cloak profiles available'); + expect(output).not.toContain('No SLAB profiles available'); }); it('uses runtime profile wording when current daemon status has no profiles', async () => { @@ -1716,7 +1716,7 @@ describe('profile list', () => { await program.parseAsync(['node', 'webcmd', 'profile', 'list']); const output = stdoutSpy.mock.calls.flat().join('\n'); - expect(output).toContain('No Cloak runtime profiles are active'); + expect(output).toContain('No SLAB profiles are active'); expect(output).toContain('Run a browser-backed command or webcmd login to create one'); expect(output).not.toContain(`Browser ${'Bridge'}`); expect(output).not.toContain(`Webcmd ${'extension'}`); diff --git a/src/cli.ts b/src/cli.ts index 9f645daf..66d56629 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -787,7 +787,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 SLAB runtime ships separately from npm; surface it if stale. const runtimeNotice = getRuntimeUpdateNotice(); if (runtimeNotice) process.stdout.write(runtimeNotice); console.log('Update complete.'); @@ -1777,7 +1777,7 @@ cli({ profileCmd .command('list') - .description('List Chrome and Chromium profiles available through the Cloak runtime') + .description('List Chrome and Chromium profiles available through SLAB') .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .action(async (opts: { format?: string }, command: Command) => { const fmt = resolveOutputFormat(opts.format); @@ -1823,13 +1823,13 @@ cli({ return; } if (profiles.length === 0) { - console.log('No Cloak runtime profiles are active.'); + console.log('No SLAB profiles are active.'); console.log('Run a browser-backed command or webcmd login to create one.'); return; } const knownContextIds = new Set(profiles.map((profile) => profile.contextId)); - console.log('Available Cloak profiles'); + console.log('Available SLAB profiles'); console.log(); for (const profile of profiles) { const alias = aliasForContextId(config, profile.contextId); @@ -1857,7 +1857,7 @@ cli({ profileCmd .command('rename') - .description('Assign a local alias to an available Cloak profile') + .description('Assign a local alias to an available SLAB profile') .argument('', 'Profile contextId from webcmd profile list') .argument('', 'Local alias, e.g. work or personal') .action((contextId: string, alias: string) => { @@ -1872,12 +1872,12 @@ cli({ profileCmd .command('use') - .description('Set the default Cloak profile for future commands') + .description('Set the default SLAB profile for future commands') .argument('', 'Profile alias or contextId') .action((profile: string) => { try { const config = setDefaultProfile(profile); - console.log(`Default Cloak profile: ${config.defaultContextId ?? profile}`); + console.log(`Default SLAB profile: ${config.defaultContextId ?? profile}`); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.USAGE_ERROR; diff --git a/src/commands/daemon.test.ts b/src/commands/daemon.test.ts index 76902221..463b1cc8 100644 --- a/src/commands/daemon.test.ts +++ b/src/commands/daemon.test.ts @@ -319,7 +319,7 @@ describe('daemonRestart', () => { await daemonRestart(); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`Daemon started on port 9777 (v${PKG_VERSION})`)); - expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Cloak runtime has not connected yet')); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('SLAB runtime has not connected yet')); }); it('reports failure when the daemon cannot stop', async () => { diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index ab69e22c..1ab594e2 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -70,7 +70,7 @@ export async function daemonStop(): Promise { export async function daemonRestart(): Promise { const before = await fetchDaemonStatus(); if (before?.profiles && before.profiles.length > 0) { - log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser ${before.profiles.length === 1 ? 'profile' : 'profiles'}; Cloak should reconnect automatically.`); + log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser ${before.profiles.length === 1 ? 'profile' : 'profiles'}; SLAB should reconnect automatically.`); } const result = await restartDaemon(); @@ -93,6 +93,6 @@ export async function daemonRestart(): Promise { const profileText = profiles > 0 ? `; ${profiles} ${profiles === 1 ? 'profile' : 'profiles'} connected` : ''; log.status(`Runtime connected${profileText}.`); } else { - log.warn('Daemon is running, but the Cloak runtime has not connected yet.'); + log.warn('Daemon is running, but the SLAB runtime has not connected yet.'); } } diff --git a/src/daemon.ts b/src/daemon.ts index f7c25310..1da996c5 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,9 +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 { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js'; +import { LocalSlabRuntimeProvider } from './browser/runtime/local-slab/provider.js'; -const provider = new LocalCloakRuntimeProvider(); +const provider = new LocalSlabRuntimeProvider(); 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 bf6bf3bd..eb1b012c 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1,7 +1,4 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockGetDaemonHealth, @@ -10,8 +7,7 @@ const { mockFindShadowedUserAdapters, mockSendCommand, mockSetDaemonCommandTimeoutSeconds, - mockBinaryInfo, - mockEnsureBinary, + mockFindSlabInstallation, } = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockConnect: vi.fn(), @@ -19,22 +15,13 @@ const { mockFindShadowedUserAdapters: vi.fn(), mockSendCommand: vi.fn(), mockSetDaemonCommandTimeoutSeconds: vi.fn(), - mockBinaryInfo: vi.fn(), - mockEnsureBinary: vi.fn(), + mockFindSlabInstallation: vi.fn(), })); vi.mock('./browser/daemon-transport.js', () => ({ getDaemonHealth: mockGetDaemonHealth, })); -// 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, -})); - vi.mock('./browser/index.js', () => ({ BrowserBridge: class { connect = mockConnect; @@ -47,6 +34,10 @@ vi.mock('./browser/daemon-client.js', () => ({ setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds, })); +vi.mock('./slab/installation.js', () => ({ + findSlabInstallation: mockFindSlabInstallation, +})); + vi.mock('./adapter-shadow.js', async () => { const actual = await vi.importActual('./adapter-shadow.js'); return { @@ -57,12 +48,6 @@ vi.mock('./adapter-shadow.js', async () => { import { checkBrowserBinary, checkConnectivity, renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; -const managedBinaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-managed-binary-')); -const managedBinaryPath = path.join(managedBinaryDir, process.platform === 'win32' ? 'chrome.exe' : 'chrome'); -fs.writeFileSync(managedBinaryPath, '#!/bin/sh\n'); -if (process.platform !== 'win32') fs.chmodSync(managedBinaryPath, 0o755); -afterAll(() => fs.rmSync(managedBinaryDir, { recursive: true, force: true })); - describe('doctor report rendering', () => { const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -71,18 +56,9 @@ describe('doctor report rendering', () => { vi.unstubAllEnvs(); mockFindShadowedUserAdapters.mockReturnValue([]); mockSetDaemonCommandTimeoutSeconds.mockClear(); - mockEnsureBinary.mockResolvedValue(managedBinaryPath); - // Installed by default so pre-existing tests exercise the generic - // connectivity-failure path, not the #239 binary-missing path. - mockBinaryInfo.mockReturnValue({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: managedBinaryPath, - installed: true, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', + mockFindSlabInstallation.mockReturnValue({ + platform: 'darwin', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', }); // Doctor always runs live connectivity. Tests that want connect to fail override. mockConnect.mockResolvedValue({ @@ -97,6 +73,22 @@ describe('doctor report rendering', () => { }); }); + it('reports the SLAB installation through the retained binary status shape', () => { + expect(checkBrowserBinary()).toEqual({ + installed: true, + path: '/Applications/SLAB.app/Contents/MacOS/SLAB', + override: false, + }); + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'SLAB', + binary: checkBrowserBinary(), + issues: [], + })); + expect(text).toContain('[OK] Browser binary: installed at /Applications/SLAB.app/Contents/MacOS/SLAB'); + }); + it('renders OK-style report when daemon and runtime connected', () => { const text = strip(renderBrowserDoctorReport({ cliVersion: '1.7.9', @@ -110,7 +102,7 @@ describe('doctor report rendering', () => { expect(text).toContain('[OK] Daemon: running on port 9777'); expect(text).toContain('(v1.7.9)'); - expect(text).toContain('[OK] Runtime: Cloak connected (v1.6.8)'); + expect(text).toContain('[OK] Runtime: SLAB connected (v1.6.8)'); expect(text).toContain('Everything looks good!'); expect(text).not.toContain('webcmd browser analyze '); }); @@ -140,7 +132,7 @@ describe('doctor report rendering', () => { })); expect(text).toContain('[MISSING] Daemon: not running'); - expect(text).toContain('[MISSING] Runtime: Cloak not connected'); + expect(text).toContain('[MISSING] Runtime: SLAB not connected'); expect(text).toContain('Daemon is not running.'); }); @@ -152,10 +144,10 @@ describe('doctor report rendering', () => { })); expect(text).toContain('[OK] Daemon: running on port 9777'); - expect(text).toContain('[MISSING] Runtime: Cloak not connected'); + expect(text).toContain('[MISSING] Runtime: SLAB not connected'); }); - it('renders OK when the connected Cloak runtime version is unknown', () => { + it('renders OK when the connected SLAB runtime version is unknown', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, runtimeConnected: true, @@ -163,35 +155,11 @@ describe('doctor report rendering', () => { issues: [], })); - expect(text).toContain('[OK] Runtime: Cloak connected (version unknown)'); + expect(text).toContain('[OK] Runtime: SLAB connected (version unknown)'); expect(text).not.toContain('Cloak runtime is connected but did not report a version.'); expect(text).toContain('Everything looks good!'); }); - it('renders the browser binary status line when installed', () => { - const text = strip(renderBrowserDoctorReport({ - daemonRunning: true, - runtimeConnected: true, - runtimeName: 'Cloak', - binary: { installed: true, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, - issues: [], - })); - - 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', () => { - const text = strip(renderBrowserDoctorReport({ - daemonRunning: true, - runtimeConnected: true, - runtimeName: 'Cloak', - binary: { installed: false, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, - issues: ['CloakBrowser Chromium is not installed and could not be downloaded at ...'], - })); - - expect(text).toContain('[MISSING] Browser binary: not installed (/home/test/.cloakbrowser/chromium-1.0.0/chrome)'); - }); - it('renders connectivity OK when live test succeeds', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, @@ -229,7 +197,7 @@ describe('doctor report rendering', () => { issues: ['Cloak runtime connection is unstable.'], })); - expect(text).toContain('[WARN] Runtime: Cloak unstable'); + expect(text).toContain('[WARN] Runtime: SLAB unstable'); expect(text).toContain('Cloak runtime connection is unstable.'); }); @@ -283,7 +251,7 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Default Cloak profile is not active: work (profile-default)'), + expect.stringContaining('Default SLAB profile is not active: work (profile-default)'), ])); expect(report.issues.join('\n')).toContain('fall back to the only active profile: active-profile'); } finally { @@ -301,7 +269,7 @@ describe('doctor report rendering', () => { expect(report.runtimeConnected).toBe(false); expect(report.runtimeFlaky).toBe(true); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Cloak runtime connection is unstable'), + expect.stringContaining('SLAB bridge connection is unstable'), ])); }); @@ -315,8 +283,8 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); const issues = report.issues.join('\n'); - expect(issues).toContain('Cloak runtime is not connected'); - expect(issues).toContain('Make sure Chrome/Chromium is open and Cloak is enabled'); + expect(issues).toContain('SLAB bridge is not connected'); + expect(issues).toContain('Make sure SLAB is running and the browser profile is available'); expect(issues).not.toContain(`Webcmd Browser ${'Bridge'}`); expect(issues).not.toContain(`Load ${'unpacked'}`); expect(issues).not.toContain('Download the latest extension'); @@ -335,7 +303,7 @@ describe('doctor report rendering', () => { ])); }); - it('uses a temporary opaque Session for live connectivity checks', async () => { + it('uses a temporary SLAB Session for live connectivity checks without a binary preflight', async () => { let timeoutSeen: number | undefined; const closeWindow = vi.fn().mockResolvedValue(undefined); mockConnect.mockImplementationOnce(async (opts?: { timeout?: number; session?: string; surface?: string }) => { @@ -364,43 +332,6 @@ describe('doctor report rendering', () => { expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenLastCalledWith(null); }); - it('installs the browser binary before starting the timed live probe', async () => { - let finishInstall!: () => void; - mockEnsureBinary.mockReturnValueOnce(new Promise((resolve) => { - finishInstall = () => resolve(managedBinaryPath); - })); - - const connectivity = checkConnectivity(); - await vi.waitFor(() => expect(mockEnsureBinary).toHaveBeenCalledTimes(1)); - - expect(mockSetDaemonCommandTimeoutSeconds).not.toHaveBeenCalled(); - expect(mockSendCommand).not.toHaveBeenCalled(); - - finishInstall(); - await expect(connectivity).resolves.toMatchObject({ ok: true }); - expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenNthCalledWith(1, 8); - expect(mockSendCommand).toHaveBeenNthCalledWith(1, 'session-create', {}); - expect(mockSendCommand).toHaveBeenLastCalledWith('session-close', { - session: 'session_doctor_11111111', - surface: 'browser', - force: true, - discard: true, - }); - }); - - it('reports binary installation failures without creating a Session', async () => { - mockEnsureBinary.mockRejectedValueOnce(new Error('binary download failed')); - - await expect(checkConnectivity()).resolves.toMatchObject({ - ok: false, - error: 'binary download failed', - }); - expect(mockSendCommand).not.toHaveBeenCalled(); - expect(mockConnect).not.toHaveBeenCalled(); - expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenCalledTimes(1); - expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenCalledWith(null); - }); - it('does not report an issue when the connected Cloak runtime does not report a version', async () => { const status = { state: 'ready' as const, @@ -539,251 +470,6 @@ describe('doctor report rendering', () => { ])); }); - 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?.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'), - ])); - 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 () => { - 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', - }); - mockConnect.mockRejectedValueOnce(new Error('fetch failed')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - - expect(report.binary?.installed).toBe(false); - const issueText = report.issues.join('\n'); - expect(issueText).toContain('CloakBrowser Chromium is not installed'); - expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); - expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); - expect(issueText).toContain('Browser connectivity test failed: fetch failed'); - expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH'); - expect(issueText).not.toContain('could not be downloaded'); - expect(issueText).not.toContain('download failed'); - }); - - it('preserves a session-create connectivity failure alongside missing-binary facts', 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', - }); - mockSendCommand.mockRejectedValueOnce(new Error('session-create refused')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const issueText = report.issues.join('\n'); - - expect(report.connectivity).toMatchObject({ ok: false, error: 'session-create refused' }); - expect(issueText).toContain('Browser connectivity test failed: session-create refused'); - expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); - expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); - expect(issueText).not.toContain('could not be downloaded'); - expect(issueText).not.toContain('download failed'); - expect(mockConnect).not.toHaveBeenCalled(); - }); - - it('reports the binary state after connectivity auto-installs Chromium', async () => { - let installed = false; - mockBinaryInfo.mockImplementation(() => ({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: managedBinaryPath, - installed, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', - })); - mockConnect.mockImplementationOnce(async () => { - installed = true; - return { - evaluate: vi.fn().mockResolvedValue(2), - closeWindow: vi.fn().mockResolvedValue(undefined), - }; - }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const text = strip(renderBrowserDoctorReport(report)); - - expect(report.binary?.installed).toBe(true); - expect(report.issues).toEqual([]); - expect(text).toContain('[OK] Browser binary: installed at'); - expect(text).not.toContain('[MISSING] Browser binary'); - expect(text).toContain('Everything looks good!'); - expect(mockBinaryInfo).toHaveBeenCalledTimes(1); - }); - - it('reports a final missing binary even when connectivity succeeds', async () => { - mockBinaryInfo.mockReturnValue({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome', - installed: false, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', - }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const text = strip(renderBrowserDoctorReport(report)); - - expect(report.connectivity?.ok).toBe(true); - expect(report.binary?.installed).toBe(false); - expect(report.issues.join('\n')).toContain('CloakBrowser Chromium is not installed'); - expect(text).toContain('[MISSING] Browser binary'); - expect(text).not.toContain('Everything looks good!'); - }); - - it('reports binary probe failures as unknown warnings', async () => { - mockBinaryInfo.mockImplementation(() => { - throw new Error('corrupt CloakBrowser metadata'); - }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - const text = strip(renderBrowserDoctorReport(report)); - - expect(report.binary?.installed).toBeUndefined(); - expect(report.issues.join('\n')).toContain('Could not check CloakBrowser Chromium binary: corrupt CloakBrowser metadata'); - expect(text).toContain('[WARN] Browser binary: status unknown'); - expect(text).not.toContain('[OK] Browser binary'); - expect(text).not.toContain('Everything looks good!'); - }); - - it('treats CLOAKBROWSER_BINARY_PATH as the effective binary check, not the managed cache', async () => { - const overridePath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-override-')), - process.platform === 'win32' ? 'chrome.exe' : 'chrome', - ); - fs.writeFileSync(overridePath, '#!/bin/sh\n'); - if (process.platform !== 'win32') fs.chmodSync(overridePath, 0o755); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - // Managed cache would report "not installed" — the override should win. - mockBinaryInfo.mockReturnValue({ - version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'linux-x64', - binaryPath: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', installed: false, - cacheDir: '/home/test/.cloakbrowser/chromium-1.0.0', downloadUrl: 'https://example.test/download', - }); - - 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', () => { - if (process.platform === 'win32') return; - const binaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-managed-non-executable-')); - const binaryPath = path.join(binaryDir, 'chrome'); - fs.writeFileSync(binaryPath, '#!/bin/sh\n', { mode: 0o644 }); - mockBinaryInfo.mockReturnValue({ - version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'linux-x64', - binaryPath, installed: true, cacheDir: binaryDir, downloadUrl: 'https://example.test/download', - }); - try { - expect(checkBrowserBinary().installed).toBe(false); - } finally { - fs.rmSync(binaryDir, { recursive: true, force: true }); - } - }); - - it('rejects a non-exe binary file on Windows', () => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - 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' }); - expect(checkBrowserBinary().installed).toBe(false); - } finally { - if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor); - vi.unstubAllEnvs(); - fs.rmSync(binaryDir, { recursive: true, force: true }); - } - }); - - 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(); - } - }); - }); }); describe('doctor window mode', () => { diff --git a/src/doctor.ts b/src/doctor.ts index aaca731f..9c5098bf 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -4,9 +4,8 @@ * Simplified for the daemon-based architecture. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { binaryInfo, ensureBinary } from 'cloakbrowser'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; import { sendCommand, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; @@ -17,6 +16,7 @@ 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 { findSlabInstallation } from './slab/installation.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -36,7 +36,6 @@ export type BrowserBinaryStatus = { path: string; downloadUrl?: string; error?: string; - /** True when CLOAKBROWSER_BINARY_PATH is set — a different check than the managed cache. */ override: boolean; }; @@ -57,41 +56,11 @@ export type DoctorReport = { issues: string[]; }; -function isLaunchableFile(binaryPath: string): boolean { - try { - if (!fs.statSync(binaryPath).isFile()) return false; - if (process.platform === 'win32') return path.extname(binaryPath).toLowerCase() === '.exe'; - fs.accessSync(binaryPath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -/** - * Check whether the CloakBrowser Chromium binary is actually installed. - * `runtimeConnected: true` only means the - * daemon/Cloak runtime process is healthy — it says nothing about whether the - * browser binary CloakBrowser needs to launch is present on disk, which is - * exactly the gap that made a missing-binary failure look like a generic - * connectivity problem (#239). - */ 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(); - return { - installed: info.installed && isLaunchableFile(info.binaryPath), - path: info.binaryPath, - downloadUrl: info.downloadUrl, - override: false, - }; - } catch (err) { - return { installed: undefined, path: 'unknown', error: getErrorMessage(err), override: false }; - } + const installation = findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }); + return installation + ? { installed: true, path: installation.executablePath, override: false } + : { installed: false, path: '/Applications/SLAB.app', override: false }; } /** @@ -102,8 +71,6 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise.', ); @@ -273,15 +229,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { : report.runtimeVersion ? ` (v${report.runtimeVersion})` : ' (version unknown)'; - const runtimeName = report.runtimeName ?? 'Cloak'; + const runtimeName = 'SLAB'; const runtimeLabel = report.runtimeFlaky ? 'unstable (connected during live check, then disconnected)' : report.runtimeConnected ? 'connected' : 'not connected'; lines.push(`${runtimeIcon} Runtime: ${runtimeName} ${runtimeLabel}${runtimeVersion}`); - // Browser binary status — distinct from "Runtime connected", which only - // reflects the daemon/Cloak process and says nothing about whether the - // Chromium binary Cloak needs to launch is actually installed. if (report.binary) { const binaryIcon = report.binary.installed === undefined ? '[WARN]' @@ -290,7 +243,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { ? 'status unknown' : report.binary.installed ? `installed at ${report.binary.path}` - : `${report.binary.override ? 'not launchable' : 'not installed'} (${report.binary.path})`; + : `not installed (${report.binary.path})`; lines.push(`${binaryIcon} Browser binary: ${binaryLabel}`); } diff --git a/src/errors.test.ts b/src/errors.test.ts index fba9ada6..bacbbf3d 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -9,6 +9,8 @@ import { TimeoutError, ArgumentError, EmptyResultError, + SlabRequiredError, + SlabUpdateRequiredError, selectorError, SessionBusyError, attachTraceReceipt, @@ -27,6 +29,8 @@ describe('Error type hierarchy', () => { new TimeoutError('test', 30), new ArgumentError('test'), new EmptyResultError('test/cmd'), + new SlabRequiredError(), + new SlabUpdateRequiredError('1.0.0', '2.0.0'), selectorError('.btn'), ]; @@ -80,6 +84,24 @@ describe('Error type hierarchy', () => { const err = new BrowserConnectError('Cannot connect'); expect(err.code).toBe('BROWSER_CONNECT'); }); + + it('SLAB errors provide configuration guidance', () => { + const required = new SlabRequiredError(); + const update = new SlabUpdateRequiredError('1.0.0', '2.0.0'); + + expect(required).toMatchObject({ + code: 'SLAB_REQUIRED', + message: 'SLAB is required for local browser commands.', + hint: 'Run `webcmd setup`, choose local mode, and install SLAB.', + exitCode: 78, + }); + expect(update).toMatchObject({ + code: 'SLAB_UPDATE_REQUIRED', + message: 'SLAB 1.0.0 is incompatible with this webcmd version.', + hint: 'Update SLAB to 2.0.0 or newer, then retry.', + exitCode: 78, + }); + }); }); describe('toEnvelope', () => { @@ -124,7 +146,7 @@ describe('toEnvelope', () => { }); it('keeps Session window conflicts on the structured temporary-failure contract', async () => { - const { SessionWindowConflictError } = await import('./browser/runtime/local-cloak/session-manager.js'); + const { SessionWindowConflictError } = await import('./browser/runtime/local-slab/session-manager.js'); expect(toEnvelope(new SessionWindowConflictError('page_1', 'session_a', 'session_b')).error) .toMatchObject({ code: 'SESSION_WINDOW_CONFLICT', exitCode: 75 }); diff --git a/src/errors.ts b/src/errors.ts index 58ac6bc7..31656580 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -112,6 +112,28 @@ export class ConfigError extends CliError { } } +export class SlabRequiredError extends CliError { + constructor() { + super( + 'SLAB_REQUIRED', + 'SLAB is required for local browser commands.', + 'Run `webcmd setup`, choose local mode, and install SLAB.', + EXIT_CODES.CONFIG_ERROR, + ); + } +} + +export class SlabUpdateRequiredError extends CliError { + constructor(installed: string, required: string) { + super( + 'SLAB_UPDATE_REQUIRED', + `SLAB ${installed} is incompatible with this webcmd version.`, + `Update SLAB to ${required} or newer, then retry.`, + EXIT_CODES.CONFIG_ERROR, + ); + } +} + export class AuthRequiredError extends CliError { readonly domain: string; constructor(domain: string, message?: string) { diff --git a/src/execution.test.ts b/src/execution.test.ts index 4bce5b16..d0b37a2a 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -33,6 +33,7 @@ import { cli, Strategy } from './registry.js'; import { withTimeoutMs } from './runtime.js'; import * as runtime from './runtime.js'; import * as capRouting from './capabilityRouting.js'; +import * as slabInstallation from './slab/installation.js'; import { clearDaemonRunContext, getDaemonRunContext } from './session-lease.js'; import { sendCommand } from './browser/daemon-client.js'; @@ -616,6 +617,7 @@ describe('executeCommand — non-browser timeout', () => { let firstRunId: string | undefined; let secondRunId: string | undefined; const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; + vi.spyOn(slabInstallation, 'isSlabInstalled').mockReturnValue(true); vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage)); vi.spyOn(runtime, 'runWithTimeout') diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index a0667e35..cd5d8ace 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; import { createServer, type Server } from 'node:http'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -163,6 +163,23 @@ describe('hosted CLI process lifecycle', () => { await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); + it('keeps hosted list, adapter, profile, and auth commands away from local SLAB and daemon paths', async () => { + const fixture = await createHostedFixture('success'); + + for (const argv of [ + ['list'], + ['lifecycle', 'stream', '-f', 'plain'], + ['profile', 'list'], + ['auth', 'status', '-f', 'plain'], + ]) { + expect((await runCli(argv, fixture.env)).status).toBe(0); + } + + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(path.join(fixture.root, 'slab-accessed'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(path.join(fixture.root, 'daemon-started'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + it('writes the live view before the prepared browser run completes', async () => { const fixture = await createHostedFixture('browser'); const cli = startCli(['live', 'view', '-f', 'plain'], fixture.env); @@ -231,6 +248,10 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): const configDir = path.join(root, 'config'); const userClis = path.join(root, '.webcmd', 'clis', 'lifecycle-sentinel'); const discoverySentinel = path.join(root, 'local-discovery-ran'); + const slabAccessed = path.join(root, 'slab-accessed'); + const slabExecutable = path.join(root, 'Applications', 'SLAB.app', 'Contents', 'MacOS', 'SLAB'); + const daemonStarted = path.join(root, 'daemon-started'); + const daemonPreload = path.join(root, 'daemon-sentinel.mjs'); const requests: string[] = []; let markRunStarted: () => void = () => undefined; const runStarted = new Promise(resolve => { markRunStarted = resolve; }); @@ -238,6 +259,33 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): const waitForRunRelease = new Promise(resolve => { releaseRun = resolve; }); await mkdir(configDir, { recursive: true }); await mkdir(userClis, { recursive: true }); + await mkdir(path.dirname(slabExecutable), { recursive: true }); + await writeFile(slabExecutable, [ + '#!/usr/bin/env node', + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(slabAccessed)}, 'accessed');`, + "throw new Error('SLAB sentinel was accessed');", + '', + ].join('\n')); + await chmod(slabExecutable, 0o755); + await writeFile(daemonPreload, [ + "import fs from 'node:fs';", + "import { syncBuiltinESMExports } from 'node:module';", + "import { writeFileSync } from 'node:fs';", + 'const originalExistsSync = fs.existsSync;', + 'fs.existsSync = function existsSyncTrap(candidate) {', + " if (String(candidate).includes('SLAB.app')) {", + ` writeFileSync(${JSON.stringify(slabAccessed)}, 'discovered');`, + " throw new Error('SLAB discovery sentinel was accessed');", + ' }', + ' return originalExistsSync.apply(this, arguments);', + '};', + 'syncBuiltinESMExports();', + "if (process.argv.some(arg => arg.endsWith('/src/daemon.ts'))) {", + ` writeFileSync(${JSON.stringify(daemonStarted)}, 'started');`, + '}', + '', + ].join('\n')); await writeFile(path.join(userClis, 'sentinel.js'), [ "import { writeFileSync } from 'node:fs';", `writeFileSync(${JSON.stringify(discoverySentinel)}, 'read');`, @@ -263,6 +311,10 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): }); return; } + if (request.url === '/v1/profiles') { + sendChunkedJson(response, { ok: true, profiles: [] }); + return; + } if (request.url === '/v1/executions' && request.method === 'POST' && outcome === 'browser') { sendChunkedJson(response, { ok: true, @@ -348,6 +400,7 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): USERPROFILE: root, WEBCMD_CONFIG_DIR: configDir, WEBCMD_NO_UPDATE_CHECK: '1', + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${pathToFileURL(daemonPreload).href}`.trim(), }, }; } diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 1946d74d..c8eeced4 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getConfigPath } from './config.js'; import { getHostedCredentialPath } from './credentials.js'; -import { runHostedSetup } from './setup.js'; +import { runHostedSetup, type SetupIo } from './setup.js'; let tempDir: string | undefined; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -17,7 +17,68 @@ afterEach(async () => { tempDir = undefined; }); +function hostedSetupFixture(overrides: Partial): SetupIo { + const answers = ['hosted', 'wcmd_live_test']; + return { + env: { WEBCMD_CONFIG_DIR: tempDir, WEBCMD_CREDENTIAL_BACKEND: 'file' }, + question: async () => answers.shift() ?? '', + fetchImpl: async () => new Response(JSON.stringify({ user: { id: 'user_demo' } }), { status: 200 }), + write: () => {}, + ...overrides, + }; +} + +function statusFixture(overrides: Partial & { mode: 'hosted' | 'local' }): SetupIo { + return { + env: { WEBCMD_CONFIG_DIR: tempDir }, + readFileSync: () => JSON.stringify({ + mode: overrides.mode, + updatedAt: '2026-07-08T00:00:00.000Z', + ...(overrides.mode === 'hosted' ? { hosted: { apiBaseUrl: 'https://api.webcmd.dev', apiKeyRef: 'wcmd_cred_test' } } : {}), + }), + existsSync: () => true, + ...overrides, + status: true, + } as SetupIo; +} + describe('webcmd setup', () => { + it('offers SLAB only after local mode is selected', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-')); + const answers = ['local', 'yes']; + const installSlab = vi.fn().mockResolvedValue({ platform: 'darwin', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }); + await runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + question: async () => answers.shift() ?? '', + isSlabInstalled: () => false, + installSlab, + ensureBridgeReady: async () => {}, + write: () => {}, + }); + expect(installSlab).toHaveBeenCalledOnce(); + }); + + it('never checks SLAB for hosted mode', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-hosted-slab-')); + const isSlabInstalled = vi.fn(() => { throw new Error('must not run'); }); + await runHostedSetup(hostedSetupFixture({ isSlabInstalled })); + expect(isSlabInstalled).not.toHaveBeenCalled(); + }); + + it('reports configured hosted mode without probing SLAB', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-status-slab-')); + const isSlabInstalled = vi.fn(() => { throw new Error('must not run'); }); + const messages: string[] = []; + const code = await runHostedSetup(statusFixture({ + mode: 'hosted', + isSlabInstalled, + write: message => { messages.push(message); }, + })); + expect(code).toBe(0); + expect(messages.join('')).toBe('{"configured":true,"mode":"hosted"}\n'); + expect(isSlabInstalled).not.toHaveBeenCalled(); + }); + it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); const answers = ['local']; @@ -114,6 +175,19 @@ describe('webcmd setup', () => { .toMatchObject({ mode: 'local' }); }, 20_000); + it('supports non-interactive local setup and JSON status', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-options-')); + const env = { ...process.env, WEBCMD_CONFIG_DIR: tempDir, WEBCMD_NO_UPDATE_CHECK: '1' }; + + const local = await runSetupProcess(['setup', '--mode', 'local'], env); + expect(local.status).toBe(0); + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ mode: 'local' }); + + const status = await runSetupProcess(['setup', '--status', '--format', 'json'], env); + expect(status.status).toBe(0); + expect(status.stdout).toBe('{"configured":true,"mode":"local"}\n'); + }, 20_000); + it('does not resolve until all caller-owned output writes complete', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slow-output-')); const output = new SetupControlledWritable(); @@ -172,6 +246,22 @@ describe('webcmd setup', () => { }); }); +async function runSetupProcess(args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string }> { + const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts', ...args], { + cwd: packageRoot, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk))); + child.stdin.end(); + const status = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + return { status, stdout: Buffer.concat(stdout).toString('utf8') }; +} + 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 0d395b8e..1573c582 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,10 +1,14 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import { writeToStream } from '../stream-write.js'; import { HostedClient } from './client.js'; import { defaultHostedApiBaseUrl, makeLocalConfig, + getConfigPath, + loadWebcmdConfig, saveWebcmdConfig, type ConfigIo, } from './config.js'; @@ -14,6 +18,8 @@ import { type HostedCredentialBackend, type HostedCredentialIo, } from './credentials.js'; +import type { InstallSlabOptions } from '../slab/install.js'; +import type { SlabInstallation, SlabInstallationIo } from '../slab/installation.js'; export interface SetupIo extends ConfigIo, HostedCredentialIo { input?: NodeJS.ReadableStream; @@ -21,6 +27,11 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { fetchImpl?: typeof fetch; question?: (prompt: string) => Promise; write?: (message: string) => void | Promise; + mode?: 'hosted' | 'local'; + status?: boolean; + isSlabInstalled?: (io: SlabInstallationIo) => boolean; + installSlab?: (options?: InstallSlabOptions) => Promise; + ensureBridgeReady?: () => Promise; } export async function runHostedSetup(io: SetupIo = {}): Promise { @@ -34,9 +45,27 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { const ask = io.question ?? ((prompt: string) => ownedReadline!.question(prompt)); try { + if (io.status) { + const config = loadWebcmdConfig(io); + await write(`${JSON.stringify({ configured: (io.existsSync ?? existsSync)(getConfigPath(io)), mode: config.mode })}\n`); + return 0; + } await write('Webcmd setup\n'); - const mode = await ask('Use hosted Webcmd Cloud or local Webcmd? [hosted/local] '); + const mode = io.mode ?? await ask('Use hosted Webcmd Cloud or local Webcmd? [hosted/local] '); if (mode.trim().toLowerCase().startsWith('l')) { + if (io.isSlabInstalled || io.installSlab || (io.input as NodeJS.ReadStream | undefined)?.isTTY || defaultInput.isTTY) { + await write('Local webcmd requires the SLAB browser.\n'); + const { isSlabInstalled, installSlab, ensureBridgeReady } = await slabHooks(io); + if (!isSlabInstalled()) { + const consent = await ask('Install SLAB now? [Y/n] '); + if (!consent.trim() || consent.trim().toLowerCase().startsWith('y')) { + await installSlab(); + await ensureBridgeReady(); + } else { + await write('SLAB was not installed. The next local browser command will ask again.\n'); + } + } + } saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io); await write('Webcmd is now configured for local mode.\n'); return 0; @@ -81,6 +110,41 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } } +async function slabHooks(io: SetupIo): Promise<{ + isSlabInstalled: () => boolean; + installSlab: () => Promise; + ensureBridgeReady: () => Promise; +}> { + if (io.isSlabInstalled && io.installSlab && io.ensureBridgeReady) { + return { + isSlabInstalled: () => io.isSlabInstalled!({ + platform: io.platform ?? process.platform, + homeDir: io.homeDir ?? homedir(), + existsSync: io.existsSync ?? existsSync, + }), + installSlab: () => io.installSlab!({ launchAfterInstall: true }), + ensureBridgeReady: io.ensureBridgeReady, + }; + } + + const [{ isSlabInstalled }, { createSlabInstallerIo, installSlabMacos }, { ensureBrowserBridgeReady }] = await Promise.all([ + import('../slab/installation.js'), + import('../slab/install.js'), + import('../browser/daemon-lifecycle.js'), + ]); + return { + isSlabInstalled: () => (io.isSlabInstalled ?? isSlabInstalled)({ + platform: io.platform ?? process.platform, + homeDir: io.homeDir ?? homedir(), + existsSync: io.existsSync ?? existsSync, + }), + installSlab: () => io.installSlab + ? io.installSlab({ launchAfterInstall: true }) + : installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true }), + ensureBridgeReady: io.ensureBridgeReady ?? (async () => { await ensureBrowserBridgeReady({ verbose: false }); }), + }; +} + function hostedAccountLabel(body: unknown): string | undefined { if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined; const user = (body as { user?: unknown }).user; diff --git a/src/main.ts b/src/main.ts index bf2aa703..62ed0a29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -74,7 +74,11 @@ if (!fastPathHandled && argv[0] === 'completion' && argv.length >= 2) { if (!fastPathHandled) { if (argv[0] === 'setup') { const { runHostedSetup } = await import('./hosted/setup.js'); - process.exitCode = await runHostedSetup(); + const mode = argv[2] === 'local' || argv[2] === 'hosted' ? argv[2] : undefined; + process.exitCode = await runHostedSetup({ + ...(mode ? { mode } : {}), + ...(argv[1] === '--status' && argv[2] === '--format' && argv[3] === 'json' ? { status: true } : {}), + }); } else if (argv[0] === 'skills' || argv[0] === 'update') { const { createProgram } = await import('./cli.js'); await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' }); diff --git a/src/postinstall.test.ts b/src/postinstall.test.ts index 2035fa44..af81acdd 100644 --- a/src/postinstall.test.ts +++ b/src/postinstall.test.ts @@ -33,4 +33,42 @@ describe('postinstall', () => { expect(result.stdout).toContain('webcmd plugin install '); expect(result.stdout).not.toMatch(/spotify|youtube|reddit|twitter/i); }); + + it('does not block without a TTY and points local setup to SLAB onboarding', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-postinstall-slab-')); + roots.push(home); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + SHELL: '/bin/zsh', + npm_config_global: 'true', + }; + delete env.CI; + delete env.CONTINUOUS_INTEGRATION; + + const result = spawnSync(process.execPath, ['scripts/postinstall.js'], { env, encoding: 'utf8' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('SLAB browser is required for local webcmd commands. Run `webcmd setup` to install it.'); + }); + + it('prints SLAB guidance when the global install shell is unknown', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-postinstall-unknown-shell-')); + roots.push(home); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + SHELL: '/bin/unknown', + npm_config_global: 'true', + }; + delete env.CI; + delete env.CONTINUOUS_INTEGRATION; + + const result = spawnSync(process.execPath, ['scripts/postinstall.js'], { env, encoding: 'utf8' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('SLAB browser is required for local webcmd commands. Run `webcmd setup` to install it.'); + }); }); diff --git a/src/skills.test.ts b/src/skills.test.ts index bfe8b5c8..fa658b36 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -74,7 +74,7 @@ describe('webcmd skills content', () => { } expect(guide).toMatch(/web fetch.*(?:remains|runs).*local/i); expect(guide).toMatch(/web fetch.*never opens a browser/i); - expect(guide).toMatch(/local.*Cloak[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); + expect(guide).toMatch(/local.*SLAB[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); expect(guide).not.toMatch(/fetch-browser|web read|--browser/i); } expect(skill).toContain('Search Summary'); diff --git a/src/slab/install-cli.ts b/src/slab/install-cli.ts new file mode 100644 index 00000000..c0bf17df --- /dev/null +++ b/src/slab/install-cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { createSlabInstallerIo, installSlabMacos } from './install.js'; + +if (process.platform === 'darwin' && process.env.WEBCMD_INSTALL_SLAB === '1') { + installSlabMacos(createSlabInstallerIo()).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts new file mode 100644 index 00000000..9c6fd1e1 --- /dev/null +++ b/src/slab/install.test.ts @@ -0,0 +1,120 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { installSlabMacos, 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; + canWriteSystemApplications?: boolean; + verifyManifest?: boolean; +} = {}) { + const operations: string[] = []; + const execFile = vi.fn(async (command: string, args: string[]) => { + 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' && args.includes('--identifier')) operations.push('codesign-verify'); + if (command === 'spctl') operations.push('spctl-verify'); + }); + const access = vi.fn(async (path: string, _mode?: number) => { + if (path === '/Applications' && options.canWriteSystemApplications === false) throw new Error('not writable'); + }); + const replaceApp = vi.fn(async () => { operations.push('replace-app'); }); + const io: SlabInstallerIo & { operations(): string[]; execFile: typeof execFile; access: typeof access; replaceApp: typeof replaceApp } = { + 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 () => (options.downloadedBytes ?? releaseBytes) }, + 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: vi.fn(async () => { operations.push('cleanup'); }), + access, + replaceApp, + verifyManifest: async () => options.verifyManifest ?? true, + bundleId: async () => options.bundleId ?? 'dev.webcmd.slab', + operations: () => operations.filter((operation) => operation !== 'cleanup'), + }; + return io; +} + +function fakeInstallerWithValidDmg() { + return fakeInstaller(); +} + +describe('SLAB macOS installer', () => { + 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('stages, verifies, and then replaces the app', async () => { + const io = fakeInstallerWithValidDmg(); + + await installSlabMacos(io); + + expect(io.operations()).toEqual([ + 'download', 'checksum', 'mount-readonly', 'copy-to-staging', + 'codesign-verify', 'spctl-verify', 'replace-app', 'detach', + ]); + }); + + it('rejects an unexpected app bundle identifier', async () => { + const io = fakeInstaller({ bundleId: 'com.example.other' }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer bundle identifier mismatch'); + }); + + it('rejects an invalid signed release manifest', async () => { + const io = fakeInstaller({ verifyManifest: false }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer release signature verification failed'); + expect(io.execFile).not.toHaveBeenCalled(); + }); + + it('falls back to the user Applications directory when the system directory is not writable', async () => { + const io = fakeInstaller({ canWriteSystemApplications: false }); + + await expect(installSlabMacos(io)).resolves.toMatchObject({ + executablePath: '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB', + }); + }); + + it('checks whether system Applications is writable before selecting it', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.access).toHaveBeenCalledWith('/Applications', constants.W_OK); + }); + + it('stages beside the selected application destination', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.replaceApp).toHaveBeenCalledWith('/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); + }); + + it('detaches and removes temporary files when verification fails after mounting', async () => { + const io = fakeInstaller({ bundleId: 'com.example.other' }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer bundle identifier mismatch'); + expect(io.operations()).toContain('detach'); + expect(io.operations()).not.toContain('replace-app'); + expect(io.rm).toHaveBeenCalledWith('/tmp/slab-install'); + }); +}); diff --git a/src/slab/install.ts b/src/slab/install.ts new file mode 100644 index 00000000..c8a24fbf --- /dev/null +++ b/src/slab/install.ts @@ -0,0 +1,143 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { access, mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import type { SlabInstallation } from './installation.js'; +import { type SlabReleaseManifest, verifySlabReleaseManifest } from './release-key.js'; + +const execFileAsync = promisify(execFileCallback); +export const SLAB_MACOS_MANIFEST_URL = 'https://downloads.webcmd.dev/slab/stable/macos.json'; +const SLAB_BUNDLE_ID = 'dev.webcmd.slab'; + +type FetchResponse = { + ok: boolean; + status?: number; + json?: () => Promise; + arrayBuffer?: () => Promise; +}; + +type ExecResult = { stdout?: string } | string | void; + +export interface SlabInstallerIo { + homeDir: string; + tempDir: string; + fetch(url: string): Promise; + execFile(command: string, args: string[]): Promise; + mkdtemp(prefix: string): Promise; + writeFile(path: string, data: Uint8Array): Promise; + sha256?(bytes: Uint8Array): Promise; + mkdir(path: string): Promise; + rm(path: string): Promise; + access(path: string, mode: number): Promise; + replaceApp(source: string, destination: string): Promise; + verifyManifest?(manifest: SlabReleaseManifest): Promise | boolean; + bundleId(appPath: string): Promise; +} + +export interface InstallSlabOptions { + launchAfterInstall?: boolean; +} + +function manifestFrom(value: unknown): SlabReleaseManifest { + if (!value || typeof value !== 'object') throw new Error('SLAB installer release manifest is invalid'); + const { url, sha256, signature } = value as Record; + if (typeof url !== 'string' || typeof sha256 !== 'string' || typeof signature !== 'string') { + throw new Error('SLAB installer release manifest is invalid'); + } + return { url, sha256, signature }; +} + +async function responseJson(response: FetchResponse): Promise { + if (!response.ok || !response.json) throw new Error(`SLAB installer download failed${response.status ? ` (${response.status})` : ''}`); + return response.json(); +} + +async function responseBytes(response: FetchResponse): Promise { + if (!response.ok || !response.arrayBuffer) throw new Error(`SLAB installer download failed${response.status ? ` (${response.status})` : ''}`); + return new Uint8Array(await response.arrayBuffer()); +} + +export async function installSlabMacos(io: SlabInstallerIo, options: InstallSlabOptions = {}): Promise { + const manifest = manifestFrom(await responseJson(await io.fetch(SLAB_MACOS_MANIFEST_URL))); + if (!await (io.verifyManifest?.(manifest) ?? verifySlabReleaseManifest(manifest))) { + throw new Error('SLAB installer release signature verification failed'); + } + + const tempPath = await io.mkdtemp(join(io.tempDir, 'webcmd-slab-')); + const dmgPath = join(tempPath, 'SLAB.dmg'); + const mountPath = join(tempPath, 'mount'); + let stagingPath: string | undefined; + let mounted = false; + + try { + const bytes = await responseBytes(await io.fetch(manifest.url)); + 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 = join(io.homeDir, 'Applications'); + await io.mkdir(applicationsDir); + } + const appPath = join(applicationsDir, 'SLAB.app'); + stagingPath = join(applicationsDir, '.SLAB.app.webcmd-staging'); + await io.rm(stagingPath); + await io.execFile('ditto', [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 io.execFile('spctl', ['--assess', '--type', 'execute', '--verbose=4', stagingPath]); + await io.replaceApp(stagingPath, appPath); + stagingPath = undefined; + if (options.launchAfterInstall) await io.execFile('open', [appPath]); + return { platform: 'darwin', executablePath: 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) => execFileAsync(command, args), + mkdtemp, + writeFile, + mkdir: async (path) => { await mkdir(path, { recursive: true }); }, + rm: async (path) => { await rm(path, { recursive: true, force: true }); }, + access: async (path, mode) => { await access(path, mode); }, + bundleId: async (appPath) => { + const result = await execFileAsync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents/Info.plist')]); + return result.stdout.trim(); + }, + replaceApp: async (source, destination) => { + const previous = `${destination}.previous`; + await rm(previous, { recursive: true, force: true }); + try { await rename(destination, previous); } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + await rename(source, destination); + } catch (error) { + await rename(previous, destination).catch(() => {}); + throw error; + } + await rm(previous, { recursive: true, force: true }); + }, + }; +} diff --git a/src/slab/installation.test.ts b/src/slab/installation.test.ts new file mode 100644 index 00000000..57aedda4 --- /dev/null +++ b/src/slab/installation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findSlabInstallation, isSlabInstalled } from './installation.js'; + +describe('SLAB installation discovery', () => { + it('finds the first valid macOS app bundle', () => { + const exists = vi.fn((candidate: string) => candidate === '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB'); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync: exists })).toEqual({ + platform: 'darwin', + executablePath: '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB', + }); + }); + + it('returns null without probing another platform', () => { + const existsSync = vi.fn(() => false); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync })).toBeNull(); + expect(existsSync.mock.calls.flat().join(' ')).not.toMatch(/Program Files|\/opt\/slab/); + }); + + it('reports whether SLAB is installed', () => { + expect(isSlabInstalled({ + platform: 'darwin', + homeDir: '/Users/me', + existsSync: (candidate) => candidate === '/Applications/SLAB.app/Contents/MacOS/SLAB', + })).toBe(true); + }); +}); diff --git a/src/slab/installation.ts b/src/slab/installation.ts new file mode 100644 index 00000000..1cd80858 --- /dev/null +++ b/src/slab/installation.ts @@ -0,0 +1,28 @@ +export interface SlabInstallation { + platform: NodeJS.Platform; + 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 executablePath of [ + '/Applications/SLAB.app/Contents/MacOS/SLAB', + `${io.homeDir}/Applications/SLAB.app/Contents/MacOS/SLAB`, + ]) { + if (io.existsSync(executablePath)) return { platform: io.platform, executablePath }; + } + + return null; +} + +export function isSlabInstalled(io: SlabInstallationIo): boolean { + return findSlabInstallation(io) !== null; +} diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts new file mode 100644 index 00000000..25b596fe --- /dev/null +++ b/src/slab/launch.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SlabBridgeUnavailableError } from '../browser/runtime/local-slab/bridge-client.js'; +import { SlabUpdateRequiredError } from '../errors.js'; +import { launchSlab } from './launch.js'; + +describe('SLAB launch', () => { + it('launches the installed CLI bridge when no app bundle is present', async () => { + const io = { + findInstallation: () => null, + isRunning: vi.fn(() => false), + launch: vi.fn(async () => {}), + restart: vi.fn(async () => {}), + hello: vi.fn() + .mockRejectedValueOnce(new SlabBridgeUnavailableError('offline')) + .mockResolvedValue({ protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }), + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toMatchObject({ protocolVersion: 1 }); + expect(io.launch).toHaveBeenCalledWith('slab-browser'); + }); + + it('restarts an unavailable installed browser once', async () => { + const hello = vi.fn() + .mockRejectedValueOnce(new SlabBridgeUnavailableError('offline')) + .mockRejectedValueOnce(new SlabBridgeUnavailableError('offline')) + .mockResolvedValue({ protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }); + const io = { + findInstallation: () => ({ platform: 'darwin' as const, executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + isRunning: () => true, + launch: vi.fn(async () => {}), + restart: vi.fn(async () => {}), + hello, + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toMatchObject({ protocolVersion: 1 }); + expect(io.restart).toHaveBeenCalledOnce(); + }); + + it('keeps polling for bridge readiness for up to five seconds', async () => { + const hello = vi.fn() + .mockRejectedValueOnce(new SlabBridgeUnavailableError('offline')) + .mockRejectedValueOnce(new SlabBridgeUnavailableError('offline')) + .mockResolvedValue({ protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }); + const io = { + findInstallation: () => ({ platform: 'darwin' as const, executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + isRunning: () => false, + launch: vi.fn(async () => {}), restart: vi.fn(async () => {}), hello, + wait: vi.fn(async () => {}), now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toMatchObject({ protocolVersion: 1 }); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('does not restart for a protocol incompatibility', async () => { + const error = new SlabUpdateRequiredError('2', 'protocol v1'); + const io = { + findInstallation: () => ({ platform: 'darwin' as const, executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + isRunning: vi.fn(() => true), launch: vi.fn(async () => {}), restart: vi.fn(async () => {}), + hello: vi.fn(async () => { throw error; }), wait: vi.fn(async () => {}), now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).rejects.toBe(error); + expect(io.restart).not.toHaveBeenCalled(); + }); + + it('returns one repair command after the readiness window expires', async () => { + const io = { + findInstallation: () => ({ platform: 'darwin' as const, executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + isRunning: () => true, launch: vi.fn(async () => {}), restart: vi.fn(async () => {}), + hello: vi.fn(async () => { throw new SlabBridgeUnavailableError('offline'); }), + wait: vi.fn(async () => {}), now: vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).rejects.toMatchObject({ hint: 'Run `webcmd setup` to repair SLAB.' }); + expect(io.restart).toHaveBeenCalledOnce(); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('checks the running macOS app bundle instead of the inner launcher binary', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + vi.doMock('node:child_process', async () => ({ + execFile: (command: string, args: string[], callback: (error?: Error | null) => void) => { + calls.push({ command, args }); + callback(null); + }, + spawn: vi.fn(), + })); + vi.resetModules(); + const { createSlabLaunchIo } = await import('./launch.js'); + const io = createSlabLaunchIo(); + const executable = '/Applications/SLAB.app/Contents/MacOS/SLAB'; + + await io.isRunning(executable); + await io.restart(executable); + + expect(calls).toEqual([ + { command: 'pgrep', args: ['-f', '/Applications/SLAB.app'] }, + { command: 'pkill', args: ['-f', '/Applications/SLAB.app'] }, + { command: 'open', args: ['/Applications/SLAB.app'] }, + ]); + }); +}); diff --git a/src/slab/launch.ts b/src/slab/launch.ts new file mode 100644 index 00000000..16adaa77 --- /dev/null +++ b/src/slab/launch.ts @@ -0,0 +1,76 @@ +import { execFile as execFileCallback, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname } from 'node:path'; +import { promisify } from 'node:util'; +import { ConfigError } from '../errors.js'; +import { SlabBridgeClient, SlabBridgeUnavailableError } from '../browser/runtime/local-slab/bridge-client.js'; +import type { SlabHelloResult } from '../browser/runtime/local-slab/protocol.js'; +import { findSlabInstallation, type SlabInstallation } from './installation.js'; +import { SlabUpdateRequiredError } from '../errors.js'; + +const execFile = promisify(execFileCallback); + +export interface SlabLaunchIo { + findInstallation(): SlabInstallation | null; + isRunning(executablePath: string): boolean | Promise; + launch(executablePath: string): Promise; + restart(executablePath: string): Promise; + hello(): Promise; + wait(): Promise; + now(): number; +} + +export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { + const installation = io.findInstallation() ?? { platform: process.platform, executablePath: 'slab-browser' }; + try { return await io.hello(); } catch (error) { + if (error instanceof SlabUpdateRequiredError || !(error instanceof SlabBridgeUnavailableError)) throw error; + } + if (await io.isRunning(installation.executablePath)) await io.restart(installation.executablePath); + else await io.launch(installation.executablePath); + + const deadline = io.now() + 5_000; + for (;;) { + try { return await io.hello(); } catch (error) { + if (error instanceof SlabUpdateRequiredError || !(error instanceof SlabBridgeUnavailableError)) throw error; + if (io.now() >= deadline) throw new ConfigError('SLAB bridge is unavailable.', 'Run `webcmd setup` to repair SLAB.'); + await io.wait(); + } + } +} + +export function createSlabLaunchIo(): SlabLaunchIo { + const client = new SlabBridgeClient(); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + isRunning: async (executablePath) => execFile('pgrep', ['-f', appProcessPattern(executablePath)]).then(() => true, () => false), + launch: async (executablePath) => { + if (executablePath === 'slab-browser') { + const child = spawn(executablePath, ['bridge'], { detached: true, stdio: 'ignore' }); + child.unref(); + return; + } + await execFile('open', [appBundlePath(executablePath)]); + }, + restart: async (executablePath) => { + await execFile('pkill', ['-f', appProcessPattern(executablePath)]).catch(() => {}); + if (executablePath === 'slab-browser') { + const child = spawn(executablePath, ['bridge'], { detached: true, stdio: 'ignore' }); + child.unref(); + } else { + await execFile('open', [appBundlePath(executablePath)]); + } + }, + hello: () => client.hello('webcmd'), + wait: () => new Promise((resolve) => setTimeout(resolve, 100)), + now: Date.now, + }; +} + +function appProcessPattern(executablePath: string): string { + return executablePath === 'slab-browser' ? executablePath : appBundlePath(executablePath); +} + +function appBundlePath(executablePath: string): string { + return dirname(dirname(dirname(executablePath))); +} diff --git a/src/slab/release-key.ts b/src/slab/release-key.ts new file mode 100644 index 00000000..652543d6 --- /dev/null +++ b/src/slab/release-key.ts @@ -0,0 +1,19 @@ +import { verify } from 'node:crypto'; + +export const SLAB_RELEASE_PUBLIC_KEY: string | undefined = undefined; + +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/update-check.ts b/src/update-check.ts index 04dee7c5..7d1a9f67 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -117,7 +117,7 @@ function buildUpdateNotices({ cliVersion, cache, now }: NoticeInputs): NoticeLin ) { lines.extension = `\n Runtime update available: v${currentExtensionVersion} → v${latestExtensionVersion}\n` + - ` Update the ${PRODUCT_DISPLAY_NAME} Cloak runtime from AgentR release artifacts.\n`; + ` Update the ${PRODUCT_DISPLAY_NAME} SLAB runtime from webcmd release artifacts.\n`; } return lines; } diff --git a/src/update.ts b/src/update.ts index 6029e165..b6e45215 100644 --- a/src/update.ts +++ b/src/update.ts @@ -25,7 +25,7 @@ export function buildUpgradeCommand(spec: string = `${PACKAGE_NAME}@latest`): Up } /** - * Notice for the separately-shipped Cloak runtime/extension, which `npm install -g` + * Notice for the separately-shipped SLAB runtime, which `npm install -g` * does NOT update. Returns the notice text when a newer runtime is known, else * undefined. Currently dormant until update-check URLs are enabled upstream. */ diff --git a/tests/e2e/cloak-runtime.test.ts b/tests/e2e/slab-runtime.test.ts similarity index 91% rename from tests/e2e/cloak-runtime.test.ts rename to tests/e2e/slab-runtime.test.ts index a116ff83..0727116f 100644 --- a/tests/e2e/cloak-runtime.test.ts +++ b/tests/e2e/slab-runtime.test.ts @@ -25,7 +25,7 @@ function isolatedOptions(options: Parameters[1] = {}): Parameters } function browserRun(session: string, source: string, options: Parameters[1] = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-run-')); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-run-')); sourceDirs.push(dir); const sourcePath = path.join(dir, 'program.js'); fs.writeFileSync(sourcePath, source); @@ -39,8 +39,8 @@ async function createSession(options: Parameters[1] = {}) { } beforeAll(async () => { - sharedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-suite-')); - sharedProfile = `cloak-suite-${Date.now()}`; + sharedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-suite-')); + sharedProfile = `slab-suite-${Date.now()}`; sourceDirs.push(sharedConfigDir); server = http.createServer((req, res) => { if (req.url === '/cookie') { @@ -67,7 +67,7 @@ beforeAll(async () => { return; } - res.end('Cloak Smoke'); + res.end('SLAB Smoke'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); @@ -80,7 +80,7 @@ afterAll(async () => { for (const dir of sourceDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); -describe('Cloak runtime e2e', () => { +describe.skipIf(process.env.WEBCMD_LIVE_SLAB !== '1')('SLAB runtime e2e', () => { it('runs Playwright against a page through webcmd browser', async () => { const session = await createSession({ timeout: 120_000 }); const result = await browserRun(session, ` @@ -88,10 +88,10 @@ describe('Cloak runtime e2e', () => { return await page.evaluate(() => document.title + ':' + window.answer); `, { timeout: 120_000 }); expect(result.code).toBe(0); - expect(result.stdout).toContain('Cloak Smoke:42'); + expect(result.stdout).toContain('SLAB Smoke:42'); }, 180_000); - it('persists cookies inside the Cloak profile', async () => { + it('persists cookies inside the SLAB profile', async () => { const session = await createSession({ timeout: 120_000 }); const cookies = await browserRun(session, ` await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); @@ -103,7 +103,7 @@ describe('Cloak runtime e2e', () => { it('survives sequential open and evaluate cycles in one persistent profile', async () => { const profile = `task5-${Date.now()}`; - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-sequential-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-sequential-')); const run = (args: string[]) => runCli(args, { timeout: 120_000, env: { @@ -158,7 +158,7 @@ describe('Cloak runtime e2e', () => { const status = await run(['daemon', 'status']); expect(status.code).toBe(0); expect(status.stdout).toContain('Daemon: running'); - expect(status.stdout).toContain('Runtime: cloak connected'); + expect(status.stdout).toContain('Runtime: SLAB connected'); expect(status.stdout).toContain(`Profiles: ${profile}`); } finally { const stopped = await run(['daemon', 'stop']); diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/slab-session-concurrency.test.ts similarity index 80% rename from tests/e2e/cloak-session-concurrency.test.ts rename to tests/e2e/slab-session-concurrency.test.ts index 809f37f2..d49485b5 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/slab-session-concurrency.test.ts @@ -4,9 +4,10 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { CloakSessionManager } from '../../src/browser/runtime/local-cloak/session-manager.js'; -import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-cloak/process-matcher.js'; -import { resolveCloakProfileDir } from '../../src/browser/runtime/local-cloak/profiles.js'; +import { SlabSessionManager } from '../../src/browser/runtime/local-slab/session-manager.js'; +import { findExactSlabProfileProcesses } from '../../src/browser/runtime/local-slab/process-matcher.js'; +import { resolveSlabProfileDir } from '../../src/browser/runtime/local-slab/profiles.js'; +import { findSlabInstallation } from '../../src/slab/installation.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); let server: http.Server; @@ -29,24 +30,21 @@ afterAll(async () => { for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); -describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrency gate', () => { - it('keeps Cloak and Playwright pinned to the supported live gate runtime', () => { +describe.skipIf(process.env.WEBCMD_LIVE_SLAB !== '1')('SLAB Session concurrency gate', () => { + it('keeps SLAB and Playwright pinned to the supported live gate runtime', () => { const appPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); - const cloakPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/package.json'), 'utf8')); const playwrightPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/playwright-core/package.json'), 'utf8')); - const cloakConfig = fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/dist/config.js'), 'utf8'); + const slab = findSlabInstallation({ platform: process.platform, homeDir: os.homedir(), existsSync: fs.existsSync }); - expect(appPkg.dependencies.cloakbrowser).toBe('0.4.5'); + expect(slab?.executablePath).toMatch(/SLAB\.app\/Contents\/MacOS\/SLAB$/u); expect(appPkg.dependencies['playwright-core']).toBe('1.61.1'); - expect(cloakPkg.version).toBe('0.4.5'); expect(playwrightPkg.version).toBe('1.61.1'); - expect(cloakConfig).toContain('"darwin-arm64": "145.0.7632.109.2"'); }); it('covers isolated Profiles, explicit Session windows, noopener pages, close survival, and keeper repair', async () => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-session-gate-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-session-gate-')); tempDirs.push(configDir); - const manager = new CloakSessionManager({ baseDir: configDir }); + const manager = new SlabSessionManager({ baseDir: configDir }); const profileA = `gate-a-${Date.now()}`; const profileB = `gate-b-${Date.now()}`; const keyA = { @@ -57,7 +55,7 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc }; const keyB = { ...keyA, profileId: profileB, session: 'session_22222222-2222-4222-8222-222222222222', sessionId: 'session_22222222-2222-4222-8222-222222222222' }; const keyA2 = { ...keyA, session: 'session_33333333-3333-4333-8333-333333333333', sessionId: 'session_33333333-3333-4333-8333-333333333333' }; - const windowId = async (page: Awaited>['page']) => { + const windowId = async (page: Awaited>['page']) => { const cdp = await page.context().newCDPSession(page); try { const target = await cdp.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; @@ -106,9 +104,9 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc }, 180_000); it('falls back to a Session-owned page when window.open is blocked', async () => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-fallback-gate-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-fallback-gate-')); tempDirs.push(configDir); - const manager = new CloakSessionManager({ baseDir: configDir }); + const manager = new SlabSessionManager({ baseDir: configDir }); const key = { profileId: `gate-fallback-${Date.now()}`, session: 'session_44444444-4444-4444-8444-444444444444', @@ -135,10 +133,10 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc } }, 180_000); - it('distinguishes work and work-2 Cloak processes from real ps output', async () => { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-process-gate-')); + it('distinguishes work and work-2 SLAB processes from real ps output', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-process-gate-')); tempDirs.push(configDir); - const manager = new CloakSessionManager({ baseDir: configDir }); + const manager = new SlabSessionManager({ baseDir: configDir }); const work = { profileId: 'work', session: 'session_55555555-5555-4555-8555-555555555555', sessionId: 'session_55555555-5555-4555-8555-555555555555', surface: 'browser' as const }; const work2 = { profileId: 'work-2', session: 'session_66666666-6666-4666-8666-666666666666', sessionId: 'session_66666666-6666-4666-8666-666666666666', surface: 'browser' as const }; try { @@ -148,8 +146,8 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc work2Page.page.goto(`${baseUrl}/work-2`), ]); - const workProcesses = await findExactCloakProfileProcesses(resolveCloakProfileDir('work', { baseDir: configDir })); - const work2Processes = await findExactCloakProfileProcesses(resolveCloakProfileDir('work-2', { baseDir: configDir })); + const workProcesses = await findExactSlabProfileProcesses(resolveSlabProfileDir('work', { baseDir: configDir })); + const work2Processes = await findExactSlabProfileProcesses(resolveSlabProfileDir('work-2', { baseDir: configDir })); expect(workProcesses.length).toBeGreaterThan(0); expect(work2Processes.length).toBeGreaterThan(0); expect(workProcesses.every(pid => !work2Processes.includes(pid))).toBe(true); diff --git a/vitest.config.ts b/vitest.config.ts index 806d64f2..18e1d5b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.ts'], - exclude: ['src/browser/runtime/local-cloak/browser-run.test.ts'], + exclude: ['src/browser/runtime/local-slab/browser-run.test.ts'], sequence: { groupOrder: 0 }, }, }, @@ -54,8 +54,8 @@ export default defineConfig({ 'tests/e2e/output-formats.test.ts', 'tests/e2e/plugin-management.test.ts', 'tests/e2e/article-download-pipeline.test.ts', - 'tests/e2e/cloak-runtime.test.ts', - 'tests/e2e/cloak-session-concurrency.test.ts', + 'tests/e2e/slab-runtime.test.ts', + 'tests/e2e/slab-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts', // Extended browser tests (20+ sites) — opt-in only: // WEBCMD_E2E=1 npx vitest run