Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions src/browser/browser-binary.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
40 changes: 40 additions & 0 deletions src/browser/browser-binary.ts
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions src/browser/runtime/local-cloak/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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({
Expand Down
14 changes: 12 additions & 2 deletions src/browser/runtime/local-cloak/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ 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';
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;
Expand Down Expand Up @@ -724,12 +728,18 @@ export class CloakSessionManager {
private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise<ProfileRuntime> {
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;
Expand Down
29 changes: 28 additions & 1 deletion src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 14 additions & 7 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 = {
Expand Down Expand Up @@ -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();
Expand All @@ -115,7 +122,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise<Co
let sessionId: string | undefined;
try {
// A first-use download can exceed doctor's deliberately short live-probe deadline.
await ensureBinary();
if (!resolveBrowserBinaryOverride()) 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.');
Expand Down Expand Up @@ -175,13 +182,13 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
if (binary.error) {
issues.push(`Could not check CloakBrowser Chromium binary: ${binary.error}`);
} else if (binary.installed === false) {
const source = binary.override ? `CLOAKBROWSER_BINARY_PATH (${binary.path})` : binary.path;
const source = binary.override ? `${binary.overrideEnv} (${binary.path})` : binary.path;
issues.push(
`CloakBrowser Chromium is ${binary.override ? 'not launchable at' : 'not installed at'} ${source}.\n` +
(binary.downloadUrl ? ` Download URL: ${binary.downloadUrl}\n` : '') +
(binary.override
? ' Check that CLOAKBROWSER_BINARY_PATH points at a compatible local Chromium executable.'
: ' Check network access to the download URL above, or set CLOAKBROWSER_BINARY_PATH to a compatible local Chromium executable.'),
? ` Check that ${binary.overrideEnv} points at a compatible local Chromium executable.`
: ' Check network access to the download URL above, or set WEBCMD_BROWSER_BINARY_PATH to a compatible local Chromium executable.'),
);
}
if (daemonFlaky) {
Expand Down
Loading