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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
pull_request:

jobs:
rust:
name: Rust (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets --all-features
- run: cargo test --all-targets --all-features

extension:
name: Chrome extension syntax
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: node --check assets/tmwd_cdp_bridge/background.js
- run: node --check assets/tmwd_cdp_bridge/content.js
- run: node --check assets/tmwd_cdp_bridge/popup.js
64 changes: 63 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ path = "src/main.rs"
anyhow = "1.0"
axum = { version = "0.7", features = ["json", "ws"] }
clap = { version = "4.5", features = ["derive"] }
dirs = "6.0"
dunce = "1.0"
fs2 = "0.4"
html5ever = "0.27"
libc = "0.2"
markup5ever_rcdom = "0.3"
path-clean = "1.0"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.48", features = ["macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
tower-http = { version = "0.6", features = ["cors"] }
url = "2.5"
uuid = { version = "1.18", features = ["v4"] }
33 changes: 23 additions & 10 deletions assets/tmwd_cdp_bridge/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const DEFAULT_WS_PORT = 18765;
const CLI_API_PORT = 18767;
let wsPort = DEFAULT_WS_PORT;
const browserId = `browser-${crypto.randomUUID()}`;
const extensionVersion = chrome.runtime.getManifest().version;
let profileId = null;
let profileLabel = null;

Expand All @@ -40,7 +41,7 @@ function withClientIdentity(payload) {
}

async function handleExtMessage(msg, sender) {
if (msg.cmd === 'status') return handleStatus();
if (msg.cmd === 'status') return await handleStatus();
if (msg.cmd === 'setPort') return await handleSetPort(msg);
if (msg.cmd === 'setProfileLabel') return await handleSetProfileLabel(msg);
lastCommandAt = Date.now();
Expand All @@ -67,7 +68,7 @@ async function handleExtMessage(msg, sender) {
if (msg.allowFocus === true && tab.windowId) await chrome.windows.update(tab.windowId, { focused: true });
return { ok: true };
} else {
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
const tabs = await queryScriptableTabs();
const data = tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }));
return { ok: true, data };
}
Expand Down Expand Up @@ -109,7 +110,7 @@ async function handleExtMessage(msg, sender) {
return { ok: false, error: 'Unknown cmd: ' + msg.cmd };
}

function handleStatus() {
async function handleStatus() {
return {
ok: true,
data: {
Expand All @@ -119,7 +120,10 @@ function handleStatus() {
lastCommandAt,
browserId,
profileId,
profileLabel
profileLabel,
extensionId: chrome.runtime.id,
extensionVersion,
fileSchemeAccess: await chrome.extension.isAllowedFileSchemeAccess()
}
};
}
Expand Down Expand Up @@ -645,7 +649,7 @@ async function handleBatch(msg, sender) {
if (c.cmd === 'cookies') {
R.push(await handleCookies(c, sender));
} else if (c.cmd === 'tabs') {
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
const tabs = await queryScriptableTabs();
R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) });
} else if (c.cmd === 'cdp') {
const tabId = c.tabId || msg.tabId || sender.tab?.id;
Expand Down Expand Up @@ -687,11 +691,16 @@ async function handleCDP(msg, sender) {
return { ok: false, error: e.message };
}
}
// Filter out chrome:// and other internal tabs that can't be scripted
const isScriptable = url => url && /^https?:/.test(url);
// Filter out chrome:// and other internal tabs that can't be scripted.
const isScriptable = (url, fileSchemeAccess = false) => !!url && (/^https?:/i.test(url) || (fileSchemeAccess && /^file:/i.test(url)));

async function queryScriptableTabs() {
const fileSchemeAccess = await chrome.extension.isAllowedFileSchemeAccess();
return (await chrome.tabs.query({})).filter(tab => isScriptable(tab.url, fileSchemeAccess));
}

