diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9bec422
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 27ce86b..7ae62cf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -9,15 +9,18 @@ dependencies = [
"anyhow",
"axum",
"clap",
+ "dirs",
+ "dunce",
"fs2",
"html5ever",
"libc",
"markup5ever_rcdom",
+ "path-clean",
"reqwest",
"serde",
"serde_json",
"tokio",
- "tower-http",
+ "url",
"uuid",
]
@@ -294,6 +297,27 @@ dependencies = [
"crypto-common",
]
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -305,6 +329,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -764,6 +794,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "litemap"
version = "0.8.2"
@@ -870,6 +909,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -893,6 +938,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "path-clean"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef"
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1130,6 +1181,17 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "reqwest"
version = "0.12.28"
diff --git a/Cargo.toml b/Cargo.toml
index 5d4e265..fa3adf9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"] }
diff --git a/assets/tmwd_cdp_bridge/background.js b/assets/tmwd_cdp_bridge/background.js
index 0009ace..baeee07 100644
--- a/assets/tmwd_cdp_bridge/background.js
+++ b/assets/tmwd_cdp_bridge/background.js
@@ -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;
@@ -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();
@@ -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 };
}
@@ -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: {
@@ -119,7 +120,10 @@ function handleStatus() {
lastCommandAt,
browserId,
profileId,
- profileLabel
+ profileLabel,
+ extensionId: chrome.runtime.id,
+ extensionVersion,
+ fileSchemeAccess: await chrome.extension.isAllowedFileSchemeAccess()
}
};
}
@@ -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;
@@ -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({
@@ -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');
@@ -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 }))
})));
}
diff --git a/assets/tmwd_cdp_bridge/manifest.json b/assets/tmwd_cdp_bridge/manifest.json
index 911e21f..40cf8ca 100644
--- a/assets/tmwd_cdp_bridge/manifest.json
+++ b/assets/tmwd_cdp_bridge/manifest.json
@@ -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",
diff --git a/assets/tmwd_cdp_bridge/popup.html b/assets/tmwd_cdp_bridge/popup.html
index 1a4fb2f..fb2974b 100644
--- a/assets/tmwd_cdp_bridge/popup.html
+++ b/assets/tmwd_cdp_bridge/popup.html
@@ -21,6 +21,7 @@
🔌 Bridge
读取连接状态...
+
读取文件网址权限...
diff --git a/assets/tmwd_cdp_bridge/popup.js b/assets/tmwd_cdp_bridge/popup.js
index 5a1a131..ae46742 100644
--- a/assets/tmwd_cdp_bridge/popup.js
+++ b/assets/tmwd_cdp_bridge/popup.js
@@ -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';
diff --git a/skills/agent-browser-cli/SKILL.md b/skills/agent-browser-cli/SKILL.md
index 16f5a59..d8823fc 100644
--- a/skills/agent-browser-cli/SKILL.md
+++ b/skills/agent-browser-cli/SKILL.md
@@ -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` 不切换默认会话。
+
## 常用命令优先级
先区分三个入口:
diff --git a/src/cli.rs b/src/cli.rs
index b10ad0b..08dc6e2 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -341,7 +341,7 @@ struct ConsoleListArgs {
#[derive(Debug, Args)]
struct OpenArgs {
- url: String,
+ target: String,
#[arg(long)]
background: bool,
#[arg(long)]
@@ -358,7 +358,7 @@ struct OpenArgs {
session: Option
,
#[arg(long = "group-title")]
group_title: Option,
- #[arg(long, default_value_t = 30.0)]
+ #[arg(long, default_value_t = 10.0)]
timeout: f64,
}
@@ -578,12 +578,19 @@ pub fn run() -> Result<()> {
CommandKind::Network(args) => run_network_command(args),
CommandKind::Console(args) => run_console_command(args),
CommandKind::Open(args) => {
+ if !args.timeout.is_finite() || args.timeout <= 0.0 {
+ return Err(anyhow!("open --timeout 必须是大于 0 的有限秒数"));
+ }
ensure_server()?;
+ let cwd = env::current_dir().context("无法读取当前工作目录")?;
+ let request_timeout = (args.timeout + 20.0).max(30.0);
print_json(request(
"POST",
"/open",
Some(json!({
- "url": args.url,
+ "target": args.target.clone(),
+ "url": args.target,
+ "cwd": cwd,
"active": !args.background,
"window": args.window,
"allow_focus": args.focus,
@@ -592,8 +599,9 @@ pub fn run() -> Result<()> {
"profile": args.profile,
"session": args.session,
"group_title": args.group_title,
+ "readiness_timeout": args.timeout,
})),
- args.timeout,
+ request_timeout,
)?);
Ok(())
}
@@ -668,10 +676,17 @@ fn request(method: &str, path: &str, payload: Option, timeout_secs: f64)
.timeout(Duration::from_secs_f64(timeout_secs.max(0.1)))
.build()?;
let url = format!("http://{HOST}:{PORT}{path}");
+ let token = config::load_api_token()?;
+ let with_token = |request: reqwest::blocking::RequestBuilder| {
+ if let Some(token) = token.as_deref() {
+ request.header(config::API_TOKEN_HEADER, token)
+ } else {
+ request
+ }
+ };
let response = match method {
- "GET" => client.get(url).send()?,
- "POST" => client
- .post(url)
+ "GET" => with_token(client.get(url)).send()?,
+ "POST" => with_token(client.post(url))
.json(&payload.unwrap_or_else(|| json!({})))
.send()?,
_ => return Err(anyhow!("不支持的 HTTP 方法: {method}")),
@@ -693,6 +708,7 @@ fn ensure_server() -> Result<()> {
let lock_path = project_dir().join(".agent-browser-cli.lock");
let lock = OpenOptions::new()
.create(true)
+ .truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
@@ -909,6 +925,11 @@ fn doctor_value() -> Result {
.and_then(|v| v.pointer("/connection/active_tabs"))
.and_then(Value::as_u64)
.unwrap_or(0);
+ let file_scheme_profiles = health
+ .as_ref()
+ .and_then(|v| v.pointer("/connection/profiles"))
+ .cloned()
+ .unwrap_or_else(|| json!([]));
let running = health
.as_ref()
.and_then(|v| v.get("running"))
@@ -974,7 +995,14 @@ fn doctor_value() -> Result {
"name": "active_tabs",
"ok": active_tabs > 0,
"active_tabs": active_tabs,
- "hint": if active_tabs > 0 { Value::Null } else { json!("Chrome 需要至少打开一个普通 http/https 网页标签页") }
+ "hint": if active_tabs > 0 { Value::Null } else { json!("Chrome 需要至少打开一个普通 http/https/file 网页标签页") }
+ }));
+ checks.push(json!({
+ "name": "file_scheme_access",
+ "ok": true,
+ "optional": true,
+ "profiles": file_scheme_profiles,
+ "hint": "文件网址访问是可选能力;关闭时不影响普通 http/https 页面控制"
}));
let ok = checks
diff --git a/src/config.rs b/src/config.rs
index 5fe911e..9751259 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,11 +1,13 @@
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::env;
-use std::fs;
+use std::fs::{self, OpenOptions};
+use std::io::{ErrorKind, Write};
use std::path::PathBuf;
pub const DEFAULT_EXTENSION_PORT: u16 = 18765;
pub const CLI_API_PORT: u16 = 18767;
+pub const API_TOKEN_HEADER: &str = "x-agent-browser-token";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
@@ -94,6 +96,70 @@ pub fn daemon_log_path() -> Result {
Ok(log_dir()?.join("daemon.log"))
}
+pub fn api_token_path() -> Result {
+ Ok(user_config_dir()?.join("api-token"))
+}
+
+pub fn load_api_token() -> Result