From e0b86a44765047a805f002247bfb4a7820c22623 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:33:01 +0530 Subject: [PATCH 01/26] feat: detect local SLAB installation --- src/errors.test.ts | 22 ++++++++++++++++++++++ src/errors.ts | 22 ++++++++++++++++++++++ src/slab/installation.test.ts | 28 ++++++++++++++++++++++++++++ src/slab/installation.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/slab/installation.test.ts create mode 100644 src/slab/installation.ts diff --git a/src/errors.test.ts b/src/errors.test.ts index fba9ada6..13abc941 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', () => { 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/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; +} From 7457b2adf06b280c622104f815b8bcde1a1fb04b Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:39:13 +0530 Subject: [PATCH 02/26] feat: install signed SLAB releases on macOS --- src/slab/install-cli.ts | 9 +++ src/slab/install.test.ts | 101 +++++++++++++++++++++++++++++ src/slab/install.ts | 133 +++++++++++++++++++++++++++++++++++++++ src/slab/release-key.ts | 19 ++++++ 4 files changed, 262 insertions(+) create mode 100644 src/slab/install-cli.ts create mode 100644 src/slab/install.test.ts create mode 100644 src/slab/install.ts create mode 100644 src/slab/release-key.ts 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..6221c73a --- /dev/null +++ b/src/slab/install.test.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +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 io: SlabInstallerIo & { operations(): string[]; execFile: typeof execFile } = { + 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: async (path) => { + if (path === '/Applications' && options.canWriteSystemApplications === false) throw new Error('not writable'); + }, + replaceApp: async () => { operations.push('replace-app'); }, + 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('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..4bdedfb2 --- /dev/null +++ b/src/slab/install.ts @@ -0,0 +1,133 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +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; +}; + +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): 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'); + const stagingPath = join(tempPath, 'SLAB.app'); + 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; + await io.execFile('ditto', [join(mountPath, 'SLAB.app'), stagingPath]); + await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); + const bundleId = await io.bundleId?.(stagingPath); + if (bundleId && bundleId !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); + await io.execFile('spctl', ['--assess', '--type', 'execute', '--verbose=4', stagingPath]); + + let applicationsDir = '/Applications'; + try { + await io.access(applicationsDir); + } catch { + applicationsDir = join(io.homeDir, 'Applications'); + await io.mkdir(applicationsDir); + } + const appPath = join(applicationsDir, 'SLAB.app'); + await io.replaceApp(stagingPath, appPath); + 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 { + await io.rm(tempPath); + } + } +} + +export function createSlabInstallerIo(): SlabInstallerIo { + return { + homeDir: homedir(), + tempDir: tmpdir(), + fetch: globalThis.fetch, + execFile: async (command, args) => { await 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) => { await access(path); }, + 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/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'), + ); +} From 48b22769cb6bfa87fd05798fcaa053c208bdf09c Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:43:09 +0530 Subject: [PATCH 03/26] fix: harden SLAB macOS installation --- src/slab/install.test.ts | 29 ++++++++++++++++++++++++----- src/slab/install.ts | 34 ++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts index 6221c73a..9c6fd1e1 100644 --- a/src/slab/install.test.ts +++ b/src/slab/install.test.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import { installSlabMacos, type SlabInstallerIo } from './install.js'; @@ -20,7 +21,11 @@ function fakeInstaller(options: { if (command === 'codesign' && args.includes('--identifier')) operations.push('codesign-verify'); if (command === 'spctl') operations.push('spctl-verify'); }); - const io: SlabInstallerIo & { operations(): string[]; execFile: typeof execFile } = { + 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') @@ -35,10 +40,8 @@ function fakeInstaller(options: { }, mkdir: async () => {}, rm: vi.fn(async () => { operations.push('cleanup'); }), - access: async (path) => { - if (path === '/Applications' && options.canWriteSystemApplications === false) throw new Error('not writable'); - }, - replaceApp: async () => { operations.push('replace-app'); }, + access, + replaceApp, verifyManifest: async () => options.verifyManifest ?? true, bundleId: async () => options.bundleId ?? 'dev.webcmd.slab', operations: () => operations.filter((operation) => operation !== 'cleanup'), @@ -90,6 +93,22 @@ describe('SLAB macOS installer', () => { }); }); + 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' }); diff --git a/src/slab/install.ts b/src/slab/install.ts index 4bdedfb2..c8a24fbf 100644 --- a/src/slab/install.ts +++ b/src/slab/install.ts @@ -1,5 +1,6 @@ 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'; @@ -18,20 +19,22 @@ type FetchResponse = { 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; + 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): Promise; + access(path: string, mode: number): Promise; replaceApp(source: string, destination: string): Promise; verifyManifest?(manifest: SlabReleaseManifest): Promise | boolean; - bundleId?(appPath: string): Promise; + bundleId(appPath: string): Promise; } export interface InstallSlabOptions { @@ -66,7 +69,7 @@ export async function installSlabMacos(io: SlabInstallerIo, options: InstallSlab const tempPath = await io.mkdtemp(join(io.tempDir, 'webcmd-slab-')); const dmgPath = join(tempPath, 'SLAB.dmg'); const mountPath = join(tempPath, 'mount'); - const stagingPath = join(tempPath, 'SLAB.app'); + let stagingPath: string | undefined; let mounted = false; try { @@ -78,27 +81,30 @@ export async function installSlabMacos(io: SlabInstallerIo, options: InstallSlab await io.mkdir(mountPath); await io.execFile('hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountPath, dmgPath]); mounted = true; - await io.execFile('ditto', [join(mountPath, 'SLAB.app'), stagingPath]); - await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); - const bundleId = await io.bundleId?.(stagingPath); - if (bundleId && bundleId !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); - await io.execFile('spctl', ['--assess', '--type', 'execute', '--verbose=4', stagingPath]); let applicationsDir = '/Applications'; try { - await io.access(applicationsDir); + 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); } } @@ -109,12 +115,16 @@ export function createSlabInstallerIo(): SlabInstallerIo { homeDir: homedir(), tempDir: tmpdir(), fetch: globalThis.fetch, - execFile: async (command, args) => { await execFileAsync(command, args); }, + 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) => { await access(path); }, + 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 }); From a2bd915c7a63698e6d063746132467c0f1830969 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:50:25 +0530 Subject: [PATCH 04/26] feat: offer SLAB during local setup --- scripts/postinstall.js | 37 +++++++++++++++- src/hosted/setup.test.ts | 92 +++++++++++++++++++++++++++++++++++++++- src/hosted/setup.ts | 66 +++++++++++++++++++++++++++- src/main.ts | 6 ++- src/postinstall.test.ts | 19 +++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 70bbff5f..05bfbca2 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; @@ -143,6 +146,8 @@ function main() { } } + await offerSlabInstall(); + // ── Plugin discovery hint ─────────────────────────────────────────── console.log(''); console.log(' \x1b[1mNext step — install a site plugin\x1b[0m'); @@ -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/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..a375d941 100644 --- a/src/postinstall.test.ts +++ b/src/postinstall.test.ts @@ -33,4 +33,23 @@ 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.'); + }); }); From 156c312b29945ab6cc7b01226e925d32ce72581c Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:52:55 +0530 Subject: [PATCH 05/26] fix: show SLAB guidance without a shell --- scripts/postinstall.js | 4 ++-- src/postinstall.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 05bfbca2..db6cfde6 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -88,6 +88,8 @@ async function main() { return; } + await offerSlabInstall(); + const shell = detectShell(); if (!shell) { // Cannot determine shell; silently skip @@ -146,8 +148,6 @@ async function main() { } } - await offerSlabInstall(); - // ── Plugin discovery hint ─────────────────────────────────────────── console.log(''); console.log(' \x1b[1mNext step — install a site plugin\x1b[0m'); diff --git a/src/postinstall.test.ts b/src/postinstall.test.ts index a375d941..af81acdd 100644 --- a/src/postinstall.test.ts +++ b/src/postinstall.test.ts @@ -52,4 +52,23 @@ describe('postinstall', () => { 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.'); + }); }); From 88c662443ee256dd64d4afc351025aaa71303804 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 00:58:27 +0530 Subject: [PATCH 06/26] feat: connect to the SLAB bridge --- .../runtime/local-slab/bridge-client.test.ts | 47 ++++++ .../runtime/local-slab/bridge-client.ts | 155 ++++++++++++++++++ src/browser/runtime/local-slab/protocol.ts | 19 +++ src/slab/launch.test.ts | 21 +++ src/slab/launch.ts | 55 +++++++ 5 files changed, 297 insertions(+) create mode 100644 src/browser/runtime/local-slab/bridge-client.test.ts create mode 100644 src/browser/runtime/local-slab/bridge-client.ts create mode 100644 src/browser/runtime/local-slab/protocol.ts create mode 100644 src/slab/launch.test.ts create mode 100644 src/slab/launch.ts 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..dccf692a --- /dev/null +++ b/src/browser/runtime/local-slab/bridge-client.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { SlabBridgeClient } from './bridge-client.js'; + +function fakeSocket() { + const client = Object.assign(new EventEmitter(), { + destroy() {}, + write() { return true; }, + }); + const server = { write: (line: string) => client.emit('data', Buffer.from(line)) }; + return { client, server }; +} + +describe('SLAB bridge client', () => { + 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('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); + }); +}); 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..3c99cf15 --- /dev/null +++ b/src/browser/runtime/local-slab/bridge-client.ts @@ -0,0 +1,155 @@ +import { createConnection } from 'node:net'; +import { tmpdir } 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; + +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; +}; + +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 ?? join(tmpdir(), 'slab-bridge.sock'); + this.#connect = options.connect ?? (() => createConnection(socketPath)); + } + + hello(clientVersion: string): Promise { + return this.#request('hello', { clientVersion }, (result) => { + if (!isRecord(result) || result.protocolVersion !== 1) { + 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', { 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', { 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.#pending.delete(id); + reject(new Error(`SLAB bridge ${method} request timed out`)); + }, 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) => this.#onData(chunk.toString())); + socket.on('error', (error: Error) => { if (this.#socket === socket) this.#failAll(error); }); + socket.on('close', () => { if (this.#socket === socket) this.#failAll(new Error('SLAB bridge connection closed')); }); + } + + #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): void { + const socket = this.#socket; + this.#socket = undefined; + socket?.destroy(); + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pending.clear(); + } +} 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/slab/launch.test.ts b/src/slab/launch.test.ts new file mode 100644 index 00000000..5487751e --- /dev/null +++ b/src/slab/launch.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { launchSlab } from './launch.js'; + +describe('SLAB launch', () => { + it('restarts an unavailable installed browser once', async () => { + const hello = vi.fn() + .mockRejectedValueOnce(new Error('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 () => {}), + }; + + await expect(launchSlab(io)).resolves.toMatchObject({ protocolVersion: 1 }); + expect(io.restart).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/slab/launch.ts b/src/slab/launch.ts new file mode 100644 index 00000000..f96eaac0 --- /dev/null +++ b/src/slab/launch.ts @@ -0,0 +1,55 @@ +import { execFile as execFileCallback } 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, SlabRequiredError } from '../errors.js'; +import { SlabBridgeClient } 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'; + +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; +} + +export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { + const installation = io.findInstallation(); + if (!installation) throw new SlabRequiredError(); + try { + return await io.hello(); + } catch { + if (await io.isRunning(installation.executablePath)) { + await io.restart(installation.executablePath); + } else { + await io.launch(installation.executablePath); + } + await io.wait(); + try { + return await io.hello(); + } catch { + throw new ConfigError('SLAB bridge is unavailable.', 'Run `webcmd setup` to repair SLAB.'); + } + } +} + +export function createSlabLaunchIo(): SlabLaunchIo { + const client = new SlabBridgeClient(); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + isRunning: async (executablePath) => execFile('pgrep', ['-f', executablePath]).then(() => true, () => false), + launch: async (executablePath) => { await execFile('open', [dirname(dirname(dirname(executablePath)))]); }, + restart: async (executablePath) => { + await execFile('pkill', ['-f', executablePath]).catch(() => {}); + await execFile('open', [dirname(dirname(dirname(executablePath)))]); + }, + hello: () => client.hello('webcmd'), + wait: () => new Promise((resolve) => setTimeout(resolve, 100)), + }; +} From d06615f563a64a981e9431daa6aeb3496e13afe9 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:03:19 +0530 Subject: [PATCH 07/26] fix: wait for the SLAB bridge --- .../runtime/local-slab/bridge-client.test.ts | 66 ++++++++++++++++++- .../runtime/local-slab/bridge-client.ts | 19 ++++-- src/slab/launch.test.ts | 47 ++++++++++++- src/slab/launch.ts | 30 +++++---- 4 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/browser/runtime/local-slab/bridge-client.test.ts b/src/browser/runtime/local-slab/bridge-client.test.ts index dccf692a..f93d7baa 100644 --- a/src/browser/runtime/local-slab/bridge-client.test.ts +++ b/src/browser/runtime/local-slab/bridge-client.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { SlabBridgeClient } from './bridge-client.js'; +import { SlabBridgeClient, SlabBridgeUnavailableError } from './bridge-client.js'; function fakeSocket() { const client = Object.assign(new EventEmitter(), { @@ -44,4 +44,68 @@ describe('SLAB bridge client', () => { 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 index 3c99cf15..7c7e8a1b 100644 --- a/src/browser/runtime/local-slab/bridge-client.ts +++ b/src/browser/runtime/local-slab/bridge-client.ts @@ -7,6 +7,8 @@ import type { SlabAttachment, SlabHelloResult, SlabProfile } from './protocol.js const MAX_RESPONSE_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 5_000; +export class SlabBridgeUnavailableError extends Error {} + interface BridgeSocket { destroy(): unknown; on(event: string, listener: (...args: any[]) => void): unknown; @@ -93,8 +95,7 @@ export class SlabBridgeClient { const id = String(this.#nextId++); return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.#pending.delete(id); - reject(new Error(`SLAB bridge ${method} request timed out`)); + 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`); @@ -105,9 +106,9 @@ export class SlabBridgeClient { if (this.#socket) return; const socket = this.#connect(); this.#socket = socket; - socket.on('data', (chunk: Buffer | string) => this.#onData(chunk.toString())); - socket.on('error', (error: Error) => { if (this.#socket === socket) this.#failAll(error); }); - socket.on('close', () => { if (this.#socket === socket) this.#failAll(new Error('SLAB bridge connection closed')); }); + 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 { @@ -142,13 +143,17 @@ export class SlabBridgeClient { 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): void { + #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(error); + pending.reject(reason); } this.#pending.clear(); } diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts index 5487751e..4d6a7f23 100644 --- a/src/slab/launch.test.ts +++ b/src/slab/launch.test.ts @@ -1,10 +1,13 @@ 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('restarts an unavailable installed browser once', async () => { const hello = vi.fn() - .mockRejectedValueOnce(new Error('offline')) + .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' }), @@ -13,9 +16,51 @@ describe('SLAB launch', () => { 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(); + }); }); diff --git a/src/slab/launch.ts b/src/slab/launch.ts index f96eaac0..4e626399 100644 --- a/src/slab/launch.ts +++ b/src/slab/launch.ts @@ -4,9 +4,10 @@ import { homedir } from 'node:os'; import { dirname } from 'node:path'; import { promisify } from 'node:util'; import { ConfigError, SlabRequiredError } from '../errors.js'; -import { SlabBridgeClient } from '../browser/runtime/local-slab/bridge-client.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); @@ -17,24 +18,24 @@ export interface SlabLaunchIo { restart(executablePath: string): Promise; hello(): Promise; wait(): Promise; + now(): number; } export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { const installation = io.findInstallation(); if (!installation) throw new SlabRequiredError(); - try { - return await io.hello(); - } catch { - if (await io.isRunning(installation.executablePath)) { - await io.restart(installation.executablePath); - } else { - await io.launch(installation.executablePath); - } - await io.wait(); - try { - return await io.hello(); - } catch { - throw new ConfigError('SLAB bridge is unavailable.', 'Run `webcmd setup` to repair SLAB.'); + 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(); } } } @@ -51,5 +52,6 @@ export function createSlabLaunchIo(): SlabLaunchIo { }, hello: () => client.hello('webcmd'), wait: () => new Promise((resolve) => setTimeout(resolve, 100)), + now: Date.now, }; } From dc74852ff9acaa17eb893839661f407f45a19fe5 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:07:40 +0530 Subject: [PATCH 08/26] refactor: rename local runtime boundary to SLAB --- .../{local-cloak => local-slab}/actions.ts | 14 +- .../browser-run.test.ts | 38 ++--- .../cloak-version.test.ts | 0 .../darwin-background-launch.test.ts | 0 .../darwin-background-launch.ts | 0 .../{local-cloak => local-slab}/downloads.ts | 0 .../network.test.ts | 12 +- .../{local-cloak => local-slab}/network.ts | 2 +- .../process-matcher.test.ts | 0 .../process-matcher.ts | 0 .../profiles.test.ts | 0 .../{local-cloak => local-slab}/profiles.ts | 0 .../provider.test.ts | 0 .../{local-cloak => local-slab}/provider.ts | 10 +- .../session-manager.test.ts | 142 +++++++++--------- .../session-manager.ts | 10 +- src/daemon.ts | 2 +- src/errors.test.ts | 2 +- 18 files changed, 116 insertions(+), 116 deletions(-) rename src/browser/runtime/{local-cloak => local-slab}/actions.ts (96%) rename src/browser/runtime/{local-cloak => local-slab}/browser-run.test.ts (83%) rename src/browser/runtime/{local-cloak => local-slab}/cloak-version.test.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/darwin-background-launch.test.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/darwin-background-launch.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/downloads.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/network.test.ts (93%) rename src/browser/runtime/{local-cloak => local-slab}/network.ts (99%) rename src/browser/runtime/{local-cloak => local-slab}/process-matcher.test.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/process-matcher.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/profiles.test.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/profiles.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/provider.test.ts (100%) rename src/browser/runtime/{local-cloak => local-slab}/provider.ts (96%) rename src/browser/runtime/{local-cloak => local-slab}/session-manager.test.ts (94%) rename src/browser/runtime/{local-cloak => local-slab}/session-manager.ts (99%) diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-slab/actions.ts similarity index 96% rename from src/browser/runtime/local-cloak/actions.ts rename to src/browser/runtime/local-slab/actions.ts index c12b35a5..6bb6a568 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(); @@ -37,7 +37,7 @@ class CloakActionError extends Error { } } -export function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string { +export function resolveCloakCommandProfileId(manager: SlabSessionManager, command: BrowserRuntimeCommand): string { const requested = command.profileId ?? command.contextId; if (requested?.trim()) return requested.trim(); @@ -62,7 +62,7 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR return { id: command.id, ok: false, errorCode: 'invalid_request', error }; } -async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { +async function resolveLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { @@ -90,7 +90,7 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim }); } -async function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { +async function resolveExistingLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { @@ -198,7 +198,7 @@ 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': { 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..87792f48 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, }); @@ -45,7 +45,7 @@ afterAll(async () => { describe('local Cloak browser run', () => { it('returns a bounded redacted snapshot for the current page', async () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-1', 'snapshot')); + 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/cloak-version.test.ts b/src/browser/runtime/local-slab/cloak-version.test.ts similarity index 100% rename from src/browser/runtime/local-cloak/cloak-version.test.ts rename to src/browser/runtime/local-slab/cloak-version.test.ts diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts b/src/browser/runtime/local-slab/darwin-background-launch.test.ts similarity index 100% rename from src/browser/runtime/local-cloak/darwin-background-launch.test.ts rename to src/browser/runtime/local-slab/darwin-background-launch.test.ts diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-slab/darwin-background-launch.ts similarity index 100% rename from src/browser/runtime/local-cloak/darwin-background-launch.ts rename to src/browser/runtime/local-slab/darwin-background-launch.ts 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-cloak/process-matcher.test.ts b/src/browser/runtime/local-slab/process-matcher.test.ts similarity index 100% rename from src/browser/runtime/local-cloak/process-matcher.test.ts rename to src/browser/runtime/local-slab/process-matcher.test.ts diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-slab/process-matcher.ts similarity index 100% rename from src/browser/runtime/local-cloak/process-matcher.ts rename to src/browser/runtime/local-slab/process-matcher.ts diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-slab/profiles.test.ts similarity index 100% rename from src/browser/runtime/local-cloak/profiles.test.ts rename to src/browser/runtime/local-slab/profiles.test.ts diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-slab/profiles.ts similarity index 100% rename from src/browser/runtime/local-cloak/profiles.ts rename to src/browser/runtime/local-slab/profiles.ts diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts similarity index 100% rename from src/browser/runtime/local-cloak/provider.test.ts rename to src/browser/runtime/local-slab/provider.test.ts diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-slab/provider.ts similarity index 96% rename from src/browser/runtime/local-cloak/provider.ts rename to src/browser/runtime/local-slab/provider.ts index 8e8b6457..8800a0ba 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -1,10 +1,10 @@ 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, resolveCloakCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; import { - CloakSessionManager, + SlabSessionManager, resolveCloakBrowserVersion, } from './session-manager.js'; @@ -15,7 +15,7 @@ export interface LocalCloakRuntimeProviderOptions { } export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { - private readonly manager: CloakSessionManager; + private readonly manager: SlabSessionManager; private readonly sessions: LocalBrowserSessionStore; private readonly sessionQueues = new Map>(); @@ -24,7 +24,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { 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() @@ -120,7 +120,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 94% rename from src/browser/runtime/local-cloak/session-manager.test.ts rename to src/browser/runtime/local-slab/session-manager.test.ts index 82fc8d18..d43c27c4 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -1,9 +1,9 @@ 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>>(); @@ -167,7 +167,7 @@ function expectedProfileDir(profileId: string): string { return path.join('/tmp/webcmd-test', 'cloak', 'profiles', profileId); } -describe('CloakSessionManager', () => { +describe('SlabSessionManager', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); @@ -176,7 +176,7 @@ describe('CloakSessionManager', () => { it('launches one persistent context per profile and reuses named sessions', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ + const manager = new SlabSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext, }); @@ -191,7 +191,7 @@ describe('CloakSessionManager', () => { 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), @@ -210,7 +210,7 @@ describe('CloakSessionManager', () => { it('reuses the fresh launch about:blank page for the first Session window', 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), @@ -233,7 +233,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 +248,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 +271,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 +288,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 +308,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 +335,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 +359,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 +381,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), @@ -402,7 +402,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 +419,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 +440,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 +462,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 +480,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 +495,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 +522,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, @@ -538,7 +538,7 @@ describe('CloakSessionManager', () => { 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), @@ -559,7 +559,7 @@ describe('CloakSessionManager', () => { 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), @@ -577,7 +577,7 @@ describe('CloakSessionManager', () => { 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 +600,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 +623,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 +645,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 +676,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 +700,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 +730,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 +754,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 +788,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 +818,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 +840,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 +858,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 +878,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 +902,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 +939,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', @@ -962,7 +962,7 @@ describe('CloakSessionManager', () => { .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 +977,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 +996,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 +1038,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 +1050,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 +1063,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 +1083,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 +1100,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 +1118,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 +1138,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), }); @@ -1153,12 +1153,12 @@ describe('CloakSessionManager', () => { it('launches a preferred profile when no Cloak 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 +1180,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 +1210,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 +1227,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 +1240,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 +1256,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 +1269,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 +1278,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 +1302,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 +1325,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 +1348,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 +1373,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 +1395,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 +1411,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 +1436,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 +1460,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 +1486,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 +1500,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 +1532,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, diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts similarity index 99% rename from src/browser/runtime/local-cloak/session-manager.ts rename to src/browser/runtime/local-slab/session-manager.ts index f2e91dfe..6385f1df 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -6,7 +6,7 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr 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 { SlabNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; import { findExactCloakProfileProcesses } from './process-matcher.js'; import { log } from '../../../logger.js'; @@ -140,7 +140,7 @@ export class SessionWindowConflictError extends CliError { } } -export interface CloakSessionManagerOptions { +export interface SlabSessionManagerOptions { baseDir?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; @@ -183,8 +183,8 @@ 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; @@ -209,7 +209,7 @@ export class CloakSessionManager { private readonly sessionPageListeners = new WeakMap void>>(); private shuttingDown = false; - constructor(private readonly opts: CloakSessionManagerOptions = {}) { + constructor(private readonly opts: SlabSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext; this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; diff --git a/src/daemon.ts b/src/daemon.ts index f7c25310..5acef232 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,7 +3,7 @@ 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 { LocalCloakRuntimeProvider } from './browser/runtime/local-slab/provider.js'; const provider = new LocalCloakRuntimeProvider(); const daemon = createDaemonServer(provider, { port: DEFAULT_DAEMON_PORT, host: '127.0.0.1', version: PKG_VERSION }); diff --git a/src/errors.test.ts b/src/errors.test.ts index 13abc941..bacbbf3d 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -146,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 }); From 78222f42df3f27bc8549ce7bfc0c1963a6037860 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:10:02 +0530 Subject: [PATCH 09/26] test: preserve browser run exclusion after SLAB rename --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 806d64f2..bde9a9b5 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 }, }, }, From c3f9a830dbe857d68099e29e8bf429a1d73bc4c7 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:11:18 +0530 Subject: [PATCH 10/26] refactor: finish local runtime SLAB rename --- src/browser/runtime/local-slab/provider.test.ts | 16 ++++++++-------- src/browser/runtime/local-slab/provider.ts | 6 +++--- src/daemon.ts | 4 ++-- tests/e2e/cloak-session-concurrency.test.ts | 14 +++++++------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/browser/runtime/local-slab/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts index daf0187d..7be94c68 100644 --- a/src/browser/runtime/local-slab/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,13 +149,13 @@ 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' }); + const provider = new LocalSlabRuntimeProvider({ baseDir: '/tmp/webcmd-test' }); await expect(provider.status()).resolves.toMatchObject({ runtimeConnected: true, runtimeName: 'cloak', @@ -167,7 +167,7 @@ describe('LocalCloakRuntimeProvider', () => { 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 +193,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', @@ -499,7 +499,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 +548,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', diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts index 8800a0ba..8047e423 100644 --- a/src/browser/runtime/local-slab/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -8,18 +8,18 @@ import { resolveCloakBrowserVersion, } from './session-manager.js'; -export interface LocalCloakRuntimeProviderOptions { +export interface LocalSlabRuntimeProviderOptions { baseDir?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } -export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { +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, diff --git a/src/daemon.ts b/src/daemon.ts index 5acef232..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-slab/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/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index 809f37f2..6cf9cdf4 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -4,9 +4,9 @@ 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 { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-slab/process-matcher.js'; +import { resolveCloakProfileDir } from '../../src/browser/runtime/local-slab/profiles.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); let server: http.Server; @@ -46,7 +46,7 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc 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-')); 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 +57,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 } }; @@ -108,7 +108,7 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc 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-')); 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', @@ -138,7 +138,7 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc 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-')); 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 { From 032ae7d36657de4b01420b70af497629bb1fd39a Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:13:54 +0530 Subject: [PATCH 11/26] refactor: finish SLAB internal boundary rename --- src/browser/runtime/local-slab/actions.ts | 36 +++++++++---------- src/browser/runtime/local-slab/provider.ts | 4 +-- .../runtime/local-slab/session-manager.ts | 24 ++++++------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts index 6bb6a568..28ed97d2 100644 --- a/src/browser/runtime/local-slab/actions.ts +++ b/src/browser/runtime/local-slab/actions.ts @@ -26,7 +26,7 @@ function snapshotBaselineStore(manager: SlabSessionManager): SnapshotBaselineSto 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: SlabSessionManager, command: BrowserRuntimeCommand): string { +export function resolveSlabCommandProfileId(manager: SlabSessionManager, command: BrowserRuntimeCommand): string { const requested = command.profileId ?? command.contextId; if (requested?.trim()) return requested.trim(); @@ -48,7 +48,7 @@ export function resolveCloakCommandProfileId(manager: SlabSessionManager, comman 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.`, undefined, @@ -63,7 +63,7 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR } async function resolveLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); + const profileId = resolveSlabCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { profileId, @@ -73,7 +73,7 @@ async function resolveLease(manager: SlabSessionManager, command: BrowserRuntime 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, @@ -91,7 +91,7 @@ async function resolveLease(manager: SlabSessionManager, command: BrowserRuntime } async function resolveExistingLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); + const profileId = resolveSlabCommandProfileId(manager, command); if (command.page) { const existing = await manager.findPageById(command.page, { profileId, @@ -101,7 +101,7 @@ async function resolveExistingLease(manager: SlabSessionManager, command: Browse 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: SlabSessionManager, command: Browse 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: SlabSessionManager, command: Browse 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; } @@ -203,7 +203,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B 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 dispatchSlabAction(manager: SlabSessionManager, command: B 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 dispatchSlabAction(manager: SlabSessionManager, command: B 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 dispatchSlabAction(manager: SlabSessionManager, command: B 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 dispatchSlabAction(manager: SlabSessionManager, command: B } 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 dispatchSlabAction(manager: SlabSessionManager, command: B } 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 dispatchSlabAction(manager: SlabSessionManager, command: B } case 'close': { const closed = await manager.closePage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, pageId: command.page, @@ -476,7 +476,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B } { const lease = await manager.bindPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId: resolveSlabCommandProfileId(manager, command), session: command.session, surface: command.surface, siteSession: command.siteSession, @@ -515,7 +515,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B 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/provider.ts b/src/browser/runtime/local-slab/provider.ts index 8047e423..631fdb2a 100644 --- a/src/browser/runtime/local-slab/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -1,7 +1,7 @@ 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 { dispatchSlabAction, resolveCloakCommandProfileId } from './actions.js'; +import { dispatchSlabAction, resolveSlabCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; import { SlabSessionManager, @@ -46,7 +46,7 @@ export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { } resolveProfileId(command: BrowserRuntimeCommand): string { - return resolveCloakCommandProfileId(this.manager, command); + return resolveSlabCommandProfileId(this.manager, command); } async createSession(command: BrowserRuntimeCommand): Promise { diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 6385f1df..2674d892 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -72,7 +72,7 @@ type PageEntry = { idleTimer?: ReturnType; }; -export interface CloakPageLease { +export interface SlabPageLease { profileId: string; leaseKey: string; context: BrowserContext; @@ -80,7 +80,7 @@ export interface CloakPageLease { pageId: string; } -export interface CloakTabInfo { +export interface SlabTabInfo { id: string; page: string; index: number; @@ -260,7 +260,7 @@ export class SlabSessionManager { } } - 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 +295,7 @@ export class SlabSessionManager { }); } - 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 +310,7 @@ export class SlabSessionManager { 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; @@ -389,7 +389,7 @@ export class SlabSessionManager { }; } - 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 SlabSessionManager { }))); } - 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 SlabSessionManager { 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 SlabSessionManager { } } - 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); @@ -516,7 +516,7 @@ export class SlabSessionManager { 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); From f32aca9ea44a0fd167f3ee53dbfd9a6efd4456df Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:21:05 +0530 Subject: [PATCH 12/26] feat: attach local sessions to SLAB --- .../runtime/local-slab/attachment.test.ts | 30 ++++ src/browser/runtime/local-slab/attachment.ts | 35 ++++ .../local-slab/session-manager.test.ts | 46 +++--- .../runtime/local-slab/session-manager.ts | 153 ++++++------------ 4 files changed, 144 insertions(+), 120 deletions(-) create mode 100644 src/browser/runtime/local-slab/attachment.test.ts create mode 100644 src/browser/runtime/local-slab/attachment.ts 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..1bea5792 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -0,0 +1,30 @@ +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(); + }); +}); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts new file mode 100644 index 00000000..dee283d9 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.ts @@ -0,0 +1,35 @@ +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); + const browser = await (options.connectOverCDP ?? chromium.connectOverCDP)(attachment.cdpUrl, { + headers: { Authorization: `Bearer ${attachment.bearerToken}` }, + }); + const context = browser.contexts()[0]; + if (!context) { + await bridge.release(attachment.connectionId).catch(() => {}); + 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), + }; +} diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index d43c27c4..d4570b70 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import path from 'node:path'; import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; import { SlabSessionManager, resolveLeaseKey } from './session-manager.js'; import { log } from '../../../logger.js'; @@ -163,8 +162,18 @@ function fakeContext() { }; } +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('SlabSessionManager', () => { @@ -173,20 +182,22 @@ describe('SlabSessionManager', () => { 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 attached = fakeAttachedProfile(launched); + const attachProfile = vi.fn().mockResolvedValue(attached); const manager = new SlabSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, + 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('correlates created targets and isolates Sessions into owned windows', async () => { @@ -531,18 +542,16 @@ describe('SlabSessionManager', () => { 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 SlabSessionManager({ baseDir: '/tmp/webcmd-test', platform: 'darwin', launchBackgroundPersistentContext: vi.fn().mockResolvedValue(launched.context), - activateBackgroundContext, }); const lease = await manager.getPage({ profileId: 'default', @@ -553,17 +562,15 @@ describe('SlabSessionManager', () => { 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 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,7 +579,6 @@ describe('SlabSessionManager', () => { 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 () => { @@ -1547,7 +1553,7 @@ describe('SlabSessionManager', () => { 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-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 2674d892..c57ef808 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -2,15 +2,13 @@ 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 { normalizeProfileId } from './profiles.js'; import { SlabNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; -import { findExactCloakProfileProcesses } from './process-matcher.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; @@ -39,7 +37,8 @@ export function resolveCloakBrowserVersion(): string | 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 { @@ -95,12 +94,12 @@ export interface SlabTabInfo { interface ProfileRuntime { profileId: string; + attachment: AttachedSlabProfile; context: BrowserContext; cdp?: CDPSession; sessions: Map; windowOwners: Map; targetPages: Map; - userDataDir: string; anchorTargetId?: string; parkingPage?: PlaywrightPage; useParkingKeeper: boolean; @@ -141,12 +140,17 @@ export class SessionWindowConflictError extends CliError { } 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; } @@ -186,11 +190,7 @@ function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { 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>(); @@ -210,14 +210,36 @@ export class SlabSessionManager { private shuttingDown = false; constructor(private readonly opts: SlabSessionManagerOptions = {}) { - 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; + 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 () => { + await Promise.race([ + context.close(), + new Promise(resolve => setTimeout(resolve, PROFILE_CLOSE_TIMEOUT_MS)), + ]); + }, + }; + } + profileStatuses() { return [...this.profiles.entries()].map(([contextId, runtime]) => ({ contextId, @@ -372,10 +394,8 @@ export class SlabSessionManager { 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), @@ -492,7 +512,6 @@ export class SlabSessionManager { 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,7 +529,6 @@ export class SlabSessionManager { 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; @@ -567,7 +585,6 @@ export class SlabSessionManager { if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); } if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { @@ -667,7 +684,7 @@ export class SlabSessionManager { 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 +709,9 @@ export class SlabSessionManager { }); } - 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 +721,13 @@ export class SlabSessionManager { } 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 +752,7 @@ export class SlabSessionManager { } if (this.shuttingDown) { runtime.closing = true; - await this.closeRuntime(runtime, false).catch(() => {}); + await this.closeRuntime(runtime).catch(() => {}); throw daemonShuttingDownError(); } this.profiles.set(profileId, runtime); @@ -844,7 +845,7 @@ export class SlabSessionManager { } runtime.closing = true; this.profiles.delete(profileId); - await this.closeRuntime(runtime, true); + await this.closeRuntime(runtime); }); }, PROFILE_IDLE_TIMEOUT_MS); runtime.idleTimer.unref?.(); @@ -872,26 +873,15 @@ export class SlabSessionManager { return false; } - private async closeRuntime(runtime: ProfileRuntime, recoverOnTimeout: boolean): Promise { + private async closeRuntime(runtime: ProfileRuntime): Promise { this.cancelProfileIdle(runtime); for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); - let timeout: ReturnType | undefined; 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; - } + await Promise.all([...runtime.targetPages.values()].map(entry => ( + pageIsClosed(entry.page) ? undefined : entry.page.close().catch(() => {}) + ))); + await runtime.attachment.release(); } finally { - if (timeout) clearTimeout(timeout); this.cleanupRuntime(runtime); } } @@ -1333,40 +1323,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. - } - } -} From daca922a95698b06e6d91777ab0e0ae4b9f514ad Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:29:50 +0530 Subject: [PATCH 13/26] fix: release SLAB attachments safely --- .../runtime/local-slab/attachment.test.ts | 13 +++++ src/browser/runtime/local-slab/attachment.ts | 28 ++++----- .../local-slab/session-manager.test.ts | 56 +++++++++++++++++- .../runtime/local-slab/session-manager.ts | 58 +++++++++++-------- 4 files changed, 116 insertions(+), 39 deletions(-) diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts index 1bea5792..61750c9a 100644 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -27,4 +27,17 @@ describe('attachSlabProfile', () => { 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 index dee283d9..9eb25915 100644 --- a/src/browser/runtime/local-slab/attachment.ts +++ b/src/browser/runtime/local-slab/attachment.ts @@ -17,19 +17,21 @@ export interface AttachSlabProfileOptions { export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { const bridge = options.bridge ?? new SlabBridgeClient(); const attachment = await bridge.attach(profileId); - const browser = await (options.connectOverCDP ?? chromium.connectOverCDP)(attachment.cdpUrl, { - headers: { Authorization: `Bearer ${attachment.bearerToken}` }, - }); - const context = browser.contexts()[0]; - if (!context) { + 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 new Error('SLAB attachment returned no persistent browser context.'); + throw error; } - return { - profileId: attachment.profile.id, - browserVersion: browser.version(), - context, - browser, - release: () => bridge.release(attachment.connectionId), - }; } diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index d4570b70..eae09bea 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -200,6 +200,56 @@ describe('SlabSessionManager', () => { 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('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('correlates created targets and isolates Sessions into owned windows', async () => { const launched = fakeContext(); const manager = new SlabSessionManager({ @@ -218,7 +268,7 @@ describe('SlabSessionManager', () => { .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 SlabSessionManager({ @@ -229,9 +279,9 @@ describe('SlabSessionManager', () => { 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']); }); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index c57ef808..1be85f35 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -109,6 +109,7 @@ interface ProfileRuntime { handoffTimer?: ReturnType; closing: boolean; disposed: boolean; + releasePromise?: Promise; lastSeenAt: number; } @@ -232,10 +233,15 @@ export class SlabSessionManager { context, browser: browser ?? {} as Browser, release: async () => { - await Promise.race([ - context.close(), - new Promise(resolve => setTimeout(resolve, PROFILE_CLOSE_TIMEOUT_MS)), - ]); + 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); + } }, }; } @@ -761,6 +767,7 @@ export class SlabSessionManager { private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + void this.releaseRuntime(runtime, false); this.cleanupRuntime(runtime); } @@ -771,7 +778,6 @@ export class SlabSessionManager { 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(); @@ -781,7 +787,6 @@ export class SlabSessionManager { 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 { @@ -874,16 +879,36 @@ export class SlabSessionManager { } 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); + const pageCdps = [...runtime.targetPages.values()].map(entry => this.pageCdpSessions.get(entry.page)); try { - await Promise.all([...runtime.targetPages.values()].map(entry => ( - pageIsClosed(entry.page) ? undefined : entry.page.close().catch(() => {}) - ))); + 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([ + ...pageCdps.map(cdp => cdp?.detach().catch(() => {})), + runtime.cdp?.detach().catch(() => {}), + ]); await runtime.attachment.release(); } finally { this.cleanupRuntime(runtime); } + })(); + return runtime.releasePromise; } private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { @@ -939,7 +964,7 @@ export class SlabSessionManager { 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); @@ -956,19 +981,6 @@ export class SlabSessionManager { 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, From da3beacb213245644024786ab0e6e61dd9ebcecf Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:35:26 +0530 Subject: [PATCH 14/26] fix: await SLAB parking cleanup --- .../local-slab/session-manager.test.ts | 42 ++++++++++++++++++- .../runtime/local-slab/session-manager.ts | 20 +++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index eae09bea..d938918f 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -8,6 +8,7 @@ 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; @@ -134,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), @@ -158,6 +163,7 @@ 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, }; } @@ -221,6 +227,19 @@ describe('SlabSessionManager', () => { 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); @@ -250,6 +269,25 @@ describe('SlabSessionManager', () => { 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 SlabSessionManager({ diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 1be85f35..537662fd 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -201,6 +201,7 @@ export class SlabSessionManager { 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 { + log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); + }); this.cleanupRuntime(runtime); } @@ -887,7 +890,7 @@ export class SlabSessionManager { runtime.releasePromise = (async () => { this.cancelProfileIdle(runtime); for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); - const pageCdps = [...runtime.targetPages.values()].map(entry => this.pageCdpSessions.get(entry.page)); + const pages = [...runtime.targetPages.values()].map(entry => entry.page); try { if (closePages) { await Promise.all([...runtime.targetPages.values()].map(entry => ( @@ -900,7 +903,7 @@ export class SlabSessionManager { runtime.anchorTargetId = undefined; } await Promise.all([ - ...pageCdps.map(cdp => cdp?.detach().catch(() => {})), + ...pages.map(page => this.detachPageCdp(page)), runtime.cdp?.detach().catch(() => {}), ]); await runtime.attachment.release(); @@ -1185,8 +1188,8 @@ export class SlabSessionManager { 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; })(); @@ -1310,6 +1313,15 @@ export class SlabSessionManager { 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 { From 310d9c4bc9cdf739a2d7a4b2050f85de5137fcbb Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:45:04 +0530 Subject: [PATCH 15/26] feat: use SLAB as the local browser provider --- package-lock.json | 103 ----- package.json | 1 - .../runtime/local-slab/cloak-version.test.ts | 25 -- .../darwin-background-launch.test.ts | 130 ------- .../local-slab/darwin-background-launch.ts | 167 -------- .../runtime/local-slab/provider.test.ts | 7 +- src/browser/runtime/local-slab/provider.ts | 8 +- .../runtime/local-slab/session-manager.ts | 28 -- src/doctor.test.ts | 358 +----------------- src/doctor.ts | 99 +---- 10 files changed, 23 insertions(+), 903 deletions(-) delete mode 100644 src/browser/runtime/local-slab/cloak-version.test.ts delete mode 100644 src/browser/runtime/local-slab/darwin-background-launch.test.ts delete mode 100644 src/browser/runtime/local-slab/darwin-background-launch.ts 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..12f33288 100644 --- a/package.json +++ b/package.json @@ -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/src/browser/runtime/local-slab/cloak-version.test.ts b/src/browser/runtime/local-slab/cloak-version.test.ts deleted file mode 100644 index 1c33f12a..00000000 --- a/src/browser/runtime/local-slab/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-slab/darwin-background-launch.test.ts b/src/browser/runtime/local-slab/darwin-background-launch.test.ts deleted file mode 100644 index 4b770be5..00000000 --- a/src/browser/runtime/local-slab/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-slab/darwin-background-launch.ts b/src/browser/runtime/local-slab/darwin-background-launch.ts deleted file mode 100644 index 98ff9837..00000000 --- a/src/browser/runtime/local-slab/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-slab/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts index 7be94c68..f43399a6 100644 --- a/src/browser/runtime/local-slab/provider.test.ts +++ b/src/browser/runtime/local-slab/provider.test.ts @@ -154,13 +154,14 @@ describe('LocalSlabRuntimeProvider', () => { runBrowserProgram.mockReset(); }); - it('reports a runtime-named connected status before any profile launches', async () => { + 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, }); }); diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts index 631fdb2a..058be4a2 100644 --- a/src/browser/runtime/local-slab/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -3,10 +3,7 @@ import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.j import { LocalBrowserSessionStore, type BrowserSessionListRow, type BrowserSessionRecord } from '../../sessions.js'; import { dispatchSlabAction, resolveSlabCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; -import { - SlabSessionManager, - resolveCloakBrowserVersion, -} from './session-manager.js'; +import { SlabSessionManager } from './session-manager.js'; export interface LocalSlabRuntimeProviderOptions { baseDir?: string; @@ -36,8 +33,7 @@ export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, - runtimeName: 'cloak', - runtimeVersion: resolveCloakBrowserVersion(), + runtimeName: 'SLAB', profiles, pending: 0, commandResultUnknown: 0, diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 537662fd..9c1ec346 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -1,41 +1,14 @@ -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 type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; import { normalizeProfileId } from './profiles.js'; import { SlabNetworkCapture } from './network.js'; -import { findPackageRoot } from '../../../package-paths.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 AttachSlabProfile = typeof attachSlabProfile; export type LaunchPersistentContext = (options: { userDataDir: string; headless: boolean; humanize: boolean }) => Promise; @@ -251,7 +224,6 @@ export class SlabSessionManager { return [...this.profiles.entries()].map(([contextId, runtime]) => ({ contextId, runtimeConnected: true, - runtimeVersion: resolveCloakBrowserVersion(), pending: 0, lastSeenAt: runtime.lastSeenAt, })); diff --git a/src/doctor.test.ts b/src/doctor.test.ts index bf6bf3bd..936dea44 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,6 @@ const { mockFindShadowedUserAdapters, mockSendCommand, mockSetDaemonCommandTimeoutSeconds, - mockBinaryInfo, - mockEnsureBinary, } = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockConnect: vi.fn(), @@ -19,22 +14,12 @@ const { mockFindShadowedUserAdapters: vi.fn(), mockSendCommand: vi.fn(), mockSetDaemonCommandTimeoutSeconds: vi.fn(), - mockBinaryInfo: vi.fn(), - mockEnsureBinary: 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; @@ -55,13 +40,7 @@ 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 })); +import { checkConnectivity, renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; describe('doctor report rendering', () => { const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -71,19 +50,6 @@ 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', - }); // Doctor always runs live connectivity. Tests that want connect to fail override. mockConnect.mockResolvedValue({ evaluate: vi.fn().mockResolvedValue(2), @@ -140,7 +106,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,7 +118,7 @@ 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', () => { @@ -168,30 +134,6 @@ describe('doctor report rendering', () => { 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, @@ -283,7 +225,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 +243,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 +257,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 +277,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 +306,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 +444,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..ddf6071e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -4,9 +4,6 @@ * Simplified for the daemon-based architecture. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { binaryInfo, ensureBinary } from 'cloakbrowser'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; import { sendCommand, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; @@ -31,15 +28,6 @@ export type ConnectivityResult = { durationMs: number; }; -export type BrowserBinaryStatus = { - installed: boolean | undefined; - path: string; - downloadUrl?: string; - error?: string; - /** True when CLOAKBROWSER_BINARY_PATH is set — a different check than the managed cache. */ - override: boolean; -}; - export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; @@ -50,50 +38,12 @@ export type DoctorReport = { runtimeFlaky?: boolean; runtimeName?: string; runtimeVersion?: string; - binary?: BrowserBinaryStatus; connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; adapterShadows?: AdapterShadow[]; 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 }; - } -} - /** * Test connectivity by attempting a real browser command. */ @@ -102,8 +52,6 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise.', ); @@ -238,7 +171,6 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 0) { const config = loadProfileConfig(); lines.push('', 'Profiles:'); From 87fbd10ae824cb7845125b37a0636c62de63e74f Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:48:08 +0530 Subject: [PATCH 16/26] fix: preserve SLAB provider status compatibility --- .../runtime/local-slab/provider.test.ts | 1 + src/browser/runtime/local-slab/provider.ts | 1 + .../runtime/local-slab/session-manager.ts | 1 + src/doctor.test.ts | 28 ++++++++++++++- src/doctor.ts | 36 +++++++++++++++++++ 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/browser/runtime/local-slab/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts index f43399a6..cfb66a5a 100644 --- a/src/browser/runtime/local-slab/provider.test.ts +++ b/src/browser/runtime/local-slab/provider.test.ts @@ -163,6 +163,7 @@ describe('LocalSlabRuntimeProvider', () => { pending: 0, commandResultUnknown: 0, }); + await expect(provider.status()).resolves.toHaveProperty('runtimeVersion', undefined); }); it('discards a temporary Session record after closing it', async () => { diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts index 058be4a2..1332a903 100644 --- a/src/browser/runtime/local-slab/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -34,6 +34,7 @@ export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { return { runtimeConnected: true, runtimeName: 'SLAB', + runtimeVersion: profiles.find(profile => profile.runtimeVersion)?.runtimeVersion, profiles, pending: 0, commandResultUnknown: 0, diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 9c1ec346..48c520d8 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -224,6 +224,7 @@ export class SlabSessionManager { return [...this.profiles.entries()].map(([contextId, runtime]) => ({ contextId, runtimeConnected: true, + runtimeVersion: runtime.attachment.browserVersion || undefined, pending: 0, lastSeenAt: runtime.lastSeenAt, })); diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 936dea44..62aafc82 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -7,6 +7,7 @@ const { mockFindShadowedUserAdapters, mockSendCommand, mockSetDaemonCommandTimeoutSeconds, + mockFindSlabInstallation, } = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockConnect: vi.fn(), @@ -14,6 +15,7 @@ const { mockFindShadowedUserAdapters: vi.fn(), mockSendCommand: vi.fn(), mockSetDaemonCommandTimeoutSeconds: vi.fn(), + mockFindSlabInstallation: vi.fn(), })); vi.mock('./browser/daemon-transport.js', () => ({ @@ -32,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 { @@ -40,7 +46,7 @@ vi.mock('./adapter-shadow.js', async () => { }; }); -import { checkConnectivity, renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; +import { checkBrowserBinary, checkConnectivity, renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; describe('doctor report rendering', () => { const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -50,6 +56,10 @@ describe('doctor report rendering', () => { vi.unstubAllEnvs(); mockFindShadowedUserAdapters.mockReturnValue([]); mockSetDaemonCommandTimeoutSeconds.mockClear(); + 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({ evaluate: vi.fn().mockResolvedValue(2), @@ -63,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', diff --git a/src/doctor.ts b/src/doctor.ts index ddf6071e..61c0a0c0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -4,6 +4,8 @@ * Simplified for the daemon-based architecture. */ +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'; @@ -14,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; @@ -28,6 +31,14 @@ export type ConnectivityResult = { durationMs: number; }; +export type BrowserBinaryStatus = { + installed: boolean | undefined; + path: string; + downloadUrl?: string; + error?: string; + override: boolean; +}; + export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; @@ -38,12 +49,20 @@ export type DoctorReport = { runtimeFlaky?: boolean; runtimeName?: string; runtimeVersion?: string; + binary?: BrowserBinaryStatus; connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; adapterShadows?: AdapterShadow[]; issues: string[]; }; +export function checkBrowserBinary(): BrowserBinaryStatus { + 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 }; +} + /** * Test connectivity by attempting a real browser command. */ @@ -87,6 +106,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 0) { const config = loadProfileConfig(); lines.push('', 'Profiles:'); From 159a1d1f201873b5586791accdf9677a9e2c3dbb Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:53:09 +0530 Subject: [PATCH 17/26] feat: require SLAB for local browser commands --- src/browser.test.ts | 36 ++++++++++++++++++++++++- src/browser/daemon-lifecycle.ts | 47 ++++++++++++++++++++++++++++----- src/browser/errors.test.ts | 12 ++++++++- src/browser/errors.ts | 2 +- 4 files changed, 88 insertions(+), 9 deletions(-) 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-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': From 6d0d0e928f6c9e6ba98d27f4b2d652cb9e6d4445 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 01:56:31 +0530 Subject: [PATCH 18/26] fix: enforce SLAB before browser commands --- src/browser/daemon-client.test.ts | 16 ++++++++++++++++ src/browser/daemon-client.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) 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; From d7bb911eb2797a6ae6ba1b77a0a98999f67397ea Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 02:06:34 +0530 Subject: [PATCH 19/26] test: lock SLAB local and hosted parity --- package.json | 1 + src/cli.test.ts | 10 +- src/cli.ts | 14 +- src/commands/daemon.test.ts | 2 +- src/commands/daemon.ts | 4 +- src/doctor.test.ts | 8 +- src/doctor.ts | 2 +- src/hosted/main-lifecycle.test.ts | 44 +++++- tests/e2e/slab-session-concurrency.test.ts | 158 +++++++++++++++++++++ vitest.config.ts | 1 + 10 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/slab-session-concurrency.test.ts diff --git a/package.json b/package.json index 12f33288..456ee7dd 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "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", 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/doctor.test.ts b/src/doctor.test.ts index 62aafc82..eb1b012c 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -102,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 '); }); @@ -147,7 +147,7 @@ describe('doctor report rendering', () => { 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, @@ -155,7 +155,7 @@ 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!'); }); @@ -197,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.'); }); diff --git a/src/doctor.ts b/src/doctor.ts index 61c0a0c0..9c5098bf 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -229,7 +229,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { : report.runtimeVersion ? ` (v${report.runtimeVersion})` : ' (version unknown)'; - const runtimeName = report.runtimeName ?? 'SLAB'; + const runtimeName = 'SLAB'; const runtimeLabel = report.runtimeFlaky ? 'unstable (connected during live check, then disconnected)' : report.runtimeConnected ? 'connected' : 'not connected'; diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index a0667e35..3f1cd661 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,22 @@ 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 { writeFileSync } from 'node:fs';", + "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 +300,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 +389,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/tests/e2e/slab-session-concurrency.test.ts b/tests/e2e/slab-session-concurrency.test.ts new file mode 100644 index 00000000..ff0d22a7 --- /dev/null +++ b/tests/e2e/slab-session-concurrency.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SlabSessionManager } from '../../src/browser/runtime/local-slab/session-manager.js'; +import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-slab/process-matcher.js'; +import { resolveCloakProfileDir } 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; +let baseUrl = ''; +const tempDirs: string[] = []; + +beforeAll(async () => { + server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + res.end(`${url.pathname}${url.pathname}`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('test server did not bind'); + baseUrl = `http://127.0.0.1:${address.port}`; +}, 30_000); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +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 playwrightPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/playwright-core/package.json'), 'utf8')); + const slab = findSlabInstallation({ platform: process.platform, homeDir: os.homedir(), existsSync: fs.existsSync }); + + expect(slab?.executablePath).toMatch(/SLAB\.app\/Contents\/MacOS\/SLAB$/u); + expect(appPkg.dependencies['playwright-core']).toBe('1.61.1'); + expect(playwrightPkg.version).toBe('1.61.1'); + }); + + it('covers isolated Profiles, explicit Session windows, noopener pages, close survival, and keeper repair', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-slab-session-gate-')); + tempDirs.push(configDir); + const manager = new SlabSessionManager({ baseDir: configDir }); + const profileA = `gate-a-${Date.now()}`; + const profileB = `gate-b-${Date.now()}`; + const keyA = { + profileId: profileA, + session: 'session_11111111-1111-4111-8111-111111111111', + sessionId: 'session_11111111-1111-4111-8111-111111111111', + surface: 'browser' as const, + }; + 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 cdp = await page.context().newCDPSession(page); + try { + const target = await cdp.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId }) as { windowId: number }).windowId; + } finally { + await cdp.detach(); + } + }; + try { + const [first, profileBFirst] = await Promise.all([manager.getPage(keyA), manager.getPage(keyB)]); + await Promise.all([ + first.page.goto(`${baseUrl}/first`), + profileBFirst.page.goto(`${baseUrl}/profile-b`), + ]); + + const otherSession = await manager.getPage(keyA2); + await otherSession.page.goto(`${baseUrl}/other-session`); + expect(await windowId(otherSession.page)).not.toBe(await windowId(first.page)); + + await first.page.bringToFront(); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); + + const second = await manager.newPage({ ...keyA, windowMode: 'background' }); + await second.page.goto(`${baseUrl}/second`); + + expect(await windowId(second.page)).toEqual(expect.any(Number)); + expect(await second.page.evaluate(() => window.opener === null)).toBe(true); + expect(await second.page.evaluate(() => document.referrer)).toBe(''); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); + expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/second`, + ]); + + await manager.closeSession(profileA, keyA.sessionId); + await profileBFirst.page.goto(`${baseUrl}/profile-b-after-a-close`); + expect(await profileBFirst.page.title()).toBe('/profile-b-after-a-close'); + + await manager.closeSession(profileB, keyB.sessionId); + const afterFinalClose = await manager.getPage(keyB); + await afterFinalClose.page.goto(`${baseUrl}/keeper-survived`); + expect(await afterFinalClose.page.title()).toBe('/keeper-survived'); + } finally { + await manager.shutdown(); + } + }, 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-slab-fallback-gate-')); + tempDirs.push(configDir); + const manager = new SlabSessionManager({ baseDir: configDir }); + const key = { + profileId: `gate-fallback-${Date.now()}`, + session: 'session_44444444-4444-4444-8444-444444444444', + sessionId: 'session_44444444-4444-4444-8444-444444444444', + surface: 'browser' as const, + }; + try { + const first = await manager.getPage(key); + await first.page.goto(`${baseUrl}/first`); + await first.page.evaluate(() => { + (window as unknown as { open: () => null }).open = () => null; + }); + + const fallback = await manager.newPage({ ...key, windowMode: 'background' }); + await fallback.page.goto(`${baseUrl}/fallback`); + + expect(await fallback.page.evaluate(() => window.opener === null)).toBe(true); + expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/fallback`, + ]); + } finally { + await manager.shutdown(); + } + }, 180_000); + + 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 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 { + const [workPage, work2Page] = await Promise.all([manager.getPage(work), manager.getPage(work2)]); + await Promise.all([ + workPage.page.goto(`${baseUrl}/work`), + work2Page.page.goto(`${baseUrl}/work-2`), + ]); + + const workProcesses = await findExactCloakProfileProcesses(resolveCloakProfileDir('work', { baseDir: configDir })); + const work2Processes = await findExactCloakProfileProcesses(resolveCloakProfileDir('work-2', { baseDir: configDir })); + expect(workProcesses.length).toBeGreaterThan(0); + expect(work2Processes.length).toBeGreaterThan(0); + expect(workProcesses.every(pid => !work2Processes.includes(pid))).toBe(true); + } finally { + await manager.shutdown(); + } + }, 180_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bde9a9b5..b9fee06e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/cloak-session-concurrency.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 From 1ad9a6f7e0b91ebf341041ac06b6a9296e58cdf1 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 02:12:24 +0530 Subject: [PATCH 20/26] fix: remove stale Cloak gate surfaces --- package.json | 1 - scripts/collect-ci-diagnostics.ps1 | 32 ++-- src/browser/runtime/local-slab/actions.ts | 8 +- .../runtime/local-slab/browser-run.test.ts | 2 +- .../local-slab/process-matcher.test.ts | 38 ++--- .../runtime/local-slab/process-matcher.ts | 16 +- .../runtime/local-slab/profiles.test.ts | 10 +- src/browser/runtime/local-slab/profiles.ts | 6 +- .../runtime/local-slab/provider.test.ts | 10 +- .../local-slab/session-manager.test.ts | 6 +- .../runtime/local-slab/session-manager.ts | 8 +- src/update-check.ts | 2 +- src/update.ts | 2 +- tests/e2e/cloak-session-concurrency.test.ts | 160 ------------------ ...k-runtime.test.ts => slab-runtime.test.ts} | 18 +- tests/e2e/slab-session-concurrency.test.ts | 8 +- vitest.config.ts | 3 +- 17 files changed, 86 insertions(+), 244 deletions(-) delete mode 100644 tests/e2e/cloak-session-concurrency.test.ts rename tests/e2e/{cloak-runtime.test.ts => slab-runtime.test.ts} (91%) diff --git a/package.json b/package.json index 456ee7dd..40e9ed44 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "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", 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/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts index 28ed97d2..27cd3c63 100644 --- a/src/browser/runtime/local-slab/actions.ts +++ b/src/browser/runtime/local-slab/actions.ts @@ -50,7 +50,7 @@ export function resolveSlabCommandProfileId(manager: SlabSessionManager, command if (active.length > 1) { 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 .', ); @@ -470,7 +470,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B 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 `.', }; } @@ -494,8 +494,8 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B 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 { diff --git a/src/browser/runtime/local-slab/browser-run.test.ts b/src/browser/runtime/local-slab/browser-run.test.ts index 87792f48..0058e48c 100644 --- a/src/browser/runtime/local-slab/browser-run.test.ts +++ b/src/browser/runtime/local-slab/browser-run.test.ts @@ -42,7 +42,7 @@ 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 dispatchSlabAction(manager, command('snapshot-1', 'snapshot')); diff --git a/src/browser/runtime/local-slab/process-matcher.test.ts b/src/browser/runtime/local-slab/process-matcher.test.ts index 568d8e87..055f7a9c 100644 --- a/src/browser/runtime/local-slab/process-matcher.test.ts +++ b/src/browser/runtime/local-slab/process-matcher.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { matchCloakProfileCommand } from './process-matcher.js'; +import { matchSlabProfileCommand } 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'; +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(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); + expect(matchSlabProfileCommand(slab, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand(slabSeparate, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand(slabQuoted, '/profiles/work')).toBe(true); + expect(matchSlabProfileCommand('C:\\Users\\me\\AppData\\Local\\SLAB\\SLAB.exe --user-data-dir=C:\\profiles\\work', 'C:\\profiles\\work')).toBe(true); + expect(matchSlabProfileCommand(slabWork2, '/profiles/work')).toBe(false); + expect(matchSlabProfileCommand(chromeWork, '/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 = '/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); + 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-slab/process-matcher.ts b/src/browser/runtime/local-slab/process-matcher.ts index dc015819..24e6b857 100644 --- a/src/browser/runtime/local-slab/process-matcher.ts +++ b/src/browser/runtime/local-slab/process-matcher.ts @@ -1,13 +1,17 @@ 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 isSlabApp = basename === 'slab' || basename === 'slab.exe'; + 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 +19,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 +32,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-slab/profiles.test.ts b/src/browser/runtime/local-slab/profiles.test.ts index 472c9c1d..9abea7aa 100644 --- a/src/browser/runtime/local-slab/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-slab/profiles.ts b/src/browser/runtime/local-slab/profiles.ts index 0f08e9aa..fca205c1 100644 --- a/src/browser/runtime/local-slab/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/provider.test.ts b/src/browser/runtime/local-slab/provider.test.ts index cfb66a5a..ac5d7529 100644 --- a/src/browser/runtime/local-slab/provider.test.ts +++ b/src/browser/runtime/local-slab/provider.test.ts @@ -311,7 +311,7 @@ describe('LocalSlabRuntimeProvider', () => { .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/')); @@ -810,14 +810,14 @@ describe('LocalSlabRuntimeProvider', () => { 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', }); }); @@ -840,7 +840,7 @@ describe('LocalSlabRuntimeProvider', () => { 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' }); @@ -849,7 +849,7 @@ describe('LocalSlabRuntimeProvider', () => { 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-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index d938918f..4025ecd2 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -488,7 +488,7 @@ describe('SlabSessionManager', () => { 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; @@ -1050,7 +1050,7 @@ describe('SlabSessionManager', () => { 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.')) @@ -1244,7 +1244,7 @@ describe('SlabSessionManager', () => { 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 SlabSessionManager({ diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 48c520d8..a0aecd0b 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -805,7 +805,7 @@ export class SlabSessionManager { 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 { @@ -950,7 +950,7 @@ export class SlabSessionManager { 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; @@ -1036,7 +1036,7 @@ export class SlabSessionManager { 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 }); }); @@ -1178,7 +1178,7 @@ export class SlabSessionManager { 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; } 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-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts deleted file mode 100644 index 6cf9cdf4..00000000 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import fs from 'node:fs'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { SlabSessionManager } from '../../src/browser/runtime/local-slab/session-manager.js'; -import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-slab/process-matcher.js'; -import { resolveCloakProfileDir } from '../../src/browser/runtime/local-slab/profiles.js'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -let server: http.Server; -let baseUrl = ''; -const tempDirs: string[] = []; - -beforeAll(async () => { - server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', 'http://127.0.0.1'); - res.end(`${url.pathname}${url.pathname}`); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('test server did not bind'); - baseUrl = `http://127.0.0.1:${address.port}`; -}, 30_000); - -afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); - 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', () => { - 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'); - - expect(appPkg.dependencies.cloakbrowser).toBe('0.4.5'); - 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-')); - tempDirs.push(configDir); - const manager = new SlabSessionManager({ baseDir: configDir }); - const profileA = `gate-a-${Date.now()}`; - const profileB = `gate-b-${Date.now()}`; - const keyA = { - profileId: profileA, - session: 'session_11111111-1111-4111-8111-111111111111', - sessionId: 'session_11111111-1111-4111-8111-111111111111', - surface: 'browser' as const, - }; - 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 cdp = await page.context().newCDPSession(page); - try { - const target = await cdp.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; - return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId }) as { windowId: number }).windowId; - } finally { - await cdp.detach(); - } - }; - try { - const [first, profileBFirst] = await Promise.all([manager.getPage(keyA), manager.getPage(keyB)]); - await Promise.all([ - first.page.goto(`${baseUrl}/first`), - profileBFirst.page.goto(`${baseUrl}/profile-b`), - ]); - - const otherSession = await manager.getPage(keyA2); - await otherSession.page.goto(`${baseUrl}/other-session`); - expect(await windowId(otherSession.page)).not.toBe(await windowId(first.page)); - - await first.page.bringToFront(); - expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); - - const second = await manager.newPage({ ...keyA, windowMode: 'background' }); - await second.page.goto(`${baseUrl}/second`); - - expect(await windowId(second.page)).toEqual(expect.any(Number)); - expect(await second.page.evaluate(() => window.opener === null)).toBe(true); - expect(await second.page.evaluate(() => document.referrer)).toBe(''); - expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); - expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ - `${baseUrl}/first`, - `${baseUrl}/second`, - ]); - - await manager.closeSession(profileA, keyA.sessionId); - await profileBFirst.page.goto(`${baseUrl}/profile-b-after-a-close`); - expect(await profileBFirst.page.title()).toBe('/profile-b-after-a-close'); - - await manager.closeSession(profileB, keyB.sessionId); - const afterFinalClose = await manager.getPage(keyB); - await afterFinalClose.page.goto(`${baseUrl}/keeper-survived`); - expect(await afterFinalClose.page.title()).toBe('/keeper-survived'); - } finally { - await manager.shutdown(); - } - }, 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-')); - tempDirs.push(configDir); - const manager = new SlabSessionManager({ baseDir: configDir }); - const key = { - profileId: `gate-fallback-${Date.now()}`, - session: 'session_44444444-4444-4444-8444-444444444444', - sessionId: 'session_44444444-4444-4444-8444-444444444444', - surface: 'browser' as const, - }; - try { - const first = await manager.getPage(key); - await first.page.goto(`${baseUrl}/first`); - await first.page.evaluate(() => { - (window as unknown as { open: () => null }).open = () => null; - }); - - const fallback = await manager.newPage({ ...key, windowMode: 'background' }); - await fallback.page.goto(`${baseUrl}/fallback`); - - expect(await fallback.page.evaluate(() => window.opener === null)).toBe(true); - expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ - `${baseUrl}/first`, - `${baseUrl}/fallback`, - ]); - } finally { - await manager.shutdown(); - } - }, 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-')); - tempDirs.push(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 { - const [workPage, work2Page] = await Promise.all([manager.getPage(work), manager.getPage(work2)]); - await Promise.all([ - workPage.page.goto(`${baseUrl}/work`), - work2Page.page.goto(`${baseUrl}/work-2`), - ]); - - const workProcesses = await findExactCloakProfileProcesses(resolveCloakProfileDir('work', { baseDir: configDir })); - const work2Processes = await findExactCloakProfileProcesses(resolveCloakProfileDir('work-2', { baseDir: configDir })); - expect(workProcesses.length).toBeGreaterThan(0); - expect(work2Processes.length).toBeGreaterThan(0); - expect(workProcesses.every(pid => !work2Processes.includes(pid))).toBe(true); - } finally { - await manager.shutdown(); - } - }, 180_000); -}); 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/slab-session-concurrency.test.ts b/tests/e2e/slab-session-concurrency.test.ts index ff0d22a7..d49485b5 100644 --- a/tests/e2e/slab-session-concurrency.test.ts +++ b/tests/e2e/slab-session-concurrency.test.ts @@ -5,8 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { SlabSessionManager } from '../../src/browser/runtime/local-slab/session-manager.js'; -import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-slab/process-matcher.js'; -import { resolveCloakProfileDir } from '../../src/browser/runtime/local-slab/profiles.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)), '../..'); @@ -146,8 +146,8 @@ describe.skipIf(process.env.WEBCMD_LIVE_SLAB !== '1')('SLAB Session concurrency 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 b9fee06e..18e1d5b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,8 +54,7 @@ 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: From 7d95e959c81d7983adb4409efa4ecaf5a7a165bb Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 02:14:10 +0530 Subject: [PATCH 21/26] test: stub SLAB install in execution metadata test --- src/execution.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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') From 01c2743d89a2c1a8d848aa337b88f4b2363ec374 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 02:17:43 +0530 Subject: [PATCH 22/26] fix: update SLAB parity surfaces --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/e2e-headed.yml | 4 ++-- PRIVACY.md | 2 +- TESTING.md | 6 +++--- bun.lock | 3 --- docs/cli-reference.mdx | 2 +- plugins/facebook/feed.js | 4 ++-- plugins/facebook/search.js | 4 ++-- plugins/skyscanner/flights.js | 2 +- plugins/ycombinator/companies.js | 2 +- skill-src/smart-search/SKILL.src.md | 2 +- skill-src/webcmd-browser/SKILL.src.md | 2 +- .../references/browser-run-playwright.src.md | 2 +- skills/smart-search/SKILL.md | 2 +- skills/webcmd-browser/SKILL.md | 2 +- .../references/browser-run-playwright.md | 2 +- .../runtime/local-slab/process-matcher.test.ts | 1 + src/browser/runtime/local-slab/process-matcher.ts | 4 +++- src/hosted/main-lifecycle.test.ts | 11 +++++++++++ src/skills.test.ts | 2 +- 20 files changed, 41 insertions(+), 30 deletions(-) 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/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/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/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/runtime/local-slab/process-matcher.test.ts b/src/browser/runtime/local-slab/process-matcher.test.ts index 055f7a9c..37c8cd1c 100644 --- a/src/browser/runtime/local-slab/process-matcher.test.ts +++ b/src/browser/runtime/local-slab/process-matcher.test.ts @@ -15,6 +15,7 @@ describe('matchSlabProfileCommand', () => { expect(matchSlabProfileCommand('C:\\Users\\me\\AppData\\Local\\SLAB\\SLAB.exe --user-data-dir=C:\\profiles\\work', 'C:\\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('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); diff --git a/src/browser/runtime/local-slab/process-matcher.ts b/src/browser/runtime/local-slab/process-matcher.ts index 24e6b857..b064cef2 100644 --- a/src/browser/runtime/local-slab/process-matcher.ts +++ b/src/browser/runtime/local-slab/process-matcher.ts @@ -6,7 +6,9 @@ export function matchSlabProfileCommand(command: string, userDataDir: string): b const executable = args[0]; const executableParts = executable?.split(/[\\/]/u) ?? []; const basename = executableParts.at(-1)?.toLowerCase() ?? ''; - const isSlabApp = basename === 'slab' || basename === 'slab.exe'; + const parent = executableParts.at(-2)?.toLowerCase() ?? ''; + const hasMacBundle = executableParts.some(part => part.toLowerCase() === 'slab.app'); + const isSlabApp = (basename === 'slab' || basename === 'slab.exe') && (hasMacBundle || parent === 'slab'); const cacheIndex = executableParts.lastIndexOf('.slabbrowser'); const isSlabCache = cacheIndex >= 0 && /^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '') diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 3f1cd661..cd5d8ace 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -269,7 +269,18 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): ].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');`, '}', 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'); From a119239f051b7e915145be7d2cdbe29289e5e87f Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 02:21:02 +0530 Subject: [PATCH 23/26] fix: clarify legacy benchmark browser boundary --- benchmarks/README.md | 21 ++++++++++++------- benchmarks/scripts/axi_runtime.py | 19 +++++++++-------- benchmarks/scripts/run_controller.py | 10 ++++----- benchmarks/tests/test_controller.py | 6 +++--- .../local-slab/process-matcher.test.ts | 2 +- .../runtime/local-slab/process-matcher.ts | 3 +-- 6 files changed, 33 insertions(+), 28 deletions(-) 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/src/browser/runtime/local-slab/process-matcher.test.ts b/src/browser/runtime/local-slab/process-matcher.test.ts index 37c8cd1c..df147673 100644 --- a/src/browser/runtime/local-slab/process-matcher.test.ts +++ b/src/browser/runtime/local-slab/process-matcher.test.ts @@ -12,10 +12,10 @@ describe('matchSlabProfileCommand', () => { expect(matchSlabProfileCommand(slab, '/profiles/work')).toBe(true); expect(matchSlabProfileCommand(slabSeparate, '/profiles/work')).toBe(true); expect(matchSlabProfileCommand(slabQuoted, '/profiles/work')).toBe(true); - expect(matchSlabProfileCommand('C:\\Users\\me\\AppData\\Local\\SLAB\\SLAB.exe --user-data-dir=C:\\profiles\\work', 'C:\\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); diff --git a/src/browser/runtime/local-slab/process-matcher.ts b/src/browser/runtime/local-slab/process-matcher.ts index b064cef2..21d85ea1 100644 --- a/src/browser/runtime/local-slab/process-matcher.ts +++ b/src/browser/runtime/local-slab/process-matcher.ts @@ -6,9 +6,8 @@ export function matchSlabProfileCommand(command: string, userDataDir: string): b const executable = args[0]; const executableParts = executable?.split(/[\\/]/u) ?? []; const basename = executableParts.at(-1)?.toLowerCase() ?? ''; - const parent = executableParts.at(-2)?.toLowerCase() ?? ''; const hasMacBundle = executableParts.some(part => part.toLowerCase() === 'slab.app'); - const isSlabApp = (basename === 'slab' || basename === 'slab.exe') && (hasMacBundle || parent === 'slab'); + const isSlabApp = basename === 'slab' && hasMacBundle; const cacheIndex = executableParts.lastIndexOf('.slabbrowser'); const isSlabCache = cacheIndex >= 0 && /^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '') From 8818e2c3dee7db279f6c32d77c6861d32f9942be Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 15:52:58 +0530 Subject: [PATCH 24/26] fix webcmd SLAB bridge integration --- .../runtime/local-slab/bridge-client.test.ts | 21 +++++++++++++++-- .../runtime/local-slab/bridge-client.ts | 23 +++++++++++++------ src/slab/launch.test.ts | 17 ++++++++++++++ src/slab/launch.ts | 23 ++++++++++++++----- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/browser/runtime/local-slab/bridge-client.test.ts b/src/browser/runtime/local-slab/bridge-client.test.ts index f93d7baa..7dc9684f 100644 --- a/src/browser/runtime/local-slab/bridge-client.test.ts +++ b/src/browser/runtime/local-slab/bridge-client.test.ts @@ -1,17 +1,22 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { SlabBridgeClient, SlabBridgeUnavailableError } from './bridge-client.js'; +import { DEFAULT_SLAB_SOCKET_PATH, SlabBridgeClient, SlabBridgeUnavailableError } from './bridge-client.js'; function fakeSocket() { const client = Object.assign(new EventEmitter(), { destroy() {}, - write() { return true; }, + 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', () => { + expect(DEFAULT_SLAB_SOCKET_PATH).toMatch(/\/\.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 }); @@ -21,6 +26,18 @@ describe('SLAB bridge client', () => { 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: 1, protocolMinVersion: 1, protocolMaxVersion: 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 }); diff --git a/src/browser/runtime/local-slab/bridge-client.ts b/src/browser/runtime/local-slab/bridge-client.ts index 7c7e8a1b..ff7f4310 100644 --- a/src/browser/runtime/local-slab/bridge-client.ts +++ b/src/browser/runtime/local-slab/bridge-client.ts @@ -1,11 +1,14 @@ import { createConnection } from 'node:net'; -import { tmpdir } from 'node:os'; +import { homedir } 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; +export const DEFAULT_SLAB_SOCKET_PATH = join(homedir(), '.slab', 'bridge.sock'); +const PROTOCOL_MIN_VERSION = 1; +const PROTOCOL_MAX_VERSION = 1; export class SlabBridgeUnavailableError extends Error {} @@ -62,13 +65,19 @@ export class SlabBridgeClient { #seenIds = new Set(); constructor(options: SlabBridgeClientOptions = {}) { - const socketPath = options.socketPath ?? join(tmpdir(), 'slab-bridge.sock'); + const socketPath = options.socketPath ?? DEFAULT_SLAB_SOCKET_PATH; this.#connect = options.connect ?? (() => createConnection(socketPath)); } hello(clientVersion: string): Promise { - return this.#request('hello', { clientVersion }, (result) => { - if (!isRecord(result) || result.protocolVersion !== 1) { + return this.#request('hello', { + clientVersion, + protocolVersion: PROTOCOL_MAX_VERSION, + protocolMinVersion: PROTOCOL_MIN_VERSION, + protocolMaxVersion: PROTOCOL_MAX_VERSION, + }, (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'); } @@ -78,19 +87,19 @@ export class SlabBridgeClient { } attach(profileId: string): Promise { - return this.#request('attach', { profileId }, (result) => { + return this.#request('attach', { protocolVersion: PROTOCOL_MAX_VERSION, 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', { connectionId }, (result) => { + await this.#request('release', { protocolVersion: PROTOCOL_MAX_VERSION, 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 { + #request(method: string, params: Record, validate: (result: unknown) => T): Promise { this.#ensureSocket(); const id = String(this.#nextId++); return new Promise((resolve, reject) => { diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts index 4d6a7f23..a4449864 100644 --- a/src/slab/launch.test.ts +++ b/src/slab/launch.test.ts @@ -4,6 +4,23 @@ 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')) diff --git a/src/slab/launch.ts b/src/slab/launch.ts index 4e626399..4d53a1f6 100644 --- a/src/slab/launch.ts +++ b/src/slab/launch.ts @@ -1,9 +1,9 @@ -import { execFile as execFileCallback } from 'node:child_process'; +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, SlabRequiredError } from '../errors.js'; +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'; @@ -22,8 +22,7 @@ export interface SlabLaunchIo { } export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { - const installation = io.findInstallation(); - if (!installation) throw new SlabRequiredError(); + 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; } @@ -45,10 +44,22 @@ export function createSlabLaunchIo(): SlabLaunchIo { return { findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), isRunning: async (executablePath) => execFile('pgrep', ['-f', executablePath]).then(() => true, () => false), - launch: async (executablePath) => { await execFile('open', [dirname(dirname(dirname(executablePath)))]); }, + launch: async (executablePath) => { + if (executablePath === 'slab-browser') { + const child = spawn(executablePath, ['bridge'], { detached: true, stdio: 'ignore' }); + child.unref(); + return; + } + await execFile('open', [dirname(dirname(dirname(executablePath)))]); + }, restart: async (executablePath) => { await execFile('pkill', ['-f', executablePath]).catch(() => {}); - await execFile('open', [dirname(dirname(dirname(executablePath)))]); + if (executablePath === 'slab-browser') { + const child = spawn(executablePath, ['bridge'], { detached: true, stdio: 'ignore' }); + child.unref(); + } else { + await execFile('open', [dirname(dirname(dirname(executablePath)))]); + } }, hello: () => client.hello('webcmd'), wait: () => new Promise((resolve) => setTimeout(resolve, 100)), From dce286c04c4b5df33c60ad288085ad4449bfd32d Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 15:59:00 +0530 Subject: [PATCH 25/26] fix SLAB bridge endpoint compatibility --- .../runtime/local-slab/bridge-client.test.ts | 5 +++-- .../runtime/local-slab/bridge-client.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/browser/runtime/local-slab/bridge-client.test.ts b/src/browser/runtime/local-slab/bridge-client.test.ts index 7dc9684f..a1ce95be 100644 --- a/src/browser/runtime/local-slab/bridge-client.test.ts +++ b/src/browser/runtime/local-slab/bridge-client.test.ts @@ -13,7 +13,8 @@ function fakeSocket() { describe('SLAB bridge client', () => { it('uses the per-user SLAB bridge socket by default', () => { - expect(DEFAULT_SLAB_SOCKET_PATH).toMatch(/\/\.slab\/bridge\.sock$/); + 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'); }); @@ -32,7 +33,7 @@ describe('SLAB bridge client', () => { 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: 1, protocolMinVersion: 1, protocolMaxVersion: 1 }, + 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 }); diff --git a/src/browser/runtime/local-slab/bridge-client.ts b/src/browser/runtime/local-slab/bridge-client.ts index ff7f4310..a4cddb0e 100644 --- a/src/browser/runtime/local-slab/bridge-client.ts +++ b/src/browser/runtime/local-slab/bridge-client.ts @@ -1,12 +1,15 @@ import { createConnection } from 'node:net'; -import { homedir } from 'node:os'; +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; -export const DEFAULT_SLAB_SOCKET_PATH = join(homedir(), '.slab', 'bridge.sock'); +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; @@ -30,6 +33,8 @@ type PendingRequest = { 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); } @@ -72,9 +77,7 @@ export class SlabBridgeClient { hello(clientVersion: string): Promise { return this.#request('hello', { clientVersion, - protocolVersion: PROTOCOL_MAX_VERSION, - protocolMinVersion: PROTOCOL_MIN_VERSION, - protocolMaxVersion: PROTOCOL_MAX_VERSION, + protocolVersion: protocolVersionRange, }, (result) => { if (!isRecord(result) || typeof result.protocolVersion !== 'number' || result.protocolVersion < PROTOCOL_MIN_VERSION || result.protocolVersion > PROTOCOL_MAX_VERSION) { @@ -87,19 +90,19 @@ export class SlabBridgeClient { } attach(profileId: string): Promise { - return this.#request('attach', { protocolVersion: PROTOCOL_MAX_VERSION, profileId }, (result) => { + 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: PROTOCOL_MAX_VERSION, connectionId }, (result) => { + 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 { + #request(method: string, params: Record, validate: (result: unknown) => T): Promise { this.#ensureSocket(); const id = String(this.#nextId++); return new Promise((resolve, reject) => { From 0530a8f1085787db8668ebbceb4ea9f6b1f6ab9a Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 19 Aug 2026 16:01:25 +0530 Subject: [PATCH 26/26] fix SLAB app running detection --- src/slab/launch.test.ts | 24 ++++++++++++++++++++++++ src/slab/launch.ts | 16 ++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts index a4449864..25b596fe 100644 --- a/src/slab/launch.test.ts +++ b/src/slab/launch.test.ts @@ -80,4 +80,28 @@ describe('SLAB launch', () => { 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 index 4d53a1f6..16adaa77 100644 --- a/src/slab/launch.ts +++ b/src/slab/launch.ts @@ -43,22 +43,22 @@ export function createSlabLaunchIo(): SlabLaunchIo { const client = new SlabBridgeClient(); return { findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), - isRunning: async (executablePath) => execFile('pgrep', ['-f', executablePath]).then(() => true, () => false), + 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', [dirname(dirname(dirname(executablePath)))]); + await execFile('open', [appBundlePath(executablePath)]); }, restart: async (executablePath) => { - await execFile('pkill', ['-f', executablePath]).catch(() => {}); + 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', [dirname(dirname(dirname(executablePath)))]); + await execFile('open', [appBundlePath(executablePath)]); } }, hello: () => client.hello('webcmd'), @@ -66,3 +66,11 @@ export function createSlabLaunchIo(): SlabLaunchIo { 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))); +}