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
4 changes: 4 additions & 0 deletions docs/guide/browser-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ opencli daemon restart # Stop + restart
```

Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely.

## Remote Exposure Warning

If you point the extension at a daemon that is reachable over a public tunnel, or any non-local network path, add your own protection layer first. The daemon has minimal built-in auth, so prefer a VPN, SSH tunnel, or another authenticated/private tunnel instead of exposing the port directly to the public internet.
3 changes: 2 additions & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"tabs",
"cookies",
"activeTab",
"alarms"
"alarms",
"storage"
],
"host_permissions": [
"<all_urls>"
Expand Down
69 changes: 69 additions & 0 deletions extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,64 @@
.dot.connecting { background: #ff9500; }
.status-text { font-size: 13px; color: #555; }
.status-text strong { color: #333; }
.settings {
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #f5f5f5;
}
.settings label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
margin-top: 8px;
}
.settings label:first-of-type { margin-top: 0; }
.settings input {
width: 100%;
padding: 6px 8px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #333;
}
.settings input:focus {
outline: none;
border-color: #007aff;
}
.settings button {
margin-top: 10px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: #007aff;
color: #fff;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
}
.settings button:hover { background: #0066d6; }
.settings button:active { opacity: 0.9; }
.settings .save-hint {
margin-top: 6px;
font-size: 11px;
color: #34c759;
min-height: 16px;
}
.settings .warning {
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #fff4e5;
font-size: 11px;
color: #7a4b00;
line-height: 1.5;
}
.hint {
margin-top: 10px;
padding: 8px 10px;
Expand Down Expand Up @@ -73,6 +131,17 @@ <h1>OpenCLI</h1>
<span class="dot disconnected" id="dot"></span>
<span class="status-text" id="status">Checking...</span>
</div>
<div class="settings">
<label for="host">Daemon host</label>
<input type="text" id="host" autocomplete="off" spellcheck="false" placeholder="localhost">
<label for="port">Daemon port</label>
<input type="number" id="port" min="1" max="65535" placeholder="19825">
<button type="button" id="save">Save &amp; reconnect</button>
<div class="save-hint" id="saveHint"></div>
<div class="warning">
If you expose the daemon remotely, protect it with a VPN, SSH tunnel, or another authenticated tunnel. The daemon has minimal built-in auth.
</div>
</div>
<div class="hint" id="hint">
This is normal. The extension connects automatically when you run any <code>opencli</code> command.
</div>
Expand Down
91 changes: 89 additions & 2 deletions extension/popup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
// Query connection status from background service worker
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
const DEFAULT_HOST = 'localhost';
const DEFAULT_PORT = 19825;
const HOST_VALIDATION_ERROR = 'Enter hostname or IP only, no scheme or port.';

function normalizeHost(host) {
let value = (host || '').trim();
if (!value) return DEFAULT_HOST;

if (value.includes('://')) {
try {
value = new URL(value).hostname || value;
} catch {
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
}
}

value = value.replace(/[/?#].*$/, '');

const bracketedIpv6Match = value.match(/^\[([^\]]+)\](?::\d+)?$/);
if (bracketedIpv6Match && bracketedIpv6Match[1]) return bracketedIpv6Match[1];

const colonCount = (value.match(/:/g) || []).length;
if (colonCount === 1) {
const [hostname] = value.split(':');
value = hostname || value;
}

return value.trim() || DEFAULT_HOST;
}

function validateHost(host) {
const value = (host || '').trim();
if (!value) return null;
if (value.includes('://')) return HOST_VALIDATION_ERROR;
if (/[/?#]/.test(value)) return HOST_VALIDATION_ERROR;
if (/^\[[^\]]+\]:\d+$/.test(value)) return HOST_VALIDATION_ERROR;

const colonCount = (value.match(/:/g) || []).length;
if (colonCount === 1 && !value.startsWith('[')) return HOST_VALIDATION_ERROR;

return null;
}

function renderStatus(resp) {
const dot = document.getElementById('dot');
const status = document.getElementById('status');
const hint = document.getElementById('hint');
Expand All @@ -22,4 +64,49 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
status.innerHTML = '<strong>No daemon connected</strong>';
hint.style.display = 'block';
}
}

function loadFields() {
chrome.storage.local.get(
{ daemonHost: DEFAULT_HOST, daemonPort: DEFAULT_PORT },
(stored) => {
document.getElementById('host').value = normalizeHost(stored.daemonHost);
document.getElementById('port').value = String(stored.daemonPort ?? DEFAULT_PORT);
},
);
}

function refreshStatus() {
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
renderStatus(resp);
});
}

document.getElementById('save').addEventListener('click', () => {
const hostRaw = document.getElementById('host').value;
const hostError = validateHost(hostRaw);
const host = normalizeHost(hostRaw);
const portNum = parseInt(document.getElementById('port').value, 10);
const hintEl = document.getElementById('saveHint');
if (hostError) {
hintEl.textContent = hostError;
hintEl.style.color = '#ff3b30';
return;
}
if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
hintEl.textContent = 'Enter a valid port (1–65535).';
hintEl.style.color = '#ff3b30';
return;
}
chrome.storage.local.set({ daemonHost: host, daemonPort: portNum }, () => {
hintEl.textContent = 'Saved. Reconnecting…';
hintEl.style.color = '#34c759';
setTimeout(() => {
hintEl.textContent = '';
refreshStatus();
}, 800);
});
});

loadFields();
refreshStatus();
107 changes: 105 additions & 2 deletions extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,44 @@ type MockTab = {
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static instances: MockWebSocket[] = [];

url: string;
readyState = MockWebSocket.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;

constructor(_url: string) {}
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send(_data: string): void {}
close(): void {
this.onclose?.();
}
}

function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}

function createChromeMock() {
let nextTabId = 10;
const storageState = {
daemonHost: 'localhost',
daemonPort: 19825,
};
const tabs: MockTab[] = [
{ id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' },
{ id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' },
Expand Down Expand Up @@ -91,19 +114,29 @@ function createChromeMock() {
onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>,
getManifest: vi.fn(() => ({ version: 'test-version' })),
},
storage: {
local: {
get: vi.fn(async (defaults: Record<string, unknown>) => ({
...defaults,
...storageState,
})),
},
onChanged: { addListener: vi.fn() } as Listener<(changes: unknown, area: string) => void>,
},
cookies: {
getAll: vi.fn(async () => []),
},
};

return { chrome, tabs, query, create, update };
return { chrome, tabs, query, create, update, storageState };
}

describe('background tab isolation', () => {
beforeEach(() => {
vi.resetModules();
vi.useRealTimers();
vi.stubGlobal('WebSocket', MockWebSocket);
MockWebSocket.instances = [];
});

afterEach(() => {
Expand Down Expand Up @@ -237,4 +270,74 @@ describe('background tab isolation', () => {
expect(chrome.windows.remove).toHaveBeenCalledWith(1);
expect(mod.__test__.getSession('site:notebooklm')).toBeNull();
});

it('ignores stale websocket close events after reconnecting with new settings', async () => {
const { chrome, storageState } = createChromeMock();
vi.stubGlobal('chrome', chrome);
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true })));

const mod = await import('./background');

await mod.__test__.connect();
const firstSocket = MockWebSocket.instances.at(-1)!;
firstSocket.readyState = MockWebSocket.OPEN;
firstSocket.onopen?.();

storageState.daemonHost = '127.0.0.1';

await mod.__test__.connect();
const secondSocket = MockWebSocket.instances.at(-1)!;
secondSocket.readyState = MockWebSocket.OPEN;
secondSocket.onopen?.();

firstSocket.onclose?.();

expect(mod.__test__.getConnectionState()).toEqual(expect.objectContaining({
ws: secondSocket,
wsUrl: 'ws://127.0.0.1:19825/ext',
readyState: MockWebSocket.OPEN,
reconnecting: false,
}));
});

it('ignores stale in-flight connect attempts after settings change', async () => {
const { chrome, storageState } = createChromeMock();
vi.stubGlobal('chrome', chrome);

const oldFetch = createDeferred<{ ok: boolean }>();
const newFetch = createDeferred<{ ok: boolean }>();
vi.stubGlobal('fetch', vi.fn((url: string) => {
if (url === 'http://localhost:19825/ping') return oldFetch.promise;
if (url === 'http://127.0.0.1:19825/ping') return newFetch.promise;
throw new Error(`Unexpected fetch url: ${url}`);
}));

const mod = await import('./background');

const firstConnect = mod.__test__.connect();
await flushPromises();

storageState.daemonHost = '127.0.0.1';
const secondConnect = mod.__test__.connect();
await flushPromises();

newFetch.resolve({ ok: true });
await secondConnect;

const newSocket = MockWebSocket.instances.at(-1)!;
newSocket.readyState = MockWebSocket.OPEN;
newSocket.onopen?.();

oldFetch.resolve({ ok: true });
await firstConnect;

expect(MockWebSocket.instances).toHaveLength(1);
expect(newSocket.url).toBe('ws://127.0.0.1:19825/ext');
expect(mod.__test__.getConnectionState()).toEqual(expect.objectContaining({
ws: newSocket,
wsUrl: 'ws://127.0.0.1:19825/ext',
readyState: MockWebSocket.OPEN,
reconnecting: false,
}));
});
});
Loading