dsh-tabbit 0.2.3 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +70 -76
- package/client/client.js +390 -0
- package/cordis.patch.yml +76 -5
- package/lib/core/index.js +756 -0
- package/lib/installer/detect.js +374 -0
- package/lib/installer/download.js +247 -0
- package/lib/installer/index.js +254 -0
- package/lib/mentions/index.js +595 -0
- package/lib/permissions/index.js +136 -0
- package/lib/runtime/cli.js +229 -0
- package/lib/runtime/client.js +454 -0
- package/lib/runtime/codec.js +126 -0
- package/lib/runtime/endpoint.js +248 -0
- package/lib/runtime/errors.js +126 -0
- package/lib/runtime/instances.js +287 -0
- package/lib/runtime/net.js +143 -0
- package/lib/runtime/peer.js +132 -0
- package/lib/tool-browser/index.js +476 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +55 -16
- package/skills/tabbit/SKILL.md +66 -0
- package/skills/tabbit/references/interaction-helpers.md +150 -0
- package/skills/tabbit/references/platform-invocation.md +174 -0
- package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
- package/skills/tabbit/references/runtime-recovery.md +104 -0
- package/README.zh-CN.md +0 -114
- package/index.js +0 -352
- package/installer.js +0 -568
- package/skills/tabbit-browser/SKILL.md +0 -274
- package/skills/tabbit-browser/agents/openai.yaml +0 -4
- package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
- package/skills/tabbit-browser/references/platform-invocation.md +0 -45
- package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
- package/update-check.js +0 -177
- /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:内网/私有目标判定(供"页面读取权限"和 web_fetch 使用)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它?—— SSRF 防护(Server-Side Request Forgery,服务端请求伪造)。
|
|
7
|
+
* web_fetch 是拿【用户的真实浏览器】去抓网页的:如果放任模型抓
|
|
8
|
+
* `http://192.168.1.1/`(路由器管理页)、`http://localhost:8080/`(本机内部
|
|
9
|
+
* 服务)这类地址,等于把用户内网暴露给了提示词注入攻击。dsh 官方出厂就是因为
|
|
10
|
+
* 没有 provider 能负责任地处理这个问题,才把 web_fetch 整个禁用的。
|
|
11
|
+
* 本插件的方案:抓内网目标不禁止,但走独立的审批门(settings
|
|
12
|
+
* `tabbit.intranetFetch`,默认逐 origin 询问用户)——判定"是不是内网目标"
|
|
13
|
+
* 就是本文件的活。
|
|
14
|
+
*
|
|
15
|
+
* 设计取向:尽力而为(best-effort),三层判定由快到慢:
|
|
16
|
+
* 1. 字面 IP:同步判段(10.x、127.x、192.168.x 等私有网段);
|
|
17
|
+
* 2. 域名形态:同步判形(localhost、*.local、无点裸主机名等);
|
|
18
|
+
* 3. DNS 解析:真的去查这个域名解析到什么 IP,但限时(默认 1.5 秒)——
|
|
19
|
+
* 查不出来就当"未知非内网"放行(反正解析不了的域名导航自己会失败)。
|
|
20
|
+
*
|
|
21
|
+
* 重定向问题:这里只判定【请求发起时】的 URL;公网 URL 302 跳到内网地址的
|
|
22
|
+
* 情况,由 web-fetch provider 在拿到最终 URL 后【再查一次】兜底(见
|
|
23
|
+
* src/web-fetch/index.ts 的"redirect containment"段)。
|
|
24
|
+
*/
|
|
25
|
+
import { lookup } from 'node:dns/promises';
|
|
26
|
+
import { isIP } from 'node:net';
|
|
27
|
+
/*
|
|
28
|
+
* IPv4 私有段判定。命中任何一段即视为私有:
|
|
29
|
+
* 10.0.0.0/8 企业内网 A 段
|
|
30
|
+
* 127.0.0.0/8 回环(本机)
|
|
31
|
+
* 0.0.0.0/8 "本网络"保留段
|
|
32
|
+
* 169.254.0.0/16 链路本地(DHCP 拿不到地址时的自动配置段)
|
|
33
|
+
* 172.16.0.0/12 企业内网 B 段(172.16 ~ 172.31)
|
|
34
|
+
* 192.168.0.0/16 家用路由器最常见段
|
|
35
|
+
* 100.64.0.0/10 CGNAT(运营商级 NAT,Tailscale 等 VPN 也用它)
|
|
36
|
+
*/
|
|
37
|
+
function ipv4IsPrivate(ip) {
|
|
38
|
+
const parts = ip.split('.').map(Number);
|
|
39
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n)))
|
|
40
|
+
return false;
|
|
41
|
+
const [a, b] = parts;
|
|
42
|
+
if (a === 10 || a === 127 || a === 0)
|
|
43
|
+
return true;
|
|
44
|
+
if (a === 169 && b === 254)
|
|
45
|
+
return true;
|
|
46
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
47
|
+
return true;
|
|
48
|
+
if (a === 192 && b === 168)
|
|
49
|
+
return true;
|
|
50
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
51
|
+
return true; // CGNAT
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
/*
|
|
55
|
+
* IPv6 私有段判定:
|
|
56
|
+
* ::1 回环;:: 未指定地址
|
|
57
|
+
* fe80::/10 链路本地
|
|
58
|
+
* fc00::/7 ULA(唯一本地地址,IPv6 的"内网段",fc/fd 开头)
|
|
59
|
+
* ::ffff:x.x.x.x IPv4 映射地址——剥掉前缀后按 IPv4 规则复判
|
|
60
|
+
*/
|
|
61
|
+
function ipv6IsPrivate(ip) {
|
|
62
|
+
const lower = ip.toLowerCase();
|
|
63
|
+
if (lower === '::1' || lower === '::')
|
|
64
|
+
return true;
|
|
65
|
+
if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd'))
|
|
66
|
+
return true;
|
|
67
|
+
if (lower.startsWith('::ffff:'))
|
|
68
|
+
return ipv4IsPrivate(lower.slice('::ffff:'.length));
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/* 字面 IP(v4 或 v6)是否属于私有/回环段。isIP 返回 4/6/0(0=不是 IP)。 */
|
|
72
|
+
export function ipIsPrivate(ip) {
|
|
73
|
+
const version = isIP(ip);
|
|
74
|
+
if (version === 4)
|
|
75
|
+
return ipv4IsPrivate(ip);
|
|
76
|
+
if (version === 6)
|
|
77
|
+
return ipv6IsPrivate(ip);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
/*
|
|
81
|
+
* 不查 DNS 的"域名形态"判定:一眼就能看出是本地/内网名字的模式。
|
|
82
|
+
* - localhost 及 *.localhost;
|
|
83
|
+
* - 常见内网专用后缀:.local(mDNS)/.internal/.lan/.home/.corp;
|
|
84
|
+
* - 不含点的裸主机名(如 http://nas/ ——公网域名必有点)。
|
|
85
|
+
* 末尾的 `.` 是 DNS 的"绝对域名"写法(如 example.com.),先剥掉再比。
|
|
86
|
+
*/
|
|
87
|
+
export function hostnameLooksLocal(hostname) {
|
|
88
|
+
const name = hostname.replace(/\.$/u, '').toLowerCase();
|
|
89
|
+
if (name === 'localhost' || name.endsWith('.localhost'))
|
|
90
|
+
return true;
|
|
91
|
+
if (name.endsWith('.local') || name.endsWith('.internal') || name.endsWith('.lan') || name.endsWith('.home') || name.endsWith('.corp')) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (!name.includes('.') && isIP(name) === 0)
|
|
95
|
+
return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/*
|
|
99
|
+
* 综合判定一个 URL 是否指向私有/内网目标。
|
|
100
|
+
*
|
|
101
|
+
* @returns 判定为私有时返回【原因描述字符串】(直接用于审批询问的文案里,
|
|
102
|
+
* 让用户知道为什么被拦);公网或无法判定时返回 undefined。
|
|
103
|
+
*
|
|
104
|
+
* 判定顺序:URL 能不能解析 → 主机是不是字面 IP → 域名形态 → 限时 DNS。
|
|
105
|
+
* DNS 用 Promise.race 跟一个定时器赛跑:超过 dnsBudgetMs 就放弃等待,
|
|
106
|
+
* 按"未知"处理——宁可漏判也不让权限检查把每次 fetch 卡住几秒。
|
|
107
|
+
* (漏判的兜底:navigation 之后 web-fetch 会对最终 URL 的字面形态再查一次。)
|
|
108
|
+
*/
|
|
109
|
+
export async function privateTargetReason(rawUrl, dnsBudgetMs = 1500) {
|
|
110
|
+
let url;
|
|
111
|
+
try {
|
|
112
|
+
url = new URL(rawUrl);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// 连 URL 都不是:不归这里管(工具那边自会报"非法 URL")。
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
// IPv6 字面量在 URL 里带方括号(http://[::1]/),先剥掉。
|
|
119
|
+
const hostname = url.hostname.replace(/^\[|\]$/gu, '');
|
|
120
|
+
if (isIP(hostname) !== 0) {
|
|
121
|
+
return ipIsPrivate(hostname) ? `IP address ${hostname} is private/loopback` : undefined;
|
|
122
|
+
}
|
|
123
|
+
if (hostnameLooksLocal(hostname)) {
|
|
124
|
+
return `hostname "${hostname}" looks like a local/intranet name`;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
// lookup(all:true) 返回该域名的全部解析地址;任何一个落在私有段就算私有
|
|
128
|
+
// (防"公网域名解析到内网 IP"的 DNS rebinding 变体)。
|
|
129
|
+
const resolved = await Promise.race([
|
|
130
|
+
lookup(hostname, { all: true }),
|
|
131
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), dnsBudgetMs).unref?.()),
|
|
132
|
+
]);
|
|
133
|
+
if (Array.isArray(resolved)) {
|
|
134
|
+
const hit = resolved.find((entry) => ipIsPrivate(entry.address));
|
|
135
|
+
if (hit)
|
|
136
|
+
return `hostname "${hostname}" resolves to private address ${hit.address}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* 域名解析不了:交给导航自己失败,这里不拦 */
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:「观看实例」溯源——从一条本机 TCP 连接反查是哪个 Tabbit 在访问我们
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 解决什么问题?多实例机器上(用户装了好几个 Tabbit),执行浏览器任务时该选
|
|
7
|
+
* 哪个实例?最符合直觉的答案是:【用户正用哪个 Tabbit 看 dsh 网页界面,
|
|
8
|
+
* 就在哪个 Tabbit 里执行】——"在哪看,就在哪跑"。
|
|
9
|
+
*
|
|
10
|
+
* 但 HTTP 请求本身不会告诉我们"我来自哪个浏览器"。本文件用一套 macOS 系统
|
|
11
|
+
* 工具链把它侦查出来:
|
|
12
|
+
*
|
|
13
|
+
* ① dsh 网页在浏览器里加载时,前端插件(client/client.js)会向
|
|
14
|
+
* `/tabbit/instance-hint` 发一个 POST(见 src/mentions/index.ts);
|
|
15
|
+
* ② 服务端拿到这条 TCP 连接的【对端端口号】(remotePort)——注意我们不信
|
|
16
|
+
* 请求体里的任何内容,身份完全取自 socket 本身,伪造不了;
|
|
17
|
+
* ③ 用 `lsof` 命令查:本机哪个进程占用着这个端口的客户端一侧?
|
|
18
|
+
* → 得到发起请求的进程 pid(Chromium 的某个网络辅助进程);
|
|
19
|
+
* ④ 用 `ps` 命令沿父进程链往上爬(Chromium 是多进程架构:辅助进程都是
|
|
20
|
+
* 主进程的子孙);
|
|
21
|
+
* ⑤ 每个在线 Tabbit 实例的 endpoint.json 里记录着它主进程的 pid
|
|
22
|
+
* (browserPid 字段)——父链上撞到哪个实例的 browserPid,就是它了。
|
|
23
|
+
*
|
|
24
|
+
* 设计取向:这是一个【启发式】(heuristic):任何一步失败(不是 macOS、
|
|
25
|
+
* lsof/ps 不可用、对端不是 Tabbit 而是普通浏览器…)都返回 undefined,
|
|
26
|
+
* 调用方(core 的实例四级解析)落回下一优先级,绝不因此报错。
|
|
27
|
+
*
|
|
28
|
+
* 仅支持 macOS:Runtime Service 目前未在 Windows 启用,lsof/ps 的调用方式
|
|
29
|
+
* 也是 macOS 版的。
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { promisify } from 'node:util';
|
|
34
|
+
// execFile 的 Promise 版(原版是回调风格);比 exec 安全——参数按数组传,
|
|
35
|
+
// 不经过 shell,不存在命令注入问题。
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
/*
|
|
38
|
+
* 建立 browserPid → 实例 id 的映射表(步骤⑤的查找表)。
|
|
39
|
+
* 只看在线实例;endpoint.json 解析失败(可能正赶上浏览器重启、文件被删)
|
|
40
|
+
* 就跳过该实例。
|
|
41
|
+
*/
|
|
42
|
+
function browserPidMap(instances) {
|
|
43
|
+
const map = new Map();
|
|
44
|
+
for (const instance of instances) {
|
|
45
|
+
if (!instance.online)
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const endpoint = JSON.parse(readFileSync(instance.endpointPath, 'utf8'));
|
|
49
|
+
if (typeof endpoint.browserPid === 'number' && endpoint.browserPid > 0) {
|
|
50
|
+
map.set(endpoint.browserPid, instance.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* endpoint 文件与浏览器重启竞态:跳过这个实例 */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/*
|
|
60
|
+
* 解析 `lsof -Fpn` 的机器可读输出(步骤③的核心)。
|
|
61
|
+
*
|
|
62
|
+
* -F 输出格式:每行一个字段,首字符是字段类型——
|
|
63
|
+
* p<pid> 接下来的行都属于这个进程
|
|
64
|
+
* n<名字> 一条网络连接,形如 "127.0.0.1:54321->127.0.0.1:3199"
|
|
65
|
+
*
|
|
66
|
+
* 我们要找的是:哪个进程的连接里,【本地端】(-> 左边)端口等于 peerPort。
|
|
67
|
+
* 为什么看本地端?——对端端口 X 对我们(服务器)来说是"对方的端口",但对
|
|
68
|
+
* 发起连接的客户端进程来说,X 正是它自己的本地端口。同时排除自己(dsh 进程
|
|
69
|
+
* 也持有这条连接的服务端一侧,别把自己认成客户端)。
|
|
70
|
+
*
|
|
71
|
+
* 导出仅为可单测(纯函数,喂字符串就能测)。
|
|
72
|
+
*/
|
|
73
|
+
export function clientPidFromLsof(output, peerPort, selfPid) {
|
|
74
|
+
let current;
|
|
75
|
+
for (const line of output.split('\n')) {
|
|
76
|
+
if (line.startsWith('p')) {
|
|
77
|
+
current = Number.parseInt(line.slice(1), 10);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!line.startsWith('n') || current === undefined || current === selfPid)
|
|
81
|
+
continue;
|
|
82
|
+
const local = line.slice(1).split('->')[0] ?? '';
|
|
83
|
+
if (local.endsWith(`:${peerPort}`))
|
|
84
|
+
return current;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/*
|
|
89
|
+
* 主入口:给定一条 loopback 连接的对端端口,返回拥有它的 Tabbit 实例 id。
|
|
90
|
+
* 非 macOS、端口非法、任何查询失败、或对端根本不是 Tabbit → undefined。
|
|
91
|
+
*/
|
|
92
|
+
export async function instanceIdForPeerPort(peerPort, instances, selfPid = process.pid) {
|
|
93
|
+
if (process.platform !== 'darwin')
|
|
94
|
+
return undefined;
|
|
95
|
+
if (!Number.isInteger(peerPort) || peerPort <= 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
const byPid = browserPidMap(instances);
|
|
98
|
+
if (byPid.size === 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
// 步骤③:lsof 按端口查连接。-n/-P 禁用域名/端口名反解(快很多),
|
|
101
|
+
// -iTCP:端口 过滤,-Fpn 机器可读输出。整体限时 3 秒。
|
|
102
|
+
let owner;
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execFileAsync('lsof', ['-n', '-P', `-iTCP:${peerPort}`, '-Fpn'], { timeout: 3000 });
|
|
105
|
+
owner = clientPidFromLsof(stdout, peerPort, selfPid);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
if (owner === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
// 步骤④:Chromium 辅助进程是主浏览器进程的子孙,沿父链最多爬 8 跳。
|
|
113
|
+
// `ps -o ppid= -p <pid>` 只输出该进程的父 pid。
|
|
114
|
+
let pid = owner;
|
|
115
|
+
for (let hop = 0; hop < 8 && pid > 1; hop += 1) {
|
|
116
|
+
const matched = byPid.get(pid);
|
|
117
|
+
if (matched !== undefined)
|
|
118
|
+
return matched; // 步骤⑤:撞上某实例的主进程 pid
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await execFileAsync('ps', ['-o', 'ppid=', '-p', String(pid)], { timeout: 2000 });
|
|
121
|
+
const ppid = Number.parseInt(stdout.trim(), 10);
|
|
122
|
+
// 防御:ppid 非法或自指(不该发生)就放弃,绝不死循环。
|
|
123
|
+
if (!Number.isFinite(ppid) || ppid <= 0 || ppid === pid)
|
|
124
|
+
return undefined;
|
|
125
|
+
pid = ppid;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|