Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.
156 changes: 120 additions & 36 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
const DAEMON_PORT = 19825;
const DAEMON_HOST = "localhost";
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
const DEFAULT_DAEMON_HOST = "localhost";
const DEFAULT_DAEMON_PORT = 19825;
function normalizeDaemonHost(host) {
let value = (host || "").trim();
if (!value) return DEFAULT_DAEMON_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?.[1]) return bracketedIpv6Match[1];
const colonCount = (value.match(/:/g) || []).length;
if (colonCount === 1) {
const [hostname] = value.split(":");
value = hostname || value;
}
return value.trim() || DEFAULT_DAEMON_HOST;
}
function buildDaemonEndpoints(host, port) {
const h = normalizeDaemonHost(host);
const p = Number.isFinite(port) && port >= 1 && port <= 65535 ? port : DEFAULT_DAEMON_PORT;
const hostPart = h.includes(":") && !h.startsWith("[") ? `[${h}]` : h;
return {
ping: `http://${hostPart}:${p}/ping`,
ws: `ws://${hostPart}:${p}/ext`
};
}
const WS_RECONNECT_BASE_DELAY = 2e3;
const WS_RECONNECT_MAX_DELAY = 5e3;

Expand Down Expand Up @@ -210,9 +237,22 @@ function registerListeners() {
});
}

const STORAGE_KEYS = { host: "daemonHost", port: "daemonPort" };
let ws = null;
let activeWsUrl = null;
let pendingWsUrl = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
async function getDaemonSettings() {
const result = await chrome.storage.local.get({
[STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST,
[STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT
});
const host = normalizeDaemonHost(result[STORAGE_KEYS.host]);
let port = typeof result[STORAGE_KEYS.port] === "number" ? result[STORAGE_KEYS.port] : DEFAULT_DAEMON_PORT;
if (!Number.isFinite(port) || port < 1 || port > 65535) port = DEFAULT_DAEMON_PORT;
return { host, port };
}
const _origLog = console.log.bind(console);
const _origWarn = console.warn.bind(console);
const _origError = console.error.bind(console);
Expand All @@ -237,45 +277,70 @@ console.error = (...args) => {
forwardLog("error", args);
};
async function connect() {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
const { host, port } = await getDaemonSettings();
const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port);
if (ws) {
if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return;
if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return;
const previousSocket = ws;
ws = null;
activeWsUrl = null;
pendingWsUrl = null;
try {
previousSocket.close();
} catch {
}
}
try {
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1e3) });
if (!res.ok) return;
} catch {
return;
}
try {
ws = new WebSocket(DAEMON_WS_URL);
pendingWsUrl = wsUrl;
const socket = new WebSocket(wsUrl);
ws = socket;
socket.onopen = () => {
if (ws !== socket) return;
console.log("[opencli] Connected to daemon");
pendingWsUrl = null;
activeWsUrl = wsUrl;
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
socket.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
};
socket.onmessage = async (event) => {
if (ws !== socket) return;
try {
const command = JSON.parse(event.data);
const result = await handleCommand(command);
if (ws !== socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(result));
} catch (err) {
console.error("[opencli] Message handling error:", err);
}
};
socket.onclose = () => {
if (ws !== socket) return;
console.log("[opencli] Disconnected from daemon");
ws = null;
activeWsUrl = null;
pendingWsUrl = null;
scheduleReconnect();
};
socket.onerror = () => {
if (ws !== socket) return;
socket.close();
};
} catch {
pendingWsUrl = null;
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log("[opencli] Connected to daemon");
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
};
ws.onmessage = async (event) => {
try {
const command = JSON.parse(event.data);
const result = await handleCommand(command);
ws?.send(JSON.stringify(result));
} catch (err) {
console.error("[opencli] Message handling error:", err);
}
};
ws.onclose = () => {
console.log("[opencli] Disconnected from daemon");
ws = null;
scheduleReconnect();
};
ws.onerror = () => {
ws?.close();
};
}
const MAX_EAGER_ATTEMPTS = 6;
function scheduleReconnect() {
Expand Down Expand Up @@ -364,12 +429,31 @@ chrome.runtime.onStartup.addListener(() => {
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") void connect();
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return;
const previousSocket = ws;
ws = null;
activeWsUrl = null;
pendingWsUrl = null;
try {
previousSocket?.close();
} catch {
}
reconnectAttempts = 0;
void connect();
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === "getStatus") {
sendResponse({
connected: ws?.readyState === WebSocket.OPEN,
reconnecting: reconnectTimer !== null
void getDaemonSettings().then(({ host, port }) => {
sendResponse({
connected: ws?.readyState === WebSocket.OPEN,
reconnecting: reconnectTimer !== null,
host,
port
});
});
return true;
}
return false;
});
Expand Down
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();
Loading