Skip to content
Merged
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
45 changes: 44 additions & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,15 @@ describe('BrowserBridge state', () => {
await expect(bridge.connect()).rejects.toThrow('Session is closing');
});

it('fails fast when daemon is running but extension is disconnected', async () => {
it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
const { PKG_VERSION } = await import('./version.js');
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
state: 'no-extension',
status: {
ok: true,
pid: 1,
uptime: 0,
daemonVersion: PKG_VERSION,
extensionConnected: false,
pending: 0,
memoryMB: 0,
Expand All @@ -162,6 +164,47 @@ describe('BrowserBridge state', () => {

await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
});

it('attempts stale daemon replacement when daemonVersion is missing', async () => {
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
state: 'no-extension',
status: {
ok: true,
pid: 1,
uptime: 0,
extensionConnected: false,
pending: 0,
memoryMB: 0,
port: 0,
},
});
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);

const bridge = new BrowserBridge();

await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
});

it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
state: 'no-extension',
status: {
ok: true,
pid: 1,
uptime: 0,
daemonVersion: '0.0.1',
extensionConnected: false,
pending: 0,
memoryMB: 0,
port: 0,
},
});
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);

const bridge = new BrowserBridge();

await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
});
});

describe('stealth anti-detection', () => {
Expand Down
76 changes: 59 additions & 17 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { Page } from './page.js';
import { getDaemonHealth } from './daemon-client.js';
import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { BrowserConnectError } from '../errors.js';
import { PKG_VERSION } from '../version.js';

const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension

Expand Down Expand Up @@ -66,21 +67,50 @@ export class BrowserBridge implements IBrowserFactory {
// Fast path: everything ready
if (health.state === 'ready') return;

// Daemon running but no extension — wait for extension with progress
// Daemon running but no extension
if (health.state === 'no-extension') {
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
// Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
const daemonVersion = health.status?.daemonVersion;
const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;

if (isStale) {
// Stale daemon — restart it so extension gets a fresh WebSocket endpoint
const reason = daemonVersion
? `v${daemonVersion} ≠ v${PKG_VERSION}`
: `pre-version daemon, CLI is v${PKG_VERSION}`;
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
}
const shutdownAccepted = await requestDaemonShutdown();
const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);

if (!portReleased) {
// Stale daemon replacement failed — don't blindly spawn on an occupied port
throw new BrowserConnectError(
'Stale daemon could not be replaced',
`A stale daemon (${reason}) is running but did not shut down.\n` +
' Run manually: opencli daemon stop && opencli doctor',
'daemon-not-running',
);
}
// Port released — fall through to spawn a fresh daemon
} else {
// Same version — wait for extension to connect
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
}
if (await this._pollUntilReady(timeoutMs)) return;
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
'If not installed:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. Open chrome://extensions → Developer Mode → Load unpacked',
'extension-not-connected',
);
}
if (await this._pollUntilReady(timeoutMs)) return;
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
' Then run: opencli doctor',
'extension-not-connected',
);
}

// No daemon — spawn one
Expand Down Expand Up @@ -113,10 +143,11 @@ export class BrowserBridge implements IBrowserFactory {
if (finalHealth.state === 'no-extension') {
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
'If not installed:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
' Then run: opencli doctor',
' 2. Open chrome://extensions → Developer Mode → Load unpacked',
'extension-not-connected',
);
}
Expand All @@ -128,6 +159,17 @@ export class BrowserBridge implements IBrowserFactory {
);
}

/** Poll until daemon is fully stopped (port released). */
private async _waitForDaemonStop(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 200));
const h = await getDaemonHealth();
if (h.state === 'stopped') return true;
}
return false;
}

/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
private async _pollUntilReady(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
Expand Down
1 change: 1 addition & 0 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface DaemonStatus {
ok: boolean;
pid: number;
uptime: number;
daemonVersion?: string;
extensionConnected: boolean;
extensionVersion?: string;
extensionCompatRange?: string;
Expand Down
17 changes: 10 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
import { TargetError } from './browser/target-errors.js';
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
import { daemonStop } from './commands/daemon.js';
Expand Down Expand Up @@ -311,11 +311,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
const page = await getBrowserPage();
await fn(page, ...args);
} catch (err) {
const msg = getErrorMessage(err);
if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
log.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
} else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
if (err instanceof BrowserConnectError) {
log.error(err.message);
if (err.hint) log.error(`Hint: ${err.hint}`);
} else if (err instanceof TargetError) {
log.error(`[${err.code}] ${err.message}`);
if (err.hint) log.error(`Hint: ${err.hint}`);
Expand All @@ -324,7 +322,12 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
}
} else {
log.error(msg);
const msg = getErrorMessage(err);
if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
} else {
log.error(msg);
}
}
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
Expand Down
2 changes: 2 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { EXIT_CODES } from './errors.js';
import { log } from './logger.js';
import { PKG_VERSION } from './version.js';

const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);

Expand Down Expand Up @@ -123,6 +124,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
ok: true,
pid: process.pid,
uptime,
daemonVersion: PKG_VERSION,
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
extensionVersion,
extensionCompatRange,
Expand Down
32 changes: 24 additions & 8 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type DoctorReport = {
cliVersion?: string;
daemonRunning: boolean;
daemonFlaky?: boolean;
daemonVersion?: string;
extensionConnected: boolean;
extensionFlaky?: boolean;
extensionVersion?: string;
Expand Down Expand Up @@ -131,13 +132,27 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.',
);
} else if (daemonRunning && !extensionConnected) {
issues.push(
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
'Please install the opencli Browser Bridge extension:\n' +
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder',
);
const daemonVersion = health.status?.daemonVersion;
const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
if (isStale) {
const reason = daemonVersion
? `daemon v${daemonVersion} ≠ CLI v${opts.cliVersion}`
: `daemon predates version reporting, CLI is v${opts.cliVersion}`;
issues.push(
`Stale daemon detected: ${reason}.\n` +
'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
' Quick fix: opencli daemon stop && opencli doctor',
);
} else {
issues.push(
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
'If the extension is not installed:\n' +
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder',
);
}
}
if (connectivity && !connectivity.ok) {
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
Expand Down Expand Up @@ -177,6 +192,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
cliVersion: opts.cliVersion,
daemonRunning,
daemonFlaky,
daemonVersion: health.status?.daemonVersion,
extensionConnected,
extensionFlaky,
extensionVersion,
Expand All @@ -196,7 +212,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
const daemonLabel = report.daemonFlaky
? 'unstable (running during live check, then stopped)'
: report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running';
: report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);

// Extension status
Expand Down
Loading