From 350d6982152a895ebf1117e254b1ef0f1c5f12d2 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 26 Aug 2026 21:51:56 +0530 Subject: [PATCH 01/34] refactor: port SLAB runtime onto current webcmd main --- src/browser/runtime/local-slab/actions.ts | 555 +++++++ src/browser/runtime/local-slab/attachment.ts | 50 + src/browser/runtime/local-slab/downloads.ts | 29 + src/browser/runtime/local-slab/network.ts | 150 ++ src/browser/runtime/local-slab/profiles.ts | 24 + src/browser/runtime/local-slab/provider.ts | 165 ++ .../local-slab/runtime-selection.test.ts | 168 +++ .../runtime/local-slab/session-manager.ts | 1323 +++++++++++++++++ src/daemon.ts | 4 +- src/slab/installation.ts | 28 + src/slab/release-key.ts | 22 + 11 files changed, 2516 insertions(+), 2 deletions(-) create mode 100644 src/browser/runtime/local-slab/actions.ts create mode 100644 src/browser/runtime/local-slab/attachment.ts create mode 100644 src/browser/runtime/local-slab/downloads.ts create mode 100644 src/browser/runtime/local-slab/network.ts create mode 100644 src/browser/runtime/local-slab/profiles.ts create mode 100644 src/browser/runtime/local-slab/provider.ts create mode 100644 src/browser/runtime/local-slab/runtime-selection.test.ts create mode 100644 src/browser/runtime/local-slab/session-manager.ts create mode 100644 src/slab/installation.ts create mode 100644 src/slab/release-key.ts diff --git a/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts new file mode 100644 index 00000000..bd065a73 --- /dev/null +++ b/src/browser/runtime/local-slab/actions.ts @@ -0,0 +1,555 @@ +import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js'; +import { extractArticle, type ExtractedArticle } from '../../article-extract.js'; +import { + captureSnapshot, + boundSnapshotText, + MemorySnapshotBaselineStore, + renderSnapshotResult, + type SnapshotBaselineStore, +} from '../../snapshot/index.js'; +import { redactText, redactUrl } from '../../../observation/redaction.js'; +import { articleHtmlToMarkdown } from '../../../download/article-download.js'; +import { waitForDownload } from './downloads.js'; +import type { SlabSessionManager } from './session-manager.js'; +import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; +import { runBrowserProgram } from '../../run/runner.js'; +import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; + +const snapshotBaselines = new WeakMap(); + +function snapshotBaselineStore(manager: SlabSessionManager): SnapshotBaselineStore { + let baselineStore = snapshotBaselines.get(manager); + if (!baselineStore) { + baselineStore = new MemorySnapshotBaselineStore(); + snapshotBaselines.set(manager, baselineStore); + } + return baselineStore; +} + +class SlabActionError extends Error { + constructor( + readonly errorCode: string, + error: string, + readonly page?: string, + readonly errorHint?: string, + ) { + super(error); + } +} + +export function resolveSlabCommandProfileId(manager: SlabSessionManager, command: BrowserRuntimeCommand): string { + const requested = command.profileId ?? command.contextId; + if (requested?.trim()) return requested.trim(); + + const preferred = command.preferredContextId?.trim(); + if (!preferred) return 'default'; + + const active = manager.activeProfileIds(); + if (active.includes(preferred)) return preferred; + if (active.length === 1) return active[0]; + if (active.length > 1) { + throw new SlabActionError( + 'profile_required', + `Default SLAB profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, + undefined, + 'Run webcmd profile list, then update the default with webcmd profile use or pass --profile .', + ); + } + return preferred; +} + +function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserRuntimeResult { + return { id: command.id, ok: false, errorCode: 'invalid_request', error }; +} + +/** + * Translate the command vocabulary ('load' | 'none') into Playwright's for a + * `page.goto` call. 'none' maps to 'commit': sites that stream analytics forever + * never fire the load event, so adapters gating readiness on their own selector + * waits must be able to skip it. + * + * Every Playwright-backed navigation in this runtime goes through here, so a + * future waitUntil value reaches all of them at once — the hardcoded literal at + * the second call site is what left `tab new --url` hanging after #106/#107. + */ +function toGotoWaitUntil(waitUntil: BrowserRuntimeCommand['waitUntil']): 'load' | 'commit' { + return waitUntil === 'none' ? 'commit' : 'load'; +} + +async function resolveLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); + if (command.page) { + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + } + return manager.getPage({ + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }); +} + +async function resolveExistingLease(manager: SlabSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveSlabCommandProfileId(manager, command); + if (command.page) { + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); + } + const existing = await manager.findPage({ + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + }); + if (existing) return existing; + throw new SlabActionError( + 'session_not_found', + `Browser session not found: ${command.session ?? ''}`, + undefined, + 'Start the session with browser run or navigate before requesting a snapshot.', + ); +} + +function execTarget(page: PlaywrightPage, frameIndex: number | undefined, pageId: string): PlaywrightPage | Frame { + if (frameIndex == null) return page; + const frame = page.frames().slice(1)[frameIndex]; + if (!frame) throw new SlabActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId); + return frame; +} + +function readableSnapshotText(article: ExtractedArticle | null): { text: string; warnings: string[]; article: unknown } { + if (!article) { + return { + text: 'No readable article content found. Use --snapshot-mode tree to inspect the page structure.', + warnings: ['No readable article content found.'], + article: null, + }; + } + const meta = [ + article.title ? `# ${article.title}` : '', + article.byline ? `> Author: ${article.byline}` : '', + article.publishedTime ? `> Published: ${article.publishedTime}` : '', + article.siteName ? `> Site: ${article.siteName}` : '', + `> Source: ${article.source}`, + ].filter(Boolean); + return { + text: `${meta.join('\n')}\n\n${articleHtmlToMarkdown(article.html)}`.trim(), + warnings: [], + article: { + title: article.title, + byline: article.byline, + publishedTime: article.publishedTime, + siteName: article.siteName, + source: article.source, + }, + }; +} + +async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, command: BrowserRuntimeCommand): Promise { + const width = Number.isFinite(command.width) && command.width! > 0 ? Math.ceil(command.width!) : undefined; + const height = !command.fullPage && Number.isFinite(command.height) && command.height! > 0 ? Math.ceil(command.height!) : undefined; + const options = { + type: command.format ?? 'png', + quality: command.format === 'jpeg' ? command.quality : undefined, + fullPage: command.fullPage, + } as const; + if (width === undefined && height === undefined) return page.screenshot(options); + + const current = page.viewportSize(); + if (current) { + // Emulated viewport: override for the shot, then restore the prior fixed size. + await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height }); + try { + return await page.screenshot(options); + } finally { + await page.setViewportSize(current); + } + } + + // Real-window context (viewport: null): setViewportSize can't return to a windowed + // state, so override reversibly via CDP and clear it so the override is per-shot only. + const windowSize = width === undefined || height === undefined + ? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })) + : { width: 0, height: 0 }; + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: width ?? windowSize.width, + height: height ?? windowSize.height, + deviceScaleFactor: 0, + mobile: false, + }); + return await page.screenshot(options); + } finally { + await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); + await cdp.detach().catch(() => {}); + } +} + +export async function dispatchSlabAction(manager: SlabSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { + try { + switch (command.action) { + case 'navigate': { + if (!command.url) return invalidRequest(command, 'Missing url'); + const profileId = resolveSlabCommandProfileId(manager, command); + const lease = await manager.navigatePage( + { + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }, + command.url, + toGotoWaitUntil(command.waitUntil), + ); + return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; + } + case 'exec': { + if (!command.code) return invalidRequest(command, 'Missing code'); + const lease = await resolveLease(manager, command); + const target = execTarget(lease.page, command.frameIndex, lease.pageId); + const data = await target.evaluate(command.code); + return { id: command.id, ok: true, data, page: lease.pageId }; + } + case 'run': { + if (typeof command.source !== 'string' || !command.source.trim()) { + return invalidRequest(command, 'Missing source'); + } + if (Buffer.byteLength(command.source, 'utf8') > BROWSER_RUN_MAX_SOURCE_BYTES) { + return { + id: command.id, + ok: false, + errorCode: 'BROWSER_RUN_SOURCE_LIMIT', + error: `Browser-run source exceeds the ${BROWSER_RUN_MAX_SOURCE_BYTES}-byte limit.`, + }; + } + const lease = await resolveLease(manager, command); + const scope = await manager.browserRunScope({ + profileId: lease.profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + }, lease.page); + const data = await runBrowserProgram({ + ...scope, + pageId: lease.pageId, + }, command.source, { + timeoutMs: command.timeoutMs, + maxOutputChars: command.maxOutputChars, + memoryLimitBytes: command.memoryLimitBytes, + snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff, + snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act', + snapshotBaselineStore: snapshotBaselineStore(manager), + onStaleContext: () => manager.invalidateIfClosedContext(lease.profileId, scope.context), + ...(signal ? { signal } : {}), + }); + return { + id: command.id, + ok: true, + data, + page: lease.pageId, + }; + } + case 'snapshot': { + const lease = await resolveExistingLease(manager, command); + if (command.snapshotMode === 'read') { + const readable = readableSnapshotText(await extractArticle(lease.page, { force: true })); + const redacted = redactUrl(redactText(readable.text, { maxStringLength: Number.MAX_SAFE_INTEGER })); + const bounded = Number.isFinite(command.maxOutputChars) + ? boundSnapshotText(redacted, command.maxOutputChars!) + : { value: redacted, truncated: false }; + return { + id: command.id, + ok: true, + data: { + ok: true, + tree: bounded.value, + article: readable.article, + page: { + id: lease.pageId, + url: redactUrl(lease.page.url()), + title: redactText(await lease.page.title().catch(() => '')), + }, + warnings: readable.warnings, + limits: { snapshotTruncated: bounded.truncated }, + }, + page: lease.pageId, + }; + } + const snapshot = await captureSnapshot(lease.page); + const rendered = renderSnapshotResult(snapshot, { + mode: command.snapshotMode === 'tree' ? 'tree' : 'act', + ref: command.ref, + maxChars: command.maxOutputChars, + }); + const redacted = redactUrl(redactText(rendered.value, { maxStringLength: Number.MAX_SAFE_INTEGER })); + const bounded = Number.isFinite(command.maxOutputChars) + ? boundSnapshotText(redacted, command.maxOutputChars!) + : { value: redacted, truncated: false }; + const warnings = [...rendered.warnings]; + if (bounded.truncated) warnings.push('Snapshot output was truncated after redaction.'); + snapshotBaselineStore(manager).set(lease.pageId, snapshot); + return { + id: command.id, + ok: true, + data: { + ok: true, + tree: bounded.value, + page: { + id: lease.pageId, + url: redactUrl(lease.page.url()), + title: redactText(await lease.page.title().catch(() => '')), + }, + warnings, + limits: { snapshotTruncated: rendered.truncated || bounded.truncated }, + }, + page: lease.pageId, + }; + } + case 'cookies': { + const lease = await resolveLease(manager, command); + const cookies = await lease.context.cookies(command.url ? [command.url] : undefined); + const data = command.domain ? cookies.filter((cookie) => cookie.domain.includes(command.domain!)) : cookies; + return { id: command.id, ok: true, data }; + } + case 'screenshot': { + const lease = await resolveLease(manager, command); + const buffer = await captureScreenshot(lease.page, lease.context, command); + return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId }; + } + case 'close-window': { + if (command.page) { + const closed = await manager.closePage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + }); + return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; + } else { + await manager.release({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + }); + return { id: command.id, ok: true, data: { closed: true, session: command.session } }; + } + } + case 'tabs': { + switch (command.op ?? 'list') { + case 'list': { + const tabs = await manager.listPages({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + }); + return { id: command.id, ok: true, data: tabs }; + } + case 'new': { + const lease = await manager.newPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + url: command.url, + waitUntil: toGotoWaitUntil(command.waitUntil), + windowMode: command.windowMode, + }); + return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; + } + case 'select': { + const lease = await manager.selectPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + windowMode: command.windowMode, + }); + if (!lease) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; + return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId }; + } + case 'close': { + const closed = await manager.closePage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + }); + if (!closed) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; + return { id: command.id, ok: true, data: { closed } }; + } + default: + return invalidRequest(command, `Unknown tabs op: ${command.op}`); + } + } + case 'set-file-input': { + if (!command.files?.length) return invalidRequest(command, 'Missing or empty files array'); + const lease = await resolveLease(manager, command); + const locator = lease.page.locator(command.selector ?? 'input[type="file"]').first(); + await locator.setInputFiles(command.files); + return { id: command.id, ok: true, data: { count: command.files.length }, page: lease.pageId }; + } + case 'insert-text': { + if (typeof command.text !== 'string') return invalidRequest(command, 'Missing text payload'); + const lease = await resolveLease(manager, command); + await lease.page.keyboard.insertText(command.text); + return { id: command.id, ok: true, data: { inserted: true }, page: lease.pageId }; + } + case 'network-capture-start': { + const lease = await resolveLease(manager, command); + manager.networkCapture.start(command.pattern ?? '', lease.page); + return { id: command.id, ok: true, data: { started: true }, page: lease.pageId }; + } + case 'network-capture-read': { + const lease = await resolveLease(manager, command); + return { id: command.id, ok: true, data: await manager.networkCapture.read(lease.page), page: lease.pageId }; + } + case 'wait-download': { + const lease = await resolveLease(manager, command); + const result = await waitForDownload(lease.page, command.pattern ?? '', command.timeoutMs ?? 30_000); + return { id: command.id, ok: true, data: result, page: lease.pageId }; + } + case 'cdp': { + if (!command.cdpMethod) return invalidRequest(command, 'Missing cdpMethod'); + const lease = await resolveLease(manager, command); + const session = await lease.context.newCDPSession(lease.page); + const data = await session.send(command.cdpMethod as any, command.cdpParams ?? {}); + return { id: command.id, ok: true, data, page: lease.pageId }; + } + case 'frames': { + const lease = await resolveLease(manager, command); + const frames = lease.page.frames().slice(1).map((frame, index) => ({ + index, + frameId: frame.name() || String(index), + url: frame.url(), + name: frame.name(), + })); + return { id: command.id, ok: true, data: frames, page: lease.pageId }; + } + case 'bind': + if (!command.page && command.index == null) { + return { + id: command.id, + ok: false, + errorCode: 'invalid_request', + error: 'Bind requires --page or --index for a SLAB runtime tab', + errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', + }; + } + { + const lease = await manager.bindPage({ + profileId: resolveSlabCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + pageId: command.page, + index: command.index, + }); + if (!lease) { + return { + id: command.id, + ok: false, + errorCode: 'bound_tab_not_found', + error: 'SLAB tab not found for bind target', + errorHint: 'Run `webcmd --session browser tab list` and choose a current SLAB tab id or index.', + }; + } + return { + id: command.id, + ok: true, + page: lease.pageId, + data: { + bound: true, + session: command.session, + page: lease.pageId, + url: lease.page.url(), + title: await lease.page.title().catch(() => ''), + }, + }; + } + default: + return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: `Unknown action: ${command.action}` }; + } + } catch (err) { + if (err instanceof SlabActionError) { + return { id: command.id, ok: false, errorCode: err.errorCode, error: err.message, ...(err.page && { page: err.page }), ...(err.errorHint && { errorHint: err.errorHint }) }; + } + if ( + err instanceof Error + && 'code' in err + && typeof err.code === 'string' + && (err.code.startsWith('BROWSER_RUN_') || err.code === 'SESSION_WINDOW_CONFLICT') + ) { + const hint = 'hint' in err && typeof err.hint === 'string' + ? err.hint + : undefined; + const details = 'details' in err ? err.details : undefined; + return { + id: command.id, + ok: false, + errorCode: err.code, + error: err.message, + ...(hint && { errorHint: hint }), + ...(details !== undefined ? { details } : {}), + }; + } + return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts new file mode 100644 index 00000000..de1b73ff --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.ts @@ -0,0 +1,50 @@ +import { chromium, type Browser, type BrowserContext } from 'playwright-core'; + +export interface AttachedSlabProfile { + profileId: string; + browserVersion: string; + context: BrowserContext; + browser: Browser; + release(): Promise; +} + +export interface SlabAttachment { + connectionId: string; + profile: { id: string; displayName: string }; + cdpUrl: string; + bearerToken: string; + expiresAt: string; +} + +export interface SlabBridge { + attach(profileId: string): Promise; + release(connectionId: string): Promise; +} + +export interface AttachSlabProfileOptions { + bridge?: SlabBridge; + connectOverCDP?: typeof chromium.connectOverCDP; +} + +export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { + const bridge = options.bridge; + if (!bridge) throw new Error('SLAB control client is not available.'); + const attachment = await bridge.attach(profileId); + try { + const browser = await (options.connectOverCDP ?? chromium.connectOverCDP.bind(chromium))(attachment.cdpUrl, { + headers: { Authorization: `Bearer ${attachment.bearerToken}` }, + }); + const context = browser.contexts()[0]; + if (!context) throw new Error('SLAB attachment returned no persistent browser context.'); + return { + profileId: attachment.profile.id, + browserVersion: browser.version(), + context, + browser, + release: () => bridge.release(attachment.connectionId), + }; + } catch (error) { + await bridge.release(attachment.connectionId).catch(() => {}); + throw error; + } +} diff --git a/src/browser/runtime/local-slab/downloads.ts b/src/browser/runtime/local-slab/downloads.ts new file mode 100644 index 00000000..5cabb10a --- /dev/null +++ b/src/browser/runtime/local-slab/downloads.ts @@ -0,0 +1,29 @@ +import type { Page } from 'playwright-core'; +import type { BrowserDownloadWaitResult } from '../../../types.js'; + +export async function waitForDownload(page: Page, pattern: string, timeoutMs: number): Promise { + const startedAt = Date.now(); + try { + const download = await page.waitForEvent('download', { + timeout: timeoutMs, + predicate: (candidate) => { + if (!pattern) return true; + return candidate.url().includes(pattern) || candidate.suggestedFilename().includes(pattern); + }, + }); + const failure = await download.failure(); + return { + downloaded: !failure, + filename: download.suggestedFilename(), + url: download.url(), + error: failure ?? undefined, + elapsedMs: Date.now() - startedAt, + }; + } catch (err) { + return { + downloaded: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - startedAt, + }; + } +} diff --git a/src/browser/runtime/local-slab/network.ts b/src/browser/runtime/local-slab/network.ts new file mode 100644 index 00000000..05d4fa46 --- /dev/null +++ b/src/browser/runtime/local-slab/network.ts @@ -0,0 +1,150 @@ +import type { Page, Request, Response } from 'playwright-core'; + +export interface NetworkCaptureEntry { + kind: 'cdp'; + url: string; + method: string; + requestHeaders?: Record; + requestBodyKind?: string; + requestBodyPreview?: string; + requestBodyFullSize?: number; + requestBodyTruncated?: boolean; + responseStatus?: number; + responseContentType?: string; + responseHeaders?: Record; + responsePreview?: string; + responseBodyFullSize?: number; + responseBodyTruncated?: boolean; + timestamp: number; +} + +type CaptureState = { + pattern: string; + entries: NetworkCaptureEntry[]; + byRequest: WeakMap; + pending: Set>; + onRequest: (request: Request) => void; + onResponse: (response: Response) => void; +}; + +const BODY_LIMIT = 8 * 1024 * 1024; + +export class SlabNetworkCapture { + private readonly states = new WeakMap(); + + constructor(private readonly limit = 200) {} + + start(pattern: string, page: Page): void { + this.stop(page); + const entries: NetworkCaptureEntry[] = []; + const byRequest = new WeakMap(); + const pending = new Set>(); + const state: CaptureState = { + pattern, + entries, + byRequest, + pending, + onRequest: (request) => { + const url = request.url(); + if (pattern && !url.includes(pattern)) return; + const body = request.postData() ?? undefined; + const entry = { + kind: 'cdp', + url, + method: request.method(), + requestHeaders: request.headers(), + requestBodyKind: body === undefined ? undefined : 'text', + requestBodyPreview: body === undefined ? undefined : body.slice(0, BODY_LIMIT), + requestBodyFullSize: body?.length, + requestBodyTruncated: body ? body.length > BODY_LIMIT : undefined, + timestamp: Date.now(), + } satisfies NetworkCaptureEntry; + entries.push(entry); + byRequest.set(request, entry); + this.bound(entries); + }, + onResponse: (response) => { + const capture = this.captureResponse(response, pattern, entries, byRequest) + .finally(() => pending.delete(capture)); + pending.add(capture); + }, + }; + page.on('request', state.onRequest); + page.on('response', state.onResponse); + this.states.set(page, state); + } + + async read(page: Page): Promise { + const state = this.states.get(page); + if (!state) return []; + await Promise.allSettled([...state.pending]); + return [...state.entries]; + } + + stop(page: Page): void { + const state = this.states.get(page); + if (!state) return; + page.off('request', state.onRequest); + page.off('response', state.onResponse); + this.states.delete(page); + } + + private async captureResponse( + response: Response, + pattern: string, + entries: NetworkCaptureEntry[], + byRequest: WeakMap, + ): Promise { + const url = response.url(); + if (pattern && !url.includes(pattern)) return; + const headers = response.headers(); + const contentType = headers['content-type']; + let preview: string | undefined; + let fullSize: number | undefined; + let truncated: boolean | undefined; + const contentLength = Number(headers['content-length']); + if (Number.isFinite(contentLength) && contentLength >= 0) fullSize = contentLength; + if (isTextLikeContentType(contentType)) { + try { + const text = await response.text(); + fullSize = text.length; + truncated = text.length > BODY_LIMIT; + preview = text.slice(0, BODY_LIMIT); + } catch { + preview = undefined; + } + } + const responseRequest = typeof response.request === 'function' ? response.request() : undefined; + const existing = responseRequest + ? byRequest.get(responseRequest) + : [...entries].reverse().find((entry) => entry.url === url && entry.responseStatus === undefined); + const target = existing ?? { + kind: 'cdp' as const, + url, + method: 'GET', + timestamp: Date.now(), + }; + target.responseStatus = response.status(); + target.responseContentType = contentType; + target.responseHeaders = headers; + target.responsePreview = preview; + target.responseBodyFullSize = fullSize; + target.responseBodyTruncated = truncated; + if (!existing) entries.push(target); + this.bound(entries); + } + + private bound(entries: NetworkCaptureEntry[]): void { + while (entries.length > this.limit) entries.shift(); + } +} + +function isTextLikeContentType(contentType: string | undefined): boolean { + if (!contentType) return false; + const normalized = contentType.toLowerCase(); + return normalized.startsWith('text/') + || normalized.includes('json') + || normalized.includes('javascript') + || normalized.includes('xml') + || normalized.includes('x-www-form-urlencoded'); +} diff --git a/src/browser/runtime/local-slab/profiles.ts b/src/browser/runtime/local-slab/profiles.ts new file mode 100644 index 00000000..fca205c1 --- /dev/null +++ b/src/browser/runtime/local-slab/profiles.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; +import os from 'node:os'; + +export interface SlabProfileDirOptions { + baseDir?: string; +} + +export function normalizeProfileId(value: string | undefined | null): string { + const id = value?.trim() || 'default'; + if (!/^[A-Za-z0-9._-]+$/.test(id) || id === '.' || id === '..') { + throw new Error(`Invalid profile id: ${value ?? ''}`); + } + return id; +} + +export function getWebcmdConfigDir(): string { + return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); +} + +export function resolveSlabProfileDir(profileId: string, opts: SlabProfileDirOptions = {}): string { + const safeProfileId = normalizeProfileId(profileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'slab', 'profiles', safeProfileId); +} diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts new file mode 100644 index 00000000..557fd0c9 --- /dev/null +++ b/src/browser/runtime/local-slab/provider.ts @@ -0,0 +1,165 @@ +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, resolveSlabCommandProfileId } from './actions.js'; +import type { AttachSlabProfile } from './session-manager.js'; +import { SlabSessionManager } from './session-manager.js'; + +export interface LocalSlabRuntimeProviderOptions { + baseDir?: string; + attachProfile?: AttachSlabProfile; +} + +export function createLocalBrowserRuntimeProvider(opts: LocalSlabRuntimeProviderOptions = {}): LocalSlabRuntimeProvider { + return new LocalSlabRuntimeProvider(opts); +} + +export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { + private managerInstance?: SlabSessionManager; + private readonly sessions: LocalBrowserSessionStore; + private readonly sessionQueues = new Map>(); + + constructor(private readonly opts: LocalSlabRuntimeProviderOptions = {}) { + this.sessions = new LocalBrowserSessionStore({ + baseDir: opts.baseDir, + isActive: session => this.managerInstance?.hasSession(session.profileId, session.id) ?? false, + }); + } + + private get manager(): SlabSessionManager { + return this.managerInstance ??= new SlabSessionManager({ + ...this.opts, + hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( + Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() + )), + }); + } + + async status(opts: RuntimeStatusOptions = {}): Promise { + const profiles = this.manager.profileStatuses(); + return { + runtimeConnected: true, + runtimeName: 'SLAB', + runtimeVersion: profiles.find(profile => profile.runtimeVersion)?.runtimeVersion, + profiles, + pending: 0, + commandResultUnknown: 0, + sessions: await this.listSessions({ profileId: opts.contextId }), + }; + } + + resolveProfileId(command: BrowserRuntimeCommand): string { + return resolveSlabCommandProfileId(this.manager, command); + } + + async createSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.create(this.resolveProfileId(command)); + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.require(this.resolveProfileId(command), command.session); + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); + } + + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const profileId = this.resolveProfileId(command); + const sessionId = command.sessionId!; + const record = this.sessions.markHandoff(profileId, sessionId, { + site: command.site!, + expiresAt: command.expiresAt!, + }); + await this.manager.foregroundSession(profileId, sessionId); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); + } + + async listSessions(input: { profileId?: string; limit?: number }): Promise { + return this.sessions.list(input.profileId, input.limit).map((session) => ({ + ...session, + runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', + })); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const record = this.sessions.require(this.resolveProfileId(command), command.session); + const closedCount = await this.manager.closeSession(record.profileId, record.id); + if (command.force && command.discard === true && record.kind === 'explicit' && !record.handoff) this.sessions.remove(record.profileId, record.id); + else if (command.force && record.handoff) this.sessions.clearHandoff(record.profileId, record.id); + else this.sessions.touch(record.profileId, record.id); + return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; + } + + async dispatch(rawCommand: BrowserRuntimeCommand, signal?: AbortSignal): Promise { + // Every dispatched command runs in the background unless the caller asked + // for a window explicitly, so no command steals focus by default. Session + // handoff bypasses dispatch and still foregrounds through foregroundSession. + const command: BrowserRuntimeCommand = rawCommand.windowMode + ? rawCommand + : { ...rawCommand, windowMode: 'background' }; + const key = this.commandQueueKey(command); + const previous = this.sessionQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + this.sessionQueues.set(key, current); + + await previous.catch(() => {}); + try { + signal?.throwIfAborted(); + if (typeof command.deadlineAt === 'number' && command.deadlineAt > 0 && Date.now() >= command.deadlineAt) { + return { + id: command.id, + ok: false, + errorCode: 'command_result_unknown', + error: 'Command deadline expired before browser work started.', + }; + } + return await this.manager.runWithProfileActivity( + this.resolveProfileId(command), + () => dispatchSlabAction(this.manager, command, signal), + ); + } finally { + release(); + if (this.sessionQueues.get(key) === current) { + this.sessionQueues.delete(key); + } + } + } + + async shutdown(): Promise { + await this.manager.shutdown(); + } + + private commandQueueKey(command: BrowserRuntimeCommand): string { + if (command.page) { + const owner = this.manager.pageOwner(command.page); + if (owner) { + const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' + ? owner.adapterSite?.trim() + : undefined; + return `session\u0000${owner.profileId}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } + } + + let profileId: string; + try { + profileId = this.resolveProfileId(command); + } catch { + profileId = command.profileId + ?? command.contextId + ?? command.preferredContextId + ?? 'default'; + } + const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' + ? command.adapterSite?.trim() + : undefined; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } +} diff --git a/src/browser/runtime/local-slab/runtime-selection.test.ts b/src/browser/runtime/local-slab/runtime-selection.test.ts new file mode 100644 index 00000000..31cca59d --- /dev/null +++ b/src/browser/runtime/local-slab/runtime-selection.test.ts @@ -0,0 +1,168 @@ +import fs from 'node:fs'; +import { AddressInfo } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DAEMON_HEADER_NAME } from '../../../constants.js'; +import { createDaemonServer } from '../../../daemon/server.js'; +import type { BrowserRuntimeProvider } from '../provider.js'; + +function fakeAttachedProfile() { + const listeners = new Map void>>(); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + const fakePage = () => { + let closed = false; + const page: any = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue('ok'), + title: vi.fn().mockResolvedValue('Title'), + url: vi.fn(() => 'https://example.com/'), + bringToFront: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => closed), + close: vi.fn(async () => { + closed = true; + }), + opener: vi.fn().mockResolvedValue(null), + on(event: string, listener: (...args: unknown[]) => void) { + const events = pageListeners.get(page) ?? new Map(); + const bucket = events.get(event) ?? new Set(); + bucket.add(listener); + events.set(event, bucket); + pageListeners.set(page, events); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + pageListeners.get(page)?.get(event)?.delete(listener); + }, + }; + targetIds.set(page, `target-${++targetCounter}`); + windowIds.set(targetIds.get(page)!, ++windowCounter); + return page; + }; + const page = fakePage(); + const allPages = [page]; + const cdp = { + send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { + if (command === 'Target.createTarget') { + const created = fakePage(); + allPages.push(created); + queueMicrotask(() => { + for (const listener of listeners.get('page') ?? []) listener(created); + }); + return { targetId: targetIds.get(created) }; + } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; + }), + on: vi.fn(), + detach: vi.fn().mockResolvedValue(undefined), + }; + const browser = { + close: vi.fn().mockResolvedValue(undefined), + newBrowserCDPSession: vi.fn().mockResolvedValue(cdp), + contexts: vi.fn(() => [context]), + version: vi.fn(() => '146.0'), + }; + context = { + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, + pages: vi.fn(() => allPages.filter((candidate) => !candidate.isClosed())), + newPage: vi.fn(async () => { + const created = fakePage(); + allPages.push(created); + return created; + }), + newCDPSession: vi.fn(async (target: object) => ({ + send: vi.fn(async (command: string, params?: { targetId?: string }) => { + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), + browser: vi.fn(() => browser), + close: vi.fn().mockResolvedValue(undefined), + }; + return { + profileId: 'default', + browserVersion: '146.0', + context, + browser, + release: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('local browser runtime selection', () => { + const servers: Array<{ close: () => Promise }> = []; + + afterEach(async () => { + while (servers.length) await servers.pop()!.close(); + }); + + it('uses the local-slab manager factory and releases the SLAB lease on daemon shutdown without quitting the browser', async () => { + const daemonSource = fs.readFileSync(fileURLToPath(new URL('../../../daemon.ts', import.meta.url)), 'utf8'); + expect(daemonSource).toContain("from './browser/runtime/local-slab/provider.js'"); + expect(daemonSource).toContain('createLocalBrowserRuntimeProvider'); + + const { createLocalBrowserRuntimeProvider, LocalSlabRuntimeProvider } = await import('./provider.js'); + const attached = fakeAttachedProfile(); + const quitApp = vi.fn(); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(attached), + }); + expect(provider).toBeInstanceOf(LocalSlabRuntimeProvider); + + const daemon = createDaemonServer(provider as BrowserRuntimeProvider, { + port: 0, + host: '127.0.0.1', + version: 'test', + }); + await daemon.listen(); + servers.push(daemon); + const address = daemon.server.address() as AddressInfo; + const baseUrl = `http://127.0.0.1:${address.port}`; + + const created = await fetch(`${baseUrl}/command`, { + method: 'POST', + headers: { [DAEMON_HEADER_NAME]: '1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'create', action: 'session-create', contextId: 'default' }), + }).then((res) => res.json()) as { data: { id: string } }; + + await fetch(`${baseUrl}/command`, { + method: 'POST', + headers: { [DAEMON_HEADER_NAME]: '1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'navigate', + action: 'navigate', + contextId: 'default', + session: created.data.id, + url: 'https://example.com/', + surface: 'browser', + }), + }); + + await daemon.close(); + + expect(attached.release).toHaveBeenCalledOnce(); + expect(attached.browser.close).not.toHaveBeenCalled(); + expect(attached.context.close).not.toHaveBeenCalled(); + expect(quitApp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts new file mode 100644 index 00000000..43e25383 --- /dev/null +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -0,0 +1,1323 @@ +import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; +import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; +import { normalizeProfileId } from './profiles.js'; +import { SlabNetworkCapture } from './network.js'; +import { log } from '../../../logger.js'; +import { CliError, EXIT_CODES } from '../../../errors.js'; +import { isClosedContextError } from '../../run/types.js'; +import { attachSlabProfile, type AttachedSlabProfile } from './attachment.js'; + +const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; +export const PROFILE_IDLE_TIMEOUT_MS = 60_000; +export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; + +export type AttachSlabProfile = typeof attachSlabProfile; + +export interface SessionKeyInput { + profileId?: string; + session?: string; + surface?: BrowserSurface; + siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + sessionId?: string; + adapterSite?: string; + runId?: string; + idleTimeout?: number; + windowMode?: BrowserWindowMode; + /** Discard the existing leased page (if any) and create a new one under the same lease. */ + freshPage?: boolean; +} + +export type NewPageInput = SessionKeyInput & { + url?: string; + /** + * Playwright `goto` readiness for `url`, already translated from the command + * vocabulary by the caller. Defaults to 'load'; 'commit' skips waiting for the + * load event on sites that never go idle. + */ + waitUntil?: 'load' | 'commit'; +}; + +type PageEntry = { + page: PlaywrightPage; + pageId: string; + targetId: string; + leaseKey: string; + sessionId?: string; + session: string; + surface: BrowserSurface; + siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + adapterSite?: string; + idleTimeout?: number; + idleTimer?: ReturnType; +}; + +export interface SlabPageLease { + profileId: string; + leaseKey: string; + context: BrowserContext; + page: PlaywrightPage; + pageId: string; +} + +export interface SlabTabInfo { + id: string; + page: string; + index: number; + title: string; + url: string; + profileId: string; + session: string; + sessionId: string; + surface: BrowserSurface; + selected: boolean; +} + +interface ProfileRuntime { + profileId: string; + attachment: AttachedSlabProfile; + context: BrowserContext; + cdp?: CDPSession; + sessions: Map; + windowOwners: Map; + targetPages: Map; + anchorTargetId?: string; + parkingPage?: PlaywrightPage; + useParkingKeeper: boolean; + keeperWarningLogged: boolean; + activeCommands: number; + idleTimer?: ReturnType; + handoffTimer?: ReturnType; + closing: boolean; + disposed: boolean; + releasePromise?: Promise; + lastSeenAt: number; +} + +interface SessionRuntime { + id: string; + windowIds: Set; + pages: Map; + selectedPageId?: string; +} + +export interface BrowserRunSessionScope { + browser: Browser; + context: BrowserContext; + page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export class SessionWindowConflictError extends CliError { + constructor(pageId: string, sessionId: string, owner?: string) { + super( + 'SESSION_WINDOW_CONFLICT', + `Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`, + undefined, + EXIT_CODES.TEMPFAIL, + ); + } +} + +export interface SlabSessionManagerOptions { + baseDir?: string; + attachProfile?: AttachSlabProfile; + hasActiveHandoff?: (profileId: string) => boolean; +} + +let pageCounter = 0; + +export function resolveLeaseKey(input: SessionKeyInput): string { + const surface = input.surface === 'adapter' ? 'adapter' : 'browser'; + const session = input.session?.trim(); + if (!session) throw new Error('Browser session is required.'); + const sessionId = input.sessionId?.trim() || session; + if (surface === 'adapter' && input.siteSession === 'persistent' && input.adapterSite) { + return `${sessionId}\u0000site:${input.adapterSite}`; + } + if (surface === 'adapter' && input.runId) { + return `${sessionId}\u0000ephemeral:${input.adapterSite ?? 'browser'}:${input.runId}`; + } + return `${surface}\u0000${encodeURIComponent(session)}`; +} + +function pageIsClosed(page: PlaywrightPage): boolean { + return page.isClosed?.() === true; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { + return Object.assign(new Error('The browser daemon is shutting down.'), { code: 'DAEMON_SHUTTING_DOWN' as const }); +} + +export class SlabSessionManager { + readonly networkCapture = new SlabNetworkCapture(); + + private readonly attachProfile: AttachSlabProfile; + private readonly hasActiveHandoff: (profileId: string) => boolean; + private readonly profiles = new Map(); + private readonly profileLaunches = new Map>(); + private readonly profileLifecycleQueues = new Map>(); + private readonly profileActivities = new Map(); + private readonly pageCreationQueues = new Map>(); + private readonly pageTargetIds = new WeakMap(); + private readonly pageTargetIdPromises = new WeakMap>(); + private readonly pageCdpSessions = new WeakMap(); + private readonly pageCdpDetaches = new WeakMap>(); + private readonly pendingTargetPages = new WeakMap>(); + private readonly targetPageWaiters = new WeakMap; + }>>(); + private readonly sessionPageListeners = new WeakMap void>>(); + private shuttingDown = false; + + constructor(private readonly opts: SlabSessionManagerOptions = {}) { + this.attachProfile = opts.attachProfile ?? attachSlabProfile; + this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); + } + + profileStatuses() { + return [...this.profiles.entries()].map(([contextId, runtime]) => ({ + contextId, + runtimeConnected: true, + runtimeVersion: runtime.attachment.browserVersion || undefined, + pending: 0, + lastSeenAt: runtime.lastSeenAt, + })); + } + + activeProfileIds(): string[] { + return [...this.profiles.keys()]; + } + + async runWithProfileActivity(profileIdInput: string | undefined, task: () => Promise): Promise { + const profileId = normalizeProfileId(profileIdInput); + await this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const count = (this.profileActivities.get(profileId) ?? 0) + 1; + this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.cancelProfileIdle(runtime); + } + }); + try { + return await task(); + } finally { + await this.withProfileLifecycleLock(profileId, async () => { + const count = Math.max(0, (this.profileActivities.get(profileId) ?? 1) - 1); + if (count === 0) this.profileActivities.delete(profileId); + else this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.scheduleProfileIdle(profileId, runtime); + } + }); + } + } + + async getPage(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + const surface = normalizeSurface(input.surface); + const leaseKey = resolveLeaseKey(input); + const freshPage = input.freshPage === true; + return this.withPageCreationLock(profileId, async () => { + const runtime = await this.getProfileRuntime(profileId, input.windowMode); + const sessionRuntime = this.getSessionRuntime(runtime, sessionId); + const existing = sessionRuntime.pages.get(leaseKey); + if (existing && !pageIsClosed(existing.page) && !freshPage) { + try { + await this.assertOwnedWindow(runtime, sessionId, existing); + runtime.lastSeenAt = Date.now(); + existing.idleTimeout = input.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing); + return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; + } catch (error) { + if (!isClosedContextError(error)) throw error; + // isClosed() reported false, but the liveness probe above shows the + // underlying CDP connection is actually dead. Invalidate the Profile + // runtime and fall through to acquire a fresh page instead of handing + // the same broken lease back out (webcmd#314). + this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); + } + } + const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, { + leaseKey, + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); + this.selectEntry(acquired.session, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: entry.page, pageId: entry.pageId }; + }); + } + + async findPage(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const leaseKey = resolveLeaseKey(input); + const runtime = this.profiles.get(profileId); + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = sessionRuntime?.pages.get(leaseKey); + if (!runtime || !sessionRuntime || !entry || pageIsClosed(entry.page)) return null; + await this.assertOwnedWindow(runtime, sessionId, entry); + runtime.lastSeenAt = Date.now(); + entry.idleTimeout = input.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + + async findPageById(pageId: string, opts: Pick): Promise { + const expectedProfileId = normalizeProfileId(opts.profileId); + const sessionId = requireSessionId(opts); + const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; + for (const [profileId, runtime] of this.profiles.entries()) { + if (expectedProfileId !== profileId) continue; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + for (const [leaseKey, entry] of sessionRuntime.pages.entries()) { + if ( + entry.pageId === pageId + && !pageIsClosed(entry.page) + && (!expectedSurface || entry.surface === expectedSurface) + ) { + await this.assertOwnedWindow(runtime, sessionId, entry); + entry.idleTimeout = opts.idleTimeout; + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + } + } + return null; + } + + pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface; sessionKind?: 'explicit' | 'adapter-default'; adapterSite?: string } | null { + for (const [profileId, runtime] of this.profiles.entries()) { + for (const entry of runtime.targetPages.values()) { + if (entry.pageId === pageId && !pageIsClosed(entry.page)) { + return { + profileId, + session: entry.session, + surface: entry.surface, + sessionKind: entry.sessionKind, + adapterSite: entry.adapterSite, + }; + } + } + } + return null; + } + + pageIdFor(page: PlaywrightPage): string | undefined { + for (const runtime of this.profiles.values()) { + for (const entry of runtime.targetPages.values()) { + if (entry.page === page) return entry.pageId; + } + } + return undefined; + } + + async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const runtime = this.profiles.get(profileId); + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); + if (!runtime || !sessionRuntime || !entry || entry.sessionId !== sessionId) { + throw new Error('Browser-run page is outside the selected Session.'); + } + await Promise.all(this.openEntries(sessionRuntime).map(([, candidate]) => ( + this.assertOwnedWindow(runtime, sessionId, candidate) + ))); + const browser = runtime.context.browser(); + if (!browser) throw new Error('The selected browser context is not attached to a browser.'); + return { + browser, + context: runtime.context, + page, + pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), + createPage: async () => (await this.newPage(input)).page, + onPage: (listener) => { + const listeners = this.sessionPageListeners.get(sessionRuntime) ?? new Set(); + listeners.add(listener); + this.sessionPageListeners.set(sessionRuntime, listeners); + return () => listeners.delete(listener); + }, + }; + } + + async listPages(input: Pick): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const surface = input.surface ? normalizeSurface(input.surface) : undefined; + const runtime = this.profiles.get(profileId); + if (!runtime) return []; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return []; + const entries = this.openEntries(sessionRuntime) + .filter(([, entry]) => !surface || entry.surface === surface); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + return Promise.all(entries.map(async ([, entry], index) => ({ + id: entry.pageId, + page: entry.pageId, + index, + title: await entry.page.title().catch(() => ''), + url: entry.page.url(), + profileId, + session: entry.session, + sessionId, + surface: entry.surface, + selected: sessionRuntime.selectedPageId === entry.pageId, + }))); + } + + async newPage(input: NewPageInput): Promise { + return this.newPageAttempt(input, 0); + } + + async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { + return this.navigatePageAttempt(input, url, waitUntil, 0); + } + + private async newPageAttempt(input: NewPageInput, attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + const surface = normalizeSurface(input.surface); + const acquired = await this.withPageCreationLock(profileId, async () => { + const result = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + return { runtime: result.runtime, sessionRuntime: result.session, page: result.page }; + }); + if (input.url) { + try { + await acquired.page.goto(input.url, { waitUntil: input.waitUntil ?? 'load' }); + } catch (error) { + if (attempt === 0 && isClosedContextError(error)) { + this.invalidateProfileRuntime(profileId, acquired.runtime); + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + return this.newPageAttempt(input, 1); + } + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + throw error; + } + } + if (this.profiles.get(profileId) !== acquired.runtime) { + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + throw new Error('Target page, context or browser has been closed'); + } + const entry = await this.registerOwnedPage(acquired.runtime, acquired.sessionRuntime, acquired.page, { + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + const leaseKey = entry.leaseKey; + this.refreshIdleTimer(acquired.runtime, acquired.sessionRuntime, leaseKey, entry); + this.selectEntry(acquired.sessionRuntime, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; + } + + private async navigatePageAttempt(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit', attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const lease = await this.getPage(input); + const runtime = this.profiles.get(profileId); + try { + await lease.page.goto(url, { waitUntil }); + return lease; + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {}); + return this.navigatePageAttempt(input, url, waitUntil, 1); + } + } + + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; + if (!match) return null; + const [leaseKey, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + if (input.windowMode !== 'background') { + await entry.page.bringToFront?.().catch(() => {}); + } + this.selectEntry(sessionRuntime, entry); + runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + + async foregroundSession(profileIdInput: string, sessionId: string): Promise { + const profileId = normalizeProfileId(profileIdInput); + const runtime = this.profiles.get(profileId); + const session = runtime?.sessions.get(sessionId); + if (!runtime || !session) return false; + const entries = this.openEntries(session); + const match = entries.find(([, entry]) => entry.pageId === session.selectedPageId) ?? entries[0]; + if (!match) return false; + const entry = match[1]; + await this.assertOwnedWindow(runtime, sessionId, entry); + await entry.page.bringToFront?.().catch(() => {}); + this.selectEntry(session, entry); + runtime.lastSeenAt = Date.now(); + return true; + } + + async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = requireSession(input.session); + const sessionId = requireSessionId(input); + const surface = normalizeSurface(input.surface); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const existingSession = runtime.sessions.get(sessionId); + let match = input.pageId + ? this.findEntryByPageId(runtime, input.pageId) + : existingSession && this.openEntries(existingSession)[input.index ?? -1]; + if (!match && input.index !== undefined) { + const candidates: PlaywrightPage[] = []; + for (const candidate of runtime.context.pages()) { + if (pageIsClosed(candidate) || candidate === runtime.parkingPage) continue; + if (await this.targetIdForPage(runtime, candidate) === runtime.anchorTargetId) continue; + candidates.push(candidate); + } + const page = candidates[input.index]; + if (page) { + const targetId = await this.targetIdForPage(runtime, page); + const entry = runtime.targetPages.get(targetId) ?? { + page, + pageId: nextPageId(), + targetId, + leaseKey: `unowned\u0000${targetId}`, + session: '', + surface, + }; + if (!runtime.targetPages.has(targetId)) { + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } + match = [entry.leaseKey, entry]; + } + } + if (!match) return null; + + const entry = match[1]; + if (entry.sessionId && entry.sessionId !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, entry.sessionId); + } + const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); + await this.assertBindableWindow(runtime, sessionRuntime, entry); + const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; + const sourceKey = entry.leaseKey; + const canonicalKey = resolveLeaseKey(input); + const currentCanonical = sessionRuntime.pages.get(canonicalKey); + + if (input.windowMode !== 'background') { + await entry.page.bringToFront?.().catch(() => {}); + } + + if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { + const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; + sessionRuntime.pages.delete(canonicalKey); + currentCanonical.leaseKey = preservedKey; + sessionRuntime.pages.set(preservedKey, currentCanonical); + this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); + } + + sourceSession?.pages.delete(sourceKey); + entry.sessionId = sessionId; + entry.leaseKey = canonicalKey; + entry.session = session; + entry.surface = surface; + entry.siteSession = input.siteSession; + entry.idleTimeout = input.idleTimeout; + sessionRuntime.pages.set(canonicalKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, canonicalKey, entry); + this.selectEntry(sessionRuntime, entry); + runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + } + + async closePage(input: Pick & { pageId?: string; index?: number }): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const runtime = this.profiles.get(profileId); + if (!runtime) return null; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; + if (!match) return null; + const [, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + await this.removeEntry(runtime, sessionRuntime, entry, true); + runtime.lastSeenAt = Date.now(); + return entry.pageId; + } + + async release(input: SessionKeyInput): Promise { + const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const runtime = this.profiles.get(profileId); + if (!runtime) return; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return; + const leaseKey = resolveLeaseKey(input); + const surface = normalizeSurface(input.surface); + const entries = this.openEntries(sessionRuntime).filter(([key, entry]) => ( + surface === 'adapter' + ? key === leaseKey + : entry.session === requireSession(input.session) && entry.surface === surface + )); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + for (const [, entry] of entries) { + if (entry.siteSession === 'persistent') continue; + await this.removeEntry(runtime, sessionRuntime, entry, true); + } + } + + hasSession(profileIdInput: string | undefined, sessionInput: string | undefined): boolean { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + return Boolean(runtime?.sessions.get(session) && this.openEntries(runtime.sessions.get(session)!).length > 0); + } + + async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + if (!runtime) return 0; + const sessionRuntime = runtime.sessions.get(session); + if (!sessionRuntime) return 0; + const entries = this.openEntries(sessionRuntime); + await Promise.all(entries.map(async ([, entry]) => { + try { + await this.assertOwnedWindow(runtime, session, entry); + } catch (error) { + if (!pageIsClosed(entry.page)) throw error; + } + })); + for (const [, entry] of entries) await this.removeEntry(runtime, sessionRuntime, entry, true); + if (entries.length > 0) runtime.lastSeenAt = Date.now(); + return entries.length; + } + + /** + * Invalidates the Profile runtime backing `context`, if it's still the active + * one, without evicting or retrying the command that observed it. Used by the + * `run` action (webcmd#314) when a post-run snapshot capture surfaces a + * closed-context signature: the run itself may have genuinely succeeded, but + * the connection is dying, so the next command on this Session shouldn't be + * handed the same lease. + */ + invalidateIfClosedContext(profileId: string, context: BrowserContext): void { + const runtime = this.profiles.get(profileId); + if (runtime?.context === context) this.invalidateProfileRuntime(profileId, runtime); + } + + async shutdown(): Promise { + this.shuttingDown = true; + while (this.profileLaunches.size > 0) { + await Promise.allSettled([...this.profileLaunches.values()]); + } + await Promise.all([...this.profiles.keys()].map(profileId => this.withProfileLifecycleLock(profileId, async () => { + const runtime = this.profiles.get(profileId); + if (!runtime) return; + this.profiles.delete(profileId); + runtime.closing = true; + await this.closeRuntime(runtime).catch(() => {}); + }))); + this.profiles.clear(); + this.profileLaunches.clear(); + this.profileActivities.clear(); + } + + private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { + return this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const existing = this.profiles.get(profileId); + if (existing && !existing.closing) { + this.cancelProfileIdle(existing); + return existing; + } + const launch = this.launchProfileRuntime(profileId, windowMode); + this.profileLaunches.set(profileId, launch); + try { + return await launch; + } finally { + if (this.profileLaunches.get(profileId) === launch) this.profileLaunches.delete(profileId); + } + }); + } + + private async launchProfileRuntime(profileId: string, _windowMode?: BrowserWindowMode): Promise { + const attachment = await this.attachProfile(profileId); + const { context, browser } = attachment; + let cdp: CDPSession | undefined; + let keeperError: unknown; + try { + cdp = await browser?.newBrowserCDPSession(); + } catch (error) { + keeperError = error; + } + const runtime: ProfileRuntime = { + profileId, + attachment, + context, + cdp, + sessions: new Map(), + windowOwners: new Map(), + targetPages: new Map(), + useParkingKeeper: !cdp, + keeperWarningLogged: false, + activeCommands: this.profileActivities.get(profileId) ?? 0, + closing: false, + disposed: false, + lastSeenAt: Date.now(), + }; + this.pendingTargetPages.set(runtime, new Map()); + this.targetPageWaiters.set(runtime, new Map()); + this.attachRuntimeLifecycle(profileId, runtime); + if (cdp) { + try { + runtime.anchorTargetId = (await cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } else { + this.warnKeeperFallback(profileId, runtime, keeperError ?? new Error('browser connection unavailable')); + } + if (this.shuttingDown) { + runtime.closing = true; + await this.closeRuntime(runtime).catch(() => {}); + throw daemonShuttingDownError(); + } + this.profiles.set(profileId, runtime); + return runtime; + } + + private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + void this.releaseRuntime(runtime, false).catch(error => { + log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); + }); + this.cleanupRuntime(runtime); + } + + private cleanupRuntime(runtime: ProfileRuntime): void { + if (runtime.disposed) return; + runtime.disposed = true; + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + this.networkCapture.stop(entry.page); + } + runtime.targetPages.clear(); + runtime.sessions.clear(); + runtime.windowOwners.clear(); + for (const waiter of this.targetPageWaiters.get(runtime)?.values() ?? []) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Target page, context or browser has been closed')); + } + this.targetPageWaiters.get(runtime)?.clear(); + } + + private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { + runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime)); + runtime.context.on('page', page => { + void this.handleContextPage(runtime, page).catch(() => {}); + }); + const onCdpEvent = (runtime.cdp as (CDPSession & { + on?: (event: string, listener: (payload: { targetId: string }) => void) => void; + }) | undefined)?.on; + onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { + this.queueAnchorRepair(profileId, runtime, targetId); + }); + } + + private queueAnchorRepair(profileId: string, runtime: ProfileRuntime, destroyedTargetId: string): void { + if (runtime.anchorTargetId !== destroyedTargetId) return; + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; + if (runtime.anchorTargetId !== undefined) return; + await this.repairAnchor(profileId, runtime); + }); + } + + private async repairAnchor(profileId: string, runtime: ProfileRuntime): Promise { + if (!runtime.cdp) return; + try { + runtime.anchorTargetId = (await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } + + private warnKeeperFallback(profileId: string, runtime: ProfileRuntime, error: unknown): void { + runtime.useParkingKeeper = true; + if (runtime.keeperWarningLogged) return; + runtime.keeperWarningLogged = true; + log.warn(`SLAB Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); + } + + private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer || runtime.handoffTimer) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.idleTimer = setTimeout(() => { + runtime.idleTimer = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.profiles.get(profileId) !== runtime || runtime.closing) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.closing = true; + this.profiles.delete(profileId); + await this.closeRuntime(runtime); + }); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.idleTimer.unref?.(); + } + + private scheduleHandoffWake(profileId: string, runtime: ProfileRuntime): void { + runtime.handoffTimer = setTimeout(() => { + runtime.handoffTimer = undefined; + this.scheduleProfileIdle(profileId, runtime); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.handoffTimer.unref?.(); + } + + private cancelProfileIdle(runtime: ProfileRuntime): void { + if (runtime.idleTimer) clearTimeout(runtime.idleTimer); + if (runtime.handoffTimer) clearTimeout(runtime.handoffTimer); + runtime.idleTimer = undefined; + runtime.handoffTimer = undefined; + } + + private hasVisiblePages(runtime: ProfileRuntime): boolean { + for (const session of runtime.sessions.values()) { + if (this.openEntries(session).length > 0) return true; + } + return false; + } + + private async closeRuntime(runtime: ProfileRuntime): Promise { + await this.releaseRuntime(runtime, true); + } + + private async releaseRuntime(runtime: ProfileRuntime, closePages: boolean): Promise { + if (runtime.releasePromise) return runtime.releasePromise; + runtime.releasePromise = (async () => { + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); + const pages = [...runtime.targetPages.values()].map(entry => entry.page); + try { + if (closePages) { + await Promise.all([...runtime.targetPages.values()].map(entry => ( + pageIsClosed(entry.page) ? undefined : entry.page.close().catch(() => {}) + ))); + } + await this.closeParkingPage(runtime); + if (runtime.anchorTargetId) { + await runtime.cdp?.send('Target.closeTarget', { targetId: runtime.anchorTargetId }).catch(() => {}); + runtime.anchorTargetId = undefined; + } + await Promise.all([ + ...pages.map(page => this.detachPageCdp(page)), + runtime.cdp?.detach().catch(() => {}), + ]); + await runtime.attachment.release(); + } finally { + this.cleanupRuntime(runtime); + } + })(); + return runtime.releasePromise; + } + + private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { + const previous = this.profileLifecycleQueues.get(profileId); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = (previous ?? Promise.resolve()).then(() => released); + this.profileLifecycleQueues.set(profileId, queue); + if (previous) await previous.catch(() => {}); + try { + return await operation(); + } finally { + release(); + if (this.profileLifecycleQueues.get(profileId) === queue) this.profileLifecycleQueues.delete(profileId); + } + } + + private assertRunning(): void { + if (this.shuttingDown) throw daemonShuttingDownError(); + } + + private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { + const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = previous.then(() => released); + this.pageCreationQueues.set(profileId, queue); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); + } + } + + private getSessionRuntime(runtime: ProfileRuntime, sessionId: string): SessionRuntime { + let session = runtime.sessions.get(sessionId); + if (!session) { + session = { id: sessionId, windowIds: new Set(), pages: new Map() }; + runtime.sessions.set(sessionId, session); + } + return session; + } + + private async createSessionPage( + runtime: ProfileRuntime, + session: SessionRuntime, + windowMode?: BrowserWindowMode, + ): Promise { + const openerEntry = this.openEntries(session)[0]?.[1]; + if (!openerEntry) return await this.findReusableLaunchPage(runtime, session.id) ?? this.createWindowPage(runtime, windowMode); + await this.assertOwnedWindow(runtime, session.id, openerEntry); + const opener = openerEntry.page; + const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); + const targetUrl = `about:blank#webcmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const openedPage = this.waitForContextPageForSession(runtime, session.id, openerWindowId, targetUrl, TARGET_PAGE_MATCH_TIMEOUT_MS); + + try { + await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); + } catch (error) { + log.warn(`SLAB window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(error)}`); + } + const page = await openedPage; + if (page) return page; + return this.createWindowPage(runtime, windowMode); + } + + private async 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, + openerWindowId: number, + targetUrl: string, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (page: PlaywrightPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + runtime.context.off('page', onPage); + resolve(page); + }; + const tryPage = async (page: PlaywrightPage) => { + if (settled || pageIsClosed(page)) return; + const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); + if (!targetId) return; + const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); + if (actualWindowId === undefined) return; + const owner = runtime.windowOwners.get(actualWindowId); + if (owner !== undefined && owner !== sessionId) return; + if (page.url() !== targetUrl) return; + done(page); + }; + const onPage = (page: PlaywrightPage) => { void tryPage(page); }; + const timer = setTimeout(() => done(null), timeoutMs); + runtime.context.on('page', onPage); + for (const page of this.pendingTargetPages.get(runtime)?.values() ?? []) void tryPage(page); + }); + } + + private async acquireSessionPage( + profileId: string, + sessionId: string, + windowMode: BrowserWindowMode | undefined, + attempt = 0, + ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { + const runtime = await this.getProfileRuntime(profileId, windowMode); + const session = this.getSessionRuntime(runtime, sessionId); + let page: PlaywrightPage; + try { + page = await this.createSessionPage(runtime, session, windowMode); + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + this.invalidateProfileRuntime(profileId, runtime); + return this.acquireSessionPage(profileId, sessionId, windowMode, 1); + } + if (this.profiles.get(profileId) !== runtime) { + if (!pageIsClosed(page)) await page.close().catch(() => {}); + throw new Error('Target page, context or browser has been closed'); + } + return { runtime, session, page }; + } + + private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode, newWindow = true): Promise { + if (!runtime.cdp) return runtime.context.newPage(); + const result = await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + newWindow, + background: windowMode === 'background', + focus: windowMode !== 'background', + }) as { targetId: string }; + return this.waitForTargetPage(runtime, result.targetId); + } + + private async waitForTargetPage(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)!; + const page = pending.get(targetId); + if (page) { + pending.delete(targetId); + pending.clear(); + return page; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.targetPageWaiters.get(runtime)?.delete(targetId); + reject(new Error(`Timed out waiting for SLAB target ${targetId}`)); + }, TARGET_PAGE_MATCH_TIMEOUT_MS); + this.targetPageWaiters.get(runtime)!.set(targetId, { resolve, reject, timer }); + }); + } + + private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const targetId = await this.targetIdForPage(runtime, page); + if (targetId === runtime.anchorTargetId) { + page.once('close', () => { + this.queueAnchorRepair(runtime.profileId, runtime, targetId); + }); + return; + } + const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); + if (waiter) { + this.targetPageWaiters.get(runtime)!.delete(targetId); + this.pendingTargetPages.get(runtime)?.clear(); + clearTimeout(waiter.timer); + waiter.resolve(page); + } else { + this.pendingTargetPages.get(runtime)?.set(targetId, page); + } + + const opener = await page.opener().catch(() => null); + const openerEntry = opener && [...runtime.targetPages.values()].find(entry => entry.page === opener); + if (!openerEntry?.sessionId) return; + const session = runtime.sessions.get(openerEntry.sessionId); + if (!session) return; + this.pendingTargetPages.get(runtime)?.delete(targetId); + await this.registerOwnedPage(runtime, session, page, { + session: openerEntry.session, + surface: openerEntry.surface, + siteSession: openerEntry.siteSession, + sessionKind: openerEntry.sessionKind, + adapterSite: openerEntry.adapterSite, + idleTimeout: openerEntry.idleTimeout, + }); + } + + private async registerOwnedPage( + runtime: ProfileRuntime, + session: SessionRuntime, + page: PlaywrightPage, + input: Pick & { leaseKey?: string }, + ): Promise { + const targetId = await this.targetIdForPage(runtime, page); + this.pendingTargetPages.get(runtime)?.delete(targetId); + const windowId = await this.windowIdForTarget(runtime, targetId, page); + const owner = runtime.windowOwners.get(windowId); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); + } + runtime.windowOwners.set(windowId, session.id); + session.windowIds.add(windowId); + + let entry = runtime.targetPages.get(targetId); + const wasOwned = Boolean(entry?.sessionId); + if (entry?.sessionId && entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + if (!entry) { + const pageId = nextPageId(); + entry = { + page, + pageId, + targetId, + leaseKey: input.leaseKey ?? `page\u0000${pageId}`, + sessionId: session.id, + session: input.session, + surface: input.surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }; + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } else { + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + entry.sessionId = session.id; + entry.session = input.session; + entry.surface = input.surface; + entry.siteSession = input.siteSession; + entry.sessionKind = input.sessionKind; + entry.adapterSite = input.adapterSite; + entry.idleTimeout = input.idleTimeout; + entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); + } + session.pages.set(entry.leaseKey, entry); + this.cancelProfileIdle(runtime); + this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); + if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); + await this.closeParkingPage(runtime); + return entry; + } + + private attachPageLifecycle(runtime: ProfileRuntime, entry: PageEntry): void { + entry.page.once('close', () => { + runtime.targetPages.delete(entry.targetId); + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + this.clearIdleTimer(entry); + if (runtime.parkingPage === entry.page) runtime.parkingPage = undefined; + this.scheduleProfileIdle(runtime.profileId, runtime); + }); + } + + private async targetIdForPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const cached = this.pageTargetIds.get(page); + if (cached) return cached; + const pending = this.pageTargetIdPromises.get(page); + if (pending) return pending; + const correlation = (async () => { + const session = await runtime.context.newCDPSession(page); + const { targetInfo } = await session.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + this.pageTargetIds.set(page, targetInfo.targetId); + this.pageCdpSessions.set(page, session); + page.once('close', () => { + this.pageTargetIds.delete(page); + void this.detachPageCdp(page); + }); + return targetInfo.targetId; + })(); + this.pageTargetIdPromises.set(page, correlation); + try { + return await correlation; + } finally { + this.pageTargetIdPromises.delete(page); + } + } + + private async windowIdForTarget(runtime: ProfileRuntime, targetId: string, page?: PlaywrightPage): Promise { + const entry = runtime.targetPages.get(targetId); + const targetPage = page ?? entry?.page; + const cdp = runtime.cdp ?? (targetPage ? this.pageCdpSessions.get(targetPage) : undefined); + if (!cdp) throw new Error('SLAB page has no CDP session.'); + const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; + return windowId; + } + + private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + } + + private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { + if (entry.sessionId) { + if (entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + await this.assertOwnedWindow(runtime, session.id, entry); + return; + } + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, owner); + } + runtime.windowOwners.set(actual, session.id); + session.windowIds.add(actual); + } + + private openEntries(runtime: SessionRuntime): [string, PageEntry][] { + return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); + } + + private findEntryByPageId(runtime: ProfileRuntime, pageId: string): [string, PageEntry] | null { + const entry = [...runtime.targetPages.values()].find(candidate => candidate.pageId === pageId && !pageIsClosed(candidate.page)); + return entry ? [entry.leaseKey, entry] : null; + } + + private sessionEntries(runtime: SessionRuntime, input: Pick): [string, PageEntry][] { + const session = requireSession(input.session); + const surface = normalizeSurface(input.surface); + return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); + } + + private selectEntry(runtime: SessionRuntime, entry: PageEntry): void { + runtime.selectedPageId = entry.pageId; + } + + private clearSelectedPage(runtime: SessionRuntime, entry: PageEntry): void { + if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + } + + private refreshIdleTimer(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): void { + this.clearIdleTimer(entry); + if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; + entry.idleTimer = setTimeout(() => { + void this.expireLease(runtime, session, leaseKey, entry); + }, entry.idleTimeout); + entry.idleTimer.unref?.(); + } + + private async expireLease(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): Promise { + if (session.pages.get(leaseKey) !== entry) return; + runtime.lastSeenAt = Date.now(); + if (entry.siteSession !== 'persistent') await this.removeEntry(runtime, session, entry, true); + } + + private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { + const shouldPark = close && runtime.useParkingKeeper + && [...runtime.targetPages.values()].every(candidate => candidate === entry || pageIsClosed(candidate.page)); + const parkingWindowId = shouldPark + ? await this.windowIdForTarget(runtime, entry.targetId, entry.page).catch(() => undefined) + : undefined; + if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + runtime.targetPages.delete(entry.targetId); + this.clearIdleTimer(entry); + this.clearSelectedPage(session, entry); + this.networkCapture.stop(entry.page); + if (close && !pageIsClosed(entry.page)) { + if (shouldPark) { + await entry.page.goto('about:blank', { waitUntil: 'load' }).catch(() => {}); + entry.sessionId = undefined; + runtime.parkingPage = pageIsClosed(entry.page) ? undefined : entry.page; + if (parkingWindowId !== undefined) { + runtime.windowOwners.delete(parkingWindowId); + session.windowIds.delete(parkingWindowId); + await runtime.cdp?.send('Browser.setWindowBounds', { + windowId: parkingWindowId, + bounds: { windowState: 'minimized' }, + }).catch(() => {}); + } + } else { + await runtime.cdp?.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + } + } + this.scheduleProfileIdle(runtime.profileId, runtime); + } + + private async closeParkingPage(runtime: ProfileRuntime): Promise { + const parkingPage = runtime.parkingPage; + if (!parkingPage) return; + runtime.parkingPage = undefined; + if (!pageIsClosed(parkingPage)) await parkingPage.close().catch(() => {}); + await this.detachPageCdp(parkingPage); + } + + private detachPageCdp(page: PlaywrightPage): Promise { + const existing = this.pageCdpDetaches.get(page); + if (existing) return existing; + const detach = this.pageCdpSessions.get(page)?.detach().catch(() => {}) ?? Promise.resolve(); + this.pageCdpDetaches.set(page, detach); + return detach; + } + + private clearIdleTimer(entry: PageEntry): void { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; + } +} + +function nextPageId(): string { + return `page-${Date.now()}-${++pageCounter}`; +} + +function normalizeSurface(surface: BrowserSurface | undefined): BrowserSurface { + return surface === 'adapter' ? 'adapter' : 'browser'; +} + +function requireSession(session: string | undefined): string { + const normalized = session?.trim(); + if (!normalized) throw new Error('Browser session is required.'); + return normalized; +} + +function requireSessionId(input: Pick): string { + return input.sessionId?.trim() || requireSession(input.session); +} diff --git a/src/daemon.ts b/src/daemon.ts index f7c25310..a4f62071 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,9 +3,9 @@ import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; import { PKG_VERSION } from './version.js'; import { createDaemonServer } from './daemon/server.js'; -import { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js'; +import { createLocalBrowserRuntimeProvider } from './browser/runtime/local-slab/provider.js'; -const provider = new LocalCloakRuntimeProvider(); +const provider = createLocalBrowserRuntimeProvider(); const daemon = createDaemonServer(provider, { port: DEFAULT_DAEMON_PORT, host: '127.0.0.1', version: PKG_VERSION }); daemon.listen().then(() => { diff --git a/src/slab/installation.ts b/src/slab/installation.ts new file mode 100644 index 00000000..1cd80858 --- /dev/null +++ b/src/slab/installation.ts @@ -0,0 +1,28 @@ +export interface SlabInstallation { + platform: NodeJS.Platform; + executablePath: string; + version?: string; +} + +export interface SlabInstallationIo { + platform: NodeJS.Platform; + homeDir: string; + existsSync(path: string): boolean; +} + +export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | null { + if (io.platform !== 'darwin') return null; + + for (const executablePath of [ + '/Applications/SLAB.app/Contents/MacOS/SLAB', + `${io.homeDir}/Applications/SLAB.app/Contents/MacOS/SLAB`, + ]) { + if (io.existsSync(executablePath)) return { platform: io.platform, executablePath }; + } + + return null; +} + +export function isSlabInstalled(io: SlabInstallationIo): boolean { + return findSlabInstallation(io) !== null; +} diff --git a/src/slab/release-key.ts b/src/slab/release-key.ts new file mode 100644 index 00000000..aa60fda3 --- /dev/null +++ b/src/slab/release-key.ts @@ -0,0 +1,22 @@ +import { verify } from 'node:crypto'; + +// Trust anchor for the signed SLAB release manifest. Left `undefined` so +// verification is fail-closed until a real production key is set: the installer +// refuses any manifest until this holds the operator's own Ed25519 public key. +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 202bebf126a57b27eeb8af6549517ae74cec1aca Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 26 Aug 2026 22:20:56 +0530 Subject: [PATCH 02/34] feat: add strict SLAB control client --- .../__fixtures__/attach.response.json | 23 ++ .../local-slab/__fixtures__/errors.json | 110 ++++++ .../__fixtures__/hello.response.json | 20 ++ .../__fixtures__/release.response.json | 30 ++ src/slab/bridge-client.test.ts | 318 ++++++++++++++++++ src/slab/bridge-client.ts | 186 ++++++++++ src/slab/contract-parity.test.ts | 134 ++++++++ src/slab/protocol.ts | 198 +++++++++++ 8 files changed, 1019 insertions(+) create mode 100644 src/browser/runtime/local-slab/__fixtures__/attach.response.json create mode 100644 src/browser/runtime/local-slab/__fixtures__/errors.json create mode 100644 src/browser/runtime/local-slab/__fixtures__/hello.response.json create mode 100644 src/browser/runtime/local-slab/__fixtures__/release.response.json create mode 100644 src/slab/bridge-client.test.ts create mode 100644 src/slab/bridge-client.ts create mode 100644 src/slab/contract-parity.test.ts create mode 100644 src/slab/protocol.ts diff --git a/src/browser/runtime/local-slab/__fixtures__/attach.response.json b/src/browser/runtime/local-slab/__fixtures__/attach.response.json new file mode 100644 index 00000000..47aec9d8 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/attach.response.json @@ -0,0 +1,23 @@ +{ + "request": { + "id": "attach-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "attach-1", + "ok": true, + "result": { + "connectionId": "00000000-0000-4000-8000-000000000000", + "profile": { "id": "default", "displayName": "Default" }, + "transport": { + "kind": "cdp-ipc", + "endpoint": "/Users/test/.slab/run/attachments/00000000-0000-4000-8000-000000000000.sock", + "credential": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/errors.json b/src/browser/runtime/local-slab/__fixtures__/errors.json new file mode 100644 index 00000000..38062206 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/errors.json @@ -0,0 +1,110 @@ +{ + "INVALID_REQUEST": { + "request": { + "id": "error-invalid-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-invalid-1", + "ok": false, + "error": { + "code": "INVALID_REQUEST", + "message": "Invalid JSON, framing, shape, size, or params" + } + } + }, + "INCOMPATIBLE_PROTOCOL": { + "request": { + "id": "error-protocol-1", + "method": "hello", + "params": { + "protocolVersion": { "min": 2, "max": 2 }, + "clientVersion": "webcmd/0.7.3" + } + }, + "response": { + "id": "error-protocol-1", + "ok": false, + "error": { + "code": "INCOMPATIBLE_PROTOCOL", + "message": "Client range does not include v1" + } + } + }, + "PROFILE_NOT_FOUND": { + "request": { + "id": "error-profile-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "missing-profile" + } + }, + "response": { + "id": "error-profile-1", + "ok": false, + "error": { + "code": "PROFILE_NOT_FOUND", + "message": "Requested profile is unavailable" + } + } + }, + "ATTACH_FAILED": { + "request": { + "id": "error-attach-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-attach-1", + "ok": false, + "error": { + "code": "ATTACH_FAILED", + "message": "Browser could not create the attachment" + } + } + }, + "AUTHENTICATION_FAILED": { + "request": { + "id": "error-auth-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-auth-1", + "ok": false, + "error": { + "code": "AUTHENTICATION_FAILED", + "message": "CDP IPC credential was missing or wrong" + } + } + }, + "CONNECTION_NOT_FOUND": { + "request": { + "id": "error-connection-1", + "method": "attach", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "profileId": "default" + } + }, + "response": { + "id": "error-connection-1", + "ok": false, + "error": { + "code": "CONNECTION_NOT_FOUND", + "message": "A non-release operation referenced an unknown lease" + } + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/hello.response.json b/src/browser/runtime/local-slab/__fixtures__/hello.response.json new file mode 100644 index 00000000..33f302b1 --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/hello.response.json @@ -0,0 +1,20 @@ +{ + "request": { + "id": "hello-1", + "method": "hello", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "clientVersion": "webcmd/0.7.3" + } + }, + "response": { + "id": "hello-1", + "ok": true, + "result": { + "protocolVersion": 1, + "browserVersion": "152.0.7977.65", + "browserPid": 1234, + "profiles": [{ "id": "default", "displayName": "Default" }] + } + } +} diff --git a/src/browser/runtime/local-slab/__fixtures__/release.response.json b/src/browser/runtime/local-slab/__fixtures__/release.response.json new file mode 100644 index 00000000..382453dd --- /dev/null +++ b/src/browser/runtime/local-slab/__fixtures__/release.response.json @@ -0,0 +1,30 @@ +{ + "request": { + "id": "release-1", + "method": "release", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "connectionId": "00000000-0000-4000-8000-000000000000" + } + }, + "response": { + "id": "release-1", + "ok": true, + "result": null + }, + "alreadyRevoked": { + "request": { + "id": "release-2", + "method": "release", + "params": { + "protocolVersion": { "min": 1, "max": 1 }, + "connectionId": "00000000-0000-4000-8000-000000000000" + } + }, + "response": { + "id": "release-2", + "ok": true, + "result": null + } + } +} diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts new file mode 100644 index 00000000..5c107f11 --- /dev/null +++ b/src/slab/bridge-client.test.ts @@ -0,0 +1,318 @@ +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SlabBridgeClient, SlabProtocolError } from './bridge-client.js'; +import { SlabCredential, SLAB_MAX_CONTROL_LINE_BYTES } from './protocol.js'; + +const CREDENTIAL = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +interface ScriptedServer { + endpoint: string; + requests: unknown[]; + close(): Promise; +} + +const servers: Array<{ server: Server; dir: string }> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async ({ server, dir }) => { + await new Promise((resolve) => server.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + })); +}); + +async function listen(onConnection: (socket: Socket) => void): Promise { + const dir = await mkdtemp(join(tmpdir(), 'slab-control-')); + const endpoint = join(dir, 'slab-bridge.sock'); + const requests: unknown[] = []; + const server = createServer(onConnection); + servers.push({ server, dir }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, () => resolve()); + }); + return { + endpoint, + requests, + async close() { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function collectRequests(socket: Socket, requests: unknown[], onRequest: (req: { id: string; method: string; params: unknown }) => void): void { + let buf = ''; + socket.on('data', (chunk) => { + buf += chunk.toString('utf8'); + let idx = buf.indexOf('\n'); + while (idx !== -1) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + const req = JSON.parse(line) as { id: string; method: string; params: unknown }; + requests.push(req); + onRequest(req); + idx = buf.indexOf('\n'); + } + }); +} + +function helloOk(id: string, browserVersion = '152.0.7977.65'): string { + return `${JSON.stringify({ + id, + ok: true, + result: { + protocolVersion: 1, + browserVersion, + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }, + })}\n`; +} + +function attachOk(id: string, credential = CREDENTIAL): string { + return `${JSON.stringify({ + id, + ok: true, + result: { + connectionId: '00000000-0000-4000-8000-000000000000', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc', + endpoint: '/Users/test/.slab/run/attachments/00000000-0000-4000-8000-000000000000.sock', + credential, + }, + }, + })}\n`; +} + +function releaseOk(id: string): string { + return `${JSON.stringify({ id, ok: true, result: null })}\n`; +} + +function errorLine(id: string, code: string, message: string): string { + return `${JSON.stringify({ id, ok: false, error: { code, message } })}\n`; +} + +function sizedHello(id: string, targetBytes: number): string { + const make = (pad: string) => JSON.stringify({ + id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: pad, + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }, + }); + const pad = 'x'.repeat(targetBytes - Buffer.byteLength(make(''))); + return `${make(pad)}\n`; +} + +describe('SlabBridgeClient', () => { + it('reassembles fragmented JSONL responses', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + const line = helloOk(req.id); + socket.write(line.slice(0, 8)); + socket.write(line.slice(8, 20)); + socket.write(line.slice(20)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).resolves.toMatchObject({ protocolVersion: 1, browserPid: 1234 }); + await client.close(); + }); + + it('splits coalesced JSONL responses', async () => { + const harness = await listen((socket) => { + const pending: string[] = []; + collectRequests(socket, harness.requests, (req) => { + pending.push(helloOk(req.id)); + if (pending.length === 2) socket.write(pending.join('')); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const [first, second] = await Promise.all([client.hello(), client.hello()]); + expect(first.protocolVersion).toBe(1); + expect(second.protocolVersion).toBe(1); + expect(new Set(harness.requests.map((req) => (req as { id: string }).id)).size).toBe(2); + await client.close(); + }); + + it('accepts a response line of exactly 64 KiB', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + const line = sizedHello(req.id, SLAB_MAX_CONTROL_LINE_BYTES); + expect(Buffer.byteLength(line.slice(0, -1))).toBe(SLAB_MAX_CONTROL_LINE_BYTES); + socket.write(line); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const result = await client.hello(); + expect(result.protocolVersion).toBe(1); + expect(result.browserVersion.length).toBeGreaterThan(64 * 1024 - 200); + await client.close(); + }); + + it('rejects an oversized line and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(sizedHello(req.id, SLAB_MAX_CONTROL_LINE_BYTES + 1)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/64 KiB|oversized|control/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects malformed JSON and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write('{not-json}\n'); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/json|invalid|malformed/i); + await expect(client.release('x')).rejects.toThrow(); + }); + + it('rejects invalid UTF-8 and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write(Buffer.from([0xff, 0xfe, 0x0a])); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/utf-8|utf8|invalid/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('times out a pending request and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => {}); + }); + const client = await SlabBridgeClient.connect(harness.endpoint, { timeoutMs: 40 }); + await expect(client.hello()).rejects.toThrow(/timeout/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects pending requests when the socket closes', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.destroy(); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/close|closed|disconnect/i); + }); + + it('rejects an unexpected response id and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, () => { + socket.write(helloOk('not-the-request-id')); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/id|unexpected/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('rejects a duplicate response and closes the control connection', async () => { + let socketRef: Socket | undefined; + const harness = await listen((socket) => { + socketRef = socket; + collectRequests(socket, harness.requests, (req) => { + socket.write(helloOk(req.id)); + socket.write(helloOk(req.id)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).resolves.toMatchObject({ protocolVersion: 1 }); + await expect(client.attach('default')).rejects.toThrow(/duplicate|closed|id/i); + await new Promise((resolve) => { + if (!socketRef || socketRef.destroyed) { + resolve(); + return; + } + socketRef.once('close', () => resolve()); + }); + expect(socketRef?.destroyed || socketRef?.readableEnded).toBeTruthy(); + }); + + it('rejects unknown response fields and closes the control connection', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: '1', + browserPid: 1, + profiles: [], + }, + extra: true, + })}\n`); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/unknown|field|unexpected/i); + await expect(client.hello()).rejects.toThrow(); + }); + + it('maps stable protocol errors without leaking credentials or raw responses', async () => { + const codes = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', + ] as const; + for (const code of codes) { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + socket.write(errorLine(req.id, code, `secret ${CREDENTIAL} raw={"ok":false}`)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const error = await client.attach('default').then( + () => { + throw new Error(`expected ${code}`); + }, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(SlabProtocolError); + expect((error as SlabProtocolError).code).toBe(code); + expect(String(error)).not.toContain(CREDENTIAL); + expect(String(error)).not.toContain('raw='); + expect((error as Error).message).not.toContain(CREDENTIAL); + await client.close().catch(() => {}); + } + }); + + it('redacts attachment credentials in inspection and stringification', async () => { + const harness = await listen((socket) => { + collectRequests(socket, harness.requests, (req) => { + if (req.method === 'attach') socket.write(attachOk(req.id)); + else if (req.method === 'release') socket.write(releaseOk(req.id)); + else socket.write(helloOk(req.id)); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + const lease = await client.attach('default'); + expect(lease.transport.kind).toBe('cdp-ipc'); + expect(lease.transport.credential).toBeInstanceOf(SlabCredential); + expect(String(lease.transport.credential)).toBe('[REDACTED]'); + expect(JSON.stringify(lease.transport.credential)).toBe('"[REDACTED]"'); + expect(inspect(lease.transport.credential)).toContain('[REDACTED]'); + expect(inspect(lease)).not.toContain(CREDENTIAL); + expect(JSON.stringify(lease)).not.toContain(CREDENTIAL); + expect(lease.transport.credential.reveal()).toBe(CREDENTIAL); + await expect(client.release(lease.connectionId)).resolves.toBeNull(); + await client.close(); + }); +}); diff --git a/src/slab/bridge-client.ts b/src/slab/bridge-client.ts new file mode 100644 index 00000000..33b2fa7a --- /dev/null +++ b/src/slab/bridge-client.ts @@ -0,0 +1,186 @@ +import { createConnection, type Socket } from 'node:net'; +import { StringDecoder } from 'node:string_decoder'; +import { PKG_VERSION } from '../version.js'; +import { + parseAttachResult, + parseControlResponse, + parseHelloResult, + parseReleaseResult, + isValidUtf8, + SLAB_ERROR_MESSAGES, + SLAB_MAX_CONTROL_LINE_BYTES, + SLAB_PROTOCOL_VERSION, + type SlabAttachResult, + type SlabErrorCode, + type SlabHelloResult, +} from './protocol.js'; + +export interface SlabBridgeClientOptions { + timeoutMs?: number; + clientVersion?: string; +} + +export class SlabProtocolError extends Error { + readonly code: SlabErrorCode; + + constructor(code: SlabErrorCode) { + super(SLAB_ERROR_MESSAGES[code]); + this.name = 'SlabProtocolError'; + this.code = code; + } +} + +interface PendingRequest { + method: string; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer?: ReturnType; +} + +export class SlabBridgeClient { + private readonly socket: Socket; + private readonly decoder = new StringDecoder('utf8'); + private readonly pending = new Map(); + private readonly timeoutMs: number; + private readonly clientVersion: string; + private pendingBytes = Buffer.alloc(0); + private nextId = 0; + private closed = false; + + private constructor(socket: Socket, options: SlabBridgeClientOptions = {}) { + this.socket = socket; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.clientVersion = options.clientVersion ?? `webcmd/${PKG_VERSION}`; + socket.on('data', (chunk) => this.onData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('close', () => this.failOpen('connection closed')); + socket.on('error', () => this.failOpen('connection closed')); + } + + static connect(endpoint: string, options: SlabBridgeClientOptions = {}): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path: endpoint }); + const client = new SlabBridgeClient(socket, options); + const onError = (error: Error) => reject(error); + socket.once('error', onError); + socket.once('connect', () => { + socket.off('error', onError); + resolve(client); + }); + }); + } + + hello(): Promise { + return this.request('hello', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + clientVersion: this.clientVersion, + }).then(parseHelloResult); + } + + attach(profile: string | { id: string }): Promise { + const profileId = typeof profile === 'string' ? profile : profile.id; + return this.request('attach', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + profileId, + }).then(parseAttachResult); + } + + release(connectionId: string): Promise { + return this.request('release', { + protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, + connectionId, + }).then(parseReleaseResult); + } + + async close(): Promise { + this.failOpen('connection closed'); + } + + private request(method: string, params: Record): Promise { + if (this.closed) return Promise.reject(new Error('SLAB control connection closed')); + const id = String(++this.nextId); + if (this.pending.has(id)) return Promise.reject(new Error('SLAB control request id is already pending')); + return new Promise((resolve, reject) => { + const timer = this.timeoutMs > 0 + ? setTimeout(() => this.failOpen('request timeout'), this.timeoutMs) + : undefined; + this.pending.set(id, { method, resolve, reject, timer }); + this.socket.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + + private onData(chunk: Buffer): void { + if (this.closed) return; + let offset = 0; + while (offset < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, offset); + if (newline === -1) { + this.appendPending(chunk.subarray(offset)); + return; + } + const line = Buffer.concat([this.pendingBytes, chunk.subarray(offset, newline)]); + this.pendingBytes = Buffer.alloc(0); + offset = newline + 1; + this.handleLine(line); + if (this.closed) return; + } + } + + private appendPending(piece: Buffer): void { + this.pendingBytes = Buffer.concat([this.pendingBytes, piece]); + if (this.pendingBytes.byteLength > SLAB_MAX_CONTROL_LINE_BYTES) { + this.failOpen('line exceeds 64 KiB'); + } + } + + private handleLine(line: Buffer): void { + if (line.byteLength > SLAB_MAX_CONTROL_LINE_BYTES) { + this.failOpen('line exceeds 64 KiB'); + return; + } + if (!isValidUtf8(line)) { + this.failOpen('response is invalid UTF-8'); + return; + } + const text = this.decoder.write(Buffer.concat([line, Buffer.from([0x0a])])).replace(/\n$/, ''); + let response; + try { + response = parseControlResponse(text); + } catch (error) { + this.failOpen(error instanceof Error ? error.message.replace(/^SLAB control /, '') : 'response is invalid JSON'); + return; + } + const pending = this.pending.get(response.id); + if (!pending) { + const kind = [...this.pending.keys()].length === 0 && this.nextId > 0 + ? 'response is a duplicate' + : 'response id is unexpected'; + this.failOpen(kind); + return; + } + this.finish(response.id); + if (!response.ok) { + pending.reject(new SlabProtocolError(response.error.code)); + return; + } + pending.resolve(response.result); + } + + private finish(id: string): void { + const pending = this.pending.get(id); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(id); + } + + private failOpen(kind: string): void { + if (this.closed) return; + this.closed = true; + const error = kind.startsWith('SLAB control ') ? new Error(kind) : new Error(`SLAB control ${kind}`); + for (const [id, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + this.socket.destroy(); + } +} diff --git a/src/slab/contract-parity.test.ts b/src/slab/contract-parity.test.ts new file mode 100644 index 00000000..08ebfb8c --- /dev/null +++ b/src/slab/contract-parity.test.ts @@ -0,0 +1,134 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const LOCAL_FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../browser/runtime/local-slab/__fixtures__'); +const FIXTURE_FILES = [ + 'hello.response.json', + 'attach.response.json', + 'release.response.json', + 'errors.json', +] as const; +const CONNECTION_ID = '00000000-0000-4000-8000-000000000000'; +const PROFILE_ID = 'default'; +const ENDPOINT = `/Users/test/.slab/run/attachments/${CONNECTION_ID}.sock`; +const CREDENTIAL = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const STABLE_ERRORS = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', +] as const; + +function keysOf(value: unknown): string[] { + return Object.keys(value as object).sort(); +} + +function findSiblingFixtures(): string | null { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const parent = dirname(dir); + const candidate = join(parent, 'slab-browser/.worktrees/slab-macos-first-alpha/protocol/fixtures'); + if (existsSync(join(candidate, 'hello.response.json'))) return candidate; + if (parent === dir) break; + dir = parent; + } + return null; +} + +async function loadLocal(name: (typeof FIXTURE_FILES)[number]): Promise<{ bytes: Buffer; parsed: unknown }> { + const bytes = await readFile(join(LOCAL_FIXTURES, name)); + return { bytes, parsed: JSON.parse(bytes.toString('utf8')) }; +} + +describe('SLAB protocol fixture parity', () => { + it('validates committed local copies against the frozen contract', async () => { + const hello = await loadLocal('hello.response.json'); + const attach = await loadLocal('attach.response.json'); + const release = await loadLocal('release.response.json'); + const errors = await loadLocal('errors.json'); + + expect(keysOf(hello.parsed)).toEqual(['request', 'response']); + const helloDoc = hello.parsed as { + request: { id: string; method: string; params: { protocolVersion: { min: number; max: number }; clientVersion: string } }; + response: { + id: string; + ok: boolean; + result: { + protocolVersion: number; + browserVersion: string; + browserPid: number; + profiles: Array<{ id: string; displayName: string }>; + }; + }; + }; + expect(helloDoc.request.method).toBe('hello'); + expect(helloDoc.request.params.protocolVersion).toEqual({ min: 1, max: 1 }); + expect(helloDoc.response.id).toBe(helloDoc.request.id); + expect(helloDoc.response.ok).toBe(true); + expect(keysOf(helloDoc.response.result)).toEqual(['browserPid', 'browserVersion', 'profiles', 'protocolVersion']); + expect(helloDoc.response.result.protocolVersion).toBe(1); + expect(helloDoc.response.result.profiles).toEqual([{ id: PROFILE_ID, displayName: 'Default' }]); + + const attachDoc = attach.parsed as { + request: { id: string; params: { profileId: string } }; + response: { + id: string; + result: { + connectionId: string; + profile: { id: string; displayName: string }; + transport: { kind: string; endpoint: string; credential: string }; + expiresAt?: unknown; + cdpUrl?: unknown; + }; + }; + }; + expect(attachDoc.request.params.profileId).toBe(PROFILE_ID); + expect(attachDoc.response.id).toBe(attachDoc.request.id); + expect(attachDoc.response.result.connectionId).toBe(CONNECTION_ID); + expect(attachDoc.response.result.expiresAt).toBeUndefined(); + expect(attachDoc.response.result.cdpUrl).toBeUndefined(); + expect(attachDoc.response.result.transport).toEqual({ + kind: 'cdp-ipc', + endpoint: ENDPOINT, + credential: CREDENTIAL, + }); + expect(attachDoc.response.result.transport.credential).toHaveLength(43); + expect(Buffer.byteLength(`${JSON.stringify(attachDoc.response)}\n`)).toBeLessThanOrEqual(64 * 1024); + + const releaseDoc = release.parsed as { + request: { params: { connectionId: string } }; + response: { result: unknown }; + alreadyRevoked: { response: { result: unknown } }; + }; + expect(releaseDoc.request.params.connectionId).toBe(CONNECTION_ID); + expect(releaseDoc.response.result).toBeNull(); + expect(releaseDoc.alreadyRevoked.response.result).toBeNull(); + + const errorDoc = errors.parsed as Record; + expect(Object.keys(errorDoc).sort()).toEqual([...STABLE_ERRORS].sort()); + for (const code of STABLE_ERRORS) { + expect(errorDoc[code].response.error.code).toBe(code); + expect(errorDoc[code].response.error.message).not.toContain(CREDENTIAL); + } + }); + + it('matches sibling fixture bytes and parsed values when the worktree is present', async () => { + const sibling = findSiblingFixtures(); + for (const name of FIXTURE_FILES) { + const local = await loadLocal(name); + const localHash = createHash('sha256').update(local.bytes).digest('hex'); + expect(localHash).toMatch(/^[0-9a-f]{64}$/); + if (!sibling) continue; + const remoteBytes = await readFile(join(sibling, name)); + expect(createHash('sha256').update(remoteBytes).digest('hex'), name).toBe(localHash); + expect(JSON.parse(remoteBytes.toString('utf8')), name).toEqual(local.parsed); + } + if (sibling) expect(existsSync(sibling)).toBe(true); + }); +}); diff --git a/src/slab/protocol.ts b/src/slab/protocol.ts new file mode 100644 index 00000000..a0ee0ad2 --- /dev/null +++ b/src/slab/protocol.ts @@ -0,0 +1,198 @@ +import { inspect } from 'node:util'; + +export const SLAB_PROTOCOL_VERSION = 1; +export const SLAB_MAX_CONTROL_LINE_BYTES = 64 * 1024; + +export const SLAB_ERROR_CODES = [ + 'INVALID_REQUEST', + 'INCOMPATIBLE_PROTOCOL', + 'PROFILE_NOT_FOUND', + 'ATTACH_FAILED', + 'AUTHENTICATION_FAILED', + 'CONNECTION_NOT_FOUND', +] as const; + +export type SlabErrorCode = (typeof SLAB_ERROR_CODES)[number]; + +export const SLAB_ERROR_MESSAGES: Record = { + INVALID_REQUEST: 'Invalid JSON, framing, shape, size, or params', + INCOMPATIBLE_PROTOCOL: 'Client range does not include v1', + PROFILE_NOT_FOUND: 'Requested profile is unavailable', + ATTACH_FAILED: 'Browser could not create the attachment', + AUTHENTICATION_FAILED: 'CDP IPC credential was missing or wrong', + CONNECTION_NOT_FOUND: 'A non-release operation referenced an unknown lease', +}; + +const SUCCESS_KEYS = ['id', 'ok', 'result'] as const; +const ERROR_KEYS = ['id', 'ok', 'error'] as const; +const ERROR_OBJ_KEYS = ['code', 'message'] as const; +const HELLO_RESULT_KEYS = ['protocolVersion', 'browserVersion', 'browserPid', 'profiles'] as const; +const ATTACH_RESULT_KEYS = ['connectionId', 'profile', 'transport'] as const; +const TRANSPORT_KEYS = ['kind', 'endpoint', 'credential'] as const; +const PROFILE_KEYS = ['id', 'displayName'] as const; + +export class SlabCredential { + readonly #value: string; + + constructor(value: string) { + this.#value = value; + } + + reveal(): string { + return this.#value; + } + + toString(): string { + return '[REDACTED]'; + } + + toJSON(): string { + return '[REDACTED]'; + } + + [inspect.custom](): string { + return '[REDACTED]'; + } +} + +export interface SlabProfileInfo { + id: string; + displayName: string; +} + +export interface SlabHelloResult { + protocolVersion: number; + browserVersion: string; + browserPid: number; + profiles: SlabProfileInfo[]; +} + +export interface SlabAttachTransport { + kind: 'cdp-ipc'; + endpoint: string; + credential: SlabCredential; +} + +export interface SlabAttachResult { + connectionId: string; + profile: SlabProfileInfo; + transport: SlabAttachTransport; +} + +export type SlabControlSuccess = { id: string; ok: true; result: unknown }; +export type SlabControlFailure = { id: string; ok: false; error: { code: SlabErrorCode; message: string } }; +export type SlabControlResponse = SlabControlSuccess | SlabControlFailure; + +export class SlabProtocolShapeError extends Error { + constructor(kind: string) { + super(`SLAB control ${kind}`); + this.name = 'SlabProtocolShapeError'; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertExactKeys(obj: Record, allowed: readonly string[]): void { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) throw new SlabProtocolShapeError('response has unknown fields'); + } + for (const key of allowed) { + if (!Object.hasOwn(obj, key)) throw new SlabProtocolShapeError('response has unknown fields'); + } +} + +export function isValidUtf8(bytes: Buffer): boolean { + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return true; + } catch { + return false; + } +} + +export function parseControlResponse(line: string): SlabControlResponse { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new SlabProtocolShapeError('response is invalid JSON'); + } + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + if (typeof value.id !== 'string') throw new SlabProtocolShapeError('response id is unexpected'); + if (value.ok === true) { + assertExactKeys(value, SUCCESS_KEYS); + return { id: value.id, ok: true, result: value.result }; + } + if (value.ok === false) { + assertExactKeys(value, ERROR_KEYS); + if (!isPlainObject(value.error)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value.error, ERROR_OBJ_KEYS); + if (typeof value.error.code !== 'string' || typeof value.error.message !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (!SLAB_ERROR_CODES.includes(value.error.code as SlabErrorCode)) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { + id: value.id, + ok: false, + error: { code: value.error.code as SlabErrorCode, message: value.error.message }, + }; + } + throw new SlabProtocolShapeError('response has unknown fields'); +} + +function parseProfile(value: unknown): SlabProfileInfo { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, PROFILE_KEYS); + if (typeof value.id !== 'string' || typeof value.displayName !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { id: value.id, displayName: value.displayName }; +} + +export function parseHelloResult(value: unknown): SlabHelloResult { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, HELLO_RESULT_KEYS); + if (value.protocolVersion !== SLAB_PROTOCOL_VERSION) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (typeof value.browserVersion !== 'string' || typeof value.browserPid !== 'number' || !Number.isInteger(value.browserPid)) { + throw new SlabProtocolShapeError('response has unknown fields'); + } + if (!Array.isArray(value.profiles)) throw new SlabProtocolShapeError('response has unknown fields'); + return { + protocolVersion: SLAB_PROTOCOL_VERSION, + browserVersion: value.browserVersion, + browserPid: value.browserPid, + profiles: value.profiles.map(parseProfile), + }; +} + +export function parseAttachResult(value: unknown): SlabAttachResult { + if (!isPlainObject(value)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value, ATTACH_RESULT_KEYS); + if (typeof value.connectionId !== 'string') throw new SlabProtocolShapeError('response has unknown fields'); + if (!isPlainObject(value.transport)) throw new SlabProtocolShapeError('response has unknown fields'); + assertExactKeys(value.transport, TRANSPORT_KEYS); + if (value.transport.kind !== 'cdp-ipc') throw new SlabProtocolShapeError('response has unknown fields'); + if (typeof value.transport.endpoint !== 'string' || typeof value.transport.credential !== 'string') { + throw new SlabProtocolShapeError('response has unknown fields'); + } + return { + connectionId: value.connectionId, + profile: parseProfile(value.profile), + transport: { + kind: 'cdp-ipc', + endpoint: value.transport.endpoint, + credential: new SlabCredential(value.transport.credential), + }, + }; +} + +export function parseReleaseResult(value: unknown): null { + if (value !== null) throw new SlabProtocolShapeError('response has unknown fields'); + return null; +} From ea53c208b8e51214c44d3a12135f3060c47f2a76 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 26 Aug 2026 23:17:11 +0530 Subject: [PATCH 03/34] fix: close SLAB control socket on result-shape errors --- src/slab/bridge-client.test.ts | 53 ++++++++++++++++++++++++++++++++++ src/slab/bridge-client.ts | 25 ++++++++++++---- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts index 5c107f11..ae6f8497 100644 --- a/src/slab/bridge-client.test.ts +++ b/src/slab/bridge-client.test.ts @@ -263,6 +263,59 @@ describe('SlabBridgeClient', () => { await expect(client.hello()).rejects.toThrow(); }); + it('rejects extra result fields and destroys the control socket', async () => { + let socketRef: Socket | undefined; + const harness = await listen((socket) => { + socketRef = socket; + collectRequests(socket, harness.requests, (req) => { + if (req.method === 'attach') { + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + connectionId: '00000000-0000-4000-8000-000000000000', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc', + endpoint: '/tmp/x.sock', + credential: CREDENTIAL, + }, + extra: true, + }, + })}\n`); + return; + } + socket.write(`${JSON.stringify({ + id: req.id, + ok: true, + result: { + protocolVersion: 1, + browserVersion: '1', + browserPid: 1, + profiles: [], + extra: true, + }, + })}\n`); + }); + }); + const client = await SlabBridgeClient.connect(harness.endpoint); + await expect(client.hello()).rejects.toThrow(/unknown|field/i); + await new Promise((resolve, reject) => { + if (!socketRef) { + reject(new Error('missing server socket')); + return; + } + if (socketRef.destroyed) { + resolve(); + return; + } + socketRef.once('close', () => resolve()); + setTimeout(() => reject(new Error('socket stayed open')), 100); + }); + expect(socketRef?.destroyed || socketRef?.readableEnded).toBeTruthy(); + await expect(client.attach('default')).rejects.toThrow(); + }); + it('maps stable protocol errors without leaking credentials or raw responses', async () => { const codes = [ 'INVALID_REQUEST', diff --git a/src/slab/bridge-client.ts b/src/slab/bridge-client.ts index 33b2fa7a..6dd9e099 100644 --- a/src/slab/bridge-client.ts +++ b/src/slab/bridge-client.ts @@ -37,6 +37,13 @@ interface PendingRequest { timer?: ReturnType; } +function parseResultForMethod(method: string, result: unknown): unknown { + if (method === 'hello') return parseHelloResult(result); + if (method === 'attach') return parseAttachResult(result); + if (method === 'release') return parseReleaseResult(result); + throw new Error('SLAB control response has unknown fields'); +} + export class SlabBridgeClient { private readonly socket: Socket; private readonly decoder = new StringDecoder('utf8'); @@ -73,7 +80,7 @@ export class SlabBridgeClient { return this.request('hello', { protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, clientVersion: this.clientVersion, - }).then(parseHelloResult); + }) as Promise; } attach(profile: string | { id: string }): Promise { @@ -81,14 +88,14 @@ export class SlabBridgeClient { return this.request('attach', { protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, profileId, - }).then(parseAttachResult); + }) as Promise; } release(connectionId: string): Promise { return this.request('release', { protocolVersion: { min: SLAB_PROTOCOL_VERSION, max: SLAB_PROTOCOL_VERSION }, connectionId, - }).then(parseReleaseResult); + }) as Promise; } async close(): Promise { @@ -157,12 +164,20 @@ export class SlabBridgeClient { this.failOpen(kind); return; } - this.finish(response.id); if (!response.ok) { + this.finish(response.id); pending.reject(new SlabProtocolError(response.error.code)); return; } - pending.resolve(response.result); + let result: unknown; + try { + result = parseResultForMethod(pending.method, response.result); + } catch (error) { + this.failOpen(error instanceof Error ? error.message.replace(/^SLAB control /, '') : 'response has unknown fields'); + return; + } + this.finish(response.id); + pending.resolve(result); } private finish(id: string): void { From a2600e1a991942fddb6dbbd570df51d63c1cd1c7 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 01:08:17 +0530 Subject: [PATCH 04/34] feat: connect Playwright to SLAB over authenticated IPC --- .../runtime/local-slab/attachment.test.ts | 55 +++++ src/browser/runtime/local-slab/attachment.ts | 22 +- src/slab/cdp-ipc-transport.test.ts | 192 +++++++++++++++++ src/slab/cdp-ipc-transport.ts | 202 ++++++++++++++++++ 4 files changed, 460 insertions(+), 11 deletions(-) create mode 100644 src/browser/runtime/local-slab/attachment.test.ts create mode 100644 src/slab/cdp-ipc-transport.test.ts create mode 100644 src/slab/cdp-ipc-transport.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..93dbfd81 --- /dev/null +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -0,0 +1,55 @@ +import type { ConnectOverCDPTransport } from 'playwright-core'; +import { describe, expect, it, vi } from 'vitest'; +import { SlabCredential } from '../../../slab/protocol.js'; +import { attachSlabProfile } from './attachment.js'; + +function attachment() { + return { + connectionId: 'connection-1', + profile: { id: 'default', displayName: 'Default' }, + transport: { + kind: 'cdp-ipc' as const, + endpoint: '/tmp/slab-attachment.sock', + credential: new SlabCredential('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), + }, + }; +} + +function transport(): ConnectOverCDPTransport { + return { open: vi.fn(), send: vi.fn(), close: vi.fn() }; +} + +describe('attachSlabProfile', () => { + it('connects Playwright through the authenticated IPC transport', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const context = {} as any; + const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const connectTransport = vi.fn().mockResolvedValue(cdpTransport); + const connectOverCDP = vi.fn().mockResolvedValue(browser); + + const result = await attachSlabProfile('default', { + bridge, + connectTransport, + connectOverCDP, + attachTimeoutMs: 123, + }); + + expect(connectTransport).toHaveBeenCalledWith({ ...lease.transport, timeoutMs: 123 }); + expect(connectOverCDP).toHaveBeenCalledWith(cdpTransport, { timeout: 123 }); + expect(result.context).toBe(context); + }); + + it('closes the transport and releases the lease when Playwright setup fails', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const connectTransport = vi.fn().mockResolvedValue(cdpTransport); + const connectOverCDP = vi.fn().mockRejectedValue(new Error('Playwright refused the connection')); + + await expect(attachSlabProfile('default', { bridge, connectTransport, connectOverCDP })).rejects.toThrow('Playwright refused'); + expect(cdpTransport.close).toHaveBeenCalledOnce(); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + }); +}); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts index de1b73ff..915939e3 100644 --- a/src/browser/runtime/local-slab/attachment.ts +++ b/src/browser/runtime/local-slab/attachment.ts @@ -1,4 +1,6 @@ -import { chromium, type Browser, type BrowserContext } from 'playwright-core'; +import { chromium, type Browser, type BrowserContext, type ConnectOverCDPTransport } from 'playwright-core'; +import { CdpIpcTransport } from '../../../slab/cdp-ipc-transport.js'; +import type { SlabAttachResult } from '../../../slab/protocol.js'; export interface AttachedSlabProfile { profileId: string; @@ -8,13 +10,7 @@ export interface AttachedSlabProfile { release(): Promise; } -export interface SlabAttachment { - connectionId: string; - profile: { id: string; displayName: string }; - cdpUrl: string; - bearerToken: string; - expiresAt: string; -} +export type SlabAttachment = SlabAttachResult; export interface SlabBridge { attach(profileId: string): Promise; @@ -24,16 +20,19 @@ export interface SlabBridge { export interface AttachSlabProfileOptions { bridge?: SlabBridge; connectOverCDP?: typeof chromium.connectOverCDP; + connectTransport?: typeof CdpIpcTransport.connect; + attachTimeoutMs?: number; } export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { const bridge = options.bridge; if (!bridge) throw new Error('SLAB control client is not available.'); const attachment = await bridge.attach(profileId); + const attachTimeoutMs = options.attachTimeoutMs ?? 30_000; + let transport: ConnectOverCDPTransport | undefined; try { - const browser = await (options.connectOverCDP ?? chromium.connectOverCDP.bind(chromium))(attachment.cdpUrl, { - headers: { Authorization: `Bearer ${attachment.bearerToken}` }, - }); + transport = await (options.connectTransport ?? CdpIpcTransport.connect)({ ...attachment.transport, timeoutMs: attachTimeoutMs }); + const browser = await (options.connectOverCDP ?? chromium.connectOverCDP.bind(chromium))(transport, { timeout: attachTimeoutMs }); const context = browser.contexts()[0]; if (!context) throw new Error('SLAB attachment returned no persistent browser context.'); return { @@ -44,6 +43,7 @@ export async function attachSlabProfile(profileId: string, options: AttachSlabPr release: () => bridge.release(attachment.connectionId), }; } catch (error) { + transport?.close(); await bridge.release(attachment.connectionId).catch(() => {}); throw error; } diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts new file mode 100644 index 00000000..1eb7917b --- /dev/null +++ b/src/slab/cdp-ipc-transport.test.ts @@ -0,0 +1,192 @@ +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ConnectOverCDPTransport } from 'playwright-core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CdpIpcTransport } from './cdp-ipc-transport.js'; +import { SlabCredential } from './protocol.js'; + +const CREDENTIAL = new SlabCredential('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); +const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +interface Harness { + endpoint: string; + frames: unknown[]; + socket(): Socket; +} + +const servers: Array<{ server: Server; dir: string; sockets: Set }> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async ({ server, dir, sockets }) => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + })); +}); + +async function listen(onConnection?: (socket: Socket) => void): Promise { + const dir = await mkdtemp(join(tmpdir(), 'slab-cdp-')); + const endpoint = join(dir, 'attachment.sock'); + const frames: unknown[] = []; + let serverSocket: Socket | undefined; + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + serverSocket = socket; + collectFrames(socket, frames); + onConnection?.(socket); + }); + servers.push({ server, dir, sockets }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, () => resolve()); + }); + return { + endpoint, + frames, + socket() { + if (!serverSocket) throw new Error('server did not accept a connection'); + return serverSocket; + }, + }; +} + +function frame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value)); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + return Buffer.concat([header, body]); +} + +function collectFrames(socket: Socket, frames: unknown[]): void { + let pending = Buffer.alloc(0); + socket.on('data', (chunk) => { + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + while (pending.byteLength >= 4) { + const length = pending.readUInt32BE(0); + if (pending.byteLength < length + 4) return; + frames.push(JSON.parse(pending.subarray(4, length + 4).toString('utf8'))); + pending = pending.subarray(length + 4); + } + }); +} + +async function connectAuthenticated(harness: Harness, timeoutMs = 100): Promise { + const transportPromise = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs }); + await expect.poll(() => harness.frames.length).toBe(1); + expect(harness.frames).toEqual([{ type: 'authenticate', credential: CREDENTIAL.reveal() }]); + harness.socket().write(frame({ type: 'authenticated' })); + return transportPromise; +} + +function closed(transport: ConnectOverCDPTransport): Promise { + return new Promise((resolve) => { + transport.onclose = resolve; + }); +} + +describe('CdpIpcTransport', () => { + it('authenticates, reassembles split frames, and delivers multiple CDP objects', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const messages: object[] = []; + transport.onmessage = (message) => messages.push(message); + transport.open?.(); + + const payload = Buffer.concat([frame({ id: 1, result: {} }), frame({ method: 'Target.attachedToTarget', params: {} })]); + harness.socket().write(payload.subarray(0, 2)); + harness.socket().write(payload.subarray(2, 9)); + harness.socket().write(payload.subarray(9)); + + await expect.poll(() => messages).toEqual([{ id: 1, result: {} }, { method: 'Target.attachedToTarget', params: {} }]); + }); + + it('frames raw CDP messages after open', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + transport.open?.(); + transport.send({ id: 1, method: 'Browser.getVersion' }); + + await expect.poll(() => harness.frames).toEqual([ + { type: 'authenticate', credential: CREDENTIAL.reveal() }, + { id: 1, method: 'Browser.getVersion' }, + ]); + }); + + it('rejects an advertised frame over 64 MiB before waiting for its body', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(MAX_FRAME_BYTES + 1); + harness.socket().write(header); + + await expect(close).resolves.toMatch(/64 MiB/i); + }); + + it.each([ + ['invalid JSON', Buffer.from('{not-json}')], + ['a JSON array', Buffer.from('[]')], + ])('closes on %s CDP frames', async (_name, body) => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + harness.socket().write(Buffer.concat([header, body])); + + await expect(close).resolves.toMatch(/JSON|object/i); + }); + + it('rejects authentication failures', async () => { + const harness = await listen(); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 100 }); + await expect.poll(() => harness.frames.length).toBe(1); + harness.socket().write(frame({ type: 'authentication_failed' })); + + await expect(connection).rejects.toThrow(/authentication/i); + }); + + it('rejects when authentication times out', async () => { + const harness = await listen(); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 10 }); + await expect(connection).rejects.toThrow(/timeout/i); + }); + + it('rejects connection errors', async () => { + await expect(CdpIpcTransport.connect({ endpoint: join(tmpdir(), 'missing-slab-cdp.sock'), credential: CREDENTIAL, timeoutMs: 100 })) + .rejects.toThrow(); + }); + + it('notifies Playwright when the peer closes', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + harness.socket().destroy(); + + await expect(close).resolves.toMatch(/closed/i); + }); + + it('closes locally only once', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + const close = closed(transport); + transport.close(); + transport.close(); + + await expect(close).resolves.toMatch(/closed/i); + await expect.poll(() => harness.socket().destroyed || harness.socket().readableEnded).toBe(true); + }); + + it('rejects sends before open and after close', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + expect(() => transport.send({ id: 1 })).toThrow(/open/i); + transport.open?.(); + transport.close(); + expect(() => transport.send({ id: 2 })).toThrow(/closed/i); + }); +}); diff --git a/src/slab/cdp-ipc-transport.ts b/src/slab/cdp-ipc-transport.ts new file mode 100644 index 00000000..46ddc567 --- /dev/null +++ b/src/slab/cdp-ipc-transport.ts @@ -0,0 +1,202 @@ +import { createConnection, type Socket } from 'node:net'; +import type { ConnectOverCDPTransport } from 'playwright-core'; +import { type SlabCredential } from './protocol.js'; + +const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +export interface CdpIpcTransportOptions { + endpoint: string; + credential: SlabCredential; + timeoutMs?: number; +} + +type State = 'ready' | 'open' | 'closed'; + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function encodeFrame(value: object): Buffer { + const body = Buffer.from(JSON.stringify(value)); + if (body.byteLength === 0 || body.byteLength > MAX_FRAME_BYTES) { + throw new Error('SLAB CDP IPC frame exceeds 64 MiB'); + } + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(body.byteLength); + return Buffer.concat([header, body]); +} + +export class CdpIpcTransport implements ConnectOverCDPTransport { + onmessage?: (message: object) => void; + onclose?: (reason?: string) => void; + + private readonly chunks: Buffer[] = []; + private pendingMessages: object[] = []; + private bufferedBytes = 0; + private expectedLength?: number; + private state: State = 'ready'; + private authenticated = false; + private resolveAuthentication?: () => void; + private rejectAuthentication?: (error: Error) => void; + private authenticationTimer?: ReturnType; + + private constructor(private readonly socket: Socket) { + socket.on('data', (chunk) => this.onData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('error', () => this.fail('connection closed')); + socket.on('close', () => this.fail('connection closed')); + } + + static connect(options: CdpIpcTransportOptions): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path: options.endpoint }); + const transport = new CdpIpcTransport(socket); + const timeoutMs = options.timeoutMs ?? 30_000; + let settled = false; + + const finishReject = (error: Error) => { + if (settled) return; + settled = true; + if (transport.authenticationTimer) clearTimeout(transport.authenticationTimer); + reject(error); + }; + const finishResolve = () => { + if (settled) return; + settled = true; + if (transport.authenticationTimer) clearTimeout(transport.authenticationTimer); + resolve(transport); + }; + const onConnectError = (error: Error) => finishReject(error); + + transport.rejectAuthentication = finishReject; + socket.once('error', onConnectError); + socket.once('connect', () => { + if (settled) return; + socket.off('error', onConnectError); + transport.resolveAuthentication = finishResolve; + transport.rejectAuthentication = finishReject; + if (timeoutMs > 0) { + transport.authenticationTimer = setTimeout(() => transport.fail('authentication timeout'), timeoutMs); + } + try { + socket.write(encodeFrame({ type: 'authenticate', credential: options.credential.reveal() })); + } catch (error) { + transport.fail(error instanceof Error ? error.message.replace(/^SLAB CDP IPC /, '') : 'authentication failed'); + } + }); + if (timeoutMs > 0) { + const connectTimer = setTimeout(() => transport.fail('connection timeout'), timeoutMs); + socket.once('connect', () => clearTimeout(connectTimer)); + socket.once('error', () => clearTimeout(connectTimer)); + } + }); + } + + open(): void { + if (this.state !== 'ready') return; + this.state = 'open'; + for (const message of this.pendingMessages) this.onmessage?.(message); + this.pendingMessages = []; + } + + send(message: object): void { + if (this.state === 'closed') throw new Error('SLAB CDP IPC transport is closed'); + if (this.state !== 'open') throw new Error('SLAB CDP IPC transport is not open'); + if (!isObject(message)) throw new Error('SLAB CDP IPC message must be an object'); + this.socket.write(encodeFrame(message)); + } + + close(): void { + this.fail('connection closed'); + } + + private onData(chunk: Buffer): void { + if (this.state === 'closed') return; + this.chunks.push(chunk); + this.bufferedBytes += chunk.byteLength; + this.parseFrames(); + } + + private parseFrames(): void { + while (this.state !== 'closed') { + if (this.expectedLength === undefined) { + if (this.bufferedBytes < 4) return; + const header = this.read(4); + const length = header.readUInt32BE(0); + if (length === 0 || length > MAX_FRAME_BYTES) { + this.fail('frame exceeds 64 MiB'); + return; + } + this.expectedLength = length; + } + if (this.bufferedBytes < this.expectedLength) return; + const body = this.read(this.expectedLength); + this.expectedLength = undefined; + this.handleFrame(body); + } + } + + private read(length: number): Buffer { + const first = this.chunks[0]; + if (first && first.byteLength >= length) { + const value = first.subarray(0, length); + if (first.byteLength === length) this.chunks.shift(); + else this.chunks[0] = first.subarray(length); + this.bufferedBytes -= length; + return value; + } + + const value = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const chunk = this.chunks.shift(); + if (!chunk) throw new Error('SLAB CDP IPC parser lost buffered data'); + const size = Math.min(chunk.byteLength, length - offset); + chunk.copy(value, offset, 0, size); + offset += size; + if (size < chunk.byteLength) this.chunks.unshift(chunk.subarray(size)); + } + this.bufferedBytes -= length; + return value; + } + + private handleFrame(body: Buffer): void { + let message: unknown; + try { + message = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); + } catch { + this.fail('frame contains invalid JSON'); + return; + } + if (!isObject(message)) { + this.fail('frame must contain a JSON object'); + return; + } + if (!this.authenticated) { + if (Object.keys(message).length !== 1 || !Object.hasOwn(message, 'type') || (message as { type?: unknown }).type !== 'authenticated') { + this.fail('authentication failed'); + return; + } + this.authenticated = true; + const resolve = this.resolveAuthentication; + this.resolveAuthentication = undefined; + this.rejectAuthentication = undefined; + if (this.authenticationTimer) clearTimeout(this.authenticationTimer); + resolve?.(); + return; + } + if (this.state === 'open') this.onmessage?.(message); + else this.pendingMessages.push(message); + } + + private fail(reason: string): void { + if (this.state === 'closed') return; + this.state = 'closed'; + if (this.authenticationTimer) clearTimeout(this.authenticationTimer); + const reject = this.rejectAuthentication; + this.resolveAuthentication = undefined; + this.rejectAuthentication = undefined; + reject?.(new Error(`SLAB CDP IPC ${reason}`)); + this.socket.destroy(); + this.onclose?.(`SLAB CDP IPC ${reason}`); + } +} From 2390b9588f2067b8030fb8b935a408a58f2db5e3 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 01:13:05 +0530 Subject: [PATCH 05/34] fix: share CDP IPC attach timeout budget --- src/slab/cdp-ipc-transport.test.ts | 16 +++++++++++++++ src/slab/cdp-ipc-transport.ts | 31 +++++++++++++++++------------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts index 1eb7917b..45a4a7db 100644 --- a/src/slab/cdp-ipc-transport.test.ts +++ b/src/slab/cdp-ipc-transport.test.ts @@ -74,6 +74,13 @@ function collectFrames(socket: Socket, frames: unknown[]): void { }); } +function blockFor(milliseconds: number): void { + const deadline = performance.now() + milliseconds; + while (performance.now() < deadline) { + // Keep the connection callback pending long enough to consume its budget. + } +} + async function connectAuthenticated(harness: Harness, timeoutMs = 100): Promise { const transportPromise = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs }); await expect.poll(() => harness.frames.length).toBe(1); @@ -156,6 +163,15 @@ describe('CdpIpcTransport', () => { await expect(connection).rejects.toThrow(/timeout/i); }); + it('uses one timeout budget for connection and authentication', async () => { + const harness = await listen(() => blockFor(40)); + const connection = CdpIpcTransport.connect({ endpoint: harness.endpoint, credential: CREDENTIAL, timeoutMs: 100 }); + await expect.poll(() => harness.frames.length).toBe(1); + setTimeout(() => harness.socket().write(frame({ type: 'authenticated' })), 80); + + await expect(connection).rejects.toThrow(/timeout/i); + }); + it('rejects connection errors', async () => { await expect(CdpIpcTransport.connect({ endpoint: join(tmpdir(), 'missing-slab-cdp.sock'), credential: CREDENTIAL, timeoutMs: 100 })) .rejects.toThrow(); diff --git a/src/slab/cdp-ipc-transport.ts b/src/slab/cdp-ipc-transport.ts index 46ddc567..870f3a32 100644 --- a/src/slab/cdp-ipc-transport.ts +++ b/src/slab/cdp-ipc-transport.ts @@ -38,7 +38,7 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { private authenticated = false; private resolveAuthentication?: () => void; private rejectAuthentication?: (error: Error) => void; - private authenticationTimer?: ReturnType; + private timeoutTimer?: ReturnType; private constructor(private readonly socket: Socket) { socket.on('data', (chunk) => this.onData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); @@ -51,43 +51,48 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { const socket = createConnection({ path: options.endpoint }); const transport = new CdpIpcTransport(socket); const timeoutMs = options.timeoutMs ?? 30_000; + const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined; let settled = false; const finishReject = (error: Error) => { if (settled) return; settled = true; - if (transport.authenticationTimer) clearTimeout(transport.authenticationTimer); + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); reject(error); }; const finishResolve = () => { if (settled) return; settled = true; - if (transport.authenticationTimer) clearTimeout(transport.authenticationTimer); + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); resolve(transport); }; const onConnectError = (error: Error) => finishReject(error); + const scheduleTimeout = (reason: string) => { + if (deadline === undefined) return; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + transport.fail(reason); + return; + } + transport.timeoutTimer = setTimeout(() => transport.fail(reason), remainingMs); + }; transport.rejectAuthentication = finishReject; socket.once('error', onConnectError); socket.once('connect', () => { if (settled) return; socket.off('error', onConnectError); + if (transport.timeoutTimer) clearTimeout(transport.timeoutTimer); transport.resolveAuthentication = finishResolve; transport.rejectAuthentication = finishReject; - if (timeoutMs > 0) { - transport.authenticationTimer = setTimeout(() => transport.fail('authentication timeout'), timeoutMs); - } + scheduleTimeout('authentication timeout'); try { socket.write(encodeFrame({ type: 'authenticate', credential: options.credential.reveal() })); } catch (error) { transport.fail(error instanceof Error ? error.message.replace(/^SLAB CDP IPC /, '') : 'authentication failed'); } }); - if (timeoutMs > 0) { - const connectTimer = setTimeout(() => transport.fail('connection timeout'), timeoutMs); - socket.once('connect', () => clearTimeout(connectTimer)); - socket.once('error', () => clearTimeout(connectTimer)); - } + scheduleTimeout('connection timeout'); }); } @@ -180,7 +185,7 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { const resolve = this.resolveAuthentication; this.resolveAuthentication = undefined; this.rejectAuthentication = undefined; - if (this.authenticationTimer) clearTimeout(this.authenticationTimer); + if (this.timeoutTimer) clearTimeout(this.timeoutTimer); resolve?.(); return; } @@ -191,7 +196,7 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { private fail(reason: string): void { if (this.state === 'closed') return; this.state = 'closed'; - if (this.authenticationTimer) clearTimeout(this.authenticationTimer); + if (this.timeoutTimer) clearTimeout(this.timeoutTimer); const reject = this.rejectAuthentication; this.resolveAuthentication = undefined; this.rejectAuthentication = undefined; From a57eee064f43a9d8ce1c9277873739fcb934f4af Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 01:28:42 +0530 Subject: [PATCH 06/34] fix: preserve human pages during SLAB sessions --- src/browser/protocol.ts | 2 + src/browser/runtime/local-slab/actions.ts | 10 +- .../runtime/local-slab/attachment.test.ts | 18 ++ src/browser/runtime/local-slab/attachment.ts | 8 +- .../local-slab/runtime-selection.test.ts | 1 + .../local-slab/session-manager.test.ts | 186 ++++++++++++++++++ .../runtime/local-slab/session-manager.ts | 136 +++++-------- 7 files changed, 272 insertions(+), 89 deletions(-) create mode 100644 src/browser/runtime/local-slab/session-manager.test.ts diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index b170cc57..c0874a8f 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -35,6 +35,8 @@ export interface BrowserRuntimeCommand { id: string; action: BrowserRuntimeAction; page?: string; + /** Native CDP target id used only to explicitly acquire an observed SLAB page. */ + targetId?: string; code?: string; session?: string; sessionId?: string; diff --git a/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts index bd065a73..6192dbe4 100644 --- a/src/browser/runtime/local-slab/actions.ts +++ b/src/browser/runtime/local-slab/actions.ts @@ -10,7 +10,7 @@ import { import { redactText, redactUrl } from '../../../observation/redaction.js'; import { articleHtmlToMarkdown } from '../../../download/article-download.js'; import { waitForDownload } from './downloads.js'; -import type { SlabSessionManager } from './session-manager.js'; +import { SlabAttachmentLostError, type SlabSessionManager } from './session-manager.js'; import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; import { runBrowserProgram } from '../../run/runner.js'; import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; @@ -478,12 +478,12 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B return { id: command.id, ok: true, data: frames, page: lease.pageId }; } case 'bind': - if (!command.page && command.index == null) { + if (!command.page && !command.targetId && command.index == null) { return { id: command.id, ok: false, errorCode: 'invalid_request', - error: 'Bind requires --page or --index for a SLAB runtime tab', + error: 'Bind requires --page, --target-id, or --index for a SLAB runtime tab', errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', }; } @@ -500,6 +500,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B idleTimeout: command.idleTimeout, windowMode: command.windowMode, pageId: command.page, + targetId: command.targetId, index: command.index, }); if (!lease) { @@ -531,6 +532,9 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B if (err instanceof SlabActionError) { return { id: command.id, ok: false, errorCode: err.errorCode, error: err.message, ...(err.page && { page: err.page }), ...(err.errorHint && { errorHint: err.errorHint }) }; } + if (err instanceof SlabAttachmentLostError) { + return { id: command.id, ok: false, errorCode: 'slab_attachment_lost', error: err.message }; + } if ( err instanceof Error && 'code' in err diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts index 93dbfd81..99d3237c 100644 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -52,4 +52,22 @@ describe('attachSlabProfile', () => { expect(cdpTransport.close).toHaveBeenCalledOnce(); expect(bridge.release).toHaveBeenCalledWith('connection-1'); }); + + it('closes its local transport before releasing the native lease', async () => { + const lease = attachment(); + const cdpTransport = transport(); + const context = {} as any; + const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const result = await attachSlabProfile('default', { + bridge, + connectTransport: vi.fn().mockResolvedValue(cdpTransport), + connectOverCDP: vi.fn().mockResolvedValue(browser), + }); + + await result.release(); + + expect(cdpTransport.close).toHaveBeenCalledOnce(); + expect(bridge.release).toHaveBeenCalledWith('connection-1'); + }); }); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts index 915939e3..93bd75fa 100644 --- a/src/browser/runtime/local-slab/attachment.ts +++ b/src/browser/runtime/local-slab/attachment.ts @@ -7,6 +7,7 @@ export interface AttachedSlabProfile { browserVersion: string; context: BrowserContext; browser: Browser; + closeTransport(): void; release(): Promise; } @@ -35,12 +36,17 @@ export async function attachSlabProfile(profileId: string, options: AttachSlabPr const browser = await (options.connectOverCDP ?? chromium.connectOverCDP.bind(chromium))(transport, { timeout: attachTimeoutMs }); const context = browser.contexts()[0]; if (!context) throw new Error('SLAB attachment returned no persistent browser context.'); + const connectedTransport = transport; return { profileId: attachment.profile.id, browserVersion: browser.version(), context, browser, - release: () => bridge.release(attachment.connectionId), + closeTransport: () => connectedTransport.close(), + release: async () => { + connectedTransport.close(); + await bridge.release(attachment.connectionId); + }, }; } catch (error) { transport?.close(); diff --git a/src/browser/runtime/local-slab/runtime-selection.test.ts b/src/browser/runtime/local-slab/runtime-selection.test.ts index 31cca59d..e8523d27 100644 --- a/src/browser/runtime/local-slab/runtime-selection.test.ts +++ b/src/browser/runtime/local-slab/runtime-selection.test.ts @@ -105,6 +105,7 @@ function fakeAttachedProfile() { browserVersion: '146.0', context, browser, + closeTransport: vi.fn(), release: vi.fn().mockResolvedValue(undefined), }; } diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts new file mode 100644 index 00000000..b1af4f8a --- /dev/null +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; +import { dispatchSlabAction } from './actions.js'; +import { SlabSessionManager } from './session-manager.js'; + +function fakeAttachedProfile() { + const listeners = new Map void>>(); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; + const makePage = (url = 'about:blank') => { + let closed = false; + const page: any = { + url: vi.fn(() => url), + title: vi.fn().mockResolvedValue('Page'), + goto: vi.fn().mockResolvedValue(undefined), + bringToFront: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => closed), + opener: vi.fn().mockResolvedValue(null), + close: vi.fn(async () => { + closed = true; + for (const listener of pageListeners.get(page)?.get('close') ?? []) listener(); + }), + once(event: string, listener: (...args: unknown[]) => void) { + const bucket = pageListeners.get(page) ?? new Map(); + const once = (...args: unknown[]) => { + bucket.get(event)?.delete(once); + listener(...args); + }; + const handlers = bucket.get(event) ?? new Set(); + handlers.add(once); + bucket.set(event, handlers); + pageListeners.set(page, bucket); + }, + }; + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, ++windowCounter); + return page; + }; + + const humanBlank = makePage(); + const humanPage = makePage('https://human.example/'); + const pages = [humanBlank, humanPage]; + const cdp = { + send: vi.fn(async (method: string, params?: { targetId?: string }) => { + if (method === 'Target.createTarget') { + const page = makePage(); + pages.push(page); + queueMicrotask(() => emit('page', page)); + return { targetId: targetIds.get(page) }; + } + if (method === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (method === 'Target.closeTarget') return { success: true }; + return {}; + }), + on: vi.fn(), + detach: vi.fn().mockResolvedValue(undefined), + }; + const browser = { + close: vi.fn().mockResolvedValue(undefined), + newBrowserCDPSession: vi.fn().mockResolvedValue(cdp), + }; + context = { + pages: vi.fn(() => pages.filter(page => !page.isClosed())), + newPage: vi.fn(async () => { + const page = makePage(); + pages.push(page); + return page; + }), + newCDPSession: vi.fn(async (page: object) => ({ + send: vi.fn(async (method: string, params?: { targetId?: string }) => { + if (method === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(page) } }; + if (method === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), + browser: vi.fn(() => browser), + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, + }; + return { + attachment: { + profileId: 'default', + browserVersion: '152.0', + context, + browser, + closeTransport: vi.fn(), + release: vi.fn().mockResolvedValue(undefined), + }, + browser, + context, + humanBlank, + humanPage, + targetIdFor: (page: object) => targetIds.get(page), + emitPage: (page: object) => emit('page', page), + emitClose: () => emit('close'), + }; +} + +async function flushPageEvent(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + +describe('SlabSessionManager ownership', () => { + it('creates and releases only an agent-owned page', async () => { + const attached = fakeAttachedProfile(); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached.attachment) }); + const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const }; + + const lease = await manager.getPage(input); + + expect(lease.page).not.toBe(attached.humanBlank); + expect(lease.page).not.toBe(attached.humanPage); + expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); + expect(manager.pageIdFor(attached.humanPage)).toBeUndefined(); + expect(manager.pageIdFor(lease.page)).toBe(lease.pageId); + + await manager.shutdown(); + await manager.shutdown(); + + expect(attached.humanBlank.close).not.toHaveBeenCalled(); + expect(attached.humanPage.close).not.toHaveBeenCalled(); + expect(attached.browser.close).not.toHaveBeenCalled(); + expect(attached.attachment.release).toHaveBeenCalledOnce(); + }); + + it('registers only an explicitly acquired observed target', async () => { + const attached = fakeAttachedProfile(); + const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached.attachment) }); + const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const }; + await manager.getPage(input); + + attached.emitPage(attached.humanPage); + await flushPageEvent(); + const lease = await manager.bindPage({ ...input, targetId: attached.targetIdFor(attached.humanPage) }); + + expect(lease?.page).toBe(attached.humanPage); + expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); + expect(manager.pageIdFor(attached.humanPage)).toBe(lease?.pageId); + }); + + it('reports a detached lease without reopening or closing SLAB', async () => { + const attached = fakeAttachedProfile(); + const attachProfile = vi.fn().mockResolvedValue(attached.attachment); + const manager = new SlabSessionManager({ attachProfile }); + const command = { + id: 'navigate-after-loss', + action: 'navigate' as const, + profileId: 'default', + session: 'agent', + sessionId: 'agent', + surface: 'browser' as const, + url: 'https://example.com/', + }; + await manager.getPage(command); + + attached.emitClose(); + await flushPageEvent(); + const result = await dispatchSlabAction(manager, command); + + expect(result).toMatchObject({ ok: false, errorCode: 'slab_attachment_lost' }); + expect(attachProfile).toHaveBeenCalledOnce(); + expect(attached.browser.close).not.toHaveBeenCalled(); + expect(attached.attachment.closeTransport).toHaveBeenCalledOnce(); + expect(attached.attachment.release).not.toHaveBeenCalled(); + + await expect(manager.getPage({ ...command, session: 'replacement', sessionId: 'replacement' })) + .resolves.toMatchObject({ profileId: 'default' }); + expect(attachProfile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 43e25383..0dc744fc 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -122,6 +122,15 @@ export class SessionWindowConflictError extends CliError { } } +export class SlabAttachmentLostError extends Error { + readonly code = 'SLAB_ATTACHMENT_LOST'; + + constructor() { + super('SLAB attachment was lost. Start a new browser session before retrying.'); + this.name = 'SlabAttachmentLostError'; + } +} + export interface SlabSessionManagerOptions { baseDir?: string; attachProfile?: AttachSlabProfile; @@ -162,6 +171,7 @@ export class SlabSessionManager { private readonly attachProfile: AttachSlabProfile; private readonly hasActiveHandoff: (profileId: string) => boolean; private readonly profiles = new Map(); + private readonly detachedSessions = new Map>(); private readonly profileLaunches = new Map>(); private readonly profileLifecycleQueues = new Map>(); private readonly profileActivities = new Map(); @@ -230,6 +240,7 @@ export class SlabSessionManager { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const surface = normalizeSurface(input.surface); const leaseKey = resolveLeaseKey(input); const freshPage = input.freshPage === true; @@ -274,6 +285,7 @@ export class SlabSessionManager { async findPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const leaseKey = resolveLeaseKey(input); const runtime = this.profiles.get(profileId); const sessionRuntime = runtime?.sessions.get(sessionId); @@ -289,6 +301,7 @@ export class SlabSessionManager { async findPageById(pageId: string, opts: Pick): Promise { const expectedProfileId = normalizeProfileId(opts.profileId); const sessionId = requireSessionId(opts); + this.assertSessionAttached(expectedProfileId, sessionId); const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; for (const [profileId, runtime] of this.profiles.entries()) { if (expectedProfileId !== profileId) continue; @@ -339,6 +352,7 @@ export class SlabSessionManager { async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const runtime = this.profiles.get(profileId); const sessionRuntime = runtime?.sessions.get(sessionId); const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); @@ -490,60 +504,32 @@ export class SlabSessionManager { return true; } - async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { + async bindPage(input: SessionKeyInput & { pageId?: string; targetId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const surface = normalizeSurface(input.surface); const runtime = this.profiles.get(profileId); if (!runtime) return null; const existingSession = runtime.sessions.get(sessionId); - let match = input.pageId - ? this.findEntryByPageId(runtime, input.pageId) - : existingSession && this.openEntries(existingSession)[input.index ?? -1]; - if (!match && input.index !== undefined) { - const candidates: PlaywrightPage[] = []; - for (const candidate of runtime.context.pages()) { - if (pageIsClosed(candidate) || candidate === runtime.parkingPage) continue; - if (await this.targetIdForPage(runtime, candidate) === runtime.anchorTargetId) continue; - candidates.push(candidate); - } - const page = candidates[input.index]; - if (page) { - const targetId = await this.targetIdForPage(runtime, page); - const entry = runtime.targetPages.get(targetId) ?? { - page, - pageId: nextPageId(), - targetId, - leaseKey: `unowned\u0000${targetId}`, - session: '', - surface, - }; - if (!runtime.targetPages.has(targetId)) { - runtime.targetPages.set(targetId, entry); - this.attachPageLifecycle(runtime, entry); - } - match = [entry.leaseKey, entry]; - } - } - if (!match) return null; - - const entry = match[1]; - if (entry.sessionId && entry.sessionId !== sessionId) { - throw new SessionWindowConflictError(entry.pageId, sessionId, entry.sessionId); - } const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); - await this.assertBindableWindow(runtime, sessionRuntime, entry); - const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; - const sourceKey = entry.leaseKey; + const targetId = input.targetId?.trim(); + const existingEntry = input.pageId + ? this.findEntryByPageId(runtime, input.pageId)?.[1] + : targetId + ? runtime.targetPages.get(targetId) + : existingSession && this.openEntries(existingSession)[input.index ?? -1]?.[1]; + const page = existingEntry?.page ?? (targetId ? this.pendingTargetPages.get(runtime)?.get(targetId) : undefined); + if (!page || pageIsClosed(page)) return null; const canonicalKey = resolveLeaseKey(input); const currentCanonical = sessionRuntime.pages.get(canonicalKey); if (input.windowMode !== 'background') { - await entry.page.bringToFront?.().catch(() => {}); + await page.bringToFront?.().catch(() => {}); } - if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { + if (currentCanonical && currentCanonical.page !== page && !pageIsClosed(currentCanonical.page)) { const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; sessionRuntime.pages.delete(canonicalKey); currentCanonical.leaseKey = preservedKey; @@ -551,18 +537,18 @@ export class SlabSessionManager { this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); } - sourceSession?.pages.delete(sourceKey); - entry.sessionId = sessionId; - entry.leaseKey = canonicalKey; - entry.session = session; - entry.surface = surface; - entry.siteSession = input.siteSession; - entry.idleTimeout = input.idleTimeout; - sessionRuntime.pages.set(canonicalKey, entry); - this.refreshIdleTimer(runtime, sessionRuntime, canonicalKey, entry); - this.selectEntry(sessionRuntime, entry); + const owned = await this.registerOwnedPage(runtime, sessionRuntime, page, { + leaseKey: canonicalKey, + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + this.selectEntry(sessionRuntime, owned); runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; + return { profileId, leaseKey: canonicalKey, context: runtime.context, page: owned.page, pageId: owned.pageId }; } async closePage(input: Pick & { pageId?: string; index?: number }): Promise { @@ -656,6 +642,7 @@ export class SlabSessionManager { await this.closeRuntime(runtime).catch(() => {}); }))); this.profiles.clear(); + this.detachedSessions.clear(); this.profileLaunches.clear(); this.profileActivities.clear(); } @@ -730,7 +717,10 @@ 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).catch(error => { + const detached = this.detachedSessions.get(profileId) ?? new Set(); + for (const sessionId of runtime.sessions.keys()) detached.add(sessionId); + if (detached.size > 0) this.detachedSessions.set(profileId, detached); + void this.releaseRuntime(runtime, false, false).catch(error => { log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); }); this.cleanupRuntime(runtime); @@ -847,7 +837,7 @@ export class SlabSessionManager { await this.releaseRuntime(runtime, true); } - private async releaseRuntime(runtime: ProfileRuntime, closePages: boolean): Promise { + private async releaseRuntime(runtime: ProfileRuntime, closePages: boolean, releaseNative = true): Promise { if (runtime.releasePromise) return runtime.releasePromise; runtime.releasePromise = (async () => { this.cancelProfileIdle(runtime); @@ -868,7 +858,8 @@ export class SlabSessionManager { ...pages.map(page => this.detachPageCdp(page)), runtime.cdp?.detach().catch(() => {}), ]); - await runtime.attachment.release(); + if (releaseNative) await runtime.attachment.release(); + else runtime.attachment.closeTransport(); } finally { this.cleanupRuntime(runtime); } @@ -897,6 +888,10 @@ export class SlabSessionManager { if (this.shuttingDown) throw daemonShuttingDownError(); } + private assertSessionAttached(profileId: string, sessionId: string): void { + if (this.detachedSessions.get(profileId)?.has(sessionId)) throw new SlabAttachmentLostError(); + } + private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); let release!: () => void; @@ -929,7 +924,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); @@ -946,19 +941,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, @@ -999,6 +981,7 @@ export class SlabSessionManager { windowMode: BrowserWindowMode | undefined, attempt = 0, ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { + this.assertSessionAttached(profileId, sessionId); const runtime = await this.getProfileRuntime(profileId, windowMode); const session = this.getSessionRuntime(runtime, sessionId); let page: PlaywrightPage; @@ -1195,23 +1178,6 @@ export class SlabSessionManager { } } - private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { - if (entry.sessionId) { - if (entry.sessionId !== session.id) { - throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); - } - await this.assertOwnedWindow(runtime, session.id, entry); - return; - } - const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); - const owner = runtime.windowOwners.get(actual); - if (owner !== undefined && owner !== session.id) { - throw new SessionWindowConflictError(entry.pageId, session.id, owner); - } - runtime.windowOwners.set(actual, session.id); - session.windowIds.add(actual); - } - private openEntries(runtime: SessionRuntime): [string, PageEntry][] { return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); } From 399457e6231c8b28aede9478599069b54dfbbf57 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 01:37:36 +0530 Subject: [PATCH 07/34] fix: preserve explicit SLAB target acquisition --- src/browser/runtime/local-slab/actions.ts | 4 +-- .../runtime/local-slab/attachment.test.ts | 1 + .../local-slab/session-manager.test.ts | 28 +++++++++++-------- .../runtime/local-slab/session-manager.ts | 19 ++++++++++++- src/cli.test.ts | 8 ++++-- src/cli.ts | 14 ++++++++-- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/browser/runtime/local-slab/actions.ts b/src/browser/runtime/local-slab/actions.ts index 6192dbe4..7e90e42c 100644 --- a/src/browser/runtime/local-slab/actions.ts +++ b/src/browser/runtime/local-slab/actions.ts @@ -484,7 +484,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B ok: false, errorCode: 'invalid_request', error: 'Bind requires --page, --target-id, or --index for a SLAB runtime tab', - errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', + errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page ` or `--target-id `.', }; } { @@ -509,7 +509,7 @@ export async function dispatchSlabAction(manager: SlabSessionManager, command: B ok: false, errorCode: 'bound_tab_not_found', error: 'SLAB tab not found for bind target', - errorHint: 'Run `webcmd --session browser tab list` and choose a current SLAB tab id or index.', + errorHint: 'Run `webcmd --session browser tab list` and choose a current SLAB tab id or index, or provide a known CDP target id with `--target-id`.', }; } return { diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts index 99d3237c..c54e33f3 100644 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -69,5 +69,6 @@ describe('attachSlabProfile', () => { expect(cdpTransport.close).toHaveBeenCalledOnce(); expect(bridge.release).toHaveBeenCalledWith('connection-1'); + expect(vi.mocked(cdpTransport.close).mock.invocationCallOrder[0]).toBeLessThan(bridge.release.mock.invocationCallOrder[0]!); }); }); diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index b1af4f8a..504e4ba0 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -139,14 +139,12 @@ describe('SlabSessionManager ownership', () => { expect(attached.attachment.release).toHaveBeenCalledOnce(); }); - it('registers only an explicitly acquired observed target', async () => { + it('registers only an explicitly acquired attachment-time target', async () => { const attached = fakeAttachedProfile(); const manager = new SlabSessionManager({ attachProfile: vi.fn().mockResolvedValue(attached.attachment) }); const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const }; await manager.getPage(input); - attached.emitPage(attached.humanPage); - await flushPageEvent(); const lease = await manager.bindPage({ ...input, targetId: attached.targetIdFor(attached.humanPage) }); expect(lease?.page).toBe(attached.humanPage); @@ -154,32 +152,38 @@ describe('SlabSessionManager ownership', () => { expect(manager.pageIdFor(attached.humanPage)).toBe(lease?.pageId); }); - it('reports a detached lease without reopening or closing SLAB', async () => { + it('reports every detached session operation without reopening or closing SLAB', async () => { const attached = fakeAttachedProfile(); const attachProfile = vi.fn().mockResolvedValue(attached.attachment); const manager = new SlabSessionManager({ attachProfile }); - const command = { - id: 'navigate-after-loss', - action: 'navigate' as const, + const input = { profileId: 'default', session: 'agent', sessionId: 'agent', surface: 'browser' as const, - url: 'https://example.com/', }; - await manager.getPage(command); + await manager.getPage(input); attached.emitClose(); await flushPageEvent(); - const result = await dispatchSlabAction(manager, command); + for (const command of [ + { ...input, id: 'tabs-after-loss', action: 'tabs' as const, op: 'list' as const }, + { ...input, id: 'select-after-loss', action: 'tabs' as const, op: 'select' as const, index: 0 }, + { ...input, id: 'close-after-loss', action: 'tabs' as const, op: 'close' as const, index: 0 }, + { ...input, id: 'release-after-loss', action: 'close-window' as const }, + ]) { + await expect(dispatchSlabAction(manager, command)).resolves.toMatchObject({ + ok: false, + errorCode: 'slab_attachment_lost', + }); + } - expect(result).toMatchObject({ ok: false, errorCode: 'slab_attachment_lost' }); expect(attachProfile).toHaveBeenCalledOnce(); expect(attached.browser.close).not.toHaveBeenCalled(); expect(attached.attachment.closeTransport).toHaveBeenCalledOnce(); expect(attached.attachment.release).not.toHaveBeenCalled(); - await expect(manager.getPage({ ...command, session: 'replacement', sessionId: 'replacement' })) + await expect(manager.getPage({ ...input, session: 'replacement', sessionId: 'replacement' })) .resolves.toMatchObject({ profileId: 'default' }); expect(attachProfile).toHaveBeenCalledTimes(2); }); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index 0dc744fc..eaf46c2c 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -382,6 +382,7 @@ export class SlabSessionManager { async listPages(input: Pick): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const surface = input.surface ? normalizeSurface(input.surface) : undefined; const runtime = this.profiles.get(profileId); if (!runtime) return []; @@ -471,6 +472,7 @@ export class SlabSessionManager { async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const runtime = this.profiles.get(profileId); if (!runtime) return null; const sessionRuntime = runtime.sessions.get(sessionId); @@ -520,7 +522,7 @@ export class SlabSessionManager { : targetId ? runtime.targetPages.get(targetId) : existingSession && this.openEntries(existingSession)[input.index ?? -1]?.[1]; - const page = existingEntry?.page ?? (targetId ? this.pendingTargetPages.get(runtime)?.get(targetId) : undefined); + const page = existingEntry?.page ?? (targetId ? await this.findPageByTargetId(runtime, targetId) : undefined); if (!page || pageIsClosed(page)) return null; const canonicalKey = resolveLeaseKey(input); const currentCanonical = sessionRuntime.pages.get(canonicalKey); @@ -554,6 +556,7 @@ export class SlabSessionManager { async closePage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const runtime = this.profiles.get(profileId); if (!runtime) return null; const sessionRuntime = runtime.sessions.get(sessionId); @@ -571,6 +574,7 @@ export class SlabSessionManager { async release(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); + this.assertSessionAttached(profileId, sessionId); const runtime = this.profiles.get(profileId); if (!runtime) return; const sessionRuntime = runtime.sessions.get(sessionId); @@ -1027,6 +1031,19 @@ export class SlabSessionManager { }); } + private async findPageByTargetId(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)?.get(targetId); + if (pending && !pageIsClosed(pending)) return pending; + + // Resolve only the caller-supplied identity. This never makes visible pages + // owned unless bindPage subsequently registers the exact matching target. + for (const page of runtime.context.pages()) { + if (pageIsClosed(page)) continue; + if (await this.targetIdForPage(runtime, page).catch(() => undefined) === targetId) return page; + } + return undefined; + } + private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { const targetId = await this.targetIdForPage(runtime, page); if (targetId === runtime.anchorTargetId) { diff --git a/src/cli.test.ts b/src/cli.test.ts index 445a6ab0..a87bd9f3 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1253,7 +1253,7 @@ name: 'search', usage: 'webcmd browser bind [options]', positionals: [], }); - expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'verbose', 'format', 'json']); + expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'targetId', 'verbose', 'format', 'json']); expect(data.structured_help).toMatchObject({ formats: ['yaml', 'json'], usage: 'webcmd browser --help -f yaml', @@ -2263,7 +2263,7 @@ describe('browser raw session commands', () => { expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); }); - it('binds only an explicit stable page id', async () => { + it('binds an explicit stable page id or CDP target id', async () => { const program = createProgram('', ''); await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123']); @@ -2271,6 +2271,10 @@ describe('browser raw session commands', () => { expect(mockSendCommand).toHaveBeenCalledWith('bind', { session: 'session_test', surface: 'browser', page: 'page-123', }); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--target-id', 'target-123']); + expect(mockSendCommand).toHaveBeenLastCalledWith('bind', { + session: 'session_test', surface: 'browser', targetId: 'target-123', + }); await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--index', '0'])) .rejects.toThrow(/process\.exit unexpectedly called/); await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', ' '])) diff --git a/src/cli.ts b/src/cli.ts index bae75c48..e9513e3a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1380,12 +1380,20 @@ cli({ browser.addCommand(withBrowserVerbose(new Command('bind') .description('Bind this session to an existing page') .addOption(new Option('--page ', 'Stable page id returned by tabs') - .makeOptionMandatory() .argParser(browserOptionValueParser('bind', 'page')!)) + .addOption(new Option('--target-id ', 'Native CDP target id for an explicitly acquired page') + .argParser(browserOptionValueParser('bind', 'targetId')!)) .action(rawBrowserAction((session, routing, opts) => { const page = typeof opts.page === 'string' ? opts.page.trim() : ''; - if (!page) throw new BrowserCommandError('--page must be a non-empty stable page id', 'invalid_request'); - return sendCommand('bind', { session, surface: 'browser', ...routing, page }); + const targetId = typeof opts.targetId === 'string' ? opts.targetId.trim() : ''; + if (page && targetId) throw new BrowserCommandError('Use either --page or --target-id, not both', 'invalid_request'); + if (!page && !targetId) throw new BrowserCommandError('Bind requires a non-empty --page or --target-id', 'invalid_request'); + return sendCommand('bind', { + session, + surface: 'browser', + ...routing, + ...(page ? { page } : { targetId }), + }); })))); const runCommand = withBrowserVerbose(new Command('run') From 413a667b45ebffc0897c588ff790d85b7873433d Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 01:47:15 +0530 Subject: [PATCH 08/34] refactor: vendor Page-only interaction humanizer --- NOTICE | 27 + package-lock.json | 103 -- package.json | 1 - src/browser/humanizer/actionability.ts | 343 +++++++ src/browser/humanizer/config.ts | 254 +++++ src/browser/humanizer/elementhandle.ts | 541 ++++++++++ src/browser/humanizer/index.ts | 935 ++++++++++++++++++ src/browser/humanizer/keyboard.ts | 214 ++++ src/browser/humanizer/mouse.ts | 213 ++++ src/browser/humanizer/page.test.ts | 106 ++ src/browser/humanizer/page.ts | 15 + src/browser/humanizer/scroll.ts | 190 ++++ .../local-slab/session-manager.test.ts | 33 + .../runtime/local-slab/session-manager.ts | 2 + 14 files changed, 2873 insertions(+), 104 deletions(-) create mode 100644 src/browser/humanizer/actionability.ts create mode 100644 src/browser/humanizer/config.ts create mode 100644 src/browser/humanizer/elementhandle.ts create mode 100644 src/browser/humanizer/index.ts create mode 100644 src/browser/humanizer/keyboard.ts create mode 100644 src/browser/humanizer/mouse.ts create mode 100644 src/browser/humanizer/page.test.ts create mode 100644 src/browser/humanizer/page.ts create mode 100644 src/browser/humanizer/scroll.ts diff --git a/NOTICE b/NOTICE index c9aba3e7..a66529cc 100644 --- a/NOTICE +++ b/NOTICE @@ -33,3 +33,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +The humanizer sources in src/browser/humanizer/actionability.ts, config.ts, +elementhandle.ts, index.ts, keyboard.ts, mouse.ts, and scroll.ts are derived +from CloakHQ/cloakbrowser@0.4.5, git commit +5176971f45d02845d3d1c0adbbda0bc93addf747. + +MIT License + +Copyright (c) 2026 CloakHQ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index 598b6180..ba7134d8 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 f2696c9b..ccbe03b4 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,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/humanizer/actionability.ts b/src/browser/humanizer/actionability.ts new file mode 100644 index 00000000..dec45b19 --- /dev/null +++ b/src/browser/humanizer/actionability.ts @@ -0,0 +1,343 @@ +/** + * Playwright-style actionability checks for the humanize layer. + * + * Checks: attached, visible, stable, enabled, editable, receives pointer events. + * Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms. + */ + +import type { Page, Frame, ElementHandle } from 'playwright-core'; + +// --------------------------------------------------------------------------- +// Error hierarchy +// --------------------------------------------------------------------------- + +export class ActionabilityError extends Error { + selector: string; + check: string; + + constructor(selector: string, check: string, message: string) { + super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`); + this.name = 'ActionabilityError'; + this.selector = selector; + this.check = check; + } +} + +export class ElementNotAttachedError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'attached', 'element not found in DOM'); + this.name = 'ElementNotAttachedError'; + } +} + +export class ElementNotVisibleError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'visible', 'element is not visible'); + this.name = 'ElementNotVisibleError'; + } +} + +export class ElementNotStableError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'stable', 'element position is still changing'); + this.name = 'ElementNotStableError'; + } +} + +export class ElementNotEnabledError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'enabled', 'element is disabled'); + this.name = 'ElementNotEnabledError'; + } +} + +export class ElementNotEditableError extends ActionabilityError { + constructor(selector: string) { + super(selector, 'editable', 'element is not editable'); + this.name = 'ElementNotEditableError'; + } +} + +export class ElementNotReceivingEventsError extends ActionabilityError { + coveringTag: string; + constructor(selector: string, coveringTag: string = 'unknown') { + super(selector, 'pointer_events', `element is covered by <${coveringTag}>`); + this.name = 'ElementNotReceivingEventsError'; + this.coveringTag = coveringTag; + } +} + +// --------------------------------------------------------------------------- +// Check-set constants +// --------------------------------------------------------------------------- + +export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events'; + +export const CHECKS_CLICK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); +export const CHECKS_HOVER: ReadonlySet = new Set(['attached', 'visible', 'pointer_events']); +export const CHECKS_INPUT: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']); +export const CHECKS_FOCUS: ReadonlySet = new Set(['attached', 'visible', 'enabled']); +export const CHECKS_CHECK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); + +const BACKOFF_MS = [100, 250, 500, 1000]; + +function backoffSleep(attempt: number): Promise { + const idx = Math.min(attempt, BACKOFF_MS.length - 1); + return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx])); +} + +// --------------------------------------------------------------------------- +// Pre-scroll actionability +// --------------------------------------------------------------------------- + +export async function ensureActionable( + pageOrFrame: Page | Frame, + selector: string, + checks: ReadonlySet, + timeout: number = 30000, + force: boolean = false, +): Promise { + if (force) return; + + const deadline = Date.now() + timeout; + let attempt = 0; + let lastError: ActionabilityError | null = null; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) { + if (lastError) throw lastError; + throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check'); + } + + try { + const loc = pageOrFrame.locator(selector).first(); + + if (checks.has('attached')) { + try { + await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotAttachedError(selector); + } + } + + if (checks.has('visible')) { + if (!await loc.isVisible()) throw new ElementNotVisibleError(selector); + } + + if (checks.has('enabled')) { + if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector); + } + + if (checks.has('editable')) { + if (!await loc.isEditable()) throw new ElementNotEditableError(selector); + } + + return; + } catch (e) { + if (e instanceof ActionabilityError) { + lastError = e; + if (Date.now() >= deadline) throw lastError; + await backoffSleep(attempt); + attempt++; + } else { + throw e; + } + } + } +} + +// --------------------------------------------------------------------------- +// Post-scroll stability check +// --------------------------------------------------------------------------- + +function boxesDiffer( + a: { x: number; y: number; width: number; height: number }, + b: { x: number; y: number; width: number; height: number }, +): boolean { + return ( + Math.abs(a.x - b.x) > 1 || + Math.abs(a.y - b.y) > 1 || + Math.abs(a.width - b.width) > 1 || + Math.abs(a.height - b.height) > 1 + ); +} + +export async function ensureStable( + pageOrFrame: Page | Frame, + selector: string, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) throw new ElementNotStableError(selector); + + const loc = pageOrFrame.locator(selector).first(); + const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); + if (!box1) throw new ElementNotAttachedError(selector); + + await new Promise(r => setTimeout(r, 100)); + + const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); + if (!box2) throw new ElementNotAttachedError(selector); + + if (!boxesDiffer(box1, box2)) return; + + if (Date.now() >= deadline) throw new ElementNotStableError(selector); + + await backoffSleep(attempt); + attempt++; + } +} + +// --------------------------------------------------------------------------- +// Pointer-events check (post-scroll, at actual click coordinates) +// --------------------------------------------------------------------------- + +const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}`; + +const POINTER_EVENTS_HANDLE_JS = `(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}`; + +export async function checkPointerEvents( + pageOrFrame: Page | Frame, + selector: string, + x: number, + y: number, + stealth?: { evaluate(expression: string): Promise } | null, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + let result: any = null; + try { + const loc = pageOrFrame.locator(selector).first(); + const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) }); + result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box }); + } catch { + result = null; + } + + if (!result || result.hit) return; + const covering = (result as any)?.covering ?? 'unknown'; + if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering); + + await backoffSleep(attempt); + attempt++; + } +} + +// --------------------------------------------------------------------------- +// ElementHandle variant +// --------------------------------------------------------------------------- + +export async function ensureActionableHandle( + el: ElementHandle, + checks: ReadonlySet, + timeout: number = 30000, + force: boolean = false, +): Promise { + if (force) return; + + const deadline = Date.now() + timeout; + let attempt = 0; + let lastError: ActionabilityError | null = null; + const label = ''; + + while (true) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs <= 0) { + if (lastError) throw lastError; + throw new ActionabilityError(label, 'timeout', 'timeout expired before first check'); + } + + try { + if (checks.has('visible')) { + try { + await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotVisibleError(label); + } + } + + if (checks.has('enabled')) { + try { + await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotEnabledError(label); + } + } + + if (checks.has('editable')) { + try { + await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); + } catch { + throw new ElementNotEditableError(label); + } + } + + return; + } catch (e) { + if (e instanceof ActionabilityError) { + lastError = e; + if (Date.now() >= deadline) throw lastError; + await backoffSleep(attempt); + attempt++; + } else { + throw e; + } + } + } +} + +export async function checkPointerEventsHandle( + el: ElementHandle, + x: number, + y: number, + timeout: number = 5000, +): Promise { + const deadline = Date.now() + timeout; + let attempt = 0; + + while (true) { + let result: any; + try { + const box = await el.boundingBox(); + result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box }); + } catch { + result = null; + } + + if (!result || result.hit) return; + + const covering = (result as any)?.covering ?? 'unknown'; + if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('', covering); + + await backoffSleep(attempt); + attempt++; + } +} diff --git a/src/browser/humanizer/config.ts b/src/browser/humanizer/config.ts new file mode 100644 index 00000000..55c81ecb --- /dev/null +++ b/src/browser/humanizer/config.ts @@ -0,0 +1,254 @@ +/** + * cloakbrowser-human — Configuration and presets. + * + * All numeric parameters for human-like behavior are centralized here. + * Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HumanConfig { + // Keyboard + typing_delay: number; + typing_delay_spread: number; + typing_pause_chance: number; + typing_pause_range: [number, number]; + shift_down_delay: [number, number]; + shift_up_delay: [number, number]; + key_hold: [number, number]; + field_switch_delay: [number, number]; + mistype_chance: number; + mistype_delay_notice: [number, number]; + mistype_delay_correct: [number, number]; + + + // Mouse — movement + mouse_steps_divisor: number; + mouse_min_steps: number; + mouse_max_steps: number; + mouse_wobble_max: number; + mouse_overshoot_chance: number; + mouse_overshoot_px: [number, number]; + mouse_burst_size: [number, number]; + mouse_burst_pause: [number, number]; + + // Mouse — clicks + click_aim_delay_input: [number, number]; + click_aim_delay_button: [number, number]; + click_hold_input: [number, number]; + click_hold_button: [number, number]; + click_input_x_range: [number, number]; + + // Mouse — idle + idle_drift_px: number; + idle_pause_range: [number, number]; + + // Scroll + scroll_delta_base: [number, number]; + scroll_delta_variance: number; + scroll_pause_fast: [number, number]; + scroll_pause_slow: [number, number]; + scroll_accel_steps: [number, number]; + scroll_decel_steps: [number, number]; + scroll_overshoot_chance: number; + scroll_overshoot_px: [number, number]; + scroll_settle_delay: [number, number]; + scroll_target_zone: [number, number]; + scroll_pre_move_delay: [number, number]; + + // Initial cursor position + initial_cursor_x: [number, number]; + initial_cursor_y: [number, number]; + + + // Idle micro-movements between actions (opt-in, adds latency) + idle_between_actions: boolean; + idle_between_duration: [number, number]; +} + +export type HumanPreset = 'default' | 'careful'; + +export type HumanActionOptions = Partial & { + timeout?: number; + force?: boolean; + human_config?: Partial; +}; + +// --------------------------------------------------------------------------- +// Default preset +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: HumanConfig = { + // Keyboard + typing_delay: 70, + typing_delay_spread: 40, + typing_pause_chance: 0.1, + typing_pause_range: [400, 1000], + shift_down_delay: [30, 70], + shift_up_delay: [20, 50], + key_hold: [15, 35], + field_switch_delay: [800, 1500], + // Mistype (typo simulation) + mistype_chance: 0.02, + mistype_delay_notice: [100, 300], + mistype_delay_correct: [50, 150], + + // Mouse — movement + mouse_steps_divisor: 8, + mouse_min_steps: 25, + mouse_max_steps: 80, + mouse_wobble_max: 1.5, + mouse_overshoot_chance: 0.15, + mouse_overshoot_px: [3, 6], + mouse_burst_size: [3, 5], + mouse_burst_pause: [8, 18], + + // Mouse — clicks + click_aim_delay_input: [60, 140], + click_aim_delay_button: [80, 200], + click_hold_input: [40, 100], + click_hold_button: [60, 150], + click_input_x_range: [0.05, 0.30], + + // Mouse — idle + idle_drift_px: 3, + idle_pause_range: [300, 1000], + + // Scroll + scroll_delta_base: [80, 130], + scroll_delta_variance: 0.2, + scroll_pause_fast: [30, 80], + scroll_pause_slow: [80, 200], + scroll_accel_steps: [2, 3], + scroll_decel_steps: [2, 3], + scroll_overshoot_chance: 0.1, + scroll_overshoot_px: [50, 150], + scroll_settle_delay: [300, 600], + scroll_target_zone: [0.20, 0.80], + scroll_pre_move_delay: [100, 300], + + // Initial cursor position (as if coming from the address bar area) + initial_cursor_x: [400, 700], + initial_cursor_y: [45, 60], + + // Idle micro-movements between actions (off by default) + idle_between_actions: false, + idle_between_duration: [0.3, 0.8], +}; + +// --------------------------------------------------------------------------- +// Careful preset — everything slower and more deliberate +// --------------------------------------------------------------------------- + +const CAREFUL_CONFIG: HumanConfig = { + ...DEFAULT_CONFIG, + + // Keyboard — slower typing + typing_delay: 100, + typing_delay_spread: 50, + typing_pause_chance: 0.15, + typing_pause_range: [500, 1200], + shift_down_delay: [40, 90], + shift_up_delay: [30, 70], + key_hold: [20, 45], + field_switch_delay: [1000, 2000], + mistype_chance: 0.03, + mistype_delay_notice: [150, 400], + mistype_delay_correct: [80, 200], + + // Mouse — slower, more precise + mouse_overshoot_chance: 0.10, + mouse_burst_pause: [12, 25], + + // Mouse — clicks (longer aiming and holding) + click_aim_delay_input: [80, 180], + click_aim_delay_button: [120, 280], + click_hold_input: [60, 140], + click_hold_button: [80, 200], + + // Scroll — slower + scroll_pause_fast: [100, 200], + scroll_pause_slow: [250, 600], + scroll_settle_delay: [400, 800], + scroll_pre_move_delay: [150, 400], + + // Idle between actions enabled for careful preset + idle_between_actions: true, + idle_between_duration: [0.4, 1.0], +}; + +// --------------------------------------------------------------------------- +// Preset map +// --------------------------------------------------------------------------- + +const PRESETS: Record = { + default: DEFAULT_CONFIG, + careful: CAREFUL_CONFIG, +}; + +/** + * Resolve a preset name or partial config into a full HumanConfig. + * If `preset` is a string, returns the corresponding built-in config. + * Any keys in `overrides` replace the preset values. + */ +export function resolveConfig( + preset: HumanPreset = 'default', + overrides?: Partial, +): HumanConfig { + const base = PRESETS[preset]; + if (!base) { + throw new Error( + `Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}` + ); + } + if (!overrides) return { ...base }; + return { ...base, ...overrides }; +} + +/** + * Merge a partial overrides object on top of an existing HumanConfig. + * Returns a new object — the original ``cfg`` is never mutated. + * + * Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })`` + * so the same patched page can type different fields at different speeds + * without re-patching. + */ +export function mergeConfig( + cfg: HumanConfig, + overrides?: Partial | null, +): HumanConfig { + if (!overrides) return cfg; + return { ...cfg, ...overrides }; +} + + +// --------------------------------------------------------------------------- +// Utility: random number in range +// --------------------------------------------------------------------------- + +/** Random float in [min, max]. */ +export function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +/** Random integer in [min, max] (inclusive). */ +export function randInt(min: number, max: number): number { + return Math.floor(rand(min, max + 1)); +} + +/** Random value from a [min, max] tuple. */ +export function randRange(range: [number, number]): number { + return rand(range[0], range[1]); +} + +/** Random integer from a [min, max] tuple. */ +export function randIntRange(range: [number, number]): number { + return randInt(range[0], range[1]); +} + +/** Sleep for `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/browser/humanizer/elementhandle.ts b/src/browser/humanizer/elementhandle.ts new file mode 100644 index 00000000..d87ce00a --- /dev/null +++ b/src/browser/humanizer/elementhandle.ts @@ -0,0 +1,541 @@ +/** + * ElementHandle humanization for Playwright. + * + * Mirrors Puppeteer's ElementHandle patching architecture. + * Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles, + * and patches all interaction methods on each ElementHandle instance. + * + * Playwright ElementHandle methods patched: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * Stealth-aware: + * - Uses CDP DOM.describeNode when available to check element type + * (no main-world JS execution) + * - Falls back to el.evaluate() only when CDP is unavailable + */ + +import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core'; +import type { HumanConfig, HumanActionOptions } from './config.js'; +import { rand, randRange, sleep, mergeConfig } from './config.js'; +import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +import { humanType } from './keyboard.js'; +import { humanScrollIntoView } from './scroll.js'; +import { + ensureActionableHandle, checkPointerEventsHandle, + CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, +} from './actionability.js'; + +// --- Platform-aware select-all shortcut --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + +// ============================================================================ +// Stealth ElementHandle input check — uses CDP DOM.describeNode +// ============================================================================ + +async function isInputElementHandle( + stealth: any, // StealthEval from index.ts + el: ElementHandle, +): Promise { + // Try CDP DOM.describeNode first (no main-world JS execution) + if (stealth) { + try { + const cdp: CDPSession = await stealth.getCdpSession(); + // Playwright exposes the JSHandle's internal preview via _objectId or similar + // We need the remote object ID. Try to get it via internal API. + const impl = (el as any)._impl ?? (el as any)._object ?? el; + const guid = (impl as any)._guid; + + // Use el.evaluate as a reliable fallback within stealth context + // Playwright doesn't expose remoteObject directly like Puppeteer + } catch { /* fallthrough */ } + } + + // Fallback: el.evaluate (works reliably in Playwright) + try { + return await el.evaluate((node: any) => { + const tag = node.tagName?.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || node.getAttribute?.('contenteditable') === 'true'; + }); + } catch { + return false; + } +} + + +// ============================================================================ +// CursorState type (matches index.ts) +// ============================================================================ + +interface CursorState { + x: number; + y: number; + initialized: boolean; +} + + +// ============================================================================ +// Patch a single Playwright ElementHandle +// ============================================================================ + +export function patchSingleElementHandle( + el: ElementHandle, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + if ((el as any)._humanPatched) return; + (el as any)._humanPatched = true; + + // Save originals + const origElClick = el.click.bind(el); + const origElDblclick = el.dblclick.bind(el); + const origElHover = el.hover.bind(el); + const origElType = el.type.bind(el); + const origElFill = el.fill.bind(el); + const origElPress = el.press.bind(el); + const origElSelectOption = el.selectOption.bind(el); + const origElCheck = el.check.bind(el); + const origElUncheck = el.uncheck.bind(el); + const origElSetChecked = (el as any).setChecked?.bind(el); + const origElTap = el.tap.bind(el); + const origElFocus = el.focus.bind(el); + const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el); + + // Nested selectors + const origEl$ = el.$.bind(el); + const origEl$$ = el.$$.bind(el); + const origElWaitForSelector = el.waitForSelector.bind(el); + + // --- Nested elements are also patched --- + (el as any).$ = async (selector: string) => { + const child = await origEl$(selector); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + (el as any).$$ = async (selector: string) => { + const children = await origEl$$(selector); + for (const child of children) { + patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return children; + }; + + (el as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const child = await origElWaitForSelector(selector, options ?? {}); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + // --- Helper: get bounding box and move cursor to element --- + // Accepts a per-call ``callCfg`` so type/fill overrides like + // ``el.type(text, { human_config: { typing_delay: 30 } })`` or + // ``el.type(text, { typing_delay: 30 })`` carry through to mouse movement + // & idle timing for that single call. + // Also scrolls the element into view first so off-screen elements work + // (#129, #172 follow-up): otherwise boundingBox() returns null and we'd + // silently fall back to the unpatched native method. + const moveToElement = async (callCfg: HumanConfig = cfg) => { + // Ensure cursor is initialized + const ensureCursorInit = (page as any)._ensureCursorInit; + if (ensureCursorInit) await ensureCursorInit(); + + // Scroll into view first so boundingBox() returns coordinates even when + // the element starts below the fold. Best-effort — if humanScrollIntoView + // throws (e.g. detached element), we let boundingBox() decide whether to + // proceed or fall back to the original method. + try { + const { cursorX, cursorY } = await humanScrollIntoView( + page, raw, + () => el.boundingBox(), + cursor.x, cursor.y, callCfg, + ); + cursor.x = cursorX; + cursor.y = cursorY; + } catch { /* let boundingBox() decide */ } + + const box = await el.boundingBox(); + if (!box) return null; + + const isInp = await isInputElementHandle(stealth, el); + const target = clickTarget(box, isInp, callCfg); + + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + return { box, isInp }; + }; + + // --- el.click() --- + (el as any).click = async (options?: HumanActionOptions & { + button?: 'left' | 'right' | 'middle'; + clickCount?: number; + delay?: number; + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElClick(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + }; + + // --- el.dblclick() --- + (el as any).dblclick = async (options?: HumanActionOptions & { + button?: 'left' | 'right' | 'middle'; + delay?: number; + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElDblclick(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- el.hover() --- + (el as any).hover = async (options?: HumanActionOptions & { + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + position?: { x: number; y: number }; + trial?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElHover(options); + }; + + // --- el.type() --- + (el as any).type = async (text: string, options?: HumanActionOptions & { + delay?: number; + noWaitAfter?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = (options as any)?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElType(text, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + await sleep(rand(100, 250)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, text, callCfg, cdpSession); + }; + + // --- el.fill() --- + (el as any).fill = async (value: string, options?: HumanActionOptions & { + force?: boolean; + noWaitAfter?: boolean; + }) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force); + const info = await moveToElement(callCfg); + if (!info) return origElFill(value, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, callCfg); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, value, callCfg, cdpSession); + }; + + // --- el.press() --- + (el as any).press = async (key: string, options?: { delay?: number; noWaitAfter?: boolean; timeout?: number }) => { + await sleep(rand(20, 60)); + await originals.keyboardDown(key); + await sleep(randRange(cfg.key_hold)); + await originals.keyboardUp(key); + }; + + // --- el.selectOption() --- + (el as any).selectOption = async (values: any, options?: { + force?: boolean; + noWaitAfter?: boolean; + timeout?: number; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force); + const info = await moveToElement(); + if (!info) return origElSelectOption(values, options); + await humanClick(raw, false, cfg); + await sleep(rand(100, 300)); + return origElSelectOption(values, options); + }; + + // --- el.check() --- + (el as any).check = async (options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const checked = await el.isChecked(); + if (checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElCheck(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.uncheck() --- + (el as any).uncheck = async (options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const checked = await el.isChecked(); + if (!checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElUncheck(options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.setChecked() --- + if (origElSetChecked) { + (el as any).setChecked = async (checked: boolean, options?: { + force?: boolean; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const force = options?.force ?? false; + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force); + try { + const current = await el.isChecked(); + if (current === checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElSetChecked(checked, options); + if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000)); + await humanClick(raw, info.isInp, cfg); + }; + } + + // --- el.tap() --- + (el as any).tap = async (options?: { + force?: boolean; + modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>; + noWaitAfter?: boolean; + position?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const info = await moveToElement(); + if (!info) return origElTap(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.focus() --- + // Move cursor humanly but use programmatic focus (no click side-effects). + // Stock Playwright el.focus() never clicks — clicking would trigger onclick, + // submit forms, navigate links, etc. + (el as any).focus = async () => { + await moveToElement(); // human-like Bézier cursor movement + await origElFocus(); // programmatic focus, no click + }; + + // --- el.scrollIntoViewIfNeeded() --- + // Playwright's native version snaps the page — a strong bot signal. + // Replace with the same accelerate → cruise → decelerate → overshoot + // wheel sequence used by page.click() etc. Falls back to the native + // method if the element is detached or scrolling fails. + if (origElScrollIntoViewIfNeeded) { + (el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const ensureCursorInit = (page as any)._ensureCursorInit; + if (ensureCursorInit) await ensureCursorInit(); + try { + const { cursorX, cursorY } = await humanScrollIntoView( + page, raw, + () => el.boundingBox(), + cursor.x, cursor.y, callCfg, + ); + cursor.x = cursorX; + cursor.y = cursorY; + } catch { + return origElScrollIntoViewIfNeeded(options); + } + }; + } +} + + +// ============================================================================ +// Page-level ElementHandle patching +// ============================================================================ + +export function patchPageElementHandles( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch page.$() — only if the method exists + if (typeof page.$ === 'function') { + const orig$ = page.$.bind(page); + (page as any).$ = async (selector: string) => { + const el = await orig$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch page.$$() + if (typeof page.$$ === 'function') { + const orig$$ = page.$$.bind(page); + (page as any).$$ = async (selector: string) => { + const els = await orig$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch page.waitForSelector() + if (typeof page.waitForSelector === 'function') { + const origWaitForSelector = page.waitForSelector.bind(page); + (page as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const el = await origWaitForSelector(selector, options ?? {}); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} + + +// ============================================================================ +// Frame-level ElementHandle patching +// ============================================================================ + +export function patchFrameElementHandles( + frame: Frame, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch frame.$() — only if the method exists + if (typeof frame.$ === 'function') { + const origFrame$ = frame.$.bind(frame); + (frame as any).$ = async (selector: string) => { + const el = await origFrame$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch frame.$$() + if (typeof frame.$$ === 'function') { + const origFrame$$ = frame.$$.bind(frame); + (frame as any).$$ = async (selector: string) => { + const els = await origFrame$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch frame.waitForSelector() + if (typeof frame.waitForSelector === 'function') { + const origFrameWaitForSelector = frame.waitForSelector.bind(frame); + (frame as any).waitForSelector = async (selector: string, options?: { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + strict?: boolean; + timeout?: number; + }) => { + const el = await origFrameWaitForSelector(selector, options ?? {}); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} diff --git a/src/browser/humanizer/index.ts b/src/browser/humanizer/index.ts new file mode 100644 index 00000000..28d1a702 --- /dev/null +++ b/src/browser/humanizer/index.ts @@ -0,0 +1,935 @@ +/** + * Human-like behavioral layer for cloakbrowser (JS/TS). + * + * Activated via humanize: true in launch() / launchContext(). + * Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. + * + * Stealth-aware (fixes #110): + * - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate + * - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events + * - Falls back to page.evaluate only when CDP session is unavailable + * + * Patches all interaction methods: + * click, dblclick, hover, type, fill, check, uncheck, selectOption, + * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. + * + * ELEMENTHANDLE-LEVEL: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * page.$(), page.$$(), page.waitForSelector() and Frame equivalents + * return patched ElementHandles automatically. + */ + +import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; +import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js'; +import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +import { humanType } from './keyboard.js'; +import { scrollToElement, humanScrollIntoView } from './scroll.js'; +import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js'; +import { + ensureActionable, ensureStable, checkPointerEvents, + CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, + type CheckName, +} from './actionability.js'; + +export { HumanConfig, resolveConfig, mergeConfig } from './config.js'; +export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +export { humanType } from './keyboard.js'; +export { scrollToElement, humanScrollIntoView } from './scroll.js'; +export { patchSingleElementHandle } from './elementhandle.js'; + +// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + +// ============================================================================ +// CDP Isolated World — stealth DOM evaluation +// ============================================================================ + +/** + * Manages a CDP isolated execution context for DOM reads. + * Produces clean Error.stack traces (no 'eval at evaluate :302:') + * and is invisible to querySelector monkey-patches in the main world. + * + * Context ID is invalidated on navigation and auto-recreated on next call. + */ +class StealthEval { + private cdp: CDPSession | null = null; + private contextId: number | null = null; + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + private async ensureCdp(): Promise { + if (!this.cdp) { + this.cdp = await this.page.context().newCDPSession(this.page); + } + return this.cdp; + } + + private async createWorld(): Promise { + const cdp = await this.ensureCdp(); + const tree = await cdp.send('Page.getFrameTree'); + const frameId = tree.frameTree.frame.id; + const result = await cdp.send('Page.createIsolatedWorld', { + frameId, + worldName: '', + grantUniveralAccess: true, + }); + const ctxId = result.executionContextId; + this.contextId = ctxId; + return ctxId; + } + + /** + * Evaluate a JS expression in the isolated world. + * Auto-recreates the world if the context was invalidated (navigation). + * Returns the result value, or undefined on failure. + */ + async evaluate(expression: string): Promise { + if (this.contextId === null) { + await this.createWorld(); + } + + for (let attempt = 0; attempt < 2; attempt++) { + try { + const cdp = await this.ensureCdp(); + const result = await cdp.send('Runtime.evaluate', { + expression, + contextId: this.contextId!, + returnByValue: true, + }); + + if (result.exceptionDetails) { + // Context was likely invalidated by navigation + if (attempt === 0) { + await this.createWorld(); + continue; + } + return undefined; + } + + return result.result?.value; + } catch { + if (attempt === 0) { + this.contextId = null; + try { + await this.createWorld(); + } catch { + return undefined; + } + continue; + } + return undefined; + } + } + return undefined; + } + + /** Mark context as stale — call after navigation. */ + invalidate(): void { + this.contextId = null; + } + + /** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */ + async getCdpSession(): Promise { + return this.ensureCdp(); + } +} + + +// ============================================================================ +// Cursor state +// ============================================================================ + +class CursorState { + x = 0; + y = 0; + initialized = false; +} + +export function createCursorState(): CursorState { + return new CursorState(); +} + + +// ============================================================================ +// Stealth DOM queries — isolated world with evaluate fallback +// ============================================================================ + +/** + * Check if selector matches an input/textarea/contenteditable element. + * Uses CDP Isolated World when available — invisible to main world. + */ +async function isInputElement( + stealth: StealthEval | null, + page: Page, + selector: string, +): Promise { + if (stealth) { + try { + const escaped = JSON.stringify(selector); + const result = await stealth.evaluate(` + (() => { + const el = document.querySelector(${escaped}); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + })() + `); + return !!result; + } catch { + // Fall through to page.evaluate + } + } + + // Fallback: page.evaluate (detectable — should only happen if CDP fails) + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }, selector).catch(() => false); +} + +/** + * Check if the element matching selector is currently focused. + * Uses CDP Isolated World when available — invisible to main world. + */ +async function isSelectorFocused( + stealth: StealthEval | null, + page: Page, + selector: string, +): Promise { + if (stealth) { + try { + const escaped = JSON.stringify(selector); + const result = await stealth.evaluate(` + (() => { + const el = document.querySelector(${escaped}); + return el === document.activeElement; + })() + `); + return !!result; + } catch { + // Fall through to page.evaluate + } + } + + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }, selector).catch(() => false); +} + + +// ============================================================================ +// Page-level patching +// ============================================================================ + +/** + * Replace page methods with human-like implementations. + */ +export function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { + const originals = { + click: page.click.bind(page), + dblclick: page.dblclick.bind(page), + hover: page.hover.bind(page), + type: page.type.bind(page), + fill: page.fill.bind(page), + check: page.check.bind(page), + uncheck: page.uncheck.bind(page), + selectOption: page.selectOption.bind(page), + press: page.press.bind(page), + goto: page.goto.bind(page), + isChecked: page.isChecked.bind(page), + mouseMove: page.mouse.move.bind(page.mouse), + mouseClick: page.mouse.click.bind(page.mouse), + mouseDblclick: page.mouse.dblclick.bind(page.mouse), + mouseWheel: page.mouse.wheel.bind(page.mouse), + mouseDown: page.mouse.down.bind(page.mouse), + mouseUp: page.mouse.up.bind(page.mouse), + keyboardType: page.keyboard.type.bind(page.keyboard), + keyboardDown: page.keyboard.down.bind(page.keyboard), + keyboardUp: page.keyboard.up.bind(page.keyboard), + keyboardPress: page.keyboard.press.bind(page.keyboard), + keyboardInsertText: page.keyboard.insertText.bind(page.keyboard), + }; + + (page as any)._original = originals; + (page as any)._humanCfg = cfg; + + // --- Stealth infrastructure --- + const stealth = new StealthEval(page); + (page as any)._stealth = stealth; + + // CDP session for shift symbol typing (lazy-initialized, reuses stealth's session) + let cdpSession: CDPSession | null = null; + const ensureCdp = async (): Promise => { + if (!cdpSession) { + try { + cdpSession = await stealth.getCdpSession(); + } catch {} + } + return cdpSession; + }; + + const raw: RawMouse = { + move: originals.mouseMove, + down: originals.mouseDown, + up: originals.mouseUp, + wheel: originals.mouseWheel, + }; + + const rawKb: RawKeyboard = { + down: originals.keyboardDown, + up: originals.keyboardUp, + type: originals.keyboardType, + insertText: originals.keyboardInsertText, + }; + + async function ensureCursorInit(): Promise { + if (!cursor.initialized) { + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]); + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]); + await originals.mouseMove(cursor.x, cursor.y); + cursor.initialized = true; + } + } + + // --- goto (invalidate isolated world on navigation) --- + const humanGoto = async (url: string, options?: { + referer?: string; + timeout?: number; + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; + }) => { + const response = await originals.goto(url, options); + stealth.invalidate(); + patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); + return response; + }; + + // --- click --- + const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const skipChecks = (options as any)?._skipChecks ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force && !skipChecks) { + await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force); + } + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(stealth, page, selector); + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, isInput, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + await humanClick(raw, isInput, callCfg); + }; + + // --- dblclick --- + const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(stealth, page, selector); + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, isInput, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- hover --- + const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => { + await ensureCursorInit(); + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const skipChecks = (options as any)?._skipChecks ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs()); + cursor.x = cursorX; + cursor.y = cursorY; + let finalBox = box; + if (!force && didScroll) { + await ensureStable(page, selector, remainingMs()); + finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; + } + const target = clickTarget(finalBox, false, callCfg); + if (!force) { + await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs()); + } + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + }; + + // --- type --- + const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force); + await sleep(randRange(callCfg.field_switch_delay)); + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 250)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, callCfg, cdp); + }; + + // --- fill (clears existing content first) --- + const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force); + await sleep(randRange(callCfg.field_switch_delay)); + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, value, callCfg, cdp); + }; + + // --- clear --- + const humanClearFn = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + // --- check --- + const humanCheckFn = async (selector: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const checked = await originals.isChecked(selector).catch(() => false); + if (!checked) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + }; + + // --- uncheck --- + const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + const checked = await originals.isChecked(selector).catch(() => true); + if (checked) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + }; + + // --- selectOption --- + const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + await sleep(rand(100, 300)); + return originals.selectOption(selector, values, options); + }; + + // --- press (checks focus first — avoids redundant mouse moves) --- + const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(key); + }; + + // --- pressSequentially --- + const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + const timeout = options?.timeout ?? 30000; + const force = options?.force ?? false; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + + if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force); + if (!await isSelectorFocused(stealth, page, selector)) { + await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any); + } + await sleep(rand(100, 250)); + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, callCfg, cdp); + }; + + // --- tap --- + const humanTapFn = async (selector: string, options?: HumanActionOptions) => { + await humanClickFn(selector, options); + }; + + // Assign page-level patches + (page as any).goto = humanGoto; + (page as any).click = humanClickFn; + (page as any).dblclick = humanDblclickFn; + (page as any).hover = humanHoverFn; + (page as any).type = humanTypeFn; + (page as any).fill = humanFillFn; + (page as any).check = humanCheckFn; + (page as any).uncheck = humanUncheckFn; + (page as any).selectOption = humanSelectOptionFn; + (page as any).press = humanPressFn; + (page as any).pressSequentially = humanPressSequentiallyFn; + (page as any).tap = humanTapFn; + (page as any).clear = humanClearFn; + + // --- mouse patches --- + page.mouse.move = async (x: number, y: number, options?: { + steps?: number; + }) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + }; + + page.mouse.click = async (x: number, y: number, options?: { + button?: 'left' | 'right' | 'middle'; + clickCount?: number; + delay?: number; + }) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + await humanClick(raw, false, cfg); + }; + + // --- keyboard patches --- + page.keyboard.type = async (text: string, options?: { delay?: number }) => { + const cdp = await ensureCdp(); + await humanType(page, rawKb, text, cfg, cdp); + }; + + // Store helpers for frame patching + (page as any)._humanCursor = cursor; + (page as any)._humanRaw = raw; + (page as any)._humanRawKb = rawKb; + (page as any)._humanOriginals = originals; + (page as any)._humanClickFn = humanClickFn; + (page as any)._humanHoverFn = humanHoverFn; + (page as any)._humanClearFn = humanClearFn; + (page as any)._humanPressFn = humanPressFn; + (page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn; + (page as any)._humanTapFn = humanTapFn; + (page as any)._ensureCursorInit = ensureCursorInit; + + // Initialize cursor immediately so it doesn't visibly jump from (0,0) + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]); + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]); + originals.mouseMove(cursor.x, cursor.y).then(() => { + cursor.initialized = true; + }).catch(() => {}); + + // --- Patch Frame-level methods (for sub-frames) --- + patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); + + // --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) --- + patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth); +} + + +// ============================================================================ +// Frame-level patching +// ============================================================================ + +/** + * Patch Frame methods so Locator-based calls go through humanization. + * All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck, + * selectOption, press, pressSequentially, tap, clear, dragAndDrop. + */ +function patchFrames( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: StealthEval, +): void { + for (const frame of iterFrames(page)) { + patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth); + // Patch frame-level ElementHandle selectors ($, $$, waitForSelector) + patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth); + } +} + +function firstFrameLocator(frame: Frame, selector: string): any { + const locator = frame.locator(selector) as any; + return typeof locator.first === 'function' ? locator.first() : locator; +} + +async function isFrameInputElement(frame: Frame, selector: string): Promise { + return firstFrameLocator(frame, selector).evaluate((el: Element) => { + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }).catch(() => false); +} + +async function isFrameSelectorFocused(frame: Frame, selector: string): Promise { + return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement) + .catch(() => false); +} + +function patchSingleFrame( + frame: Frame, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: StealthEval, +): void { + if ((frame as any)._humanPatched) return; + (frame as any)._humanPatched = true; + + // Save originals for methods that need fallback + const origFrameClick = frame.click.bind(frame); + const origFrameDblclick = frame.dblclick.bind(frame); + const origFrameHover = frame.hover.bind(frame); + const origFrameType = frame.type.bind(frame); + const origFrameFill = frame.fill.bind(frame); + const origFrameCheck = frame.check.bind(frame); + const origFrameUncheck = frame.uncheck.bind(frame); + const origFrameSelectOption = frame.selectOption.bind(frame); + const origFramePress = frame.press.bind(frame); + const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame); + const origFrameTap = (frame as any).tap?.bind(frame); + const origFrameDragAndDrop = frame.dragAndDrop.bind(frame); + + const moveToFrameSelector = async ( + selector: string, + options: HumanActionOptions | undefined, + inputBias: boolean, + remainingMs: () => number, + ) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + if (callCfg.idle_between_actions) { + await humanIdle(raw, cursor.x, cursor.y, callCfg); + } + + const locator = firstFrameLocator(frame, selector); + if (typeof locator.scrollIntoViewIfNeeded === 'function') { + await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined); + } + const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null); + if (!box) return null; + + const isInput = inputBias || await isFrameInputElement(frame, selector); + const target = clickTarget(box, isInput, callCfg); + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg); + cursor.x = target.x; + cursor.y = target.y; + return { callCfg, isInput }; + }; + + const frameClick = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + await humanClick(raw, moved.isInput, moved.callCfg); + }; + + const getFrameCdp = async () => stealth.getCdpSession().catch(() => null); + + const frameHover = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + }; + + (frame as any).click = frameClick; + + (frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(0, deadline - Date.now()); + const moved = await moveToFrameSelector(selector, options, false, remainingMs); + if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) }); + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + (frame as any).hover = frameHover; + + (frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + await sleep(randRange(callCfg.field_switch_delay)); + await frameClick(selector, options); + await sleep(rand(100, 250)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options)); + }; + + (frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + await sleep(randRange(callCfg.field_switch_delay)); + await frameClick(selector, options); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options)); + }; + + (frame as any).check = async (selector: string, options?: HumanActionOptions) => { + const locator = firstFrameLocator(frame, selector); + if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options); + const checked = await locator.isChecked(); + if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options)); + }; + + (frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => { + const locator = firstFrameLocator(frame, selector); + if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options); + const checked = await locator.isChecked(); + if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options)); + }; + + (frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => { + await frameHover(selector, options); + await sleep(rand(100, 300)); + return origFrameSelectOption(selector, values, options); + }; + + (frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => { + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(key); + }; + + (frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => { + const callCfg = mergeConfig(cfg, options?.human_config ?? options); + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(100, 250)); + const cdp = await getFrameCdp(); + await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options)); + }; + + (frame as any).tap = async (selector: string, options?: HumanActionOptions) => { + await frameClick(selector, options).catch(() => origFrameTap?.(selector, options)); + }; + + (frame as any).clear = async (selector: string, options?: HumanActionOptions) => { + if (!await isFrameSelectorFocused(frame, selector)) { + await frameClick(selector, options); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + (frame as any).dragAndDrop = async (source: string, target: string, options?: { + force?: boolean; + noWaitAfter?: boolean; + sourcePosition?: { x: number; y: number }; + strict?: boolean; + targetPosition?: { x: number; y: number }; + timeout?: number; + trial?: boolean; + }) => { + const timeout = options?.timeout ?? 30000; + const deadline = Date.now() + timeout; + const remainingMs = () => Math.max(1, deadline - Date.now()); + const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null); + const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null); + + if (srcBox && tgtBox) { + const sx = srcBox.x + srcBox.width / 2; + const sy = srcBox.y + srcBox.height / 2; + const tx = tgtBox.x + tgtBox.width / 2; + const ty = tgtBox.y + tgtBox.height / 2; + + await page.mouse.move(sx, sy); + await sleep(rand(100, 200)); + await originals.mouseDown(); + await sleep(rand(80, 150)); + await page.mouse.move(tx, ty); + await sleep(rand(80, 150)); + await originals.mouseUp(); + } else { + return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) }); + } + }; +} + + +function* iterFrames(page: Page): Generator { + try { + const mainFrame = page.mainFrame(); + yield mainFrame; + for (const child of mainFrame.childFrames()) { + yield child; + } + } catch {} +} + + +// ============================================================================ +// Context-level patching +// ============================================================================ + +function patchContext(context: BrowserContext, cfg: HumanConfig): void { + const cursor = new CursorState(); + for (const page of context.pages()) { + patchPage(page, cfg, cursor); + } + context.on('page', (page: Page) => { + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + }); + + const origNewPage = context.newPage.bind(context); + (context as any).newPage = async () => { + const page = await origNewPage(); + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + + +// ============================================================================ +// Browser-level patching +// ============================================================================ + +function patchBrowser(browser: Browser, cfg: HumanConfig): void { + for (const context of browser.contexts()) { + patchContext(context, cfg); + } + + const origNewContext = browser.newContext.bind(browser); + (browser as any).newContext = async (options?: Parameters[0]) => { + const context = await origNewContext(options); + patchContext(context, cfg); + return context; + }; + + const origNewPage = browser.newPage.bind(browser); + (browser as any).newPage = async (options?: Parameters[0]) => { + const page = await origNewPage(options); + if (!(page as any)._original) { + const ctx = page.context(); + if (!(ctx as any)._humanPatched) { + patchContext(ctx, cfg); + (ctx as any)._humanPatched = true; + } + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + +export { humanizePage } from './page.js'; diff --git a/src/browser/humanizer/keyboard.ts b/src/browser/humanizer/keyboard.ts new file mode 100644 index 00000000..cb1d6ff5 --- /dev/null +++ b/src/browser/humanizer/keyboard.ts @@ -0,0 +1,214 @@ +/** + * cloakbrowser-human — Human-like keyboard input. + * + * Stealth-aware: when a CDPSession is provided, shift symbols are typed + * via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace). + * Falls back to page.evaluate when no CDPSession is available. + */ + +import type { Page, CDPSession } from 'playwright-core'; +import { RawKeyboard } from './mouse.js'; +import { HumanConfig, rand, randRange, sleep } from './config.js'; + +const SHIFT_SYMBOLS = new Set([ + '@', '#', '!', '$', '%', '^', '&', '*', '(', ')', + '_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~', +]); + +const NEARBY_KEYS: Record = { + a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf', + f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm', + k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp', + p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy', + u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu', + z: 'asx', + '1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt', + '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', +}; + +/** + * CDP key code for each shift symbol's physical key. + * Used by Input.dispatchKeyEvent to produce isTrusted=true events. + */ +const SHIFT_SYMBOL_CODES: Record = { + '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', + '%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', + '(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal', + '{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash', + ':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period', + '?': 'Slash', '~': 'Backquote', +}; + +/** + * Windows virtual key codes for shift symbols. + * Input.dispatchKeyEvent uses these to match real keyboard behavior. + */ +const SHIFT_SYMBOL_KEYCODES: Record = { + '!': 49, '@': 50, '#': 51, '$': 52, '%': 53, + '^': 54, '&': 55, '*': 56, '(': 57, ')': 48, + '_': 189, '+': 187, '{': 219, '}': 221, '|': 220, + ':': 186, '"': 222, '<': 188, '>': 190, '?': 191, + '~': 192, +}; + +function isAscii(ch: string): boolean { + const code = ch.codePointAt(0); + return code !== undefined && code < 128; +} + +function getNearbyKey(ch: string): string { + const lower = ch.toLowerCase(); + if (lower in NEARBY_KEYS) { + const neighbors = NEARBY_KEYS[lower]; + const wrong = neighbors[Math.floor(Math.random() * neighbors.length)]; + return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong; + } + return ch; +} + +function isUpperCase(ch: string): boolean { + return ch.length === 1 && ch >= 'A' && ch <= 'Z'; +} + +/** + * Type text with human-like per-character timing, mistype simulation, + * and realistic shift handling. + * + * @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent + * producing isTrusted=true events with no evaluate stack trace. + * If null/undefined, falls back to page.evaluate (detectable). + */ +export async function humanType( + page: Page, + raw: RawKeyboard, + text: string, + cfg: HumanConfig, + cdpSession?: CDPSession | null, +): Promise { + const chars = [...text]; // Handle emoji surrogate pairs correctly + + for (let i = 0; i < chars.length; i++) { + const ch = chars[i]; + + // Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText + if (!isAscii(ch)) { + await sleep(randRange(cfg.key_hold)); + await raw.insertText(ch); + if (i < chars.length - 1) { + await interCharDelay(cfg); + } + continue; + } + + // Mistype chance — only for ASCII alphanumeric + if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) { + const wrong = getNearbyKey(ch); + await typeNormalChar(raw, wrong, cfg); + await sleep(randRange(cfg.mistype_delay_notice)); + await raw.down('Backspace'); + await sleep(randRange(cfg.key_hold)); + await raw.up('Backspace'); + await sleep(randRange(cfg.mistype_delay_correct)); + } + + if (isUpperCase(ch)) { + await typeShiftedChar(raw, ch, cfg); + } else if (SHIFT_SYMBOLS.has(ch)) { + await typeShiftSymbol(page, raw, ch, cfg, cdpSession); + } else { + await typeNormalChar(raw, ch, cfg); + } + + if (i < chars.length - 1) { + await interCharDelay(cfg); + } + } +} + +async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); +} + +async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); +} + +/** + * Type a shift symbol character. + * + * Stealth path (cdpSession provided): + * Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack. + * + * Fallback path (no cdpSession): + * Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent. + * Detectable via isTrusted=false and evaluate stack frame. + */ +async function typeShiftSymbol( + page: Page, + raw: RawKeyboard, + ch: string, + cfg: HumanConfig, + cdpSession?: CDPSession | null, +): Promise { + if (cdpSession) { + // --- Stealth path: CDP Input.dispatchKeyEvent --- + const code = SHIFT_SYMBOL_CODES[ch] || ''; + const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0; + + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + + await cdpSession.send('Input.dispatchKeyEvent', { + type: 'keyDown', + modifiers: 8, // Shift modifier flag + key: ch, + code, + windowsVirtualKeyCode: keyCode, + text: ch, + unmodifiedText: ch, + }); + await sleep(randRange(cfg.key_hold)); + + await cdpSession.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: 8, + key: ch, + code, + windowsVirtualKeyCode: keyCode, + }); + + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); + } else { + // --- Fallback path: page.evaluate (detectable) --- + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.insertText(ch); + await page.evaluate((key: string) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }, ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); + } +} + +async function interCharDelay(cfg: HumanConfig): Promise { + if (Math.random() < cfg.typing_pause_chance) { + await sleep(randRange(cfg.typing_pause_range)); + } else { + const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread; + await sleep(Math.max(10, delay)); + } +} diff --git a/src/browser/humanizer/mouse.ts b/src/browser/humanizer/mouse.ts new file mode 100644 index 00000000..277f0ac1 --- /dev/null +++ b/src/browser/humanizer/mouse.ts @@ -0,0 +1,213 @@ +/** + * cloakbrowser-human — Human-like mouse movement and clicking. + */ + +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; + +// --------------------------------------------------------------------------- +// Raw interface — original Playwright methods, bypassing the wrapper +// --------------------------------------------------------------------------- + +export interface RawMouse { + move: (x: number, y: number) => Promise; + down: (options?: any) => Promise; + up: (options?: any) => Promise; + wheel: (deltaX: number, deltaY: number) => Promise; +} + +export interface RawKeyboard { + down: (key: string) => Promise; + up: (key: string) => Promise; + type: (text: string) => Promise; + insertText: (text: string) => Promise; +} + +// --------------------------------------------------------------------------- +// Easing +// --------------------------------------------------------------------------- + +function easeInOut(t: number): number { + return t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// --------------------------------------------------------------------------- +// Bezier +// --------------------------------------------------------------------------- + +interface Point { + x: number; + y: number; +} + +function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + }; +} + +function randomControlPoints(start: Point, end: Point): [Point, Point] { + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.hypot(dx, dy); + const px = -dy / (dist || 1); + const py = dx / (dist || 1); + const bias1 = rand(-0.3, 0.3) * dist; + const bias2 = rand(-0.3, 0.3) * dist; + return [ + { x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 }, + { x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 }, + ]; +} + +// --------------------------------------------------------------------------- +// Human mouse movement +// --------------------------------------------------------------------------- + +export async function humanMove( + raw: RawMouse, + startX: number, + startY: number, + endX: number, + endY: number, + cfg: HumanConfig, +): Promise { + const dist = Math.hypot(endX - startX, endY - startY); + if (dist < 1) return; + + const steps = Math.max( + cfg.mouse_min_steps, + Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)), + ); + + const start: Point = { x: startX, y: startY }; + const end: Point = { x: endX, y: endY }; + const [cp1, cp2] = randomControlPoints(start, end); + + let burstCounter = 0; + const burstSize = randIntRange(cfg.mouse_burst_size); + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const easedT = easeInOut(progress); + const pt = bezier(start, cp1, cp2, end, easedT); + + const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max; + const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp; + const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp; + + await raw.move(Math.round(wx), Math.round(wy)); + + burstCounter++; + if (burstCounter >= burstSize && i < steps) { + await sleep(randRange(cfg.mouse_burst_pause)); + burstCounter = 0; + } + } + + if (Math.random() < cfg.mouse_overshoot_chance) { + const overshootDist = randRange(cfg.mouse_overshoot_px); + const angle = Math.atan2(endY - startY, endX - startX); + const ovX = Math.round(endX + Math.cos(angle) * overshootDist); + const ovY = Math.round(endY + Math.sin(angle) * overshootDist); + await raw.move(ovX, ovY); + await sleep(rand(30, 70)); + const corrX = Math.round(endX + (Math.random() - 0.5) * 4); + const corrY = Math.round(endY + (Math.random() - 0.5) * 4); + await raw.move(corrX, corrY); + } +} + +// --------------------------------------------------------------------------- +// Human click +// --------------------------------------------------------------------------- + +export function clickTarget( + box: { x: number; y: number; width: number; height: number }, + isInput: boolean, + cfg: HumanConfig, +): Point { + if (isInput) { + const xFrac = randRange(cfg.click_input_x_range); + const yFrac = rand(0.30, 0.70); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; + } + const xFrac = rand(0.35, 0.65); + const yFrac = rand(0.35, 0.65); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; +} + +export async function humanClick( + raw: RawMouse, + isInput: boolean, + cfg: HumanConfig, +): Promise { + const aimDelay = isInput + ? randRange(cfg.click_aim_delay_input) + : randRange(cfg.click_aim_delay_button); + await sleep(aimDelay); + + const holdTime = isInput + ? randRange(cfg.click_hold_input) + : randRange(cfg.click_hold_button); + await raw.down(); + await sleep(holdTime); + await raw.up(); +} + +// --------------------------------------------------------------------------- +// Human idle / drift +// --------------------------------------------------------------------------- + +export function humanIdle( + raw: RawMouse, + cx: number, + cy: number, + cfg: HumanConfig, +): Promise; +export function humanIdle( + raw: RawMouse, + seconds: number, + cx: number, + cy: number, + cfg: HumanConfig, +): Promise; +export async function humanIdle( + raw: RawMouse, + secondsOrCx: number, + cxOrCy: number, + cyOrCfg: number | HumanConfig, + maybeCfg?: HumanConfig, +): Promise { + const hasExplicitSeconds = maybeCfg !== undefined; + const seconds = hasExplicitSeconds + ? secondsOrCx + : rand((cyOrCfg as HumanConfig).idle_between_duration[0], (cyOrCfg as HumanConfig).idle_between_duration[1]); + const cx = hasExplicitSeconds ? cxOrCy : secondsOrCx; + const cy = hasExplicitSeconds ? (cyOrCfg as number) : cxOrCy; + const cfg = hasExplicitSeconds ? maybeCfg! : (cyOrCfg as HumanConfig); + const endTime = Date.now() + seconds * 1000; + let x = cx; + let y = cy; + while (Date.now() < endTime) { + const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + x += dx; + y += dy; + await raw.move(Math.round(x), Math.round(y)); + await sleep(randRange(cfg.idle_pause_range)); + } +} diff --git a/src/browser/humanizer/page.test.ts b/src/browser/humanizer/page.test.ts new file mode 100644 index 00000000..54a06323 --- /dev/null +++ b/src/browser/humanizer/page.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as humanizer from './index.js'; +import { humanizePage } from './page.js'; + +function fakePage() { + const browser = { + contexts: vi.fn(), + newContext: vi.fn(), + newPage: vi.fn(), + }; + const context = { + browser: vi.fn(() => browser), + pages: vi.fn(), + on: vi.fn(), + newPage: vi.fn(), + newCDPSession: vi.fn(), + }; + const page = { + context: vi.fn(() => context), + mainFrame: vi.fn(() => { throw new Error('no frames'); }), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + pressSequentially: vi.fn(), + tap: vi.fn(), + clear: vi.fn(), + goto: vi.fn(), + isChecked: vi.fn(), + $: vi.fn(), + $$: vi.fn(), + waitForSelector: vi.fn(), + mouse: { + move: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + wheel: vi.fn(), + down: vi.fn(), + up: vi.fn(), + }, + keyboard: { + type: vi.fn(), + down: vi.fn(), + up: vi.fn(), + press: vi.fn(), + insertText: vi.fn(), + }, + }; + return { browser, context, page }; +} + +describe('humanizePage', () => { + it('patches only the supplied Page once without touching its context, browser, or other pages', () => { + const owned = fakePage(); + const human = fakePage(); + const actionMethods = ['click', 'dblclick', 'hover', 'type', 'fill', 'check', 'uncheck', 'selectOption', 'press', 'pressSequentially', 'tap', 'clear'] as const; + const original = { + ownedPrototype: Object.getPrototypeOf(owned.page), + unrelatedPrototype: Object.getPrototypeOf(human.page), + actions: Object.fromEntries(actionMethods.map(method => [method, owned.page[method]])), + mouse: { move: owned.page.mouse.move, click: owned.page.mouse.click }, + keyboard: { type: owned.page.keyboard.type }, + contextPages: owned.context.pages, + browserContexts: owned.browser.contexts, + unrelatedActions: Object.fromEntries(actionMethods.map(method => [method, human.page[method]])), + unrelatedMouse: { move: human.page.mouse.move, click: human.page.mouse.click }, + unrelatedKeyboard: { type: human.page.keyboard.type }, + }; + + expect(humanizePage(owned.page as any)).toBe(owned.page); + for (const method of actionMethods) expect(owned.page[method]).not.toBe(original.actions[method]); + expect(owned.page.mouse.move).not.toBe(original.mouse.move); + expect(owned.page.mouse.click).not.toBe(original.mouse.click); + expect(owned.page.keyboard.type).not.toBe(original.keyboard.type); + expect(Object.getPrototypeOf(owned.page)).toBe(original.ownedPrototype); + expect(owned.context.pages).toBe(original.contextPages); + expect(owned.browser.contexts).toBe(original.browserContexts); + expect(owned.context.pages).not.toHaveBeenCalled(); + expect(owned.context.on).not.toHaveBeenCalled(); + expect(owned.context.newPage).not.toHaveBeenCalled(); + expect(owned.browser.contexts).not.toHaveBeenCalled(); + expect(owned.browser.newContext).not.toHaveBeenCalled(); + expect(owned.browser.newPage).not.toHaveBeenCalled(); + for (const method of actionMethods) expect(human.page[method]).toBe(original.unrelatedActions[method]); + expect(human.page.mouse.move).toBe(original.unrelatedMouse.move); + expect(human.page.mouse.click).toBe(original.unrelatedMouse.click); + expect(human.page.keyboard.type).toBe(original.unrelatedKeyboard.type); + expect(Object.getPrototypeOf(human.page)).toBe(original.unrelatedPrototype); + + const patched = { click: owned.page.click, mouseMove: owned.page.mouse.move, keyboardType: owned.page.keyboard.type }; + expect(humanizePage(owned.page as any)).toBe(owned.page); + expect(owned.page.click).toBe(patched.click); + expect(owned.page.mouse.move).toBe(patched.mouseMove); + expect(owned.page.keyboard.type).toBe(patched.keyboardType); + }); + + it('does not export context or browser patch helpers', () => { + expect(humanizer).not.toHaveProperty('patchContext'); + expect(humanizer).not.toHaveProperty('patchBrowser'); + }); +}); diff --git a/src/browser/humanizer/page.ts b/src/browser/humanizer/page.ts new file mode 100644 index 00000000..cd7684ce --- /dev/null +++ b/src/browser/humanizer/page.ts @@ -0,0 +1,15 @@ +/** + * Page-only facade for the CloakHQ humanizer vendored from cloakbrowser@0.4.5 + * at git commit 5176971f45d02845d3d1c0adbbda0bc93addf747. See NOTICE. + */ +import type { Page } from 'playwright-core'; +import { createCursorState, patchPage, resolveConfig, type HumanConfig } from './index.js'; + +const humanizedPages = new WeakSet(); + +export function humanizePage(page: Page, config?: Partial): Page { + if (humanizedPages.has(page)) return page; + patchPage(page, resolveConfig('default', config), createCursorState()); + humanizedPages.add(page); + return page; +} diff --git a/src/browser/humanizer/scroll.ts b/src/browser/humanizer/scroll.ts new file mode 100644 index 00000000..d9594693 --- /dev/null +++ b/src/browser/humanizer/scroll.ts @@ -0,0 +1,190 @@ +/** + * cloakbrowser-human — Human-like scrolling via mouse wheel events. + */ + +import type { Page } from 'playwright-core'; +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; +import { RawMouse, humanMove } from './mouse.js'; + +interface ElementBounds { + x: number; + y: number; + width: number; + height: number; +} + +function isInViewport( + bounds: ElementBounds, + viewportHeight: number, + cfg: HumanConfig, +): boolean { + const topEdge = bounds.y; + const bottomEdge = bounds.y + bounds.height; + const zoneTop = viewportHeight * cfg.scroll_target_zone[0]; + const zoneBottom = viewportHeight * cfg.scroll_target_zone[1]; + return topEdge >= zoneTop && bottomEdge <= zoneBottom; +} + +async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise { + const absD = Math.abs(delta); + const sign = delta > 0 ? 1 : -1; + let sent = 0; + while (sent < absD) { + const stepSize = rand(20, 40); + const chunk = Math.min(stepSize, absD - sent); + await raw.wheel(0, Math.round(chunk) * sign); + sent += chunk; + await sleep(rand(8, 20)); + } +} + +/** + * Humanized scrolling that takes an arbitrary ``getBox`` callable. + * + * Used by both ``scrollToElement`` (selector-based) and the ElementHandle + * ``scrollIntoViewIfNeeded`` patch so the same accelerate → cruise → + * decelerate → overshoot behavior runs everywhere. + */ +export async function humanScrollIntoView( + page: Page, + raw: RawMouse, + getBox: () => Promise, + cursorX: number, + cursorY: number, + cfg: HumanConfig, +): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> { + // Headed launches default to no_viewport so the page tracks the real OS + // window; page.viewportSize() is then null. Fall back to the live window + // dimensions so humanize works headed (the stealth-relevant mode). + let viewport = page.viewportSize(); + if (!viewport) { + viewport = await page.evaluate( + () => ({ width: window.innerWidth, height: window.innerHeight }), + ); + } + if (!viewport || !viewport.height) throw new Error('Viewport size not available'); + + let box = await getBox(); + if (!box) throw new Error('Element not found while scrolling into view'); + + if (isInViewport(box, viewport.height, cfg)) { + return { box, cursorX, cursorY, didScroll: false }; + } + + // Move cursor into scroll area + const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7)); + const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7)); + await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg); + cursorX = scrollAreaX; + cursorY = scrollAreaY; + await sleep(randRange(cfg.scroll_pre_move_delay)); + + // Calculate scroll distance + const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]); + const elementCenter = box.y + box.height / 2; + const distanceToScroll = elementCenter - targetY; + + const direction = distanceToScroll > 0 ? 1 : -1; + const absDistance = Math.abs(distanceToScroll); + const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2; + const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta)); + const accelSteps = randIntRange(cfg.scroll_accel_steps); + const decelSteps = randIntRange(cfg.scroll_decel_steps); + + let scrolled = 0; + + // Scroll loop: accelerate → cruise → decelerate + for (let i = 0; i < totalClicks; i++) { + let delta: number; + let pause: number; + + if (i < accelSteps) { + delta = rand(80, 100); + pause = randRange(cfg.scroll_pause_slow); + } else if (i >= totalClicks - decelSteps) { + delta = rand(60, 90); + pause = randRange(cfg.scroll_pause_slow); + } else { + delta = randRange(cfg.scroll_delta_base); + pause = randRange(cfg.scroll_pause_fast); + } + + delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance; + delta = Math.round(delta) * direction; + + await smoothWheel(raw, delta, cfg); + scrolled += Math.abs(delta); + await sleep(pause); + + // Check visibility every 3 steps + if (i % 3 === 2 || i === totalClicks - 1) { + box = await getBox(); + if (box && isInViewport(box, viewport.height, cfg)) { + break; + } + } + + if (scrolled >= absDistance * 1.1) break; + } + + // Optional overshoot + correction + if (Math.random() < cfg.scroll_overshoot_chance) { + const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction; + await smoothWheel(raw, overshootPx, cfg); + await sleep(randRange(cfg.scroll_settle_delay)); + + const corrections = randIntRange([1, 2]); + for (let c = 0; c < corrections; c++) { + const corrDelta = Math.round(rand(40, 80)) * -direction; + await smoothWheel(raw, corrDelta, cfg); + await sleep(rand(100, 250)); + } + } + + // Settle + await sleep(randRange(cfg.scroll_settle_delay)); + + box = await getBox(); + if (!box) throw new Error('Element lost after scrolling into view'); + + return { box, cursorX, cursorY, didScroll: true }; +} + +/** + * Selector-based humanized scroll. + * + * ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so + * callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for + * slow-loading elements (#172). Default matches Playwright's 30000ms when not specified. + * + * Returns `{ box, cursorX, cursorY, didScroll }`. + */ +export async function scrollToElement( + page: Page, + raw: RawMouse, + selector: string, + cursorX: number, + cursorY: number, + cfg: HumanConfig, + timeout?: number, +): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> { + return humanScrollIntoView( + page, raw, + () => getElementBox(page, selector, timeout), + cursorX, cursorY, cfg, + ); +} + +async function getElementBox( + page: Page, + selector: string, + timeout: number = 30000, +): Promise { + const el = page.locator(selector).first(); + try { + const box = await el.boundingBox({ timeout: Math.max(1, timeout) }); + return box; + } catch { + return null; + } +} diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index 504e4ba0..9feaa1d1 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -19,6 +19,36 @@ function fakeAttachedProfile() { const page: any = { url: vi.fn(() => url), title: vi.fn().mockResolvedValue('Page'), + context: vi.fn(() => context), + mainFrame: vi.fn(() => { throw new Error('no frames'); }), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + isChecked: vi.fn(), + $: vi.fn(), + $$: vi.fn(), + waitForSelector: vi.fn(), + mouse: { + move: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + wheel: vi.fn(), + down: vi.fn(), + up: vi.fn(), + }, + keyboard: { + type: vi.fn(), + down: vi.fn(), + up: vi.fn(), + press: vi.fn(), + insertText: vi.fn(), + }, goto: vi.fn().mockResolvedValue(undefined), bringToFront: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue(undefined), @@ -129,6 +159,9 @@ describe('SlabSessionManager ownership', () => { expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); expect(manager.pageIdFor(attached.humanPage)).toBeUndefined(); expect(manager.pageIdFor(lease.page)).toBe(lease.pageId); + expect((lease.page as any)._original).toEqual(expect.any(Object)); + expect((attached.humanBlank as any)._original).toBeUndefined(); + expect((attached.humanPage as any)._original).toBeUndefined(); await manager.shutdown(); await manager.shutdown(); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index eaf46c2c..d3b57f4c 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -5,6 +5,7 @@ import { SlabNetworkCapture } from './network.js'; import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; import { isClosedContextError } from '../../run/types.js'; +import { humanizePage } from '../../humanizer/page.js'; import { attachSlabProfile, type AttachedSlabProfile } from './attachment.js'; const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -1131,6 +1132,7 @@ export class SlabSessionManager { entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); } session.pages.set(entry.leaseKey, entry); + humanizePage(page); this.cancelProfileIdle(runtime); this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); From a2ac590fb94b190cb7acfa2d2a552841e97d3d2e Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 02:03:14 +0530 Subject: [PATCH 09/34] fix: remove stale cloakbrowser runtime imports --- bun.lock | 8 - src/browser/profile.test.ts | 4 +- src/browser/profile.ts | 6 +- src/browser/runtime/local-cloak/actions.ts | 555 ------ .../runtime/local-cloak/browser-run.test.ts | 237 --- .../runtime/local-cloak/cloak-version.test.ts | 25 - .../darwin-background-launch.test.ts | 130 -- .../local-cloak/darwin-background-launch.ts | 167 -- src/browser/runtime/local-cloak/downloads.ts | 29 - .../runtime/local-cloak/network.test.ts | 140 -- src/browser/runtime/local-cloak/network.ts | 150 -- .../local-cloak/process-matcher.test.ts | 28 - .../runtime/local-cloak/process-matcher.ts | 65 - .../runtime/local-cloak/profiles.test.ts | 22 - src/browser/runtime/local-cloak/profiles.ts | 24 - .../runtime/local-cloak/provider.test.ts | 951 ---------- src/browser/runtime/local-cloak/provider.ts | 162 -- .../local-cloak/session-manager.test.ts | 1667 ----------------- .../runtime/local-cloak/session-manager.ts | 1401 -------------- .../local-slab/dependency-boundary.test.ts | 39 + src/doctor.test.ts | 317 +--- src/doctor.ts | 74 +- src/errors.test.ts | 2 +- 23 files changed, 100 insertions(+), 6103 deletions(-) delete mode 100644 src/browser/runtime/local-cloak/actions.ts delete mode 100644 src/browser/runtime/local-cloak/browser-run.test.ts delete mode 100644 src/browser/runtime/local-cloak/cloak-version.test.ts delete mode 100644 src/browser/runtime/local-cloak/darwin-background-launch.test.ts delete mode 100644 src/browser/runtime/local-cloak/darwin-background-launch.ts delete mode 100644 src/browser/runtime/local-cloak/downloads.ts delete mode 100644 src/browser/runtime/local-cloak/network.test.ts delete mode 100644 src/browser/runtime/local-cloak/network.ts delete mode 100644 src/browser/runtime/local-cloak/process-matcher.test.ts delete mode 100644 src/browser/runtime/local-cloak/process-matcher.ts delete mode 100644 src/browser/runtime/local-cloak/profiles.test.ts delete mode 100644 src/browser/runtime/local-cloak/profiles.ts delete mode 100644 src/browser/runtime/local-cloak/provider.test.ts delete mode 100644 src/browser/runtime/local-cloak/provider.ts delete mode 100644 src/browser/runtime/local-cloak/session-manager.test.ts delete mode 100644 src/browser/runtime/local-cloak/session-manager.ts create mode 100644 src/browser/runtime/local-slab/dependency-boundary.test.ts diff --git a/bun.lock b/bun.lock index 713ba223..e9799b92 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", @@ -122,7 +121,6 @@ "@google/genai": ["@google/genai@2.13.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], @@ -250,11 +248,9 @@ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "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=="], @@ -356,9 +352,7 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -418,7 +412,6 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -472,7 +465,6 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index 52020797..c2681485 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -116,7 +116,7 @@ describe('createProfile', () => { expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: true }); expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: false }); expect(loadProfileConfig().aliases['eval-a']).toBe('eval-a'); - expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(true); + expect(fs.existsSync(path.join(configDir, 'slab', 'profiles', 'eval-a'))).toBe(true); }); it('rejects an invalid alias', () => { @@ -174,7 +174,7 @@ describe('setDefaultProfile membership', () => { setDefaultProfile('__audit_nope__', []); } catch (err) { expect((err as ArgumentError).message).toBe( - 'No profile matches "__audit_nope__". No Cloak profiles are available.', + 'No profile matches "__audit_nope__". No SLAB profiles are available.', ); expect((err as ArgumentError).hint).toContain('webcmd profile list'); } diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 857a3dec..27545478 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -3,7 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; -import { normalizeProfileId, resolveCloakProfileDir } from './runtime/local-cloak/profiles.js'; +import { normalizeProfileId, resolveSlabProfileDir } from './runtime/local-slab/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -121,7 +121,7 @@ export function createProfile(alias: string): { contextId: string; alias: string } config.aliases[name] = contextId; saveProfileConfig(config); - fs.mkdirSync(resolveCloakProfileDir(contextId), { recursive: true }); + fs.mkdirSync(resolveSlabProfileDir(contextId), { recursive: true }); return { contextId, alias: name, created: true }; } @@ -173,7 +173,7 @@ export function setDefaultProfile(profile: string, rows: ProfileListRow[]): Prof const usage = `usage: ${CLI_COMMAND} profile use `; if (labels.length === 0) { throw new ArgumentError( - `No profile matches "${name}". No Cloak profiles are available.`, + `No profile matches "${name}". No SLAB profiles are available.`, `${usage}\nRun ${CLI_COMMAND} profile list, or create one with a browser-backed command.`, ); } diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts deleted file mode 100644 index 41f6c169..00000000 --- a/src/browser/runtime/local-cloak/actions.ts +++ /dev/null @@ -1,555 +0,0 @@ -import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js'; -import { extractArticle, type ExtractedArticle } from '../../article-extract.js'; -import { - captureSnapshot, - boundSnapshotText, - MemorySnapshotBaselineStore, - renderSnapshotResult, - type SnapshotBaselineStore, -} from '../../snapshot/index.js'; -import { redactText, redactUrl } from '../../../observation/redaction.js'; -import { articleHtmlToMarkdown } from '../../../download/article-download.js'; -import { waitForDownload } from './downloads.js'; -import type { CloakSessionManager } from './session-manager.js'; -import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; -import { runBrowserProgram } from '../../run/runner.js'; -import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js'; - -const snapshotBaselines = new WeakMap(); - -function snapshotBaselineStore(manager: CloakSessionManager): SnapshotBaselineStore { - let baselineStore = snapshotBaselines.get(manager); - if (!baselineStore) { - baselineStore = new MemorySnapshotBaselineStore(); - snapshotBaselines.set(manager, baselineStore); - } - return baselineStore; -} - -class CloakActionError extends Error { - constructor( - readonly errorCode: string, - error: string, - readonly page?: string, - readonly errorHint?: string, - ) { - super(error); - } -} - -export function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string { - const requested = command.profileId ?? command.contextId; - if (requested?.trim()) return requested.trim(); - - const preferred = command.preferredContextId?.trim(); - if (!preferred) return 'default'; - - const active = manager.activeProfileIds(); - if (active.includes(preferred)) return preferred; - if (active.length === 1) return active[0]; - if (active.length > 1) { - throw new CloakActionError( - 'profile_required', - `Default Cloak profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, - undefined, - 'Run webcmd profile list, then update the default with webcmd profile use or pass --profile .', - ); - } - return preferred; -} - -function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserRuntimeResult { - return { id: command.id, ok: false, errorCode: 'invalid_request', error }; -} - -/** - * Translate the command vocabulary ('load' | 'none') into Playwright's for a - * `page.goto` call. 'none' maps to 'commit': sites that stream analytics forever - * never fire the load event, so adapters gating readiness on their own selector - * waits must be able to skip it. - * - * Every Playwright-backed navigation in this runtime goes through here, so a - * future waitUntil value reaches all of them at once — the hardcoded literal at - * the second call site is what left `tab new --url` hanging after #106/#107. - */ -function toGotoWaitUntil(waitUntil: BrowserRuntimeCommand['waitUntil']): 'load' | 'commit' { - return waitUntil === 'none' ? 'commit' : 'load'; -} - -async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); - if (command.page) { - const existing = await manager.findPageById(command.page, { - profileId, - session: command.session, - sessionId: command.sessionId, - surface: command.surface, - idleTimeout: command.idleTimeout, - }); - if (existing) return existing; - throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); - } - return manager.getPage({ - profileId, - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - freshPage: command.freshPage, - windowMode: command.windowMode, - }); -} - -async function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { - const profileId = resolveCloakCommandProfileId(manager, command); - if (command.page) { - const existing = await manager.findPageById(command.page, { - profileId, - session: command.session, - sessionId: command.sessionId, - surface: command.surface, - idleTimeout: command.idleTimeout, - }); - if (existing) return existing; - throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); - } - const existing = await manager.findPage({ - profileId, - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - }); - if (existing) return existing; - throw new CloakActionError( - 'session_not_found', - `Browser session not found: ${command.session ?? ''}`, - undefined, - 'Start the session with browser run or navigate before requesting a snapshot.', - ); -} - -function execTarget(page: PlaywrightPage, frameIndex: number | undefined, pageId: string): PlaywrightPage | Frame { - if (frameIndex == null) return page; - const frame = page.frames().slice(1)[frameIndex]; - if (!frame) throw new CloakActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId); - return frame; -} - -function readableSnapshotText(article: ExtractedArticle | null): { text: string; warnings: string[]; article: unknown } { - if (!article) { - return { - text: 'No readable article content found. Use --snapshot-mode tree to inspect the page structure.', - warnings: ['No readable article content found.'], - article: null, - }; - } - const meta = [ - article.title ? `# ${article.title}` : '', - article.byline ? `> Author: ${article.byline}` : '', - article.publishedTime ? `> Published: ${article.publishedTime}` : '', - article.siteName ? `> Site: ${article.siteName}` : '', - `> Source: ${article.source}`, - ].filter(Boolean); - return { - text: `${meta.join('\n')}\n\n${articleHtmlToMarkdown(article.html)}`.trim(), - warnings: [], - article: { - title: article.title, - byline: article.byline, - publishedTime: article.publishedTime, - siteName: article.siteName, - source: article.source, - }, - }; -} - -async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, command: BrowserRuntimeCommand): Promise { - const width = Number.isFinite(command.width) && command.width! > 0 ? Math.ceil(command.width!) : undefined; - const height = !command.fullPage && Number.isFinite(command.height) && command.height! > 0 ? Math.ceil(command.height!) : undefined; - const options = { - type: command.format ?? 'png', - quality: command.format === 'jpeg' ? command.quality : undefined, - fullPage: command.fullPage, - } as const; - if (width === undefined && height === undefined) return page.screenshot(options); - - const current = page.viewportSize(); - if (current) { - // Emulated viewport: override for the shot, then restore the prior fixed size. - await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height }); - try { - return await page.screenshot(options); - } finally { - await page.setViewportSize(current); - } - } - - // Real-window context (viewport: null): setViewportSize can't return to a windowed - // state, so override reversibly via CDP and clear it so the override is per-shot only. - const windowSize = width === undefined || height === undefined - ? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })) - : { width: 0, height: 0 }; - const cdp = await context.newCDPSession(page); - try { - await cdp.send('Emulation.setDeviceMetricsOverride', { - width: width ?? windowSize.width, - height: height ?? windowSize.height, - deviceScaleFactor: 0, - mobile: false, - }); - return await page.screenshot(options); - } finally { - await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); - await cdp.detach().catch(() => {}); - } -} - -export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { - try { - switch (command.action) { - case 'navigate': { - if (!command.url) return invalidRequest(command, 'Missing url'); - const profileId = resolveCloakCommandProfileId(manager, command); - const lease = await manager.navigatePage( - { - profileId, - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - freshPage: command.freshPage, - windowMode: command.windowMode, - }, - command.url, - toGotoWaitUntil(command.waitUntil), - ); - return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; - } - case 'exec': { - if (!command.code) return invalidRequest(command, 'Missing code'); - const lease = await resolveLease(manager, command); - const target = execTarget(lease.page, command.frameIndex, lease.pageId); - const data = await target.evaluate(command.code); - return { id: command.id, ok: true, data, page: lease.pageId }; - } - case 'run': { - if (typeof command.source !== 'string' || !command.source.trim()) { - return invalidRequest(command, 'Missing source'); - } - if (Buffer.byteLength(command.source, 'utf8') > BROWSER_RUN_MAX_SOURCE_BYTES) { - return { - id: command.id, - ok: false, - errorCode: 'BROWSER_RUN_SOURCE_LIMIT', - error: `Browser-run source exceeds the ${BROWSER_RUN_MAX_SOURCE_BYTES}-byte limit.`, - }; - } - const lease = await resolveLease(manager, command); - const scope = await manager.browserRunScope({ - profileId: lease.profileId, - session: command.session, - sessionId: command.sessionId, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - windowMode: command.windowMode, - }, lease.page); - const data = await runBrowserProgram({ - ...scope, - pageId: lease.pageId, - }, command.source, { - timeoutMs: command.timeoutMs, - maxOutputChars: command.maxOutputChars, - memoryLimitBytes: command.memoryLimitBytes, - snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff, - snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act', - snapshotBaselineStore: snapshotBaselineStore(manager), - onStaleContext: () => manager.invalidateIfClosedContext(lease.profileId, scope.context), - ...(signal ? { signal } : {}), - }); - return { - id: command.id, - ok: true, - data, - page: lease.pageId, - }; - } - case 'snapshot': { - const lease = await resolveExistingLease(manager, command); - if (command.snapshotMode === 'read') { - const readable = readableSnapshotText(await extractArticle(lease.page, { force: true })); - const redacted = redactUrl(redactText(readable.text, { maxStringLength: Number.MAX_SAFE_INTEGER })); - const bounded = Number.isFinite(command.maxOutputChars) - ? boundSnapshotText(redacted, command.maxOutputChars!) - : { value: redacted, truncated: false }; - return { - id: command.id, - ok: true, - data: { - ok: true, - tree: bounded.value, - article: readable.article, - page: { - id: lease.pageId, - url: redactUrl(lease.page.url()), - title: redactText(await lease.page.title().catch(() => '')), - }, - warnings: readable.warnings, - limits: { snapshotTruncated: bounded.truncated }, - }, - page: lease.pageId, - }; - } - const snapshot = await captureSnapshot(lease.page); - const rendered = renderSnapshotResult(snapshot, { - mode: command.snapshotMode === 'tree' ? 'tree' : 'act', - ref: command.ref, - maxChars: command.maxOutputChars, - }); - const redacted = redactUrl(redactText(rendered.value, { maxStringLength: Number.MAX_SAFE_INTEGER })); - const bounded = Number.isFinite(command.maxOutputChars) - ? boundSnapshotText(redacted, command.maxOutputChars!) - : { value: redacted, truncated: false }; - const warnings = [...rendered.warnings]; - if (bounded.truncated) warnings.push('Snapshot output was truncated after redaction.'); - snapshotBaselineStore(manager).set(lease.pageId, snapshot); - return { - id: command.id, - ok: true, - data: { - ok: true, - tree: bounded.value, - page: { - id: lease.pageId, - url: redactUrl(lease.page.url()), - title: redactText(await lease.page.title().catch(() => '')), - }, - warnings, - limits: { snapshotTruncated: rendered.truncated || bounded.truncated }, - }, - page: lease.pageId, - }; - } - case 'cookies': { - const lease = await resolveLease(manager, command); - const cookies = await lease.context.cookies(command.url ? [command.url] : undefined); - const data = command.domain ? cookies.filter((cookie) => cookie.domain.includes(command.domain!)) : cookies; - return { id: command.id, ok: true, data }; - } - case 'screenshot': { - const lease = await resolveLease(manager, command); - const buffer = await captureScreenshot(lease.page, lease.context, command); - return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId }; - } - case 'close-window': { - if (command.page) { - const closed = await manager.closePage({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - pageId: command.page, - }); - return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; - } else { - await manager.release({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - }); - return { id: command.id, ok: true, data: { closed: true, session: command.session } }; - } - } - case 'tabs': { - switch (command.op ?? 'list') { - case 'list': { - const tabs = await manager.listPages({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - }); - return { id: command.id, ok: true, data: tabs }; - } - case 'new': { - const lease = await manager.newPage({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - url: command.url, - waitUntil: toGotoWaitUntil(command.waitUntil), - windowMode: command.windowMode, - }); - return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; - } - case 'select': { - const lease = await manager.selectPage({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - pageId: command.page, - index: command.index, - windowMode: command.windowMode, - }); - if (!lease) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; - return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId }; - } - case 'close': { - const closed = await manager.closePage({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - pageId: command.page, - index: command.index, - }); - if (!closed) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; - return { id: command.id, ok: true, data: { closed } }; - } - default: - return invalidRequest(command, `Unknown tabs op: ${command.op}`); - } - } - case 'set-file-input': { - if (!command.files?.length) return invalidRequest(command, 'Missing or empty files array'); - const lease = await resolveLease(manager, command); - const locator = lease.page.locator(command.selector ?? 'input[type="file"]').first(); - await locator.setInputFiles(command.files); - return { id: command.id, ok: true, data: { count: command.files.length }, page: lease.pageId }; - } - case 'insert-text': { - if (typeof command.text !== 'string') return invalidRequest(command, 'Missing text payload'); - const lease = await resolveLease(manager, command); - await lease.page.keyboard.insertText(command.text); - return { id: command.id, ok: true, data: { inserted: true }, page: lease.pageId }; - } - case 'network-capture-start': { - const lease = await resolveLease(manager, command); - manager.networkCapture.start(command.pattern ?? '', lease.page); - return { id: command.id, ok: true, data: { started: true }, page: lease.pageId }; - } - case 'network-capture-read': { - const lease = await resolveLease(manager, command); - return { id: command.id, ok: true, data: await manager.networkCapture.read(lease.page), page: lease.pageId }; - } - case 'wait-download': { - const lease = await resolveLease(manager, command); - const result = await waitForDownload(lease.page, command.pattern ?? '', command.timeoutMs ?? 30_000); - return { id: command.id, ok: true, data: result, page: lease.pageId }; - } - case 'cdp': { - if (!command.cdpMethod) return invalidRequest(command, 'Missing cdpMethod'); - const lease = await resolveLease(manager, command); - const session = await lease.context.newCDPSession(lease.page); - const data = await session.send(command.cdpMethod as any, command.cdpParams ?? {}); - return { id: command.id, ok: true, data, page: lease.pageId }; - } - case 'frames': { - const lease = await resolveLease(manager, command); - const frames = lease.page.frames().slice(1).map((frame, index) => ({ - index, - frameId: frame.name() || String(index), - url: frame.url(), - name: frame.name(), - })); - return { id: command.id, ok: true, data: frames, page: lease.pageId }; - } - case 'bind': - if (!command.page && command.index == null) { - return { - id: command.id, - ok: false, - errorCode: 'invalid_request', - error: 'Bind requires --page or --index for a Cloak runtime tab', - errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', - }; - } - { - const lease = await manager.bindPage({ - profileId: resolveCloakCommandProfileId(manager, command), - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - sessionKind: command.sessionKind, - sessionId: command.sessionId, - adapterSite: command.adapterSite, - runId: command.runId, - idleTimeout: command.idleTimeout, - windowMode: command.windowMode, - pageId: command.page, - index: command.index, - }); - if (!lease) { - return { - 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.', - }; - } - return { - id: command.id, - ok: true, - page: lease.pageId, - data: { - bound: true, - session: command.session, - page: lease.pageId, - url: lease.page.url(), - title: await lease.page.title().catch(() => ''), - }, - }; - } - default: - return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: `Unknown action: ${command.action}` }; - } - } catch (err) { - if (err instanceof CloakActionError) { - return { id: command.id, ok: false, errorCode: err.errorCode, error: err.message, ...(err.page && { page: err.page }), ...(err.errorHint && { errorHint: err.errorHint }) }; - } - if ( - err instanceof Error - && 'code' in err - && typeof err.code === 'string' - && (err.code.startsWith('BROWSER_RUN_') || err.code === 'SESSION_WINDOW_CONFLICT') - ) { - const hint = 'hint' in err && typeof err.hint === 'string' - ? err.hint - : undefined; - const details = 'details' in err ? err.details : undefined; - return { - id: command.id, - ok: false, - errorCode: err.code, - error: err.message, - ...(hint && { errorHint: hint }), - ...(details !== undefined ? { details } : {}), - }; - } - return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: err instanceof Error ? err.message : String(err) }; - } -} diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-cloak/browser-run.test.ts deleted file mode 100644 index 125f5984..00000000 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -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 * as snapshot from '../../snapshot/index.js'; - -let browser: Browser; -let context: BrowserContext; -let initialPage: Page; -let manager: CloakSessionManager; -let launchPersistentContext: ReturnType>; - -const command = (id: string, action: 'run' | 'snapshot' | 'tabs' | 'bind' | 'close-window', extra: Record = {}) => ({ - id, - action, - profileId: 'default', - session: 'work', - surface: 'browser' as const, - ...extra, -}); - -beforeAll(async () => { - browser = await chromium.launch({ headless: true }); -}); - -beforeEach(async () => { - context = await browser.newContext(); - initialPage = await context.newPage(); - launchPersistentContext = vi.fn().mockResolvedValue(context); - manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-browser-run-test', - launchPersistentContext, - }); - initialPage = (await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' })).page; -}); - -afterEach(async () => { - await context.close(); -}); - -afterAll(async () => { - await browser.close(); -}); - -describe('local Cloak browser run', () => { - it('returns a bounded redacted snapshot for the current page', async () => { - await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const result = await dispatchCloakAction(manager, command('snapshot-1', 'snapshot')); - - expect(result).toMatchObject({ - ok: true, - data: { - ok: true, - tree: expect.any(String), - page: { id: expect.any(String), url: 'about:blank', title: '' }, - warnings: [], - limits: { snapshotTruncated: false }, - }, - }); - }); - - it('redacts page, frame, and href URL parameters before bounding snapshot output', async () => { - await context.route('**/*', route => route.fulfill({ - body: 'Next', - })); - 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', { - maxOutputChars: 500, - })); - const tree = (result.data as { tree: string }).tree; - - expect(tree.length).toBeLessThanOrEqual(500); - expect(tree).not.toMatch(/page-secret|page-auth|href-secret|href-auth/); - expect(tree).toContain('key=[REDACTED]'); - expect(tree).toContain('auth=[REDACTED]'); - expect(tree).toContain('>Next'); - }); - - it('propagates critical omission warnings from explicit structural snapshots', async () => { - await initialPage.setContent(`
${Array.from({ length: 20 }, (_, index) => - ``).join('')}
`); - await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - const result = await dispatchCloakAction(manager, command('snapshot-critical', 'snapshot', { - maxOutputChars: 220, - })); - - expect(result).toMatchObject({ - ok: true, - data: { - warnings: [expect.stringMatching(/inspect.*ref/i)], - limits: { snapshotTruncated: true }, - }, - }); - expect((result.data as { tree: string }).tree.length).toBeLessThanOrEqual(220); - }); - - it('returns readable markdown for read snapshots', async () => { - await initialPage.setContent(` -
-
-

Readable Benchmark Notes

-

This paragraph is deliberately long enough to be treated as content, with benchmark evidence and enough words for extraction.

- -
-
- `); - await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - const result = await dispatchCloakAction(manager, command('snapshot-readable', 'snapshot', { - snapshotMode: 'read', - })); - const data = result.data as { tree: string; article: { source: string } | null }; - - expect(result.ok).toBe(true); - expect(data.tree).toContain('Readable Benchmark Notes'); - expect(data.tree).toContain('benchmark evidence'); - expect(data.tree).not.toContain('Ignore chrome'); - expect(data.article?.source).toMatch(/readability|fallback/); - }); - - it('keeps failed read snapshots in the article pipeline', async () => { - const capture = vi.spyOn(snapshot, 'captureSnapshot'); - const pageWithSnapshot = initialPage as Page & { snapshot?: () => Promise }; - const pageSnapshot = vi.fn<() => Promise>().mockResolvedValue('AX fallback'); - pageWithSnapshot.snapshot = pageSnapshot; - await initialPage.setContent(''); - await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - const result = await dispatchCloakAction(manager, command('snapshot-read-miss', 'snapshot', { - snapshotMode: 'read', - })); - - expect(result).toMatchObject({ - ok: true, - data: { - tree: 'No readable article content found. Use --snapshot-mode tree to inspect the page structure.', - article: null, - }, - }); - expect(capture).not.toHaveBeenCalled(); - expect(pageSnapshot).not.toHaveBeenCalled(); - capture.mockRestore(); - }); - - it('does not create a browser session for snapshot inspection', async () => { - const launch = vi.fn().mockResolvedValue(context); - const unstarted = new CloakSessionManager({ - baseDir: '/tmp/webcmd-browser-snapshot-unstarted', - launchPersistentContext: launch, - }); - - const result = await dispatchCloakAction(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', { - source: "return 'ok';", - noSnapshotDiff: true, - })); - - expect(result).toMatchObject({ ok: true, data: { result: 'ok' } }); - expect(result.data).not.toHaveProperty('snapshotDiff'); - }); - - it('reuses the lease and keeps page state without keeping sandbox variables', async () => { - const first = await dispatchCloakAction(manager, command('run-1', 'run', { - source: ` - globalThis.onlyThisRun = 'gone'; - await page.setContent('

persisted

'); - return await page.locator('#state').innerText(); - `, - snapshotDiff: true, - })); - const second = await dispatchCloakAction(manager, command('run-2', 'run', { - source: ` - return { - state: await page.locator('#state').innerText(), - variable: typeof globalThis.onlyThisRun, - }; - `, - })); - - expect(first).toMatchObject({ ok: true, data: { result: 'persisted' } }); - expect(first.page).toBeDefined(); - expect(first.data).toMatchObject({ - timings: { - quickjs_boot_ms: expect.any(Number), - client_bundle_init_ms: expect.any(Number), - program_ms: expect.any(Number), - browser_wait_ms: expect.any(Number), - snapshot_ms: expect.any(Number), - }, - }); - expect(Object.values((first.data as { timings: Record }).timings) - .every(value => value >= 0)).toBe(true); - expect(second).toMatchObject({ - ok: true, - page: first.page, - data: { result: { state: 'persisted', variable: 'undefined' } }, - }); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(initialPage.isClosed()).toBe(false); - }); - - it('lists without creating a runtime', async () => { - const unstartedLaunch = vi.fn(); - const unstarted = new CloakSessionManager({ - baseDir: '/tmp/webcmd-browser-run-test-unstarted', - launchPersistentContext: unstartedLaunch, - }); - - await expect(dispatchCloakAction(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', { - source: "await page.setContent('

original

'); return 'original';", - })); - const created = await dispatchCloakAction(manager, command('new-tab', 'tabs', { - op: 'new', - session: 'manual', - })); - const bound = await dispatchCloakAction(manager, command('bind', 'bind', { page: created.page })); - - expect(original).toMatchObject({ ok: true, page: expect.any(String) }); - expect(bound).toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(created).toMatchObject({ ok: true, page: expect.any(String) }); - }); -}); diff --git a/src/browser/runtime/local-cloak/cloak-version.test.ts b/src/browser/runtime/local-cloak/cloak-version.test.ts deleted file mode 100644 index 1c33f12a..00000000 --- a/src/browser/runtime/local-cloak/cloak-version.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// Isolated file: the version cache is module-level and populated by the first -// call in the process, so this must import a fresh copy to observe the read. -describe('resolveCloakBrowserVersion', () => { - it('resolves and reads cloakbrowser/package.json only once per process', async () => { - vi.resetModules(); - const fs = (await import('node:fs')).default; - // Import before spying: loading the module graph reads files of its own, - // and only reads made by resolveCloakBrowserVersion should be counted. - const { resolveCloakBrowserVersion } = await import('./session-manager.js'); - const readFileSync = vi.spyOn(fs, 'readFileSync'); - try { - const first = resolveCloakBrowserVersion(); - const second = resolveCloakBrowserVersion(); - const third = resolveCloakBrowserVersion(); - - expect(second).toBe(first); - expect(third).toBe(first); - expect(readFileSync).toHaveBeenCalledTimes(1); - } finally { - readFileSync.mockRestore(); - } - }); -}); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts deleted file mode 100644 index 4b770be5..00000000 --- a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { Browser, BrowserContext } from 'playwright-core'; -import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext, waitForDevToolsPort } from './darwin-background-launch.js'; - -const options = { - userDataDir: '/tmp/cloak profile', - headless: false, - humanize: true, -}; - -function fakeRuntime() { - const context = { close: vi.fn() } as unknown as BrowserContext; - const browser = { - contexts: vi.fn(() => [context]), - close: vi.fn().mockResolvedValue(undefined), - } as unknown as Browser; - return { browser, context }; -} - -function fakeDependencies(browser: Browser) { - return { - buildLaunchOptions: vi.fn().mockResolvedValue({ - executablePath: '/Applications/Cloak Chromium.app/Contents/MacOS/Chromium', - args: ['--fingerprint=123'], - }), - humanizeBrowser: vi.fn().mockResolvedValue(undefined), - openApplication: vi.fn().mockResolvedValue(undefined), - activateApplication: vi.fn().mockResolvedValue(undefined), - readPort: vi.fn().mockResolvedValue(43123), - connectOverCDP: vi.fn().mockResolvedValue(browser), - terminateProfile: vi.fn().mockResolvedValue(undefined), - removePortFile: vi.fn().mockResolvedValue(undefined), - registerBundle: vi.fn().mockResolvedValue(undefined), - }; -} - -describe('launchDarwinBackgroundPersistentContext', () => { - it('launches the Chromium app without activation and connects through loopback CDP', async () => { - const { browser, context } = fakeRuntime(); - const deps = fakeDependencies(browser); - - const result = await launchDarwinBackgroundPersistentContext(options, deps); - - expect(deps.removePortFile).toHaveBeenCalledWith('/tmp/cloak profile/DevToolsActivePort'); - expect(deps.openApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app', [ - '--fingerprint=123', - '--password-store=basic', - '--use-mock-keychain', - '--disable-popup-blocking', - '--disable-features=DestroyProfileOnBrowserClose', - '--user-data-dir=/tmp/cloak profile', - '--remote-debugging-address=127.0.0.1', - '--remote-debugging-port=0', - 'about:blank', - ]); - expect(deps.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:43123'); - expect(deps.humanizeBrowser).toHaveBeenCalledWith(browser, expect.objectContaining({ humanize: true })); - expect(result).toBe(context); - - await activateDarwinBackgroundContext(result); - expect(deps.activateApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - - await result.close(); - expect(browser.close).toHaveBeenCalledOnce(); - expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir); - }); - - it('fails immediately when the CDP port file misses its deadline', async () => { - await expect(waitForDevToolsPort('/missing/DevToolsActivePort', 0)).rejects.toThrow( - 'Timed out waiting for background Chromium CDP endpoint', - ); - }); - - it('terminates the launched profile when CDP connection fails', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - deps.connectOverCDP.mockRejectedValueOnce(new Error('connect failed')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('connect failed'); - - expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir); - }); - - it('re-registers a stale LaunchServices bundle and retries once on kLSNoExecutableErr', async () => { - const { browser, context } = fakeRuntime(); - const deps = fakeDependencies(browser); - const lsError = new Error( - 'Command failed: /usr/bin/open -g -n /Applications/Cloak Chromium.app --args ...\n' + - 'The application cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain ' + - 'Code=-10827 "kLSNoExecutableErr: The executable is missing"', - ); - deps.openApplication.mockRejectedValueOnce(lsError).mockResolvedValueOnce(undefined); - - const result = await launchDarwinBackgroundPersistentContext(options, deps); - - expect(deps.registerBundle).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - expect(deps.openApplication).toHaveBeenCalledTimes(2); - expect(result).toBe(context); - }); - - it('surfaces a remediation error when re-registering does not fix kLSNoExecutableErr', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - const lsError = new Error('kLSNoExecutableErr: The executable is missing'); - deps.openApplication - .mockRejectedValueOnce(lsError) - .mockRejectedValueOnce(new Error('retry failed: bundle executable is invalid')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow( - /retry failed: bundle executable is invalid[\s\S]*lsregister -f "\/Applications\/Cloak Chromium\.app"/, - ); - - expect(deps.registerBundle).toHaveBeenCalledWith('/Applications/Cloak Chromium.app'); - expect(deps.openApplication).toHaveBeenCalledTimes(2); - // Never "launched" (both attempts failed before the app came up), so no - // stray terminateProfile call for a process that never started. - expect(deps.terminateProfile).not.toHaveBeenCalled(); - }); - - it('does not attempt lsregister remediation for unrelated open failures', async () => { - const { browser } = fakeRuntime(); - const deps = fakeDependencies(browser); - deps.openApplication.mockRejectedValue(new Error('some other launch failure')); - - await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('some other launch failure'); - - expect(deps.registerBundle).not.toHaveBeenCalled(); - expect(deps.openApplication).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-cloak/darwin-background-launch.ts deleted file mode 100644 index 98ff9837..00000000 --- a/src/browser/runtime/local-cloak/darwin-background-launch.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { execFile } from 'node:child_process'; -import { readFile, rm } from 'node:fs/promises'; -import { posix as path } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { promisify } from 'node:util'; -import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser'; -import type { LaunchPersistentContextOptions } from 'cloakbrowser'; -import { chromium } from 'playwright-core'; -import type { Browser, BrowserContext } from 'playwright-core'; -import { findExactCloakProfileProcesses } from './process-matcher.js'; - -const execFileAsync = promisify(execFile); - -// macOS LaunchServices' registration database for `.app` bundles. A script-driven -// unzip of a new/updated Chromium bundle (rather than a Finder/.pkg install) can -// land outside the triggers that make LS pick it up, leaving `open` unable to -// resolve a bundle that runs fine when executed directly (kLSNoExecutableErr). -const LSREGISTER_PATH = - '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; -const LS_NO_EXECUTABLE_MARKER = 'kLSNoExecutableErr'; - -type Dependencies = { - buildLaunchOptions: typeof buildLaunchOptions; - humanizeBrowser: typeof humanizeBrowser; - openApplication: (appPath: string, args: string[]) => Promise; - activateApplication: (appPath: string) => Promise; - readPort: (portFile: string) => Promise; - connectOverCDP: (endpoint: string) => Promise; - terminateProfile: (userDataDir: string) => Promise; - removePortFile: (portFile: string) => Promise; - /** Force LaunchServices to re-scan a bundle after a stale-cache `open` failure. */ - registerBundle: (appPath: string) => Promise; -}; - -async function openApplication(appPath: string, args: string[]): Promise { - await execFileAsync('/usr/bin/open', ['-g', '-n', appPath, '--args', ...args]); -} - -async function registerBundle(appPath: string): Promise { - await execFileAsync(LSREGISTER_PATH, ['-f', appPath]); -} - -function isStaleLaunchServicesError(err: unknown): boolean { - return err instanceof Error && err.message.includes(LS_NO_EXECUTABLE_MARKER); -} - -/** - * Open the app, retrying once via `lsregister -f` when macOS reports the bundle - * as missing due to a stale LaunchServices cache entry rather than an actually - * missing executable (see #220). - */ -async function openApplicationWithLsRegisterRetry( - deps: Dependencies, - appPath: string, - args: string[], -): Promise { - try { - await deps.openApplication(appPath, args); - } catch (err) { - if (!isStaleLaunchServicesError(err)) throw err; - try { - await deps.registerBundle(appPath); - await deps.openApplication(appPath, args); - } catch (retryError) { - const retryMessage = retryError instanceof Error ? retryError.message : String(retryError); - throw new Error( - `Cloak Chromium bundle exists but macOS LaunchServices has a stale record for it (${LS_NO_EXECUTABLE_MARKER}): ${appPath}\n` + - `Automatic remediation failed: ${retryMessage}\n` + - `Re-registering it automatically did not resolve the issue. Fix it manually with:\n` + - ` ${LSREGISTER_PATH} -f "${appPath}"`, - { cause: retryError }, - ); - } - } -} - -export async function waitForDevToolsPort(portFile: string, timeoutMs = 10_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const port = Number.parseInt((await readFile(portFile, 'utf8')).split('\n')[0], 10); - if (Number.isInteger(port) && port > 0) return port; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - await delay(50); - } - throw new Error('Timed out waiting for background Chromium CDP endpoint'); -} - -async function terminateProfile(userDataDir: string): Promise { - for (const pid of await findExactCloakProfileProcesses(userDataDir)) process.kill(pid, 'SIGTERM'); -} - -const defaultDependencies: Dependencies = { - buildLaunchOptions, - humanizeBrowser, - openApplication, - activateApplication: async appPath => { - await execFileAsync('/usr/bin/open', [appPath]); - }, - readPort: waitForDevToolsPort, - connectOverCDP: endpoint => chromium.connectOverCDP(endpoint), - terminateProfile, - removePortFile: portFile => rm(portFile, { force: true }), - registerBundle, -}; - -const contextActivators = new WeakMap Promise>(); - -export async function activateDarwinBackgroundContext(context: BrowserContext): Promise { - await contextActivators.get(context)?.(); -} - -function appPathFor(executablePath: string): string { - const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`; - const index = executablePath.lastIndexOf(marker); - if (index < 0) throw new Error(`Cloak Chromium executable is not inside a macOS app bundle: ${executablePath}`); - return executablePath.slice(0, index); -} - -export async function launchDarwinBackgroundPersistentContext( - options: LaunchPersistentContextOptions, - deps: Dependencies = defaultDependencies, -): Promise { - const portFile = path.join(options.userDataDir, 'DevToolsActivePort'); - await deps.removePortFile(portFile); - const launchOptions = await deps.buildLaunchOptions(options); - if (!launchOptions.executablePath) throw new Error('Cloak Chromium executable path is missing'); - const appPath = appPathFor(launchOptions.executablePath); - - let browser: Browser | undefined; - let launched = false; - try { - await openApplicationWithLsRegisterRetry(deps, appPath, [ - ...(launchOptions.args ?? []), - '--password-store=basic', - '--use-mock-keychain', - '--disable-popup-blocking', - '--disable-features=DestroyProfileOnBrowserClose', - `--user-data-dir=${options.userDataDir}`, - '--remote-debugging-address=127.0.0.1', - '--remote-debugging-port=0', - 'about:blank', - ]); - launched = true; - const port = await deps.readPort(portFile); - browser = await deps.connectOverCDP(`http://127.0.0.1:${port}`); - await deps.humanizeBrowser(browser, options); - const context = browser.contexts()[0]; - if (!context) throw new Error('Background Chromium did not expose a persistent context'); - contextActivators.set(context, () => deps.activateApplication(appPath)); - context.close = async () => { - try { - await browser!.close(); - } finally { - contextActivators.delete(context); - await deps.terminateProfile(options.userDataDir); - } - }; - return context; - } catch (error) { - await browser?.close().catch(() => {}); - if (launched) await deps.terminateProfile(options.userDataDir).catch(() => {}); - throw error; - } -} diff --git a/src/browser/runtime/local-cloak/downloads.ts b/src/browser/runtime/local-cloak/downloads.ts deleted file mode 100644 index 5cabb10a..00000000 --- a/src/browser/runtime/local-cloak/downloads.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Page } from 'playwright-core'; -import type { BrowserDownloadWaitResult } from '../../../types.js'; - -export async function waitForDownload(page: Page, pattern: string, timeoutMs: number): Promise { - const startedAt = Date.now(); - try { - const download = await page.waitForEvent('download', { - timeout: timeoutMs, - predicate: (candidate) => { - if (!pattern) return true; - return candidate.url().includes(pattern) || candidate.suggestedFilename().includes(pattern); - }, - }); - const failure = await download.failure(); - return { - downloaded: !failure, - filename: download.suggestedFilename(), - url: download.url(), - error: failure ?? undefined, - elapsedMs: Date.now() - startedAt, - }; - } catch (err) { - return { - downloaded: false, - error: err instanceof Error ? err.message : String(err), - elapsedMs: Date.now() - startedAt, - }; - } -} diff --git a/src/browser/runtime/local-cloak/network.test.ts b/src/browser/runtime/local-cloak/network.test.ts deleted file mode 100644 index 6e7d58c0..00000000 --- a/src/browser/runtime/local-cloak/network.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { EventEmitter } from 'node:events'; -import { describe, expect, it, vi } from 'vitest'; -import { CloakNetworkCapture } from './network.js'; - -class FakePage extends EventEmitter { - off(event: string, listener: (...args: any[]) => void) { - this.removeListener(event, listener); - return this; - } -} - -describe('CloakNetworkCapture', () => { - it('captures matching request and response metadata with a bounded buffer', async () => { - const page = new FakePage(); - const capture = new CloakNetworkCapture(2); - capture.start('api.example', page as any); - - const req = { - url: () => 'https://api.example/items', - method: () => 'POST', - headers: () => ({ accept: 'application/json' }), - postData: () => '{"x":1}', - }; - const res = { - url: () => 'https://api.example/items', - status: () => 200, - headers: () => ({ 'content-type': 'application/json' }), - text: async () => '{"ok":true}', - }; - - page.emit('request', req); - page.emit('response', res); - - expect(await capture.read(page as any)).toEqual([expect.objectContaining({ - kind: 'cdp', - url: 'https://api.example/items', - method: 'POST', - responseStatus: 200, - responsePreview: '{"ok":true}', - })]); - }); - - it('matches same-url responses to their exact request identity', async () => { - const page = new FakePage(); - const capture = new CloakNetworkCapture(10); - capture.start('api.example', page as any); - - const firstReq = { - url: () => 'https://api.example/items', - method: () => 'POST', - headers: () => ({ 'x-request': 'first' }), - postData: () => 'first', - }; - const secondReq = { - url: () => 'https://api.example/items', - method: () => 'PUT', - headers: () => ({ 'x-request': 'second' }), - postData: () => 'second', - }; - const firstRes = { - url: () => 'https://api.example/items', - request: () => firstReq, - status: () => 201, - headers: () => ({ 'content-type': 'application/json' }), - text: async () => '{"first":true}', - }; - - page.emit('request', firstReq); - page.emit('request', secondReq); - page.emit('response', firstRes); - - const entries = await capture.read(page as any); - expect(entries).toEqual([ - expect.objectContaining({ - method: 'POST', - requestBodyPreview: 'first', - responseStatus: 201, - responsePreview: '{"first":true}', - }), - expect.objectContaining({ - method: 'PUT', - requestBodyPreview: 'second', - }), - ]); - expect(entries[1]).not.toHaveProperty('responseStatus'); - expect(entries[1]).not.toHaveProperty('responsePreview'); - }); - - it('skips response body previews for non-text content types', async () => { - const page = new FakePage(); - const capture = new CloakNetworkCapture(10); - capture.start('api.example', page as any); - - const req = { - url: () => 'https://api.example/image.png', - method: () => 'GET', - headers: () => ({}), - postData: () => null, - }; - const text = vi.fn(async () => 'binary-ish body'); - const res = { - url: () => 'https://api.example/image.png', - request: () => req, - status: () => 200, - headers: () => ({ 'content-type': 'image/png', 'content-length': '15' }), - text, - }; - - page.emit('request', req); - page.emit('response', res); - - expect(await capture.read(page as any)).toEqual([expect.objectContaining({ - responseContentType: 'image/png', - responseBodyFullSize: 15, - responseBodyTruncated: undefined, - responsePreview: undefined, - })]); - expect(text).not.toHaveBeenCalled(); - }); - - it('evicts older entries when the bounded buffer limit is exceeded', async () => { - const page = new FakePage(); - const capture = new CloakNetworkCapture(2); - capture.start('api.example', page as any); - - for (const id of ['one', 'two', 'three']) { - page.emit('request', { - url: () => `https://api.example/${id}`, - method: () => 'GET', - headers: () => ({}), - postData: () => null, - }); - } - - expect(await capture.read(page as any)).toEqual([ - expect.objectContaining({ url: 'https://api.example/two' }), - expect.objectContaining({ url: 'https://api.example/three' }), - ]); - }); -}); diff --git a/src/browser/runtime/local-cloak/network.ts b/src/browser/runtime/local-cloak/network.ts deleted file mode 100644 index d10f2aae..00000000 --- a/src/browser/runtime/local-cloak/network.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Page, Request, Response } from 'playwright-core'; - -export interface NetworkCaptureEntry { - kind: 'cdp'; - url: string; - method: string; - requestHeaders?: Record; - requestBodyKind?: string; - requestBodyPreview?: string; - requestBodyFullSize?: number; - requestBodyTruncated?: boolean; - responseStatus?: number; - responseContentType?: string; - responseHeaders?: Record; - responsePreview?: string; - responseBodyFullSize?: number; - responseBodyTruncated?: boolean; - timestamp: number; -} - -type CaptureState = { - pattern: string; - entries: NetworkCaptureEntry[]; - byRequest: WeakMap; - pending: Set>; - onRequest: (request: Request) => void; - onResponse: (response: Response) => void; -}; - -const BODY_LIMIT = 8 * 1024 * 1024; - -export class CloakNetworkCapture { - private readonly states = new WeakMap(); - - constructor(private readonly limit = 200) {} - - start(pattern: string, page: Page): void { - this.stop(page); - const entries: NetworkCaptureEntry[] = []; - const byRequest = new WeakMap(); - const pending = new Set>(); - const state: CaptureState = { - pattern, - entries, - byRequest, - pending, - onRequest: (request) => { - const url = request.url(); - if (pattern && !url.includes(pattern)) return; - const body = request.postData() ?? undefined; - const entry = { - kind: 'cdp', - url, - method: request.method(), - requestHeaders: request.headers(), - requestBodyKind: body === undefined ? undefined : 'text', - requestBodyPreview: body === undefined ? undefined : body.slice(0, BODY_LIMIT), - requestBodyFullSize: body?.length, - requestBodyTruncated: body ? body.length > BODY_LIMIT : undefined, - timestamp: Date.now(), - } satisfies NetworkCaptureEntry; - entries.push(entry); - byRequest.set(request, entry); - this.bound(entries); - }, - onResponse: (response) => { - const capture = this.captureResponse(response, pattern, entries, byRequest) - .finally(() => pending.delete(capture)); - pending.add(capture); - }, - }; - page.on('request', state.onRequest); - page.on('response', state.onResponse); - this.states.set(page, state); - } - - async read(page: Page): Promise { - const state = this.states.get(page); - if (!state) return []; - await Promise.allSettled([...state.pending]); - return [...state.entries]; - } - - stop(page: Page): void { - const state = this.states.get(page); - if (!state) return; - page.off('request', state.onRequest); - page.off('response', state.onResponse); - this.states.delete(page); - } - - private async captureResponse( - response: Response, - pattern: string, - entries: NetworkCaptureEntry[], - byRequest: WeakMap, - ): Promise { - const url = response.url(); - if (pattern && !url.includes(pattern)) return; - const headers = response.headers(); - const contentType = headers['content-type']; - let preview: string | undefined; - let fullSize: number | undefined; - let truncated: boolean | undefined; - const contentLength = Number(headers['content-length']); - if (Number.isFinite(contentLength) && contentLength >= 0) fullSize = contentLength; - if (isTextLikeContentType(contentType)) { - try { - const text = await response.text(); - fullSize = text.length; - truncated = text.length > BODY_LIMIT; - preview = text.slice(0, BODY_LIMIT); - } catch { - preview = undefined; - } - } - const responseRequest = typeof response.request === 'function' ? response.request() : undefined; - const existing = responseRequest - ? byRequest.get(responseRequest) - : [...entries].reverse().find((entry) => entry.url === url && entry.responseStatus === undefined); - const target = existing ?? { - kind: 'cdp' as const, - url, - method: 'GET', - timestamp: Date.now(), - }; - target.responseStatus = response.status(); - target.responseContentType = contentType; - target.responseHeaders = headers; - target.responsePreview = preview; - target.responseBodyFullSize = fullSize; - target.responseBodyTruncated = truncated; - if (!existing) entries.push(target); - this.bound(entries); - } - - private bound(entries: NetworkCaptureEntry[]): void { - while (entries.length > this.limit) entries.shift(); - } -} - -function isTextLikeContentType(contentType: string | undefined): boolean { - if (!contentType) return false; - const normalized = contentType.toLowerCase(); - return normalized.startsWith('text/') - || normalized.includes('json') - || normalized.includes('javascript') - || normalized.includes('xml') - || normalized.includes('x-www-form-urlencoded'); -} diff --git a/src/browser/runtime/local-cloak/process-matcher.test.ts b/src/browser/runtime/local-cloak/process-matcher.test.ts deleted file mode 100644 index 568d8e87..00000000 --- a/src/browser/runtime/local-cloak/process-matcher.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { matchCloakProfileCommand } from './process-matcher.js'; - -describe('matchCloakProfileCommand', () => { - it('matches only exact Cloak user-data-dir arguments', () => { - const cloak = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work'; - const cloakSeparate = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir /profiles/work'; - const cloakQuoted = '"/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/Chromium.app/Contents/MacOS/Chromium" "--user-data-dir=/profiles/work"'; - const cloakWork2 = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work-2'; - const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; - - expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); - expect(matchCloakProfileCommand('C:\\Users\\me\\.cloakbrowser\\chromium-146.0.7680.177.4\\chrome.exe --user-data-dir=C:\\profiles\\work', 'C:\\profiles\\work')).toBe(true); - expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('node /tmp/.cloakbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - expect(matchCloakProfileCommand('/tmp/.cloakbrowser/helper --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); - }); - - it('accepts quotes around a separate or equals-form profile value', () => { - const executable = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome'; - expect(matchCloakProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); - expect(matchCloakProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); - }); -}); diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-cloak/process-matcher.ts deleted file mode 100644 index dc015819..00000000 --- a/src/browser/runtime/local-cloak/process-matcher.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs'; -import { execFile } from 'node:child_process'; - -export function matchCloakProfileCommand(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; - 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; - } - return false; -} - -export async function findExactCloakProfileProcesses(userDataDir: string): Promise { - const aliases = new Set([userDataDir]); - try { - aliases.add(fs.realpathSync.native(userDataDir)); - } catch { - // The launch path is still useful when the directory does not exist yet. - } - const stdout = await psOutput(); - const pids = stdout.split('\n').flatMap((line) => { - const match = line.match(/^\s*(\d+)\s+(.+)$/); - 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 [...new Set(pids)]; -} - -function splitCommand(command: string): string[] { - const args: string[] = []; - let current = ''; - let quote = ''; - for (const char of command) { - if (quote) { - if (char === quote) quote = ''; - else current += char; - } else if (char === '"' || char === "'") { - quote = char; - } else if (/\s/u.test(char)) { - if (current) { - args.push(current); - current = ''; - } - } else { - current += char; - } - } - if (current) args.push(current); - return args; -} - -function psOutput(): Promise { - return new Promise((resolve) => { - execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => { - resolve(err ? '' : String(stdout)); - }); - }); -} diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-cloak/profiles.test.ts deleted file mode 100644 index 472c9c1d..00000000 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; - -describe('cloak profile resolution', () => { - it('normalizes empty profile ids to default', () => { - expect(normalizeProfileId(undefined)).toBe('default'); - expect(normalizeProfileId('')).toBe('default'); - expect(normalizeProfileId(' work ')).toBe('work'); - }); - - it('rejects path traversal and separators', () => { - expect(() => normalizeProfileId('../x')).toThrow(/Invalid profile id/); - expect(() => normalizeProfileId('a/b')).toThrow(/Invalid profile id/); - 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')); - }); -}); diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-cloak/profiles.ts deleted file mode 100644 index 0f08e9aa..00000000 --- a/src/browser/runtime/local-cloak/profiles.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'node:path'; -import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; -import os from 'node:os'; - -export interface CloakProfileDirOptions { - baseDir?: string; -} - -export function normalizeProfileId(value: string | undefined | null): string { - const id = value?.trim() || 'default'; - if (!/^[A-Za-z0-9._-]+$/.test(id) || id === '.' || id === '..') { - throw new Error(`Invalid profile id: ${value ?? ''}`); - } - return id; -} - -export function getWebcmdConfigDir(): string { - return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); -} - -export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { - const safeProfileId = normalizeProfileId(profileId); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'cloak', 'profiles', safeProfileId); -} diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts deleted file mode 100644 index daf0187d..00000000 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ /dev/null @@ -1,951 +0,0 @@ -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 { BrowserRunError } from '../../run/types.js'; - -const runBrowserProgram = vi.hoisted(() => vi.fn()); - -vi.mock('../../run/runner.js', () => ({ - runBrowserProgram, -})); - -function runOutput(result: unknown) { - return { - ok: true as const, - result, - logs: [], - page: { id: 'page-1', url: '', title: '' }, - artifacts: [], - warnings: [], - limits: { outputTruncated: false, snapshotTruncated: false }, - }; -} - -function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }, opener: object | null = null) { - let closed = false; - let viewportSize = initialViewport; - const listeners = new Map void>>(); - const page = { - isClosed: vi.fn(() => closed), - goto: vi.fn(async (nextUrl: string) => { - url = nextUrl; - }), - evaluate: vi.fn().mockResolvedValue({ ok: true }), - frames: vi.fn((): unknown[] => []), - title: vi.fn().mockResolvedValue('Example'), - url: vi.fn(() => url), - screenshot: vi.fn().mockResolvedValue(Buffer.from('image')), - viewportSize: vi.fn(() => viewportSize), - setViewportSize: vi.fn(async (size: { width: number; height: number }) => { - viewportSize = size; - }), - locator: vi.fn(), - waitForEvent: vi.fn(), - opener: vi.fn().mockResolvedValue(opener), - on(event: string, listener: (...args: unknown[]) => void) { - const bucket = listeners.get(event) ?? new Set(); - bucket.add(listener); - listeners.set(event, bucket); - }, - once(event: string, listener: (...args: unknown[]) => void) { - const once = (...args: unknown[]) => { - page.off(event, once); - listener(...args); - }; - page.on(event, once); - }, - off(event: string, listener: (...args: unknown[]) => void) { - listeners.get(event)?.delete(listener); - }, - bringToFront: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockImplementation(async () => { - closed = true; - for (const listener of listeners.get('close') ?? []) listener(); - }), - }; - return page; -} - -function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { - const pages = [fakePage('https://example.com/', initialViewport)]; - const listeners = new Map void>>(); - const emit = (event: string, ...args: unknown[]) => { - for (const listener of listeners.get(event) ?? []) listener(...args); - }; - const targetIds = new WeakMap(); - const windowIds = new Map(); - let targetCounter = 0; - let windowCounter = 0; - const assignTarget = (page: object) => { - const targetId = `target-${++targetCounter}`; - targetIds.set(page, targetId); - windowIds.set(targetId, ++windowCounter); - return targetId; - }; - assignTarget(pages[0]); - const cdpSession = { send: vi.fn(), detach: vi.fn().mockResolvedValue(undefined) }; - const pageCdpSessions: { send: ReturnType; detach: ReturnType }[] = []; - const browser = { contexts: vi.fn(() => [context]), newBrowserCDPSession: vi.fn().mockResolvedValue(cdpSession) }; - const context = { - browser: vi.fn(() => browser), - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - const bucket = listeners.get(event) ?? new Set(); - bucket.add(listener); - listeners.set(event, bucket); - }), - off: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.get(event)?.delete(listener); - }), - waitForEvent: vi.fn((event: string) => new Promise((resolve) => context.on(event, resolve))), - pages: vi.fn(() => pages.filter((page) => !page.isClosed())), - newPage: vi.fn(async () => { - const page = fakePage('about:blank'); - pages.push(page); - assignTarget(page); - return page; - }), - newCDPSession: vi.fn(async (target: object) => { - const pageSession = { - send: vi.fn(async (command: string, params?: unknown) => { - if (params === undefined) cdpSession.send(command); - else cdpSession.send(command, params); - if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; - return {}; - }), - detach: vi.fn().mockResolvedValue(undefined), - }; - pageCdpSessions.push(pageSession); - return pageSession; - }), - cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), - close: vi.fn().mockResolvedValue(undefined), - }; - let usedInitialPage = false; - cdpSession.send.mockImplementation(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { - if (command === 'Target.createTarget') { - const page = params?.hidden ? fakePage('about:blank') : usedInitialPage ? await context.newPage() : pages[0]; - if (params?.hidden) { - pages.push(page); - assignTarget(page); - } else { - usedInitialPage = true; - } - queueMicrotask(() => emit('page', page)); - return { targetId: targetIds.get(page) }; - } - if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; - if (command === 'Target.closeTarget') return { success: true }; - return {}; - }); - const provider = new LocalCloakRuntimeProvider({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(context), - // Commands now default to background, which routes a darwin launch through - // the background launcher; the fake context stands in for both. - launchBackgroundPersistentContext: vi.fn().mockResolvedValue(context), - }); - return { provider, browser, page: pages[0], pages, context, cdpSession, pageCdpSessions }; -} - -describe('LocalCloakRuntimeProvider', () => { - beforeEach(() => { - runBrowserProgram.mockReset(); - }); - - it('reports a runtime-named connected status before any profile launches', async () => { - const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test' }); - await expect(provider.status()).resolves.toMatchObject({ - runtimeConnected: true, - runtimeName: 'cloak', - profiles: [], - pending: 0, - }); - }); - - 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 session = await provider.createSession({ - id: 'create-doctor-session', - action: 'session-create', - contextId: 'default', - }); - - await provider.closeSession({ - id: 'close-doctor-session', - action: 'session-close', - contextId: 'default', - session: session.id, - surface: 'browser', - force: true, - discard: true, - }); - - await expect(provider.listSessions({ profileId: 'default' })).resolves.toEqual([]); - } finally { - fs.rmSync(baseDir, { recursive: true, force: true }); - } - }); - - 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 session = await provider.createSession({ - id: 'create-user-session', - action: 'session-create', - contextId: 'default', - }); - - await provider.closeSession({ - id: 'close-user-session', - action: 'session-close', - contextId: 'default', - session: session.id, - surface: 'browser', - discard: true, - }); - - await expect(provider.listSessions({ profileId: 'default' })).resolves.toMatchObject([ - { id: session.id, kind: 'explicit' }, - ]); - } finally { - fs.rmSync(baseDir, { recursive: true, force: true }); - } - }); - - it('navigates and returns page identity', async () => { - const { provider, page } = makeProviderWithFakePage(); - const result = await provider.dispatch({ - id: 'cmd-1', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - expect(result).toMatchObject({ id: 'cmd-1', ok: true, page: expect.any(String) }); - expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'load' })); - }); - - it.each([ - { label: 'omits windowMode', windowMode: undefined, background: true, focus: false }, - { label: 'asks for foreground', windowMode: 'foreground' as const, background: false, focus: true }, - ])('opens a window tab per the command that $label', async ({ windowMode, background, focus }) => { - const { provider, cdpSession } = makeProviderWithFakePage(); - for (const session of ['first', 'second']) { - await provider.dispatch({ - id: `nav-${session}`, - action: 'navigate', - session, - surface: 'browser', - url: `https://${session}.example/`, - profileId: 'default', - ...(windowMode ? { windowMode } : {}), - }); - } - - const windowTargets = cdpSession.send.mock.calls - .filter(([command, params]) => command === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden) - .map(([, params]) => params as { background?: boolean; focus?: boolean }); - expect(windowTargets.length).toBeGreaterThan(0); - for (const params of windowTargets) expect(params).toMatchObject({ background, focus }); - }); - - it("maps waitUntil 'none' to a commit-only navigation wait", async () => { - const { provider, page } = makeProviderWithFakePage(); - const result = await provider.dispatch({ - id: 'cmd-1', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - waitUntil: 'none', - profileId: 'default', - }); - expect(result).toMatchObject({ id: 'cmd-1', ok: true, page: expect.any(String) }); - expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'commit' })); - }); - - it('does not execute a queued command after its daemon deadline expires', async () => { - const { provider, page } = makeProviderWithFakePage(); - let releaseFirst!: () => void; - page.goto.mockImplementationOnce(() => new Promise((resolve) => { - releaseFirst = resolve; - })); - const first = provider.dispatch({ - id: 'first', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://first.example/', - profileId: 'default', - }); - await vi.waitFor(() => expect(page.goto).toHaveBeenCalledTimes(1)); - const second = provider.dispatch({ - id: 'second', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://late.example/', - profileId: 'default', - deadlineAt: Date.now() - 1, - }); - - releaseFirst(); - - await expect(first).resolves.toMatchObject({ ok: true }); - await expect(second).resolves.toMatchObject({ ok: false, errorCode: 'command_result_unknown' }); - expect(page.goto).toHaveBeenCalledTimes(1); - }); - - it('evaluates JavaScript in the resolved page', async () => { - const { provider } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', page: nav.page, code: '1 + 1', profileId: 'default' })) - .resolves.toMatchObject({ id: 'exec', ok: true, data: { ok: true }, page: nav.page }); - }); - - it('runs Playwright-style source against the selected Cloak page', async () => { - const { provider, browser, context, page } = makeProviderWithFakePage(); - runBrowserProgram.mockResolvedValue(runOutput('https://example.com/')); - - await expect(provider.dispatch({ - id: 'run', - action: 'run', - session: 'work', - surface: 'browser', - source: ` - return page.url(); - `, - profileId: 'default', - })).resolves.toMatchObject({ - id: 'run', - ok: true, - page: expect.any(String), - data: { - ok: true, - result: 'https://example.com/', - }, - }); - expect(runBrowserProgram).toHaveBeenCalledWith(expect.objectContaining({ - browser, - context, - page, - pages: expect.any(Function), - }), expect.stringContaining('return page.url()'), expect.objectContaining({ - snapshotDiff: undefined, - })); - }); - - it('browser-run receives only pages from the selected session', async () => { - const { provider, pages } = makeProviderWithFakePage(); - runBrowserProgram.mockResolvedValue(runOutput(null)); - await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); - await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await provider.dispatch({ - id: 'run', - action: 'run', - session: 'first', - surface: 'browser', - source: 'return context.pages().length;', - profileId: 'default', - }); - - expect(runBrowserProgram).toHaveBeenCalledWith(expect.objectContaining({ - pages: expect.any(Function), - }), expect.any(String), expect.any(Object)); - expect(runBrowserProgram.mock.calls[0][0].pages()).toEqual([pages[0]]); - }); - - it('preserves structured browser-run error details', async () => { - const { provider } = makeProviderWithFakePage(); - runBrowserProgram.mockRejectedValue(new BrowserRunError( - 'BROWSER_RUN_TIMEOUT', - 'Timed out', - undefined, - { - logs: [{ level: 'warn', args: ['started'] }], - page: { id: 'page-1', url: 'https://example.com/', title: 'Example' }, - artifacts: [], - warnings: [{ - code: 'BROWSER_RUN_SIDE_EFFECTS_MAY_HAVE_OCCURRED', - message: 'Already-issued browser actions were not rolled back.', - }], - limits: { outputTruncated: false, snapshotTruncated: false }, - }, - )); - - await expect(provider.dispatch({ - id: 'run', - action: 'run', - session: 'work', - surface: 'browser', - source: 'return null;', - profileId: 'default', - })).resolves.toMatchObject({ - ok: false, - errorCode: 'BROWSER_RUN_TIMEOUT', - details: { - logs: [{ level: 'warn', args: ['started'] }], - page: { id: 'page-1', url: 'https://example.com/', title: 'Example' }, - }, - }); - }); - - it('rejects oversized run source even when the daemon is called directly', async () => { - const { provider } = makeProviderWithFakePage(); - - await expect(provider.dispatch({ - id: 'run-large', - action: 'run', - session: 'work', - surface: 'browser', - source: 'x'.repeat(256 * 1024 + 1), - profileId: 'default', - })).resolves.toMatchObject({ - id: 'run-large', - ok: false, - errorCode: 'BROWSER_RUN_SOURCE_LIMIT', - }); - }); - - it('does not route existing exec commands through the QuickJS runner', async () => { - const { provider, page } = makeProviderWithFakePage(); - - await provider.dispatch({ - id: 'exec', - action: 'exec', - session: 'work', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - expect(page.evaluate).toHaveBeenCalledWith('document.title'); - }); - - it('serializes run and primitive commands for the same local session', async () => { - const { provider, page } = makeProviderWithFakePage(); - runBrowserProgram.mockImplementationOnce(async () => { - await new Promise(resolve => setTimeout(resolve, 30)); - return runOutput(1); - }); - const first = provider.dispatch({ - id: 'run', - action: 'run', - session: 'work', - surface: 'browser', - source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', - profileId: 'default', - }); - const second = provider.dispatch({ - id: 'exec', - action: 'exec', - session: 'work', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(page.evaluate).not.toHaveBeenCalled(); - await Promise.all([first, second]); - expect(page.evaluate).toHaveBeenCalledTimes(1); - }); - - it('serializes raw and adapter commands in the same explicit Session', async () => { - const { provider } = makeProviderWithFakePage(); - const manager = (provider as unknown as { manager: { - runWithProfileActivity(profileId: string, operation: () => Promise): Promise; - } }).manager; - const runWithProfileActivity = manager.runWithProfileActivity.bind(manager); - let active = 0; - let maxActive = 0; - vi.spyOn(manager, 'runWithProfileActivity').mockImplementation(async (profileId, operation) => { - active += 1; - maxActive = Math.max(maxActive, active); - try { - return await runWithProfileActivity(profileId, operation); - } finally { - active -= 1; - } - }); - let finishRun!: () => void; - runBrowserProgram.mockImplementationOnce(() => new Promise((resolve) => { - finishRun = () => resolve(runOutput(1)); - })); - const raw = provider.dispatch({ - id: 'raw-run', action: 'run', session: 'session_a', sessionKind: 'explicit', - surface: 'browser', source: 'return 1;', profileId: 'default', - }); - await vi.waitFor(() => expect(runBrowserProgram).toHaveBeenCalledTimes(1)); - const adapter = provider.dispatch({ - id: 'adapter-exec', action: 'exec', session: 'session_a', sessionKind: 'explicit', - surface: 'adapter', adapterSite: 'github', code: 'document.title', profileId: 'default', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(runBrowserProgram).toHaveBeenCalledTimes(1); - finishRun(); - await Promise.all([raw, adapter]); - expect(maxActive).toBe(1); - }); - - 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.bind(provider); - - expect(queueKey({ - id: 'github', - action: 'exec', - surface: 'adapter', - session: 'session_default', - sessionKind: 'adapter-default', - adapterSite: 'github', - profileId: 'default', - })).not.toBe(queueKey({ - id: 'linkedin', - action: 'exec', - surface: 'adapter', - session: 'session_default', - sessionKind: 'adapter-default', - adapterSite: 'linkedin', - profileId: 'default', - })); - expect(queueKey({ - id: 'github-explicit', - action: 'exec', - surface: 'adapter', - session: 'session_default', - sessionKind: 'explicit', - adapterSite: 'github', - profileId: 'default', - })).toBe(queueKey({ - id: 'linkedin-explicit', - action: 'exec', - surface: 'adapter', - session: 'session_default', - sessionKind: 'explicit', - adapterSite: 'linkedin', - profileId: 'default', - })); - expect(queueKey({ - id: 'github-explicit', action: 'exec', surface: 'adapter', session: 'session_a', - sessionKind: 'explicit', adapterSite: 'github', profileId: 'default', - })).toBe(queueKey({ - id: 'raw-explicit', action: 'exec', surface: 'browser', session: 'session_a', - sessionKind: 'explicit', profileId: 'default', - })); - }); - - 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.bind(provider); - const github = await provider.dispatch({ - id: 'github-nav', - action: 'navigate', - surface: 'adapter', - session: 'session_default', - sessionId: 'session_default', - sessionKind: 'adapter-default', - siteSession: 'persistent', - adapterSite: 'github', - profileId: 'default', - url: 'https://github.example/', - }); - const linkedin = await provider.dispatch({ - id: 'linkedin-nav', - action: 'navigate', - surface: 'adapter', - session: 'session_default', - sessionId: 'session_default', - sessionKind: 'adapter-default', - siteSession: 'persistent', - adapterSite: 'linkedin', - profileId: 'default', - url: 'https://linkedin.example/', - }); - - expect(queueKey({ - id: 'github-followup', - action: 'exec', - surface: 'adapter', - session: 'session_default', - page: github.page, - profileId: 'default', - })).not.toBe(queueKey({ - id: 'linkedin-followup', - action: 'exec', - surface: 'adapter', - session: 'session_default', - page: linkedin.page, - profileId: 'default', - })); - }); - - it('serializes commands by the resolved page lease when explicit page metadata differs', async () => { - const { provider, page } = makeProviderWithFakePage(); - runBrowserProgram.mockImplementationOnce(async () => { - await new Promise(resolve => setTimeout(resolve, 30)); - return runOutput(1); - }); - const nav = await provider.dispatch({ - id: 'nav', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - page.evaluate.mockClear(); - - const first = provider.dispatch({ - id: 'run', - action: 'run', - page: nav.page, - session: 'work', - surface: 'browser', - source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', - profileId: 'default', - }); - const second = provider.dispatch({ - id: 'exec', - action: 'exec', - page: nav.page, - session: 'work', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(page.evaluate).not.toHaveBeenCalled(); - await Promise.all([first, second]); - expect(page.evaluate).toHaveBeenCalledTimes(1); - }); - - it('does not adopt a sibling Session page created during browser-run', async () => { - const { provider } = makeProviderWithFakePage(); - runBrowserProgram.mockImplementationOnce(async (input) => { - await provider.dispatch({ - id: 'sibling', - action: 'tabs', - op: 'new', - session: 'session_b', - surface: 'browser', - profileId: 'default', - }); - expect(input.pages().map((candidate: { url(): string }) => candidate.url())) - .toEqual(['https://example.com/']); - return runOutput(null); - }); - await provider.dispatch({ - id: 'nav-a', - action: 'navigate', - session: 'session_a', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - await provider.dispatch({ - id: 'run-a', - action: 'run', - session: 'session_a', - surface: 'browser', - source: 'return null;', - profileId: 'default', - }); - }); - - it('denies misleading Session metadata for an owned page', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ - id: 'nav', - action: 'navigate', - session: 'session_a', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - await expect(provider.dispatch({ - id: 'bind', - action: 'bind', - page: nav.page, - session: 'session_b', - surface: 'browser', - profileId: 'default', - })).resolves.toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(page.bringToFront).not.toHaveBeenCalled(); - expect(page.evaluate).not.toHaveBeenCalled(); - }); - - it('evaluates JavaScript in the requested iframe', async () => { - const { provider, page } = makeProviderWithFakePage(); - const frame = { evaluate: vi.fn().mockResolvedValue('inside frame'), url: vi.fn(() => 'https://frame.example/'), name: vi.fn(() => 'frame') }; - page.frames.mockReturnValue([page, frame]); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', page: nav.page, frameIndex: 0, code: 'document.body.textContent', profileId: 'default' })) - .resolves.toMatchObject({ id: 'exec', ok: true, data: 'inside frame', page: nav.page }); - expect(frame.evaluate).toHaveBeenCalledWith('document.body.textContent'); - expect(page.evaluate).not.toHaveBeenCalledWith('document.body.textContent'); - }); - - it('returns a typed error when the requested iframe is out of range', async () => { - const { provider, page } = makeProviderWithFakePage(); - page.frames.mockReturnValue([page]); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', page: nav.page, frameIndex: 0, code: '1 + 1', profileId: 'default' })) - .resolves.toMatchObject({ - id: 'exec', - ok: false, - errorCode: 'frame_not_found', - error: 'Frame not found: 0', - page: nav.page, - }); - }); - - it('returns a typed stale page error instead of falling back when command.page is unknown', async () => { - const { provider, page } = makeProviderWithFakePage(); - await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', page: 'page-stale', code: '1 + 1', profileId: 'default' })) - .resolves.toMatchObject({ - id: 'exec', - ok: false, - errorCode: 'stale_page_identity', - error: 'Page not found: page-stale — stale page identity', - }); - expect(page.evaluate).not.toHaveBeenCalled(); - }); - - it('returns typed validation errors for missing navigate url and exec code', async () => { - const { provider } = makeProviderWithFakePage(); - - await expect(provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', profileId: 'default' })) - .resolves.toMatchObject({ id: 'nav', ok: false, errorCode: 'invalid_request', error: 'Missing url' }); - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', profileId: 'default' })) - .resolves.toMatchObject({ id: 'exec', ok: false, errorCode: 'invalid_request', error: 'Missing code' }); - }); - - it('returns filtered cookies from the resolved context', async () => { - const { provider } = makeProviderWithFakePage(); - - await expect(provider.dispatch({ id: 'cookies', action: 'cookies', session: 'work', surface: 'browser', domain: 'example.com', profileId: 'default' })) - .resolves.toMatchObject({ id: 'cookies', ok: true, data: [{ name: 'sid', value: '1', domain: 'example.com', path: '/' }] }); - }); - - it('captures screenshots as base64 and preserves page identity', async () => { - const { provider } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', fullPage: true, profileId: 'default' })) - .resolves.toMatchObject({ id: 'shot', ok: true, data: Buffer.from('image').toString('base64'), page: nav.page }); - }); - - it('applies screenshot width overrides with the current viewport height', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 900, profileId: 'default' }); - - expect(page.setViewportSize).toHaveBeenCalledWith({ width: 900, height: 720 }); - expect(page.screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: undefined })); - }); - - it('applies screenshot height overrides with the current viewport width', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', height: 480, profileId: 'default' }); - - expect(page.setViewportSize).toHaveBeenCalledWith({ width: 1280, height: 480 }); - }); - - it('restores the previous viewport after screenshot overrides', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 900, height: 480, profileId: 'default' }); - - expect(page.setViewportSize).toHaveBeenNthCalledWith(1, { width: 900, height: 480 }); - expect(page.setViewportSize).toHaveBeenNthCalledWith(2, { width: 1280, height: 720 }); - expect(page.screenshot).toHaveBeenCalledTimes(1); - }); - - it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => { - const { provider, page, cdpSession, pageCdpSessions } = makeProviderWithFakePage(null); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' }); - - // The override must not permanently pin the real window via setViewportSize (#120). - expect(page.setViewportSize).not.toHaveBeenCalled(); - expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 })); - // ...and it must be cleared afterward so the override is per-shot only. - expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride'); - expect(pageCdpSessions.some(session => session.detach.mock.calls.length > 0)).toBe(true); - expect(page.screenshot).toHaveBeenCalledTimes(1); - }); - - it('ignores screenshot height overrides for full-page captures while applying width', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', fullPage: true, width: 700, height: 480, profileId: 'default' }); - - expect(page.setViewportSize).toHaveBeenCalledWith({ width: 700, height: 720 }); - expect(page.screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true })); - }); - - it('requires an explicit Cloak 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', - }); - }); - - it('rejects binding a page owned by another Session', async () => { - const { provider, pages } = makeProviderWithFakePage(); - const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://signed-in.example/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - }); - - it('does not enumerate another Session page by bind index', async () => { - const { provider, pages } = makeProviderWithFakePage(); - await provider.dispatch({ id: 'nav', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); - await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', index: 1, profileId: 'default' })) - .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - }); - - it('returns a typed bind error when the requested Cloak tab is missing', async () => { - const { provider } = makeProviderWithFakePage(); - await provider.dispatch({ id: 'nav', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', page: 'missing-page', profileId: 'default' })) - .resolves.toMatchObject({ - id: 'bind', - ok: false, - errorCode: 'bound_tab_not_found', - error: 'Cloak tab not found for bind target', - }); - }); - - it('sets file input through the first matching locator', async () => { - const { provider, page } = makeProviderWithFakePage(); - const setInputFiles = vi.fn().mockResolvedValue(undefined); - page.locator = vi.fn().mockReturnValue({ first: () => ({ setInputFiles }) }); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - await expect(provider.dispatch({ id: 'upload', action: 'set-file-input', session: 'work', surface: 'browser', page: nav.page, files: ['/tmp/a.txt'], profileId: 'default' })) - .resolves.toMatchObject({ id: 'upload', ok: true, data: { count: 1 } }); - expect(setInputFiles).toHaveBeenCalledWith(['/tmp/a.txt']); - }); - - it('lists current tabs with page identities', async () => { - const { provider } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'tabs', action: 'tabs', op: 'list', session: 'work', surface: 'browser', profileId: 'default' })) - .resolves.toMatchObject({ - id: 'tabs', - ok: true, - data: [expect.objectContaining({ id: nav.page, page: nav.page, index: 0, url: 'https://example.com/' })], - }); - }); - - it('creates, selects, and closes tabs by command op', async () => { - const { provider, pages } = makeProviderWithFakePage(); - - const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - expect(created).toMatchObject({ id: 'new', ok: true, page: expect.any(String), data: { url: 'https://second.example/' } }); - expect(pages[0].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'load' })); - - await expect(provider.dispatch({ id: 'select', action: 'tabs', op: 'select', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'select', ok: true, page: created.page, data: { selected: true } }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - - await expect(provider.dispatch({ id: 'close', action: 'tabs', op: 'close', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } }); - expect(pages[0].close.mock.calls.length + pages[0].goto.mock.calls.filter(([url]) => url === 'about:blank').length).toBeGreaterThan(0); - }); - - it('does not bring selected tabs to front in background window mode', async () => { - const { provider, pages } = makeProviderWithFakePage(); - - const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await expect(provider.dispatch({ - id: 'select', - action: 'tabs', - op: 'select', - session: 'work', - surface: 'browser', - page: created.page, - profileId: 'default', - windowMode: 'background', - })).resolves.toMatchObject({ id: 'select', ok: true }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - }); - - it('does not bring bound tabs to front in background window mode', async () => { - const { provider, pages } = makeProviderWithFakePage(); - const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await expect(provider.dispatch({ - id: 'bind', - action: 'bind', - session: 'target', - surface: 'browser', - page: created.page, - profileId: 'default', - windowMode: 'background', - })).resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - }); - - it('does not bring bound tabs to front by default', async () => { - const { provider, pages } = makeProviderWithFakePage(); - const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'target', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); - expect(pages[0].bringToFront).not.toHaveBeenCalled(); - }); - - it('rejects a page identity from a different session', async () => { - const { provider, pages } = makeProviderWithFakePage(); - const first = await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); - const second = await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); - - await expect(provider.dispatch({ id: 'close-window', action: 'close-window', session: 'first', surface: 'browser', page: second.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'close-window', ok: true, data: { closed: false, page: second.page } }); - - await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'first', surface: 'browser', page: second.page, code: 'document.title', profileId: 'default' })) - .resolves.toMatchObject({ id: 'exec', ok: false, errorCode: 'stale_page_identity' }); - - expect(pages[0].isClosed()).toBe(false); - expect(pages[1].close).not.toHaveBeenCalled(); - expect(pages[1].evaluate).not.toHaveBeenCalled(); - expect(first.page).not.toBe(second.page); - }); -}); diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts deleted file mode 100644 index 8e8b6457..00000000 --- a/src/browser/runtime/local-cloak/provider.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 type { LaunchPersistentContext } from './session-manager.js'; -import { - CloakSessionManager, - resolveCloakBrowserVersion, -} from './session-manager.js'; - -export interface LocalCloakRuntimeProviderOptions { - baseDir?: string; - launchPersistentContext?: LaunchPersistentContext; - launchBackgroundPersistentContext?: LaunchPersistentContext; -} - -export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { - private readonly manager: CloakSessionManager; - private readonly sessions: LocalBrowserSessionStore; - private readonly sessionQueues = new Map>(); - - constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { - this.sessions = new LocalBrowserSessionStore({ - baseDir: opts.baseDir, - isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, - }); - this.manager = new CloakSessionManager({ - ...opts, - hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( - Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() - )), - }); - } - - async status(opts: RuntimeStatusOptions = {}): Promise { - const profiles = this.manager.profileStatuses(); - return { - runtimeConnected: true, - runtimeName: 'cloak', - runtimeVersion: resolveCloakBrowserVersion(), - profiles, - pending: 0, - commandResultUnknown: 0, - sessions: await this.listSessions({ profileId: opts.contextId }), - }; - } - - resolveProfileId(command: BrowserRuntimeCommand): string { - return resolveCloakCommandProfileId(this.manager, command); - } - - async createSession(command: BrowserRuntimeCommand): Promise { - return this.sessions.create(this.resolveProfileId(command)); - } - - async requireSession(command: BrowserRuntimeCommand): Promise { - return this.sessions.require(this.resolveProfileId(command), command.session); - } - - async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { - return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); - } - - async startSessionHandoff(command: BrowserRuntimeCommand): Promise { - const profileId = this.resolveProfileId(command); - const sessionId = command.sessionId!; - const record = this.sessions.markHandoff(profileId, sessionId, { - site: command.site!, - expiresAt: command.expiresAt!, - }); - await this.manager.foregroundSession(profileId, sessionId); - return record; - } - - async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { - return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); - } - - async listSessions(input: { profileId?: string; limit?: number }): Promise { - return this.sessions.list(input.profileId, input.limit).map((session) => ({ - ...session, - runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', - })); - } - - async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { - const record = this.sessions.require(this.resolveProfileId(command), command.session); - const closedCount = await this.manager.closeSession(record.profileId, record.id); - if (command.force && command.discard === true && record.kind === 'explicit' && !record.handoff) this.sessions.remove(record.profileId, record.id); - else if (command.force && record.handoff) this.sessions.clearHandoff(record.profileId, record.id); - else this.sessions.touch(record.profileId, record.id); - return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; - } - - async dispatch(rawCommand: BrowserRuntimeCommand, signal?: AbortSignal): Promise { - // Every dispatched command runs in the background unless the caller asked - // for a window explicitly, so no command steals focus by default. Session - // handoff bypasses dispatch and still foregrounds through foregroundSession. - const command: BrowserRuntimeCommand = rawCommand.windowMode - ? rawCommand - : { ...rawCommand, windowMode: 'background' }; - const key = this.commandQueueKey(command); - const previous = this.sessionQueues.get(key) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - this.sessionQueues.set(key, current); - - await previous.catch(() => {}); - try { - signal?.throwIfAborted(); - if (typeof command.deadlineAt === 'number' && command.deadlineAt > 0 && Date.now() >= command.deadlineAt) { - return { - id: command.id, - ok: false, - errorCode: 'command_result_unknown', - error: 'Command deadline expired before browser work started.', - }; - } - return await this.manager.runWithProfileActivity( - this.resolveProfileId(command), - () => dispatchCloakAction(this.manager, command, signal), - ); - } finally { - release(); - if (this.sessionQueues.get(key) === current) { - this.sessionQueues.delete(key); - } - } - } - - async shutdown(): Promise { - await this.manager.shutdown(); - } - - private commandQueueKey(command: BrowserRuntimeCommand): string { - if (command.page) { - const owner = this.manager.pageOwner(command.page); - if (owner) { - const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' - ? owner.adapterSite?.trim() - : undefined; - return `session\u0000${owner.profileId}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; - } - } - - let profileId: string; - try { - profileId = this.resolveProfileId(command); - } catch { - profileId = command.profileId - ?? command.contextId - ?? command.preferredContextId - ?? 'default'; - } - const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' - ? command.adapterSite?.trim() - : undefined; - return `session\u0000${profileId.trim() || 'default'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; - } -} diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts deleted file mode 100644 index ab1a9f21..00000000 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ /dev/null @@ -1,1667 +0,0 @@ -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 { log } from '../../../logger.js'; -import { dispatchCloakAction } from './actions.js'; - -function fakeContext() { - const listeners = new Map void>>(); - const cdpListeners = new Map void>>(); - const pageListeners = new WeakMap void>>>(); - const targetIds = new WeakMap(); - const windowIds = new Map(); - let targetCounter = 0; - let windowCounter = 0; - let context: any; - const emitPageEvent = (page: any, event: string, ...args: unknown[]) => { - for (const listener of pageListeners.get(page)?.get(event) ?? []) listener(...args); - }; - const fakePage = (opener?: any, windowId = ++windowCounter, initialUrl = 'https://example.com/') => { - let closed = false; - let currentUrl = initialUrl; - const page: any = { - goto: vi.fn().mockImplementation(async (url: string) => { - currentUrl = url; - }), - evaluate: vi.fn(async (fn: unknown, ...args: unknown[]) => { - if (typeof fn !== 'function' || !String(fn).includes('window.open')) return 'ok'; - const source = String(fn); - const popup = fakePage(source.includes('noopener') ? undefined : page, windowId, typeof args[0] === 'string' ? args[0] : 'about:blank'); - allPages.push(popup); - queueMicrotask(() => { - if (!source.includes('noopener')) emitPageEvent(page, 'popup', popup); - emit('page', popup); - }); - return null; - }), - title: vi.fn().mockResolvedValue('Title'), - url: vi.fn(() => currentUrl), - screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), - bringToFront: vi.fn().mockResolvedValue(undefined), - isClosed: vi.fn(() => closed), - close: vi.fn(async () => { - closed = true; - emitPageEvent(page, 'close'); - }), - opener: vi.fn().mockResolvedValue(opener ?? null), - on(event: string, listener: (...args: unknown[]) => void) { - const events = pageListeners.get(page) ?? new Map(); - const bucket = events.get(event) ?? new Set(); - bucket.add(listener); - events.set(event, bucket); - pageListeners.set(page, events); - }, - once(event: string, listener: (...args: unknown[]) => void) { - const once = (...args: unknown[]) => { - page.off(event, once); - listener(...args); - }; - page.on(event, once); - }, - off(event: string, listener: (...args: unknown[]) => void) { - pageListeners.get(page)?.get(event)?.delete(listener); - }, - waitForEvent(event: string) { - return new Promise((resolve, reject) => { - page.once(event, resolve); - setTimeout(() => reject(new Error(`Timeout waiting for ${event}`)), 0); - }); - }, - }; - const targetId = `target-${++targetCounter}`; - targetIds.set(page, targetId); - windowIds.set(targetId, windowId); - return page; - }; - const page = fakePage(); - const allPages = [page]; - const backgroundPages: ReturnType[] = []; - const emit = (event: string, ...args: unknown[]) => { - for (const listener of listeners.get(event) ?? []) listener(...args); - }; - const cdp = { - send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean; newWindow?: boolean }) => { - if (command === 'Target.createTarget') { - let backgroundPage; - if (params?.hidden) { - backgroundPage = fakePage(); - allPages.push(backgroundPage); - } else if (params?.newWindow === false) { - let opener; - for (let index = allPages.length - 1; index >= 0; index -= 1) { - if (!allPages[index]!.isClosed()) { - opener = allPages[index]; - break; - } - } - backgroundPage = fakePage(undefined, opener ? windowIds.get(targetIds.get(opener)!) : undefined); - allPages.push(backgroundPage); - } else { - backgroundPage = await context.newPage(); - } - backgroundPages.push(backgroundPage); - queueMicrotask(() => emit('page', backgroundPage)); - return { targetId: targetIds.get(backgroundPage) }; - } - if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; - if (command === 'Target.closeTarget') return { success: true }; - return {}; - }), - on: vi.fn((event: string, listener: (...args: any[]) => void) => { - const bucket = cdpListeners.get(event) ?? new Set(); - bucket.add(listener); - cdpListeners.set(event, bucket); - }), - detach: vi.fn().mockResolvedValue(undefined), - }; - return { - context: context = { - on(event: string, listener: (...args: unknown[]) => void) { - const bucket = listeners.get(event) ?? new Set(); - bucket.add(listener); - listeners.set(event, bucket); - }, - off(event: string, listener: (...args: unknown[]) => void) { - listeners.get(event)?.delete(listener); - }, - emit, - waitForEvent(event: string) { - return new Promise((resolve) => this.on(event, resolve)); - }, - pages: vi.fn(() => allPages.filter((page) => !page.isClosed())), - newPage: vi.fn(async () => { - const created = fakePage(); - allPages.push(created); - return created; - }), - newCDPSession: vi.fn(async (target: object) => ({ - send: vi.fn(async (command: string, params?: { targetId?: string }) => { - if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; - if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; - return {}; - }), - detach: vi.fn().mockResolvedValue(undefined), - })), - browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), - cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), - close: vi.fn().mockResolvedValue(undefined), - }, - page, - backgroundPages, - cdp, - targetIdFor: (target: object) => targetIds.get(target), - windowIdFor: (target: object) => windowIds.get(targetIds.get(target) ?? ''), - moveToWindow: (target: object, windowId: number) => windowIds.set(targetIds.get(target)!, windowId), - emitPage: (target: object) => emit('page', target), - emitPageEvent, - emitCdp: (event: string, payload: unknown) => { - for (const listener of cdpListeners.get(event) ?? []) listener(payload); - }, - pageListenerCount: (target: object, event: string) => pageListeners.get(target)?.get(event)?.size ?? 0, - makePage: fakePage, - }; -} - -function expectedProfileDir(profileId: string): string { - return path.join('/tmp/webcmd-test', 'cloak', 'profiles', profileId); -} - -describe('CloakSessionManager', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - 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({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - const first = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const second = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - expect(first.page).toBe(second.page); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); - }); - - it('correlates created targets and isolates Sessions into owned windows', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const first = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); - const second = await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); - - expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)) - .toHaveLength(2); - expect(launched.windowIdFor(first.page)).not.toBe(launched.windowIdFor(second.page)); - expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) - .map(tab => tab.sessionId)).toEqual(['session_a']); - }); - - 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({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const lease = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); - - expect(lease.page).toBe(launched.page); - expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)) - .toHaveLength(0); - expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) - .map(tab => tab.sessionId)).toEqual(['session_a']); - }); - - it('matches Target.createTarget by target id instead of adopting the next context page', async () => { - const launched = fakeContext(); - const unrelated = launched.makePage(); - const send = launched.cdp.send.getMockImplementation()!; - launched.cdp.send.mockImplementationOnce(async (method: string, params: unknown) => { - launched.emitPage(unrelated); - return send(method, params as { targetId?: string } | undefined); - }); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const lease = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); - - expect(lease.page).not.toBe(unrelated); - expect(manager.pageIdFor(unrelated)).toBeUndefined(); - expect(launched.targetIdFor(lease.page)).toEqual(expect.stringMatching(/^target-/)); - }); - - it('creates later Session pages with noopener and adopts the context page in the same window', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - - const second = await manager.newPage(key); - const evaluate = vi.mocked(first.page.evaluate); - expect(evaluate).toHaveBeenCalledTimes(1); - expect(String(evaluate.mock.calls[0]?.[0])).toContain('noopener'); - expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( - method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden - ))).toHaveLength(1); - expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); - expect(await second.page.opener()).toBeNull(); - expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); - }); - - it('falls back to another owned window when Chromium does not create the requested tab', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - vi.mocked(first.page.evaluate).mockResolvedValueOnce(null); - - const second = await manager.newPage(key); - - expect(launched.windowIdFor(second.page)).not.toBe(launched.windowIdFor(first.page)); - expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); - }); - - it('logs when window.open fails before falling back to another owned window', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - vi.mocked(first.page.evaluate).mockRejectedValueOnce(new Error('window.open blocked')); - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - vi.useFakeTimers(); - - const second = manager.newPage(key); - await vi.advanceTimersByTimeAsync(1_000); - - await expect(second).resolves.toMatchObject({ page: expect.any(Object) }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('window.open failed')); - }); - - it('ignores an unmarked opener-less page when waiting for a Session tab', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - const opened = launched.makePage(undefined, 999); - vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { - queueMicrotask(() => launched.emitPage(opened)); - return null; - }); - - vi.useFakeTimers(); - const secondPromise = manager.newPage(key); - await vi.advanceTimersByTimeAsync(1_000); - const second = await secondPromise; - - expect(second.page).not.toBe(opened); - expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( - method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden - ))).toHaveLength(2); - expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); - }); - - it('keeps a site popup owned while noopener tab creation uses another page', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - const popup = launched.makePage(first.page, 999); - vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { - queueMicrotask(() => { - launched.emitPageEvent(first.page, 'popup', popup); - launched.emitPage(popup); - }); - return null; - }); - - const second = await manager.newPage(key); - - expect(second.page).not.toBe(popup); - expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a', 'session_a']); - }); - - it('creates a later page in its Session window when another Session was used last', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const firstKey = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(firstKey); - await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); - - const second = await manager.newPage(firstKey); - - expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); - }); - - it('times out target correlation and releases the profile creation lock', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const send = launched.cdp.send.getMockImplementation()!; - launched.cdp.send.mockImplementation(async (method: string, params?: { hidden?: boolean }) => ( - method === 'Target.createTarget' && !params?.hidden - ? { targetId: 'missing-target' } - : send(method, params) - )); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - 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'); - await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.any(Object))); - await vi.advanceTimersByTimeAsync(1_000); - await missingExpectation; - - launched.cdp.send.mockImplementation(send); - const next = manager.getPage(key); - await vi.runAllTimersAsync(); - await expect(next).resolves.toMatchObject({ pageId: expect.any(String) }); - }); - - it('registers a child-window popup under its opener Session', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - const popup = launched.makePage(first.page, 999); - - launched.emitPage(popup); - await vi.waitFor(() => expect(manager.pageIdFor(popup)).toEqual(expect.any(String))); - - expect((await manager.listPages(key)).map(tab => tab.session)).toEqual(['session_a', 'session_a']); - expect(launched.windowIdFor(popup)).toBe(999); - }); - - it('rejects every operation after a Session page moves into another owned window', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; - const first = await manager.getPage(a); - const second = await manager.getPage(b); - launched.moveToWindow(first.page, launched.windowIdFor(second.page)!); - - await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - await expect(manager.selectPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - await expect(manager.bindPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - await expect(manager.closePage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - await expect(manager.closeSession(a.profileId, a.sessionId)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - expect(first.page.close).not.toHaveBeenCalled(); - expect(await manager.findPageById(second.pageId, a)).toBeNull(); - }); - - it('closes a Session when its page disappears during the ownership check', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const lease = await manager.getPage(key); - const send = launched.cdp.send.getMockImplementation()!; - - launched.cdp.send.mockImplementation(async (method: string, params?: { targetId?: string }) => { - if (method === 'Browser.getWindowForTarget' && params?.targetId === launched.targetIdFor(lease.page)) { - await lease.page.close(); - throw new Error('Protocol error (Browser.getWindowForTarget): No target with given id'); - } - return send(method, params); - }); - - await expect(manager.closeSession(key.profileId, key.sessionId)).resolves.toBe(1); - expect(await manager.listPages(key)).toEqual([]); - }); - - it('does not let another Session bind an owned page moved to an unowned window', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; - const first = await manager.getPage(a); - launched.moveToWindow(first.page, 999); - - await expect(manager.bindPage({ ...b, pageId: first.pageId })) - .rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - expect(first.page.close).not.toHaveBeenCalled(); - expect(await manager.listPages(b)).toEqual([]); - await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - }); - - it('checks opener window ownership before calling window.open', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; - const first = await manager.getPage(key); - launched.moveToWindow(first.page, 999); - - await expect(manager.newPage(key)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); - expect(first.page.evaluate).not.toHaveBeenCalled(); - expect(first.page.close).not.toHaveBeenCalled(); - }); - - it('binds an unowned context page without adopting another Session page', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - await manager.getPage({ profileId: 'default', session: 'session_a', surface: 'browser' }); - - const bound = await manager.bindPage({ - profileId: 'default', - session: 'session_b', - surface: 'browser', - index: 0, - }); - - expect(bound?.page).toBe(launched.page); - expect((await manager.listPages({ profileId: 'default', session: 'session_b' })).map(tab => tab.id)) - .toEqual([bound?.pageId]); - expect(await manager.listPages({ profileId: 'default', session: 'session_a' })).toHaveLength(1); - }); - - it.each([ - { platform: 'darwin', windowMode: 'background', backgroundCalls: 1, normalCalls: 0 }, - { platform: 'darwin', windowMode: 'foreground', backgroundCalls: 0, normalCalls: 1 }, - { platform: 'linux', windowMode: 'background', backgroundCalls: 0, normalCalls: 1 }, - ] as const)('routes a cold $platform $windowMode launch', async ({ platform, windowMode, backgroundCalls, normalCalls }) => { - const launched = fakeContext(); - const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform, - launchPersistentContext, - launchBackgroundPersistentContext, - }); - - await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', windowMode }); - - expect(launchBackgroundPersistentContext).toHaveBeenCalledTimes(backgroundCalls); - expect(launchPersistentContext).toHaveBeenCalledTimes(normalCalls); - }); - - it('reactivates a background-launched context for foreground tab selection', async () => { - const launched = fakeContext(); - const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchBackgroundPersistentContext: vi.fn().mockResolvedValue(launched.context), - activateBackgroundContext, - }); - const lease = await manager.getPage({ - profileId: 'default', - session: 'work', - surface: 'browser', - windowMode: 'background', - }); - - await manager.selectPage({ profileId: 'default', session: 'work', surface: 'browser', pageId: lease.pageId, windowMode: 'foreground' }); - - expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); - }); - - it('foregrounds only the selected Session window during handoff', async () => { - const launched = fakeContext(); - const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); - const manager = new CloakSessionManager({ - 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' }); - - await manager.foregroundSession('work', 'session_a'); - - expect(first.page.bringToFront).toHaveBeenCalledOnce(); - expect(sibling.page.bringToFront).not.toHaveBeenCalled(); - expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); - }); - - it('creates a warm background lease tab without focusing Chromium', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - await manager.getPage({ profileId: 'default', session: 'first', surface: 'adapter' }); - await manager.getPage({ - profileId: 'default', - session: 'second', - surface: 'adapter', - windowMode: 'background', - }); - - expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { - url: 'about:blank', - newWindow: true, - background: true, - focus: false, - }); - }); - - it('creates an explicit background tab without focusing Chromium', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); - await manager.newPage({ - profileId: 'default', - session: 'background', - surface: 'browser', - windowMode: 'background', - }); - - expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { - url: 'about:blank', - newWindow: true, - background: true, - focus: false, - }); - }); - - it('creates an explicit foreground tab in a new CDP window', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - await manager.newPage({ - profileId: 'default', - session: 'foreground', - surface: 'browser', - windowMode: 'foreground', - }); - - expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { - url: 'about:blank', - newWindow: true, - background: false, - focus: true, - }); - }); - - it('gives concurrent background tabs distinct pages', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - await manager.getPage({ profileId: 'default', session: 'warm', surface: 'browser' }); - const firstRequest = manager.newPage({ - profileId: 'default', - session: 'first', - surface: 'browser', - windowMode: 'background', - }); - const secondRequest = manager.newPage({ - profileId: 'default', - session: 'second', - surface: 'browser', - windowMode: 'background', - }); - const [first, second] = await Promise.all([firstRequest, secondRequest]); - - expect(first.page).not.toBe(second.page); - expect(launched.backgroundPages.slice(-2)).toEqual([first.page, second.page]); - }); - - it('coalesces concurrent same-lease page acquisition', async () => { - const launched = fakeContext(); - launched.context.newPage.mockResolvedValue(fakeContext().page); - let resolveLaunch!: (context: BrowserContext) => void; - const launchPersistentContext = vi.fn(() => new Promise((resolve) => { - resolveLaunch = resolve; - })); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - const firstPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - const secondPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - await Promise.resolve(); - - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - resolveLaunch(launched.context as unknown as BrowserContext); - const [first, second] = await Promise.all([firstPage, secondPage]); - - expect(first.context).toBe(launched.context); - expect(second.context).toBe(launched.context); - expect(first.page).toBe(second.page); - expect(first.pageId).toBe(second.pageId); - expect(launched.context.newPage).toHaveBeenCalledOnce(); - expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.objectContaining({ newWindow: true })); - }); - - it('evicts a closed runtime and clears every tracked page resource', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const stopCapture = vi.spyOn(manager.networkCapture, 'stop'); - - const first = await manager.getPage({ profileId: 'default', session: 'one', surface: 'browser', idleTimeout: 25 }); - const second = await manager.newPage({ profileId: 'default', session: 'two', surface: 'browser', idleTimeout: 25 }); - expect(manager.activeProfileIds()).toEqual(['default']); - expect(vi.getTimerCount()).toBe(2); - - launched.context.emit('close'); - - expect(manager.activeProfileIds()).toEqual([]); - expect(manager.profileStatuses()).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - expect(stopCapture).toHaveBeenCalledTimes(2); - expect(stopCapture).toHaveBeenCalledWith(first.page); - expect(stopCapture).toHaveBeenCalledWith(second.page); - await vi.advanceTimersByTimeAsync(25); - expect(first.page.close).not.toHaveBeenCalled(); - expect(second.page.close).not.toHaveBeenCalled(); - }); - - it('does not let a late close from an old runtime evict its replacement', async () => { - const first = fakeContext(); - const replacement = fakeContext(); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); - first.context.emit('close'); - const replacementLease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' }); - - first.context.emit('close'); - - expect(manager.activeProfileIds()).toEqual(['default']); - expect(manager.profileStatuses()).toHaveLength(1); - expect((await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' })).context) - .toBe(replacementLease.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('coalesces simultaneous replacement launches after a context closes', async () => { - const first = fakeContext(); - const replacement = fakeContext(); - let resolveReplacement!: (context: BrowserContext) => void; - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockImplementationOnce(() => new Promise((resolve) => { - resolveReplacement = resolve; - })); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); - first.context.emit('close'); - - const one = manager.getPage({ profileId: 'default', session: 'one', surface: 'browser' }); - const two = manager.getPage({ profileId: 'default', session: 'two', surface: 'browser' }); - await Promise.resolve(); - - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - resolveReplacement(replacement.context as unknown as BrowserContext); - const leases = await Promise.all([one, two]); - expect(leases[0].context).toBe(replacement.context); - expect(leases[1].context).toBe(replacement.context); - }); - - it('discards a page created after its runtime closes and defers recovery to the next command', async () => { - const first = fakeContext(); - first.context.pages.mockReturnValue([]); - let resolveFirstPage!: (page: typeof first.page) => void; - let markPageCreationStarted!: () => void; - const pageCreationStarted = new Promise((resolve) => { - markPageCreationStarted = resolve; - }); - first.context.newPage.mockImplementation(() => { - markPageCreationStarted(); - return new Promise((resolve) => { - resolveFirstPage = resolve; - }); - }); - const replacement = fakeContext(); - replacement.context.pages.mockReturnValue([]); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const pendingLease = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - await pageCreationStarted; - first.context.emit('close'); - resolveFirstPage(first.page); - - await expect(pendingLease).rejects.toThrow('Target page, context or browser has been closed'); - expect(first.page.close).toHaveBeenCalled(); - expect(manager.activeProfileIds()).toEqual([]); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - - const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - expect(lease.context).toBe(replacement.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('does not publish an orphaned page after acquisition validation', async () => { - vi.useFakeTimers(); - const first = fakeContext(); - first.context.pages.mockReturnValue([]); - first.context.newPage.mockImplementation(() => { - queueMicrotask(() => first.context.emit('close')); - return Promise.resolve(first.page); - }); - const replacement = fakeContext(); - replacement.context.pages.mockReturnValue([]); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ 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'); - - expect(manager.activeProfileIds()).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - const lease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' }); - expect(lease.context).toBe(replacement.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('retries getPage page creation once after a closed-context failure', async () => { - const closed = new Error('Target page, context or browser has been closed'); - const first = fakeContext(); - first.context.pages.mockReturnValue([]); - first.context.newPage.mockRejectedValue(closed); - const replacement = fakeContext(); - replacement.context.pages.mockReturnValue([]); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - expect(lease.context).toBe(replacement.context); - expect(first.context.newPage).toHaveBeenCalledTimes(1); - expect(replacement.context.newPage).toHaveBeenCalledTimes(1); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('invalidates a reused getPage() lease when the CDP liveness probe finds a dead context (webcmd#314)', async () => { - const first = fakeContext(); - const replacement = fakeContext(); - replacement.context.pages.mockReturnValue([]); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const firstLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - expect(firstLease.context).toBe(first.context); - - // Simulate the issue's repro: isClosed() still reports false, but the - // underlying CDP connection is dead, so the liveness probe used on reuse - // (Browser.getWindowForTarget, via assertOwnedWindow) fails. - first.cdp.send.mockImplementation(async (command: string) => { - if (command === 'Browser.getWindowForTarget') { - throw new Error('Target page, context or browser has been closed'); - } - return {}; - }); - - // isClosed() still reports false right up to the reuse attempt — the fast - // path's precondition holds; only the liveness probe catches the dead lease. - expect(firstLease.page.isClosed()).toBe(false); - const secondLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - expect(secondLease.context).toBe(replacement.context); - expect(secondLease.page).not.toBe(firstLease.page); - expect((firstLease.page as unknown as { close: ReturnType }).close).toHaveBeenCalled(); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('retries explicit newPage page creation once after a closed-context failure', async () => { - const closed = new Error('browserContext.newPage: Target page, context or browser has been closed'); - const first = fakeContext(); - first.context.newPage.mockRejectedValue(closed); - const replacement = fakeContext(); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - expect(lease.context).toBe(replacement.context); - expect(first.context.newPage).toHaveBeenCalledTimes(1); - expect(replacement.context.newPage).toHaveBeenCalledTimes(1); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('returns the second closed-context page creation failure without looping', async () => { - const firstFailure = new Error('Target page, context or browser has been closed'); - const secondFailure = new Error('Target page, context or browser has been closed again'); - const first = fakeContext(); - first.context.newPage.mockRejectedValue(firstFailure); - const replacement = fakeContext(); - replacement.context.newPage.mockRejectedValue(secondFailure); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - await expect(manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' })) - .rejects.toBe(secondFailure); - expect(first.context.newPage).toHaveBeenCalledTimes(1); - expect(replacement.context.newPage).toHaveBeenCalledTimes(1); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('keeps an explicitly navigated page untracked until navigation succeeds', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - let resolveNavigation!: () => void; - let markNavigationStarted!: () => void; - const navigationStarted = new Promise((resolve) => { - markNavigationStarted = resolve; - }); - launched.page.goto.mockImplementation(() => { - markNavigationStarted(); - return new Promise((resolve) => { - resolveNavigation = resolve; - }); - }); - launched.context.newPage.mockResolvedValue(launched.page); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const pendingLease = manager.newPage({ - profileId: 'default', - session: 'work', - surface: 'browser', - idleTimeout: 25, - url: 'https://example.com/', - }); - await navigationStarted; - const pagesDuringNavigation = await manager.listPages({ profileId: 'default', session: 'work' }); - const pageIdDuringNavigation = manager.pageIdFor(launched.page as unknown as PlaywrightPage); - const timersDuringNavigation = vi.getTimerCount(); - resolveNavigation(); - const lease = await pendingLease; - - expect(pagesDuringNavigation).toEqual([]); - expect(pageIdDuringNavigation).toBeUndefined(); - expect(timersDuringNavigation).toBe(0); - expect(manager.pageIdFor(launched.page as unknown as PlaywrightPage)).toBe(lease.pageId); - expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); - expect(vi.getTimerCount()).toBe(1); - }); - - it('retries initial navigation once after a closed-context failure', async () => { - const navigationFailure = new Error('Target page, context or browser has been closed'); - const launched = fakeContext(); - launched.page.goto.mockRejectedValue(navigationFailure); - launched.context.newPage.mockResolvedValue(launched.page); - const replacement = fakeContext(); - replacement.context.newPage.mockResolvedValue(replacement.page); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(launched.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const lease = await manager.newPage({ - profileId: 'default', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - }); - - expect(lease.context).toBe(replacement.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - expect(launched.page.goto).toHaveBeenCalledTimes(1); - expect(launched.page.close).toHaveBeenCalledTimes(1); - expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); - 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 () => { - const launched = fakeContext(); - const launchPersistentContext = vi.fn() - .mockRejectedValueOnce(new Error('browserType.launchPersistentContext: Opening in existing browser session.')) - .mockResolvedValueOnce(launched.context); - const recoverLockedProfile = vi.fn().mockResolvedValue(true); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - recoverLockedProfile, - }); - - const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); - - expect(lease.context).toBe(launched.context); - expect(recoverLockedProfile).toHaveBeenCalledWith(expectedProfileDir('default')); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('freshPage closes the existing persistent lease page and creates a new one', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'site:district', surface: 'adapter' as const, siteSession: 'persistent' as const }; - - const first = await manager.getPage(key); - expect((await manager.getPage(key)).page).toBe(first.page); - - const fresh = await manager.getPage({ ...key, freshPage: true }); - expect(first.page.close).toHaveBeenCalled(); - expect(fresh.page).not.toBe(first.page); - - const reused = await manager.getPage(key); - expect(reused.page).toBe(fresh.page); - }); - - it('keeps persistent adapter pages separate by Session and site', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const base = { - profileId: 'default', - session: 'session_a', - sessionId: 'session_a', - surface: 'adapter' as const, - siteSession: 'persistent' as const, - }; - - const githubA = await manager.getPage({ ...base, adapterSite: 'github' }); - const linkedinA = await manager.getPage({ ...base, adapterSite: 'linkedin' }); - const githubB = await manager.getPage({ ...base, session: 'session_b', sessionId: 'session_b', adapterSite: 'github' }); - - expect(linkedinA.page).not.toBe(githubA.page); - expect(githubB.page).not.toBe(githubA.page); - expect((await manager.getPage({ ...base, adapterSite: 'github' })).page).toBe(githubA.page); - }); - - it('keys ephemeral adapter pages by Session, site, and run', () => { - const base = { - session: 'session_a', - sessionId: 'session_a', - surface: 'adapter' as const, - siteSession: 'ephemeral' as const, - }; - - expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })) - .toBe('session_a\0ephemeral:github:run_a'); - expect(resolveLeaseKey({ ...base, adapterSite: 'linkedin', runId: 'run_a' })) - .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); - expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_b' })) - .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); - }); - - it('freshPage never adopts a leftover context tab', async () => { - const launched = fakeContext(); - const leftover = launched.page; - const created = fakeContext().page; - launched.context.newPage.mockResolvedValue(created); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const lease = await manager.getPage({ profileId: 'default', session: 'site:district', surface: 'adapter', siteSession: 'persistent', freshPage: true }); - expect(lease.page).toBe(created); - expect(lease.page).not.toBe(leftover); - }); - - it('closes ephemeral adapter sessions when released', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const, adapterSite: 'github', runId: 'run_a' }; - const lease = await manager.getPage(key); - await manager.release(key); - expect(lease.page.close).toHaveBeenCalled(); - }); - - it('releases only the owning ephemeral adapter site and run', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const base = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const }; - const github = { ...base, adapterSite: 'github', runId: 'run_a' }; - const linkedin = { ...base, adapterSite: 'linkedin', runId: 'run_b' }; - const githubLease = await manager.getPage(github); - const linkedinLease = await manager.getPage(linkedin); - - await manager.release(github); - - expect(githubLease.page.close).toHaveBeenCalledOnce(); - expect(linkedinLease.page.close).not.toHaveBeenCalled(); - expect((await manager.getPage(linkedin)).page).toBe(linkedinLease.page); - }); - - it('keeps persistent adapter pages tracked when release is requested', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'persistent' as const, adapterSite: 'github', runId: 'run_a' }; - const lease = await manager.getPage(key); - - await manager.release(key); - - expect(lease.page.close).not.toHaveBeenCalled(); - await expect(manager.listPages(key)).resolves.toHaveLength(1); - expect((await manager.getPage(key)).page).toBe(lease.page); - }); - - it('closes non-persistent leases when their idle timeout expires', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); - - await vi.advanceTimersByTimeAsync(24); - expect(lease.page.close).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(lease.page.close).toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default', session: 'work' })).toEqual([]); - }); - - it('refreshes an idle timeout when a lease is reused', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const first = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); - - await vi.advanceTimersByTimeAsync(20); - const second = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); - expect(second.page).toBe(first.page); - await vi.advanceTimersByTimeAsync(20); - expect(first.page.close).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(5); - expect(first.page.close).toHaveBeenCalled(); - }); - - it('does not close persistent site sessions when their idle timeout expires', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - const lease = await manager.getPage({ profileId: 'default', session: 'site:x:uuid', surface: 'adapter', siteSession: 'persistent', idleTimeout: 25 }); - - await vi.advanceTimersByTimeAsync(25); - - expect(lease.page.close).not.toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default', session: 'site:x:uuid' })).toHaveLength(1); - }); - - 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({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - await dispatchCloakAction(manager, { - id: 'cmd-preferred', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - preferredContextId: 'profile-default', - }); - - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launchPersistentContext.mock.calls[0][0].userDataDir).toBe(expectedProfileDir('profile-default')); - }); - - it('retries action navigation once after a closed-context failure', async () => { - const first = fakeContext(); - first.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed')); - first.context.newPage.mockResolvedValue(first.page); - const replacement = fakeContext(); - replacement.context.newPage.mockResolvedValue(replacement.page); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - const result = await dispatchCloakAction(manager, { - id: 'cmd-retry-navigation', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - - expect(result).toMatchObject({ ok: true }); - expect(first.page.goto).toHaveBeenCalledTimes(1); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); - }); - - it('does not invalidate a replacement runtime when stale navigation fails', async () => { - const first = fakeContext(); - first.context.newPage.mockResolvedValue(first.page); - let rejectNavigation!: (error: Error) => void; - first.page.goto.mockImplementationOnce(() => new Promise((_, reject) => { rejectNavigation = reject; })); - const replacement = fakeContext(); - replacement.context.newPage.mockResolvedValue(replacement.page); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const manager = new CloakSessionManager({ 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'); - await vi.waitFor(() => expect(first.page.goto).toHaveBeenCalledTimes(1)); - first.context.emit('close'); - const replacementLease = await manager.getPage(key); - rejectNavigation(new Error('Target page, context or browser has been closed')); - - await expect(navigation).resolves.toMatchObject({ context: replacement.context }); - expect(replacementLease.context).toBe(replacement.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - 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({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - await dispatchCloakAction(manager, { - id: 'cmd-active', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - contextId: 'active', - }); - await dispatchCloakAction(manager, { - id: 'cmd-stale-default', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/next', - preferredContextId: 'stale-default', - }); - - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launchPersistentContext.mock.calls[0][0].userDataDir).toBe(expectedProfileDir('active')); - }); - - 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({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext, - }); - - await dispatchCloakAction(manager, { - id: 'cmd-a', - action: 'navigate', - session: 'work-a', - surface: 'browser', - url: 'https://example.com/a', - contextId: 'profile-a', - }); - await dispatchCloakAction(manager, { - id: 'cmd-b', - action: 'navigate', - session: 'work-b', - surface: 'browser', - url: 'https://example.com/b', - contextId: 'profile-b', - }); - - const result = await dispatchCloakAction(manager, { - id: 'cmd-stale', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - preferredContextId: 'stale-default', - }); - - expect(result).toMatchObject({ - id: 'cmd-stale', - ok: false, - errorCode: 'profile_required', - }); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); - - it('does not publish a runtime until its hidden keeper exists', async () => { - const launched = fakeContext(); - const send = launched.cdp.send.getMockImplementation()!; - let resolveAnchor!: () => void; - launched.cdp.send.mockImplementationOnce(() => new Promise((resolve) => { - resolveAnchor = () => resolve({ targetId: 'anchor-target' }); - })); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { - url: 'about:blank', - hidden: true, - background: true, - })); - expect(manager.activeProfileIds()).toEqual([]); - - resolveAnchor(); - launched.cdp.send.mockImplementation(send); - await pending; - expect(manager.activeProfileIds()).toEqual(['work']); - }); - - it('keeps an empty profile warm for sixty seconds before closing it', async () => { - vi.useFakeTimers(); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'linux', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - - await vi.advanceTimersByTimeAsync(59_999); - expect(manager.activeProfileIds()).toEqual(['work']); - expect(launched.context.close).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(manager.activeProfileIds()).toEqual([]); - expect(launched.context.close).toHaveBeenCalledOnce(); - }); - - it('fences a launch that finishes after shutdown starts', async () => { - const launched = fakeContext(); - let resolveLaunch!: (context: BrowserContext) => void; - const launchPersistentContext = vi.fn(() => new Promise((resolve) => { - resolveLaunch = resolve; - })); - const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - - const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await vi.waitFor(() => expect(launchPersistentContext).toHaveBeenCalledOnce()); - const shutdown = manager.shutdown(); - resolveLaunch(launched.context as unknown as BrowserContext); - - await shutdown; - await expect(pending).rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); - expect(launched.context.close).toHaveBeenCalledOnce(); - expect(manager.activeProfileIds()).toEqual([]); - await expect(manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' })) - .rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); - expect(launchPersistentContext).toHaveBeenCalledOnce(); - }); - - it('falls back to a parking keeper when macOS rejects the hidden target', async () => { - const launched = fakeContext(); - const send = launched.cdp.send.getMockImplementation()!; - launched.cdp.send.mockImplementation((method: string, params?: { hidden?: boolean }) => ( - method === 'Target.createTarget' && params?.hidden - ? Promise.reject(new Error('hidden targets unsupported')) - : send(method, params) - )); - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - - expect(manager.activeProfileIds()).toEqual(['work']); - expect(lease.page.goto).toHaveBeenLastCalledWith('about:blank', { waitUntil: 'load' }); - expect(lease.page.close).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledOnce(); - expect(launched.cdp.detach).not.toHaveBeenCalled(); - await manager.shutdown(); - expect(launched.cdp.detach).toHaveBeenCalledOnce(); - }); - - it('uses a parking keeper when the persistent context exposes no browser', async () => { - const launched = fakeContext(); - launched.context.browser.mockReturnValue(null); - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - - const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - expect(lease.context).toBe(launched.context); - expect(manager.activeProfileIds()).toEqual(['work']); - expect(warn).toHaveBeenCalledOnce(); - }); - - it.each(['linux', 'win32'] as const)('reuses a warm %s profile and replaces its parking page on the next Session', async (platform) => { - vi.useFakeTimers(); - const launched = fakeContext(); - const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform, - launchPersistentContext, - }); - const first = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - await vi.advanceTimersByTimeAsync(59_999); - - const second = await manager.runWithProfileActivity('work', () => ( - manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }) - )); - - expect(second.context).toBe(first.context); - expect(second.page).not.toBe(first.page); - expect(first.page.close).toHaveBeenCalledOnce(); - expect(await manager.listPages({ profileId: 'work', session: 'session_a' })).toEqual([]); - expect(await manager.listPages({ profileId: 'work', session: 'session_b' })).toHaveLength(1); - expect(launchPersistentContext).toHaveBeenCalledOnce(); - }); - - it('rechecks an empty profile after its active handoff expires', async () => { - vi.useFakeTimers(); - let handoffActive = true; - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - hasActiveHandoff: () => handoffActive, - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - - await vi.advanceTimersByTimeAsync(60_000); - expect(manager.activeProfileIds()).toEqual(['work']); - handoffActive = false; - await vi.advanceTimersByTimeAsync(60_000); - - expect(manager.activeProfileIds()).toEqual(['work']); - await vi.advanceTimersByTimeAsync(60_000); - - expect(manager.activeProfileIds()).toEqual([]); - expect(launched.context.close).toHaveBeenCalledOnce(); - }); - - it('starts a fresh idle grace when handoff expiry is observed near a wakeup boundary', async () => { - vi.useFakeTimers(); - let handoffActive = true; - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - hasActiveHandoff: () => handoffActive, - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - - await vi.advanceTimersByTimeAsync(119_999); - handoffActive = false; - await vi.advanceTimersByTimeAsync(1); - expect(manager.activeProfileIds()).toEqual(['work']); - - await vi.advanceTimersByTimeAsync(59_999); - expect(manager.activeProfileIds()).toEqual(['work']); - await vi.advanceTimersByTimeAsync(1); - - expect(manager.activeProfileIds()).toEqual([]); - expect(launched.context.close).toHaveBeenCalledOnce(); - }); - - it('unrefs the profile idle timer', async () => { - const timer = setTimeout(() => {}, 0); - const unref = vi.spyOn(Object.getPrototypeOf(timer), 'unref'); - clearTimeout(timer); - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - - await manager.closeSession('work', 'session_a'); - - expect(unref).toHaveBeenCalled(); - await manager.shutdown(); - }); - - it('repairs one anchor for duplicate destruction and page-close notifications', async () => { - const launched = fakeContext(); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'darwin', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - const anchor = launched.backgroundPages[0]; - const anchorTargetId = launched.targetIdFor(anchor)!; - launched.emitPage(anchor); - await vi.waitFor(() => expect(launched.pageListenerCount(anchor, 'close')).toBeGreaterThan(0)); - - launched.emitCdp('Target.targetDestroyed', { targetId: anchorTargetId }); - await anchor.close(); - await vi.waitFor(() => expect(launched.cdp.send.mock.calls.filter(([, params]) => ( - (params as { hidden?: boolean })?.hidden - ))).toHaveLength(2)); - await Promise.resolve(); - - expect(launched.cdp.send.mock.calls.filter(([, params]) => ( - (params as { hidden?: boolean })?.hidden - ))).toHaveLength(2); - }); - - it('recovers one timed-out idle close before launching one replacement', async () => { - vi.useFakeTimers(); - const first = fakeContext(); - first.context.close.mockImplementation(() => new Promise(() => {})); - const replacement = fakeContext(); - const launchPersistentContext = vi.fn() - .mockResolvedValueOnce(first.context) - .mockResolvedValueOnce(replacement.context); - const recoverLockedProfile = vi.fn().mockResolvedValue(true); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - platform: 'linux', - launchPersistentContext, - recoverLockedProfile, - }); - await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); - await manager.closeSession('work', 'session_a'); - await vi.advanceTimersByTimeAsync(60_000); - - const one = manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }); - const two = manager.getPage({ profileId: 'work', session: 'session_c', surface: 'browser' }); - await vi.advanceTimersByTimeAsync(3_000); - const leases = await Promise.all([one, two]); - - expect(recoverLockedProfile).toHaveBeenCalledOnce(); - expect(leases[0].context).toBe(replacement.context); - expect(leases[1].context).toBe(replacement.context); - expect(launchPersistentContext).toHaveBeenCalledTimes(2); - }); -}); - -describe('waitUntil plumbing', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - function managerWithPage() { - const launched = fakeContext(); - launched.context.newPage.mockResolvedValue(launched.page); - const manager = new CloakSessionManager({ - baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(launched.context), - }); - return { manager, page: launched.page }; - } - - // 'none' has to reach Playwright as 'commit'. Waiting for 'load' on a site that - // never goes idle is the hang #106 was filed about. - it('maps waitUntil none to commit when opening a tab with a url', async () => { - const { manager, page } = managerWithPage(); - - await dispatchCloakAction(manager, { - id: 'cmd-tab-none', - action: 'tabs', - op: 'new', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - waitUntil: 'none', - }); - - expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'commit' }); - }); - - it('defaults a tab opened without waitUntil to load', async () => { - const { manager, page } = managerWithPage(); - - await dispatchCloakAction(manager, { - id: 'cmd-tab-default', - action: 'tabs', - op: 'new', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - }); - - expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); - }); - - it('still maps waitUntil none to commit on navigate', async () => { - const { manager, page } = managerWithPage(); - - await dispatchCloakAction(manager, { - id: 'cmd-navigate-none', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - waitUntil: 'none', - }); - - expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'commit' }); - }); - - it('defaults navigate without waitUntil to load', async () => { - const { manager, page } = managerWithPage(); - - await dispatchCloakAction(manager, { - id: 'cmd-navigate-default', - action: 'navigate', - session: 'work', - surface: 'browser', - url: 'https://example.com/', - }); - - expect(page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); - }); -}); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts deleted file mode 100644 index 8778add2..00000000 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ /dev/null @@ -1,1401 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; -import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; -import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; -import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js'; -import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; -import { CloakNetworkCapture } from './network.js'; -import { findPackageRoot } from '../../../package-paths.js'; -import { findExactCloakProfileProcesses } from './process-matcher.js'; -import { log } from '../../../logger.js'; -import { CliError, EXIT_CODES } from '../../../errors.js'; -import { isClosedContextError } from '../../run/types.js'; - -const UNRESOLVED = Symbol('unresolved'); -const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; -export const PROFILE_IDLE_TIMEOUT_MS = 60_000; -export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; -let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; - -/** - * Installed `cloakbrowser` npm package version, for doctor/status display. - * - * Resolved once per process. The version cannot change while we are running, and - * `profileStatuses()` calls this per profile, so an uncached read meant N+1 - * synchronous resolve-read-parse cycles on every status poll. The sentinel keeps - * a genuine `undefined` (the catch path) cached too, so an unresolvable - * `cloakbrowser` is not retried on every call. - */ -export function resolveCloakBrowserVersion(): string | undefined { - if (cachedCloakBrowserVersion !== UNRESOLVED) return cachedCloakBrowserVersion; - try { - const entryPath = fileURLToPath(import.meta.resolve('cloakbrowser')); - const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8')) as { version?: unknown }; - cachedCloakBrowserVersion = typeof pkg.version === 'string' ? pkg.version : undefined; - } catch { - cachedCloakBrowserVersion = undefined; - } - return cachedCloakBrowserVersion; -} - -export type LaunchPersistentContext = typeof cloakLaunchPersistentContext; -export type RecoverLockedProfile = (userDataDir: string) => Promise; - -export interface SessionKeyInput { - profileId?: string; - session?: string; - surface?: BrowserSurface; - siteSession?: SiteSessionMode; - sessionKind?: 'explicit' | 'adapter-default'; - sessionId?: string; - adapterSite?: string; - runId?: string; - idleTimeout?: number; - windowMode?: BrowserWindowMode; - /** Discard the existing leased page (if any) and create a new one under the same lease. */ - freshPage?: boolean; -} - -export type NewPageInput = SessionKeyInput & { - url?: string; - /** - * Playwright `goto` readiness for `url`, already translated from the command - * vocabulary by the caller. Defaults to 'load'; 'commit' skips waiting for the - * load event on sites that never go idle. - */ - waitUntil?: 'load' | 'commit'; -}; - -type PageEntry = { - page: PlaywrightPage; - pageId: string; - targetId: string; - leaseKey: string; - sessionId?: string; - session: string; - surface: BrowserSurface; - siteSession?: SiteSessionMode; - sessionKind?: 'explicit' | 'adapter-default'; - adapterSite?: string; - idleTimeout?: number; - idleTimer?: ReturnType; -}; - -export interface CloakPageLease { - profileId: string; - leaseKey: string; - context: BrowserContext; - page: PlaywrightPage; - pageId: string; -} - -export interface CloakTabInfo { - id: string; - page: string; - index: number; - title: string; - url: string; - profileId: string; - session: string; - sessionId: string; - surface: BrowserSurface; - selected: boolean; -} - -interface ProfileRuntime { - profileId: string; - context: BrowserContext; - cdp?: CDPSession; - sessions: Map; - windowOwners: Map; - targetPages: Map; - userDataDir: string; - anchorTargetId?: string; - parkingPage?: PlaywrightPage; - useParkingKeeper: boolean; - keeperWarningLogged: boolean; - activeCommands: number; - idleTimer?: ReturnType; - handoffTimer?: ReturnType; - closing: boolean; - disposed: boolean; - lastSeenAt: number; -} - -interface SessionRuntime { - id: string; - windowIds: Set; - pages: Map; - selectedPageId?: string; -} - -export interface BrowserRunSessionScope { - browser: Browser; - context: BrowserContext; - page: PlaywrightPage; - pages(): readonly PlaywrightPage[]; - createPage(): Promise; - onPage(listener: (page: PlaywrightPage) => void): () => void; -} - -export class SessionWindowConflictError extends CliError { - constructor(pageId: string, sessionId: string, owner?: string) { - super( - 'SESSION_WINDOW_CONFLICT', - `Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`, - undefined, - EXIT_CODES.TEMPFAIL, - ); - } -} - -export interface CloakSessionManagerOptions { - baseDir?: string; - launchPersistentContext?: LaunchPersistentContext; - launchBackgroundPersistentContext?: LaunchPersistentContext; - activateBackgroundContext?: typeof activateDarwinBackgroundContext; - recoverLockedProfile?: RecoverLockedProfile; - platform?: NodeJS.Platform; - hasActiveHandoff?: (profileId: string) => boolean; -} - -let pageCounter = 0; - -export function resolveLeaseKey(input: SessionKeyInput): string { - const surface = input.surface === 'adapter' ? 'adapter' : 'browser'; - const session = input.session?.trim(); - if (!session) throw new Error('Browser session is required.'); - const sessionId = input.sessionId?.trim() || session; - if (surface === 'adapter' && input.siteSession === 'persistent' && input.adapterSite) { - return `${sessionId}\u0000site:${input.adapterSite}`; - } - if (surface === 'adapter' && input.runId) { - return `${sessionId}\u0000ephemeral:${input.adapterSite ?? 'browser'}:${input.runId}`; - } - return `${surface}\u0000${encodeURIComponent(session)}`; -} - -function pageIsClosed(page: PlaywrightPage): boolean { - return page.isClosed?.() === true; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { - return Object.assign(new Error('The browser daemon is shutting down.'), { code: 'DAEMON_SHUTTING_DOWN' as const }); -} - -export class CloakSessionManager { - readonly networkCapture = new CloakNetworkCapture(); - - private readonly launchPersistentContext: LaunchPersistentContext; - private readonly launchBackgroundPersistentContext: LaunchPersistentContext; - private readonly activateBackgroundContext: typeof activateDarwinBackgroundContext; - private readonly platform: NodeJS.Platform; - private readonly recoverLockedProfile: RecoverLockedProfile; - private readonly hasActiveHandoff: (profileId: string) => boolean; - private readonly profiles = new Map(); - private readonly profileLaunches = new Map>(); - private readonly profileLifecycleQueues = new Map>(); - private readonly profileActivities = new Map(); - private readonly pageCreationQueues = new Map>(); - private readonly pageTargetIds = new WeakMap(); - private readonly pageTargetIdPromises = new WeakMap>(); - private readonly pageCdpSessions = new WeakMap(); - private readonly pendingTargetPages = new WeakMap>(); - private readonly targetPageWaiters = new WeakMap; - }>>(); - private readonly sessionPageListeners = new WeakMap void>>(); - private shuttingDown = false; - - constructor(private readonly opts: CloakSessionManagerOptions = {}) { - this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; - this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext; - this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; - this.platform = opts.platform ?? process.platform; - this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile; - this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); - } - - profileStatuses() { - return [...this.profiles.entries()].map(([contextId, runtime]) => ({ - contextId, - runtimeConnected: true, - runtimeVersion: resolveCloakBrowserVersion(), - pending: 0, - lastSeenAt: runtime.lastSeenAt, - })); - } - - activeProfileIds(): string[] { - return [...this.profiles.keys()]; - } - - async runWithProfileActivity(profileIdInput: string | undefined, task: () => Promise): Promise { - const profileId = normalizeProfileId(profileIdInput); - await this.withProfileLifecycleLock(profileId, async () => { - this.assertRunning(); - const count = (this.profileActivities.get(profileId) ?? 0) + 1; - this.profileActivities.set(profileId, count); - const runtime = this.profiles.get(profileId); - if (runtime) { - runtime.activeCommands = count; - this.cancelProfileIdle(runtime); - } - }); - try { - return await task(); - } finally { - await this.withProfileLifecycleLock(profileId, async () => { - const count = Math.max(0, (this.profileActivities.get(profileId) ?? 1) - 1); - if (count === 0) this.profileActivities.delete(profileId); - else this.profileActivities.set(profileId, count); - const runtime = this.profiles.get(profileId); - if (runtime) { - runtime.activeCommands = count; - this.scheduleProfileIdle(profileId, runtime); - } - }); - } - } - - async getPage(input: SessionKeyInput): Promise { - const profileId = normalizeProfileId(input.profileId); - const session = requireSession(input.session); - const sessionId = requireSessionId(input); - const surface = normalizeSurface(input.surface); - const leaseKey = resolveLeaseKey(input); - const freshPage = input.freshPage === true; - return this.withPageCreationLock(profileId, async () => { - const runtime = await this.getProfileRuntime(profileId, input.windowMode); - const sessionRuntime = this.getSessionRuntime(runtime, sessionId); - const existing = sessionRuntime.pages.get(leaseKey); - if (existing && !pageIsClosed(existing.page) && !freshPage) { - try { - await this.assertOwnedWindow(runtime, sessionId, existing); - runtime.lastSeenAt = Date.now(); - existing.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing); - return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; - } catch (error) { - if (!isClosedContextError(error)) throw error; - // isClosed() reported false, but the liveness probe above shows the - // underlying CDP connection is actually dead. Invalidate the Profile - // runtime and fall through to acquire a fresh page instead of handing - // the same broken lease back out (webcmd#314). - this.invalidateProfileRuntime(profileId, runtime); - if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); - } - } - const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode); - const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, { - leaseKey, - session, - surface, - siteSession: input.siteSession, - sessionKind: input.sessionKind, - adapterSite: input.adapterSite, - idleTimeout: input.idleTimeout, - }); - if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); - this.selectEntry(acquired.session, entry); - acquired.runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: acquired.runtime.context, page: entry.page, pageId: entry.pageId }; - }); - } - - async findPage(input: SessionKeyInput): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const leaseKey = resolveLeaseKey(input); - const runtime = this.profiles.get(profileId); - const sessionRuntime = runtime?.sessions.get(sessionId); - const entry = sessionRuntime?.pages.get(leaseKey); - if (!runtime || !sessionRuntime || !entry || pageIsClosed(entry.page)) return null; - await this.assertOwnedWindow(runtime, sessionId, entry); - runtime.lastSeenAt = Date.now(); - entry.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); - return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; - } - - async findPageById(pageId: string, opts: Pick): Promise { - const expectedProfileId = normalizeProfileId(opts.profileId); - const sessionId = requireSessionId(opts); - const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; - for (const [profileId, runtime] of this.profiles.entries()) { - if (expectedProfileId !== profileId) continue; - const sessionRuntime = runtime.sessions.get(sessionId); - if (!sessionRuntime) return null; - for (const [leaseKey, entry] of sessionRuntime.pages.entries()) { - if ( - entry.pageId === pageId - && !pageIsClosed(entry.page) - && (!expectedSurface || entry.surface === expectedSurface) - ) { - await this.assertOwnedWindow(runtime, sessionId, entry); - entry.idleTimeout = opts.idleTimeout; - this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); - return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; - } - } - } - return null; - } - - pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface; sessionKind?: 'explicit' | 'adapter-default'; adapterSite?: string } | null { - for (const [profileId, runtime] of this.profiles.entries()) { - for (const entry of runtime.targetPages.values()) { - if (entry.pageId === pageId && !pageIsClosed(entry.page)) { - return { - profileId, - session: entry.session, - surface: entry.surface, - sessionKind: entry.sessionKind, - adapterSite: entry.adapterSite, - }; - } - } - } - return null; - } - - pageIdFor(page: PlaywrightPage): string | undefined { - for (const runtime of this.profiles.values()) { - for (const entry of runtime.targetPages.values()) { - if (entry.page === page) return entry.pageId; - } - } - return undefined; - } - - async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const runtime = this.profiles.get(profileId); - const sessionRuntime = runtime?.sessions.get(sessionId); - const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); - if (!runtime || !sessionRuntime || !entry || entry.sessionId !== sessionId) { - throw new Error('Browser-run page is outside the selected Session.'); - } - await Promise.all(this.openEntries(sessionRuntime).map(([, candidate]) => ( - this.assertOwnedWindow(runtime, sessionId, candidate) - ))); - const browser = runtime.context.browser(); - if (!browser) throw new Error('The selected browser context is not attached to a browser.'); - return { - browser, - context: runtime.context, - page, - pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), - createPage: async () => (await this.newPage(input)).page, - onPage: (listener) => { - const listeners = this.sessionPageListeners.get(sessionRuntime) ?? new Set(); - listeners.add(listener); - this.sessionPageListeners.set(sessionRuntime, listeners); - return () => listeners.delete(listener); - }, - }; - } - - async listPages(input: Pick): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const surface = input.surface ? normalizeSurface(input.surface) : undefined; - const runtime = this.profiles.get(profileId); - if (!runtime) return []; - const sessionRuntime = runtime.sessions.get(sessionId); - if (!sessionRuntime) return []; - const entries = this.openEntries(sessionRuntime) - .filter(([, entry]) => !surface || entry.surface === surface); - await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); - return Promise.all(entries.map(async ([, entry], index) => ({ - id: entry.pageId, - page: entry.pageId, - index, - title: await entry.page.title().catch(() => ''), - url: entry.page.url(), - profileId, - session: entry.session, - sessionId, - surface: entry.surface, - selected: sessionRuntime.selectedPageId === entry.pageId, - }))); - } - - async newPage(input: NewPageInput): Promise { - return this.newPageAttempt(input, 0); - } - - async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { - return this.navigatePageAttempt(input, url, waitUntil, 0); - } - - private async newPageAttempt(input: NewPageInput, attempt: number): Promise { - const profileId = normalizeProfileId(input.profileId); - const session = requireSession(input.session); - const sessionId = requireSessionId(input); - const surface = normalizeSurface(input.surface); - const acquired = await this.withPageCreationLock(profileId, async () => { - const result = await this.acquireSessionPage(profileId, sessionId, input.windowMode); - return { runtime: result.runtime, sessionRuntime: result.session, page: result.page }; - }); - if (input.url) { - try { - await acquired.page.goto(input.url, { waitUntil: input.waitUntil ?? 'load' }); - } catch (error) { - if (attempt === 0 && isClosedContextError(error)) { - this.invalidateProfileRuntime(profileId, acquired.runtime); - if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); - return this.newPageAttempt(input, 1); - } - if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); - throw error; - } - } - if (this.profiles.get(profileId) !== acquired.runtime) { - if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); - throw new Error('Target page, context or browser has been closed'); - } - const entry = await this.registerOwnedPage(acquired.runtime, acquired.sessionRuntime, acquired.page, { - session, - surface, - siteSession: input.siteSession, - sessionKind: input.sessionKind, - adapterSite: input.adapterSite, - idleTimeout: input.idleTimeout, - }); - const leaseKey = entry.leaseKey; - this.refreshIdleTimer(acquired.runtime, acquired.sessionRuntime, leaseKey, entry); - this.selectEntry(acquired.sessionRuntime, entry); - acquired.runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; - } - - private async navigatePageAttempt(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit', attempt: number): Promise { - const profileId = normalizeProfileId(input.profileId); - const lease = await this.getPage(input); - const runtime = this.profiles.get(profileId); - try { - await lease.page.goto(url, { waitUntil }); - return lease; - } catch (error) { - if (attempt !== 0 || !isClosedContextError(error)) throw error; - if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime); - if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {}); - return this.navigatePageAttempt(input, url, waitUntil, 1); - } - } - - async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const runtime = this.profiles.get(profileId); - if (!runtime) return null; - const sessionRuntime = runtime.sessions.get(sessionId); - if (!sessionRuntime) return null; - const candidates = this.sessionEntries(sessionRuntime, input); - const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; - if (!match) return null; - const [leaseKey, entry] = match; - await this.assertOwnedWindow(runtime, sessionId, entry); - if (input.windowMode !== 'background') { - await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); - } - this.selectEntry(sessionRuntime, entry); - runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; - } - - async foregroundSession(profileIdInput: string, sessionId: string): Promise { - const profileId = normalizeProfileId(profileIdInput); - const runtime = this.profiles.get(profileId); - const session = runtime?.sessions.get(sessionId); - if (!runtime || !session) return false; - const entries = this.openEntries(session); - const match = entries.find(([, entry]) => entry.pageId === session.selectedPageId) ?? entries[0]; - if (!match) return false; - const entry = match[1]; - await this.assertOwnedWindow(runtime, sessionId, entry); - await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); - this.selectEntry(session, entry); - runtime.lastSeenAt = Date.now(); - return true; - } - - async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { - const profileId = normalizeProfileId(input.profileId); - const session = requireSession(input.session); - const sessionId = requireSessionId(input); - const surface = normalizeSurface(input.surface); - const runtime = this.profiles.get(profileId); - if (!runtime) return null; - const existingSession = runtime.sessions.get(sessionId); - let match = input.pageId - ? this.findEntryByPageId(runtime, input.pageId) - : existingSession && this.openEntries(existingSession)[input.index ?? -1]; - if (!match && input.index !== undefined) { - const candidates: PlaywrightPage[] = []; - for (const candidate of runtime.context.pages()) { - if (pageIsClosed(candidate) || candidate === runtime.parkingPage) continue; - if (await this.targetIdForPage(runtime, candidate) === runtime.anchorTargetId) continue; - candidates.push(candidate); - } - const page = candidates[input.index]; - if (page) { - const targetId = await this.targetIdForPage(runtime, page); - const entry = runtime.targetPages.get(targetId) ?? { - page, - pageId: nextPageId(), - targetId, - leaseKey: `unowned\u0000${targetId}`, - session: '', - surface, - }; - if (!runtime.targetPages.has(targetId)) { - runtime.targetPages.set(targetId, entry); - this.attachPageLifecycle(runtime, entry); - } - match = [entry.leaseKey, entry]; - } - } - if (!match) return null; - - const entry = match[1]; - if (entry.sessionId && entry.sessionId !== sessionId) { - throw new SessionWindowConflictError(entry.pageId, sessionId, entry.sessionId); - } - const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); - await this.assertBindableWindow(runtime, sessionRuntime, entry); - const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; - const sourceKey = entry.leaseKey; - const canonicalKey = resolveLeaseKey(input); - const currentCanonical = sessionRuntime.pages.get(canonicalKey); - - if (input.windowMode !== 'background') { - await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); - } - - if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { - const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; - sessionRuntime.pages.delete(canonicalKey); - currentCanonical.leaseKey = preservedKey; - sessionRuntime.pages.set(preservedKey, currentCanonical); - this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); - } - - sourceSession?.pages.delete(sourceKey); - entry.sessionId = sessionId; - entry.leaseKey = canonicalKey; - entry.session = session; - entry.surface = surface; - entry.siteSession = input.siteSession; - entry.idleTimeout = input.idleTimeout; - sessionRuntime.pages.set(canonicalKey, entry); - this.refreshIdleTimer(runtime, sessionRuntime, canonicalKey, entry); - this.selectEntry(sessionRuntime, entry); - runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; - } - - async closePage(input: Pick & { pageId?: string; index?: number }): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const runtime = this.profiles.get(profileId); - if (!runtime) return null; - const sessionRuntime = runtime.sessions.get(sessionId); - if (!sessionRuntime) return null; - const candidates = this.sessionEntries(sessionRuntime, input); - const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; - if (!match) return null; - const [, entry] = match; - await this.assertOwnedWindow(runtime, sessionId, entry); - await this.removeEntry(runtime, sessionRuntime, entry, true); - runtime.lastSeenAt = Date.now(); - return entry.pageId; - } - - async release(input: SessionKeyInput): Promise { - const profileId = normalizeProfileId(input.profileId); - const sessionId = requireSessionId(input); - const runtime = this.profiles.get(profileId); - if (!runtime) return; - const sessionRuntime = runtime.sessions.get(sessionId); - if (!sessionRuntime) return; - const leaseKey = resolveLeaseKey(input); - const surface = normalizeSurface(input.surface); - const entries = this.openEntries(sessionRuntime).filter(([key, entry]) => ( - surface === 'adapter' - ? key === leaseKey - : entry.session === requireSession(input.session) && entry.surface === surface - )); - await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); - for (const [, entry] of entries) { - if (entry.siteSession === 'persistent') continue; - await this.removeEntry(runtime, sessionRuntime, entry, true); - } - } - - hasSession(profileIdInput: string | undefined, sessionInput: string | undefined): boolean { - const profileId = normalizeProfileId(profileIdInput); - const session = requireSession(sessionInput); - const runtime = this.profiles.get(profileId); - return Boolean(runtime?.sessions.get(session) && this.openEntries(runtime.sessions.get(session)!).length > 0); - } - - async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { - const profileId = normalizeProfileId(profileIdInput); - const session = requireSession(sessionInput); - const runtime = this.profiles.get(profileId); - if (!runtime) return 0; - const sessionRuntime = runtime.sessions.get(session); - if (!sessionRuntime) return 0; - const entries = this.openEntries(sessionRuntime); - await Promise.all(entries.map(async ([, entry]) => { - try { - await this.assertOwnedWindow(runtime, session, entry); - } catch (error) { - if (!pageIsClosed(entry.page)) throw error; - } - })); - for (const [, entry] of entries) await this.removeEntry(runtime, sessionRuntime, entry, true); - if (entries.length > 0) runtime.lastSeenAt = Date.now(); - return entries.length; - } - - /** - * Invalidates the Profile runtime backing `context`, if it's still the active - * one, without evicting or retrying the command that observed it. Used by the - * `run` action (webcmd#314) when a post-run snapshot capture surfaces a - * closed-context signature: the run itself may have genuinely succeeded, but - * the connection is dying, so the next command on this Session shouldn't be - * handed the same lease. - */ - invalidateIfClosedContext(profileId: string, context: BrowserContext): void { - const runtime = this.profiles.get(profileId); - if (runtime?.context === context) this.invalidateProfileRuntime(profileId, runtime); - } - - async shutdown(): Promise { - this.shuttingDown = true; - while (this.profileLaunches.size > 0) { - await Promise.allSettled([...this.profileLaunches.values()]); - } - await Promise.all([...this.profiles.keys()].map(profileId => this.withProfileLifecycleLock(profileId, async () => { - const runtime = this.profiles.get(profileId); - if (!runtime) return; - this.profiles.delete(profileId); - runtime.closing = true; - await this.closeRuntime(runtime, false).catch(() => {}); - }))); - this.profiles.clear(); - this.profileLaunches.clear(); - this.profileActivities.clear(); - } - - private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - return this.withProfileLifecycleLock(profileId, async () => { - this.assertRunning(); - const existing = this.profiles.get(profileId); - if (existing && !existing.closing) { - this.cancelProfileIdle(existing); - return existing; - } - const launch = this.launchProfileRuntime(profileId, windowMode); - this.profileLaunches.set(profileId, launch); - try { - return await launch; - } finally { - if (this.profileLaunches.get(profileId) === launch) this.profileLaunches.delete(profileId); - } - }); - } - - private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const 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(); - let cdp: CDPSession | undefined; - let keeperError: unknown; - try { - cdp = await browser?.newBrowserCDPSession(); - } catch (error) { - keeperError = error; - } - const runtime: ProfileRuntime = { - profileId, - context, - cdp, - sessions: new Map(), - windowOwners: new Map(), - targetPages: new Map(), - userDataDir, - useParkingKeeper: this.platform !== 'darwin' || !cdp, - keeperWarningLogged: false, - activeCommands: this.profileActivities.get(profileId) ?? 0, - closing: false, - disposed: false, - lastSeenAt: Date.now(), - }; - this.pendingTargetPages.set(runtime, new Map()); - this.targetPageWaiters.set(runtime, new Map()); - this.attachRuntimeLifecycle(profileId, runtime); - if (cdp) { - try { - runtime.anchorTargetId = (await cdp.send('Target.createTarget', { - url: 'about:blank', - hidden: true, - background: true, - }) as { targetId: string }).targetId; - } catch (error) { - this.warnKeeperFallback(profileId, runtime, error); - } - } else { - this.warnKeeperFallback(profileId, runtime, keeperError ?? new Error('browser connection unavailable')); - } - if (this.shuttingDown) { - runtime.closing = true; - await this.closeRuntime(runtime, false).catch(() => {}); - throw daemonShuttingDownError(); - } - this.profiles.set(profileId, runtime); - return runtime; - } - - private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { - if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); - this.cleanupRuntime(runtime); - } - - private cleanupRuntime(runtime: ProfileRuntime): void { - if (runtime.disposed) return; - runtime.disposed = true; - this.cancelProfileIdle(runtime); - for (const entry of runtime.targetPages.values()) { - if (entry.idleTimer) clearTimeout(entry.idleTimer); - this.networkCapture.stop(entry.page); - void this.pageCdpSessions.get(entry.page)?.detach().catch(() => {}); - } - runtime.targetPages.clear(); - runtime.sessions.clear(); - runtime.windowOwners.clear(); - for (const waiter of this.targetPageWaiters.get(runtime)?.values() ?? []) { - clearTimeout(waiter.timer); - waiter.reject(new Error('Target page, context or browser has been closed')); - } - this.targetPageWaiters.get(runtime)?.clear(); - void runtime.cdp?.detach().catch(() => {}); - } - - private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { - runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime)); - runtime.context.on('page', page => { - void this.handleContextPage(runtime, page).catch(() => {}); - }); - const onCdpEvent = (runtime.cdp as (CDPSession & { - on?: (event: string, listener: (payload: { targetId: string }) => void) => void; - }) | undefined)?.on; - onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { - this.queueAnchorRepair(profileId, runtime, targetId); - }); - } - - private queueAnchorRepair(profileId: string, runtime: ProfileRuntime, destroyedTargetId: string): void { - if (runtime.anchorTargetId !== destroyedTargetId) return; - runtime.anchorTargetId = undefined; - void this.withProfileLifecycleLock(profileId, async () => { - if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; - if (runtime.anchorTargetId !== undefined) return; - await this.repairAnchor(profileId, runtime); - }); - } - - private async repairAnchor(profileId: string, runtime: ProfileRuntime): Promise { - if (!runtime.cdp) return; - try { - runtime.anchorTargetId = (await runtime.cdp.send('Target.createTarget', { - url: 'about:blank', - hidden: true, - background: true, - }) as { targetId: string }).targetId; - } catch (error) { - this.warnKeeperFallback(profileId, runtime, error); - } - } - - private warnKeeperFallback(profileId: string, runtime: ProfileRuntime, error: unknown): void { - runtime.useParkingKeeper = true; - if (runtime.keeperWarningLogged) return; - runtime.keeperWarningLogged = true; - log.warn(`Cloak Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); - } - - private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { - if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer || runtime.handoffTimer) return; - if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; - if (this.hasActiveHandoff(profileId)) { - this.scheduleHandoffWake(profileId, runtime); - return; - } - runtime.idleTimer = setTimeout(() => { - runtime.idleTimer = undefined; - void this.withProfileLifecycleLock(profileId, async () => { - if (this.profiles.get(profileId) !== runtime || runtime.closing) return; - if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; - if (this.hasActiveHandoff(profileId)) { - this.scheduleHandoffWake(profileId, runtime); - return; - } - runtime.closing = true; - this.profiles.delete(profileId); - await this.closeRuntime(runtime, true); - }); - }, PROFILE_IDLE_TIMEOUT_MS); - runtime.idleTimer.unref?.(); - } - - private scheduleHandoffWake(profileId: string, runtime: ProfileRuntime): void { - runtime.handoffTimer = setTimeout(() => { - runtime.handoffTimer = undefined; - this.scheduleProfileIdle(profileId, runtime); - }, PROFILE_IDLE_TIMEOUT_MS); - runtime.handoffTimer.unref?.(); - } - - private cancelProfileIdle(runtime: ProfileRuntime): void { - if (runtime.idleTimer) clearTimeout(runtime.idleTimer); - if (runtime.handoffTimer) clearTimeout(runtime.handoffTimer); - runtime.idleTimer = undefined; - runtime.handoffTimer = undefined; - } - - private hasVisiblePages(runtime: ProfileRuntime): boolean { - for (const session of runtime.sessions.values()) { - if (this.openEntries(session).length > 0) return true; - } - return false; - } - - private async closeRuntime(runtime: ProfileRuntime, recoverOnTimeout: boolean): 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; - } - } finally { - if (timeout) clearTimeout(timeout); - this.cleanupRuntime(runtime); - } - } - - private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { - const previous = this.profileLifecycleQueues.get(profileId); - let release!: () => void; - const released = new Promise((resolve) => { - release = resolve; - }); - const queue = (previous ?? Promise.resolve()).then(() => released); - this.profileLifecycleQueues.set(profileId, queue); - if (previous) await previous.catch(() => {}); - try { - return await operation(); - } finally { - release(); - if (this.profileLifecycleQueues.get(profileId) === queue) this.profileLifecycleQueues.delete(profileId); - } - } - - private assertRunning(): void { - if (this.shuttingDown) throw daemonShuttingDownError(); - } - - private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { - const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); - let release!: () => void; - const released = new Promise((resolve) => { - release = resolve; - }); - const queue = previous.then(() => released); - this.pageCreationQueues.set(profileId, queue); - await previous; - try { - return await operation(); - } finally { - release(); - if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); - } - } - - private getSessionRuntime(runtime: ProfileRuntime, sessionId: string): SessionRuntime { - let session = runtime.sessions.get(sessionId); - if (!session) { - session = { id: sessionId, windowIds: new Set(), pages: new Map() }; - runtime.sessions.set(sessionId, session); - } - return session; - } - - private async createSessionPage( - runtime: ProfileRuntime, - session: SessionRuntime, - windowMode?: BrowserWindowMode, - ): Promise { - const openerEntry = this.openEntries(session)[0]?.[1]; - if (!openerEntry) return await this.findReusableLaunchPage(runtime, session.id) ?? this.createWindowPage(runtime, windowMode); - await this.assertOwnedWindow(runtime, session.id, openerEntry); - const opener = openerEntry.page; - const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); - const targetUrl = `about:blank#webcmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const openedPage = this.waitForContextPageForSession(runtime, session.id, openerWindowId, targetUrl, TARGET_PAGE_MATCH_TIMEOUT_MS); - - try { - await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); - } catch (error) { - log.warn(`Cloak window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(error)}`); - } - const page = await openedPage; - if (page) return page; - return this.createWindowPage(runtime, windowMode); - } - - private async findReusableLaunchPage(runtime: ProfileRuntime, sessionId: string): Promise { - for (const page of runtime.context.pages()) { - if (pageIsClosed(page) || page === runtime.parkingPage || page.url() !== 'about:blank') continue; - const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); - if (!targetId || targetId === runtime.anchorTargetId || runtime.targetPages.has(targetId)) continue; - const windowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); - if (windowId === undefined) continue; - const owner = runtime.windowOwners.get(windowId); - if (owner === undefined || owner === sessionId) return page; - } - return undefined; - } - - private async waitForContextPageForSession( - runtime: ProfileRuntime, - sessionId: string, - openerWindowId: number, - targetUrl: string, - timeoutMs: number, - ): Promise { - return new Promise((resolve) => { - let settled = false; - const done = (page: PlaywrightPage | null) => { - if (settled) return; - settled = true; - clearTimeout(timer); - runtime.context.off('page', onPage); - resolve(page); - }; - const tryPage = async (page: PlaywrightPage) => { - if (settled || pageIsClosed(page)) return; - const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); - if (!targetId) return; - const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); - if (actualWindowId === undefined) return; - const owner = runtime.windowOwners.get(actualWindowId); - if (owner !== undefined && owner !== sessionId) return; - if (page.url() !== targetUrl) return; - done(page); - }; - const onPage = (page: PlaywrightPage) => { void tryPage(page); }; - const timer = setTimeout(() => done(null), timeoutMs); - runtime.context.on('page', onPage); - for (const page of this.pendingTargetPages.get(runtime)?.values() ?? []) void tryPage(page); - }); - } - - private async acquireSessionPage( - profileId: string, - sessionId: string, - windowMode: BrowserWindowMode | undefined, - attempt = 0, - ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { - const runtime = await this.getProfileRuntime(profileId, windowMode); - const session = this.getSessionRuntime(runtime, sessionId); - let page: PlaywrightPage; - try { - page = await this.createSessionPage(runtime, session, windowMode); - } catch (error) { - if (attempt !== 0 || !isClosedContextError(error)) throw error; - this.invalidateProfileRuntime(profileId, runtime); - return this.acquireSessionPage(profileId, sessionId, windowMode, 1); - } - if (this.profiles.get(profileId) !== runtime) { - if (!pageIsClosed(page)) await page.close().catch(() => {}); - throw new Error('Target page, context or browser has been closed'); - } - return { runtime, session, page }; - } - - private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode, newWindow = true): Promise { - if (!runtime.cdp) return runtime.context.newPage(); - const result = await runtime.cdp.send('Target.createTarget', { - url: 'about:blank', - newWindow, - background: windowMode === 'background', - focus: windowMode !== 'background', - }) as { targetId: string }; - return this.waitForTargetPage(runtime, result.targetId); - } - - private async waitForTargetPage(runtime: ProfileRuntime, targetId: string): Promise { - const pending = this.pendingTargetPages.get(runtime)!; - const page = pending.get(targetId); - if (page) { - pending.delete(targetId); - pending.clear(); - return page; - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.targetPageWaiters.get(runtime)?.delete(targetId); - reject(new Error(`Timed out waiting for Cloak target ${targetId}`)); - }, TARGET_PAGE_MATCH_TIMEOUT_MS); - this.targetPageWaiters.get(runtime)!.set(targetId, { resolve, reject, timer }); - }); - } - - private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { - const targetId = await this.targetIdForPage(runtime, page); - if (targetId === runtime.anchorTargetId) { - page.once('close', () => { - this.queueAnchorRepair(runtime.profileId, runtime, targetId); - }); - return; - } - const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); - if (waiter) { - this.targetPageWaiters.get(runtime)!.delete(targetId); - this.pendingTargetPages.get(runtime)?.clear(); - clearTimeout(waiter.timer); - waiter.resolve(page); - } else { - this.pendingTargetPages.get(runtime)?.set(targetId, page); - } - - const opener = await page.opener().catch(() => null); - const openerEntry = opener && [...runtime.targetPages.values()].find(entry => entry.page === opener); - if (!openerEntry?.sessionId) return; - const session = runtime.sessions.get(openerEntry.sessionId); - if (!session) return; - this.pendingTargetPages.get(runtime)?.delete(targetId); - await this.registerOwnedPage(runtime, session, page, { - session: openerEntry.session, - surface: openerEntry.surface, - siteSession: openerEntry.siteSession, - sessionKind: openerEntry.sessionKind, - adapterSite: openerEntry.adapterSite, - idleTimeout: openerEntry.idleTimeout, - }); - } - - private async registerOwnedPage( - runtime: ProfileRuntime, - session: SessionRuntime, - page: PlaywrightPage, - input: Pick & { leaseKey?: string }, - ): Promise { - const targetId = await this.targetIdForPage(runtime, page); - this.pendingTargetPages.get(runtime)?.delete(targetId); - const windowId = await this.windowIdForTarget(runtime, targetId, page); - const owner = runtime.windowOwners.get(windowId); - if (owner !== undefined && owner !== session.id) { - throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); - } - runtime.windowOwners.set(windowId, session.id); - session.windowIds.add(windowId); - - let entry = runtime.targetPages.get(targetId); - const wasOwned = Boolean(entry?.sessionId); - if (entry?.sessionId && entry.sessionId !== session.id) { - throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); - } - if (!entry) { - const pageId = nextPageId(); - entry = { - page, - pageId, - targetId, - leaseKey: input.leaseKey ?? `page\u0000${pageId}`, - sessionId: session.id, - session: input.session, - surface: input.surface, - siteSession: input.siteSession, - sessionKind: input.sessionKind, - adapterSite: input.adapterSite, - idleTimeout: input.idleTimeout, - }; - runtime.targetPages.set(targetId, entry); - this.attachPageLifecycle(runtime, entry); - } else { - if (entry.sessionId) { - const session = runtime.sessions.get(entry.sessionId); - if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); - } - entry.sessionId = session.id; - entry.session = input.session; - entry.surface = input.surface; - entry.siteSession = input.siteSession; - entry.sessionKind = input.sessionKind; - entry.adapterSite = input.adapterSite; - entry.idleTimeout = input.idleTimeout; - entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); - } - session.pages.set(entry.leaseKey, entry); - this.cancelProfileIdle(runtime); - this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); - if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); - await this.closeParkingPage(runtime); - return entry; - } - - private attachPageLifecycle(runtime: ProfileRuntime, entry: PageEntry): void { - entry.page.once('close', () => { - runtime.targetPages.delete(entry.targetId); - if (entry.sessionId) { - const session = runtime.sessions.get(entry.sessionId); - if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); - } - this.clearIdleTimer(entry); - if (runtime.parkingPage === entry.page) runtime.parkingPage = undefined; - this.scheduleProfileIdle(runtime.profileId, runtime); - }); - } - - private async targetIdForPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { - const cached = this.pageTargetIds.get(page); - if (cached) return cached; - const pending = this.pageTargetIdPromises.get(page); - if (pending) return pending; - const correlation = (async () => { - const session = await runtime.context.newCDPSession(page); - const { targetInfo } = await session.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; - this.pageTargetIds.set(page, targetInfo.targetId); - this.pageCdpSessions.set(page, session); - page.once('close', () => { - this.pageTargetIds.delete(page); - this.pageCdpSessions.delete(page); - void session.detach().catch(() => {}); - }); - return targetInfo.targetId; - })(); - this.pageTargetIdPromises.set(page, correlation); - try { - return await correlation; - } finally { - this.pageTargetIdPromises.delete(page); - } - } - - private async windowIdForTarget(runtime: ProfileRuntime, targetId: string, page?: PlaywrightPage): Promise { - const entry = runtime.targetPages.get(targetId); - const targetPage = page ?? entry?.page; - const cdp = runtime.cdp ?? (targetPage ? this.pageCdpSessions.get(targetPage) : undefined); - if (!cdp) throw new Error('Cloak page has no CDP session.'); - const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; - return windowId; - } - - private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { - const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); - const owner = runtime.windowOwners.get(actual); - if (owner !== undefined && owner !== sessionId) { - throw new SessionWindowConflictError(entry.pageId, sessionId, owner); - } - if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { - throw new SessionWindowConflictError(entry.pageId, sessionId, owner); - } - } - - private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { - if (entry.sessionId) { - if (entry.sessionId !== session.id) { - throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); - } - await this.assertOwnedWindow(runtime, session.id, entry); - return; - } - const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); - const owner = runtime.windowOwners.get(actual); - if (owner !== undefined && owner !== session.id) { - throw new SessionWindowConflictError(entry.pageId, session.id, owner); - } - runtime.windowOwners.set(actual, session.id); - session.windowIds.add(actual); - } - - private openEntries(runtime: SessionRuntime): [string, PageEntry][] { - return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); - } - - private findEntryByPageId(runtime: ProfileRuntime, pageId: string): [string, PageEntry] | null { - const entry = [...runtime.targetPages.values()].find(candidate => candidate.pageId === pageId && !pageIsClosed(candidate.page)); - return entry ? [entry.leaseKey, entry] : null; - } - - private sessionEntries(runtime: SessionRuntime, input: Pick): [string, PageEntry][] { - const session = requireSession(input.session); - const surface = normalizeSurface(input.surface); - return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); - } - - private selectEntry(runtime: SessionRuntime, entry: PageEntry): void { - runtime.selectedPageId = entry.pageId; - } - - private clearSelectedPage(runtime: SessionRuntime, entry: PageEntry): void { - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; - } - - private refreshIdleTimer(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): void { - this.clearIdleTimer(entry); - if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; - entry.idleTimer = setTimeout(() => { - void this.expireLease(runtime, session, leaseKey, entry); - }, entry.idleTimeout); - entry.idleTimer.unref?.(); - } - - private async expireLease(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): Promise { - if (session.pages.get(leaseKey) !== entry) return; - runtime.lastSeenAt = Date.now(); - if (entry.siteSession !== 'persistent') await this.removeEntry(runtime, session, entry, true); - } - - private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { - const shouldPark = close && runtime.useParkingKeeper - && [...runtime.targetPages.values()].every(candidate => candidate === entry || pageIsClosed(candidate.page)); - const parkingWindowId = shouldPark - ? await this.windowIdForTarget(runtime, entry.targetId, entry.page).catch(() => undefined) - : undefined; - if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); - runtime.targetPages.delete(entry.targetId); - this.clearIdleTimer(entry); - this.clearSelectedPage(session, entry); - this.networkCapture.stop(entry.page); - if (close && !pageIsClosed(entry.page)) { - if (shouldPark) { - await entry.page.goto('about:blank', { waitUntil: 'load' }).catch(() => {}); - entry.sessionId = undefined; - runtime.parkingPage = pageIsClosed(entry.page) ? undefined : entry.page; - if (parkingWindowId !== undefined) { - runtime.windowOwners.delete(parkingWindowId); - session.windowIds.delete(parkingWindowId); - await runtime.cdp?.send('Browser.setWindowBounds', { - windowId: parkingWindowId, - bounds: { windowState: 'minimized' }, - }).catch(() => {}); - } - } else { - await runtime.cdp?.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); - if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); - } - } - this.scheduleProfileIdle(runtime.profileId, runtime); - } - - private async closeParkingPage(runtime: ProfileRuntime): Promise { - const parkingPage = runtime.parkingPage; - if (!parkingPage) return; - runtime.parkingPage = undefined; - if (!pageIsClosed(parkingPage)) await parkingPage.close().catch(() => {}); - } - - private clearIdleTimer(entry: PageEntry): void { - if (entry.idleTimer) clearTimeout(entry.idleTimer); - entry.idleTimer = undefined; - } -} - -function nextPageId(): string { - return `page-${Date.now()}-${++pageCounter}`; -} - -function normalizeSurface(surface: BrowserSurface | undefined): BrowserSurface { - return surface === 'adapter' ? 'adapter' : 'browser'; -} - -function requireSession(session: string | undefined): string { - const normalized = session?.trim(); - if (!normalized) throw new Error('Browser session is required.'); - return normalized; -} - -function requireSessionId(input: Pick): string { - return input.sessionId?.trim() || requireSession(input.session); -} - -function isProfileAlreadyInUseError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - return message.includes('Opening in existing browser session') - || message.includes('Failed to create a ProcessSingleton for your profile directory'); -} - -async function recoverLockedCloakProfile(userDataDir: string): Promise { - if (process.platform === 'win32') return false; - const initial = await findExactCloakProfileProcesses(userDataDir); - if (initial.length === 0) return false; - - signalPids(initial, 'SIGTERM'); - if (await waitForProfileProcessesToExit(userDataDir, 2500)) return true; - - signalPids(await findExactCloakProfileProcesses(userDataDir), 'SIGKILL'); - return waitForProfileProcessesToExit(userDataDir, 1500); -} - -async function waitForProfileProcessesToExit(userDataDir: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 100)); - if ((await findExactCloakProfileProcesses(userDataDir)).length === 0) return true; - } - return (await findExactCloakProfileProcesses(userDataDir)).length === 0; -} - -function signalPids(pids: number[], signal: NodeJS.Signals): void { - for (const pid of pids) { - try { - process.kill(pid, signal); - } catch { - // Already exited or not signalable; the follow-up poll decides recovery. - } - } -} diff --git a/src/browser/runtime/local-slab/dependency-boundary.test.ts b/src/browser/runtime/local-slab/dependency-boundary.test.ts new file mode 100644 index 00000000..c8bf8c34 --- /dev/null +++ b/src/browser/runtime/local-slab/dependency-boundary.test.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const REPOSITORY_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); + +function productionTypeScriptFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return productionTypeScriptFiles(target); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [target] : []; + }); +} + +describe('local SLAB dependency boundary', () => { + it('has no production imports of the retired cloakbrowser runtime', () => { + const retiredImports = productionTypeScriptFiles(SOURCE_ROOT) + .filter(file => /(?:from|import)\s*\(?\s*['"]cloakbrowser['"]|import\.meta\.resolve\(\s*['"]cloakbrowser['"]/.test(fs.readFileSync(file, 'utf8'))) + .map(file => path.relative(SOURCE_ROOT, file)); + + expect(retiredImports).toEqual([]); + }); + + it('has no production routing through local-cloak', () => { + const staleRoutes = productionTypeScriptFiles(SOURCE_ROOT) + .filter(file => fs.readFileSync(file, 'utf8').includes('runtime/local-cloak')) + .map(file => path.relative(SOURCE_ROOT, file)); + + expect(staleRoutes).toEqual([]); + }); + + it('keeps npm and Bun lockfiles free of the retired dependency', () => { + for (const lockfile of ['package-lock.json', 'bun.lock']) { + expect(fs.readFileSync(path.join(REPOSITORY_ROOT, lockfile), 'utf8')).not.toContain('cloakbrowser'); + } + }); +}); diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 5c797825..c954b240 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -10,8 +10,7 @@ const { mockFindShadowedUserAdapters, mockSendCommand, mockSetDaemonCommandTimeoutSeconds, - mockBinaryInfo, - mockEnsureBinary, + mockFindSlabInstallation, } = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockConnect: vi.fn(), @@ -19,20 +18,15 @@ const { mockFindShadowedUserAdapters: vi.fn(), mockSendCommand: vi.fn(), mockSetDaemonCommandTimeoutSeconds: vi.fn(), - mockBinaryInfo: vi.fn(), - mockEnsureBinary: vi.fn(), + mockFindSlabInstallation: vi.fn(), })); vi.mock('./browser/daemon-transport.js', () => ({ getDaemonHealth: mockGetDaemonHealth, })); -// Real binaryInfo() reads this machine's actual CloakBrowser cache dir, which -// varies by dev box/CI runner — mock it so doctor tests are hermetic and the -// #239 binary-missing path can be exercised deterministically. -vi.mock('cloakbrowser', () => ({ - binaryInfo: mockBinaryInfo, - ensureBinary: mockEnsureBinary, +vi.mock('./slab/installation.js', () => ({ + findSlabInstallation: mockFindSlabInstallation, })); vi.mock('./browser/index.js', () => ({ @@ -74,18 +68,9 @@ describe('doctor report rendering', () => { vi.stubEnv('WEBCMD_CONFIG_DIR', isolatedConfigDir); mockFindShadowedUserAdapters.mockReturnValue([]); mockSetDaemonCommandTimeoutSeconds.mockClear(); - mockEnsureBinary.mockResolvedValue(managedBinaryPath); - // Installed by default so pre-existing tests exercise the generic - // connectivity-failure path, not the #239 binary-missing path. - mockBinaryInfo.mockReturnValue({ - version: '146.0.7680.177.5', - bundledVersion: '146.0.7680.177.5', - tier: 'free', - platform: 'linux-x64', - binaryPath: managedBinaryPath, - installed: true, - cacheDir: '/home/test/.cloakbrowser/chromium-146.0.7680.177.5', - downloadUrl: 'https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz', + mockFindSlabInstallation.mockReturnValue({ + platform: 'darwin', + executablePath: managedBinaryPath, }); // Doctor always runs live connectivity. Tests that want connect to fail override. mockConnect.mockResolvedValue({ @@ -143,7 +128,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.'); }); @@ -151,11 +136,11 @@ describe('doctor report rendering', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, runtimeConnected: false, - issues: ['Daemon is running but the Cloak runtime is not connected.'], + issues: ['Daemon is running but the SLAB runtime is not connected.'], })); 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', () => { @@ -263,7 +248,7 @@ describe('doctor report rendering', () => { ])); }); - it('reports a stale default Cloak profile when it is not active', async () => { + it('reports a stale default SLAB profile when it is not active', async () => { const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path'); @@ -286,7 +271,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 { @@ -304,11 +289,11 @@ 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 runtime connection is unstable'), ])); }); - it('uses runtime-neutral readiness hints when the runtime is disconnected', async () => { + it('uses SLAB readiness hints when the runtime is disconnected', async () => { mockConnect.mockRejectedValueOnce(new Error('runtime unavailable')); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', @@ -318,8 +303,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 runtime is not connected'); + expect(issues).toContain('Make sure SLAB is open'); expect(issues).not.toContain(`Webcmd Browser ${'Bridge'}`); expect(issues).not.toContain(`Load ${'unpacked'}`); expect(issues).not.toContain('Download the latest extension'); @@ -367,43 +352,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, @@ -542,249 +490,46 @@ 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 () => { + describe('SLAB installation status', () => { + it('reports an installed SLAB app without altering a generic connectivity failure', async () => { mockConnect.mockRejectedValueOnce(new Error('page.goto: Target page, context or browser has been closed')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'SLAB' } }); const report = await runBrowserDoctor(); - expect(report.binary?.installed).toBe(true); + expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath, override: false }); expect(report.issues).toEqual(expect.arrayContaining([ expect.stringContaining('Browser connectivity test failed: page.goto: Target page, context or browser has been closed'), ])); - const issueText = report.issues.join('\n'); - expect(issueText).not.toContain('CloakBrowser Chromium is not installed'); - expect(issueText).not.toContain('not launchable'); - expect(issueText).not.toContain('Download URL:'); - expect(issueText).not.toContain('CLOAKBROWSER_BINARY_PATH'); + expect(report.issues.join('\n')).not.toContain('SLAB is not installed'); }); - 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', - }); + it('reports when SLAB is not installed alongside connectivity facts', async () => { + mockFindSlabInstallation.mockReturnValueOnce(null); mockSendCommand.mockRejectedValueOnce(new Error('session-create refused')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'SLAB' } }); const report = await runBrowserDoctor(); const issueText = report.issues.join('\n'); + expect(report.binary).toMatchObject({ installed: false, path: 'SLAB.app', override: false }); expect(report.connectivity).toMatchObject({ ok: false, error: 'session-create refused' }); + expect(issueText).toContain('SLAB is not installed at SLAB.app'); 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), - }; + it('reports failed SLAB installation checks as warnings', async () => { + mockFindSlabInstallation.mockImplementationOnce(() => { + throw new Error('installation metadata unavailable'); }); - 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' } }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'SLAB' } }); 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(report.issues.join('\n')).toContain('Could not check SLAB installation: installation metadata unavailable'); 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(); - } }); }); }); diff --git a/src/doctor.ts b/src/doctor.ts index aaca731f..b9b92975 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -5,8 +5,7 @@ */ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { binaryInfo, ensureBinary } from 'cloakbrowser'; +import * as os from 'node:os'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; import { sendCommand, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; @@ -17,6 +16,7 @@ import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; +import { findSlabInstallation } from './slab/installation.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -36,7 +36,7 @@ export type BrowserBinaryStatus = { path: string; downloadUrl?: string; error?: string; - /** True when CLOAKBROWSER_BINARY_PATH is set — a different check than the managed cache. */ + /** SLAB is a normal installed application rather than a managed package binary. */ override: boolean; }; @@ -57,38 +57,20 @@ export type DoctorReport = { issues: string[]; }; -function isLaunchableFile(binaryPath: string): boolean { - try { - if (!fs.statSync(binaryPath).isFile()) return false; - if (process.platform === 'win32') return path.extname(binaryPath).toLowerCase() === '.exe'; - fs.accessSync(binaryPath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - /** - * Check whether the CloakBrowser Chromium binary is actually installed. - * `runtimeConnected: true` only means the - * daemon/Cloak runtime process is healthy — it says nothing about whether the - * browser binary CloakBrowser needs to launch is present on disk, which is - * exactly the gap that made a missing-binary failure look like a generic - * connectivity problem (#239). + * Check whether the normal SLAB application is installed. Runtime attachment + * is separate: a running daemon does not prove the app is available to launch. */ 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, - }; + const installation = findSlabInstallation({ + platform: process.platform, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); + return installation + ? { installed: true, path: installation.executablePath, override: false } + : { installed: false, path: 'SLAB.app', override: false }; } catch (err) { return { installed: undefined, path: 'unknown', error: getErrorMessage(err), override: false }; } @@ -102,8 +84,6 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise.', ); @@ -273,15 +249,13 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { : report.runtimeVersion ? ` (v${report.runtimeVersion})` : ' (version unknown)'; - const runtimeName = report.runtimeName ?? 'Cloak'; + const runtimeName = report.runtimeName ?? 'SLAB'; const runtimeLabel = report.runtimeFlaky ? 'unstable (connected during live check, then disconnected)' : report.runtimeConnected ? 'connected' : 'not connected'; lines.push(`${runtimeIcon} Runtime: ${runtimeName} ${runtimeLabel}${runtimeVersion}`); - // Browser binary status — distinct from "Runtime connected", which only - // reflects the daemon/Cloak process and says nothing about whether the - // Chromium binary Cloak needs to launch is actually installed. + // Application availability is distinct from a live daemon attachment. if (report.binary) { const binaryIcon = report.binary.installed === undefined ? '[WARN]' diff --git a/src/errors.test.ts b/src/errors.test.ts index d1bfa379..bc0ff94f 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -131,7 +131,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 56b5041877aa6234ddffcdd57936fe050d9e4ec9 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 02:30:53 +0530 Subject: [PATCH 10/34] feat: complete webcmd integration with SLAB --- PRIVACY.md | 2 +- TESTING.md | 6 +- docs/cli-reference.mdx | 2 +- package.json | 1 - plugins/skyscanner/flights.js | 2 +- plugins/ycombinator/companies.js | 2 +- skill-src/cli/smart-search/SKILL.src.md | 2 +- skill-src/cli/webcmd-browser/SKILL.src.md | 2 +- skills/smart-search/SKILL.md | 2 +- skills/webcmd-browser/SKILL.md | 2 +- src/browser/command-catalog.test.ts | 8 +- src/browser/command-catalog.ts | 13 +- src/browser/daemon-lifecycle.ts | 8 +- src/browser/errors.ts | 2 +- .../runtime/local-slab/attachment.test.ts | 22 ++- src/browser/runtime/local-slab/attachment.ts | 10 +- src/cli.test.ts | 14 +- src/cli.ts | 16 +- src/commands/daemon.test.ts | 2 +- src/commands/daemon.ts | 4 +- src/doctor.test.ts | 36 ++-- src/hosted/browser-args.test.ts | 10 +- src/hosted/browser-args.ts | 16 +- src/hosted/setup.test.ts | 40 ++++ src/hosted/setup.ts | 39 +++- src/skills.test.ts | 2 +- src/slab/control-bridge.test.ts | 27 +++ src/slab/control-bridge.ts | 37 ++++ src/slab/install.test.ts | 116 +++++++++++ src/slab/install.ts | 145 ++++++++++++++ src/slab/installation.test.ts | 34 ++++ src/slab/installation.ts | 16 +- src/slab/launch.test.ts | 107 ++++++++++ src/slab/launch.ts | 69 +++++++ src/slab/release-key.ts | 10 +- src/slab/status.test.ts | 35 ++++ src/slab/status.ts | 37 ++++ src/update-check.ts | 2 +- src/update.ts | 2 +- tests/e2e/cloak-runtime.test.ts | 182 ------------------ tests/e2e/cloak-session-concurrency.test.ts | 160 --------------- vitest.config.ts | 3 - 42 files changed, 817 insertions(+), 430 deletions(-) create mode 100644 src/slab/control-bridge.test.ts create mode 100644 src/slab/control-bridge.ts create mode 100644 src/slab/install.test.ts create mode 100644 src/slab/install.ts create mode 100644 src/slab/installation.test.ts create mode 100644 src/slab/launch.test.ts create mode 100644 src/slab/launch.ts create mode 100644 src/slab/status.test.ts create mode 100644 src/slab/status.ts delete mode 100644 tests/e2e/cloak-runtime.test.ts delete mode 100644 tests/e2e/cloak-session-concurrency.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index 8601c41d..3185540d 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 SLAB browser communicates with webcmd through owner-scoped local IPC. webcmd does not expose a raw TCP debugging endpoint. The runtime can access browser pages and cookies because browser automation requires those permissions. Webcmd does not send 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..67f8b6af 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 unit src/slab src/browser/runtime/local-slab ``` -The first run may download the CloakBrowser Chromium binary. Browser-backed tests no longer require a Chrome extension. +These tests use the local SLAB control contract and do not download or launch a browser. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index e83262b7..db938d14 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -71,7 +71,7 @@ webcmd --profile work session close \ session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 ``` -Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use SLAB. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs diff --git a/package.json b/package.json index ccbe03b4..f295387d 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,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", "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/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/cli/smart-search/SKILL.src.md b/skill-src/cli/smart-search/SKILL.src.md index 358b5f63..f68fa95f 100644 --- a/skill-src/cli/smart-search/SKILL.src.md +++ b/skill-src/cli/smart-search/SKILL.src.md @@ -43,7 +43,7 @@ webcmd web fetch --url Run `webcmd web fetch` before browser work or non-Webcmd HTTP clients. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. If a URL was already fetched outside Webcmd and got non-2xx, 403, blocked, or Cloudflare, that does not change the order: run `webcmd web fetch --url ` once before any browser escalation. -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/cli/webcmd-browser/SKILL.src.md b/skill-src/cli/webcmd-browser/SKILL.src.md index a1ee6d44..7f3f5d90 100644 --- a/skill-src/cli/webcmd-browser/SKILL.src.md +++ b/skill-src/cli/webcmd-browser/SKILL.src.md @@ -40,7 +40,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 create work diff --git a/skills/smart-search/SKILL.md b/skills/smart-search/SKILL.md index a54351bd..8d854a72 100644 --- a/skills/smart-search/SKILL.md +++ b/skills/smart-search/SKILL.md @@ -43,7 +43,7 @@ webcmd web fetch --url Run `webcmd web fetch` before browser work or non-Webcmd HTTP clients. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. If a URL was already fetched outside Webcmd and got non-2xx, 403, blocked, or Cloudflare, that does not change the order: run `webcmd web fetch --url ` once before any browser escalation. -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 c9bcfa2c..6618e50e 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -40,7 +40,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 create work diff --git a/src/browser/command-catalog.test.ts b/src/browser/command-catalog.test.ts index 99ed85c9..076d77df 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -56,10 +56,11 @@ describe('browserCommandCatalog', () => { expect(() => browserOptionValueParser('verify', 'trace')?.('invalid')).toThrow(/off, on, retain-on-failure/); }); - it('requires a stable page id for bind and limits run to program options', () => { + it('allows either stable page selector for bind and limits run to program options', () => { const commands = new Map(browserCommandCatalog.map(command => [command.command, command])); expect(commands.get('bind')?.options).toEqual([ - expect.objectContaining({ name: 'page', required: true }), + expect.objectContaining({ name: 'page', required: false }), + expect.objectContaining({ name: 'targetId', required: false }), expect.objectContaining({ name: 'verbose', type: 'boolean' }), ]); expect(commands.get('run')?.options.map(option => option.name)).toEqual([ @@ -71,6 +72,9 @@ describe('browserCommandCatalog', () => { 'noSnapshotDiff', 'verbose', ]); + expect(browserOptionFlags(commands.get('bind')!.options[1]!, 'bind')).toBe('--target-id '); + expect(browserOptionValueParser('bind', 'targetId')?.(' target-123 ')).toBe('target-123'); + expect(() => browserOptionValueParser('bind', 'targetId')?.(' ')).toThrow(/non-empty id/); }); it('includes snapshot as the read-only browser inspection command', () => { diff --git a/src/browser/command-catalog.ts b/src/browser/command-catalog.ts index a4443248..47d2ac3b 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -135,7 +135,7 @@ export function browserOptionFlags(option: HostedArgumentContract, commandPath?: const longName = option.name.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`); if (option.name === 'verbose') return '-v, --verbose'; if (option.type === 'boolean') return `--${longName}`; - const valueName = option.name === 'page' ? 'id' + const valueName = option.name === 'page' || option.name === 'targetId' ? 'id' : option.name === 'file' ? 'path' : option.name === 'timeout' && commandPath === 'run' ? 'seconds' : option.name === 'maxOutput' ? 'characters' @@ -152,11 +152,11 @@ export function browserOptionValueParser( commandPath: string, optionName: string, ): ((value: string) => unknown) | undefined { - if (commandPath === 'bind' && optionName === 'page') { + if (commandPath === 'bind' && (optionName === 'page' || optionName === 'targetId')) { return (value: string): string => { - const page = value.trim(); - if (!page) throw new InvalidArgumentError('--page must be a non-empty stable page id'); - return page; + const id = value.trim(); + if (!id) throw new InvalidArgumentError(`--${optionName === 'targetId' ? 'target-id' : 'page'} must be a non-empty id`); + return id; }; } if (optionName === 'snapshotMode' && commandPath === 'run') return runSnapshotModeParser; @@ -180,7 +180,8 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [ ], 'require-existing'), command('init', 'Generate an adapter scaffold. Does not take --session.', 'init', [adapterNamePositional], [], 'sessionless'), command('bind', 'Bind this session to an existing page', 'bind', [], [ - option('page', 'Stable page id returned by tabs', { required: true }), + option('page', 'Stable page id returned by tabs'), + option('targetId', 'Native CDP target id for an explicitly acquired page'), verboseFlag(), ], 'require-existing'), command('verify', 'Verify an adapter against its fixture. Does not take --session.', 'verify', [adapterNamePositional], [ diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 214f3380..48f475e6 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -177,8 +177,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(' Make sure SLAB is open.\n'); } const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); @@ -199,14 +199,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 .', + 'Open the matching SLAB profile and make sure SLAB is running, 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.', + 'Open SLAB and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); } diff --git a/src/browser/errors.ts b/src/browser/errors.ts index faa55a0d..976d12ba 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -127,7 +127,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str case 'extension-not-connected': return new BrowserConnectError( 'Browser runtime is not ready.' + (detail ? `\n\n${detail}` : ''), - 'Run `webcmd daemon restart`. If this is the first browser-backed command, wait for CloakBrowser to finish installing its browser binary, then retry.', + 'Open SLAB and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); case 'command-failed': diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts index c54e33f3..1d4aabd4 100644 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -1,4 +1,4 @@ -import type { ConnectOverCDPTransport } from 'playwright-core'; +import type { Browser, BrowserContext, ConnectOverCDPTransport } from 'playwright-core'; import { describe, expect, it, vi } from 'vitest'; import { SlabCredential } from '../../../slab/protocol.js'; import { attachSlabProfile } from './attachment.js'; @@ -41,6 +41,26 @@ describe('attachSlabProfile', () => { expect(result.context).toBe(context); }); + it('creates a launch-aware control bridge when a caller does not provide one', async () => { + const lease = attachment(); + const bridge = { + attach: vi.fn().mockResolvedValue(lease), + release: vi.fn().mockResolvedValue(undefined), + }; + const connectBridge = vi.fn().mockResolvedValue(bridge); + const context = {} as BrowserContext; + const browser = { contexts: () => [context], version: () => '1' } as unknown as Browser; + + await attachSlabProfile('default', { + connectBridge, + connectTransport: vi.fn().mockResolvedValue({ close: vi.fn() }), + connectOverCDP: vi.fn().mockResolvedValue(browser), + }); + + expect(connectBridge).toHaveBeenCalledOnce(); + expect(bridge.attach).toHaveBeenCalledWith('default'); + }); + it('closes the transport and releases the lease when Playwright setup fails', async () => { const lease = attachment(); const cdpTransport = transport(); diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts index 93bd75fa..9122a142 100644 --- a/src/browser/runtime/local-slab/attachment.ts +++ b/src/browser/runtime/local-slab/attachment.ts @@ -1,6 +1,7 @@ import { chromium, type Browser, type BrowserContext, type ConnectOverCDPTransport } from 'playwright-core'; import { CdpIpcTransport } from '../../../slab/cdp-ipc-transport.js'; import type { SlabAttachResult } from '../../../slab/protocol.js'; +import { connectSlabControlBridge, type SlabControlBridge } from '../../../slab/control-bridge.js'; export interface AttachedSlabProfile { profileId: string; @@ -13,21 +14,18 @@ export interface AttachedSlabProfile { export type SlabAttachment = SlabAttachResult; -export interface SlabBridge { - attach(profileId: string): Promise; - release(connectionId: string): Promise; -} +export type SlabBridge = SlabControlBridge; export interface AttachSlabProfileOptions { bridge?: SlabBridge; + connectBridge?: () => Promise; connectOverCDP?: typeof chromium.connectOverCDP; connectTransport?: typeof CdpIpcTransport.connect; attachTimeoutMs?: number; } export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { - const bridge = options.bridge; - if (!bridge) throw new Error('SLAB control client is not available.'); + const bridge = options.bridge ?? await (options.connectBridge ?? connectSlabControlBridge)(); const attachment = await bridge.attach(profileId); const attachTimeoutMs = options.attachTimeoutMs ?? 30_000; let transport: ConnectOverCDPTransport | undefined; diff --git a/src/cli.test.ts b/src/cli.test.ts index a87bd9f3..61bdd6dd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1438,12 +1438,12 @@ name: 'search', expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['create', '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 SLAB profiles available through the local runtime', }); 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 }, @@ -1451,7 +1451,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; @@ -1988,7 +1988,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 () => { @@ -2012,7 +2012,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 runtime 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'}`); @@ -2167,7 +2167,7 @@ describe('structured output for data-returning built-ins', () => { await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'use', 'ctx_live']); - expect(stdout()).toContain('Default Cloak profile: ctx_live'); + expect(stdout()).toContain('Default SLAB profile: ctx_live'); }); it('sets the default from a saved alias when the daemon is down', async () => { @@ -2180,7 +2180,7 @@ describe('structured output for data-returning built-ins', () => { await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'use', 'work']); - expect(stdout()).toContain('Default Cloak profile: ctx_work'); + expect(stdout()).toContain('Default SLAB profile: ctx_work'); }); it('fails structured profile list with DAEMON_UNAVAILABLE instead of an empty array', async () => { diff --git a/src/cli.ts b/src/cli.ts index e9513e3a..9b9b6a38 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -877,7 +877,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.'); @@ -2076,7 +2076,7 @@ cli({ const profileListCmd = addOutputFormatOption(profileCmd .command('list') - .description('List Chrome and Chromium profiles available through the Cloak runtime')); + .description('List SLAB profiles available through the local runtime')); profileListCmd.action(async (opts: { format?: string }, command: Command) => { const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; @@ -2121,13 +2121,13 @@ cli({ return; } if (profiles.length === 0) { - console.log('No Cloak runtime profiles are active.'); + console.log('No SLAB runtime 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); @@ -2155,7 +2155,7 @@ cli({ profileCmd .command('create') - .description('Create a Cloak profile alias') + .description('Create a SLAB profile alias') .argument('', 'Local alias, e.g. work or personal') .action(async (alias: string, _opts: unknown, command: Command) => { const result = createProfile(alias); @@ -2174,7 +2174,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(async (contextId: string, alias: string, _opts: unknown, command: Command) => { @@ -2191,7 +2191,7 @@ 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 from webcmd profile list') .action(async (profile: string, _opts: unknown, command: Command) => { const status = await fetchDaemonStatus(); @@ -2206,7 +2206,7 @@ cli({ profile, defaultContextId: next.defaultContextId ?? profile, }, () => { - console.log(`Default Cloak profile: ${next.defaultContextId ?? profile}`); + console.log(`Default SLAB profile: ${next.defaultContextId ?? profile}`); }); }); 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 8a3c5bc8..54453ff5 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -110,7 +110,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(); @@ -133,6 +133,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 c954b240..0562d405 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -143,16 +143,16 @@ 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, - runtimeName: 'Cloak', + runtimeName: 'SLAB', issues: [], })); - expect(text).toContain('[OK] Runtime: Cloak connected (version unknown)'); - expect(text).not.toContain('Cloak runtime is connected but did not report a version.'); + expect(text).toContain('[OK] Runtime: SLAB connected (version unknown)'); + expect(text).not.toContain('SLAB runtime is connected but did not report a version.'); expect(text).toContain('Everything looks good!'); }); @@ -160,24 +160,24 @@ describe('doctor report rendering', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, runtimeConnected: true, - runtimeName: 'Cloak', - binary: { installed: true, path: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', override: false }, + runtimeName: 'SLAB', + binary: { installed: true, path: '/Applications/SLAB.app', override: false }, issues: [], })); - expect(text).toContain('[OK] Browser binary: installed at /home/test/.cloakbrowser/chromium-1.0.0/chrome'); + expect(text).toContain('[OK] Browser binary: installed at /Applications/SLAB.app'); }); 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 ...'], + runtimeName: 'SLAB', + binary: { installed: false, path: '/Applications/SLAB.app', override: false }, + issues: ['SLAB.app is not installed.'], })); - expect(text).toContain('[MISSING] Browser binary: not installed (/home/test/.cloakbrowser/chromium-1.0.0/chrome)'); + expect(text).toContain('[MISSING] Browser binary: not installed (/Applications/SLAB.app)'); }); it('renders connectivity OK when live test succeeds', () => { @@ -212,13 +212,13 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeFlaky: true, - runtimeName: 'Cloak', + runtimeName: 'SLAB', connectivity: { ok: true, durationMs: 1234 }, - issues: ['Cloak runtime connection is unstable.'], + issues: ['SLAB runtime connection is unstable.'], })); - expect(text).toContain('[WARN] Runtime: Cloak unstable'); - expect(text).toContain('Cloak runtime connection is unstable.'); + expect(text).toContain('[WARN] Runtime: SLAB unstable'); + expect(text).toContain('SLAB runtime connection is unstable.'); }); it('renders unstable daemon state when live connectivity and status disagree', () => { @@ -335,7 +335,7 @@ describe('doctor report rendering', () => { closeWindow, }; }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'SLAB' } }); await runBrowserDoctor(); @@ -352,12 +352,12 @@ describe('doctor report rendering', () => { expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenLastCalledWith(null); }); - it('does not report an issue when the connected Cloak runtime does not report a version', async () => { + it('does not report an issue when the connected SLAB runtime does not report a version', async () => { const status = { state: 'ready' as const, status: { runtimeConnected: true, - runtimeName: 'Cloak', + runtimeName: 'SLAB', runtimeVersion: undefined, }, }; diff --git a/src/hosted/browser-args.test.ts b/src/hosted/browser-args.test.ts index cc8241b0..721b6713 100644 --- a/src/hosted/browser-args.test.ts +++ b/src/hosted/browser-args.test.ts @@ -38,15 +38,23 @@ describe('hosted browser argument surface', () => { expect(() => parse(['--session', 'session_work', 'browser', 'fork', 'linkedin/search'])).toThrow(); }); - it('requires a stable page id for bind', () => { + it('requires exactly one supported page selector for bind', () => { expect(parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123'])).toMatchObject({ commandName: 'bind', session: 'session_work', options: { page: 'page-123' }, }); + expect(parse(['--session', 'session_work', 'browser', 'bind', '--target-id', 'target-123'])).toMatchObject({ + commandName: 'bind', + session: 'session_work', + options: { targetId: 'target-123' }, + }); expect(() => parse(['--session', 'session_work', 'browser', 'bind'])).toThrow(CommanderStructuralError); expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--target-id', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123', '--target-id', 'target-123'])) + .toThrow(CommanderStructuralError); }); it('accepts only run program options', () => { diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index abdcbd70..709ef9c2 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -139,11 +139,25 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost ); } - return parsed ?? { + const result = parsed ?? { positionals: [], options: {}, ...readBrowserGlobals(root, browser), }; + validateBindSelectors(result); + return result; +} + +function validateBindSelectors(result: ParsedHostedBrowserStructure): void { + if (result.commandName !== 'bind') return; + const page = typeof result.options.page === 'string' ? result.options.page : ''; + const targetId = typeof result.options.targetId === 'string' ? result.options.targetId : ''; + if (Boolean(page) === Boolean(targetId)) { + throw new CommanderStructuralError( + `error: Bind requires exactly one of --page or --target-id\n`, + EXIT_CODES.USAGE_ERROR, + ); + } } function normalizeBrowserOptions(command: string, options: Record): Record { diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 0532dce4..5e6120f8 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -18,6 +18,46 @@ afterEach(async () => { }); describe('webcmd setup', () => { + it('reports a running preliminary SLAB app in local setup status without invoking installation', async () => { + const messages: string[] = []; + const slabStatus = vi.fn(async () => 'preliminary-running' as const); + + const code = await runHostedSetup({ + argv: ['--status'], + isTTY: false, + existsSync: () => true, + readFileSync: (() => JSON.stringify({ mode: 'local', updatedAt: '2026-08-27T00:00:00.000Z' })) as never, + slabStatus, + write: message => { messages.push(message); }, + }); + + expect(code).toBe(0); + expect(messages.join('')).toBe('{"configured":true,"mode":"local","slab":"preliminary-running"}\n'); + expect(slabStatus).toHaveBeenCalledOnce(); + }); + + it('does not probe SLAB for hosted setup status', async () => { + const messages: string[] = []; + const slabStatus = vi.fn(async () => 'not-installed' as const); + + const code = await runHostedSetup({ + argv: ['--status'], + isTTY: false, + existsSync: () => true, + readFileSync: (() => JSON.stringify({ + mode: 'hosted', + updatedAt: '2026-08-27T00:00:00.000Z', + hosted: { apiBaseUrl: 'https://api.webcmd.dev', apiKeyRef: 'wcmd_cred_test' }, + })) as never, + slabStatus, + write: message => { messages.push(message); }, + }); + + expect(code).toBe(0); + expect(messages.join('')).toBe('{"configured":true,"mode":"hosted"}\n'); + expect(slabStatus).not.toHaveBeenCalled(); + }); + it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); const answers = ['local']; diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 1b102cd5..87301b9a 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,5 +1,6 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; +import * as fs from 'node:fs'; import { CLI_COMMAND } from '../brand.js'; import { ArgumentError, toEnvelope } from '../errors.js'; import { formatErrorEnvelope } from '../output.js'; @@ -7,6 +8,8 @@ import { writeToStream } from '../stream-write.js'; import { HostedClient } from './client.js'; import { defaultHostedApiBaseUrl, + getConfigPath, + loadWebcmdConfig, makeLocalConfig, saveWebcmdConfig, type ConfigIo, @@ -27,17 +30,19 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { write?: (message: string) => void | Promise; argv?: readonly string[]; isTTY?: boolean; + slabStatus?: () => Promise<'preliminary-running' | 'installed-running' | 'installed-not-running' | 'not-installed'>; } type SetupMode = 'local' | 'hosted'; -const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--api-key ]`; +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup [--status] [--mode [--api-key ]]`; const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; const SETUP_HELP = [ `${CLI_COMMAND} setup`, '', 'Configure local or hosted mode.', '', + ' --status Show the configured mode and local SLAB status', ' --mode Required when stdin is not a TTY', ' --api-key Required for --mode hosted when stdin is not a TTY', ' -h, --help', @@ -67,6 +72,19 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { return 0; } + if (parsed.status) { + const config = loadWebcmdConfig(io); + const status: { configured: boolean; mode: SetupMode; slab?: string } = { + configured: (io.existsSync ?? fs.existsSync)(getConfigPath(io)), + mode: config.mode, + }; + if (config.mode === 'local') { + status.slab = await (io.slabStatus ?? localSlabStatus)(); + } + await write(`${JSON.stringify(status)}\n`); + return 0; + } + const interactive = canPrompt(io); let mode = parsed.mode; if (!mode) { @@ -156,12 +174,17 @@ function canPrompt(io: SetupIo): boolean { return process.stdin.isTTY === true && process.stdout.isTTY === true; } -function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; apiKey?: string } { +function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; mode?: SetupMode; apiKey?: string } { let mode: SetupMode | undefined; let apiKey: string | undefined; + let status = false; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; if (token === '--help' || token === '-h') return { help: true }; + if (token === '--status') { + status = true; + continue; + } if (token === '--mode' || token.startsWith('--mode=')) { const value = token.startsWith('--mode=') ? token.slice('--mode='.length) : argv[++i]; @@ -189,10 +212,18 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMod throw new ArgumentError( `unknown flag ${token} for \`setup\``, - `valid flags for \`setup\`: --mode, --api-key, --help\n${SETUP_USAGE}`, + `valid flags for \`setup\`: --status, --mode, --api-key, --help\n${SETUP_USAGE}`, ); } - return { mode, apiKey }; + if (status && (mode || apiKey)) { + throw new ArgumentError('--status cannot be combined with setup options.', SETUP_USAGE); + } + return { ...(status ? { status: true as const } : {}), mode, apiKey }; +} + +async function localSlabStatus(): Promise<'preliminary-running' | 'installed-running' | 'installed-not-running' | 'not-installed'> { + const { inspectSlabStatus } = await import('../slab/status.js'); + return inspectSlabStatus(); } function hostedAccountLabel(body: unknown): string | undefined { diff --git a/src/skills.test.ts b/src/skills.test.ts index b4f04d7e..d1581e59 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -75,7 +75,7 @@ describe('webcmd skills content', () => { } expect(guide).toMatch(/web fetch.*(?:remains|runs).*local/i); expect(guide).toMatch(/web fetch.*never opens a browser/i); - expect(guide).toMatch(/local.*Cloak[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); + expect(guide).toMatch(/local.*SLAB[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); expect(guide).not.toMatch(/fetch-browser|web read|--browser/i); } expect(skill).toContain('Search Summary'); diff --git a/src/slab/control-bridge.test.ts b/src/slab/control-bridge.test.ts new file mode 100644 index 00000000..1a30fd90 --- /dev/null +++ b/src/slab/control-bridge.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { connectSlabControlBridge } from './control-bridge.js'; + +describe('SLAB control bridge', () => { + it('probes or opens SLAB before creating the lease-owning control client', async () => { + const calls: string[] = []; + const client = { + attach: vi.fn(async () => ({ connectionId: 'connection-1' })), + release: vi.fn(async () => null), + close: vi.fn(async () => { calls.push('close'); }), + }; + const bridge = await connectSlabControlBridge({ + ensureLaunched: async () => { calls.push('launch'); }, + connect: async () => { + calls.push('connect'); + return client as never; + }, + }); + + await bridge.attach('default'); + await bridge.release('connection-1'); + + expect(calls).toEqual(['launch', 'connect', 'close']); + expect(client.attach).toHaveBeenCalledWith('default'); + expect(client.release).toHaveBeenCalledWith('connection-1'); + }); +}); diff --git a/src/slab/control-bridge.ts b/src/slab/control-bridge.ts new file mode 100644 index 00000000..49583663 --- /dev/null +++ b/src/slab/control-bridge.ts @@ -0,0 +1,37 @@ +import { homedir } from 'node:os'; +import { SlabBridgeClient } from './bridge-client.js'; +import { slabControlEndpoint } from './installation.js'; +import { launchSlab } from './launch.js'; +import type { SlabAttachResult } from './protocol.js'; + +export interface SlabControlBridge { + attach(profileId: string): Promise; + release(connectionId: string): Promise; +} + +export interface SlabControlBridgeIo { + ensureLaunched(): Promise; + connect(): Promise>; +} + +export async function connectSlabControlBridge(io: SlabControlBridgeIo = createSlabControlBridgeIo()): Promise { + await io.ensureLaunched(); + const client = await io.connect(); + return { + attach: profileId => client.attach(profileId), + release: async connectionId => { + try { + await client.release(connectionId); + } finally { + await client.close(); + } + }, + }; +} + +export function createSlabControlBridgeIo(): SlabControlBridgeIo { + return { + ensureLaunched: launchSlab, + connect: () => SlabBridgeClient.connect(slabControlEndpoint(homedir())), + }; +} diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts new file mode 100644 index 00000000..e9096e1b --- /dev/null +++ b/src/slab/install.test.ts @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { installSlabMacos, replaceSlabAppAtomically, type SlabInstallerIo } from './install.js'; + +const releaseBytes = Buffer.from('signed-slab-dmg'); +const releaseSha256 = createHash('sha256').update(releaseBytes).digest('hex'); + +function fakeInstaller(options: { + expectedSha256?: string; + downloadedBytes?: Buffer; + bundleId?: string; + verifyManifest?: boolean; + failCommand?: 'codesign' | 'spctl'; +} = {}) { + const operations: string[] = []; + const execFile = vi.fn(async (command: string, args: string[]) => { + if (command === options.failCommand) throw new Error(`${command} rejected the staged app`); + if (command === 'hdiutil' && args[0] === 'attach') operations.push('mount-readonly'); + if (command === 'hdiutil' && args[0] === 'detach') operations.push('detach'); + if (command === 'ditto') operations.push('copy-to-staging'); + if (command === 'codesign') operations.push('codesign-verify'); + if (command === 'spctl') operations.push('spctl-verify'); + }); + const replaceApp = vi.fn(async () => { operations.push('replace-app'); }); + const access = vi.fn(async () => {}); + const io: SlabInstallerIo & { operations(): string[]; execFile: typeof execFile; replaceApp: typeof replaceApp; access: typeof access } = { + homeDir: '/Users/me', + tempDir: '/tmp', + fetch: async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: options.expectedSha256 ?? releaseSha256, signature: 'release-signature' }) } + : { ok: true, arrayBuffer: async () => { + const bytes = options.downloadedBytes ?? releaseBytes; + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } }, + execFile, + mkdtemp: async () => '/tmp/slab-install', + writeFile: async () => { operations.push('download'); }, + sha256: async bytes => { + operations.push('checksum'); + return createHash('sha256').update(bytes).digest('hex'); + }, + mkdir: async () => {}, + rm: async () => { operations.push('cleanup'); }, + access, + replaceApp, + verifyManifest: () => options.verifyManifest ?? true, + bundleId: async () => options.bundleId ?? 'dev.webcmd.slab', + operations: () => operations.filter(operation => operation !== 'cleanup'), + }; + return io; +} + +describe('SLAB macOS installer', () => { + it('rejects an invalid manifest signature before download', async () => { + const io = fakeInstaller({ verifyManifest: false }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer release signature verification failed'); + expect(io.execFile).not.toHaveBeenCalled(); + }); + + it('verifies SHA-256 before mounting the DMG', async () => { + const io = fakeInstaller({ expectedSha256: '00'.repeat(32), downloadedBytes: Buffer.from('not-the-release') }); + + await expect(installSlabMacos(io)).rejects.toThrow('SLAB installer checksum mismatch'); + expect(io.execFile).not.toHaveBeenCalled(); + }); + + it('mounts the downloaded DMG read-only and stages it before replacement', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.execFile).toHaveBeenCalledWith('hdiutil', expect.arrayContaining(['attach', '-readonly', '-nobrowse', '-mountpoint'])); + expect(io.operations()).toEqual([ + 'download', 'checksum', 'mount-readonly', 'copy-to-staging', + 'codesign-verify', 'spctl-verify', 'replace-app', 'detach', + ]); + expect(io.replaceApp).toHaveBeenCalledWith('/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); + }); + + it('rejects a staged app with the wrong bundle identifier', async () => { + await expect(installSlabMacos(fakeInstaller({ bundleId: 'com.example.other' }))) + .rejects.toThrow('SLAB installer bundle identifier mismatch'); + }); + + it.each(['codesign', 'spctl'] as const)('does not replace the app when %s verification fails', async (failCommand) => { + const io = fakeInstaller({ failCommand }); + + await expect(installSlabMacos(io)).rejects.toThrow(`${failCommand} rejected the staged app`); + expect(io.replaceApp).not.toHaveBeenCalled(); + }); + + it('checks system Applications write access before choosing its staging directory', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.access).toHaveBeenCalledWith('/Applications', constants.W_OK); + }); + + it('rolls the existing app back when the staging rename fails', async () => { + const rename = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('destination busy')) + .mockResolvedValueOnce(undefined); + const rm = vi.fn(async () => {}); + + await expect(replaceSlabAppAtomically({ rename, rm }, '/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app')) + .rejects.toThrow('destination busy'); + expect(rename).toHaveBeenNthCalledWith(1, '/Applications/SLAB.app', '/Applications/SLAB.app.previous'); + expect(rename).toHaveBeenNthCalledWith(2, '/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); + expect(rename).toHaveBeenNthCalledWith(3, '/Applications/SLAB.app.previous', '/Applications/SLAB.app'); + expect(rm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/slab/install.ts b/src/slab/install.ts new file mode 100644 index 00000000..197bae38 --- /dev/null +++ b/src/slab/install.ts @@ -0,0 +1,145 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { access, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { execFile as execFileCallback } from 'node:child_process'; +import { verifySlabReleaseManifest, type SlabReleaseManifest } from './release-key.js'; +import type { SlabInstallation } from './installation.js'; + +const execFile = promisify(execFileCallback); + +export const SLAB_BUNDLE_ID = 'dev.webcmd.slab'; +export const SLAB_RELEASE_MANIFEST_URL = 'https://downloads.webcmd.dev/slab/macos-arm64.json'; + +export interface InstallSlabOptions { + launchAfterInstall?: boolean; + manifestUrl?: string; +} + +export interface SlabInstallerIo { + homeDir: string; + tempDir: string; + fetch(url: string): Promise<{ ok: boolean; json?(): Promise; arrayBuffer?(): Promise }>; + execFile(command: string, args: string[]): Promise; + mkdtemp(prefix: string): Promise; + writeFile(path: string, bytes: Uint8Array): Promise; + sha256?(bytes: Uint8Array): Promise; + mkdir(path: string): Promise; + rm(path: string): Promise; + access(path: string, mode: number): Promise; + bundleId(appPath: string): Promise; + replaceApp(source: string, destination: string): Promise; + verifyManifest(manifest: SlabReleaseManifest): boolean | Promise; +} + +export interface SlabReplacementIo { + rename(source: string, destination: string): Promise; + rm(path: string): Promise; +} + +function parseManifest(value: unknown): SlabReleaseManifest { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('SLAB installer release manifest is invalid'); + const manifest = value as Partial; + if (typeof manifest.url !== 'string' || typeof manifest.sha256 !== 'string' || typeof manifest.signature !== 'string') { + throw new Error('SLAB installer release manifest is invalid'); + } + return { url: manifest.url, sha256: manifest.sha256, signature: manifest.signature }; +} + +async function responseJson(response: { ok: boolean; json?(): Promise }): Promise { + if (!response.ok || !response.json) throw new Error('SLAB installer release manifest download failed'); + return response.json(); +} + +async function responseBytes(response: { ok: boolean; arrayBuffer?(): Promise }): Promise { + if (!response.ok || !response.arrayBuffer) throw new Error('SLAB installer download failed'); + return Buffer.from(await response.arrayBuffer()); +} + +export async function replaceSlabAppAtomically(io: SlabReplacementIo, source: string, destination: string): Promise { + const previous = `${destination}.previous`; + await io.rm(previous); + try { + await io.rename(destination, previous); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + await io.rename(source, destination); + } catch (error) { + await io.rename(previous, destination).catch(() => undefined); + throw error; + } + await io.rm(previous); +} + +export async function installSlabMacos(io: SlabInstallerIo = createSlabInstallerIo(), options: InstallSlabOptions = {}): Promise { + const manifestResponse = await io.fetch(options.manifestUrl ?? SLAB_RELEASE_MANIFEST_URL); + const manifest = parseManifest(await responseJson(manifestResponse)); + if (!await io.verifyManifest(manifest)) throw new Error('SLAB installer release signature verification failed'); + + const tempPath = await io.mkdtemp(join(io.tempDir, 'webcmd-slab-')); + const dmgPath = join(tempPath, 'SLAB.dmg'); + const mountPath = join(tempPath, 'mount'); + let stagingPath: string | undefined; + let mounted = false; + + try { + const bytes = await responseBytes(await io.fetch(manifest.url)); + await io.writeFile(dmgPath, bytes); + const checksum = await (io.sha256?.(bytes) ?? Promise.resolve(createHash('sha256').update(bytes).digest('hex'))); + if (checksum.toLowerCase() !== manifest.sha256.toLowerCase()) throw new Error('SLAB installer checksum mismatch'); + + await io.mkdir(mountPath); + await io.execFile('hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountPath, dmgPath]); + mounted = true; + + let applicationsDir = '/Applications'; + try { + await io.access(applicationsDir, constants.W_OK); + } catch { + applicationsDir = join(io.homeDir, 'Applications'); + await io.mkdir(applicationsDir); + } + const appPath = join(applicationsDir, 'SLAB.app'); + stagingPath = join(applicationsDir, '.SLAB.app.webcmd-staging'); + await io.rm(stagingPath); + await io.execFile('ditto', [join(mountPath, 'SLAB.app'), stagingPath]); + await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); + if (await io.bundleId(stagingPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); + await io.execFile('spctl', ['--assess', '--type', 'execute', '--verbose=4', stagingPath]); + await io.replaceApp(stagingPath, appPath); + stagingPath = undefined; + if (options.launchAfterInstall) await io.execFile('open', [appPath]); + return { platform: 'darwin', appPath, executablePath: join(appPath, 'Contents', 'MacOS', 'SLAB') }; + } finally { + try { + if (mounted) await io.execFile('hdiutil', ['detach', mountPath]); + } finally { + if (stagingPath) await io.rm(stagingPath); + await io.rm(tempPath); + } + } +} + +export function createSlabInstallerIo(): SlabInstallerIo { + return { + homeDir: homedir(), + tempDir: tmpdir(), + fetch: globalThis.fetch, + execFile: async (command, args) => execFile(command, args), + mkdtemp, + writeFile, + mkdir: async path => { await mkdir(path, { recursive: true }); }, + rm: async path => { await rm(path, { recursive: true, force: true }); }, + access, + bundleId: async appPath => { + const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist')]); + return result.stdout.trim(); + }, + replaceApp: (source, destination) => replaceSlabAppAtomically({ rename, rm: async path => { await rm(path, { recursive: true, force: true }); } }, source, destination), + verifyManifest: verifySlabReleaseManifest, + }; +} diff --git a/src/slab/installation.test.ts b/src/slab/installation.test.ts new file mode 100644 index 00000000..d48abb59 --- /dev/null +++ b/src/slab/installation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findSlabInstallation, isSlabInstalled, slabControlEndpoint } from './installation.js'; + +describe('SLAB installation discovery', () => { + it('finds the first installed normal macOS app bundle', () => { + const existsSync = vi.fn((candidate: string) => candidate === '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB'); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync })).toEqual({ + platform: 'darwin', + appPath: '/Users/me/Applications/SLAB.app', + executablePath: '/Users/me/Applications/SLAB.app/Contents/MacOS/SLAB', + }); + }); + + it('does not fall back to a standalone browser executable', () => { + const existsSync = vi.fn(() => false); + + expect(findSlabInstallation({ platform: 'darwin', homeDir: '/Users/me', existsSync })).toBeNull(); + expect(existsSync.mock.calls.flat().join(' ')).not.toContain('slab-browser'); + }); + + it('reports only normal macOS app bundle availability', () => { + expect(isSlabInstalled({ + platform: 'darwin', + homeDir: '/Users/me', + existsSync: candidate => candidate === '/Applications/SLAB.app/Contents/MacOS/SLAB', + })).toBe(true); + expect(isSlabInstalled({ platform: 'linux', homeDir: '/Users/me', existsSync: () => true })).toBe(false); + }); + + it('uses the owner-scoped control socket path', () => { + expect(slabControlEndpoint('/Users/me')).toBe('/Users/me/.slab/run/slab-bridge.sock'); + }); +}); diff --git a/src/slab/installation.ts b/src/slab/installation.ts index 1cd80858..f0885d4c 100644 --- a/src/slab/installation.ts +++ b/src/slab/installation.ts @@ -1,5 +1,8 @@ +import { join } from 'node:path'; + export interface SlabInstallation { platform: NodeJS.Platform; + appPath: string; executablePath: string; version?: string; } @@ -13,11 +16,12 @@ export interface SlabInstallationIo { 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`, + for (const appPath of [ + '/Applications/SLAB.app', + join(io.homeDir, 'Applications', 'SLAB.app'), ]) { - if (io.existsSync(executablePath)) return { platform: io.platform, executablePath }; + const executablePath = join(appPath, 'Contents', 'MacOS', 'SLAB'); + if (io.existsSync(executablePath)) return { platform: io.platform, appPath, executablePath }; } return null; @@ -26,3 +30,7 @@ export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | export function isSlabInstalled(io: SlabInstallationIo): boolean { return findSlabInstallation(io) !== null; } + +export function slabControlEndpoint(homeDir: string): string { + return join(homeDir, '.slab', 'run', 'slab-bridge.sock'); +} diff --git a/src/slab/launch.test.ts b/src/slab/launch.test.ts new file mode 100644 index 00000000..09185c74 --- /dev/null +++ b/src/slab/launch.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConfigError } from '../errors.js'; +import { launchSlab } from './launch.js'; + +const helloResult = { protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }; +const app = { + platform: 'darwin' as const, + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', +}; + +describe('SLAB launch', () => { + it('uses an already-running preliminary app without requiring installation discovery', async () => { + const io = { + findInstallation: vi.fn(() => { throw new Error('must not discover'); }), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockResolvedValue(helloResult), + wait: vi.fn(), + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.findInstallation).not.toHaveBeenCalled(); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('fails closed when no installed app is available after the control socket is unavailable', async () => { + const io = { + findInstallation: vi.fn(() => null), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockRejectedValue(new Error('control socket unavailable')), + wait: vi.fn(), + now: vi.fn(), + }; + + await expect(launchSlab(io)).rejects.toBeInstanceOf(ConfigError); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('does not launch when hello reports an invalid control response', async () => { + const responseError = new Error('SLAB control response is invalid'); + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(), + launch: vi.fn(), + hello: vi.fn().mockRejectedValue(responseError), + wait: vi.fn(), + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).rejects.toBe(responseError); + expect(io.launch).not.toHaveBeenCalled(); + }); + + it('opens only the installed normal app and waits for hello', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => false), + launch: vi.fn(async () => {}), + hello: vi.fn() + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockResolvedValue(helloResult), + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.launch).toHaveBeenCalledWith('/Applications/SLAB.app'); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('does not relaunch an already-running installed app while waiting for its control socket', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => true), + launch: vi.fn(async () => {}), + hello: vi.fn() + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockRejectedValueOnce(new Error('control socket unavailable')) + .mockResolvedValue(helloResult), + wait: vi.fn(async () => {}), + now: vi.fn(() => 0), + }; + + await expect(launchSlab(io)).resolves.toEqual(helloResult); + expect(io.launch).not.toHaveBeenCalled(); + expect(io.wait).toHaveBeenCalledOnce(); + }); + + it('times out after five seconds when the owner-scoped control socket never becomes ready', async () => { + const io = { + findInstallation: vi.fn(() => app), + isRunning: vi.fn(() => true), + launch: vi.fn(async () => {}), + hello: vi.fn(async () => { throw new Error('control socket unavailable'); }), + wait: vi.fn(async () => {}), + now: vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(5_000), + }; + + await expect(launchSlab(io)).rejects.toMatchObject({ hint: 'Open SLAB and retry the browser command.' }); + expect(io.launch).not.toHaveBeenCalled(); + expect(io.wait).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/slab/launch.ts b/src/slab/launch.ts new file mode 100644 index 00000000..418699b6 --- /dev/null +++ b/src/slab/launch.ts @@ -0,0 +1,69 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { promisify } from 'node:util'; +import { ConfigError } from '../errors.js'; +import { SlabBridgeClient, SlabProtocolError } from './bridge-client.js'; +import { findSlabInstallation, slabControlEndpoint, type SlabInstallation } from './installation.js'; +import type { SlabHelloResult } from './protocol.js'; + +const execFile = promisify(execFileCallback); +const CONTROL_READY_TIMEOUT_MS = 5_000; + +export interface SlabLaunchIo { + findInstallation(): SlabInstallation | null; + isRunning(appPath: string): boolean | Promise; + launch(appPath: string): Promise; + hello(): Promise; + wait(): Promise; + now(): number; +} + +function isControlUnavailable(error: unknown): boolean { + if (error instanceof SlabProtocolError) return false; + return !(error instanceof Error && error.message.startsWith('SLAB control response')); +} + +export async function launchSlab(io: SlabLaunchIo = createSlabLaunchIo()): Promise { + try { + return await io.hello(); + } catch (error) { + if (!isControlUnavailable(error)) throw error; + } + + const installation = io.findInstallation(); + if (!installation) { + throw new ConfigError('SLAB is not installed and its control endpoint is unavailable.', 'Open a preliminary SLAB build, or install the official SLAB.app.'); + } + if (!await io.isRunning(installation.appPath)) await io.launch(installation.appPath); + + const deadline = io.now() + CONTROL_READY_TIMEOUT_MS; + for (;;) { + try { + return await io.hello(); + } catch (error) { + if (!isControlUnavailable(error)) throw error; + if (io.now() >= deadline) throw new ConfigError('SLAB control endpoint did not become ready within five seconds.', 'Open SLAB and retry the browser command.'); + await io.wait(); + } + } +} + +export function createSlabLaunchIo(): SlabLaunchIo { + const endpoint = slabControlEndpoint(homedir()); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + isRunning: async () => execFile('pgrep', ['-x', 'SLAB']).then(() => true, () => false), + launch: async appPath => { await execFile('open', [appPath]); }, + hello: async () => { + const client = await SlabBridgeClient.connect(endpoint, { timeoutMs: 1_000 }); + try { + return await client.hello(); + } finally { + await client.close(); + } + }, + wait: () => new Promise(resolve => setTimeout(resolve, 100)), + now: Date.now, + }; +} diff --git a/src/slab/release-key.ts b/src/slab/release-key.ts index aa60fda3..76b54dd9 100644 --- a/src/slab/release-key.ts +++ b/src/slab/release-key.ts @@ -1,9 +1,11 @@ import { verify } from 'node:crypto'; -// Trust anchor for the signed SLAB release manifest. Left `undefined` so -// verification is fail-closed until a real production key is set: the installer -// refuses any manifest until this holds the operator's own Ed25519 public key. -export const SLAB_RELEASE_PUBLIC_KEY: string | undefined = undefined; +// Trust anchor for the signed SLAB release manifest. This public key is safe to +// embed; the matching private key stays in the official release environment. +export const SLAB_RELEASE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAoMo7Cbb1CRk2csqvdxMrR3SLBhQ9a8RHeDTRnChTeSQ= +-----END PUBLIC KEY----- +`; export interface SlabReleaseManifest { url: string; diff --git a/src/slab/status.test.ts b/src/slab/status.test.ts new file mode 100644 index 00000000..dd02009d --- /dev/null +++ b/src/slab/status.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { inspectSlabStatus } from './status.js'; + +const hello = { protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }; +const installation = { + platform: 'darwin' as const, + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', +}; + +describe('SLAB setup status', () => { + it('reports a control-ready app without requiring a signed installation', async () => { + const io = { + findInstallation: vi.fn(() => null), + hello: vi.fn().mockResolvedValue(hello), + }; + + await expect(inspectSlabStatus(io)).resolves.toBe('preliminary-running'); + expect(io.findInstallation).toHaveBeenCalledOnce(); + }); + + it('reports an installed normal app that is already running', async () => { + await expect(inspectSlabStatus({ + findInstallation: () => installation, + hello: async () => hello, + })).resolves.toBe('installed-running'); + }); + + it('reports a missing app when its control socket is unavailable', async () => { + await expect(inspectSlabStatus({ + findInstallation: () => null, + hello: async () => { throw new Error('control socket unavailable'); }, + })).resolves.toBe('not-installed'); + }); +}); diff --git a/src/slab/status.ts b/src/slab/status.ts new file mode 100644 index 00000000..9aba4067 --- /dev/null +++ b/src/slab/status.ts @@ -0,0 +1,37 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { SlabBridgeClient } from './bridge-client.js'; +import { findSlabInstallation, slabControlEndpoint, type SlabInstallation } from './installation.js'; +import type { SlabHelloResult } from './protocol.js'; + +export type SlabSetupStatus = 'preliminary-running' | 'installed-running' | 'installed-not-running' | 'not-installed'; + +export interface SlabStatusIo { + findInstallation(): SlabInstallation | null; + hello(): Promise; +} + +export async function inspectSlabStatus(io: SlabStatusIo = createSlabStatusIo()): Promise { + const installation = io.findInstallation(); + try { + await io.hello(); + return installation ? 'installed-running' : 'preliminary-running'; + } catch { + return installation ? 'installed-not-running' : 'not-installed'; + } +} + +export function createSlabStatusIo(): SlabStatusIo { + const endpoint = slabControlEndpoint(homedir()); + return { + findInstallation: () => findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }), + hello: async () => { + const client = await SlabBridgeClient.connect(endpoint, { timeoutMs: 1_000 }); + try { + return await client.hello(); + } finally { + await client.close(); + } + }, + }; +} diff --git a/src/update-check.ts b/src/update-check.ts index 04dee7c5..b990eb3c 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 official release artifacts.\n`; } return lines; } diff --git a/src/update.ts b/src/update.ts index 6029e165..b6e45215 100644 --- a/src/update.ts +++ b/src/update.ts @@ -25,7 +25,7 @@ export function buildUpgradeCommand(spec: string = `${PACKAGE_NAME}@latest`): Up } /** - * Notice for the separately-shipped Cloak runtime/extension, which `npm install -g` + * Notice for the separately-shipped SLAB runtime, which `npm install -g` * does NOT update. Returns the notice text when a newer runtime is known, else * undefined. Currently dormant until update-check URLs are enabled upstream. */ diff --git a/tests/e2e/cloak-runtime.test.ts b/tests/e2e/cloak-runtime.test.ts deleted file mode 100644 index f8e4140c..00000000 --- a/tests/e2e/cloak-runtime.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import fs from 'node:fs'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { runCli } from './helpers.js'; - -let server: http.Server; -let baseUrl = ''; -const sourceDirs: string[] = []; -let sharedConfigDir = ''; -let sharedProfile = ''; - -function isolatedOptions(options: Parameters[1] = {}): Parameters[1] { - return { - ...options, - env: { - HOME: path.join(sharedConfigDir, 'home'), - USERPROFILE: path.join(sharedConfigDir, 'home'), - WEBCMD_CONFIG_DIR: sharedConfigDir, - WEBCMD_PROFILE: sharedProfile, - ...options.env, - }, - }; -} - -function browserRun(session: string, source: string, options: Parameters[1] = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-run-')); - sourceDirs.push(dir); - const sourcePath = path.join(dir, 'program.js'); - fs.writeFileSync(sourcePath, source); - return runCli(['--session', session, 'browser', 'run', '--file', sourcePath], isolatedOptions(options)); -} - -async function createSession(options: Parameters[1] = {}) { - const result = await runCli(['session', 'create', '-f', 'json'], isolatedOptions(options)); - expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0); - return JSON.parse(result.stdout).id as string; -} - -beforeAll(async () => { - sharedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-suite-')); - sharedProfile = `cloak-suite-${Date.now()}`; - sourceDirs.push(sharedConfigDir); - const created = await runCli(['profile', 'create', sharedProfile], isolatedOptions({ timeout: 120_000 })); - expect(created.code, `${created.stdout}\n${created.stderr}`).toBe(0); - server = http.createServer((req, res) => { - if (req.url === '/cookie') { - res.setHeader('Set-Cookie', 'webcmd_smoke=ok; Path=/'); - res.end('Cookiecookie'); - return; - } - - if (req.url === '/api') { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ ok: true })); - return; - } - - const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); - if (requestUrl.pathname === '/counter') { - const index = requestUrl.searchParams.get('index'); - if (!index || !/^\d+$/.test(index)) { - res.statusCode = 400; - res.end('invalid counter index'); - return; - } - res.end(`Counter ${index}counter`); - return; - } - - res.end('Cloak Smoke'); - }); - 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 sourceDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); -}); - -describe('Cloak runtime e2e', () => { - it('runs Playwright against a page through webcmd browser', async () => { - const session = await createSession({ timeout: 120_000 }); - const result = await browserRun(session, ` - await page.goto(${JSON.stringify(baseUrl)}); - return await page.evaluate(() => document.title + ':' + window.answer); - `, { timeout: 120_000 }); - expect(result.code).toBe(0); - expect(result.stdout).toContain('Cloak Smoke:42'); - }, 180_000); - - it('persists cookies inside the Cloak profile', async () => { - const session = await createSession({ timeout: 120_000 }); - const cookies = await browserRun(session, ` - await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); - return await page.evaluate(() => document.cookie); - `, { timeout: 120_000 }); - expect(cookies.code).toBe(0); - expect(cookies.stdout).toContain('webcmd_smoke=ok'); - }, 180_000); - - 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 run = (args: string[]) => runCli(args, { - timeout: 120_000, - env: { - WEBCMD_CONFIG_DIR: configDir, - WEBCMD_PROFILE: profile, - }, - }); - expect((await run(['profile', 'create', profile])).code).toBe(0); - const waitForStoppedDaemon = async () => { - let status = await run(['daemon', 'status']); - for (let attempt = 0; attempt < 20 && !status.stdout.includes('Daemon: not running'); attempt += 1) { - await new Promise(resolve => setTimeout(resolve, 250)); - status = await run(['daemon', 'status']); - } - return status; - }; - let stopCode: number | undefined; - let stoppedStatus = { stdout: '', stderr: '', code: 1 }; - - try { - expect((await run(['daemon', 'stop'])).code).toBe(0); - expect((await waitForStoppedDaemon()).stdout).toContain('Daemon: not running'); - - try { - const session = await createSession({ - timeout: 120_000, - env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, - }); - expect((await browserRun(session, `await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); return null;`, { - timeout: 120_000, - env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, - })).code).toBe(0); - - for (let index = 0; index < 3; index += 1) { - const evaluated = await browserRun(session, ` - await page.goto(${JSON.stringify(`${baseUrl}/counter?index=${index}`)}); - return await page.locator('body').getAttribute('data-index'); - `, { - timeout: 120_000, - env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, - }); - expect(evaluated.code).toBe(0); - expect(evaluated.stdout).toContain(`"result": "${index}"`); - } - - const cookies = await browserRun(session, 'return await page.evaluate(() => document.cookie);', { - timeout: 120_000, - env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, - }); - expect(cookies.code).toBe(0); - expect(cookies.stdout).toContain('webcmd_smoke=ok'); - - 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(`Profiles: ${profile}`); - } finally { - const stopped = await run(['daemon', 'stop']); - stopCode = stopped.code; - } - - } finally { - if (stopCode !== undefined) { - stoppedStatus = await waitForStoppedDaemon(); - } - fs.rmSync(configDir, { recursive: true, force: true }); - } - - expect(stopCode).toBe(0); - expect(stoppedStatus.code).toBe(0); - expect(stoppedStatus.stdout).toContain('Daemon: not running'); - }, 480_000); -}); diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts deleted file mode 100644 index 809f37f2..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 { 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'; - -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 CloakSessionManager({ 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 CloakSessionManager({ 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 CloakSessionManager({ 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 7a75094a..5459a05c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,7 +23,6 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.ts'], - exclude: ['src/browser/runtime/local-cloak/browser-run.test.ts'], sequence: { groupOrder: 0 }, }, }, @@ -55,8 +54,6 @@ export default defineConfig({ 'tests/e2e/plugin-management.test.ts', 'tests/e2e/adapter-authoring-parity.test.ts', 'tests/e2e/article-download-pipeline.test.ts', - 'tests/e2e/cloak-runtime.test.ts', - 'tests/e2e/cloak-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 a55a85cf30482817e28d643ea1f4f1e3945f3b06 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 27 Aug 2026 05:01:43 +0530 Subject: [PATCH 11/34] test: harden page humanizer source coverage --- src/browser/humanizer/page.test.ts | 271 +++++++++++++++++- .../local-slab/session-manager.test.ts | 2 + 2 files changed, 268 insertions(+), 5 deletions(-) diff --git a/src/browser/humanizer/page.test.ts b/src/browser/humanizer/page.test.ts index 54a06323..15946256 100644 --- a/src/browser/humanizer/page.test.ts +++ b/src/browser/humanizer/page.test.ts @@ -1,8 +1,138 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { HumanConfig } from './config.js'; import * as humanizer from './index.js'; import { humanizePage } from './page.js'; -function fakePage() { +const FAST_HUMAN_CONFIG: Partial = { + initial_cursor_x: [0, 0], + initial_cursor_y: [0, 0], + mouse_min_steps: 6, + mouse_max_steps: 6, + mouse_steps_divisor: 1, + mouse_wobble_max: 0, + mouse_overshoot_chance: 0, + mouse_burst_size: [100, 100], + mouse_burst_pause: [0, 0], + click_aim_delay_input: [0, 0], + click_aim_delay_button: [0, 0], + click_hold_input: [0, 0], + click_hold_button: [0, 0], + key_hold: [0, 0], + field_switch_delay: [0, 0], + shift_down_delay: [0, 0], + shift_up_delay: [0, 0], + typing_delay: 20, + typing_delay_spread: 10, + typing_pause_chance: 0, + typing_pause_range: [0, 0], + mistype_chance: 0, + scroll_delta_base: [100, 100], + scroll_delta_variance: 0, + scroll_pause_fast: [0, 0], + scroll_pause_slow: [0, 0], + scroll_accel_steps: [1, 1], + scroll_decel_steps: [1, 1], + scroll_overshoot_chance: 0, + scroll_settle_delay: [0, 0], + scroll_pre_move_delay: [0, 0], + scroll_target_zone: [0.5, 0.5], + idle_between_actions: false, +}; + +function fakeCdpSession() { + return { + send: vi.fn(async (method: string) => { + if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'frame-1' } } }; + if (method === 'Page.createIsolatedWorld') return { executionContextId: 7 }; + if (method === 'Runtime.evaluate') return { result: { value: false } }; + return {}; + }), + }; +} + +function fakeLocator(boxes: Array<{ x: number; y: number; width: number; height: number }> = [{ x: 10, y: 10, width: 20, height: 10 }]) { + let boxIndex = 0; + const node = { + tagName: 'BUTTON', + getAttribute: vi.fn(() => null), + }; + const locator: any = { + first: vi.fn(() => locator), + waitFor: vi.fn().mockResolvedValue(undefined), + isVisible: vi.fn().mockResolvedValue(true), + isEnabled: vi.fn().mockResolvedValue(true), + isEditable: vi.fn().mockResolvedValue(true), + isChecked: vi.fn().mockResolvedValue(false), + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + boundingBox: vi.fn(async () => boxes[Math.min(boxIndex++, boxes.length - 1)]), + evaluate: vi.fn(async (fn: unknown) => { + if (typeof fn === 'string') return { hit: true }; + return (fn as (el: typeof node) => unknown)(node); + }), + }; + return locator; +} + +function fakeElement(box = { x: 10, y: 10, width: 20, height: 10 }) { + const node = { + tagName: 'BUTTON', + getAttribute: vi.fn(() => null), + }; + const child = { + boundingBox: vi.fn().mockResolvedValue(box), + evaluate: vi.fn(async (fn: unknown) => { + if (typeof fn === 'string') return { hit: true }; + return (fn as (el: typeof node) => unknown)(node); + }), + waitForElementState: vi.fn().mockResolvedValue(undefined), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + press: vi.fn(), + selectOption: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + setChecked: vi.fn(), + tap: vi.fn(), + focus: vi.fn(), + scrollIntoViewIfNeeded: vi.fn(), + isChecked: vi.fn().mockResolvedValue(false), + $: vi.fn().mockResolvedValue(null), + $$: vi.fn().mockResolvedValue([]), + waitForSelector: vi.fn().mockResolvedValue(null), + }; + return child; +} + +function fakeFrame(locator = fakeLocator()): any { + return { + childFrames: vi.fn(() => []), + locator: vi.fn(() => locator), + click: vi.fn(), + dblclick: vi.fn(), + hover: vi.fn(), + type: vi.fn(), + fill: vi.fn(), + check: vi.fn(), + uncheck: vi.fn(), + selectOption: vi.fn(), + press: vi.fn(), + pressSequentially: vi.fn(), + tap: vi.fn(), + clear: vi.fn(), + dragAndDrop: vi.fn(), + $: vi.fn().mockResolvedValue(null), + $$: vi.fn().mockResolvedValue([]), + waitForSelector: vi.fn().mockResolvedValue(null), + }; +} + +function fakePage(input?: { locator?: any; mainFrame?: any; cdp?: ReturnType }) { + const cdp = input?.cdp ?? fakeCdpSession(); + const locator = input?.locator ?? fakeLocator(); + const mainFrame = input?.mainFrame; const browser = { contexts: vi.fn(), newContext: vi.fn(), @@ -13,11 +143,14 @@ function fakePage() { pages: vi.fn(), on: vi.fn(), newPage: vi.fn(), - newCDPSession: vi.fn(), + newCDPSession: vi.fn().mockResolvedValue(cdp), }; const page = { context: vi.fn(() => context), - mainFrame: vi.fn(() => { throw new Error('no frames'); }), + mainFrame: mainFrame ? vi.fn(() => mainFrame) : vi.fn(() => { throw new Error('no frames'); }), + locator: vi.fn(() => locator), + viewportSize: vi.fn(() => ({ width: 100, height: 100 })), + evaluate: vi.fn().mockResolvedValue({ width: 100, height: 100 }), click: vi.fn(), dblclick: vi.fn(), hover: vi.fn(), @@ -51,9 +184,22 @@ function fakePage() { insertText: vi.fn(), }, }; - return { browser, context, page }; + return { browser, context, cdp, locator, page }; } +function mockRandom(values: number[]) { + let index = 0; + return vi.spyOn(Math, 'random').mockImplementation(() => { + const value = values[Math.min(index, values.length - 1)] ?? 0.5; + index += 1; + return value; + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('humanizePage', () => { it('patches only the supplied Page once without touching its context, browser, or other pages', () => { const owned = fakePage(); @@ -103,4 +249,119 @@ describe('humanizePage', () => { expect(humanizer).not.toHaveProperty('patchContext'); expect(humanizer).not.toHaveProperty('patchBrowser'); }); + + it('moves the registered Page mouse through a non-collinear path instead of a direct jump', async () => { + mockRandom([1]); + const owned = fakePage(); + const rawMove = owned.page.mouse.move; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await Promise.resolve(); + rawMove.mockClear(); + await owned.page.mouse.move(120, 0); + + const path = rawMove.mock.calls.map(([x, y]) => [x, y]); + expect(path).toHaveLength(7); + expect(path.at(0)).toEqual([0, 0]); + expect(path.at(-1)).toEqual([120, 0]); + expect(path.slice(1, -1).some(([, y]) => y !== 0)).toBe(true); + }); + + it('types through the registered Page keyboard with nonconstant inter-character cadence', async () => { + mockRandom([ + 0, 0, + 0.9, 0.5, 0.9, 0, + 0.9, 0.5, 0.9, 1, + 0.9, 0.5, + ]); + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const owned = fakePage(); + + humanizePage(owned.page as any, { ...FAST_HUMAN_CONFIG, key_hold: [1, 1] }); + await owned.page.keyboard.type('abc'); + + const interCharacterDelays = timeoutSpy.mock.calls + .map(([, ms]) => Number(ms)) + .filter(ms => ms >= 10); + expect(interCharacterDelays).toEqual([10, 30]); + expect(owned.page.keyboard.down.mock.calls.map(([key]) => key)).toEqual(['a', 'b', 'c']); + expect(owned.page.keyboard.up.mock.calls.map(([key]) => key)).toEqual(['a', 'b', 'c']); + }); + + it('uses CDP dispatchKeyEvent for shift symbols on the trusted-event path', async () => { + const owned = fakePage(); + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await owned.page.keyboard.type('@'); + + expect(owned.cdp.send).toHaveBeenCalledWith('Input.dispatchKeyEvent', expect.objectContaining({ + type: 'keyDown', + key: '@', + code: 'Digit2', + modifiers: 8, + })); + expect(owned.cdp.send).toHaveBeenCalledWith('Input.dispatchKeyEvent', expect.objectContaining({ + type: 'keyUp', + key: '@', + code: 'Digit2', + modifiers: 8, + })); + expect(owned.page.keyboard.insertText).not.toHaveBeenCalled(); + expect(owned.page.evaluate).not.toHaveBeenCalled(); + }); + + it('keeps Page click scrolling bounded when the target stays outside the viewport', async () => { + const locator = fakeLocator([{ x: 20, y: 250, width: 20, height: 10 }]); + const owned = fakePage({ locator }); + const rawClick = owned.page.click; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await owned.page.click('#outside', { force: true, timeout: 1000 }); + + const totalWheelY = owned.page.mouse.wheel.mock.calls + .reduce((sum, [, deltaY]) => sum + Math.abs(Number(deltaY)), 0); + expect(totalWheelY).toBeGreaterThan(0); + expect(totalWheelY).toBeLessThanOrEqual(300); + expect(rawClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); + + it('humanizes existing child frame interactions on the registered Page', async () => { + const childLocator = fakeLocator([{ x: 25, y: 25, width: 30, height: 12 }]); + const childFrame = fakeFrame(childLocator); + const mainFrame = fakeFrame(); + mainFrame.childFrames.mockReturnValue([childFrame]); + const owned = fakePage({ mainFrame }); + const rawChildClick = childFrame.click; + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + await childFrame.click('#frame-button', { timeout: 1000 }); + + expect(childLocator.scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: expect.any(Number) }); + expect(rawChildClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); + + it('patches ElementHandles returned by the registered Page without touching handles from other pages', async () => { + const ownedHandle = fakeElement({ x: 30, y: 30, width: 20, height: 10 }); + const humanHandle = fakeElement({ x: 30, y: 30, width: 20, height: 10 }); + const owned = fakePage(); + const human = fakePage(); + const rawOwnedHandleClick = ownedHandle.click; + owned.page.$.mockResolvedValue(ownedHandle); + human.page.$.mockResolvedValue(humanHandle); + + humanizePage(owned.page as any, FAST_HUMAN_CONFIG); + const patchedHandle = await owned.page.$('#owned'); + const untouchedHandle = await human.page.$('#human'); + await patchedHandle.click({ force: true, timeout: 1000 }); + + expect((patchedHandle as any)._humanPatched).toBe(true); + expect((untouchedHandle as any)._humanPatched).toBeUndefined(); + expect(rawOwnedHandleClick).not.toHaveBeenCalled(); + expect(owned.page.mouse.down).toHaveBeenCalledOnce(); + expect(owned.page.mouse.up).toHaveBeenCalledOnce(); + }); }); diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index 9feaa1d1..e2a418c1 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -183,6 +183,8 @@ describe('SlabSessionManager ownership', () => { expect(lease?.page).toBe(attached.humanPage); expect(manager.pageIdFor(attached.humanBlank)).toBeUndefined(); expect(manager.pageIdFor(attached.humanPage)).toBe(lease?.pageId); + expect((attached.humanPage as any)._original).toEqual(expect.any(Object)); + expect((attached.humanBlank as any)._original).toBeUndefined(); }); it('reports every detached session operation without reopening or closing SLAB', async () => { From eefd221604985e666d8fcd6e6cc8d6a5ae597c31 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Fri, 28 Aug 2026 17:58:48 +0530 Subject: [PATCH 12/34] feat(browser): support custom Chromium binary path --- CHANGELOG.md | 1 + README.md | 15 +++++++ src/browser/browser-binary.test.ts | 38 ++++++++++++++++ src/browser/browser-binary.ts | 40 +++++++++++++++++ .../local-cloak/session-manager.test.ts | 43 +++++++++++++++++++ .../runtime/local-cloak/session-manager.ts | 14 +++++- src/doctor.test.ts | 29 ++++++++++++- src/doctor.ts | 21 ++++++--- 8 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 src/browser/browser-binary.test.ts create mode 100644 src/browser/browser-binary.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a999323..7ef0d09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed +- `WEBCMD_BROWSER_BINARY_PATH` can select a compatible Chromium executable for local browser Sessions; it takes precedence over the existing `CLOAKBROWSER_BINARY_PATH` override. - Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them. - Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance. - Local auth commands initialize user CLI compatibility shims, and hosted auth uses the same native grammar, flags, choices, and help as local mode. diff --git a/README.md b/README.md index 05785fa7..f9cf347f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,21 @@ Webcmd requires Node.js 20.6+. npm install -g @agentrhq/webcmd ``` +To use a compatible Chromium fork instead of Webcmd's managed browser binary, +set its executable path before starting or restarting the daemon: + +```bash +WEBCMD_BROWSER_BINARY_PATH="/path/to/chrome" webcmd daemon restart +webcmd doctor +``` + +The daemon keeps the selected executable for its lifetime. The legacy +`CLOAKBROWSER_BINARY_PATH` variable remains supported, but +`WEBCMD_BROWSER_BINARY_PATH` takes precedence when both are set. +On macOS, custom executables use Playwright's normal Chromium launcher because +third-party app bundles may not support the managed browser's background-CDP +launch contract. + The npm package ships the Webcmd core and browser commands, but no site adapters. Search the plugin catalog and explicitly install the adapter you need: diff --git a/src/browser/browser-binary.test.ts b/src/browser/browser-binary.test.ts new file mode 100644 index 00000000..0b7b8674 --- /dev/null +++ b/src/browser/browser-binary.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + applyBrowserBinaryOverrideToCloakEnvironment, + resolveBrowserBinaryOverride, +} from './browser-binary.js'; + +describe('browser binary override', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('prefers the Webcmd-owned variable over the legacy CloakBrowser variable', () => { + expect(resolveBrowserBinaryOverride({ + WEBCMD_BROWSER_BINARY_PATH: '/opt/chromium-fork/chrome', + CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', + })).toEqual({ + path: '/opt/chromium-fork/chrome', + envVar: 'WEBCMD_BROWSER_BINARY_PATH', + }); + }); + + it('mirrors the generic override so CloakBrowser skips managed resolution', () => { + const env = { + WEBCMD_BROWSER_BINARY_PATH: '/opt/chromium-fork/chrome', + CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', + }; + + applyBrowserBinaryOverrideToCloakEnvironment(env); + + expect(env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/chromium-fork/chrome'); + }); + + it('leaves the environment unchanged when only the legacy variable is set', () => { + const env = { CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome' }; + + applyBrowserBinaryOverrideToCloakEnvironment(env); + + expect(env).toEqual({ CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome' }); + }); +}); diff --git a/src/browser/browser-binary.ts b/src/browser/browser-binary.ts new file mode 100644 index 00000000..2cba7bcc --- /dev/null +++ b/src/browser/browser-binary.ts @@ -0,0 +1,40 @@ +export const WEBCMD_BROWSER_BINARY_PATH_ENV = 'WEBCMD_BROWSER_BINARY_PATH'; +export const CLOAKBROWSER_BINARY_PATH_ENV = 'CLOAKBROWSER_BINARY_PATH'; + +export type BrowserBinaryOverride = { + path: string; + envVar: typeof WEBCMD_BROWSER_BINARY_PATH_ENV | typeof CLOAKBROWSER_BINARY_PATH_ENV; +}; + +/** + * Resolve the browser executable selected by the user. + * + * The Webcmd-owned name takes precedence. The CloakBrowser-specific name stays + * supported so existing installations continue to launch the same binary. + */ +export function resolveBrowserBinaryOverride( + env: NodeJS.ProcessEnv = process.env, +): BrowserBinaryOverride | undefined { + if (env[WEBCMD_BROWSER_BINARY_PATH_ENV]) { + return { path: env[WEBCMD_BROWSER_BINARY_PATH_ENV], envVar: WEBCMD_BROWSER_BINARY_PATH_ENV }; + } + if (env[CLOAKBROWSER_BINARY_PATH_ENV]) { + return { path: env[CLOAKBROWSER_BINARY_PATH_ENV], envVar: CLOAKBROWSER_BINARY_PATH_ENV }; + } + return undefined; +} + +/** + * CloakBrowser resolves its managed executable before applying raw Playwright + * launch options. Mirror Webcmd's generic override into the legacy variable so + * the wrapper short-circuits that download and platform-resolution path. + */ +export function applyBrowserBinaryOverrideToCloakEnvironment( + env: NodeJS.ProcessEnv = process.env, +): BrowserBinaryOverride | undefined { + const override = resolveBrowserBinaryOverride(env); + if (override?.envVar === WEBCMD_BROWSER_BINARY_PATH_ENV) { + env[CLOAKBROWSER_BINARY_PATH_ENV] = override.path; + } + return override; +} diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index ab1a9f21..8cab2205 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -171,6 +171,7 @@ describe('CloakSessionManager', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it('launches one persistent context per profile and reuses named sessions', async () => { @@ -189,6 +190,48 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); }); + it('passes WEBCMD_BROWSER_BINARY_PATH through as the Playwright executable', async () => { + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/opt/cloak/chrome'); + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/opt/chromium-fork/chrome'); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext, + }); + + await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); + + expect(launchPersistentContext).toHaveBeenCalledWith(expect.objectContaining({ + launchOptions: { executablePath: '/opt/chromium-fork/chrome' }, + })); + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/chromium-fork/chrome'); + }); + + it('uses the normal macOS launcher for a custom app-bundle executable', async () => { + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', ''); + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/Applications/ChromiumFork.app/Contents/MacOS/ChromiumFork'); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext, + launchBackgroundPersistentContext, + }); + + await manager.getPage({ + profileId: 'default', + session: 'work', + surface: 'browser', + windowMode: 'background', + }); + + expect(launchPersistentContext).toHaveBeenCalledOnce(); + expect(launchBackgroundPersistentContext).not.toHaveBeenCalled(); + }); + it('correlates created targets and isolates Sessions into owned windows', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 8778add2..9bdfd72d 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url'; import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; -import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js'; +import { + activateDarwinBackgroundContext, + launchDarwinBackgroundPersistentContext, +} from './darwin-background-launch.js'; import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; @@ -12,6 +15,7 @@ import { findExactCloakProfileProcesses } from './process-matcher.js'; import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; import { isClosedContextError } from '../../run/types.js'; +import { applyBrowserBinaryOverrideToCloakEnvironment } from '../../browser-binary.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -724,12 +728,18 @@ export class CloakSessionManager { private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); fs.mkdirSync(userDataDir, { recursive: true }); + const binaryOverride = applyBrowserBinaryOverrideToCloakEnvironment(); const launchOptions = { userDataDir, headless: false, humanize: true, + ...(binaryOverride ? { launchOptions: { executablePath: binaryOverride.path } } : {}), }; - const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' + // The macOS background launcher depends on Cloak Chromium publishing a + // DevToolsActivePort file. Compatible Chromium forks may be app bundles but + // not implement that contract, so custom executables use Playwright's + // normal persistent launcher instead. + const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' && !binaryOverride ? this.launchBackgroundPersistentContext : this.launchPersistentContext; let context: BrowserContext; diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 55f2568b..148ed4fc 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -588,7 +588,7 @@ describe('doctor report rendering', () => { expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); expect(issueText).toContain('Browser connectivity test failed: fetch failed'); - expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH'); + expect(issueText).toContain('WEBCMD_BROWSER_BINARY_PATH'); expect(issueText).not.toContain('could not be downloaded'); expect(issueText).not.toContain('download failed'); }); @@ -717,6 +717,33 @@ describe('doctor report rendering', () => { } }); + it('prefers WEBCMD_BROWSER_BINARY_PATH and skips the managed binary download', async () => { + const overridePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-generic-binary-override-')), + process.platform === 'win32' ? 'chrome.exe' : 'chrome', + ); + fs.writeFileSync(overridePath, '#!/bin/sh\n'); + if (process.platform !== 'win32') fs.chmodSync(overridePath, 0o755); + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', overridePath); + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/legacy/cloak/chrome'); + try { + const binary = checkBrowserBinary(); + const connectivity = await checkConnectivity(); + + expect(binary).toMatchObject({ + installed: true, + path: overridePath, + override: true, + overrideEnv: 'WEBCMD_BROWSER_BINARY_PATH', + }); + expect(connectivity.ok).toBe(true); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + } 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); diff --git a/src/doctor.ts b/src/doctor.ts index 695cac3c..59b19cfc 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -17,6 +17,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 { resolveBrowserBinaryOverride } from './browser/browser-binary.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -36,8 +37,9 @@ export type BrowserBinaryStatus = { path: string; downloadUrl?: string; error?: string; - /** True when CLOAKBROWSER_BINARY_PATH is set — a different check than the managed cache. */ + /** True when a custom executable is selected instead of the managed cache. */ override: boolean; + overrideEnv?: string; }; export type DoctorReport = { @@ -89,9 +91,14 @@ function isLaunchableFile(binaryPath: string): boolean { * connectivity problem (#239). */ export function checkBrowserBinary(): BrowserBinaryStatus { - const override = process.env.CLOAKBROWSER_BINARY_PATH; + const override = resolveBrowserBinaryOverride(); if (override) { - return { installed: isLaunchableFile(override), path: override, override: true }; + return { + installed: isLaunchableFile(override.path), + path: override.path, + override: true, + overrideEnv: override.envVar, + }; } try { const info = binaryInfo(); @@ -115,7 +122,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise Date: Fri, 28 Aug 2026 18:21:53 +0530 Subject: [PATCH 13/34] Readme updated --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index f9cf347f..05785fa7 100644 --- a/README.md +++ b/README.md @@ -73,21 +73,6 @@ Webcmd requires Node.js 20.6+. npm install -g @agentrhq/webcmd ``` -To use a compatible Chromium fork instead of Webcmd's managed browser binary, -set its executable path before starting or restarting the daemon: - -```bash -WEBCMD_BROWSER_BINARY_PATH="/path/to/chrome" webcmd daemon restart -webcmd doctor -``` - -The daemon keeps the selected executable for its lifetime. The legacy -`CLOAKBROWSER_BINARY_PATH` variable remains supported, but -`WEBCMD_BROWSER_BINARY_PATH` takes precedence when both are set. -On macOS, custom executables use Playwright's normal Chromium launcher because -third-party app bundles may not support the managed browser's background-CDP -launch contract. - The npm package ships the Webcmd core and browser commands, but no site adapters. Search the plugin catalog and explicitly install the adapter you need: From ba3f44883e4845fbdc022cc1bb172a202cdd0b10 Mon Sep 17 00:00:00 2001 From: beubax Date: Sun, 30 Aug 2026 07:31:31 +0530 Subject: [PATCH 14/34] fix: open slab cdp transport after auth --- src/slab/cdp-ipc-transport.test.ts | 15 ++++++++++++--- src/slab/cdp-ipc-transport.ts | 25 ++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts index 45a4a7db..2fb825e8 100644 --- a/src/slab/cdp-ipc-transport.test.ts +++ b/src/slab/cdp-ipc-transport.test.ts @@ -123,6 +123,17 @@ describe('CdpIpcTransport', () => { ]); }); + it('allows CDP sends immediately after authentication', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + transport.send({ id: 1, method: 'Browser.getVersion' }); + + await expect.poll(() => harness.frames).toEqual([ + { type: 'authenticate', credential: CREDENTIAL.reveal() }, + { id: 1, method: 'Browser.getVersion' }, + ]); + }); + it('rejects an advertised frame over 64 MiB before waiting for its body', async () => { const harness = await listen(); const transport = await connectAuthenticated(harness); @@ -197,11 +208,9 @@ describe('CdpIpcTransport', () => { await expect.poll(() => harness.socket().destroyed || harness.socket().readableEnded).toBe(true); }); - it('rejects sends before open and after close', async () => { + it('rejects sends after close', async () => { const harness = await listen(); const transport = await connectAuthenticated(harness); - expect(() => transport.send({ id: 1 })).toThrow(/open/i); - transport.open?.(); transport.close(); expect(() => transport.send({ id: 2 })).toThrow(/closed/i); }); diff --git a/src/slab/cdp-ipc-transport.ts b/src/slab/cdp-ipc-transport.ts index 870f3a32..f59bd28c 100644 --- a/src/slab/cdp-ipc-transport.ts +++ b/src/slab/cdp-ipc-transport.ts @@ -27,10 +27,10 @@ function encodeFrame(value: object): Buffer { } export class CdpIpcTransport implements ConnectOverCDPTransport { - onmessage?: (message: object) => void; onclose?: (reason?: string) => void; private readonly chunks: Buffer[] = []; + private messageHandler?: (message: object) => void; private pendingMessages: object[] = []; private bufferedBytes = 0; private expectedLength?: number; @@ -46,6 +46,15 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { socket.on('close', () => this.fail('connection closed')); } + get onmessage(): ((message: object) => void) | undefined { + return this.messageHandler; + } + + set onmessage(handler: ((message: object) => void) | undefined) { + this.messageHandler = handler; + this.flushPendingMessages(); + } + static connect(options: CdpIpcTransportOptions): Promise { return new Promise((resolve, reject) => { const socket = createConnection({ path: options.endpoint }); @@ -99,13 +108,12 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { open(): void { if (this.state !== 'ready') return; this.state = 'open'; - for (const message of this.pendingMessages) this.onmessage?.(message); - this.pendingMessages = []; + this.flushPendingMessages(); } send(message: object): void { if (this.state === 'closed') throw new Error('SLAB CDP IPC transport is closed'); - if (this.state !== 'open') throw new Error('SLAB CDP IPC transport is not open'); + if (!this.authenticated) throw new Error('SLAB CDP IPC transport is not authenticated'); if (!isObject(message)) throw new Error('SLAB CDP IPC message must be an object'); this.socket.write(encodeFrame(message)); } @@ -182,6 +190,7 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { return; } this.authenticated = true; + this.state = 'open'; const resolve = this.resolveAuthentication; this.resolveAuthentication = undefined; this.rejectAuthentication = undefined; @@ -189,10 +198,16 @@ export class CdpIpcTransport implements ConnectOverCDPTransport { resolve?.(); return; } - if (this.state === 'open') this.onmessage?.(message); + if (this.state === 'open' && this.messageHandler) this.messageHandler(message); else this.pendingMessages.push(message); } + private flushPendingMessages(): void { + if (this.state !== 'open' || !this.messageHandler) return; + for (const message of this.pendingMessages) this.messageHandler(message); + this.pendingMessages = []; + } + private fail(reason: string): void { if (this.state === 'closed') return; this.state = 'closed'; From 1cf98c2c4c9de502fde576469b7ddfabb241d66f Mon Sep 17 00:00:00 2001 From: beubax Date: Sun, 30 Aug 2026 07:44:08 +0530 Subject: [PATCH 15/34] test: cover buffered slab cdp frames --- src/slab/cdp-ipc-transport.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts index 2fb825e8..bbadc94d 100644 --- a/src/slab/cdp-ipc-transport.test.ts +++ b/src/slab/cdp-ipc-transport.test.ts @@ -134,6 +134,18 @@ describe('CdpIpcTransport', () => { ]); }); + it('delivers CDP frames received before onmessage is assigned', async () => { + const harness = await listen(); + const transport = await connectAuthenticated(harness); + harness.socket().write(frame({ id: 1, result: {} })); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const messages: object[] = []; + transport.onmessage = (message) => messages.push(message); + + await expect.poll(() => messages).toEqual([{ id: 1, result: {} }]); + }); + it('rejects an advertised frame over 64 MiB before waiting for its body', async () => { const harness = await listen(); const transport = await connectAuthenticated(harness); From d79a9b0eb169c9b69c5250a98aac531d87cc1499 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Mon, 31 Aug 2026 14:20:26 +0530 Subject: [PATCH 16/34] feat(browser): isolate profiles by browser binary --- CHANGELOG.md | 2 +- docs/troubleshooting.mdx | 6 ++ src/browser/browser-binary.test.ts | 51 +++++++++++++++++ src/browser/browser-binary.ts | 55 +++++++++++++++++++ .../runtime/local-cloak/profiles.test.ts | 9 +++ src/browser/runtime/local-cloak/profiles.ts | 5 +- .../local-cloak/session-manager.test.ts | 1 + 7 files changed, 127 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa07099a..cb9ee926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Changed -- `WEBCMD_BROWSER_BINARY_PATH` can select a compatible Chromium executable for local browser Sessions; it takes precedence over the existing `CLOAKBROWSER_BINARY_PATH` override. +- `WEBCMD_BROWSER_BINARY_PATH` can select a compatible Chromium executable for local browser Sessions; it takes precedence over the existing `CLOAKBROWSER_BINARY_PATH` override and isolates each browser build's profile data from managed Cloak profiles. - Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them. - Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance. - Local auth commands initialize user CLI compatibility shims, and hosted auth uses the same native grammar, flags, choices, and help as local mode. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 07502fbc..35e5a51c 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -138,7 +138,13 @@ Useful environment variables: | `WEBCMD_WINDOW` | Optional `foreground` or `background` override; browser commands default to `background`. | | `WEBCMD_BROWSER_CONNECT_TIMEOUT` | Seconds to wait for the browser bridge. | | `WEBCMD_BROWSER_COMMAND_TIMEOUT` | Seconds to wait for one browser command. | +| `WEBCMD_BROWSER_BINARY_PATH` | Executable path for a compatible local Chromium fork. Without it, Webcmd uses managed Cloak as usual. | | `WEBCMD_CDP_ENDPOINT` | Manual CDP endpoint for remote browsers or Electron apps. | | `WEBCMD_CDP_TARGET` | Filter CDP targets by URL substring. | | `WEBCMD_CACHE_DIR` | Browser state and network cache directory. | | `WEBCMD_VERBOSE` | Enable verbose logs. | + +After changing `WEBCMD_BROWSER_BINARY_PATH`, restart the daemon and run +`webcmd doctor`. Custom browser builds use their own profile directory under +`~/.webcmd//profiles`, so their cookies and browser state do not modify +the managed Cloak profiles in `~/.webcmd/cloak/profiles`. diff --git a/src/browser/browser-binary.test.ts b/src/browser/browser-binary.test.ts index 0b7b8674..0e17f4d0 100644 --- a/src/browser/browser-binary.test.ts +++ b/src/browser/browser-binary.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyBrowserBinaryOverrideToCloakEnvironment, + resolveBrowserProfileNamespace, resolveBrowserBinaryOverride, } from './browser-binary.js'; @@ -35,4 +36,54 @@ describe('browser binary override', () => { expect(env).toEqual({ CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome' }); }); + + it('keeps managed and legacy Cloak binaries in the existing namespace', () => { + expect(resolveBrowserProfileNamespace({})).toBe('cloak'); + expect(resolveBrowserProfileNamespace({ + CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', + })).toBe('cloak'); + }); + + it('derives stable namespaces for ChromiumFish and Clark Browser', () => { + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish', + })).toBe('chromiumfish'); + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/Users/test/.clarkbrowser/chromium-148/Chromium.app/Contents/MacOS/Chromium', + })).toBe('clark'); + }); + + it('uses a named app bundle for other custom Chromium builds', () => { + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + })).toBe('brave'); + }); + + it('does not classify browsers from unrelated path substrings', () => { + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/Users/clarkkent/tools/chrome', + })).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + }); + + it('keeps unknown custom binaries separate with deterministic namespaces', () => { + const first = resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-one/chrome', + }); + const second = resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-two/chrome', + }); + + expect(first).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + expect(second).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + expect(first).not.toBe(second); + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-one/chrome', + })).toBe(first); + }); + + it('never lets a custom binary reuse the reserved managed Cloak namespace', () => { + expect(resolveBrowserProfileNamespace({ + WEBCMD_BROWSER_BINARY_PATH: '/Applications/Cloak.app/Contents/MacOS/Cloak', + })).toMatch(/^custom-cloak-[a-f0-9]{8}$/); + }); }); diff --git a/src/browser/browser-binary.ts b/src/browser/browser-binary.ts index 2cba7bcc..141ca9c8 100644 --- a/src/browser/browser-binary.ts +++ b/src/browser/browser-binary.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export const WEBCMD_BROWSER_BINARY_PATH_ENV = 'WEBCMD_BROWSER_BINARY_PATH'; export const CLOAKBROWSER_BINARY_PATH_ENV = 'CLOAKBROWSER_BINARY_PATH'; @@ -6,6 +8,59 @@ export type BrowserBinaryOverride = { envVar: typeof WEBCMD_BROWSER_BINARY_PATH_ENV | typeof CLOAKBROWSER_BINARY_PATH_ENV; }; +function normalizeBrowserNamespace(value: string): string { + return value + .replace(/\.app$/i, '') + .replace(/[._\s]+/g, '-') + .replace(/-?browser$/i, '') + .replace(/[^a-zA-Z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +function browserPathHash(binaryPath: string): string { + return createHash('sha256').update(binaryPath).digest('hex').slice(0, 8); +} + +function safeCustomNamespace(candidate: string, binaryPath: string): string { + return candidate === 'cloak' + ? `custom-cloak-${browserPathHash(binaryPath)}` + : candidate; +} + +/** + * Select the on-disk namespace that owns local Chromium profile data. + * Managed Cloak and its legacy override retain the historical `cloak` path. + */ +export function resolveBrowserProfileNamespace( + env: NodeJS.ProcessEnv = process.env, +): string { + const genericPath = env[WEBCMD_BROWSER_BINARY_PATH_ENV]?.trim(); + if (!genericPath) return 'cloak'; + + const components = genericPath.split(/[\\/]+/).filter(Boolean); + const appBundle = [...components].reverse().find(component => /\.app$/i.test(component)); + const executable = normalizeBrowserNamespace(components.at(-1) ?? ''); + const appNamespace = normalizeBrowserNamespace(appBundle ?? ''); + if (appNamespace === 'chromiumfish' || executable === 'chromiumfish') return 'chromiumfish'; + if ( + appNamespace === 'clark' + || executable === 'clark' + || components.some(component => component.toLowerCase() === '.clarkbrowser') + ) return 'clark'; + + if (appBundle) { + if (appNamespace && appNamespace !== 'chromium') { + return safeCustomNamespace(appNamespace, genericPath); + } + } + + if (executable && !['chrome', 'chromium'].includes(executable)) { + return safeCustomNamespace(executable, genericPath); + } + return `custom-chromium-${browserPathHash(genericPath)}`; +} + /** * Resolve the browser executable selected by the user. * diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-cloak/profiles.test.ts index 472c9c1d..3fab8897 100644 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ b/src/browser/runtime/local-cloak/profiles.test.ts @@ -19,4 +19,13 @@ describe('cloak profile resolution', () => { expect(resolveCloakProfileDir('work', { baseDir: '/tmp/webcmd' })) .toBe(path.join('/tmp/webcmd', 'cloak', 'profiles', 'work')); }); + + it('isolates profiles for a custom browser binary', () => { + expect(resolveCloakProfileDir('default', { + baseDir: '/tmp/webcmd', + env: { + WEBCMD_BROWSER_BINARY_PATH: '/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish', + }, + })).toBe(path.join('/tmp/webcmd', 'chromiumfish', 'profiles', 'default')); + }); }); diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-cloak/profiles.ts index 0f08e9aa..ccb3c364 100644 --- a/src/browser/runtime/local-cloak/profiles.ts +++ b/src/browser/runtime/local-cloak/profiles.ts @@ -1,9 +1,11 @@ import path from 'node:path'; import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; import os from 'node:os'; +import { resolveBrowserProfileNamespace } from '../../browser-binary.js'; export interface CloakProfileDirOptions { baseDir?: string; + env?: NodeJS.ProcessEnv; } export function normalizeProfileId(value: string | undefined | null): string { @@ -20,5 +22,6 @@ export function getWebcmdConfigDir(): string { export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { const safeProfileId = normalizeProfileId(profileId); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), 'cloak', 'profiles', safeProfileId); + const browserNamespace = resolveBrowserProfileNamespace(opts.env); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), browserNamespace, 'profiles', safeProfileId); } diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 8cab2205..22a908c8 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -203,6 +203,7 @@ describe('CloakSessionManager', () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); expect(launchPersistentContext).toHaveBeenCalledWith(expect.objectContaining({ + userDataDir: expect.stringMatching(/\/custom-chromium-[a-f0-9]{8}\/profiles\/default$/), launchOptions: { executablePath: '/opt/chromium-fork/chrome' }, })); expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/chromium-fork/chrome'); From b6f93caa089263766b275776fa9acc5dc770f121 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Mon, 31 Aug 2026 14:30:53 +0530 Subject: [PATCH 17/34] test(browser): make profile path assertion portable --- .../runtime/local-cloak/session-manager.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 22a908c8..384ccfe1 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -4,6 +4,7 @@ import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; import { log } from '../../../logger.js'; import { dispatchCloakAction } from './actions.js'; +import { resolveBrowserProfileNamespace } from '../../browser-binary.js'; function fakeContext() { const listeners = new Map void>>(); @@ -191,8 +192,9 @@ describe('CloakSessionManager', () => { }); it('passes WEBCMD_BROWSER_BINARY_PATH through as the Playwright executable', async () => { + const browserPath = '/opt/chromium-fork/chrome'; vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/opt/cloak/chrome'); - vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/opt/chromium-fork/chrome'); + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', browserPath); const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const manager = new CloakSessionManager({ @@ -203,10 +205,15 @@ describe('CloakSessionManager', () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); expect(launchPersistentContext).toHaveBeenCalledWith(expect.objectContaining({ - userDataDir: expect.stringMatching(/\/custom-chromium-[a-f0-9]{8}\/profiles\/default$/), - launchOptions: { executablePath: '/opt/chromium-fork/chrome' }, + userDataDir: path.join( + '/tmp/webcmd-test', + resolveBrowserProfileNamespace({ WEBCMD_BROWSER_BINARY_PATH: browserPath }), + 'profiles', + 'default', + ), + launchOptions: { executablePath: browserPath }, })); - expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/chromium-fork/chrome'); + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe(browserPath); }); it('uses the normal macOS launcher for a custom app-bundle executable', async () => { From 151e1ec3bcdacb69111719e75b8680b1bb5285d3 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 31 Aug 2026 21:10:53 +0530 Subject: [PATCH 18/34] fix: harden slab startup and session cleanup --- src/browser/humanizer/index.ts | 6 ++- src/browser/profile.test.ts | 2 +- src/browser/profile.ts | 5 +- .../__fixtures__/attach.response.json | 2 +- src/browser/runtime/local-slab/provider.ts | 44 +++++++++++++-- .../local-slab/runtime-selection.test.ts | 53 +++++++++++++++++++ .../local-slab/session-manager.test.ts | 3 +- .../runtime/local-slab/session-manager.ts | 2 +- src/cli.test.ts | 21 +++++++- src/cli.ts | 3 +- src/slab/bridge-client.test.ts | 2 +- src/slab/contract-parity.test.ts | 2 +- 12 files changed, 128 insertions(+), 17 deletions(-) diff --git a/src/browser/humanizer/index.ts b/src/browser/humanizer/index.ts index 28d1a702..b3c42887 100644 --- a/src/browser/humanizer/index.ts +++ b/src/browser/humanizer/index.ts @@ -23,7 +23,8 @@ */ import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; -import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js'; +import type { HumanConfig, HumanActionOptions } from './config.js'; +import { resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js'; import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; import { humanType } from './keyboard.js'; import { scrollToElement, humanScrollIntoView } from './scroll.js'; @@ -34,7 +35,8 @@ import { type CheckName, } from './actionability.js'; -export { HumanConfig, resolveConfig, mergeConfig } from './config.js'; +export type { HumanConfig } from './config.js'; +export { resolveConfig, mergeConfig } from './config.js'; export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; export { humanType } from './keyboard.js'; export { scrollToElement, humanScrollIntoView } from './scroll.js'; diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index c2681485..577739c3 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -116,7 +116,7 @@ describe('createProfile', () => { expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: true }); expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: false }); expect(loadProfileConfig().aliases['eval-a']).toBe('eval-a'); - expect(fs.existsSync(path.join(configDir, 'slab', 'profiles', 'eval-a'))).toBe(true); + expect(fs.existsSync(path.join(configDir, 'slab', 'profiles', 'eval-a'))).toBe(false); }); it('rejects an invalid alias', () => { diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 27545478..8a33d21b 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -3,7 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; -import { normalizeProfileId, resolveSlabProfileDir } from './runtime/local-slab/profiles.js'; +import { normalizeProfileId } from './runtime/local-slab/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -97,7 +97,7 @@ export class ProfileNotFoundError extends CliError { super( 'PROFILE_NOT_FOUND', `No profile matches "${name}". ${valid}`, - `usage: ${CLI_COMMAND} --profile session create\nCreate one: ${CLI_COMMAND} profile create ${name}\nList profiles: ${CLI_COMMAND} profile list`, + `usage: ${CLI_COMMAND} --profile session create\nSave an alias: ${CLI_COMMAND} profile create ${name}\nList profiles: ${CLI_COMMAND} profile list`, EXIT_CODES.EMPTY_RESULT, ); } @@ -121,7 +121,6 @@ export function createProfile(alias: string): { contextId: string; alias: string } config.aliases[name] = contextId; saveProfileConfig(config); - fs.mkdirSync(resolveSlabProfileDir(contextId), { recursive: true }); return { contextId, alias: name, created: true }; } diff --git a/src/browser/runtime/local-slab/__fixtures__/attach.response.json b/src/browser/runtime/local-slab/__fixtures__/attach.response.json index 47aec9d8..9d71c4d5 100644 --- a/src/browser/runtime/local-slab/__fixtures__/attach.response.json +++ b/src/browser/runtime/local-slab/__fixtures__/attach.response.json @@ -15,7 +15,7 @@ "profile": { "id": "default", "displayName": "Default" }, "transport": { "kind": "cdp-ipc", - "endpoint": "/Users/test/.slab/run/attachments/00000000-0000-4000-8000-000000000000.sock", + "endpoint": "/Users/test/.slab/run/AAAAAAAAAAA.sock", "credential": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } } diff --git a/src/browser/runtime/local-slab/provider.ts b/src/browser/runtime/local-slab/provider.ts index 557fd0c9..d6a4e93f 100644 --- a/src/browser/runtime/local-slab/provider.ts +++ b/src/browser/runtime/local-slab/provider.ts @@ -1,6 +1,10 @@ +import { homedir } from 'node:os'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../../protocol.js'; import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.js'; import { LocalBrowserSessionStore, type BrowserSessionListRow, type BrowserSessionRecord } from '../../sessions.js'; +import { SlabBridgeClient } from '../../../slab/bridge-client.js'; +import { slabControlEndpoint } from '../../../slab/installation.js'; +import type { SlabHelloResult } from '../../../slab/protocol.js'; import { dispatchSlabAction, resolveSlabCommandProfileId } from './actions.js'; import type { AttachSlabProfile } from './session-manager.js'; import { SlabSessionManager } from './session-manager.js'; @@ -8,6 +12,7 @@ import { SlabSessionManager } from './session-manager.js'; export interface LocalSlabRuntimeProviderOptions { baseDir?: string; attachProfile?: AttachSlabProfile; + statusBridge?: () => Promise>; } export function createLocalBrowserRuntimeProvider(opts: LocalSlabRuntimeProviderOptions = {}): LocalSlabRuntimeProvider { @@ -37,11 +42,34 @@ export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { async status(opts: RuntimeStatusOptions = {}): Promise { const profiles = this.manager.profileStatuses(); + const hello = await this.nativeStatus().catch(() => undefined); + const profileById = new Map(profiles.map(profile => [profile.contextId, profile])); + if (hello) { + for (const profile of hello.profiles) { + if (!profileById.has(profile.id)) { + profileById.set(profile.id, { + contextId: profile.id, + runtimeConnected: true, + runtimeVersion: hello.browserVersion, + pending: 0, + }); + } + } + } + const statusProfiles = [...profileById.values()]; + const requestedProfile = opts.contextId?.trim(); + const selectedProfile = requestedProfile + ? statusProfiles.find(profile => profile.contextId === requestedProfile) + : undefined; + const runtimeConnected = requestedProfile + ? Boolean(selectedProfile?.runtimeConnected) + : statusProfiles.some(profile => profile.runtimeConnected); return { - runtimeConnected: true, + runtimeConnected, runtimeName: 'SLAB', - runtimeVersion: profiles.find(profile => profile.runtimeVersion)?.runtimeVersion, - profiles, + runtimeVersion: statusProfiles.find(profile => profile.runtimeVersion)?.runtimeVersion ?? hello?.browserVersion, + profiles: statusProfiles, + ...(requestedProfile && !selectedProfile?.runtimeConnected ? { profileDisconnected: true } : {}), pending: 0, commandResultUnknown: 0, sessions: await this.listSessions({ profileId: opts.contextId }), @@ -137,6 +165,16 @@ export class LocalSlabRuntimeProvider implements BrowserRuntimeProvider { await this.manager.shutdown(); } + private async nativeStatus(): Promise { + const client = await (this.opts.statusBridge?.() + ?? SlabBridgeClient.connect(slabControlEndpoint(homedir()), { timeoutMs: 1_000 })); + try { + return await client.hello(); + } finally { + await client.close(); + } + } + private commandQueueKey(command: BrowserRuntimeCommand): string { if (command.page) { const owner = this.manager.pageOwner(command.page); diff --git a/src/browser/runtime/local-slab/runtime-selection.test.ts b/src/browser/runtime/local-slab/runtime-selection.test.ts index e8523d27..f82c05da 100644 --- a/src/browser/runtime/local-slab/runtime-selection.test.ts +++ b/src/browser/runtime/local-slab/runtime-selection.test.ts @@ -166,4 +166,57 @@ describe('local browser runtime selection', () => { expect(attached.context.close).not.toHaveBeenCalled(); expect(quitApp).not.toHaveBeenCalled(); }); + + it('reports disconnected when the native SLAB control endpoint is unavailable', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), + statusBridge: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')), + }); + + await expect(provider.status()).resolves.toMatchObject({ + runtimeConnected: false, + profiles: [], + }); + }); + + it('reports ready from native SLAB hello before the first profile attachment', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const close = vi.fn().mockResolvedValue(undefined); + const attachProfile = vi.fn().mockResolvedValue(fakeAttachedProfile()); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile, + statusBridge: vi.fn().mockResolvedValue({ + close, + hello: vi.fn().mockResolvedValue({ + protocolVersion: 1, + browserVersion: '152.0.7977.65', + browserPid: 1234, + profiles: [{ id: 'default', displayName: 'Default' }], + }), + }), + }); + + await expect(provider.status({ contextId: 'default' })).resolves.toMatchObject({ + runtimeConnected: true, + runtimeVersion: '152.0.7977.65', + profiles: [{ contextId: 'default', runtimeConnected: true, runtimeVersion: '152.0.7977.65', pending: 0 }], + }); + expect(close).toHaveBeenCalledOnce(); + expect(attachProfile).not.toHaveBeenCalled(); + }); + + it('reports a requested profile as disconnected when no active SLAB profile matches', async () => { + const { createLocalBrowserRuntimeProvider } = await import('./provider.js'); + const provider = createLocalBrowserRuntimeProvider({ + attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), + statusBridge: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')), + }); + + await expect(provider.status({ contextId: 'work' })).resolves.toMatchObject({ + runtimeConnected: false, + profileDisconnected: true, + profiles: [], + }); + }); }); diff --git a/src/browser/runtime/local-slab/session-manager.test.ts b/src/browser/runtime/local-slab/session-manager.test.ts index e2a418c1..1c34f8c0 100644 --- a/src/browser/runtime/local-slab/session-manager.test.ts +++ b/src/browser/runtime/local-slab/session-manager.test.ts @@ -215,8 +215,7 @@ describe('SlabSessionManager ownership', () => { expect(attachProfile).toHaveBeenCalledOnce(); expect(attached.browser.close).not.toHaveBeenCalled(); - expect(attached.attachment.closeTransport).toHaveBeenCalledOnce(); - expect(attached.attachment.release).not.toHaveBeenCalled(); + expect(attached.attachment.release).toHaveBeenCalledOnce(); await expect(manager.getPage({ ...input, session: 'replacement', sessionId: 'replacement' })) .resolves.toMatchObject({ profileId: 'default' }); diff --git a/src/browser/runtime/local-slab/session-manager.ts b/src/browser/runtime/local-slab/session-manager.ts index d3b57f4c..637aa4a8 100644 --- a/src/browser/runtime/local-slab/session-manager.ts +++ b/src/browser/runtime/local-slab/session-manager.ts @@ -725,7 +725,7 @@ export class SlabSessionManager { const detached = this.detachedSessions.get(profileId) ?? new Set(); for (const sessionId of runtime.sessions.keys()) detached.add(sessionId); if (detached.size > 0) this.detachedSessions.set(profileId, detached); - void this.releaseRuntime(runtime, false, false).catch(error => { + void this.releaseRuntime(runtime, false).catch(error => { log.warn(`SLAB Profile ${profileId} release failed: ${errorMessage(error)}`); }); this.cleanupRuntime(runtime); diff --git a/src/cli.test.ts b/src/cli.test.ts index 61bdd6dd..d4b9caea 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2466,7 +2466,26 @@ describe('browser Session lifecycle commands', () => { expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'eval-a' }); }); - it('rejects an unknown --profile on session create with PROFILE_NOT_FOUND', async () => { + it('allows explicit profile bootstrap when daemon status is unavailable', async () => { + mockSendCommand.mockResolvedValue({ + id: 'session_work', + kind: 'explicit', + profileId: 'work', + runtimeState: 'idle', + }); + + await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'work', 'session', 'create']); + + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'work' }); + }); + + it('rejects an unknown --profile on session create when daemon reports active profiles', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + daemonVersion: PKG_VERSION, + runtimeConnected: true, + profiles: [{ contextId: 'default', runtimeConnected: true, pending: 0 }], + })))); + await expect(createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'does-not-exist', 'session', 'create'])) .rejects.toMatchObject({ code: 'PROFILE_NOT_FOUND', diff --git a/src/cli.ts b/src/cli.ts index 9b9b6a38..7dab01bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -587,6 +587,7 @@ async function requireKnownProfileId(command?: Command): Promise { const requested = explicitProfileName(command); if (!requested) return profileId; const status = await fetchDaemonStatus(); + if (!status || isDaemonStale(status, PKG_VERSION)) return profileId; const connected = status && !isDaemonStale(status, PKG_VERSION) && Array.isArray(status.profiles) ? status.profiles : []; @@ -2167,7 +2168,7 @@ cli({ created: result.created, }, () => { console.log(result.created - ? `Profile ${result.alias} created (contextId: ${result.contextId}).` + ? `Profile alias ${result.alias} saved (contextId: ${result.contextId}).` : `Profile ${result.alias} already exists (contextId: ${result.contextId}).`); }); }); diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts index ae6f8497..cbbf4aee 100644 --- a/src/slab/bridge-client.test.ts +++ b/src/slab/bridge-client.test.ts @@ -81,7 +81,7 @@ function attachOk(id: string, credential = CREDENTIAL): string { profile: { id: 'default', displayName: 'Default' }, transport: { kind: 'cdp-ipc', - endpoint: '/Users/test/.slab/run/attachments/00000000-0000-4000-8000-000000000000.sock', + endpoint: '/Users/test/.slab/run/AAAAAAAAAAA.sock', credential, }, }, diff --git a/src/slab/contract-parity.test.ts b/src/slab/contract-parity.test.ts index 08ebfb8c..7ffafb1a 100644 --- a/src/slab/contract-parity.test.ts +++ b/src/slab/contract-parity.test.ts @@ -14,7 +14,7 @@ const FIXTURE_FILES = [ ] as const; const CONNECTION_ID = '00000000-0000-4000-8000-000000000000'; const PROFILE_ID = 'default'; -const ENDPOINT = `/Users/test/.slab/run/attachments/${CONNECTION_ID}.sock`; +const ENDPOINT = '/Users/test/.slab/run/AAAAAAAAAAA.sock'; const CREDENTIAL = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const STABLE_ERRORS = [ 'INVALID_REQUEST', From 321e2980ca2aa034b8039731623f8a5bdbb53956 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 00:34:15 +0530 Subject: [PATCH 19/34] fix: keep Cloak guidance during runtime rollout --- docs/cli-reference.mdx | 2 +- src/browser/daemon-lifecycle.ts | 8 ++++---- src/browser/errors.ts | 2 +- src/commands/daemon.test.ts | 2 +- src/commands/daemon.ts | 4 ++-- src/skills.test.ts | 2 +- src/update-check.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 09b79170..d492a2cb 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -70,7 +70,7 @@ webcmd --profile work \ webcmd --profile work session close work-project-k7 ``` -Local browser commands use SLAB. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 48f475e6..4494d9a2 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -177,8 +177,8 @@ export async function ensureBrowserBridgeReady( } spawnedProcess = daemonLifecycleHooks.spawnDaemonProcess(); } else if (verbose && (isVerbose() || process.stderr.isTTY)) { - process.stderr.write('⏳ Waiting for SLAB to connect...\n'); - process.stderr.write(' Make sure SLAB is open.\n'); + process.stderr.write('⏳ Waiting for Cloak to connect...\n'); + process.stderr.write(' Make sure Chrome/Chromium is open and Cloak is enabled.\n'); } const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); @@ -199,14 +199,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 SLAB profile and make sure SLAB is running, or choose another profile with webcmd profile use .', + 'Open the matching Chrome profile and make sure Cloak is enabled, or choose another profile with webcmd profile use .', 'profile-disconnected', ); } if (health.state === 'no-runtime') { return new BrowserConnectError( 'Browser runtime is not ready', - 'Open SLAB and retry the browser command. Run `webcmd doctor` for local status.', + 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); } diff --git a/src/browser/errors.ts b/src/browser/errors.ts index 976d12ba..fb68ad70 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -127,7 +127,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str case 'extension-not-connected': return new BrowserConnectError( 'Browser runtime is not ready.' + (detail ? `\n\n${detail}` : ''), - 'Open SLAB and retry the browser command. Run `webcmd doctor` for local status.', + 'Open Chrome/Chromium with Cloak enabled and retry the browser command. Run `webcmd doctor` for local status.', 'runtime-not-ready', ); case 'command-failed': diff --git a/src/commands/daemon.test.ts b/src/commands/daemon.test.ts index 463b1cc8..76902221 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('SLAB runtime has not connected yet')); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Cloak 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 54453ff5..8a3c5bc8 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -110,7 +110,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'}; SLAB should reconnect automatically.`); + log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser ${before.profiles.length === 1 ? 'profile' : 'profiles'}; Cloak should reconnect automatically.`); } const result = await restartDaemon(); @@ -133,6 +133,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 SLAB runtime has not connected yet.'); + log.warn('Daemon is running, but the Cloak runtime has not connected yet.'); } } diff --git a/src/skills.test.ts b/src/skills.test.ts index fc80cedd..0ae79254 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -75,7 +75,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.*SLAB[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); + expect(guide).toMatch(/local.*Cloak[\s\S]{0,160}hosted.*Webcmd Cloud.*Browser Use/i); expect(guide).not.toMatch(/fetch-browser|web read|--browser/i); } expect(skill).toContain('Search Summary'); diff --git a/src/update-check.ts b/src/update-check.ts index b990eb3c..49ecf14c 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -117,7 +117,7 @@ function buildUpdateNotices({ cliVersion, cache, now }: NoticeInputs): NoticeLin ) { lines.extension = `\n Runtime update available: v${currentExtensionVersion} → v${latestExtensionVersion}\n` + - ` Update the ${PRODUCT_DISPLAY_NAME} SLAB runtime from official release artifacts.\n`; + ` Update the ${PRODUCT_DISPLAY_NAME} Cloak runtime from official release artifacts.\n`; } return lines; } From fa258619cccc1f6b5f9377ebc98c4ac54291057a Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 00:40:36 +0530 Subject: [PATCH 20/34] feat: persist local browser selection --- src/hosted/config.test.ts | 34 ++++++++++++++++++++++ src/hosted/config.ts | 29 ++++++++++++++++--- src/hosted/setup.test.ts | 61 +++++++++++++++++++++++++++++++++++++-- src/hosted/setup.ts | 44 ++++++++++++++++++++++++---- 4 files changed, 156 insertions(+), 12 deletions(-) diff --git a/src/hosted/config.test.ts b/src/hosted/config.test.ts index e4bbfada..f315e3bf 100644 --- a/src/hosted/config.test.ts +++ b/src/hosted/config.test.ts @@ -29,6 +29,7 @@ describe('hosted config', () => { expect(loadWebcmdConfig({ env: { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv })).toEqual({ mode: 'local', updatedAt: '1970-01-01T00:00:00.000Z', + browser: { kind: 'cloak' }, }); }); @@ -134,8 +135,41 @@ describe('hosted config', () => { expect(makeLocalConfig(new Date('2026-07-08T00:00:00.000Z'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'cloak' }, }); expect(defaultHostedApiBaseUrl({ WEBCMD_CLOUD_API_URL: 'https://cloud.example.com/' } as NodeJS.ProcessEnv)) .toBe('https://cloud.example.com'); }); + + it('round-trips each local browser choice', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-config-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const updatedAt = '2026-08-31T00:00:00.000Z'; + + for (const browser of [ + { kind: 'cloak' } as const, + { kind: 'slab' } as const, + { kind: 'custom', executablePath: '/Applications/Chrome.app/Contents/MacOS/Google Chrome' } as const, + ]) { + saveWebcmdConfig({ mode: 'local', updatedAt, browser }, { env }); + expect(loadWebcmdConfig({ env })).toEqual({ mode: 'local', updatedAt, browser }); + } + }); + + it('defaults old or invalid local browser data to Cloak', () => { + expect(loadWebcmdConfig({ + readFileSync: (() => '{"mode":"local","updatedAt":"2026-08-31T00:00:00.000Z"}') as never, + })).toMatchObject({ mode: 'local', browser: { kind: 'cloak' } }); + + for (const browser of [ + { kind: 'custom' }, + { kind: 'custom', executablePath: '' }, + { kind: 'custom', executablePath: 'relative/browser' }, + { kind: 'other' }, + ]) { + expect(loadWebcmdConfig({ + readFileSync: (() => JSON.stringify({ mode: 'local', updatedAt: '2026-08-31T00:00:00.000Z', browser })) as never, + })).toMatchObject({ mode: 'local', browser: { kind: 'cloak' } }); + } + }); }); diff --git a/src/hosted/config.ts b/src/hosted/config.ts index 34a5dfa1..465a36b0 100644 --- a/src/hosted/config.ts +++ b/src/hosted/config.ts @@ -9,10 +9,16 @@ export interface HostedManifestCache { manifest: unknown; } +export type LocalBrowserConfig = + | { kind: 'cloak' } + | { kind: 'slab' } + | { kind: 'custom'; executablePath: string }; + export type WebcmdConfig = | { mode: 'local'; updatedAt: string; + browser: LocalBrowserConfig; } | { mode: 'hosted'; @@ -55,7 +61,7 @@ export function getConfigPath(io: Pick = {}): strin function parseConfig(raw: string): WebcmdConfig { const parsed = JSON.parse(raw) as Partial; if (parsed.mode === 'local' && typeof parsed.updatedAt === 'string') { - return { mode: 'local', updatedAt: parsed.updatedAt }; + return { mode: 'local', updatedAt: parsed.updatedAt, browser: readLocalBrowser((parsed as { browser?: unknown }).browser) }; } if ( parsed.mode === 'hosted' @@ -78,7 +84,7 @@ function parseConfig(raw: string): WebcmdConfig { }, }; } - return { mode: 'local', updatedAt: new Date(0).toISOString() }; + return makeLocalConfig(new Date(0)); } export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { @@ -86,7 +92,7 @@ export function loadWebcmdConfig(io: ConfigIo = {}): WebcmdConfig { try { return parseConfig(readFileSync(getConfigPath(io), 'utf-8') as string); } catch { - return { mode: 'local', updatedAt: new Date(0).toISOString() }; + return makeLocalConfig(new Date(0)); } } @@ -107,10 +113,14 @@ export function saveWebcmdConfig(config: WebcmdConfig, io: ConfigIo = {}): void export type LocalWebcmdConfig = Extract; export type HostedWebcmdConfig = Extract; -export function makeLocalConfig(now: Date = new Date()): LocalWebcmdConfig { +export function makeLocalConfig( + now: Date = new Date(), + browser: LocalBrowserConfig = { kind: 'cloak' }, +): LocalWebcmdConfig { return { mode: 'local', updatedAt: now.toISOString(), + browser, }; } @@ -195,6 +205,17 @@ function readCredentialBackend(value: unknown): HostedCredentialBackend | undefi return value === 'os' || value === 'file-fallback' ? value : undefined; } +function readLocalBrowser(value: unknown): LocalBrowserConfig { + if (value && typeof value === 'object') { + const browser = value as { kind?: unknown; executablePath?: unknown }; + if (browser.kind === 'cloak' || browser.kind === 'slab') return { kind: browser.kind }; + if (browser.kind === 'custom' && typeof browser.executablePath === 'string' && path.isAbsolute(browser.executablePath)) { + return { kind: 'custom', executablePath: browser.executablePath }; + } + } + return { kind: 'cloak' }; +} + function persistableConfig(config: WebcmdConfig): WebcmdConfig { if (config.mode !== 'hosted' || !config.hosted.apiKeyRef) return config; return { diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 0532dce4..efe9916d 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { describe('webcmd setup', () => { it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); - const answers = ['local']; + const answers = ['local', 'slab']; const messages: string[] = []; const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; @@ -36,6 +36,7 @@ describe('webcmd setup', () => { expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'slab' }, }); expect(messages.join('')).toContain('local mode'); }); @@ -110,10 +111,65 @@ describe('webcmd setup', () => { expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', + browser: { kind: 'cloak' }, }); expect(messages.join('')).toContain('local mode'); }); + it.each([ + [['--mode', 'local', '--browser', 'cloak'], { kind: 'cloak' }], + [['--mode=local', '--browser=slab'], { kind: 'slab' }], + [['--mode', 'local', '--browser', '/Applications/Chrome.app/Contents/MacOS/Google Chrome'], { + kind: 'custom', executablePath: '/Applications/Chrome.app/Contents/MacOS/Google Chrome', + }], + ])('persists browser selection from %j', async (argv, browser) => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + + await expect(runHostedSetup({ + env, + argv, + isTTY: false, + now: () => new Date('2026-08-31T00:00:00.000Z'), + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser, + }); + }); + + it.each([ + [['--mode', 'local', '--browser'], '--browser requires a value.'], + [['--mode', 'local', '--browser', 'chrome'], '--browser must be cloak, slab, or an absolute path'], + [['--mode', 'local', '--browser', 'relative/browser'], '--browser must be cloak, slab, or an absolute path'], + [['--mode', 'hosted', '--browser', 'slab', '--api-key', 'wcmd_live_test'], '--browser is only valid with --mode local.'], + ])('rejects invalid browser arguments from %j', async (argv, message) => { + const stderr = collectStderr(); + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: join(tmpdir(), `webcmd-setup-browser-error-${Date.now()}`) }, + argv, + isTTY: false, + stderr: stderr.stream, + write: () => undefined, + })).resolves.toBe(2); + + expect(stderr.text()).toContain(message); + }); + + it('shows browser usage in setup help', async () => { + const messages: string[] = []; + + await expect(runHostedSetup({ + argv: ['--help'], + write: message => { messages.push(message); }, + })).resolves.toBe(0); + + expect(messages.join('')).toContain('--browser '); + }); + it('rejects non-TTY setup without --mode and never prompts', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-nontty-')); const messages: string[] = []; @@ -206,11 +262,12 @@ describe('webcmd setup', () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slow-output-')); const output = new SetupControlledWritable(); let settled = false; + const answers = ['local', 'cloak']; const run = runHostedSetup({ env: { WEBCMD_CONFIG_DIR: tempDir }, output, - question: async () => 'local', + question: async () => answers.shift() ?? '', }).then(code => { settled = true; return code; diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index ff4d23ad..b46841d9 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,5 +1,6 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; +import { isAbsolute } from 'node:path'; import { CLI_COMMAND } from '../brand.js'; import { ArgumentError, toEnvelope } from '../errors.js'; import { formatErrorEnvelope } from '../output.js'; @@ -10,6 +11,7 @@ import { makeLocalConfig, saveWebcmdConfig, type ConfigIo, + type LocalBrowserConfig, } from './config.js'; import { makeStoredHostedConfig, @@ -31,7 +33,7 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { type SetupMode = 'local' | 'hosted'; -const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--api-key ]`; +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--browser ] [--api-key ]`; const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; const SETUP_HELP = [ `${CLI_COMMAND} setup`, @@ -39,6 +41,7 @@ const SETUP_HELP = [ 'Configure local or hosted mode.', '', ' --mode Required when stdin is not a TTY', + ' --browser Local browser in local mode', ' --api-key Required for --mode hosted when stdin is not a TTY', ' -h, --help', '', @@ -91,11 +94,21 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, ); } - saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io); + const browser = parsed.browser ?? (interactive + ? parseLocalBrowser((await ask('Local browser [cloak/slab/absolute path] (cloak): ')).trim() || 'cloak') + : { kind: 'cloak' }); + saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date(), browser), io); await write('Webcmd is now configured for local mode.\n'); return 0; } + if (parsed.browser) { + throw new ArgumentError( + '--browser is only valid with --mode local.', + `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, + ); + } + let apiKey = parsed.apiKey?.trim(); if (!apiKey) { if (!interactive) { @@ -151,13 +164,14 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } function canPrompt(io: SetupIo): boolean { - if (io.question) return true; if (io.isTTY !== undefined) return io.isTTY; + if (io.question) return true; return process.stdin.isTTY === true && process.stdout.isTTY === true; } -function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; apiKey?: string } { +function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; browser?: LocalBrowserConfig; apiKey?: string } { let mode: SetupMode | undefined; + let browser: LocalBrowserConfig | undefined; let apiKey: string | undefined; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; @@ -186,12 +200,30 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMod continue; } + if (token === '--browser' || token.startsWith('--browser=')) { + const value = token.startsWith('--browser=') ? token.slice('--browser='.length) : argv[++i]; + browser = parseLocalBrowser(value); + continue; + } + throw new ArgumentError( `unknown flag ${token} for \`setup\``, - `valid flags for \`setup\`: --mode, --api-key, --help\n${SETUP_USAGE}`, + `valid flags for \`setup\`: --mode, --browser, --api-key, --help\n${SETUP_USAGE}`, ); } - return { mode, apiKey }; + return { mode, browser, apiKey }; +} + +function parseLocalBrowser(value: string | undefined): LocalBrowserConfig { + if (!value || value.startsWith('-')) { + throw new ArgumentError('--browser requires a value.', `${SETUP_USAGE}\n${SETUP_EXAMPLE}`); + } + if (value === 'cloak' || value === 'slab') return { kind: value }; + if (isAbsolute(value)) return { kind: 'custom', executablePath: value }; + throw new ArgumentError( + `--browser must be cloak, slab, or an absolute path (got: "${value}").`, + `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, + ); } function hostedAccountLabel(body: unknown): string | undefined { From 7b2f28677216965ad3be520d9db422755e8ba343 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 00:54:35 +0530 Subject: [PATCH 21/34] feat: select configured local browser provider --- src/browser/browser-binary.test.ts | 101 +++++----------- src/browser/browser-binary.ts | 57 ++------- src/browser/profile.test.ts | 4 +- src/browser/profile.ts | 5 +- .../runtime/configured-provider.test.ts | 59 +++++++++ src/browser/runtime/configured-provider.ts | 20 ++++ .../runtime/local-cloak/profiles.test.ts | 4 +- src/browser/runtime/local-cloak/profiles.ts | 6 +- .../runtime/local-cloak/provider.test.ts | 5 + src/browser/runtime/local-cloak/provider.ts | 11 +- .../local-cloak/session-manager.test.ts | 12 +- .../runtime/local-cloak/session-manager.ts | 15 ++- .../local-slab/runtime-selection.test.ts | 8 +- src/daemon.ts | 6 +- src/doctor.test.ts | 113 +++--------------- src/doctor.ts | 27 +---- 16 files changed, 184 insertions(+), 269 deletions(-) create mode 100644 src/browser/runtime/configured-provider.test.ts create mode 100644 src/browser/runtime/configured-provider.ts diff --git a/src/browser/browser-binary.test.ts b/src/browser/browser-binary.test.ts index 0e17f4d0..86344032 100644 --- a/src/browser/browser-binary.test.ts +++ b/src/browser/browser-binary.test.ts @@ -1,89 +1,50 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - applyBrowserBinaryOverrideToCloakEnvironment, - resolveBrowserProfileNamespace, - resolveBrowserBinaryOverride, -} from './browser-binary.js'; +import * as browserBinary from './browser-binary.js'; -describe('browser binary override', () => { +describe('browser binary configuration', () => { afterEach(() => vi.unstubAllEnvs()); - it('prefers the Webcmd-owned variable over the legacy CloakBrowser variable', () => { - expect(resolveBrowserBinaryOverride({ - WEBCMD_BROWSER_BINARY_PATH: '/opt/chromium-fork/chrome', - CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', - })).toEqual({ - path: '/opt/chromium-fork/chrome', - envVar: 'WEBCMD_BROWSER_BINARY_PATH', - }); - }); - - it('mirrors the generic override so CloakBrowser skips managed resolution', () => { + it('uses explicit paths for custom namespaces and never reads the retired Webcmd environment variable', () => { + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/Applications/Ignored Browser.app/Contents/MacOS/Ignored Browser'); + expect(browserBinary.resolveBrowserProfileNamespace('/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish')) + .toBe('chromiumfish'); + expect(browserBinary.resolveBrowserProfileNamespace('/Users/test/.clarkbrowser/chromium-148/Chromium.app/Contents/MacOS/Chromium')) + .toBe('clark'); + expect(browserBinary.resolveBrowserProfileNamespace('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser')) + .toBe('brave'); + expect(browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome')) + .toMatch(/^custom-chromium-[a-f0-9]{8}$/); + }); + + it('sets the legacy Cloak executable only from persisted configuration and clears it for managed Cloak', () => { + const configure = (browserBinary as typeof browserBinary & { + configureCloakBrowserBinary?: (executablePath: string | undefined, env: NodeJS.ProcessEnv) => void; + }).configureCloakBrowserBinary; + expect(configure).toBeTypeOf('function'); const env = { - WEBCMD_BROWSER_BINARY_PATH: '/opt/chromium-fork/chrome', - CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', - }; - - applyBrowserBinaryOverrideToCloakEnvironment(env); - - expect(env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/chromium-fork/chrome'); - }); - - it('leaves the environment unchanged when only the legacy variable is set', () => { - const env = { CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome' }; - - applyBrowserBinaryOverrideToCloakEnvironment(env); + WEBCMD_BROWSER_BINARY_PATH: '/opt/ignored/chrome', + CLOAKBROWSER_BINARY_PATH: '/opt/inherited/cloak', + } as NodeJS.ProcessEnv; - expect(env).toEqual({ CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome' }); - }); - - it('keeps managed and legacy Cloak binaries in the existing namespace', () => { - expect(resolveBrowserProfileNamespace({})).toBe('cloak'); - expect(resolveBrowserProfileNamespace({ - CLOAKBROWSER_BINARY_PATH: '/opt/cloak/chrome', - })).toBe('cloak'); - }); - - it('derives stable namespaces for ChromiumFish and Clark Browser', () => { - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish', - })).toBe('chromiumfish'); - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/Users/test/.clarkbrowser/chromium-148/Chromium.app/Contents/MacOS/Chromium', - })).toBe('clark'); - }); - - it('uses a named app bundle for other custom Chromium builds', () => { - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', - })).toBe('brave'); - }); + configure?.('/opt/configured/chrome', env); + expect(env.CLOAKBROWSER_BINARY_PATH).toBe('/opt/configured/chrome'); - it('does not classify browsers from unrelated path substrings', () => { - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/Users/clarkkent/tools/chrome', - })).toMatch(/^custom-chromium-[a-f0-9]{8}$/); + configure?.(undefined, env); + expect(env.CLOAKBROWSER_BINARY_PATH).toBeUndefined(); }); it('keeps unknown custom binaries separate with deterministic namespaces', () => { - const first = resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-one/chrome', - }); - const second = resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-two/chrome', - }); + const first = browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome'); + const second = browserBinary.resolveBrowserProfileNamespace('/opt/fork-two/chrome'); expect(first).toMatch(/^custom-chromium-[a-f0-9]{8}$/); expect(second).toMatch(/^custom-chromium-[a-f0-9]{8}$/); expect(first).not.toBe(second); - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/opt/fork-one/chrome', - })).toBe(first); + expect(browserBinary.resolveBrowserProfileNamespace('/opt/fork-one/chrome')).toBe(first); }); it('never lets a custom binary reuse the reserved managed Cloak namespace', () => { - expect(resolveBrowserProfileNamespace({ - WEBCMD_BROWSER_BINARY_PATH: '/Applications/Cloak.app/Contents/MacOS/Cloak', - })).toMatch(/^custom-cloak-[a-f0-9]{8}$/); + expect(browserBinary.resolveBrowserProfileNamespace('/Applications/Cloak.app/Contents/MacOS/Cloak')) + .toMatch(/^custom-cloak-[a-f0-9]{8}$/); }); }); diff --git a/src/browser/browser-binary.ts b/src/browser/browser-binary.ts index 141ca9c8..2f997496 100644 --- a/src/browser/browser-binary.ts +++ b/src/browser/browser-binary.ts @@ -1,13 +1,7 @@ import { createHash } from 'node:crypto'; -export const WEBCMD_BROWSER_BINARY_PATH_ENV = 'WEBCMD_BROWSER_BINARY_PATH'; export const CLOAKBROWSER_BINARY_PATH_ENV = 'CLOAKBROWSER_BINARY_PATH'; -export type BrowserBinaryOverride = { - path: string; - envVar: typeof WEBCMD_BROWSER_BINARY_PATH_ENV | typeof CLOAKBROWSER_BINARY_PATH_ENV; -}; - function normalizeBrowserNamespace(value: string): string { return value .replace(/\.app$/i, '') @@ -30,15 +24,15 @@ function safeCustomNamespace(candidate: string, binaryPath: string): string { /** * Select the on-disk namespace that owns local Chromium profile data. - * Managed Cloak and its legacy override retain the historical `cloak` path. + * Managed Cloak retains the historical `cloak` path. */ export function resolveBrowserProfileNamespace( - env: NodeJS.ProcessEnv = process.env, + executablePath?: string, ): string { - const genericPath = env[WEBCMD_BROWSER_BINARY_PATH_ENV]?.trim(); - if (!genericPath) return 'cloak'; + const binaryPath = executablePath?.trim(); + if (!binaryPath) return 'cloak'; - const components = genericPath.split(/[\\/]+/).filter(Boolean); + const components = binaryPath.split(/[\\/]+/).filter(Boolean); const appBundle = [...components].reverse().find(component => /\.app$/i.test(component)); const executable = normalizeBrowserNamespace(components.at(-1) ?? ''); const appNamespace = normalizeBrowserNamespace(appBundle ?? ''); @@ -51,45 +45,20 @@ export function resolveBrowserProfileNamespace( if (appBundle) { if (appNamespace && appNamespace !== 'chromium') { - return safeCustomNamespace(appNamespace, genericPath); + return safeCustomNamespace(appNamespace, binaryPath); } } if (executable && !['chrome', 'chromium'].includes(executable)) { - return safeCustomNamespace(executable, genericPath); - } - return `custom-chromium-${browserPathHash(genericPath)}`; -} - -/** - * Resolve the browser executable selected by the user. - * - * The Webcmd-owned name takes precedence. The CloakBrowser-specific name stays - * supported so existing installations continue to launch the same binary. - */ -export function resolveBrowserBinaryOverride( - env: NodeJS.ProcessEnv = process.env, -): BrowserBinaryOverride | undefined { - if (env[WEBCMD_BROWSER_BINARY_PATH_ENV]) { - return { path: env[WEBCMD_BROWSER_BINARY_PATH_ENV], envVar: WEBCMD_BROWSER_BINARY_PATH_ENV }; - } - if (env[CLOAKBROWSER_BINARY_PATH_ENV]) { - return { path: env[CLOAKBROWSER_BINARY_PATH_ENV], envVar: CLOAKBROWSER_BINARY_PATH_ENV }; + return safeCustomNamespace(executable, binaryPath); } - return undefined; + return `custom-chromium-${browserPathHash(binaryPath)}`; } -/** - * CloakBrowser resolves its managed executable before applying raw Playwright - * launch options. Mirror Webcmd's generic override into the legacy variable so - * the wrapper short-circuits that download and platform-resolution path. - */ -export function applyBrowserBinaryOverrideToCloakEnvironment( +export function configureCloakBrowserBinary( + executablePath: string | undefined, env: NodeJS.ProcessEnv = process.env, -): BrowserBinaryOverride | undefined { - const override = resolveBrowserBinaryOverride(env); - if (override?.envVar === WEBCMD_BROWSER_BINARY_PATH_ENV) { - env[CLOAKBROWSER_BINARY_PATH_ENV] = override.path; - } - return override; +): void { + if (executablePath) env[CLOAKBROWSER_BINARY_PATH_ENV] = executablePath; + else delete env[CLOAKBROWSER_BINARY_PATH_ENV]; } diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index 52020797..354accaa 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -116,7 +116,7 @@ describe('createProfile', () => { expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: true }); expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: false }); expect(loadProfileConfig().aliases['eval-a']).toBe('eval-a'); - expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(true); + expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(false); }); it('rejects an invalid alias', () => { @@ -174,7 +174,7 @@ describe('setDefaultProfile membership', () => { setDefaultProfile('__audit_nope__', []); } catch (err) { expect((err as ArgumentError).message).toBe( - 'No profile matches "__audit_nope__". No Cloak profiles are available.', + 'No profile matches "__audit_nope__". No browser profiles are available.', ); expect((err as ArgumentError).hint).toContain('webcmd profile list'); } diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 857a3dec..b4bd31c0 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -3,7 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; -import { normalizeProfileId, resolveCloakProfileDir } from './runtime/local-cloak/profiles.js'; +import { normalizeProfileId } from './runtime/local-cloak/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -121,7 +121,6 @@ export function createProfile(alias: string): { contextId: string; alias: string } config.aliases[name] = contextId; saveProfileConfig(config); - fs.mkdirSync(resolveCloakProfileDir(contextId), { recursive: true }); return { contextId, alias: name, created: true }; } @@ -173,7 +172,7 @@ export function setDefaultProfile(profile: string, rows: ProfileListRow[]): Prof const usage = `usage: ${CLI_COMMAND} profile use `; if (labels.length === 0) { throw new ArgumentError( - `No profile matches "${name}". No Cloak profiles are available.`, + `No profile matches "${name}". No browser profiles are available.`, `${usage}\nRun ${CLI_COMMAND} profile list, or create one with a browser-backed command.`, ); } diff --git a/src/browser/runtime/configured-provider.test.ts b/src/browser/runtime/configured-provider.test.ts new file mode 100644 index 00000000..1d4738a0 --- /dev/null +++ b/src/browser/runtime/configured-provider.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { makeLocalConfig } from '../../hosted/config.js'; +import { LocalCloakRuntimeProvider } from './local-cloak/provider.js'; +import { LocalSlabRuntimeProvider } from './local-slab/provider.js'; +import { createConfiguredLocalBrowserRuntimeProvider } from './configured-provider.js'; + +describe('configured local browser provider', () => { + afterEach(() => { + vi.doUnmock('./local-cloak/provider.js'); + vi.doUnmock('./local-slab/provider.js'); + vi.resetModules(); + }); + + it('selects Cloak or custom Cloak directly, and selects SLAB directly', async () => { + const cloak = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { kind: 'cloak' })); + const custom = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { + kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + })); + const slab = createConfiguredLocalBrowserRuntimeProvider(makeLocalConfig(new Date(0), { kind: 'slab' })); + + expect(cloak).toBeInstanceOf(LocalCloakRuntimeProvider); + expect(custom).toBeInstanceOf(LocalCloakRuntimeProvider); + expect(await custom.status()).toMatchObject({ runtimeName: 'custom' }); + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'); + expect(slab).toBeInstanceOf(LocalSlabRuntimeProvider); + }); + + it('passes the persisted custom path and canonical namespace to Cloak', async () => { + const LocalCloakRuntimeProvider = vi.fn(); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + const executablePath = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'; + + createProvider(makeLocalConfig(new Date(0), { kind: 'custom', executablePath })); + + expect(LocalCloakRuntimeProvider).toHaveBeenCalledWith({ + executablePath, + profileNamespace: 'brave', + runtimeName: 'custom', + }); + }); + + it('does not fall back to Cloak when SLAB construction fails', async () => { + const cloak = vi.fn(); + const failure = new Error('SLAB startup failed'); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider: cloak })); + vi.doMock('./local-slab/provider.js', () => ({ + LocalSlabRuntimeProvider: class { + constructor() { + throw failure; + } + }, + })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + + expect(() => createProvider(makeLocalConfig(new Date(0), { kind: 'slab' }))).toThrow(failure); + expect(cloak).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/runtime/configured-provider.ts b/src/browser/runtime/configured-provider.ts new file mode 100644 index 00000000..3c6e1251 --- /dev/null +++ b/src/browser/runtime/configured-provider.ts @@ -0,0 +1,20 @@ +import type { LocalWebcmdConfig } from '../../hosted/config.js'; +import { configureCloakBrowserBinary, resolveBrowserProfileNamespace } from '../browser-binary.js'; +import { LocalCloakRuntimeProvider } from './local-cloak/provider.js'; +import { LocalSlabRuntimeProvider } from './local-slab/provider.js'; +import type { BrowserRuntimeProvider } from './provider.js'; + +export function createConfiguredLocalBrowserRuntimeProvider( + config?: LocalWebcmdConfig, +): BrowserRuntimeProvider { + const browser = config?.browser ?? { kind: 'cloak' }; + if (browser.kind === 'slab') return new LocalSlabRuntimeProvider(); + + const executablePath = browser.kind === 'custom' ? browser.executablePath : undefined; + configureCloakBrowserBinary(executablePath); + return new LocalCloakRuntimeProvider({ + executablePath, + profileNamespace: resolveBrowserProfileNamespace(executablePath), + runtimeName: browser.kind, + }); +} diff --git a/src/browser/runtime/local-cloak/profiles.test.ts b/src/browser/runtime/local-cloak/profiles.test.ts index 3fab8897..81a0c72c 100644 --- a/src/browser/runtime/local-cloak/profiles.test.ts +++ b/src/browser/runtime/local-cloak/profiles.test.ts @@ -23,9 +23,7 @@ describe('cloak profile resolution', () => { it('isolates profiles for a custom browser binary', () => { expect(resolveCloakProfileDir('default', { baseDir: '/tmp/webcmd', - env: { - WEBCMD_BROWSER_BINARY_PATH: '/Users/test/Library/Caches/chromiumfish/151/mac-arm64/ChromiumFish.app/Contents/MacOS/ChromiumFish', - }, + profileNamespace: 'chromiumfish', })).toBe(path.join('/tmp/webcmd', 'chromiumfish', 'profiles', 'default')); }); }); diff --git a/src/browser/runtime/local-cloak/profiles.ts b/src/browser/runtime/local-cloak/profiles.ts index ccb3c364..9e2df423 100644 --- a/src/browser/runtime/local-cloak/profiles.ts +++ b/src/browser/runtime/local-cloak/profiles.ts @@ -1,11 +1,10 @@ import path from 'node:path'; import { CONFIG_DIR_NAME, ENV_PREFIX } from '../../../brand.js'; import os from 'node:os'; -import { resolveBrowserProfileNamespace } from '../../browser-binary.js'; export interface CloakProfileDirOptions { baseDir?: string; - env?: NodeJS.ProcessEnv; + profileNamespace?: string; } export function normalizeProfileId(value: string | undefined | null): string { @@ -22,6 +21,5 @@ export function getWebcmdConfigDir(): string { export function resolveCloakProfileDir(profileId: string, opts: CloakProfileDirOptions = {}): string { const safeProfileId = normalizeProfileId(profileId); - const browserNamespace = resolveBrowserProfileNamespace(opts.env); - return path.join(opts.baseDir ?? getWebcmdConfigDir(), browserNamespace, 'profiles', safeProfileId); + return path.join(opts.baseDir ?? getWebcmdConfigDir(), opts.profileNamespace ?? 'cloak', 'profiles', safeProfileId); } diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index bd436bad..13a98318 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -165,6 +165,11 @@ describe('LocalCloakRuntimeProvider', () => { }); }); + it('reports the configured custom runtime name', async () => { + const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test', runtimeName: 'custom' }); + await expect(provider.status()).resolves.toMatchObject({ runtimeName: 'custom' }); + }); + it('discards a temporary Session record after closing it', async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); try { diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 6b6d599a..9f6cb9f4 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -10,6 +10,9 @@ import { export interface LocalCloakRuntimeProviderOptions { baseDir?: string; + profileNamespace?: string; + executablePath?: string; + runtimeName?: 'cloak' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } @@ -25,7 +28,11 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, }); this.manager = new CloakSessionManager({ - ...opts, + baseDir: opts.baseDir, + profileNamespace: opts.profileNamespace, + executablePath: opts.executablePath, + launchPersistentContext: opts.launchPersistentContext, + launchBackgroundPersistentContext: opts.launchBackgroundPersistentContext, hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() )), @@ -36,7 +43,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, - runtimeName: 'cloak', + runtimeName: this.opts.runtimeName ?? 'cloak', runtimeVersion: resolveCloakBrowserVersion(), profiles, pending: 0, diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 384ccfe1..b50b679e 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -4,7 +4,6 @@ import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; import { log } from '../../../logger.js'; import { dispatchCloakAction } from './actions.js'; -import { resolveBrowserProfileNamespace } from '../../browser-binary.js'; function fakeContext() { const listeners = new Map void>>(); @@ -191,14 +190,15 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); }); - it('passes WEBCMD_BROWSER_BINARY_PATH through as the Playwright executable', async () => { + it('passes the configured executable and namespace through to Cloak', async () => { const browserPath = '/opt/chromium-fork/chrome'; vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/opt/cloak/chrome'); - vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', browserPath); const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + executablePath: browserPath, + profileNamespace: 'custom-chromium-12345678', launchPersistentContext, }); @@ -207,7 +207,7 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext).toHaveBeenCalledWith(expect.objectContaining({ userDataDir: path.join( '/tmp/webcmd-test', - resolveBrowserProfileNamespace({ WEBCMD_BROWSER_BINARY_PATH: browserPath }), + 'custom-chromium-12345678', 'profiles', 'default', ), @@ -216,14 +216,14 @@ describe('CloakSessionManager', () => { expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe(browserPath); }); - it('uses the normal macOS launcher for a custom app-bundle executable', async () => { + it('uses the normal macOS launcher for a configured custom app-bundle executable', async () => { vi.stubEnv('CLOAKBROWSER_BINARY_PATH', ''); - vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/Applications/ChromiumFork.app/Contents/MacOS/ChromiumFork'); const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + executablePath: '/Applications/ChromiumFork.app/Contents/MacOS/ChromiumFork', platform: 'darwin', launchPersistentContext, launchBackgroundPersistentContext, diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 9bdfd72d..9271dc64 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -15,7 +15,7 @@ import { findExactCloakProfileProcesses } from './process-matcher.js'; import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; import { isClosedContextError } from '../../run/types.js'; -import { applyBrowserBinaryOverrideToCloakEnvironment } from '../../browser-binary.js'; +import { configureCloakBrowserBinary } from '../../browser-binary.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -157,6 +157,8 @@ export class SessionWindowConflictError extends CliError { export interface CloakSessionManagerOptions { baseDir?: string; + profileNamespace?: string; + executablePath?: string; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; activateBackgroundContext?: typeof activateDarwinBackgroundContext; @@ -726,20 +728,23 @@ export class CloakSessionManager { } private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); + const userDataDir = resolveCloakProfileDir(profileId, { + baseDir: this.opts.baseDir, + profileNamespace: this.opts.profileNamespace, + }); fs.mkdirSync(userDataDir, { recursive: true }); - const binaryOverride = applyBrowserBinaryOverrideToCloakEnvironment(); + configureCloakBrowserBinary(this.opts.executablePath); const launchOptions = { userDataDir, headless: false, humanize: true, - ...(binaryOverride ? { launchOptions: { executablePath: binaryOverride.path } } : {}), + ...(this.opts.executablePath ? { launchOptions: { executablePath: this.opts.executablePath } } : {}), }; // The macOS background launcher depends on Cloak Chromium publishing a // DevToolsActivePort file. Compatible Chromium forks may be app bundles but // not implement that contract, so custom executables use Playwright's // normal persistent launcher instead. - const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' && !binaryOverride + const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' && !this.opts.executablePath ? this.launchBackgroundPersistentContext : this.launchPersistentContext; let context: BrowserContext; diff --git a/src/browser/runtime/local-slab/runtime-selection.test.ts b/src/browser/runtime/local-slab/runtime-selection.test.ts index 8ddd2e99..1d805cce 100644 --- a/src/browser/runtime/local-slab/runtime-selection.test.ts +++ b/src/browser/runtime/local-slab/runtime-selection.test.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; function fakeAttachedProfile() { @@ -107,11 +105,7 @@ function fakeAttachedProfile() { } describe('local browser runtime selection', () => { - it('keeps Cloak as the daemon default while retaining the SLAB provider factory', async () => { - const daemonSource = fs.readFileSync(fileURLToPath(new URL('../../../daemon.ts', import.meta.url)), 'utf8'); - expect(daemonSource).toContain("from './browser/runtime/local-cloak/provider.js'"); - expect(daemonSource).toContain('new LocalCloakRuntimeProvider'); - + it('retains the SLAB provider factory', async () => { const { createLocalBrowserRuntimeProvider, LocalSlabRuntimeProvider } = await import('./provider.js'); const provider = createLocalBrowserRuntimeProvider({ attachProfile: vi.fn().mockResolvedValue(fakeAttachedProfile()), diff --git a/src/daemon.ts b/src/daemon.ts index f7c25310..49bef89c 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,9 +3,11 @@ import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; import { PKG_VERSION } from './version.js'; import { createDaemonServer } from './daemon/server.js'; -import { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js'; +import { loadWebcmdConfig } from './hosted/config.js'; +import { createConfiguredLocalBrowserRuntimeProvider } from './browser/runtime/configured-provider.js'; -const provider = new LocalCloakRuntimeProvider(); +const config = loadWebcmdConfig(); +const provider = createConfiguredLocalBrowserRuntimeProvider(config.mode === 'local' ? config : undefined); const daemon = createDaemonServer(provider, { port: DEFAULT_DAEMON_PORT, host: '127.0.0.1', version: PKG_VERSION }); daemon.listen().then(() => { diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 50c29a64..f976562f 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -176,7 +176,7 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - binary: { installed: true, path: '/Applications/Cloak Chromium.app', override: false }, + binary: { installed: true, path: '/Applications/Cloak Chromium.app' }, issues: [], })); @@ -188,7 +188,7 @@ describe('doctor report rendering', () => { daemonRunning: true, runtimeConnected: true, runtimeName: 'Cloak', - binary: { installed: false, path: '/Applications/Cloak Chromium.app', override: false }, + binary: { installed: false, path: '/Applications/Cloak Chromium.app' }, issues: ['CloakBrowser Chromium is not installed.'], })); @@ -549,7 +549,7 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); - expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath, override: false }); + expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath }); expect(report.issues).toEqual(expect.arrayContaining([ expect.stringContaining('Browser connectivity test failed: page.goto: Target page, context or browser has been closed'), ])); @@ -578,7 +578,7 @@ describe('doctor report rendering', () => { expect(issueText).toContain('/home/test/.cloakbrowser/chromium-146.0.7680.177.5/chrome'); expect(issueText).toContain('https://cloakbrowser.dev/chromium-v146.0.7680.177.5/cloakbrowser-linux-x64.tar.gz'); expect(issueText).toContain('Browser connectivity test failed: fetch failed'); - expect(issueText).toContain('WEBCMD_BROWSER_BINARY_PATH'); + expect(issueText).toContain('Check network access to the download URL above.'); expect(issueText).not.toContain('could not be downloaded'); expect(issueText).not.toContain('download failed'); }); @@ -620,82 +620,13 @@ describe('doctor report rendering', () => { expect(text).not.toContain('Everything looks good!'); }); - it('treats CLOAKBROWSER_BINARY_PATH as the effective binary check, not the managed cache', async () => { - const overridePath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-override-')), - process.platform === 'win32' ? 'chrome.exe' : 'chrome', - ); - fs.writeFileSync(overridePath, '#!/bin/sh\n'); - if (process.platform !== 'win32') fs.chmodSync(overridePath, 0o755); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - // Managed cache would report "not installed" — the override should win. - mockBinaryInfo.mockReturnValue({ - version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'linux-x64', - binaryPath: '/home/test/.cloakbrowser/chromium-1.0.0/chrome', installed: false, - cacheDir: '/home/test/.cloakbrowser/chromium-1.0.0', downloadUrl: 'https://example.test/download', - }); - - 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('prefers WEBCMD_BROWSER_BINARY_PATH and skips the managed binary download', async () => { - const overridePath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-generic-binary-override-')), - process.platform === 'win32' ? 'chrome.exe' : 'chrome', - ); - fs.writeFileSync(overridePath, '#!/bin/sh\n'); - if (process.platform !== 'win32') fs.chmodSync(overridePath, 0o755); - vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', overridePath); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/legacy/cloak/chrome'); - try { - const binary = checkBrowserBinary(); - const connectivity = await checkConnectivity(); - - expect(binary).toMatchObject({ - installed: true, - path: overridePath, - override: true, - overrideEnv: 'WEBCMD_BROWSER_BINARY_PATH', - }); - expect(connectivity.ok).toBe(true); - expect(mockEnsureBinary).not.toHaveBeenCalled(); - } 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('ignores retired browser-binary environment variables and uses the managed binary', async () => { + vi.stubEnv('WEBCMD_BROWSER_BINARY_PATH', '/does/not/exist/webcmd-chrome'); + vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/does/not/exist/cloak-chrome'); - it('rejects a non-executable CLOAKBROWSER_BINARY_PATH file on POSIX', async () => { - if (process.platform === 'win32') return; - const overridePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-binary-non-executable-')), 'chrome'); - fs.writeFileSync(overridePath, '#!/bin/sh\n', { mode: 0o644 }); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', overridePath); - try { - expect(checkBrowserBinary().installed).toBe(false); - } finally { - vi.unstubAllEnvs(); - fs.rmSync(path.dirname(overridePath), { recursive: true, force: true }); - } + expect(checkBrowserBinary()).toMatchObject({ installed: true, path: managedBinaryPath }); + await checkConnectivity(); + expect(mockEnsureBinary).toHaveBeenCalledOnce(); }); it('rejects a managed non-executable binary on POSIX', () => { @@ -719,9 +650,12 @@ describe('doctor report rendering', () => { const binaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-windows-binary-')); const binaryPath = path.join(binaryDir, 'chrome.txt'); fs.writeFileSync(binaryPath, 'not an executable'); - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', binaryPath); try { Object.defineProperty(process, 'platform', { ...platformDescriptor, value: 'win32' }); + mockBinaryInfo.mockReturnValue({ + version: '1.0.0', bundledVersion: '1.0.0', tier: 'free', platform: 'win32-x64', + binaryPath, installed: true, cacheDir: binaryDir, downloadUrl: 'https://example.test/download', + }); expect(checkBrowserBinary().installed).toBe(false); } finally { if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor); @@ -730,25 +664,6 @@ describe('doctor report rendering', () => { } }); - it('reports override-specific guidance when CLOAKBROWSER_BINARY_PATH points nowhere', async () => { - vi.stubEnv('CLOAKBROWSER_BINARY_PATH', '/does/not/exist/chrome'); - try { - mockConnect.mockRejectedValueOnce(new Error('spawn /does/not/exist/chrome ENOENT')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); - - const report = await runBrowserDoctor(); - - expect(report.binary?.installed).toBe(false); - expect(report.binary?.override).toBe(true); - const issueText = report.issues.join('\n'); - const text = strip(renderBrowserDoctorReport(report)); - expect(issueText).toContain('CLOAKBROWSER_BINARY_PATH (/does/not/exist/chrome)'); - expect(issueText).toContain('compatible local Chromium executable'); - expect(text).toContain('[MISSING] Browser binary: not launchable (/does/not/exist/chrome)'); - } finally { - vi.unstubAllEnvs(); - } - }); }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 079c5ade..6ef767c9 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -17,7 +17,6 @@ import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; -import { resolveBrowserBinaryOverride } from './browser/browser-binary.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -37,9 +36,6 @@ export type BrowserBinaryStatus = { path: string; downloadUrl?: string; error?: string; - /** True when a custom executable is selected instead of the managed cache. */ - override: boolean; - overrideEnv?: string; }; export type DoctorReport = { @@ -89,25 +85,15 @@ function isLaunchableFile(binaryPath: string): boolean { * needs to launch is present on disk. */ export function checkBrowserBinary(): BrowserBinaryStatus { - const override = resolveBrowserBinaryOverride(); - if (override) { - return { - installed: isLaunchableFile(override.path), - path: override.path, - override: true, - overrideEnv: override.envVar, - }; - } 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 }; + return { installed: undefined, path: 'unknown', error: getErrorMessage(err) }; } } @@ -120,7 +106,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise Date: Tue, 1 Sep 2026 01:05:35 +0530 Subject: [PATCH 22/34] feat: make setup apply browser selection --- src/hosted/setup.test.ts | 243 +++++++++++++++++++++++++++++++++++++-- src/hosted/setup.ts | 100 +++++++++++++++- src/slab/status.test.ts | 11 +- src/slab/status.ts | 4 + 4 files changed, 341 insertions(+), 17 deletions(-) diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index efe9916d..c6462efb 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -5,7 +5,7 @@ import path, { join } from 'node:path'; import { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getConfigPath } from './config.js'; +import { getConfigPath, makeLocalConfig, saveWebcmdConfig } from './config.js'; import { getHostedCredentialPath } from './credentials.js'; import { runHostedSetup } from './setup.js'; @@ -20,7 +20,7 @@ afterEach(async () => { describe('webcmd setup', () => { it('writes local mode from interactive answer', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); - const answers = ['local', 'slab']; + const answers = ['local', 'cloak']; const messages: string[] = []; const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; @@ -36,7 +36,7 @@ describe('webcmd setup', () => { expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({ mode: 'local', updatedAt: '2026-07-08T00:00:00.000Z', - browser: { kind: 'slab' }, + browser: { kind: 'cloak' }, }); expect(messages.join('')).toContain('local mode'); }); @@ -116,28 +116,191 @@ describe('webcmd setup', () => { expect(messages.join('')).toContain('local mode'); }); - it.each([ - [['--mode', 'local', '--browser', 'cloak'], { kind: 'cloak' }], - [['--mode=local', '--browser=slab'], { kind: 'slab' }], - [['--mode', 'local', '--browser', '/Applications/Chrome.app/Contents/MacOS/Google Chrome'], { - kind: 'custom', executablePath: '/Applications/Chrome.app/Contents/MacOS/Google Chrome', - }], - ])('persists browser selection from %j', async (argv, browser) => { + it('validates Cloak before persisting the selection', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-browser-')); const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const events: string[] = []; await expect(runHostedSetup({ env, - argv, + argv: ['--mode', 'local', '--browser', 'cloak'], isTTY: false, now: () => new Date('2026-08-31T00:00:00.000Z'), + resolveCloakPackage: async () => { events.push('validate'); return 'file:///cloakbrowser/index.js'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { + events.push('save'); + saveWebcmdConfig(config, configIo); + }, write: () => undefined, })).resolves.toBe(0); expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ mode: 'local', - browser, + browser: { kind: 'cloak' }, }); + expect(events).toEqual(['validate', 'save']); + }); + + it('persists a canonical custom executable path', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-custom-browser-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', '/Applications/Chrome.app/Contents/MacOS/Google Chrome'], + isTTY: false, + realpath: async () => '/private/Applications/Chrome.app/Contents/MacOS/Google Chrome', + stat: async () => ({ isFile: () => true }), + access: async () => undefined, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser: { kind: 'custom', executablePath: '/private/Applications/Chrome.app/Contents/MacOS/Google Chrome' }, + }); + }); + + it('reuses an installed SLAB app without reinstalling it', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); + const installSlabMacos = vi.fn(); + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + installSlabMacos, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(installSlabMacos).not.toHaveBeenCalled(); + }); + + it('installs SLAB and probes its control protocol before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-install-')); + const events: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + findSlabInstallation: () => null, + installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, + inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, + write: () => undefined, + })).resolves.toBe(0); + + expect(events).toEqual(['install', 'hello', 'save']); + }); + + it('leaves config and daemon unchanged when SLAB installation fails', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-failure-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-30T00:00:00.000Z')), { env }); + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + findSlabInstallation: () => null, + installSlabMacos: async () => { throw new Error('download failed'); }, + fetchDaemonStatus: async () => daemonStatus('cloak'), + restartDaemon, + write: () => undefined, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('does not restart a daemon when config persistence fails', async () => { + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('cloak'), + saveConfig: () => { throw new Error('disk full'); }, + restartDaemon, + write: () => undefined, + })).resolves.toBe(1); + + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('restarts a running daemon and requires the selected runtime', async () => { + const restartDaemon = vi.fn(async () => ({ previousStatus: daemonStatus('cloak'), status: daemonStatus('custom'), stopped: true, spawned: true })); + + await expect(runHostedSetup({ + argv: ['--mode', 'local', '--browser', '/custom/browser'], + isTTY: false, + realpath: async () => '/custom/browser', + stat: async () => ({ isFile: () => true }), + access: async () => undefined, + fetchDaemonStatus: async () => daemonStatus('cloak'), + restartDaemon, + write: () => undefined, + })).resolves.toBe(0); + + expect(restartDaemon).toHaveBeenCalledOnce(); + }); + + it('does not start a stopped daemon during setup', async () => { + const restartDaemon = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => null, + restartDaemon, + write: () => undefined, + })).resolves.toBe(0); + + expect(restartDaemon).not.toHaveBeenCalled(); + }); + + it('keeps a valid selection when the restarted daemon reports the wrong runtime', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-runtime-mismatch-')); + const messages: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('custom'), + restartDaemon: async () => ({ previousStatus: daemonStatus('custom'), status: daemonStatus('custom'), stopped: true, spawned: true }), + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env: { WEBCMD_CONFIG_DIR: tempDir } }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(messages.join('')).toContain('webcmd daemon restart'); + }); + + it('rejects SLAB setup off macOS before changing config', async () => { + const installSlabMacos = vi.fn(); + + await expect(runHostedSetup({ + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'linux', + installSlabMacos, + write: () => undefined, + })).resolves.toBe(1); + + expect(installSlabMacos).not.toHaveBeenCalled(); }); it.each([ @@ -170,6 +333,47 @@ describe('webcmd setup', () => { expect(messages.join('')).toContain('--browser '); }); + it('reports the configured custom browser without probing SLAB', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-status-custom-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const inspectSlabStatus = vi.fn(); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), { kind: 'custom', executablePath: '/custom/browser' }), { env }); + const messages: string[] = []; + + await expect(runHostedSetup({ env, argv: ['--status'], inspectSlabStatus, write: message => { messages.push(message); } })).resolves.toBe(0); + + expect(JSON.parse(messages.join(''))).toEqual({ + configured: true, + mode: 'local', + browser: { kind: 'custom', executablePath: '/custom/browser' }, + }); + expect(inspectSlabStatus).not.toHaveBeenCalled(); + }); + + it('reports SLAB runtime status without installing or launching it', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-status-slab-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const installSlabMacos = vi.fn(); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), { kind: 'slab' }), { env }); + const messages: string[] = []; + + await expect(runHostedSetup({ + env, + argv: ['--status'], + inspectSlabStatus: async () => 'installed-not-running', + installSlabMacos, + write: message => { messages.push(message); }, + })).resolves.toBe(0); + + expect(JSON.parse(messages.join(''))).toEqual({ + configured: true, + mode: 'local', + browser: { kind: 'slab' }, + runtime: 'installed-not-running', + }); + expect(installSlabMacos).not.toHaveBeenCalled(); + }); + it('rejects non-TTY setup without --mode and never prompts', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-nontty-')); const messages: string[] = []; @@ -268,6 +472,8 @@ describe('webcmd setup', () => { env: { WEBCMD_CONFIG_DIR: tempDir }, output, question: async () => answers.shift() ?? '', + fetchDaemonStatus: async () => null, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', }).then(code => { settled = true; return code; @@ -328,6 +534,19 @@ function collectStderr(): { stream: Writable; text: () => string } { return { stream, text: () => Buffer.concat(chunks).toString('utf8') }; } +function daemonStatus(runtimeName: string) { + return { + ok: true, + pid: 1234, + uptime: 1, + runtimeConnected: true, + runtimeName, + pending: 0, + memoryMB: 1, + port: 9777, + }; +} + async function within(promise: Promise, milliseconds = 500): Promise { let timer: ReturnType | undefined; try { diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index b46841d9..c8a70eff 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,17 +1,28 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; +import { constants, existsSync } from 'node:fs'; +import { access, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { isAbsolute } from 'node:path'; import { CLI_COMMAND } from '../brand.js'; import { ArgumentError, toEnvelope } from '../errors.js'; import { formatErrorEnvelope } from '../output.js'; import { writeToStream } from '../stream-write.js'; +import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; +import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; +import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; +import { findSlabInstallation, type SlabInstallation } from '../slab/installation.js'; +import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; import { HostedClient } from './client.js'; import { defaultHostedApiBaseUrl, + getConfigPath, + loadWebcmdConfig, makeLocalConfig, saveWebcmdConfig, type ConfigIo, type LocalBrowserConfig, + type WebcmdConfig, } from './config.js'; import { makeStoredHostedConfig, @@ -29,6 +40,16 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { write?: (message: string) => void | Promise; argv?: readonly string[]; isTTY?: boolean; + resolveCloakPackage?: () => string | Promise; + realpath?: (path: string) => Promise; + stat?: (path: string) => Promise<{ isFile(): boolean }>; + access?: (path: string, mode: number) => Promise; + findSlabInstallation?: () => SlabInstallation | null; + installSlabMacos?: () => Promise; + inspectSlabStatus?: () => Promise; + fetchDaemonStatus?: () => Promise; + restartDaemon?: () => Promise; + saveConfig?: (config: WebcmdConfig, io: ConfigIo) => void; } type SetupMode = 'local' | 'hosted'; @@ -43,6 +64,7 @@ const SETUP_HELP = [ ' --mode Required when stdin is not a TTY', ' --browser Local browser in local mode', ' --api-key Required for --mode hosted when stdin is not a TTY', + ' --status Show the configured mode and local browser', ' -h, --help', '', SETUP_EXAMPLE, @@ -69,6 +91,10 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { await write(SETUP_HELP); return 0; } + if (parsed.status) { + await write(`${JSON.stringify(await getSetupStatus(io))}\n`); + return 0; + } const interactive = canPrompt(io); let mode = parsed.mode; @@ -97,7 +123,17 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { const browser = parsed.browser ?? (interactive ? parseLocalBrowser((await ask('Local browser [cloak/slab/absolute path] (cloak): ')).trim() || 'cloak') : { kind: 'cloak' }); - saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date(), browser), io); + const before = await (io.fetchDaemonStatus ?? fetchDaemonStatus)(); + try { + const selected = await validateLocalBrowser(browser, io); + (io.saveConfig ?? saveWebcmdConfig)(makeLocalConfig(io.now?.() ?? new Date(), selected), io); + if (before) await restartConfiguredDaemon(selected, io); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await write(`Local browser setup failed: ${message}\n`); + if (err instanceof DaemonRestartError) await write('Run `webcmd daemon restart` to apply the selected browser.\n'); + return 1; + } await write('Webcmd is now configured for local mode.\n'); return 0; } @@ -169,13 +205,69 @@ function canPrompt(io: SetupIo): boolean { return process.stdin.isTTY === true && process.stdout.isTTY === true; } -function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; browser?: LocalBrowserConfig; apiKey?: string } { +async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): Promise { + if (browser.kind === 'cloak') { + await (io.resolveCloakPackage ?? (() => import.meta.resolve('cloakbrowser')))(); + return browser; + } + if (browser.kind === 'custom') { + const executablePath = await (io.realpath ?? realpath)(browser.executablePath); + if (!(await (io.stat ?? stat)(executablePath)).isFile()) throw new Error(`Browser executable is not a file: ${executablePath}`); + await (io.access ?? access)(executablePath, constants.X_OK); + return { kind: 'custom', executablePath }; + } + if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); + if ((io.findSlabInstallation ?? defaultFindSlabInstallation)()) return browser; + await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + if (!slabStatusHasHello(await (io.inspectSlabStatus ?? inspectSlabStatus)())) throw new Error('SLAB did not report its control protocol after launch.'); + return browser; +} + +function defaultFindSlabInstallation(): SlabInstallation | null { + return findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }); +} + +async function restartConfiguredDaemon(browser: LocalBrowserConfig, io: SetupIo): Promise { + const result = await (io.restartDaemon ?? restartDaemon)(); + const expected = browser.kind === 'slab' ? 'SLAB' : browser.kind; + if (!result.stopped || result.status?.runtimeName !== expected) { + throw new DaemonRestartError(`Daemon restarted without the selected ${expected} runtime.`); + } +} + +class DaemonRestartError extends Error {} + +export interface SetupStatus { + configured: boolean; + mode: SetupMode; + browser: LocalBrowserConfig | null; + runtime?: SlabSetupStatus; +} + +export async function getSetupStatus(io: SetupIo = {}): Promise { + const config = loadWebcmdConfig(io); + const browser = config.mode === 'local' ? config.browser : null; + const status: SetupStatus = { + configured: (io.existsSync ?? existsSync)(getConfigPath(io)), + mode: config.mode, + browser, + }; + if (browser?.kind === 'slab') status.runtime = await (io.inspectSlabStatus ?? inspectSlabStatus)(); + return status; +} + +function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; mode?: SetupMode; browser?: LocalBrowserConfig; apiKey?: string } { let mode: SetupMode | undefined; let browser: LocalBrowserConfig | undefined; let apiKey: string | undefined; + let status: true | undefined; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; if (token === '--help' || token === '-h') return { help: true }; + if (token === '--status') { + status = true; + continue; + } if (token === '--mode' || token.startsWith('--mode=')) { const value = token.startsWith('--mode=') ? token.slice('--mode='.length) : argv[++i]; if (value !== 'local' && value !== 'hosted') { @@ -208,10 +300,10 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMod throw new ArgumentError( `unknown flag ${token} for \`setup\``, - `valid flags for \`setup\`: --mode, --browser, --api-key, --help\n${SETUP_USAGE}`, + `valid flags for \`setup\`: --mode, --browser, --api-key, --status, --help\n${SETUP_USAGE}`, ); } - return { mode, browser, apiKey }; + return { ...(status ? { status } : {}), mode, browser, apiKey }; } function parseLocalBrowser(value: string | undefined): LocalBrowserConfig { diff --git a/src/slab/status.test.ts b/src/slab/status.test.ts index dd02009d..3a925815 100644 --- a/src/slab/status.test.ts +++ b/src/slab/status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { inspectSlabStatus } from './status.js'; +import { inspectSlabStatus, slabStatusHasHello } from './status.js'; const hello = { protocolVersion: 1, browserVersion: '1', browserPid: 1234, profiles: [] }; const installation = { @@ -9,6 +9,15 @@ const installation = { }; describe('SLAB setup status', () => { + it.each([ + ['preliminary-running', true], + ['installed-running', true], + ['installed-not-running', false], + ['not-installed', false], + ] as const)('recognizes whether %s completed the control hello', (status, expected) => { + expect(slabStatusHasHello(status)).toBe(expected); + }); + it('reports a control-ready app without requiring a signed installation', async () => { const io = { findInstallation: vi.fn(() => null), diff --git a/src/slab/status.ts b/src/slab/status.ts index 9aba4067..f8d66edf 100644 --- a/src/slab/status.ts +++ b/src/slab/status.ts @@ -21,6 +21,10 @@ export async function inspectSlabStatus(io: SlabStatusIo = createSlabStatusIo()) } } +export function slabStatusHasHello(status: SlabSetupStatus): boolean { + return status === 'preliminary-running' || status === 'installed-running'; +} + export function createSlabStatusIo(): SlabStatusIo { const endpoint = slabControlEndpoint(homedir()); return { From ae13ec5ff30c2353f8bc0bc22178a2cb4ec1f64f Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 01:11:03 +0530 Subject: [PATCH 23/34] fix: validate reused SLAB during setup --- src/hosted/setup.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ src/hosted/setup.ts | 13 ++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index c6462efb..a94d7509 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -166,6 +166,7 @@ describe('webcmd setup', () => { it('reuses an installed SLAB app without reinstalling it', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); const installSlabMacos = vi.fn(); + const events: string[] = []; await expect(runHostedSetup({ env: { WEBCMD_CONFIG_DIR: tempDir }, @@ -174,11 +175,33 @@ describe('webcmd setup', () => { platform: 'darwin', findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), installSlabMacos, + inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, write: () => undefined, })).resolves.toBe(0); expect(installSlabMacos).not.toHaveBeenCalled(); + expect(events).toEqual(['hello', 'save']); + }); + + it('rejects an installed SLAB app that does not answer its control protocol', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-not-ready-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-30T00:00:00.000Z')), { env }); + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + inspectSlabStatus: async () => 'installed-not-running', + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); }); it('installs SLAB and probes its control protocol before persisting', async () => { @@ -289,6 +312,25 @@ describe('webcmd setup', () => { expect(messages.join('')).toContain('webcmd daemon restart'); }); + it('keeps a valid selection and prints restart guidance when daemon restart fails', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-restart-failure-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const messages: string[] = []; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local'], + isTTY: false, + resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + fetchDaemonStatus: async () => daemonStatus('custom'), + restartDaemon: async () => { throw new Error('port still busy'); }, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + expect(messages.join('')).toContain('webcmd daemon restart'); + }); + it('rejects SLAB setup off macOS before changing config', async () => { const installSlabMacos = vi.fn(); diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index c8a70eff..a639bb12 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -217,8 +217,9 @@ async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): P return { kind: 'custom', executablePath }; } if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); - if ((io.findSlabInstallation ?? defaultFindSlabInstallation)()) return browser; - await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + if (!(io.findSlabInstallation ?? defaultFindSlabInstallation)()) { + await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + } if (!slabStatusHasHello(await (io.inspectSlabStatus ?? inspectSlabStatus)())) throw new Error('SLAB did not report its control protocol after launch.'); return browser; } @@ -228,7 +229,13 @@ function defaultFindSlabInstallation(): SlabInstallation | null { } async function restartConfiguredDaemon(browser: LocalBrowserConfig, io: SetupIo): Promise { - const result = await (io.restartDaemon ?? restartDaemon)(); + let result: DaemonRestartResult; + try { + result = await (io.restartDaemon ?? restartDaemon)(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new DaemonRestartError(message); + } const expected = browser.kind === 'slab' ? 'SLAB' : browser.kind; if (!result.stopped || result.status?.runtimeName !== expected) { throw new DaemonRestartError(`Daemon restarted without the selected ${expected} runtime.`); From a1154575bd736587d0d84ea2a18435bcc73104b5 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 01:17:36 +0530 Subject: [PATCH 24/34] fix: verify and install the SLAB alpha --- src/hosted/setup.test.ts | 23 +++++++ src/hosted/setup.ts | 14 ++++- src/slab/install.test.ts | 91 ++++++++++++++++++++++++++-- src/slab/install.ts | 67 ++++++++++++++++++-- src/slab/installation.test.ts | 26 +++++++- src/slab/installation.ts | 38 ++++++++++++ tests/e2e/slab-alpha-install.test.ts | 54 +++++++++++++++++ vitest.config.ts | 1 + 8 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/slab-alpha-install.test.ts diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index a94d7509..3395b2ac 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -174,6 +174,7 @@ describe('webcmd setup', () => { isTTY: false, platform: 'darwin', findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + validateSlabInstallation: async () => true, installSlabMacos, inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, @@ -196,6 +197,7 @@ describe('webcmd setup', () => { isTTY: false, platform: 'darwin', findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + validateSlabInstallation: async () => true, inspectSlabStatus: async () => 'installed-not-running', fetchDaemonStatus: async () => null, write: () => undefined, @@ -224,6 +226,27 @@ describe('webcmd setup', () => { expect(events).toEqual(['install', 'hello', 'save']); }); + it('reinstalls SLAB when an existing app fails trust validation', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reinstall-')); + const events: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + validateSlabInstallation: async () => false, + installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, + inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, + fetchDaemonStatus: async () => null, + saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, + write: () => undefined, + })).resolves.toBe(0); + + expect(events).toEqual(['install', 'hello', 'save']); + }); + it('leaves config and daemon unchanged when SLAB installation fails', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-failure-')); const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index a639bb12..12edbe4a 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -11,7 +11,12 @@ import { writeToStream } from '../stream-write.js'; import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; -import { findSlabInstallation, type SlabInstallation } from '../slab/installation.js'; +import { + createSlabInstallationValidationIo, + findSlabInstallation, + validateSlabInstallation, + type SlabInstallation, +} from '../slab/installation.js'; import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; import { HostedClient } from './client.js'; import { @@ -46,6 +51,7 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { access?: (path: string, mode: number) => Promise; findSlabInstallation?: () => SlabInstallation | null; installSlabMacos?: () => Promise; + validateSlabInstallation?: (installation: SlabInstallation) => Promise; inspectSlabStatus?: () => Promise; fetchDaemonStatus?: () => Promise; restartDaemon?: () => Promise; @@ -217,7 +223,11 @@ async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): P return { kind: 'custom', executablePath }; } if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); - if (!(io.findSlabInstallation ?? defaultFindSlabInstallation)()) { + const installation = (io.findSlabInstallation ?? defaultFindSlabInstallation)(); + const trusted = installation + ? await (io.validateSlabInstallation ?? (candidate => validateSlabInstallation(candidate, createSlabInstallationValidationIo())))(installation) + : false; + if (!trusted) { await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); } if (!slabStatusHasHello(await (io.inspectSlabStatus ?? inspectSlabStatus)())) throw new Error('SLAB did not report its control protocol after launch.'); diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts index e9096e1b..95bb03b8 100644 --- a/src/slab/install.test.ts +++ b/src/slab/install.test.ts @@ -11,16 +11,25 @@ function fakeInstaller(options: { downloadedBytes?: Buffer; bundleId?: string; verifyManifest?: boolean; - failCommand?: 'codesign' | 'spctl'; + failCommand?: 'codesign' | 'xattr'; + xattrAbsent?: boolean; } = {}) { const operations: string[] = []; + const writes: string[] = []; const execFile = vi.fn(async (command: string, args: string[]) => { if (command === options.failCommand) throw new Error(`${command} rejected the staged app`); if (command === 'hdiutil' && args[0] === 'attach') operations.push('mount-readonly'); if (command === 'hdiutil' && args[0] === 'detach') operations.push('detach'); if (command === 'ditto') operations.push('copy-to-staging'); if (command === 'codesign') operations.push('codesign-verify'); - if (command === 'spctl') operations.push('spctl-verify'); + if (command === 'xattr') { + operations.push('clear-quarantine'); + if (options.xattrAbsent) { + const error = new Error('No such xattr: com.apple.quarantine') as Error & { stderr?: string }; + error.stderr = 'xattr: No such xattr: com.apple.quarantine'; + throw error; + } + } }); const replaceApp = vi.fn(async () => { operations.push('replace-app'); }); const access = vi.fn(async () => {}); @@ -46,9 +55,10 @@ function fakeInstaller(options: { replaceApp, verifyManifest: () => options.verifyManifest ?? true, bundleId: async () => options.bundleId ?? 'dev.webcmd.slab', + write: async message => { writes.push(message); }, operations: () => operations.filter(operation => operation !== 'cleanup'), }; - return io; + return { ...io, writes }; } describe('SLAB macOS installer', () => { @@ -74,7 +84,7 @@ describe('SLAB macOS installer', () => { expect(io.execFile).toHaveBeenCalledWith('hdiutil', expect.arrayContaining(['attach', '-readonly', '-nobrowse', '-mountpoint'])); expect(io.operations()).toEqual([ 'download', 'checksum', 'mount-readonly', 'copy-to-staging', - 'codesign-verify', 'spctl-verify', 'replace-app', 'detach', + 'codesign-verify', 'clear-quarantine', 'replace-app', 'detach', ]); expect(io.replaceApp).toHaveBeenCalledWith('/Applications/.SLAB.app.webcmd-staging', '/Applications/SLAB.app'); }); @@ -84,13 +94,84 @@ describe('SLAB macOS installer', () => { .rejects.toThrow('SLAB installer bundle identifier mismatch'); }); - it.each(['codesign', 'spctl'] as const)('does not replace the app when %s verification fails', async (failCommand) => { + it.each(['codesign', 'xattr'] as const)('does not replace the app when %s verification fails', async (failCommand) => { const io = fakeInstaller({ failCommand }); await expect(installSlabMacos(io)).rejects.toThrow(`${failCommand} rejected the staged app`); expect(io.replaceApp).not.toHaveBeenCalled(); }); + it('treats an absent quarantine attribute as harmless', async () => { + const io = fakeInstaller({ xattrAbsent: true }); + + await expect(installSlabMacos(io)).resolves.toMatchObject({ + appPath: '/Applications/SLAB.app', + }); + expect(io.replaceApp).toHaveBeenCalled(); + }); + + it('removes only the quarantine xattr after verification and before replacement', async () => { + const io = fakeInstaller(); + + await installSlabMacos(io); + + expect(io.execFile).toHaveBeenCalledWith('xattr', ['-dr', 'com.apple.quarantine', '/Applications/.SLAB.app.webcmd-staging']); + expect(io.operations()).toEqual([ + 'download', 'checksum', 'mount-readonly', 'copy-to-staging', + 'codesign-verify', 'clear-quarantine', 'replace-app', 'detach', + ]); + }); + + it('reports percent and byte progress when Content-Length is known', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: (name: string) => name.toLowerCase() === 'content-length' ? String(bytes.length) : null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 4)); + controller.enqueue(bytes.subarray(4)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.join('')).toContain('100%'); + expect(io.writes.join('')).toContain(`${bytes.length.toFixed(1)} B / ${bytes.length.toFixed(1)} B`); + }); + + it('reports downloaded bytes when Content-Length is unknown', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: () => null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 4)); + controller.enqueue(bytes.subarray(4)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.join('')).not.toContain('%'); + expect(io.writes.join('')).toContain(`${bytes.length.toFixed(1)} B`); + }); + it('checks system Applications write access before choosing its staging directory', async () => { const io = fakeInstaller(); diff --git a/src/slab/install.ts b/src/slab/install.ts index 197bae38..6af7654a 100644 --- a/src/slab/install.ts +++ b/src/slab/install.ts @@ -5,6 +5,7 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { execFile as execFileCallback } from 'node:child_process'; +import { formatBytes } from '../download/progress.js'; import { verifySlabReleaseManifest, type SlabReleaseManifest } from './release-key.js'; import type { SlabInstallation } from './installation.js'; @@ -21,7 +22,13 @@ export interface InstallSlabOptions { export interface SlabInstallerIo { homeDir: string; tempDir: string; - fetch(url: string): Promise<{ ok: boolean; json?(): Promise; arrayBuffer?(): Promise }>; + fetch(url: string): Promise<{ + ok: boolean; + json?(): Promise; + arrayBuffer?(): Promise; + body?: ReadableStream | null; + headers?: { get(name: string): string | null | undefined }; + }>; execFile(command: string, args: string[]): Promise; mkdtemp(prefix: string): Promise; writeFile(path: string, bytes: Uint8Array): Promise; @@ -32,6 +39,7 @@ export interface SlabInstallerIo { bundleId(appPath: string): Promise; replaceApp(source: string, destination: string): Promise; verifyManifest(manifest: SlabReleaseManifest): boolean | Promise; + write?(message: string): void | Promise; } export interface SlabReplacementIo { @@ -53,9 +61,55 @@ async function responseJson(response: { ok: boolean; json?(): Promise } return response.json(); } -async function responseBytes(response: { ok: boolean; arrayBuffer?(): Promise }): Promise { - if (!response.ok || !response.arrayBuffer) throw new Error('SLAB installer download failed'); - return Buffer.from(await response.arrayBuffer()); +async function writeProgress(io: SlabInstallerIo, received: number, total?: number, done: boolean = false): Promise { + if (!io.write) return; + const summary = total && total > 0 + ? `${Math.round((received / total) * 100)}% ${formatBytes(received)} / ${formatBytes(total)}` + : formatBytes(received); + await io.write(`\rDownloading SLAB DMG: ${summary}${done ? '\n' : ''}`); +} + +async function responseBytes(response: { + ok: boolean; + arrayBuffer?(): Promise; + body?: ReadableStream | null; + headers?: { get(name: string): string | null | undefined }; +}, io: SlabInstallerIo): Promise { + if (!response.ok) throw new Error('SLAB installer download failed'); + const total = Number(response.headers?.get('content-length') ?? ''); + const expectedBytes = Number.isFinite(total) && total > 0 ? total : undefined; + if (response.body) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + received += value.byteLength; + await writeProgress(io, received, expectedBytes); + } + await writeProgress(io, received, expectedBytes, true); + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))); + } + if (!response.arrayBuffer) throw new Error('SLAB installer download failed'); + const bytes = Buffer.from(await response.arrayBuffer()); + await writeProgress(io, bytes.byteLength, expectedBytes ?? bytes.byteLength, true); + return bytes; +} + +async function clearQuarantine(io: SlabInstallerIo, appPath: string): Promise { + try { + await io.execFile('xattr', ['-dr', 'com.apple.quarantine', appPath]); + } catch (error) { + const details = [ + error instanceof Error ? error.message : String(error), + typeof error === 'object' && error && 'stderr' in error ? String((error as { stderr?: unknown }).stderr ?? '') : '', + ].join('\n'); + if (details.includes('No such xattr') && details.includes('com.apple.quarantine')) return; + throw error; + } } export async function replaceSlabAppAtomically(io: SlabReplacementIo, source: string, destination: string): Promise { @@ -87,7 +141,7 @@ export async function installSlabMacos(io: SlabInstallerIo = createSlabInstaller let mounted = false; try { - const bytes = await responseBytes(await io.fetch(manifest.url)); + const bytes = await responseBytes(await io.fetch(manifest.url), io); await io.writeFile(dmgPath, bytes); const checksum = await (io.sha256?.(bytes) ?? Promise.resolve(createHash('sha256').update(bytes).digest('hex'))); if (checksum.toLowerCase() !== manifest.sha256.toLowerCase()) throw new Error('SLAB installer checksum mismatch'); @@ -109,7 +163,7 @@ export async function installSlabMacos(io: SlabInstallerIo = createSlabInstaller 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 clearQuarantine(io, stagingPath); await io.replaceApp(stagingPath, appPath); stagingPath = undefined; if (options.launchAfterInstall) await io.execFile('open', [appPath]); @@ -141,5 +195,6 @@ export function createSlabInstallerIo(): SlabInstallerIo { }, replaceApp: (source, destination) => replaceSlabAppAtomically({ rename, rm: async path => { await rm(path, { recursive: true, force: true }); } }, source, destination), verifyManifest: verifySlabReleaseManifest, + write: async message => { process.stderr.write(message); }, }; } diff --git a/src/slab/installation.test.ts b/src/slab/installation.test.ts index d48abb59..7c43d673 100644 --- a/src/slab/installation.test.ts +++ b/src/slab/installation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { findSlabInstallation, isSlabInstalled, slabControlEndpoint } from './installation.js'; +import { findSlabInstallation, isSlabInstalled, slabControlEndpoint, validateSlabInstallation } from './installation.js'; describe('SLAB installation discovery', () => { it('finds the first installed normal macOS app bundle', () => { @@ -31,4 +31,28 @@ describe('SLAB installation discovery', () => { it('uses the owner-scoped control socket path', () => { expect(slabControlEndpoint('/Users/me')).toBe('/Users/me/.slab/run/slab-bridge.sock'); }); + + it('accepts an installation only when the executable, bundle id, and code signature validate', async () => { + await expect(validateSlabInstallation({ + platform: 'darwin', + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', + }, { + access: async () => undefined, + bundleId: async () => 'dev.webcmd.slab', + execFile: async () => undefined, + })).resolves.toBe(true); + }); + + it('rejects an installation with the wrong bundle id', async () => { + await expect(validateSlabInstallation({ + platform: 'darwin', + appPath: '/Applications/SLAB.app', + executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', + }, { + access: async () => undefined, + bundleId: async () => 'com.example.other', + execFile: async () => undefined, + })).resolves.toBe(false); + }); }); diff --git a/src/slab/installation.ts b/src/slab/installation.ts index f0885d4c..37420515 100644 --- a/src/slab/installation.ts +++ b/src/slab/installation.ts @@ -1,4 +1,8 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access } from 'node:fs/promises'; import { join } from 'node:path'; +import { promisify } from 'node:util'; export interface SlabInstallation { platform: NodeJS.Platform; @@ -13,6 +17,15 @@ export interface SlabInstallationIo { existsSync(path: string): boolean; } +export interface SlabInstallationValidationIo { + access(path: string, mode: number): Promise; + bundleId(appPath: string): Promise; + execFile(command: string, args: string[]): Promise; +} + +const execFile = promisify(execFileCallback); +const SLAB_BUNDLE_ID = 'dev.webcmd.slab'; + export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | null { if (io.platform !== 'darwin') return null; @@ -34,3 +47,28 @@ export function isSlabInstalled(io: SlabInstallationIo): boolean { export function slabControlEndpoint(homeDir: string): string { return join(homeDir, '.slab', 'run', 'slab-bridge.sock'); } + +export async function validateSlabInstallation( + installation: SlabInstallation, + io: SlabInstallationValidationIo = createSlabInstallationValidationIo(), +): Promise { + try { + await io.access(installation.executablePath, constants.X_OK); + if (await io.bundleId(installation.appPath) !== SLAB_BUNDLE_ID) return false; + await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, installation.appPath]); + return true; + } catch { + return false; + } +} + +export function createSlabInstallationValidationIo(): SlabInstallationValidationIo { + return { + access, + bundleId: async appPath => { + const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist')]); + return result.stdout.trim(); + }, + execFile: async (command, args) => execFile(command, args), + }; +} diff --git a/tests/e2e/slab-alpha-install.test.ts b/tests/e2e/slab-alpha-install.test.ts new file mode 100644 index 00000000..31091f98 --- /dev/null +++ b/tests/e2e/slab-alpha-install.test.ts @@ -0,0 +1,54 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { constants } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SlabBridgeClient } from '../../src/slab/bridge-client.js'; +import { createSlabInstallerIo, installSlabMacos } from '../../src/slab/install.js'; +import { findSlabInstallation, slabControlEndpoint } from '../../src/slab/installation.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); +}); + +describe.skipIf(process.env.WEBCMD_LIVE_SLAB_ALPHA !== '1')('SLAB alpha installer live gate', () => { + it('installs from the explicit alpha manifest into an isolated home and answers hello', async () => { + const manifestUrl = process.env.WEBCMD_SLAB_ALPHA_MANIFEST_URL; + if (!manifestUrl) throw new Error('WEBCMD_SLAB_ALPHA_MANIFEST_URL is required when WEBCMD_LIVE_SLAB_ALPHA=1'); + if (findSlabInstallation({ platform: 'darwin', homeDir: homedir(), existsSync })) { + throw new Error('Refusing to run live alpha installer against an existing daily SLAB app'); + } + + const testHome = await mkdtemp(join(tmpdir(), 'webcmd-slab-alpha-home-')); + tempDirs.push(testHome); + + const installation = await installSlabMacos({ + ...createSlabInstallerIo(), + homeDir: testHome, + access: async (path, mode) => { + if (path === '/Applications' && mode === constants.W_OK) { + const error = new Error('permission denied') as Error & { code?: string }; + error.code = 'EACCES'; + throw error; + } + await access(path, mode); + }, + }, { + manifestUrl, + launchAfterInstall: true, + }); + + expect(installation.appPath).toBe(join(testHome, 'Applications', 'SLAB.app')); + + const client = await SlabBridgeClient.connect(slabControlEndpoint(testHome), { timeoutMs: 5_000 }); + try { + const hello = await client.hello(); + expect(hello.protocolVersion).toBe(1); + } finally { + await client.close(); + } + }, 180_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9a925eba..1e4a2f1b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ 'tests/e2e/plugin-management.test.ts', 'tests/e2e/adapter-authoring-parity.test.ts', 'tests/e2e/article-download-pipeline.test.ts', + 'tests/e2e/slab-alpha-install.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/cloak-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts', From f2b5c51816a909e7d2f69f3dfaea2ef21953f0a0 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 01:29:31 +0530 Subject: [PATCH 25/34] docs: explain local browser selection --- .../task-6-report.md | 27 ++++ PRIVACY.md | 4 +- TESTING.md | 15 ++ docs/cli-reference.mdx | 6 +- docs/troubleshooting.mdx | 13 +- src/doctor.test.ts | 133 +++++++++++++++++- src/doctor.ts | 60 ++++++-- src/hosted/setup.test.ts | 1 + src/hosted/setup.ts | 2 +- 9 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 .superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md diff --git a/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md b/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md new file mode 100644 index 00000000..8d43fb86 --- /dev/null +++ b/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md @@ -0,0 +1,27 @@ +# Task 6 Report + +Date: 2026-09-01 + +Summary: +- Updated `doctor` to read the configured local browser, report it explicitly, scope Cloak-only binary checks to Cloak/custom, and avoid Cloak wording for SLAB. +- Updated `setup --help` and docs to keep `webcmd setup --mode local --browser cloak|slab|/absolute/path` as the user-facing source of truth. +- Removed user-facing `WEBCMD_BROWSER_BINARY_PATH` setup guidance from troubleshooting docs. + +Files changed: +- `src/doctor.ts` +- `src/doctor.test.ts` +- `src/hosted/setup.ts` +- `src/hosted/setup.test.ts` +- `docs/cli-reference.mdx` +- `docs/troubleshooting.mdx` +- `PRIVACY.md` +- `TESTING.md` + +Verification: +- `npm --prefix ../webcmd/.worktrees/custom-browser-binary-path test -- --run src/doctor.test.ts src/hosted/setup.test.ts` +- `npm --prefix ../webcmd/.worktrees/custom-browser-binary-path run typecheck` +- `git -C ../webcmd/.worktrees/custom-browser-binary-path diff --check` + +Notes: +- `NOTICE` was checked and left unchanged. +- No push performed. diff --git a/PRIVACY.md b/PRIVACY.md index 3185540d..b0902170 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,9 +1,11 @@ # webcmd Privacy +Local mode keeps Cloak as the bundled default browser. SLAB is a macOS alpha opt-in selected with `webcmd setup --mode local --browser slab`, and a compatible local Chromium fork can be selected with `webcmd setup --mode local --browser /absolute/path/to/browser`. + The SLAB browser communicates with webcmd through owner-scoped local IPC. webcmd does not expose a raw TCP debugging endpoint. The 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. -Trace artifacts, cache files, plugins, user adapters, and site memory are stored under `~/.webcmd`. +Trace artifacts, cache files, plugins, user adapters, and site memory are stored under `~/.webcmd`. Custom browser selections keep their own local profile directories and do not overwrite the managed Cloak profiles. For attribution and license information, see `LICENSE` and `NOTICE`. diff --git a/TESTING.md b/TESTING.md index 0a4b910b..8f305342 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,6 +11,10 @@ npm test The core package contains no site adapters; `npm test` runs the unit project. Public adapter tests live in `agentrhq/webcmd-plugins`. +Local browser coverage stays split on purpose: Cloak remains the default and +bundled runtime, SLAB is the macOS alpha opt-in path, and custom absolute +executables reuse the Cloak-compatible runtime with separate profile data. + ## Skill Sources Bundled skills are generated from `skill-src/` with litprompt. After editing a @@ -38,3 +42,14 @@ npx vitest run --project unit src/slab src/browser/runtime/local-slab ``` These tests use the local SLAB control contract and do not download or launch a browser. + +## Browser Selection Checks + +Run: + +```bash +npx vitest run --project unit src/doctor.test.ts src/hosted/setup.test.ts +``` + +These checks cover bundled Cloak fallback, explicit Cloak, custom absolute +browser paths, `setup --status`, and doctor output for the selected browser. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index d492a2cb..5083ebf8 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -70,7 +70,7 @@ webcmd --profile work \ webcmd --profile work session close work-project-k7 ``` -Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs @@ -140,7 +140,7 @@ Ordinary `curl` is neither required nor automatically authenticated. | --- | --- | | `list` | Show registered core, legacy user, plugin, and external commands. | | `setup` | Choose local or hosted mode interactively. | -| `doctor` | Diagnose browser bridge and daemon connectivity. | +| `doctor` | Diagnose the selected local browser runtime and daemon connectivity. | | `daemon` | Manage the local Webcmd daemon: status, stop, and restart. | | `artifact` | Download a hosted execution artifact to `--output`. | | `browser` | Agent-facing browser runtime for exploration and verification. | @@ -185,6 +185,8 @@ webcmd profile list -f json Each keeps its human-readable report as the `table` rendering, which stays the default. Pass another format to get the underlying result object instead — the validation report for `validate`, the verify report for `verify`, the diagnostic report for `doctor`, and a row set for `profile list`. +`webcmd setup --status` returns the configured local `browser` selection in JSON. `webcmd doctor` reports the live `Runtime` plus a `Selected browser` line so you can tell whether local mode is using bundled Cloak, macOS-alpha SLAB, or a custom absolute executable path. + `daemon status -f json` returns `{ "running": false }` when no daemon is reachable, and otherwise reports `running`, `stale`, `pid`, `version`, `uptimeMs`, `runtimeConnected`, `profiles`, `memoryMB`, and `port`. `profile list` returns one row per profile with `contextId`, `alias`, `default`, `connected`, and `runtimeVersion`, covering both connected profiles and saved aliases that are not currently connected. If the daemon is unreachable or stale, `profile list -f json`/`-f yaml` fails with a `DAEMON_UNAVAILABLE` error (exit 1) and a restart hint instead of returning `[]` — an empty list and an unreadable runtime are different facts. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 35e5a51c..fdb009fc 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -7,6 +7,8 @@ description: Prompt-based fixes for common Webcmd install, profile, auth, plugin Most troubleshooting should be done by an agent. Give the symptom, the command, the expected output, and permission to diagnose. +For local browser selection, use `webcmd setup --mode local --browser cloak`, `webcmd setup --mode local --browser slab`, or `webcmd setup --mode local --browser /absolute/path/to/browser`. Cloak remains the bundled default; SLAB is the macOS alpha opt-in. + ## Basic Diagnosis ```text @@ -138,13 +140,14 @@ Useful environment variables: | `WEBCMD_WINDOW` | Optional `foreground` or `background` override; browser commands default to `background`. | | `WEBCMD_BROWSER_CONNECT_TIMEOUT` | Seconds to wait for the browser bridge. | | `WEBCMD_BROWSER_COMMAND_TIMEOUT` | Seconds to wait for one browser command. | -| `WEBCMD_BROWSER_BINARY_PATH` | Executable path for a compatible local Chromium fork. Without it, Webcmd uses managed Cloak as usual. | | `WEBCMD_CDP_ENDPOINT` | Manual CDP endpoint for remote browsers or Electron apps. | | `WEBCMD_CDP_TARGET` | Filter CDP targets by URL substring. | | `WEBCMD_CACHE_DIR` | Browser state and network cache directory. | | `WEBCMD_VERBOSE` | Enable verbose logs. | -After changing `WEBCMD_BROWSER_BINARY_PATH`, restart the daemon and run -`webcmd doctor`. Custom browser builds use their own profile directory under -`~/.webcmd//profiles`, so their cookies and browser state do not modify -the managed Cloak profiles in `~/.webcmd/cloak/profiles`. +After changing the local browser with `webcmd setup --mode local --browser ...`, +restart the daemon and run `webcmd doctor`. `webcmd doctor` reports the live +runtime plus a `Selected browser` line, and `webcmd setup --status` returns the +configured `browser` object. Custom browser builds use their own profile +directory under `~/.webcmd//profiles`, so their cookies and browser +state do not modify the managed Cloak profiles in `~/.webcmd/cloak/profiles`. diff --git a/src/doctor.test.ts b/src/doctor.test.ts index f976562f..81d04ce0 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -3,6 +3,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EXIT_CODES } from './errors.js'; +import { getConfigPath, makeLocalConfig, saveWebcmdConfig } from './hosted/config.js'; const { mockGetDaemonHealth, @@ -66,6 +67,13 @@ fs.writeFileSync(managedBinaryPath, '#!/bin/sh\n'); if (process.platform !== 'win32') fs.chmodSync(managedBinaryPath, 0o755); afterAll(() => fs.rmSync(managedBinaryDir, { recursive: true, force: true })); +function writeLocalConfig(browser?: Parameters[1]): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), browser), { env: { WEBCMD_CONFIG_DIR: configDir } }); + return configDir; +} + describe('doctor report rendering', () => { const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); const isolatedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-render-')); @@ -195,6 +203,42 @@ describe('doctor report rendering', () => { expect(text).toContain('[MISSING] Browser binary: not installed (/Applications/Cloak Chromium.app)'); }); + it('renders the selected browser as bundled Cloak by default', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'Cloak', + selectedBrowser: { kind: 'cloak' }, + issues: [], + })); + + expect(text).toContain('[OK] Selected browser: Cloak (bundled default)'); + }); + + it('renders the selected browser as a custom executable path', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'custom', + selectedBrowser: { kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' }, + issues: [], + })); + + expect(text).toContain('[OK] Selected browser: custom (/Applications/Brave Browser.app/Contents/MacOS/Brave Browser)'); + }); + + it('renders the selected browser as SLAB alpha', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'SLAB', + selectedBrowser: { kind: 'slab' }, + issues: [], + })); + + expect(text).toContain('[OK] Selected browser: SLAB (macOS alpha opt-in)'); + }); + it('renders connectivity OK when live test succeeds', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, @@ -286,7 +330,7 @@ describe('doctor report rendering', () => { const report = await runBrowserDoctor(); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Default Cloak profile is not active: work (profile-default)'), + expect.stringContaining('Default browser profile is not active: work (profile-default)'), ])); expect(report.issues.join('\n')).toContain('fall back to the only active profile: active-profile'); } finally { @@ -295,6 +339,91 @@ describe('doctor report rendering', () => { } }); + it('defaults an old local config to bundled Cloak', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-legacy-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + fs.writeFileSync( + getConfigPath({ env: { WEBCMD_CONFIG_DIR: configDir } }), + JSON.stringify({ mode: 'local', updatedAt: '2026-08-31T00:00:00.000Z' }), + ); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'Cloak' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ kind: 'cloak' }); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('uses the configured custom executable for doctor checks', async () => { + const configDir = writeLocalConfig({ + kind: 'custom', + executablePath: managedBinaryPath, + }); + mockEnsureBinary.mockImplementationOnce(async () => { + expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe(managedBinaryPath); + return managedBinaryPath; + }); + mockBinaryInfo.mockImplementationOnce(() => ({ + version: '1.0.0', + bundledVersion: '1.0.0', + tier: 'free', + platform: 'linux-x64', + binaryPath: process.env.CLOAKBROWSER_BINARY_PATH ?? managedBinaryPath, + installed: true, + cacheDir: managedBinaryDir, + downloadUrl: 'https://example.test/download', + })); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'custom' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ + kind: 'custom', + executablePath: managedBinaryPath, + }); + expect(report.binary).toMatchObject({ + installed: true, + path: managedBinaryPath, + }); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('checks only the configured SLAB runtime', async () => { + const configDir = writeLocalConfig({ kind: 'slab' }); + mockConnect.mockRejectedValueOnce(new Error('slab runtime unavailable')); + mockGetDaemonHealth.mockResolvedValueOnce({ + state: 'no-runtime', + status: { runtimeConnected: false, runtimeName: 'SLAB' }, + }); + + try { + const report = await runBrowserDoctor(); + const text = strip(renderBrowserDoctorReport(report)); + const issues = report.issues.join('\n'); + + expect(report.selectedBrowser).toEqual({ kind: 'slab' }); + expect(report.binary).toBeUndefined(); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); + expect(text).toContain('[OK] Selected browser: SLAB (macOS alpha opt-in)'); + expect(text).not.toContain('Browser binary:'); + expect(issues).toContain('SLAB runtime is not connected'); + expect(issues).not.toContain('Cloak runtime is not connected'); + expect(issues).not.toContain('Chrome/Chromium'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('reports flapping when live check succeeds but final status shows runtime disconnected', async () => { mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false, runtimeName: 'Cloak' } }); @@ -538,7 +667,7 @@ describe('doctor report rendering', () => { expect(report.profiles).toHaveLength(2); expect(report.issues).toEqual(expect.arrayContaining([ - expect.stringContaining('Multiple Chrome profiles are connected'), + expect.stringContaining('Multiple browser profiles are connected'), ])); }); diff --git a/src/doctor.ts b/src/doctor.ts index 6ef767c9..8f46a329 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -17,6 +17,8 @@ import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; +import { configureCloakBrowserBinary } from './browser/browser-binary.js'; +import { loadWebcmdConfig, type LocalBrowserConfig } from './hosted/config.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -48,6 +50,7 @@ export type DoctorReport = { runtimeFlaky?: boolean; runtimeName?: string; runtimeVersion?: string; + selectedBrowser?: LocalBrowserConfig; binary?: BrowserBinaryStatus; connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; @@ -100,13 +103,16 @@ export function checkBrowserBinary(): BrowserBinaryStatus { /** * Test connectivity by attempting a real browser command. */ -export async function checkConnectivity(opts?: { timeout?: number }): Promise { +export async function checkConnectivity( + browser: LocalBrowserConfig = { kind: 'cloak' }, + opts?: { timeout?: number }, +): Promise { const start = Date.now(); const timeoutSeconds = opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS; let sessionId: string | undefined; try { // A first-use download can exceed doctor's deliberately short live-probe deadline. - await ensureBinary(); + if (browser.kind !== 'slab') await ensureBinary(); setDaemonCommandTimeoutSeconds(timeoutSeconds); const session = await sendCommand('session-create', { sessionName: 'Doctor Probe' }) as { id?: unknown }; if (typeof session.id !== 'string') throw new Error('Doctor could not create a browser Session.'); @@ -140,11 +146,14 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { + const config = loadWebcmdConfig(); + const selectedBrowser = config.mode === 'local' ? config.browser : { kind: 'cloak' } satisfies LocalBrowserConfig; + configureCloakBrowserBinary(selectedBrowser.kind === 'custom' ? selectedBrowser.executablePath : undefined); // Live connectivity check is the core of doctor — it doubles as auto-start // (bridge.connect spawns daemon) and validates // end-to-end browser bridge health. - const connectivity = await checkConnectivity(); - const binary = checkBrowserBinary(); + const connectivity = await checkConnectivity(selectedBrowser); + const binary = selectedBrowser.kind === 'slab' ? undefined : checkBrowserBinary(); // Single status read *after* connectivity side-effects settle. const health = await getDaemonHealth(); @@ -157,19 +166,26 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise, or pass --profile .', ); } else if (health.state === 'profile-disconnected') { issues.push( `Selected browser profile is not connected: ${health.status?.contextId ?? 'unknown'}.\n` + - ' Open that Chrome profile and make sure Cloak is enabled.', + (selectedBrowser.kind === 'slab' + ? ' Open SLAB and reconnect that profile.' + : selectedBrowser.kind === 'custom' + ? ' Open that browser profile and make sure the selected browser is running.' + : ' Open that Chrome profile and make sure Cloak is enabled.'), ); } else { issues.push( - 'Daemon is running but the Cloak runtime is not connected.\n' + - ' Make sure Chrome/Chromium is open and Cloak is enabled.\n' + + `Daemon is running but the ${expectedRuntimeLabel} runtime is not connected.\n` + + (selectedBrowser.kind === 'slab' + ? ' Make sure SLAB is open.\n' + : selectedBrowser.kind === 'custom' + ? ' Make sure the selected browser executable can launch and the browser is open.\n' + : ' Make sure Chrome/Chromium is open and Cloak is enabled.\n') + ' If Chrome is already open, try: webcmd daemon restart', ); } @@ -219,7 +243,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise.', ); @@ -238,6 +262,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { })).resolves.toBe(0); expect(messages.join('')).toContain('--browser '); + expect(messages.join('')).toContain('Cloak stays default, SLAB is macOS alpha opt-in'); }); it('reports the configured custom browser without probing SLAB', async () => { diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 12edbe4a..753fffef 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -68,7 +68,7 @@ const SETUP_HELP = [ 'Configure local or hosted mode.', '', ' --mode Required when stdin is not a TTY', - ' --browser Local browser in local mode', + ' --browser Local browser in local mode; Cloak stays default, SLAB is macOS alpha opt-in', ' --api-key Required for --mode hosted when stdin is not a TTY', ' --status Show the configured mode and local browser', ' -h, --help', From eb5c4f97da40abd7337a9cfdf4a32274144c9131 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 01:32:28 +0530 Subject: [PATCH 26/34] fix: report selected browser in doctor --- src/doctor.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/doctor.ts | 22 ++++++++++++++-------- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 81d04ce0..00ed54fa 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -239,6 +239,24 @@ describe('doctor report rendering', () => { expect(text).toContain('[OK] Selected browser: SLAB (macOS alpha opt-in)'); }); + it('uses the selected browser label when runtime name is missing', () => { + const slab = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: false, + selectedBrowser: { kind: 'slab' }, + issues: [], + })); + const custom = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: false, + selectedBrowser: { kind: 'custom', executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' }, + issues: [], + })); + + expect(slab).toContain('[MISSING] Runtime: SLAB not connected'); + expect(custom).toContain('[MISSING] Runtime: custom not connected'); + }); + it('renders connectivity OK when live test succeeds', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, @@ -437,6 +455,25 @@ describe('doctor report rendering', () => { ])); }); + it('uses SLAB wording when the selected SLAB runtime flaps', async () => { + const configDir = writeLocalConfig({ kind: 'slab' }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false } }); + + try { + const report = await runBrowserDoctor(); + const issues = report.issues.join('\n'); + const text = strip(renderBrowserDoctorReport(report)); + + expect(report.runtimeFlaky).toBe(true); + expect(issues).toContain('SLAB runtime connection is unstable'); + expect(issues).not.toContain('Cloak runtime connection is unstable'); + expect(text).toContain('[WARN] Runtime: SLAB unstable'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('uses Cloak readiness hints when the runtime is disconnected', async () => { mockConnect.mockRejectedValueOnce(new Error('runtime unavailable')); mockGetDaemonHealth.mockResolvedValueOnce({ diff --git a/src/doctor.ts b/src/doctor.ts index 8f46a329..b000a6ca 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -166,11 +166,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Tue, 1 Sep 2026 01:33:44 +0530 Subject: [PATCH 27/34] test: cover custom doctor runtime flaps --- src/doctor.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 00ed54fa..2ca2b8dc 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -474,6 +474,25 @@ describe('doctor report rendering', () => { } }); + it('uses custom wording when the selected custom runtime flaps', async () => { + const configDir = writeLocalConfig({ kind: 'custom', executablePath: managedBinaryPath }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false } }); + + try { + const report = await runBrowserDoctor(); + const issues = report.issues.join('\n'); + const text = strip(renderBrowserDoctorReport(report)); + + expect(report.runtimeFlaky).toBe(true); + expect(issues).toContain('custom runtime connection is unstable'); + expect(issues).not.toContain('Cloak runtime connection is unstable'); + expect(text).toContain('[WARN] Runtime: custom unstable'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('uses Cloak readiness hints when the runtime is disconnected', async () => { mockConnect.mockRejectedValueOnce(new Error('runtime unavailable')); mockGetDaemonHealth.mockResolvedValueOnce({ From b1f0c51b3db7cfb3e849a71683f456e1bcd353fa Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 02:09:22 +0530 Subject: [PATCH 28/34] fix: address browser runtime rollout review --- .../task-6-report.md | 27 ----------- src/browser.test.ts | 46 ++++++++++++++++++- src/browser/daemon-lifecycle.ts | 22 +++++++++ .../runtime/local-slab/attachment.test.ts | 19 ++++++-- src/browser/runtime/local-slab/attachment.ts | 8 +++- src/doctor.test.ts | 16 +------ src/doctor.ts | 12 +++-- src/hosted/setup.test.ts | 31 ++++++------- src/hosted/setup.ts | 34 ++++++-------- src/slab/control-bridge.test.ts | 16 +++++++ src/slab/control-bridge.ts | 2 + src/slab/installation.test.ts | 25 +--------- src/slab/installation.ts | 38 --------------- 13 files changed, 148 insertions(+), 148 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md diff --git a/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md b/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md deleted file mode 100644 index 8d43fb86..00000000 --- a/.superpowers/sdd/2026-08-31-slab-macos-first-alpha-03-webcmd-gradual-rollout/task-6-report.md +++ /dev/null @@ -1,27 +0,0 @@ -# Task 6 Report - -Date: 2026-09-01 - -Summary: -- Updated `doctor` to read the configured local browser, report it explicitly, scope Cloak-only binary checks to Cloak/custom, and avoid Cloak wording for SLAB. -- Updated `setup --help` and docs to keep `webcmd setup --mode local --browser cloak|slab|/absolute/path` as the user-facing source of truth. -- Removed user-facing `WEBCMD_BROWSER_BINARY_PATH` setup guidance from troubleshooting docs. - -Files changed: -- `src/doctor.ts` -- `src/doctor.test.ts` -- `src/hosted/setup.ts` -- `src/hosted/setup.test.ts` -- `docs/cli-reference.mdx` -- `docs/troubleshooting.mdx` -- `PRIVACY.md` -- `TESTING.md` - -Verification: -- `npm --prefix ../webcmd/.worktrees/custom-browser-binary-path test -- --run src/doctor.test.ts src/hosted/setup.test.ts` -- `npm --prefix ../webcmd/.worktrees/custom-browser-binary-path run typecheck` -- `git -C ../webcmd/.worktrees/custom-browser-binary-path diff --check` - -Notes: -- `NOTICE` was checked and left unchanged. -- No push performed. diff --git a/src/browser.test.ts b/src/browser.test.ts index 7b2549f3..bbf8c23e 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { afterEach, describe, it, expect, vi } from 'vitest'; import { BrowserBridge, generateStealthJs } from './browser/index.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js'; @@ -6,11 +9,20 @@ import { __test__ as cdpTest } from './browser/cdp.js'; import { classifyBrowserError } from './browser/errors.js'; import * as daemonTransport from './browser/daemon-transport.js'; import * as daemonLifecycle from './browser/daemon-lifecycle.js'; +import { makeLocalConfig, saveWebcmdConfig } from './hosted/config.js'; afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); +function useBrowserConfig(browser: Parameters[1]): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-config-')); + vi.stubEnv('WEBCMD_CONFIG_DIR', configDir); + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z'), browser), { env: { WEBCMD_CONFIG_DIR: configDir } }); + return configDir; +} + describe('browser helpers', () => { it('extracts tab entries from string snapshots', () => { const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension'); @@ -150,6 +162,7 @@ describe('BrowserBridge state', () => { }); it('fails fast when daemon is running but runtime is disconnected (same version)', async () => { + const configDir = useBrowserConfig({ kind: 'cloak' }); const { PKG_VERSION } = await import('./version.js'); vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({ state: 'no-runtime', @@ -168,7 +181,38 @@ describe('BrowserBridge state', () => { const bridge = new BrowserBridge(); - await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready'); + try { + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready'); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('lets selected SLAB commands reach dispatch when the daemon is running but SLAB is not attached yet', async () => { + const configDir = useBrowserConfig({ kind: 'slab' }); + const { PKG_VERSION } = await import('./version.js'); + vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({ + state: 'no-runtime', + status: { + ok: true, + pid: 999999, + uptime: 0, + daemonVersion: PKG_VERSION, + runtimeConnected: false, + runtimeName: 'SLAB', + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + + const bridge = new BrowserBridge(); + + try { + await expect(bridge.connect({ timeout: 0.1, session: 's1' })).resolves.toBeDefined(); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } }); it('attempts stale daemon replacement when daemonVersion is missing', async () => { diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 4494d9a2..7edfdb0b 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -6,6 +6,7 @@ import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; import { PKG_VERSION } from '../version.js'; import { isVerbose } from '../logger.js'; +import { loadWebcmdConfig } from '../hosted/config.js'; import { waitForBridgeReady } from './bridge-readiness.js'; import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type DaemonHealth, type DaemonStatus } from './daemon-transport.js'; @@ -127,6 +128,7 @@ export async function ensureBrowserBridgeReady( const health = await getDaemonHealth({ contextId }); const daemonVersion = health.status?.daemonVersion; const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION); + const selectedSlab = selectedLocalBrowserKind() === 'slab'; let staleDaemonReplaced = false; let spawnedProcess: ChildProcess | null = null; @@ -171,6 +173,10 @@ export async function ensureBrowserBridgeReady( throw browserConnectErrorFromHealth(health, contextId); } + if (!staleDaemonReplaced && selectedSlab && health.state !== 'stopped') { + return { health, spawnedProcess }; + } + if (staleDaemonReplaced || health.state === 'stopped') { if (verbose && (isVerbose() || process.stderr.isTTY)) { process.stderr.write('⏳ Starting daemon...\n'); @@ -181,11 +187,27 @@ export async function ensureBrowserBridgeReady( process.stderr.write(' Make sure Chrome/Chromium is open and Cloak is enabled.\n'); } + if (selectedSlab) { + const status = await waitForDaemonStatus(timeoutMs); + if (status) { + const finalHealth = await getDaemonHealth({ contextId }); + if (finalHealth.state !== 'profile-required' && finalHealth.state !== 'stopped') { + return { health: finalHealth, spawnedProcess }; + } + throw browserConnectErrorFromHealth(finalHealth, contextId); + } + } + const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); if (finalHealth.state === 'ready') return { health: finalHealth, spawnedProcess }; throw browserConnectErrorFromHealth(finalHealth, contextId); } +function selectedLocalBrowserKind(): 'cloak' | 'slab' | 'custom' { + const config = loadWebcmdConfig(); + return config.mode === 'local' ? config.browser.kind : 'cloak'; +} + function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string): BrowserConnectError { if (health.state === 'profile-required') { return new BrowserConnectError( diff --git a/src/browser/runtime/local-slab/attachment.test.ts b/src/browser/runtime/local-slab/attachment.test.ts index 1d4aabd4..3a818917 100644 --- a/src/browser/runtime/local-slab/attachment.test.ts +++ b/src/browser/runtime/local-slab/attachment.test.ts @@ -25,7 +25,7 @@ describe('attachSlabProfile', () => { const cdpTransport = transport(); const context = {} as any; const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; - const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; const connectTransport = vi.fn().mockResolvedValue(cdpTransport); const connectOverCDP = vi.fn().mockResolvedValue(browser); @@ -46,6 +46,7 @@ describe('attachSlabProfile', () => { const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), }; const connectBridge = vi.fn().mockResolvedValue(bridge); const context = {} as BrowserContext; @@ -64,7 +65,7 @@ describe('attachSlabProfile', () => { it('closes the transport and releases the lease when Playwright setup fails', async () => { const lease = attachment(); const cdpTransport = transport(); - const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; const connectTransport = vi.fn().mockResolvedValue(cdpTransport); const connectOverCDP = vi.fn().mockRejectedValue(new Error('Playwright refused the connection')); @@ -73,12 +74,24 @@ describe('attachSlabProfile', () => { expect(bridge.release).toHaveBeenCalledWith('connection-1'); }); + it('closes the bridge when native attach fails before a lease exists', async () => { + const bridge = { + attach: vi.fn().mockRejectedValue(new Error('profile missing')), + release: vi.fn().mockResolvedValue(null), + close: vi.fn().mockResolvedValue(null), + }; + + await expect(attachSlabProfile('default', { bridge })).rejects.toThrow('profile missing'); + expect(bridge.close).toHaveBeenCalledOnce(); + expect(bridge.release).not.toHaveBeenCalled(); + }); + it('closes its local transport before releasing the native lease', async () => { const lease = attachment(); const cdpTransport = transport(); const context = {} as any; const browser = { contexts: vi.fn(() => [context]), version: vi.fn(() => '152.0') } as any; - const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null) }; + const bridge = { attach: vi.fn().mockResolvedValue(lease), release: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(null) }; const result = await attachSlabProfile('default', { bridge, connectTransport: vi.fn().mockResolvedValue(cdpTransport), diff --git a/src/browser/runtime/local-slab/attachment.ts b/src/browser/runtime/local-slab/attachment.ts index 9122a142..764af2b9 100644 --- a/src/browser/runtime/local-slab/attachment.ts +++ b/src/browser/runtime/local-slab/attachment.ts @@ -26,7 +26,13 @@ export interface AttachSlabProfileOptions { export async function attachSlabProfile(profileId: string, options: AttachSlabProfileOptions = {}): Promise { const bridge = options.bridge ?? await (options.connectBridge ?? connectSlabControlBridge)(); - const attachment = await bridge.attach(profileId); + let attachment: SlabAttachResult; + try { + attachment = await bridge.attach(profileId); + } catch (error) { + await bridge.close().catch(() => {}); + throw error; + } const attachTimeoutMs = options.attachTimeoutMs ?? 30_000; let transport: ConnectOverCDPTransport | undefined; try { diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 2ca2b8dc..19f53599 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -381,20 +381,6 @@ describe('doctor report rendering', () => { kind: 'custom', executablePath: managedBinaryPath, }); - mockEnsureBinary.mockImplementationOnce(async () => { - expect(process.env.CLOAKBROWSER_BINARY_PATH).toBe(managedBinaryPath); - return managedBinaryPath; - }); - mockBinaryInfo.mockImplementationOnce(() => ({ - version: '1.0.0', - bundledVersion: '1.0.0', - tier: 'free', - platform: 'linux-x64', - binaryPath: process.env.CLOAKBROWSER_BINARY_PATH ?? managedBinaryPath, - installed: true, - cacheDir: managedBinaryDir, - downloadUrl: 'https://example.test/download', - })); mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'custom' } }); try { @@ -408,6 +394,8 @@ describe('doctor report rendering', () => { installed: true, path: managedBinaryPath, }); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); } finally { vi.unstubAllEnvs(); fs.rmSync(configDir, { recursive: true, force: true }); diff --git a/src/doctor.ts b/src/doctor.ts index b000a6ca..274f7f2f 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -87,7 +87,13 @@ function isLaunchableFile(binaryPath: string): boolean { * healthy — it says nothing about whether the browser binary CloakBrowser * needs to launch is present on disk. */ -export function checkBrowserBinary(): BrowserBinaryStatus { +export function checkBrowserBinary(browser: LocalBrowserConfig = { kind: 'cloak' }): BrowserBinaryStatus { + if (browser.kind === 'custom') { + return { + installed: isLaunchableFile(browser.executablePath), + path: browser.executablePath, + }; + } try { const info = binaryInfo(); return { @@ -112,7 +118,7 @@ export async function checkConnectivity( let sessionId: string | undefined; try { // A first-use download can exceed doctor's deliberately short live-probe deadline. - if (browser.kind !== 'slab') await ensureBinary(); + if (browser.kind === 'cloak') await ensureBinary(); setDaemonCommandTimeoutSeconds(timeoutSeconds); const session = await sendCommand('session-create', { sessionName: 'Doctor Probe' }) as { id?: unknown }; if (typeof session.id !== 'string') throw new Error('Doctor could not create a browser Session.'); @@ -153,7 +159,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { }); }); - it('reuses an installed SLAB app without reinstalling it', async () => { + it('reinstalls an existing SLAB app through the signed installer path', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); - const installSlabMacos = vi.fn(); const events: string[] = []; await expect(runHostedSetup({ @@ -173,20 +173,17 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), - validateSlabInstallation: async () => true, - installSlabMacos, + installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, write: () => undefined, })).resolves.toBe(0); - expect(installSlabMacos).not.toHaveBeenCalled(); - expect(events).toEqual(['hello', 'save']); + expect(events).toEqual(['install', 'hello', 'save']); }); - it('rejects an installed SLAB app that does not answer its control protocol', async () => { + it('rejects SLAB when it does not answer its control protocol after install', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-not-ready-')); const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; saveWebcmdConfig(makeLocalConfig(new Date('2026-08-30T00:00:00.000Z')), { env }); @@ -196,9 +193,9 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), - validateSlabInstallation: async () => true, + installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), inspectSlabStatus: async () => 'installed-not-running', + wait: async () => undefined, fetchDaemonStatus: async () => null, write: () => undefined, })).resolves.toBe(1); @@ -215,7 +212,6 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - findSlabInstallation: () => null, installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, @@ -226,25 +222,25 @@ describe('webcmd setup', () => { expect(events).toEqual(['install', 'hello', 'save']); }); - it('reinstalls SLAB when an existing app fails trust validation', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reinstall-')); + it('polls for SLAB control readiness before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-ready-poll-')); const events: string[] = []; + const statuses: SlabSetupStatus[] = ['installed-not-running', 'installed-running']; await expect(runHostedSetup({ env: { WEBCMD_CONFIG_DIR: tempDir }, argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - findSlabInstallation: () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), - validateSlabInstallation: async () => false, installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, - inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, + inspectSlabStatus: async () => { events.push('hello'); return statuses.shift() ?? 'installed-running'; }, + wait: async () => { events.push('wait'); }, fetchDaemonStatus: async () => null, saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, write: () => undefined, })).resolves.toBe(0); - expect(events).toEqual(['install', 'hello', 'save']); + expect(events).toEqual(['install', 'hello', 'wait', 'hello', 'save']); }); it('leaves config and daemon unchanged when SLAB installation fails', async () => { @@ -258,7 +254,6 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - findSlabInstallation: () => null, installSlabMacos: async () => { throw new Error('download failed'); }, fetchDaemonStatus: async () => daemonStatus('cloak'), restartDaemon, diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 753fffef..6a81628c 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -2,7 +2,6 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; import { constants, existsSync } from 'node:fs'; import { access, realpath, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { isAbsolute } from 'node:path'; import { CLI_COMMAND } from '../brand.js'; import { ArgumentError, toEnvelope } from '../errors.js'; @@ -11,12 +10,7 @@ import { writeToStream } from '../stream-write.js'; import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; -import { - createSlabInstallationValidationIo, - findSlabInstallation, - validateSlabInstallation, - type SlabInstallation, -} from '../slab/installation.js'; +import type { SlabInstallation } from '../slab/installation.js'; import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; import { HostedClient } from './client.js'; import { @@ -49,10 +43,9 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { realpath?: (path: string) => Promise; stat?: (path: string) => Promise<{ isFile(): boolean }>; access?: (path: string, mode: number) => Promise; - findSlabInstallation?: () => SlabInstallation | null; installSlabMacos?: () => Promise; - validateSlabInstallation?: (installation: SlabInstallation) => Promise; inspectSlabStatus?: () => Promise; + wait?: (ms: number) => Promise; fetchDaemonStatus?: () => Promise; restartDaemon?: () => Promise; saveConfig?: (config: WebcmdConfig, io: ConfigIo) => void; @@ -223,19 +216,22 @@ async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): P return { kind: 'custom', executablePath }; } if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); - const installation = (io.findSlabInstallation ?? defaultFindSlabInstallation)(); - const trusted = installation - ? await (io.validateSlabInstallation ?? (candidate => validateSlabInstallation(candidate, createSlabInstallationValidationIo())))(installation) - : false; - if (!trusted) { - await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); - } - if (!slabStatusHasHello(await (io.inspectSlabStatus ?? inspectSlabStatus)())) throw new Error('SLAB did not report its control protocol after launch.'); + await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + if (!await waitForSlabHello(io)) throw new Error('SLAB did not report its control protocol after launch.'); return browser; } -function defaultFindSlabInstallation(): SlabInstallation | null { - return findSlabInstallation({ platform: process.platform, homeDir: homedir(), existsSync }); +async function waitForSlabHello(io: SetupIo, timeoutMs = 10_000): Promise { + const inspect = io.inspectSlabStatus ?? inspectSlabStatus; + const wait = io.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const intervalMs = 250; + const attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs)); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + if (slabStatusHasHello(await inspect())) return true; + if (attempt === attempts) return false; + await wait(intervalMs); + } + return false; } async function restartConfiguredDaemon(browser: LocalBrowserConfig, io: SetupIo): Promise { diff --git a/src/slab/control-bridge.test.ts b/src/slab/control-bridge.test.ts index 1a30fd90..e15bbf90 100644 --- a/src/slab/control-bridge.test.ts +++ b/src/slab/control-bridge.test.ts @@ -24,4 +24,20 @@ describe('SLAB control bridge', () => { expect(client.attach).toHaveBeenCalledWith('default'); expect(client.release).toHaveBeenCalledWith('connection-1'); }); + + it('can close the control client before a lease exists', async () => { + const client = { + attach: vi.fn(), + release: vi.fn(), + close: vi.fn(async () => null), + }; + const bridge = await connectSlabControlBridge({ + ensureLaunched: async () => undefined, + connect: async () => client as never, + }); + + await bridge.close(); + + expect(client.close).toHaveBeenCalledOnce(); + }); }); diff --git a/src/slab/control-bridge.ts b/src/slab/control-bridge.ts index 49583663..3f4f28f8 100644 --- a/src/slab/control-bridge.ts +++ b/src/slab/control-bridge.ts @@ -7,6 +7,7 @@ import type { SlabAttachResult } from './protocol.js'; export interface SlabControlBridge { attach(profileId: string): Promise; release(connectionId: string): Promise; + close(): Promise; } export interface SlabControlBridgeIo { @@ -26,6 +27,7 @@ export async function connectSlabControlBridge(io: SlabControlBridgeIo = createS await client.close(); } }, + close: () => client.close(), }; } diff --git a/src/slab/installation.test.ts b/src/slab/installation.test.ts index 7c43d673..6db86573 100644 --- a/src/slab/installation.test.ts +++ b/src/slab/installation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { findSlabInstallation, isSlabInstalled, slabControlEndpoint, validateSlabInstallation } from './installation.js'; +import { findSlabInstallation, isSlabInstalled, slabControlEndpoint } from './installation.js'; describe('SLAB installation discovery', () => { it('finds the first installed normal macOS app bundle', () => { @@ -32,27 +32,4 @@ describe('SLAB installation discovery', () => { expect(slabControlEndpoint('/Users/me')).toBe('/Users/me/.slab/run/slab-bridge.sock'); }); - it('accepts an installation only when the executable, bundle id, and code signature validate', async () => { - await expect(validateSlabInstallation({ - platform: 'darwin', - appPath: '/Applications/SLAB.app', - executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', - }, { - access: async () => undefined, - bundleId: async () => 'dev.webcmd.slab', - execFile: async () => undefined, - })).resolves.toBe(true); - }); - - it('rejects an installation with the wrong bundle id', async () => { - await expect(validateSlabInstallation({ - platform: 'darwin', - appPath: '/Applications/SLAB.app', - executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB', - }, { - access: async () => undefined, - bundleId: async () => 'com.example.other', - execFile: async () => undefined, - })).resolves.toBe(false); - }); }); diff --git a/src/slab/installation.ts b/src/slab/installation.ts index 37420515..f0885d4c 100644 --- a/src/slab/installation.ts +++ b/src/slab/installation.ts @@ -1,8 +1,4 @@ -import { execFile as execFileCallback } from 'node:child_process'; -import { constants } from 'node:fs'; -import { access } from 'node:fs/promises'; import { join } from 'node:path'; -import { promisify } from 'node:util'; export interface SlabInstallation { platform: NodeJS.Platform; @@ -17,15 +13,6 @@ export interface SlabInstallationIo { existsSync(path: string): boolean; } -export interface SlabInstallationValidationIo { - access(path: string, mode: number): Promise; - bundleId(appPath: string): Promise; - execFile(command: string, args: string[]): Promise; -} - -const execFile = promisify(execFileCallback); -const SLAB_BUNDLE_ID = 'dev.webcmd.slab'; - export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | null { if (io.platform !== 'darwin') return null; @@ -47,28 +34,3 @@ export function isSlabInstalled(io: SlabInstallationIo): boolean { export function slabControlEndpoint(homeDir: string): string { return join(homeDir, '.slab', 'run', 'slab-bridge.sock'); } - -export async function validateSlabInstallation( - installation: SlabInstallation, - io: SlabInstallationValidationIo = createSlabInstallationValidationIo(), -): Promise { - try { - await io.access(installation.executablePath, constants.X_OK); - if (await io.bundleId(installation.appPath) !== SLAB_BUNDLE_ID) return false; - await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, installation.appPath]); - return true; - } catch { - return false; - } -} - -export function createSlabInstallationValidationIo(): SlabInstallationValidationIo { - return { - access, - bundleId: async appPath => { - const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist')]); - return result.stdout.trim(); - }, - execFile: async (command, args) => execFile(command, args), - }; -} From eeba128e568097c8c995eb8368b255620927f3bf Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 14:19:53 +0530 Subject: [PATCH 29/34] Update SLAB release public key --- src/slab/release-key.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slab/release-key.ts b/src/slab/release-key.ts index 76b54dd9..d1046150 100644 --- a/src/slab/release-key.ts +++ b/src/slab/release-key.ts @@ -3,7 +3,7 @@ import { verify } from 'node:crypto'; // Trust anchor for the signed SLAB release manifest. This public key is safe to // embed; the matching private key stays in the official release environment. export const SLAB_RELEASE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAoMo7Cbb1CRk2csqvdxMrR3SLBhQ9a8RHeDTRnChTeSQ= +MCowBQYDK2VwAyEAR0ZysgfDP6qRNlsKV3AZBsNnV78ZhD55RAhWDYykmeg= -----END PUBLIC KEY----- `; From a9107fa549bc045811a5b73c2954f3982f77203b Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 1 Sep 2026 14:51:03 +0530 Subject: [PATCH 30/34] Harden SLAB setup first launch --- src/hosted/setup.test.ts | 20 ++++++++++++++++++++ src/hosted/setup.ts | 2 +- src/slab/install.test.ts | 25 +++++++++++++++++++++++++ src/slab/install.ts | 14 +++++++++++--- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 66dc0b10..74484381 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -243,6 +243,26 @@ describe('webcmd setup', () => { expect(events).toEqual(['install', 'hello', 'wait', 'hello', 'save']); }); + it('allows slow first SLAB launch before persisting', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-slow-ready-')); + const statuses: SlabSetupStatus[] = [ + ...Array.from({ length: 41 }).fill('installed-not-running'), + 'installed-running', + ]; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'slab'], + isTTY: false, + platform: 'darwin', + installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), + inspectSlabStatus: async () => statuses.shift() ?? 'installed-running', + wait: async () => undefined, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + }); + it('leaves config and daemon unchanged when SLAB installation fails', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-failure-')); const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 6a81628c..d06d5d07 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -221,7 +221,7 @@ async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): P return browser; } -async function waitForSlabHello(io: SetupIo, timeoutMs = 10_000): Promise { +async function waitForSlabHello(io: SetupIo, timeoutMs = 60_000): Promise { const inspect = io.inspectSlabStatus ?? inspectSlabStatus; const wait = io.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); const intervalMs = 250; diff --git a/src/slab/install.test.ts b/src/slab/install.test.ts index 95bb03b8..ff3e88c3 100644 --- a/src/slab/install.test.ts +++ b/src/slab/install.test.ts @@ -147,6 +147,31 @@ describe('SLAB macOS installer', () => { expect(io.writes.join('')).toContain(`${bytes.length.toFixed(1)} B / ${bytes.length.toFixed(1)} B`); }); + it('throttles repeated percent progress updates', async () => { + const bytes = Buffer.from('signed-slab-dmg'); + const io = fakeInstaller({ + downloadedBytes: bytes, + }); + io.fetch = async (url) => url.endsWith('.json') + ? { ok: true, json: async () => ({ url: 'https://downloads.webcmd.dev/slab/SLAB.dmg', sha256: releaseSha256, signature: 'release-signature' }) } + : { + ok: true, + headers: { get: (name: string) => name.toLowerCase() === 'content-length' ? '1000' : null }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 1)); + controller.enqueue(bytes.subarray(1, 2)); + controller.enqueue(bytes.subarray(2)); + controller.close(); + }, + }), + }; + + await installSlabMacos(io); + + expect(io.writes.filter(message => message.includes('0%'))).toHaveLength(1); + }); + it('reports downloaded bytes when Content-Length is unknown', async () => { const bytes = Buffer.from('signed-slab-dmg'); const io = fakeInstaller({ diff --git a/src/slab/install.ts b/src/slab/install.ts index 6af7654a..48c34139 100644 --- a/src/slab/install.ts +++ b/src/slab/install.ts @@ -61,12 +61,19 @@ async function responseJson(response: { ok: boolean; json?(): Promise } return response.json(); } -async function writeProgress(io: SlabInstallerIo, received: number, total?: number, done: boolean = false): Promise { - if (!io.write) return; +function progressBucket(received: number, total?: number): string { + return total && total > 0 ? String(Math.round((received / total) * 100)) : String(Math.floor(received / (1024 * 1024))); +} + +async function writeProgress(io: SlabInstallerIo, received: number, total?: number, done: boolean = false, lastBucket?: string): Promise { + if (!io.write) return lastBucket; + const bucket = progressBucket(received, total); + if (!done && bucket === lastBucket) return lastBucket; const summary = total && total > 0 ? `${Math.round((received / total) * 100)}% ${formatBytes(received)} / ${formatBytes(total)}` : formatBytes(received); await io.write(`\rDownloading SLAB DMG: ${summary}${done ? '\n' : ''}`); + return bucket; } async function responseBytes(response: { @@ -82,13 +89,14 @@ async function responseBytes(response: { const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; + let lastBucket: string | undefined; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; chunks.push(value); received += value.byteLength; - await writeProgress(io, received, expectedBytes); + lastBucket = await writeProgress(io, received, expectedBytes, false, lastBucket); } await writeProgress(io, received, expectedBytes, true); return Buffer.concat(chunks.map(chunk => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))); From 7fcec409da9d211f850407014d847f084c8999dd Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 1 Sep 2026 23:39:52 +0530 Subject: [PATCH 31/34] feat(setup): add installed Google Chrome support --- CHANGELOG.md | 1 + docs/cli-reference.mdx | 4 +- docs/troubleshooting.mdx | 9 +- src/browser/daemon-lifecycle.ts | 2 +- src/browser/google-chrome.test.ts | 58 +++++++++++ src/browser/google-chrome.ts | 68 +++++++++++++ .../runtime/configured-provider.test.ts | 15 +++ src/browser/runtime/configured-provider.ts | 6 +- src/browser/runtime/local-cloak/provider.ts | 2 +- src/doctor.test.ts | 36 +++++++ src/doctor.ts | 23 +++-- src/hosted/config.test.ts | 3 + src/hosted/config.ts | 4 + src/hosted/setup.test.ts | 99 ++++++++++++++++++- src/hosted/setup.ts | 61 +++++++++--- 15 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 src/browser/google-chrome.test.ts create mode 100644 src/browser/google-chrome.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9ee926..878c9531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `webcmd setup --mode local --browser chrome` detects and reuses an installed normal Google Chrome with an isolated `~/.webcmd/chrome/profiles` directory. - Hosted mode can negotiate and run core validation, diagnostics, adapter lifecycle, profile lifecycle, and catalog-list commands advertised by Webcmd Cloud. - `@agentrhq/webcmd/adapter-analysis` exposes platform-neutral validation and convention-audit rules for trusted hosted command inventories. - `@agentrhq/webcmd/hosted/core-commands` exposes the `hosted-core-commands-v1` capability contract and canonical command IDs. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 5083ebf8..07bf8814 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -70,7 +70,7 @@ webcmd --profile work \ webcmd --profile work session close work-project-k7 ``` -Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser chrome` reuses an installed Google Chrome, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. If Google Chrome is unavailable, setup links to the official installer and leaves the existing selection unchanged. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs @@ -185,7 +185,7 @@ webcmd profile list -f json Each keeps its human-readable report as the `table` rendering, which stays the default. Pass another format to get the underlying result object instead — the validation report for `validate`, the verify report for `verify`, the diagnostic report for `doctor`, and a row set for `profile list`. -`webcmd setup --status` returns the configured local `browser` selection in JSON. `webcmd doctor` reports the live `Runtime` plus a `Selected browser` line so you can tell whether local mode is using bundled Cloak, macOS-alpha SLAB, or a custom absolute executable path. +`webcmd setup --status` returns the configured local `browser` selection in JSON. `webcmd doctor` reports the live `Runtime` plus a `Selected browser` line so you can tell whether local mode is using bundled Cloak, installed Google Chrome, macOS-alpha SLAB, or a custom absolute executable path. `daemon status -f json` returns `{ "running": false }` when no daemon is reachable, and otherwise reports `running`, `stale`, `pid`, `version`, `uptimeMs`, `runtimeConnected`, `profiles`, `memoryMB`, and `port`. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index fdb009fc..b1c2afa0 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -7,7 +7,7 @@ description: Prompt-based fixes for common Webcmd install, profile, auth, plugin Most troubleshooting should be done by an agent. Give the symptom, the command, the expected output, and permission to diagnose. -For local browser selection, use `webcmd setup --mode local --browser cloak`, `webcmd setup --mode local --browser slab`, or `webcmd setup --mode local --browser /absolute/path/to/browser`. Cloak remains the bundled default; SLAB is the macOS alpha opt-in. +For local browser selection, use `webcmd setup --mode local --browser cloak`, `webcmd setup --mode local --browser chrome`, `webcmd setup --mode local --browser slab`, or `webcmd setup --mode local --browser /absolute/path/to/browser`. Cloak remains the bundled default; Chrome reuses an installed normal Google Chrome, and SLAB is the macOS alpha opt-in. ## Basic Diagnosis @@ -145,6 +145,13 @@ Useful environment variables: | `WEBCMD_CACHE_DIR` | Browser state and network cache directory. | | `WEBCMD_VERBOSE` | Enable verbose logs. | +`webcmd setup --mode local --browser chrome` uses an existing normal Google +Chrome installation and keeps its profiles under `~/.webcmd/chrome/profiles`. +Interactive setup labels Chrome as `installed` or `install required`. Webcmd +does not install Chrome automatically; when it is missing and selected, setup +links to `https://www.google.com/chrome/` and preserves the current browser +selection. + After changing the local browser with `webcmd setup --mode local --browser ...`, restart the daemon and run `webcmd doctor`. `webcmd doctor` reports the live runtime plus a `Selected browser` line, and `webcmd setup --status` returns the diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts index 7edfdb0b..197cb263 100644 --- a/src/browser/daemon-lifecycle.ts +++ b/src/browser/daemon-lifecycle.ts @@ -203,7 +203,7 @@ export async function ensureBrowserBridgeReady( throw browserConnectErrorFromHealth(finalHealth, contextId); } -function selectedLocalBrowserKind(): 'cloak' | 'slab' | 'custom' { +function selectedLocalBrowserKind(): 'cloak' | 'chrome' | 'slab' | 'custom' { const config = loadWebcmdConfig(); return config.mode === 'local' ? config.browser.kind : 'cloak'; } diff --git a/src/browser/google-chrome.test.ts b/src/browser/google-chrome.test.ts new file mode 100644 index 00000000..42d21a78 --- /dev/null +++ b/src/browser/google-chrome.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { findInstalledGoogleChrome, googleChromeCandidates } from './google-chrome.js'; + +describe('Google Chrome discovery', () => { + it('checks system and user application folders on macOS', () => { + expect(googleChromeCandidates({ platform: 'darwin', homeDir: '/Users/test', env: {} })).toEqual([ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ]); + }); + + it('checks Google Chrome installation locations on Windows', () => { + expect(googleChromeCandidates({ + platform: 'win32', + homeDir: 'C:\\Users\\test', + env: { + PROGRAMFILES: 'C:\\Program Files', + 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)', + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', + }, + })).toEqual([ + path.win32.join('C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.win32.join('C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.win32.join('C:\\Users\\test\\AppData\\Local', 'Google', 'Chrome', 'Application', 'chrome.exe'), + ]); + }); + + it('checks PATH and standard Google Chrome locations on Linux', () => { + expect(googleChromeCandidates({ + platform: 'linux', + homeDir: '/home/test', + env: { PATH: '/custom/bin:/usr/local/bin' }, + })).toContain('/custom/bin/google-chrome'); + expect(googleChromeCandidates({ platform: 'linux', homeDir: '/home/test', env: {} })) + .toContain('/opt/google/chrome/google-chrome'); + }); + + it('reuses the first launchable Google Chrome installation', async () => { + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const userChrome = '/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const canonicalUserChrome = '/private/Users/test/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const resolveRealpath = vi.fn(async (candidate: string) => { + if (candidate === systemChrome) throw new Error('missing'); + if (candidate === userChrome) return canonicalUserChrome; + throw new Error('unexpected candidate'); + }); + + await expect(findInstalledGoogleChrome({ + platform: 'darwin', + homeDir: '/Users/test', + env: {}, + realpath: resolveRealpath as never, + stat: (async () => ({ isFile: () => true })) as never, + access: (async () => undefined) as never, + })).resolves.toBe(canonicalUserChrome); + }); +}); diff --git a/src/browser/google-chrome.ts b/src/browser/google-chrome.ts new file mode 100644 index 00000000..3be2c357 --- /dev/null +++ b/src/browser/google-chrome.ts @@ -0,0 +1,68 @@ +import { constants } from 'node:fs'; +import { access, realpath, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export interface GoogleChromeDiscoveryOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + realpath?: typeof realpath; + stat?: typeof stat; + access?: typeof access; +} + +export function googleChromeCandidates(opts: GoogleChromeDiscoveryOptions = {}): string[] { + const platform = opts.platform ?? process.platform; + const env = opts.env ?? process.env; + const homeDir = opts.homeDir ?? os.homedir(); + + if (platform === 'darwin') { + const executable = ['Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome']; + return [ + path.posix.join('/Applications', ...executable), + path.posix.join(homeDir, 'Applications', ...executable), + ]; + } + + if (platform === 'win32') { + return [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA] + .filter((root): root is string => Boolean(root)) + .map(root => path.win32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe')); + } + + const pathCandidates = (env.PATH ?? '') + .split(':') + .filter(Boolean) + .flatMap(directory => [ + path.posix.join(directory, 'google-chrome'), + path.posix.join(directory, 'google-chrome-stable'), + ]); + return [...new Set([ + ...pathCandidates, + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/opt/google/chrome/google-chrome', + ])]; +} + +export async function findInstalledGoogleChrome( + opts: GoogleChromeDiscoveryOptions = {}, +): Promise { + const resolveRealpath = opts.realpath ?? realpath; + const inspect = opts.stat ?? stat; + const checkAccess = opts.access ?? access; + for (const candidate of googleChromeCandidates(opts)) { + try { + const executablePath = await resolveRealpath(candidate); + if (!(await inspect(executablePath)).isFile()) continue; + if ((opts.platform ?? process.platform) !== 'win32') { + await checkAccess(executablePath, constants.X_OK); + } + return executablePath; + } catch { + // Try the next standard Google Chrome installation location. + } + } + return undefined; +} diff --git a/src/browser/runtime/configured-provider.test.ts b/src/browser/runtime/configured-provider.test.ts index 1d4738a0..93a3548e 100644 --- a/src/browser/runtime/configured-provider.test.ts +++ b/src/browser/runtime/configured-provider.test.ts @@ -40,6 +40,21 @@ describe('configured local browser provider', () => { }); }); + it('runs configured Google Chrome with the stable chrome profile namespace', async () => { + const LocalCloakRuntimeProvider = vi.fn(); + vi.doMock('./local-cloak/provider.js', () => ({ LocalCloakRuntimeProvider })); + const { createConfiguredLocalBrowserRuntimeProvider: createProvider } = await import('./configured-provider.js'); + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + + createProvider(makeLocalConfig(new Date(0), { kind: 'chrome', executablePath })); + + expect(LocalCloakRuntimeProvider).toHaveBeenCalledWith({ + executablePath, + profileNamespace: 'chrome', + runtimeName: 'chrome', + }); + }); + it('does not fall back to Cloak when SLAB construction fails', async () => { const cloak = vi.fn(); const failure = new Error('SLAB startup failed'); diff --git a/src/browser/runtime/configured-provider.ts b/src/browser/runtime/configured-provider.ts index 3c6e1251..92ab9ae5 100644 --- a/src/browser/runtime/configured-provider.ts +++ b/src/browser/runtime/configured-provider.ts @@ -10,11 +10,13 @@ export function createConfiguredLocalBrowserRuntimeProvider( const browser = config?.browser ?? { kind: 'cloak' }; if (browser.kind === 'slab') return new LocalSlabRuntimeProvider(); - const executablePath = browser.kind === 'custom' ? browser.executablePath : undefined; + const executablePath = browser.kind === 'custom' || browser.kind === 'chrome' + ? browser.executablePath + : undefined; configureCloakBrowserBinary(executablePath); return new LocalCloakRuntimeProvider({ executablePath, - profileNamespace: resolveBrowserProfileNamespace(executablePath), + profileNamespace: browser.kind === 'chrome' ? 'chrome' : resolveBrowserProfileNamespace(executablePath), runtimeName: browser.kind, }); } diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 9f6cb9f4..35ced9c2 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -12,7 +12,7 @@ export interface LocalCloakRuntimeProviderOptions { baseDir?: string; profileNamespace?: string; executablePath?: string; - runtimeName?: 'cloak' | 'custom'; + runtimeName?: 'cloak' | 'chrome' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; } diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 19f53599..de443c67 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -227,6 +227,19 @@ describe('doctor report rendering', () => { expect(text).toContain('[OK] Selected browser: custom (/Applications/Brave Browser.app/Contents/MacOS/Brave Browser)'); }); + it('renders the selected browser as Google Chrome', () => { + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + runtimeConnected: true, + runtimeName: 'chrome', + selectedBrowser: { kind: 'chrome', executablePath }, + issues: [], + })); + + expect(text).toContain(`[OK] Selected browser: Google Chrome (${executablePath})`); + }); + it('renders the selected browser as SLAB alpha', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, @@ -402,6 +415,29 @@ describe('doctor report rendering', () => { } }); + it('uses the configured Google Chrome executable for doctor checks', async () => { + const configDir = writeLocalConfig({ + kind: 'chrome', + executablePath: managedBinaryPath, + }); + mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { runtimeConnected: true, runtimeName: 'chrome' } }); + + try { + const report = await runBrowserDoctor(); + + expect(report.selectedBrowser).toEqual({ + kind: 'chrome', + executablePath: managedBinaryPath, + }); + expect(report.binary).toMatchObject({ installed: true, path: managedBinaryPath }); + expect(mockEnsureBinary).not.toHaveBeenCalled(); + expect(mockBinaryInfo).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('checks only the configured SLAB runtime', async () => { const configDir = writeLocalConfig({ kind: 'slab' }); mockConnect.mockRejectedValueOnce(new Error('slab runtime unavailable')); diff --git a/src/doctor.ts b/src/doctor.ts index 274f7f2f..0b93da2b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -88,7 +88,7 @@ function isLaunchableFile(binaryPath: string): boolean { * needs to launch is present on disk. */ export function checkBrowserBinary(browser: LocalBrowserConfig = { kind: 'cloak' }): BrowserBinaryStatus { - if (browser.kind === 'custom') { + if (browser.kind === 'custom' || browser.kind === 'chrome') { return { installed: isLaunchableFile(browser.executablePath), path: browser.executablePath, @@ -154,7 +154,11 @@ export async function checkConnectivity( export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { const config = loadWebcmdConfig(); const selectedBrowser = config.mode === 'local' ? config.browser : { kind: 'cloak' } satisfies LocalBrowserConfig; - configureCloakBrowserBinary(selectedBrowser.kind === 'custom' ? selectedBrowser.executablePath : undefined); + configureCloakBrowserBinary( + selectedBrowser.kind === 'custom' || selectedBrowser.kind === 'chrome' + ? selectedBrowser.executablePath + : undefined, + ); // Live connectivity check is the core of doctor — it doubles as auto-start // (bridge.connect spawns daemon) and validates // end-to-end browser bridge health. @@ -180,12 +184,12 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { for (const browser of [ { kind: 'cloak' } as const, + { kind: 'chrome', executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' } as const, { kind: 'slab' } as const, { kind: 'custom', executablePath: '/Applications/Chrome.app/Contents/MacOS/Google Chrome' } as const, ]) { @@ -165,6 +166,8 @@ describe('hosted config', () => { { kind: 'custom' }, { kind: 'custom', executablePath: '' }, { kind: 'custom', executablePath: 'relative/browser' }, + { kind: 'chrome' }, + { kind: 'chrome', executablePath: 'relative/browser' }, { kind: 'other' }, ]) { expect(loadWebcmdConfig({ diff --git a/src/hosted/config.ts b/src/hosted/config.ts index 465a36b0..38da3bd6 100644 --- a/src/hosted/config.ts +++ b/src/hosted/config.ts @@ -11,6 +11,7 @@ export interface HostedManifestCache { export type LocalBrowserConfig = | { kind: 'cloak' } + | { kind: 'chrome'; executablePath: string } | { kind: 'slab' } | { kind: 'custom'; executablePath: string }; @@ -209,6 +210,9 @@ function readLocalBrowser(value: unknown): LocalBrowserConfig { if (value && typeof value === 'object') { const browser = value as { kind?: unknown; executablePath?: unknown }; if (browser.kind === 'cloak' || browser.kind === 'slab') return { kind: browser.kind }; + if (browser.kind === 'chrome' && typeof browser.executablePath === 'string' && path.isAbsolute(browser.executablePath)) { + return { kind: 'chrome', executablePath: browser.executablePath }; + } if (browser.kind === 'custom' && typeof browser.executablePath === 'string' && path.isAbsolute(browser.executablePath)) { return { kind: 'custom', executablePath: browser.executablePath }; } diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 74484381..e78c645f 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -30,6 +30,7 @@ describe('webcmd setup', () => { platform: 'linux', now: () => new Date('2026-07-08T00:00:00.000Z'), question: async () => answers.shift() ?? '', + fetchDaemonStatus: async () => null, write: (message) => { messages.push(message); }, }); @@ -42,6 +43,58 @@ describe('webcmd setup', () => { expect(messages.join('')).toContain('local mode'); }); + it('shows installed Chrome in the interactive browser prompt and reuses its detection', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-interactive-chrome-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const answers = ['local', 'chrome']; + const prompts: string[] = []; + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const resolveGoogleChromeExecutable = vi.fn(async () => executablePath); + + await expect(runHostedSetup({ + env, + question: async prompt => { + prompts.push(prompt); + return answers.shift() ?? ''; + }, + resolveGoogleChromeExecutable, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(prompts).toContain('Local browser [cloak/chrome (installed)/slab/absolute path] (cloak): '); + expect(resolveGoogleChromeExecutable).toHaveBeenCalledOnce(); + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + browser: { kind: 'chrome', executablePath }, + }); + }); + + it('shows install required and the official link when unavailable Chrome is selected', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-interactive-chrome-missing-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + saveWebcmdConfig(makeLocalConfig(new Date('2026-08-31T00:00:00.000Z')), { env }); + const answers = ['local', 'chrome']; + const prompts: string[] = []; + const messages: string[] = []; + const resolveGoogleChromeExecutable = vi.fn(async () => undefined); + + await expect(runHostedSetup({ + env, + question: async prompt => { + prompts.push(prompt); + return answers.shift() ?? ''; + }, + resolveGoogleChromeExecutable, + fetchDaemonStatus: async () => null, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(prompts).toContain('Local browser [cloak/chrome (install required)/slab/absolute path] (cloak): '); + expect(resolveGoogleChromeExecutable).toHaveBeenCalledOnce(); + expect(messages.join('')).toContain('https://www.google.com/chrome/'); + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ browser: { kind: 'cloak' } }); + }); + it('writes hosted mode and validates with /v1/me', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-')); const answers = ['hosted', 'wcmd_live_test']; @@ -104,6 +157,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local'], isTTY: false, question, + fetchDaemonStatus: async () => null, write: (message) => { messages.push(message); }, }); @@ -164,6 +218,43 @@ describe('webcmd setup', () => { }); }); + it('detects and persists an installed Google Chrome executable', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-chrome-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + const executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + + await expect(runHostedSetup({ + env, + argv: ['--mode', 'local', '--browser', 'chrome'], + isTTY: false, + resolveGoogleChromeExecutable: async () => executablePath, + fetchDaemonStatus: async () => null, + write: () => undefined, + })).resolves.toBe(0); + + expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({ + mode: 'local', + browser: { kind: 'chrome', executablePath }, + }); + }); + + it('explains how to install Google Chrome when it is unavailable', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-chrome-missing-')); + const messages: string[] = []; + + await expect(runHostedSetup({ + env: { WEBCMD_CONFIG_DIR: tempDir }, + argv: ['--mode', 'local', '--browser', 'chrome'], + isTTY: false, + resolveGoogleChromeExecutable: async () => undefined, + fetchDaemonStatus: async () => null, + write: message => { messages.push(message); }, + })).resolves.toBe(1); + + expect(messages.join('')).toContain('Google Chrome is not installed'); + expect(messages.join('')).toContain('https://www.google.com/chrome/'); + }); + it('reinstalls an existing SLAB app through the signed installer path', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); const events: string[] = []; @@ -385,8 +476,7 @@ describe('webcmd setup', () => { it.each([ [['--mode', 'local', '--browser'], '--browser requires a value.'], - [['--mode', 'local', '--browser', 'chrome'], '--browser must be cloak, slab, or an absolute path'], - [['--mode', 'local', '--browser', 'relative/browser'], '--browser must be cloak, slab, or an absolute path'], + [['--mode', 'local', '--browser', 'relative/browser'], '--browser must be cloak, chrome, slab, or an absolute path'], [['--mode', 'hosted', '--browser', 'slab', '--api-key', 'wcmd_live_test'], '--browser is only valid with --mode local.'], ])('rejects invalid browser arguments from %j', async (argv, message) => { const stderr = collectStderr(); @@ -410,8 +500,8 @@ describe('webcmd setup', () => { write: message => { messages.push(message); }, })).resolves.toBe(0); - expect(messages.join('')).toContain('--browser '); - expect(messages.join('')).toContain('Cloak stays default, SLAB is macOS alpha opt-in'); + expect(messages.join('')).toContain('--browser '); + expect(messages.join('')).toContain('Cloak is default, Chrome reuses an installed Google Chrome'); }); it('reports the configured custom browser without probing SLAB', async () => { @@ -555,6 +645,7 @@ describe('webcmd setup', () => { question: async () => answers.shift() ?? '', fetchDaemonStatus: async () => null, resolveCloakPackage: async () => 'file:///cloakbrowser/index.js', + resolveGoogleChromeExecutable: async () => undefined, }).then(code => { settled = true; return code; diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index d06d5d07..0f854801 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -9,6 +9,7 @@ import { formatErrorEnvelope } from '../output.js'; import { writeToStream } from '../stream-write.js'; import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; +import { findInstalledGoogleChrome } from '../browser/google-chrome.js'; import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; import type { SlabInstallation } from '../slab/installation.js'; import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; @@ -40,6 +41,7 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { argv?: readonly string[]; isTTY?: boolean; resolveCloakPackage?: () => string | Promise; + resolveGoogleChromeExecutable?: () => Promise; realpath?: (path: string) => Promise; stat?: (path: string) => Promise<{ isFile(): boolean }>; access?: (path: string, mode: number) => Promise; @@ -52,8 +54,9 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { } type SetupMode = 'local' | 'hosted'; +type LocalBrowserSelection = LocalBrowserConfig | { kind: 'chrome' }; -const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--browser ] [--api-key ]`; +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--browser ] [--api-key ]`; const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; const SETUP_HELP = [ `${CLI_COMMAND} setup`, @@ -61,7 +64,7 @@ const SETUP_HELP = [ 'Configure local or hosted mode.', '', ' --mode Required when stdin is not a TTY', - ' --browser Local browser in local mode; Cloak stays default, SLAB is macOS alpha opt-in', + ' --browser Local browser; Cloak is default, Chrome reuses an installed Google Chrome, SLAB is macOS alpha', ' --api-key Required for --mode hosted when stdin is not a TTY', ' --status Show the configured mode and local browser', ' -h, --help', @@ -119,12 +122,19 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, ); } - const browser = parsed.browser ?? (interactive - ? parseLocalBrowser((await ask('Local browser [cloak/slab/absolute path] (cloak): ')).trim() || 'cloak') - : { kind: 'cloak' }); + let chromeDiscovery: { executablePath: string | undefined } | undefined; + let browser = parsed.browser; + if (!browser && interactive) { + chromeDiscovery = { executablePath: await resolveGoogleChromeExecutable(io) }; + const chromeStatus = chromeDiscovery.executablePath ? 'installed' : 'install required'; + browser = parseLocalBrowser( + (await ask(`Local browser [cloak/chrome (${chromeStatus})/slab/absolute path] (cloak): `)).trim() || 'cloak', + ); + } + browser ??= { kind: 'cloak' }; const before = await (io.fetchDaemonStatus ?? fetchDaemonStatus)(); try { - const selected = await validateLocalBrowser(browser, io); + const selected = await validateLocalBrowser(browser, io, chromeDiscovery); (io.saveConfig ?? saveWebcmdConfig)(makeLocalConfig(io.now?.() ?? new Date(), selected), io); if (before) await restartConfiguredDaemon(selected, io); } catch (err) { @@ -204,7 +214,11 @@ function canPrompt(io: SetupIo): boolean { return process.stdin.isTTY === true && process.stdout.isTTY === true; } -async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): Promise { +async function validateLocalBrowser( + browser: LocalBrowserSelection, + io: SetupIo, + chromeDiscovery?: { executablePath: string | undefined }, +): Promise { if (browser.kind === 'cloak') { await (io.resolveCloakPackage ?? (() => import.meta.resolve('cloakbrowser')))(); return browser; @@ -215,12 +229,33 @@ async function validateLocalBrowser(browser: LocalBrowserConfig, io: SetupIo): P await (io.access ?? access)(executablePath, constants.X_OK); return { kind: 'custom', executablePath }; } + if (browser.kind === 'chrome') { + const executablePath = 'executablePath' in browser + ? browser.executablePath + : chromeDiscovery + ? chromeDiscovery.executablePath + : await resolveGoogleChromeExecutable(io); + if (!executablePath) { + throw new Error( + `Google Chrome is not installed. Install it from https://www.google.com/chrome/, then rerun ${CLI_COMMAND} setup --mode local --browser chrome.`, + ); + } + return { kind: 'chrome', executablePath }; + } if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); if (!await waitForSlabHello(io)) throw new Error('SLAB did not report its control protocol after launch.'); return browser; } +function resolveGoogleChromeExecutable(io: SetupIo): Promise { + return (io.resolveGoogleChromeExecutable ?? (() => findInstalledGoogleChrome({ + platform: io.platform ?? process.platform, + env: io.env ?? process.env, + homeDir: io.homeDir, + })))(); +} + async function waitForSlabHello(io: SetupIo, timeoutMs = 60_000): Promise { const inspect = io.inspectSlabStatus ?? inspectSlabStatus; const wait = io.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); @@ -269,9 +304,9 @@ export async function getSetupStatus(io: SetupIo = {}): Promise { return status; } -function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; mode?: SetupMode; browser?: LocalBrowserConfig; apiKey?: string } { +function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; mode?: SetupMode; browser?: LocalBrowserSelection; apiKey?: string } { let mode: SetupMode | undefined; - let browser: LocalBrowserConfig | undefined; + let browser: LocalBrowserSelection | undefined; let apiKey: string | undefined; let status: true | undefined; for (let i = 0; i < argv.length; i++) { @@ -319,14 +354,16 @@ function parseSetupArgs(argv: readonly string[]): { help?: true; status?: true; return { ...(status ? { status } : {}), mode, browser, apiKey }; } -function parseLocalBrowser(value: string | undefined): LocalBrowserConfig { +function parseLocalBrowser(value: string | undefined): LocalBrowserSelection { if (!value || value.startsWith('-')) { throw new ArgumentError('--browser requires a value.', `${SETUP_USAGE}\n${SETUP_EXAMPLE}`); } - if (value === 'cloak' || value === 'slab') return { kind: value }; + if (value === 'cloak') return { kind: 'cloak' }; + if (value === 'chrome') return { kind: 'chrome' }; + if (value === 'slab') return { kind: 'slab' }; if (isAbsolute(value)) return { kind: 'custom', executablePath: value }; throw new ArgumentError( - `--browser must be cloak, slab, or an absolute path (got: "${value}").`, + `--browser must be cloak, chrome, slab, or an absolute path (got: "${value}").`, `${SETUP_USAGE}\n${SETUP_EXAMPLE}`, ); } From b6ad913f9f9c223087c7d1740d5d3f6d45930da9 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 2 Sep 2026 09:50:53 +0530 Subject: [PATCH 32/34] fix: stabilize browser runtime CI --- .../runtime/local-cloak/browser-run.test.ts | 4 +++- src/slab/bridge-client.test.ts | 2 +- src/slab/cdp-ipc-transport.test.ts | 2 +- src/slab/install.ts | 20 +++++++++---------- src/slab/installation.ts | 8 ++++---- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-cloak/browser-run.test.ts index 1877a707..37449dfe 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-cloak/browser-run.test.ts @@ -24,6 +24,9 @@ const command = (id: string, action: 'run' | 'snapshot' | 'tabs' | 'bind' | 'clo ...extra, }); +const describeWithPlaywrightChromium = fs.existsSync(chromium.executablePath()) ? describe : describe.skip; + +describeWithPlaywrightChromium('local Cloak browser run', () => { beforeAll(async () => { browser = await chromium.launch({ headless: true }); }); @@ -51,7 +54,6 @@ afterAll(async () => { await browser.close(); }); -describe('local Cloak browser run', () => { it('returns a bounded redacted snapshot for the current page', async () => { await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' }); const result = await dispatchCloakAction(manager, command('snapshot-1', 'snapshot')); diff --git a/src/slab/bridge-client.test.ts b/src/slab/bridge-client.test.ts index cbbf4aee..a98798da 100644 --- a/src/slab/bridge-client.test.ts +++ b/src/slab/bridge-client.test.ts @@ -111,7 +111,7 @@ function sizedHello(id: string, targetBytes: number): string { return `${make(pad)}\n`; } -describe('SlabBridgeClient', () => { +describe.skipIf(process.platform === 'win32')('SlabBridgeClient', () => { it('reassembles fragmented JSONL responses', async () => { const harness = await listen((socket) => { collectRequests(socket, harness.requests, (req) => { diff --git a/src/slab/cdp-ipc-transport.test.ts b/src/slab/cdp-ipc-transport.test.ts index bbadc94d..3bebd9fa 100644 --- a/src/slab/cdp-ipc-transport.test.ts +++ b/src/slab/cdp-ipc-transport.test.ts @@ -95,7 +95,7 @@ function closed(transport: ConnectOverCDPTransport): Promise }); } -describe('CdpIpcTransport', () => { +describe.skipIf(process.platform === 'win32')('CdpIpcTransport', () => { it('authenticates, reassembles split frames, and delivers multiple CDP objects', async () => { const harness = await listen(); const transport = await connectAuthenticated(harness); diff --git a/src/slab/install.ts b/src/slab/install.ts index 48c34139..31bf3ff9 100644 --- a/src/slab/install.ts +++ b/src/slab/install.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; import { access, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { posix } from 'node:path'; import { promisify } from 'node:util'; import { execFile as execFileCallback } from 'node:child_process'; import { formatBytes } from '../download/progress.js'; @@ -142,9 +142,9 @@ export async function installSlabMacos(io: SlabInstallerIo = createSlabInstaller const manifest = parseManifest(await responseJson(manifestResponse)); if (!await io.verifyManifest(manifest)) throw new Error('SLAB installer release signature verification failed'); - const tempPath = await io.mkdtemp(join(io.tempDir, 'webcmd-slab-')); - const dmgPath = join(tempPath, 'SLAB.dmg'); - const mountPath = join(tempPath, 'mount'); + const tempPath = await io.mkdtemp(posix.join(io.tempDir, 'webcmd-slab-')); + const dmgPath = posix.join(tempPath, 'SLAB.dmg'); + const mountPath = posix.join(tempPath, 'mount'); let stagingPath: string | undefined; let mounted = false; @@ -162,20 +162,20 @@ export async function installSlabMacos(io: SlabInstallerIo = createSlabInstaller try { await io.access(applicationsDir, constants.W_OK); } catch { - applicationsDir = join(io.homeDir, 'Applications'); + applicationsDir = posix.join(io.homeDir, 'Applications'); await io.mkdir(applicationsDir); } - const appPath = join(applicationsDir, 'SLAB.app'); - stagingPath = join(applicationsDir, '.SLAB.app.webcmd-staging'); + const appPath = posix.join(applicationsDir, 'SLAB.app'); + stagingPath = posix.join(applicationsDir, '.SLAB.app.webcmd-staging'); await io.rm(stagingPath); - await io.execFile('ditto', [join(mountPath, 'SLAB.app'), stagingPath]); + await io.execFile('ditto', [posix.join(mountPath, 'SLAB.app'), stagingPath]); await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); if (await io.bundleId(stagingPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); await clearQuarantine(io, stagingPath); await io.replaceApp(stagingPath, appPath); stagingPath = undefined; if (options.launchAfterInstall) await io.execFile('open', [appPath]); - return { platform: 'darwin', appPath, executablePath: join(appPath, 'Contents', 'MacOS', 'SLAB') }; + return { platform: 'darwin', appPath, executablePath: posix.join(appPath, 'Contents', 'MacOS', 'SLAB') }; } finally { try { if (mounted) await io.execFile('hdiutil', ['detach', mountPath]); @@ -198,7 +198,7 @@ export function createSlabInstallerIo(): SlabInstallerIo { rm: async path => { await rm(path, { recursive: true, force: true }); }, access, bundleId: async appPath => { - const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist')]); + const result = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', posix.join(appPath, 'Contents', 'Info.plist')]); return result.stdout.trim(); }, replaceApp: (source, destination) => replaceSlabAppAtomically({ rename, rm: async path => { await rm(path, { recursive: true, force: true }); } }, source, destination), diff --git a/src/slab/installation.ts b/src/slab/installation.ts index f0885d4c..862a3caf 100644 --- a/src/slab/installation.ts +++ b/src/slab/installation.ts @@ -1,4 +1,4 @@ -import { join } from 'node:path'; +import { posix } from 'node:path'; export interface SlabInstallation { platform: NodeJS.Platform; @@ -18,9 +18,9 @@ export function findSlabInstallation(io: SlabInstallationIo): SlabInstallation | for (const appPath of [ '/Applications/SLAB.app', - join(io.homeDir, 'Applications', 'SLAB.app'), + posix.join(io.homeDir, 'Applications', 'SLAB.app'), ]) { - const executablePath = join(appPath, 'Contents', 'MacOS', 'SLAB'); + const executablePath = posix.join(appPath, 'Contents', 'MacOS', 'SLAB'); if (io.existsSync(executablePath)) return { platform: io.platform, appPath, executablePath }; } @@ -32,5 +32,5 @@ export function isSlabInstalled(io: SlabInstallationIo): boolean { } export function slabControlEndpoint(homeDir: string): string { - return join(homeDir, '.slab', 'run', 'slab-bridge.sock'); + return posix.join(homeDir, '.slab', 'run', 'slab-bridge.sock'); } From de3ba2f05a32c3cb22836f77a4a894c2250c1c32 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 2 Sep 2026 10:37:43 +0530 Subject: [PATCH 33/34] fix(setup): reuse installed SLAB app --- src/hosted/setup.test.ts | 17 ++++++++++++++--- src/hosted/setup.ts | 20 +++++++++++++++++--- src/slab/install.ts | 11 +++++++++-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index e78c645f..13cf76f4 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -255,23 +255,29 @@ describe('webcmd setup', () => { expect(messages.join('')).toContain('https://www.google.com/chrome/'); }); - it('reinstalls an existing SLAB app through the signed installer path', async () => { + it('reuses an existing SLAB app without downloading it again', async () => { tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slab-reuse-')); const events: string[] = []; + const installSlabMacos = vi.fn(); await expect(runHostedSetup({ env: { WEBCMD_CONFIG_DIR: tempDir }, argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', - installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, + homeDir: '/Users/me', + existsSync: candidate => candidate === '/Applications/SLAB.app/Contents/MacOS/SLAB', + installSlabMacos, + verifySlabApp: async () => { events.push('verify'); }, + launchSlabApp: async () => { events.push('launch'); }, inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, saveConfig: (config, configIo) => { events.push('save'); saveWebcmdConfig(config, configIo); }, write: () => undefined, })).resolves.toBe(0); - expect(events).toEqual(['install', 'hello', 'save']); + expect(installSlabMacos).not.toHaveBeenCalled(); + expect(events).toEqual(['verify', 'launch', 'hello', 'save']); }); it('rejects SLAB when it does not answer its control protocol after install', async () => { @@ -284,6 +290,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', + existsSync: () => false, installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), inspectSlabStatus: async () => 'installed-not-running', wait: async () => undefined, @@ -303,6 +310,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', + existsSync: () => false, installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, inspectSlabStatus: async () => { events.push('hello'); return 'installed-running'; }, fetchDaemonStatus: async () => null, @@ -323,6 +331,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', + existsSync: () => false, installSlabMacos: async () => { events.push('install'); return { platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }; }, inspectSlabStatus: async () => { events.push('hello'); return statuses.shift() ?? 'installed-running'; }, wait: async () => { events.push('wait'); }, @@ -346,6 +355,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', + existsSync: () => false, installSlabMacos: async () => ({ platform: 'darwin', appPath: '/Applications/SLAB.app', executablePath: '/Applications/SLAB.app/Contents/MacOS/SLAB' }), inspectSlabStatus: async () => statuses.shift() ?? 'installed-running', wait: async () => undefined, @@ -365,6 +375,7 @@ describe('webcmd setup', () => { argv: ['--mode', 'local', '--browser', 'slab'], isTTY: false, platform: 'darwin', + existsSync: () => false, installSlabMacos: async () => { throw new Error('download failed'); }, fetchDaemonStatus: async () => daemonStatus('cloak'), restartDaemon, diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 0f854801..1f734dd8 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -1,6 +1,7 @@ import { createInterface } from 'node:readline/promises'; import { stdin as defaultInput, stdout as defaultOutput } from 'node:process'; import { constants, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import { access, realpath, stat } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; import { CLI_COMMAND } from '../brand.js'; @@ -10,8 +11,9 @@ import { writeToStream } from '../stream-write.js'; import { fetchDaemonStatus, type DaemonStatus } from '../browser/daemon-transport.js'; import { restartDaemon, type DaemonRestartResult } from '../browser/daemon-lifecycle.js'; import { findInstalledGoogleChrome } from '../browser/google-chrome.js'; -import { createSlabInstallerIo, installSlabMacos } from '../slab/install.js'; -import type { SlabInstallation } from '../slab/installation.js'; +import { createSlabInstallerIo, installSlabMacos, verifySlabApp } from '../slab/install.js'; +import { findSlabInstallation, type SlabInstallation } from '../slab/installation.js'; +import { createSlabLaunchIo } from '../slab/launch.js'; import { inspectSlabStatus, slabStatusHasHello, type SlabSetupStatus } from '../slab/status.js'; import { HostedClient } from './client.js'; import { @@ -46,6 +48,8 @@ export interface SetupIo extends ConfigIo, HostedCredentialIo { stat?: (path: string) => Promise<{ isFile(): boolean }>; access?: (path: string, mode: number) => Promise; installSlabMacos?: () => Promise; + verifySlabApp?: (appPath: string) => Promise; + launchSlabApp?: (appPath: string) => Promise; inspectSlabStatus?: () => Promise; wait?: (ms: number) => Promise; fetchDaemonStatus?: () => Promise; @@ -243,7 +247,17 @@ async function validateLocalBrowser( return { kind: 'chrome', executablePath }; } if ((io.platform ?? process.platform) !== 'darwin') throw new Error('SLAB setup is only supported on macOS.'); - await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + const installation = findSlabInstallation({ + platform: io.platform ?? process.platform, + homeDir: io.homeDir ?? homedir(), + existsSync: io.existsSync ?? existsSync, + }); + if (installation) { + await (io.verifySlabApp ?? (appPath => verifySlabApp(createSlabInstallerIo(), appPath)))(installation.appPath); + await (io.launchSlabApp ?? createSlabLaunchIo().launch)(installation.appPath); + } else { + await (io.installSlabMacos ?? (() => installSlabMacos(createSlabInstallerIo(), { launchAfterInstall: true })))(); + } if (!await waitForSlabHello(io)) throw new Error('SLAB did not report its control protocol after launch.'); return browser; } diff --git a/src/slab/install.ts b/src/slab/install.ts index 31bf3ff9..e29e17fa 100644 --- a/src/slab/install.ts +++ b/src/slab/install.ts @@ -47,6 +47,14 @@ export interface SlabReplacementIo { rm(path: string): Promise; } +export async function verifySlabApp( + io: Pick, + appPath: string, +): Promise { + await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, appPath]); + if (await io.bundleId(appPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); +} + function parseManifest(value: unknown): SlabReleaseManifest { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('SLAB installer release manifest is invalid'); const manifest = value as Partial; @@ -169,8 +177,7 @@ export async function installSlabMacos(io: SlabInstallerIo = createSlabInstaller stagingPath = posix.join(applicationsDir, '.SLAB.app.webcmd-staging'); await io.rm(stagingPath); await io.execFile('ditto', [posix.join(mountPath, 'SLAB.app'), stagingPath]); - await io.execFile('codesign', ['--verify', '--deep', '--strict', '--identifier', SLAB_BUNDLE_ID, stagingPath]); - if (await io.bundleId(stagingPath) !== SLAB_BUNDLE_ID) throw new Error('SLAB installer bundle identifier mismatch'); + await verifySlabApp(io, stagingPath); await clearQuarantine(io, stagingPath); await io.replaceApp(stagingPath, appPath); stagingPath = undefined; From a9ea552c060bdffef2795b997509248607841c60 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 2 Sep 2026 10:45:22 +0530 Subject: [PATCH 34/34] fix(docs): remove retired skill references --- docs/agents/aider.md | 5 +---- docs/agents/cline.md | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/agents/aider.md b/docs/agents/aider.md index 5075daf1..34612422 100644 --- a/docs/agents/aider.md +++ b/docs/agents/aider.md @@ -30,11 +30,10 @@ webcmd skills add When `webcmd skills add` prompts, choose the `agents` provider. It installs into `~/.agents/skills/` (user) or `.agents/skills/` (project). -Then add those skill files to Aider's `read` list in `.aider.conf.yml` at your repo root or home directory: +Then add the skill file to Aider's `read` list in `.aider.conf.yml` at your repo root or home directory: ```yaml read: - - ~/.agents/skills/webcmd-usage/SKILL.md - ~/.agents/skills/webcmd-browser/SKILL.md ``` @@ -44,7 +43,6 @@ For a project-scoped setup, use `.agents/skills/` paths relative to the repo: ```yaml read: - - .agents/skills/webcmd-usage/SKILL.md - .agents/skills/webcmd-browser/SKILL.md ``` @@ -93,4 +91,3 @@ Aider has no search index. When you need to discover URLs, find them yourself or * [Aider documentation](https://aider.chat/docs/) — installation, usage, LLM configuration, and in-chat commands. * [`start.md`](../../start.md) — common setup, [auth profiles and human handoff](../../start.md#auth-profiles-and-human-handoff), and [security](../../start.md#security). * [`webcmd-browser`](../../skills/webcmd-browser/SKILL.md) — the raw browser session surface. -* [`webcmd-usage`](../../skills/webcmd-usage/SKILL.md) — adapter-first usage rules. diff --git a/docs/agents/cline.md b/docs/agents/cline.md index 922d5d11..de4e1afe 100644 --- a/docs/agents/cline.md +++ b/docs/agents/cline.md @@ -34,7 +34,7 @@ For a project-scoped setup that travels with the repo: webcmd skills add --path .cline/skills --scope project ``` -Cline also discovers skills in `~/.cline/skills/`, `.cline/skills/`, and `.claude/skills/`. It loads skill metadata at startup and activates `webcmd-usage` and `webcmd-browser` on demand through its `use_skill` tool. +Cline also discovers skills in `~/.cline/skills/`, `.cline/skills/`, and `.claude/skills/`. It loads skill metadata at startup and activates `webcmd-browser` on demand through its `use_skill` tool. Restart Cline (or start a new task) after installing skills. In the extension, confirm they appear under the Skills tab (scale icon in the Cline panel). @@ -92,4 +92,3 @@ Check for browser or scraping MCP servers in `.cline/mcp.json` — they overlap * [Cline documentation](https://docs.cline.bot/cline-overview) — installation, providers, and the core workflow. * [`start.md`](../../start.md) — common setup, [auth profiles and human handoff](../../start.md#auth-profiles-and-human-handoff), and [security](../../start.md#security). * [`webcmd-browser`](../../skills/webcmd-browser/SKILL.md) — the raw browser session surface. -* [`webcmd-usage`](../../skills/webcmd-usage/SKILL.md) — adapter-first usage rules.