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/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