async function injectContentScriptsIntoExistingTabs() {
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
const tabs = await queryScriptableTabs();
for (const tab of tabs) {
try {
await chrome.scripting.executeScript({
Expand Down Expand Up @@ -1029,9 +1038,11 @@ async function connectWS() {
console.log('[TMWD-WS] Connected!');
scheduleKeepalive(); // Keep SW alive while connected
await loadClientIdentity();
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
const tabs = await queryScriptableTabs();
ws.send(JSON.stringify(withClientIdentity({
type: 'ext_ready',
extension_version: extensionVersion,
file_scheme_access: await chrome.extension.isAllowedFileSchemeAccess(),
tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
})));
console.log('[TMWD-WS] Sent ext_ready with', tabs.length, 'tabs');
Expand Down Expand Up @@ -1103,10 +1114,12 @@ chrome.runtime.onInstalled.addListener(() => {
// Sync tab list on changes
async function sendTabsUpdate() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url) && !/streamlit/i.test(t.title));
const tabs = (await queryScriptableTabs()).filter(t => !/streamlit/i.test(t.title));
await loadClientIdentity();
ws.send(JSON.stringify(withClientIdentity({
type: 'tabs_update',
extension_version: extensionVersion,
file_scheme_access: await chrome.extension.isAllowedFileSchemeAccess(),
tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
})));
}
Expand Down
2 changes: 1 addition & 1 deletion assets/tmwd_cdp_bridge/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Agent Browser CLI Bridge",
"version": "2.0",
"version": "2.1",
"description": "Browser control bridge for agent-browser-cli",
"permissions": [
"cookies",
Expand Down
1 change: 1 addition & 0 deletions assets/tmwd_cdp_bridge/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<h3>🔌 Bridge</h3>
<div class="panel">
<div id="bridgeStatus" class="status">读取连接状态...</div>
<div id="fileAccessStatus" class="status">读取文件网址权限...</div>
<div class="row">
<label>端口 <input id="port" type="number" min="1" max="65535"></label>
<button id="savePort">保存并重连</button>
Expand Down
6 changes: 6 additions & 0 deletions assets/tmwd_cdp_bridge/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ async function refreshBridgeStatus() {
const data = resp.data || {};
portInput.value = data.wsPort || 18765;
status.textContent = `状态: ${data.wsConnected ? '已连接' : '未连接'} ${data.wsUrl || ''}`;
const fileAccessStatus = document.getElementById('fileAccessStatus');
fileAccessStatus.textContent = `文件网址访问: ${data.fileSchemeAccess ? '已启用' : '未启用'}`;
fileAccessStatus.className = data.fileSchemeAccess ? 'status' : 'error';
renderProfileStatus(data);
} catch (e) {
status.textContent = '状态读取失败: ' + e.message;
status.className = 'error';
const fileAccessStatus = document.getElementById('fileAccessStatus');
fileAccessStatus.textContent = '文件网址权限读取失败: ' + e.message;
fileAccessStatus.className = 'error';
const profileStatus = document.getElementById('profileStatus');
profileStatus.textContent = 'Profile 读取失败: ' + e.message;
profileStatus.className = 'error';
Expand Down
24 changes: 24 additions & 0 deletions skills/agent-browser-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,30 @@ agent-browser-cli logs --tail 100

补充一个容易误判的点:`status` / `doctor` 里看到 `daemon_not_running`,如果此时还没有执行 `tabs` / `open` / `exec` / `scan`,通常只是 daemon 按需常驻而未启动,不代表故障。只有目标命令已经失败,或者日志/输出明确提示端口、扩展、标签页异常时,才进入排障。

## 打开本地文件

`open` 支持 HTTP(S) URL、绝对 `file://` URL 和本机文件路径。相对路径以执行 CLI 时的当前目录为基准;支持 `~`、中文、空格和符号链接,符号链接会解析到真实文件。所有普通文件类型均可打开,目录会被拒绝。

```bash
agent-browser-cli open ./demo.html
agent-browser-cli open ~/Documents/report.pdf
agent-browser-cli open 'file:///Users/me/My%20Files/demo.html#intro'
agent-browser-cli open ./demo.html --timeout 15
```

本地文件要求 Chrome 扩展版本至少为 2.1,并在 `chrome://extensions` → Agent Browser CLI Bridge → 详情中开启“允许访问文件网址”。权限关闭时 `open` 会在创建标签前失败;`status` / `doctor` 会按 Profile 报告 `file_scheme_access`,但该可选能力不影响普通 HTTP(S) 健康状态。

输入分类规则:

- `./`、`../`、`~/`、绝对路径和显式 `file://` 表示本地文件;文件必须存在且是普通文件。
- 裸输入若对应当前目录中已存在的文件,文件优先;否则按 Web 地址处理。要强制打开网站,显式写 `https://`。
- 普通路径中的 `?` 和 `#` 是文件名字符;仅显式 `file://` URL 使用 query/fragment 语义。
- `localhost`、回环/私网 IP、单标签主机和 `.local` 的无协议地址默认使用 HTTP;其他域名默认 HTTPS;端口 80/443 分别强制 HTTP/HTTPS。
- 只支持 `http:`、`https:` 和 `file:`;其他显式 scheme 会返回 `unsupported_scheme`。
- 异平台路径会明确报错,例如 macOS/Linux 不会把 `C:\\Users\\...` 错当作网址。

本地文件 `open` 默认最多等待 10 秒,直到新标签成为可控制会话;可用 `--timeout` 调整。超时后标签会保留,错误结果包含稳定的 `error_code` 和 `opened_tab_id`。活动 `open` 成功后新标签成为默认会话,`--background` 不切换默认会话。

## 常用命令优先级

先区分三个入口:
Expand Down
Loading
Loading