Compare commits

...

10 Commits

Author SHA1 Message Date
jakevin 7f57e76485 fix: treat empty tab URL as debuggable (fixes first-run doctor --live failure) (#259)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
When a new automation window is created, the initial tab URL may be
empty briefly while Chrome loads the data: URI. isDebuggableUrl('') was
returning false, causing ensureAttached to reject the tab.

Fix: only reject known non-debuggable URLs (chrome://, chrome-extension://).
Empty/undefined URLs are now treated as debuggable since they represent
tabs still loading.

Also adds 200ms delay after window creation to let Chrome populate the
tab URL.
2026-03-22 22:10:30 +08:00
jakevin e9818c1b41 chore: remove CRX from release pipeline and docs (#258)
CRX files cannot be installed in modern Chrome without Chrome Web Store
publishing. Updated all docs to recommend 'Load unpacked' installation
method only. Added npm package loading method as alternative.

- Removed CRX build step from build-extension.yml workflow
- Removed CRX from artifact upload and release attachment
- Updated README.md, README.zh-CN.md, browser-bridge docs (en/zh)
- Added 'Load from npm package' as installation method
2026-03-22 22:07:33 +08:00
jakevin 3e91876d13 fix: replace all about:blank with data: URI to prevent New Tab Override interception (#257)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
Root cause: getAutomationWindow and resolveTabId used about:blank which
New Tab Override extensions intercept immediately, replacing it with
chrome-extension:// URLs that cannot be debugged.

Changes:
- Window creation: about:blank → data:text/html
- reuseTab fallback: about:blank → data:text/html
- newTab handler: about:blank → data:text/html
- Added diagnostic logging to resolveTabId for debugging
- Synced extension version to 1.2.4

Ref: #249
2026-03-22 22:04:25 +08:00
jakevin 7c02588105 chore: bump version to 1.2.3 (#256)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:48:17 +08:00
jakevin 112fdefa8d fix: harden resolveTabId against New Tab Override extension interception (#255)
resolveTabId's reuseTab path now verifies the URL is actually debuggable
after navigating to about:blank. If a New Tab Override extension intercepts
it (setting it back to chrome-extension://), falls back to a data: URI,
then creates a fresh tab as last resort.

This fixes the persistent 'attach failed: Cannot access chrome-extension://'
error for users with New Tab Override extensions installed.

Ref: #249
2026-03-22 21:47:44 +08:00
jakevin e077ad2336 chore: bump version to 1.2.2 (#254)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:27:03 +08:00
jakevin 81384ede00 chore: bump version to 1.2.1 (#252)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:24:42 +08:00
jakevin 71b2c3961b fix: harden browser automation pipeline (resolves #249) (#251)
- resolveTabId: validate URL even for explicit tabId, fall through to
  auto-resolve when tab is not debuggable or has been closed
- handleNavigate: wait for URL change before checking 'complete' status
  to avoid race condition with stale about:blank
- ensureAttached: pre-check tab URL, verify cached attach with probe,
  invalidate cache on URL change via onUpdated listener
- daemon-client: recognize transient extension errors (disconnected,
  attach failed) as retryable with 1500ms delay; fresh command ID per attempt
- pipeline executor: add per-step retry for browser steps (up to 2 retries
  on transient errors); cleanup automation window on pipeline failure
- page.ts: selectTab/newTab/closeTab properly update/invalidate _tabId
- daemon.ts: add WebSocket ping/pong heartbeat (15s interval, 2-miss disconnect)
- Increase automation window idle timeout from 30s to 120s
- Fix timeout param edge cases in BrowserBridge._ensureDaemon
- Remove unused chalk import; fix trailing import placement

Closes #249
2026-03-22 21:23:21 +08:00
jakevin b3b9892836 docs: add star history chart (#246) 2026-03-22 19:22:41 +08:00
jakevin 520622ac75 ci: update GitHub Actions runtime versions (#245) 2026-03-22 19:02:21 +08:00
23 changed files with 419 additions and 135 deletions
+3 -18
View File
@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
cache: 'npm'
@@ -45,35 +45,20 @@ jobs:
cd extension-package
zip -r ../opencli-extension.zip .
- name: Create Extension CRX
run: |
npm install -g crx3
if [ -n "${{ secrets.CRX_PRIVATE_KEY }}" ]; then
echo "Found CRX_PRIVATE_KEY, signing extension..."
echo "${{ secrets.CRX_PRIVATE_KEY }}" > crx-key.pem
crx3 pack extension-package -o opencli-extension.crx -p crx-key.pem
rm crx-key.pem
else
echo "No CRX_PRIVATE_KEY configured. Generating CRX with a temporary random key..."
crx3 pack extension-package -o opencli-extension.crx
fi
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v4
with:
name: opencli-extension-build
path: |
opencli-extension.zip
opencli-extension.crx
retention-days: 7
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v2.6.1
with:
files: |
opencli-extension.zip
opencli-extension.crx
draft: false
prerelease: false
env:
+6 -6
View File
@@ -18,9 +18,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
@@ -43,9 +43,9 @@ jobs:
node-version: ['20', '22']
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
@@ -62,9 +62,9 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
doc-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Check adapter doc coverage
run: bash scripts/check-doc-coverage.sh --strict
@@ -22,9 +22,9 @@ jobs:
docs-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+3 -3
View File
@@ -13,9 +13,9 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
@@ -27,7 +27,7 @@ jobs:
run: npx tsc --noEmit
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v2.6.1
with:
generate_release_notes: true
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+12 -4
View File
@@ -61,11 +61,15 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
You can install the extension via either method:
**Method 1: Download Pre-built Release (Recommended)**
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
**Method 2: Load Unpacked Source (For Developers)**
**Method 2: Load from npm Package**
1. After installing opencli via npm, open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select `node_modules/@jackwener/opencli/extension` directory.
**Method 3: Load Source (For Developers)**
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select the `extension/` directory from this repository.
@@ -342,6 +346,10 @@ git push --follow-tags
The CI will automatically build, create a GitHub release, and publish to npm.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
## License
[Apache-2.0](./LICENSE)
+12 -4
View File
@@ -62,11 +62,15 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
你可以选择以下任一方式安装扩展:
**方式一:下载构建好的安装包(推荐)**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip``opencli-extension.crx`
2. 打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**
3. `.crx` 拖入浏览器窗口,或将解压后的文件夹拖入即可完成安装
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹
**方式二:加载源码(针对开发者)**
**方式二:从 npm 包加载**
1. 通过 npm 安装 opencli 后,打开 `chrome://extensions`,启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择 `node_modules/@jackwener/opencli/extension` 目录。
**方式三:加载源码(针对开发者)**
1. 同样在 `chrome://extensions` 开启 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库代码树中的 `extension/` 文件夹。
@@ -325,6 +329,10 @@ npm version minor # 0.1.0 → 0.2.0
git push --follow-tags
```
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
## License
[Apache-2.0](./LICENSE)
+3 -3
View File
@@ -8,9 +8,9 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
### Method 1: Download Pre-built Release (Recommended)
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
### Method 2: Load Unpacked Source (For Developers)
+3 -3
View File
@@ -8,9 +8,9 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
### 方法 1:下载预构建版本(推荐)
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip``opencli-extension.crx`
2. 打开 `chrome://extensions`,启用**开发者模式**。
3. 拖放 `.crx` 文件或解压后的文件夹到扩展页面
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 `chrome://extensions`,启用**开发者模式**。
3. 点击**加载已解压的扩展程序**,选择解压后的文件夹。
### 方法 2:加载源码(开发者)
+112 -28
View File
@@ -5,8 +5,33 @@ const WS_RECONNECT_BASE_DELAY = 2e3;
const WS_RECONNECT_MAX_DELAY = 6e4;
const attached = /* @__PURE__ */ new Set();
function isDebuggableUrl$1(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function ensureAttached(tabId) {
if (attached.has(tabId)) return;
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl$1(tab.url)) {
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) {
try {
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression: "1",
returnByValue: true
});
return;
} catch {
attached.delete(tabId);
}
}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
@@ -89,6 +114,17 @@ function registerListeners() {
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try {
chrome.debugger.detach({ tabId });
} catch {
}
}
}
});
}
let ws = null;
@@ -161,7 +197,7 @@ function scheduleReconnect() {
}, delay);
}
const automationSessions = /* @__PURE__ */ new Map();
const WINDOW_IDLE_TIMEOUT = 3e4;
const WINDOW_IDLE_TIMEOUT = 12e4;
function getWorkspaceKey(workspace) {
return workspace?.trim() || "default";
}
@@ -192,7 +228,7 @@ async function getAutomationWindow(workspace) {
}
}
const win = await chrome.windows.create({
url: "about:blank",
url: "data:text/html,<html></html>",
focused: false,
width: 1280,
height: 900,
@@ -206,6 +242,7 @@ async function getAutomationWindow(workspace) {
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
await new Promise((resolve) => setTimeout(resolve, 200));
return session.windowId;
}
chrome.windows.onRemoved.addListener((windowId) => {
@@ -265,18 +302,46 @@ async function handleCommand(cmd) {
};
}
}
function isWebUrl(url) {
if (!url) return false;
function isDebuggableUrl(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function resolveTabId(tabId, workspace) {
if (tabId !== void 0) return tabId;
if (tabId !== void 0) {
try {
const tab = await chrome.tabs.get(tabId);
console.log(`[opencli] resolveTabId: explicit tabId=${tabId}, url=${tab.url}`);
if (isDebuggableUrl(tab.url)) return tabId;
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
if (webTab?.id) return webTab.id;
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) {
console.log(`[opencli] resolveTabId: found debuggable tab ${debuggableTab.id} (${debuggableTab.url})`);
return debuggableTab.id;
}
console.warn(`[opencli] resolveTabId: no debuggable tabs found, tabs: ${tabs.map((t) => `${t.id}=${t.url}`).join(", ")}`);
const reuseTab = tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: "data:text/html,<html></html>" });
await new Promise((resolve) => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
console.warn(`[opencli] about:blank was intercepted (${updated.url}), trying data: URI`);
await chrome.tabs.update(reuseTab.id, { url: "data:text/html,<html></html>" });
await new Promise((resolve) => setTimeout(resolve, 300));
const updated2 = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated2.url)) return reuseTab.id;
console.warn(`[opencli] data: URI also intercepted, creating fresh tab`);
} catch {
}
}
const newTab = await chrome.tabs.create({ windowId, url: "data:text/html,<html></html>", active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
}
@@ -292,7 +357,7 @@ async function listAutomationTabs(workspace) {
}
async function listAutomationWebTabs(workspace) {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd, workspace) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
@@ -307,28 +372,47 @@ async function handleExec(cmd, workspace) {
async function handleNavigate(cmd, workspace) {
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? "";
const targetUrl = cmd.url;
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
chrome.tabs.get(tabId).then((tab2) => {
if (tab2.status === "complete") {
resolve();
return;
let urlChanged = false;
const listener = (id, info, tab2) => {
if (id !== tabId) return;
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
const listener = (id, info) => {
if (id === tabId && info.status === "complete") {
if (urlChanged && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15e3);
});
} catch {
}
}, 100);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15e3);
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut }
};
}
async function handleTabs(cmd, workspace) {
switch (cmd.op) {
@@ -345,7 +429,7 @@ async function handleTabs(cmd, workspace) {
}
case "new": {
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true });
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "data:text/html,<html></html>", active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case "close": {
@@ -425,7 +509,7 @@ async function handleSessions(cmd) {
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return { id: cmd.id, ok: true, data };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "OpenCLI",
"version": "0.2.0",
"version": "1.2.5",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"permissions": [
"debugger",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencli-extension",
"version": "0.2.0",
"version": "1.2.5",
"private": true,
"type": "module",
"scripts": {
+95 -27
View File
@@ -97,7 +97,7 @@ type AutomationSession = {
};
const automationSessions = new Map<string, AutomationSession>();
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
function getWorkspaceKey(workspace?: string): string {
return workspace?.trim() || 'default';
@@ -135,9 +135,10 @@ async function getAutomationWindow(workspace: string): Promise<number> {
}
}
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
// Using about:blank would be hijacked by extensions like "New Tab Override".
const win = await chrome.windows.create({
url: 'about:blank',
url: 'data:text/html,<html></html>',
focused: false,
width: 1280,
height: 900,
@@ -151,6 +152,8 @@ async function getAutomationWindow(workspace: string): Promise<number> {
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
// Brief delay to let Chrome load the initial data: URI tab
await new Promise(resolve => setTimeout(resolve, 200));
return session.windowId;
}
@@ -228,7 +231,7 @@ async function handleCommand(cmd: Command): Promise<Result> {
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
function isDebuggableUrl(url?: string): boolean {
if (!url) return false;
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}
@@ -238,7 +241,21 @@ function isDebuggableUrl(url?: string): boolean {
* Otherwise, find or create a tab in the dedicated automation window.
*/
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
if (tabId !== undefined) return tabId;
// Even when an explicit tabId is provided, validate it is still debuggable.
// This prevents issues when extensions hijack the tab URL to chrome-extension://
// or when the tab has been closed by the user.
if (tabId !== undefined) {
try {
const tab = await chrome.tabs.get(tabId);
console.log(`[opencli] resolveTabId: explicit tabId=${tabId}, url=${tab.url}`);
if (isDebuggableUrl(tab.url)) return tabId;
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
// Tab was closed — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
// Get (or create) the automation window
const windowId = await getAutomationWindow(workspace);
@@ -246,7 +263,11 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
// Prefer an existing debuggable tab (about:blank, http://, https://, etc.)
const tabs = await chrome.tabs.query({ windowId });
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
if (debuggableTab?.id) {
console.log(`[opencli] resolveTabId: found debuggable tab ${debuggableTab.id} (${debuggableTab.url})`);
return debuggableTab.id;
}
console.warn(`[opencli] resolveTabId: no debuggable tabs found, tabs: ${tabs.map(t => `${t.id}=${t.url}`).join(', ')}`);
// No debuggable tab found — this typically happens when a "New Tab Override"
// extension replaces about:blank with a chrome-extension:// page.
@@ -254,12 +275,28 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
// accumulating orphan tabs if chrome.tabs.create is also intercepted).
const reuseTab = tabs.find(t => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: 'about:blank' });
return reuseTab.id;
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
// Wait for the navigation to take effect
await new Promise(resolve => setTimeout(resolve, 300));
// Verify the URL is actually debuggable (New Tab Override may have intercepted)
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
// New Tab Override intercepted about:blank — try data: URI instead
console.warn(`[opencli] about:blank was intercepted (${updated.url}), trying data: URI`);
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
await new Promise(resolve => setTimeout(resolve, 300));
const updated2 = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated2.url)) return reuseTab.id;
// data: URI also intercepted — create a brand new tab
console.warn(`[opencli] data: URI also intercepted, creating fresh tab`);
} catch {
// Tab was closed during navigation
}
}
// Window has no tabs at all — create one
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
// Window has no debuggable tabs — create one
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
if (!newTab.id) throw new Error('Failed to create tab in automation window');
return newTab.id;
}
@@ -294,31 +331,62 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });
// Wait for page to finish loading, checking current status first to avoid race
// Capture the current URL before navigation to detect actual URL change
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? '';
const targetUrl = cmd.url;
await chrome.tabs.update(tabId, { url: targetUrl });
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
let timedOut = false;
await new Promise<void>((resolve) => {
// Check if already complete (e.g. cached pages)
chrome.tabs.get(tabId).then(tab => {
if (tab.status === 'complete') { resolve(); return; }
let urlChanged = false;
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
if (id !== tabId) return;
// Track URL change (new URL differs from the one before navigation)
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
// Only resolve when both URL has changed AND status is complete
if (urlChanged && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Also check if the tab already navigated (e.g. instant cache hit)
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Timeout fallback
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15000);
});
} catch { /* tab gone */ }
}, 100);
// Timeout fallback with warning
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15000);
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut },
};
}
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
@@ -337,7 +405,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
}
case 'new': {
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'data:text/html,<html></html>', active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case 'close': {
+42 -1
View File
@@ -8,8 +8,40 @@
const attached = new Set<number>();
/** Check if a URL can be attached via CDP */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}
async function ensureAttached(tabId: number): Promise<void> {
if (attached.has(tabId)) return;
// Verify the tab URL is debuggable before attempting attach
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
// Invalidate cache if previously attached
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
}
} catch (e) {
// Re-throw our own error, catch only chrome.tabs.get failures
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) {
// Verify the debugger is still actually attached by sending a harmless command
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1', returnByValue: true,
});
return; // Still attached and working
} catch {
// Stale cache entry — need to re-attach
attached.delete(tabId);
}
}
try {
await chrome.debugger.attach({ tabId }, '1.3');
@@ -122,4 +154,13 @@ export function registerListeners(): void {
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
}
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.2.0",
"version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.2.0",
"version": "1.2.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.2.0",
"version": "1.2.5",
"publishConfig": {
"access": "public"
},
+20 -5
View File
@@ -7,6 +7,8 @@
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
import type { BrowserSessionInfo } from '../types.js';
let _idCounter = 0;
function generateId(): string {
@@ -69,17 +71,19 @@ export async function isExtensionConnected(): Promise<boolean> {
/**
* Send a command to the daemon and wait for a result.
* Retries up to 3 times with 500ms delay for transient failures.
* Retries up to 4 times: network errors retry at 500ms,
* transient extension errors retry at 1500ms.
*/
export async function sendCommand(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
): Promise<unknown> {
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
const maxRetries = 3;
const maxRetries = 4;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Generate a fresh ID per attempt to avoid daemon-side duplicate detection
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
@@ -95,6 +99,17 @@ export async function sendCommand(
const result = (await res.json()) as DaemonResult;
if (!result.ok) {
// Check if error is a transient extension issue worth retrying
const errMsg = result.error ?? '';
const isTransient = errMsg.includes('Extension disconnected')
|| errMsg.includes('Extension not connected')
|| errMsg.includes('attach failed')
|| errMsg.includes('no longer exists');
if (isTransient && attempt < maxRetries) {
// Longer delay for extension recovery (service worker restart)
await new Promise(r => setTimeout(r, 1500));
continue;
}
throw new Error(result.error ?? 'Daemon command failed');
}
@@ -117,4 +132,4 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
const result = await sendCommand('sessions');
return Array.isArray(result) ? result : [];
}
import type { BrowserSessionInfo } from '../types.js';
+3 -1
View File
@@ -55,7 +55,9 @@ export class BrowserBridge {
}
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
// Use default if not provided, zero, or negative
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
const timeoutMs = effectiveSeconds * 1000;
if (await isExtensionConnected()) return;
if (await isDaemonRunning()) {
+7 -2
View File
@@ -186,14 +186,19 @@ export class Page implements IPage {
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
// Invalidate cached tabId — the closed tab might have been our active one.
// We can't know for sure (close-by-index doesn't return tabId), so reset.
this._tabId = undefined;
}
async newTab(): Promise<void> {
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
if (result?.tabId) this._tabId = result.tabId;
}
async selectTab(index: number): Promise<void> {
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number };
if (result?.selected) this._tabId = result.selected;
}
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
+23
View File
@@ -142,6 +142,27 @@ wss.on('connection', (ws: WebSocket) => {
console.error('[daemon] Extension connected');
extensionWs = ws;
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
let missedPongs = 0;
const heartbeatInterval = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
clearInterval(heartbeatInterval);
return;
}
if (missedPongs >= 2) {
console.error('[daemon] Extension heartbeat lost, closing connection');
clearInterval(heartbeatInterval);
ws.terminate();
return;
}
missedPongs++;
ws.ping();
}, 15000);
ws.on('pong', () => {
missedPongs = 0;
});
ws.on('message', (data: RawData) => {
try {
const msg = JSON.parse(data.toString());
@@ -168,6 +189,7 @@ wss.on('connection', (ws: WebSocket) => {
ws.on('close', () => {
console.error('[daemon] Extension disconnected');
clearInterval(heartbeatInterval);
if (extensionWs === ws) {
extensionWs = null;
// Reject all pending requests since the extension is gone
@@ -180,6 +202,7 @@ wss.on('connection', (ws: WebSocket) => {
});
ws.on('error', () => {
clearInterval(heartbeatInterval);
if (extensionWs === ws) extensionWs = null;
});
});
+61 -16
View File
@@ -2,7 +2,7 @@
* Pipeline executor: runs YAML pipeline steps sequentially.
*/
import chalk from 'chalk';
import type { IPage } from '../types.js';
import { getStep, type StepHandler } from './registry.js';
import { log } from '../logger.js';
@@ -11,8 +11,13 @@ import { ConfigError } from '../errors.js';
export interface PipelineContext {
args?: Record<string, unknown>;
debug?: boolean;
/** Max retry attempts per step (default: 2 for browser steps, 0 for others) */
stepRetries?: number;
}
/** Steps that interact with the browser and may fail transiently */
const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']);
export async function executePipeline(
page: IPage | null,
pipeline: unknown[],
@@ -23,28 +28,68 @@ export async function executePipeline(
let data: unknown = null;
const total = pipeline.length;
for (let i = 0; i < pipeline.length; i++) {
const step = pipeline[i];
if (!step || typeof step !== 'object') continue;
for (const [op, params] of Object.entries(step)) {
if (debug) debugStepStart(i + 1, total, op, params);
try {
for (let i = 0; i < pipeline.length; i++) {
const step = pipeline[i];
if (!step || typeof step !== 'object') continue;
for (const [op, params] of Object.entries(step)) {
if (debug) debugStepStart(i + 1, total, op, params);
const handler = getStep(op);
if (handler) {
data = await handler(page, params, data, args);
} else {
throw new ConfigError(
`Unknown pipeline step "${op}" at index ${i}.`,
'Check the YAML pipeline step name or register the custom step before execution.',
);
const handler = getStep(op);
if (handler) {
data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries);
} else {
throw new ConfigError(
`Unknown pipeline step "${op}" at index ${i}.`,
'Check the YAML pipeline step name or register the custom step before execution.',
);
}
if (debug) debugStepResult(op, data);
}
if (debug) debugStepResult(op, data);
}
} catch (err) {
// Attempt cleanup: close automation window on pipeline failure
if (page && typeof (page as unknown as Record<string, unknown>).closeWindow === 'function') {
try { await (page as unknown as { closeWindow: () => Promise<void> }).closeWindow(); } catch { /* ignore */ }
}
throw err;
}
return data;
}
async function executeStepWithRetry(
handler: StepHandler,
page: IPage | null,
params: unknown,
data: unknown,
args: Record<string, unknown>,
op: string,
configRetries?: number,
): Promise<unknown> {
const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await handler(page, params, data, args);
} catch (err) {
if (attempt >= maxRetries) throw err;
// Only retry on transient browser errors
const msg = err instanceof Error ? err.message : '';
const isTransient = msg.includes('Extension disconnected')
|| msg.includes('attach failed')
|| msg.includes('no longer exists')
|| msg.includes('CDP connection')
|| msg.includes('Daemon command failed');
if (!isTransient) throw err;
// Brief delay before retry
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Unreachable
throw new Error(`Step "${op}" failed after ${maxRetries} retries`);
}
function debugStepStart(stepNum: number, total: number, op: string, params: unknown): void {
let preview = '';
if (typeof params === 'string') {