dsh-tabbit 0.2.2 → 0.3.0
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 +100 -0
- package/LICENSE +21 -0
- package/README.en.md +116 -0
- package/README.md +55 -81
- package/client/client.js +389 -0
- package/cordis.patch.yml +77 -2
- package/lib/core/index.js +742 -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 +431 -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 +425 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +53 -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,248 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:直连 Runtime Service 公开端点(endpoint.json + NDJSON socket)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 这是与浏览器通信的【第二条物理通道】,与 cli.ts(launcher 子进程)并列:
|
|
7
|
+
*
|
|
8
|
+
* cli.ts —— 每次 spawn launcher 子进程,走完整 CLI 面(求值/finish/…),
|
|
9
|
+
* 具备"浏览器没在跑时自动拉起"的能力,代价是 ~1 秒级延迟;
|
|
10
|
+
* endpoint.ts(本文件)—— 直接连浏览器发布的本机 socket,仅覆盖【无任务】
|
|
11
|
+
* 操作(目前是 tabs 清单与 ping),稳态延迟 ~1ms,
|
|
12
|
+
* 但【不会】拉起浏览器:离线就如实报离线。
|
|
13
|
+
*
|
|
14
|
+
* 选型背景(2026-08-28,与 Tabbit 团队确认):persistent 模式(CLI 的 NDJSON
|
|
15
|
+
* 交互式子命令)计划移除,不能依赖;而 socket 协议里的 unbound `tabs` 是
|
|
16
|
+
* dispatch 的一等 case,与 persistent/bootstrap 绑定机制无关,且在 tab-browser
|
|
17
|
+
* tip-of-tree 上逐行未变——这是"零浏览器改动 + 快速读取"的唯一交集。
|
|
18
|
+
*
|
|
19
|
+
* ─── 线上协议(对 Dev 1.13.8 / 稳定 1.11.16 / tip-of-tree 三版源码核对一致,
|
|
20
|
+
* 并经真机验证;服务端实现 runtime-public-server.mjs + runtime-service.mjs)───
|
|
21
|
+
*
|
|
22
|
+
* 1. endpoint.json(schema v2,浏览器 C++ 侧 local_agent_endpoint.cc 写出):
|
|
23
|
+
* {version:2, kind:"browser-runtime-service", transport, address,
|
|
24
|
+
* token(32字节 base64url), generation, browserPid}
|
|
25
|
+
* 位置在 <用户数据目录>/LocalAgent/endpoint.json(注册表记录里给了全路径)。
|
|
26
|
+
* 【只在 Runtime Service 运行期间存在,浏览器退出即删;每次重启
|
|
27
|
+
* address/token/generation 三元组全部轮换】——所以本文件的铁律是:
|
|
28
|
+
* 每次连接前【现读】该文件,绝不缓存凭据。
|
|
29
|
+
* 2. 传输:macOS/Linux = unix domain socket(0600);Windows = named pipe
|
|
30
|
+
* (\\.\pipe\tabbit-runtime-…)。Node 的 net.createConnection 对两者同一 API。
|
|
31
|
+
* 3. 帧格式:NDJSON(一行一个 JSON + \n)。连接后第一帧必须是【严格恰好
|
|
32
|
+
* 三个键】的认证帧 {"version":1,"token":…,"generation":…}——服务端用
|
|
33
|
+
* timingSafeEqual 比对,不匹配就【无响应直接断开】(这是认证失败唯一的
|
|
34
|
+
* 可观测征兆,见下面 STALE_ENDPOINT 的处理)。认证帧成功也没有回执。
|
|
35
|
+
* 4. 认证后每帧一个请求对象,响应一行 {ok:true,value} 或
|
|
36
|
+
* {ok:false,error:{name,code,message}}。非 persistent 连接一问一答后由
|
|
37
|
+
* 服务端主动收尾;我们读到响应行就自行断开,不依赖这一行为。
|
|
38
|
+
* 5. unbound {"op":"tabs"}(连接上没有任务绑定时):返回全 profile 标签页
|
|
39
|
+
* 清单。服务端内部开一个临时会话并在同一 dispatch 里 finalize(keep:true)
|
|
40
|
+
* ——不产生任务、页面、标签组,也不出现在 `tasks` 列表里(真机核查)。
|
|
41
|
+
* {"op":"ping"} 返回 {running:true, generation},可当健康检查。
|
|
42
|
+
* 6. 服务端限额:认证帧 ≤4KB、请求 ≤64MB、8 并发 dispatch(超了回
|
|
43
|
+
* SERVICE_BUSY)、未认证连接空闲 5 秒收、dispatch 超时 150 秒。
|
|
44
|
+
*
|
|
45
|
+
* 错误统一包装成 TabbitCliError(与 CLI 通道共用一套错误分类,上层不用区分
|
|
46
|
+
* 消息是从哪条通道冒出来的)。
|
|
47
|
+
*/
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { createConnection } from 'node:net';
|
|
50
|
+
import { TabbitCliError, classifyAppError } from './errors.js';
|
|
51
|
+
/* 我们支持的 endpoint.json schema 版本(浏览器侧 kBrowserRuntimeEndpointVersion)。 */
|
|
52
|
+
const ENDPOINT_SCHEMA_VERSION = 2;
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
54
|
+
/*
|
|
55
|
+
* 读取并校验 endpoint.json。三种失败各有语义:
|
|
56
|
+
* - 文件读不到/不是 JSON:浏览器离线(文件退出即删)——ENDPOINT_MISSING;
|
|
57
|
+
* - schema 版本或 kind 不认识:浏览器换了协议世代,需要升级本插件——
|
|
58
|
+
* ENDPOINT_UNSUPPORTED(宁可明确失败也不猜着连);
|
|
59
|
+
* - transport 不认识:同上。
|
|
60
|
+
*/
|
|
61
|
+
export function readEndpoint(endpointPath) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readFileSync(endpointPath, 'utf8'));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new TabbitCliError({
|
|
68
|
+
kind: 'browser-unavailable',
|
|
69
|
+
code: 'ENDPOINT_MISSING',
|
|
70
|
+
message: `Tabbit Browser is not running (cannot read ${endpointPath}): ${String(error?.message ?? error)}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (parsed.version !== ENDPOINT_SCHEMA_VERSION || parsed.kind !== 'browser-runtime-service') {
|
|
74
|
+
throw new TabbitCliError({
|
|
75
|
+
kind: 'protocol',
|
|
76
|
+
code: 'ENDPOINT_UNSUPPORTED',
|
|
77
|
+
message: `Unsupported Runtime Service endpoint schema (version=${String(parsed.version)} kind=${String(parsed.kind)}); update dsh-tabbit.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if ((parsed.transport !== 'unix_socket' && parsed.transport !== 'named_pipe') ||
|
|
81
|
+
typeof parsed.address !== 'string' ||
|
|
82
|
+
parsed.address === '' ||
|
|
83
|
+
typeof parsed.token !== 'string' ||
|
|
84
|
+
typeof parsed.generation !== 'string') {
|
|
85
|
+
throw new TabbitCliError({
|
|
86
|
+
kind: 'protocol',
|
|
87
|
+
code: 'ENDPOINT_UNSUPPORTED',
|
|
88
|
+
message: 'Runtime Service endpoint file is missing transport/address/token/generation fields.',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
/*
|
|
94
|
+
* 在一条新连接上完成"认证帧 + 单个请求帧 → 一行响应"的完整交换。
|
|
95
|
+
*
|
|
96
|
+
* 实现要点:
|
|
97
|
+
* - 认证帧和请求帧一次性写出(服务端逐帧消费,无需等认证回执——协议里
|
|
98
|
+
* 认证成功本来就没有回执);
|
|
99
|
+
* - settled 布尔量保证 resolve/reject 只发生一次(error/close/data 会竞争);
|
|
100
|
+
* - 读到第一个换行即为完整响应,之后立刻 destroy——unbound 连接没有任何
|
|
101
|
+
* 需要善后的服务端状态(无任务绑定),断开即干净;
|
|
102
|
+
* - 【close 先于 data 到达 = 认证被拒或服务端换代】:服务端对坏凭据的行为
|
|
103
|
+
* 是无响应断开,映射为 STALE_ENDPOINT,由上层(requestViaEndpoint)现读
|
|
104
|
+
* endpoint.json 重试一次——覆盖"浏览器刚重启、我们拿的是上一代凭据"窗口。
|
|
105
|
+
*/
|
|
106
|
+
function endpointRequestOnce(endpoint, payload, timeoutMs) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const socket = createConnection(endpoint.address);
|
|
109
|
+
let buffer = '';
|
|
110
|
+
let settled = false;
|
|
111
|
+
const settle = (action) => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
socket.destroy();
|
|
117
|
+
action();
|
|
118
|
+
};
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
settle(() => reject(new TabbitCliError({
|
|
121
|
+
kind: 'timeout',
|
|
122
|
+
code: 'CLIENT_TIMEOUT',
|
|
123
|
+
message: `Runtime Service endpoint did not respond within ${timeoutMs}ms`,
|
|
124
|
+
})));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
timer.unref();
|
|
127
|
+
socket.on('error', (error) => {
|
|
128
|
+
// 两类都归为"凭据/端点过期"(可现读文件重试一次):
|
|
129
|
+
// ENOENT/ECONNREFUSED —— 文件还在但 socket 已不可连(浏览器正在退出
|
|
130
|
+
// 或重启的窗口期);
|
|
131
|
+
// ECONNRESET/EPIPE —— 交换中途被掐(服务端对坏凭据的 destroy 在
|
|
132
|
+
// 客户端常表现为 RST,而不是干净的 close)。
|
|
133
|
+
const stale = error.code === 'ENOENT' ||
|
|
134
|
+
error.code === 'ECONNREFUSED' ||
|
|
135
|
+
error.code === 'ECONNRESET' ||
|
|
136
|
+
error.code === 'EPIPE';
|
|
137
|
+
settle(() => reject(new TabbitCliError({
|
|
138
|
+
kind: stale ? 'browser-unavailable' : 'protocol',
|
|
139
|
+
code: stale ? 'STALE_ENDPOINT' : 'SOCKET_ERROR',
|
|
140
|
+
message: stale
|
|
141
|
+
? `Runtime Service connection dropped before a response (browser restarting or stale credentials): ${error.code}`
|
|
142
|
+
: `Runtime Service socket error: ${error.message}`,
|
|
143
|
+
})));
|
|
144
|
+
});
|
|
145
|
+
socket.on('connect', () => {
|
|
146
|
+
socket.write(`${JSON.stringify({ version: 1, token: endpoint.token, generation: endpoint.generation })}\n` +
|
|
147
|
+
`${JSON.stringify(payload)}\n`);
|
|
148
|
+
});
|
|
149
|
+
socket.on('data', (chunk) => {
|
|
150
|
+
buffer += chunk.toString('utf8');
|
|
151
|
+
const newline = buffer.indexOf('\n');
|
|
152
|
+
if (newline < 0)
|
|
153
|
+
return;
|
|
154
|
+
settle(() => {
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = JSON.parse(buffer.slice(0, newline));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
reject(new TabbitCliError({
|
|
161
|
+
kind: 'protocol',
|
|
162
|
+
code: 'BAD_FRAME',
|
|
163
|
+
message: `Runtime Service returned a non-JSON frame: ${buffer.slice(0, 200)}`,
|
|
164
|
+
}));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (response.ok === true) {
|
|
168
|
+
resolve(response.value);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const error = response.error ?? {};
|
|
172
|
+
reject(new TabbitCliError({
|
|
173
|
+
kind: classifyAppError(error),
|
|
174
|
+
code: error.code ?? 'REQUEST_FAILED',
|
|
175
|
+
message: error.message ?? 'Runtime Service request failed',
|
|
176
|
+
}));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
socket.on('close', () => settle(() => reject(new TabbitCliError({
|
|
180
|
+
kind: 'browser-unavailable',
|
|
181
|
+
code: 'STALE_ENDPOINT',
|
|
182
|
+
message: 'Runtime Service closed the connection before responding (endpoint credentials are stale; the browser likely restarted)',
|
|
183
|
+
}))));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/*
|
|
187
|
+
* 对外的单请求入口:现读 endpoint.json → 交换一次;命中"凭据过期/socket 刚没"
|
|
188
|
+
* 这两类【重启窗口】错误时,再现读一次文件重试——文件是浏览器新一代身份的
|
|
189
|
+
* 唯一权威来源,重读即自愈。其余错误(离线、超时、服务端应用错误)原样上抛。
|
|
190
|
+
*/
|
|
191
|
+
export async function requestViaEndpoint(endpointPath, payload, options = {}) {
|
|
192
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
193
|
+
try {
|
|
194
|
+
return await endpointRequestOnce(readEndpoint(endpointPath), payload, timeoutMs);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!(error instanceof TabbitCliError) || error.code !== 'STALE_ENDPOINT')
|
|
198
|
+
throw error;
|
|
199
|
+
return await endpointRequestOnce(readEndpoint(endpointPath), payload, timeoutMs);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/*
|
|
203
|
+
* 全 profile 标签页清单(含用户自己开的页面,不限于代理任务页)。
|
|
204
|
+
* 零副作用:不建任务、不开页面、不出现在 tasks 列表(服务端 unbound tabs
|
|
205
|
+
* 的固有语义)。返回值形状逐字段校验——数据要进提示词/UI,宁可在这里挡住
|
|
206
|
+
* 服务端未来的形状漂移,也不把 unknown 直接漏给上层。
|
|
207
|
+
*/
|
|
208
|
+
export async function listAllTabs(endpointPath, options) {
|
|
209
|
+
const value = await requestViaEndpoint(endpointPath, { op: 'tabs' }, options);
|
|
210
|
+
const inventory = value;
|
|
211
|
+
if (typeof inventory !== 'object' || inventory === null || !Array.isArray(inventory.tabs)) {
|
|
212
|
+
throw new TabbitCliError({
|
|
213
|
+
kind: 'protocol',
|
|
214
|
+
code: 'BAD_FRAME',
|
|
215
|
+
message: 'Runtime Service tabs inventory has an unexpected shape',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const tabs = [];
|
|
219
|
+
for (const entry of inventory.tabs) {
|
|
220
|
+
if (typeof entry !== 'object' || entry === null)
|
|
221
|
+
continue;
|
|
222
|
+
const tab = entry;
|
|
223
|
+
if (typeof tab.tabId !== 'number' || typeof tab.url !== 'string')
|
|
224
|
+
continue;
|
|
225
|
+
const group = tab.group;
|
|
226
|
+
tabs.push({
|
|
227
|
+
tabId: tab.tabId,
|
|
228
|
+
windowId: typeof tab.windowId === 'number' ? tab.windowId : 0,
|
|
229
|
+
index: typeof tab.index === 'number' ? tab.index : 0,
|
|
230
|
+
title: typeof tab.title === 'string' ? tab.title : '',
|
|
231
|
+
url: tab.url,
|
|
232
|
+
active: tab.active === true,
|
|
233
|
+
state: tab.state === 'owned' || tab.state === 'busy' ? tab.state : 'available',
|
|
234
|
+
group: typeof group === 'object' && group !== null && typeof group.groupId === 'string'
|
|
235
|
+
? { groupId: group.groupId, title: typeof group.title === 'string' ? group.title : '' }
|
|
236
|
+
: null,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return { tabs, truncated: inventory.truncated === true };
|
|
240
|
+
}
|
|
241
|
+
/* 健康检查:浏览器在线时返回 {running:true, generation}。 */
|
|
242
|
+
export async function pingEndpoint(endpointPath, options) {
|
|
243
|
+
const value = (await requestViaEndpoint(endpointPath, { op: 'ping' }, options));
|
|
244
|
+
return {
|
|
245
|
+
running: value?.running === true,
|
|
246
|
+
generation: typeof value?.generation === 'string' ? value.generation : '',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:错误分类体系(error taxonomy)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 本文件定义了与 Tabbit Browser「Runtime Service」(浏览器内置的自动化运行时
|
|
7
|
+
* 服务)打交道时可能遇到的所有错误类型。我们不直接连浏览器,而是通过命令行
|
|
8
|
+
* 启动器(launcher,装在 ~/.local/bin/ 下,新名 `tabbit-cli`、旧名
|
|
9
|
+
* `tabbit-playwright`)间接通信,所以错误会从好几个"层"冒出来:
|
|
10
|
+
*
|
|
11
|
+
* 第 1 层:launcher 外壳脚本(POSIX shell 脚本)自己失败
|
|
12
|
+
* —— 比如机器上装了多个 Tabbit 实例、它不知道选哪个。
|
|
13
|
+
* 表现:stderr 上输出【纯文本】错误信息,进程退出码 69。
|
|
14
|
+
*
|
|
15
|
+
* 第 2 层:launcher 转交给的浏览器原生 CLI 失败
|
|
16
|
+
* —— 比如任务超时、浏览器没起来、任务队列满了。
|
|
17
|
+
* 表现:stderr 上输出【一行 JSON】,形如
|
|
18
|
+
* `{"ok":false,"error":{name,code,message}}`,退出码 64/69/70。
|
|
19
|
+
*
|
|
20
|
+
* 第 3 层:子进程本身起不来(launcher 文件不存在/没有执行权限),
|
|
21
|
+
* 或者我们这边等超时把它杀了。
|
|
22
|
+
*
|
|
23
|
+
* 上层代码(client.ts、tool-browser 等)拿到这里分类好的错误后,才能决定:
|
|
24
|
+
* 该不该自动重试?该给模型/用户什么样的提示语?
|
|
25
|
+
* 所有涉及具体错误码和退出码的知识,都是对真机 CLI 实测得来的(不是猜的)。
|
|
26
|
+
*/
|
|
27
|
+
/*
|
|
28
|
+
* Runtime Service 在应用层(第 2 层)会报出的错误码常量表。
|
|
29
|
+
* 键是我们自己起的驼峰名,值是 CLI 真实返回的错误码字符串。
|
|
30
|
+
* 集中列在这里,是为了让 classifyAppError() 的 switch 不出现裸字符串。
|
|
31
|
+
*/
|
|
32
|
+
export const CLI_ERROR_CODES = {
|
|
33
|
+
/* 浏览器或它的 Runtime Service 不可达(可能正在启动/已退出)。 */
|
|
34
|
+
browserUnavailable: 'BROWSER_RUNTIME_UNAVAILABLE',
|
|
35
|
+
/* 服务端并发已满,暂时忙。 */
|
|
36
|
+
serviceBusy: 'SERVICE_BUSY',
|
|
37
|
+
/* 单次求值超时(服务端上限 120 秒)。 */
|
|
38
|
+
taskTimeout: 'TASK_TIMEOUT',
|
|
39
|
+
/* 任务数达到上限(整台机器最多 8 个并发任务)。 */
|
|
40
|
+
taskLimitReached: 'TASK_LIMIT_REACHED',
|
|
41
|
+
/* 单个任务的排队队列满了。 */
|
|
42
|
+
taskQueueFull: 'TASK_QUEUE_FULL',
|
|
43
|
+
/* 任务的 worker 进程丢了(浏览器崩溃/重启)——任务内所有状态已丢失。 */
|
|
44
|
+
taskWorkerLost: 'TASK_WORKER_LOST',
|
|
45
|
+
/* 服务"代际"(generation)不匹配——服务重启过,旧任务作废。 */
|
|
46
|
+
generationMismatch: 'GENERATION_MISMATCH',
|
|
47
|
+
/* 整个 Runtime Service 丢失。 */
|
|
48
|
+
serviceLost: 'SERVICE_LOST',
|
|
49
|
+
/* CLI 与服务端协议版本不匹配(浏览器和 launcher 版本差太多)。 */
|
|
50
|
+
protocolMismatch: 'PROTOCOL_MISMATCH',
|
|
51
|
+
/* 想 claim(认领)的标签页已被别的任务占有。 */
|
|
52
|
+
tabOwnershipConflict: 'TAB_OWNERSHIP_CONFLICT',
|
|
53
|
+
/* 兜底的通用请求失败码。 */
|
|
54
|
+
requestFailed: 'REQUEST_FAILED',
|
|
55
|
+
/* 提交的代码体积超限。 */
|
|
56
|
+
codeTooLarge: 'CODE_TOO_LARGE',
|
|
57
|
+
/* 返回结果体积超限。 */
|
|
58
|
+
resultTooLarge: 'RESULT_TOO_LARGE',
|
|
59
|
+
};
|
|
60
|
+
/*
|
|
61
|
+
* 统一的错误类:所有从 CLI 层冒出来的失败都被包装成这个类抛出。
|
|
62
|
+
* 继承自 JS 内置的 Error,附加了四个字段方便上层判断与排障:
|
|
63
|
+
* - kind:行为类别(见上面的 TabbitErrorKind)
|
|
64
|
+
* - code:CLI 原始错误码(或我们自己造的,如 CLIENT_TIMEOUT)
|
|
65
|
+
* - exitCode:子进程退出码(没有则为 null)
|
|
66
|
+
* - stderrRaw:stderr 原文(排障时看)
|
|
67
|
+
*/
|
|
68
|
+
export class TabbitCliError extends Error {
|
|
69
|
+
kind;
|
|
70
|
+
code;
|
|
71
|
+
exitCode;
|
|
72
|
+
stderrRaw;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
// super() 调用父类 Error 的构造函数,把 message 存进去。
|
|
75
|
+
super(options.message);
|
|
76
|
+
this.name = 'TabbitCliError';
|
|
77
|
+
this.kind = options.kind;
|
|
78
|
+
this.code = options.code;
|
|
79
|
+
this.exitCode = options.exitCode ?? null;
|
|
80
|
+
this.stderrRaw = options.stderrRaw ?? '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/*
|
|
84
|
+
* "任务被隔离"没有专属错误码,只能靠错误信息文案匹配。
|
|
85
|
+
* 服务端原文形如 "... is quarantined; checkpoint before submitting more work"。
|
|
86
|
+
*/
|
|
87
|
+
const QUARANTINE_PATTERN = /is quarantined; checkpoint before submitting more work/u;
|
|
88
|
+
/* "未知任务名"错误的文案前缀(对不存在的任务调 finish/receipt 时出现)。 */
|
|
89
|
+
const UNKNOWN_TASK_PATTERN = /^Unknown task name: /u;
|
|
90
|
+
/*
|
|
91
|
+
* 把 CLI 返回的应用层错误对象({name, code, message})归类成 TabbitErrorKind。
|
|
92
|
+
* 这是"错误码 → 行为类别"的唯一映射点。
|
|
93
|
+
*/
|
|
94
|
+
export function classifyAppError(error) {
|
|
95
|
+
const code = error.code ?? CLI_ERROR_CODES.requestFailed;
|
|
96
|
+
const message = error.message ?? '';
|
|
97
|
+
switch (code) {
|
|
98
|
+
case CLI_ERROR_CODES.browserUnavailable:
|
|
99
|
+
return 'browser-unavailable';
|
|
100
|
+
case CLI_ERROR_CODES.serviceBusy:
|
|
101
|
+
case CLI_ERROR_CODES.taskQueueFull:
|
|
102
|
+
return 'busy';
|
|
103
|
+
// 下面三种码本质相同:任务/服务的"上一世"没了,之前的状态全部作废。
|
|
104
|
+
case CLI_ERROR_CODES.taskWorkerLost:
|
|
105
|
+
case CLI_ERROR_CODES.generationMismatch:
|
|
106
|
+
case CLI_ERROR_CODES.serviceLost:
|
|
107
|
+
return 'task-reset';
|
|
108
|
+
case CLI_ERROR_CODES.tabOwnershipConflict:
|
|
109
|
+
return 'tab-claim';
|
|
110
|
+
case CLI_ERROR_CODES.taskTimeout:
|
|
111
|
+
return 'timeout';
|
|
112
|
+
default:
|
|
113
|
+
// 隔离错误没有专属 code,只能从 message 文案里认。
|
|
114
|
+
if (QUARANTINE_PATTERN.test(message))
|
|
115
|
+
return 'quarantined';
|
|
116
|
+
return 'app';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/*
|
|
120
|
+
* 判断一个错误是不是"未知任务名"。
|
|
121
|
+
* 用途:清理任务时(finishTask),任务可能早已不存在(浏览器重启过/用户手动
|
|
122
|
+
* 关了)——这种情况视为"已经清理完成",静默吞掉即可,不算失败。
|
|
123
|
+
*/
|
|
124
|
+
export function isUnknownTaskError(error) {
|
|
125
|
+
return error instanceof TabbitCliError && UNKNOWN_TASK_PATTERN.test(error.message);
|
|
126
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:读取 Tabbit「实例注册表」(instance registry)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 背景知识:一台机器上可以同时装多个 Tabbit Browser(比如正式版 + Dev 版 +
|
|
7
|
+
* 国内版)。每个安装好的浏览器在首次启动时会写一条注册记录,【两平台不同构】
|
|
8
|
+
* (均以 tab-browser 源码 local_agent_host_integration_manager.cc 为准):
|
|
9
|
+
*
|
|
10
|
+
* macOS/Linux:`~/.local/share/tabbit-playwright/instances/<16位大写hex>.instance`
|
|
11
|
+
* 三行纯文本:
|
|
12
|
+
* 第 1 行:固定魔法串(见下面 REGISTRY_MAGIC),防止误读无关文件;
|
|
13
|
+
* 第 2 行:该实例自带 CLI 可执行文件的绝对路径;
|
|
14
|
+
* 第 3 行:该实例 endpoint.json 的绝对路径——【浏览器只在自己的
|
|
15
|
+
* Runtime Service 正在运行时才会创建这个文件,退出即删】。
|
|
16
|
+
* 所以"endpoint 文件当前存在" == "这个实例在线"。
|
|
17
|
+
* 新版浏览器(2026-08 起)还会在旁边写一个同名 `.product` 档案(两行:
|
|
18
|
+
* 魔法串 + 产品名,如 "Tabbit Browser Dev")——比从 CLI 路径猜应用名可靠,
|
|
19
|
+
* 有就优先用它做展示名。
|
|
20
|
+
*
|
|
21
|
+
* Windows:`%LOCALAPPDATA%\Tabbit\LocalAgent\instances\<16位大写hex>.json`
|
|
22
|
+
* (C++ 侧 BuildWindowsInstanceRecord 写出的 JSON,DACL 保护):
|
|
23
|
+
* {version:1, instanceId, product, cliPath, endpointPath, browserPath,
|
|
24
|
+
* userDataDir}
|
|
25
|
+
* 产品名直接在记录里,没有 .product 旁档;在线判定同样看 endpointPath
|
|
26
|
+
* 文件是否存在。注意所有产品共用同一个注册表目录(launcher 也固定装在
|
|
27
|
+
* %LOCALAPPDATA%\Tabbit\LocalAgent\bin\,不随产品名变化)。
|
|
28
|
+
*
|
|
29
|
+
* launcher 外壳脚本(新名 `tabbit-cli`,旧名 `tabbit-playwright`,见
|
|
30
|
+
* defaultLauncherPath 的说明)自己也读这个注册表来决定把命令转发
|
|
31
|
+
* 给哪个实例。我们为什么还要【自己再解析一遍】而不是只依赖外壳的报错?
|
|
32
|
+
* 1. `/tabbit-info` 诊断命令要能列出所有实例给用户看;
|
|
33
|
+
* 2. 选择无歧义时(只有一个在线实例)可以自动选中,用户零配置;
|
|
34
|
+
* 3. 有歧义时能抛出带完整实例清单的、可操作的错误信息。
|
|
35
|
+
* 本文件的校验规则刻意与外壳脚本保持一致,避免"我们认、外壳不认"的分裂。
|
|
36
|
+
*/
|
|
37
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
40
|
+
/* 注册文件必须以这行魔法串开头,否则视为无关文件跳过。 */
|
|
41
|
+
const REGISTRY_MAGIC = '# tabbit-playwright instance managed by Tabbit Browser';
|
|
42
|
+
/* .product 档案的首行魔法串(第二行是产品名)。 */
|
|
43
|
+
const PRODUCT_MAGIC = '# tabbit-playwright product managed by Tabbit Browser';
|
|
44
|
+
/* 合法实例 id 的形状:恰好 16 位大写十六进制。 */
|
|
45
|
+
const INSTANCE_ID_PATTERN = /^[0-9A-F]{16}$/u;
|
|
46
|
+
/* POSIX 注册表目录的默认位置。 */
|
|
47
|
+
export function defaultRegistryDir() {
|
|
48
|
+
return join(homedir(), '.local', 'share', 'tabbit-playwright', 'instances');
|
|
49
|
+
}
|
|
50
|
+
/* Windows 注册表目录的默认位置(= 原生 CLI 目录的上一级 + instances)。 */
|
|
51
|
+
export function defaultWindowsRegistryDir({ env = process.env, userHome = homedir(), } = {}) {
|
|
52
|
+
const base = env.LOCALAPPDATA || join(userHome, 'AppData', 'Local');
|
|
53
|
+
return join(base, 'Tabbit', 'LocalAgent', 'instances');
|
|
54
|
+
}
|
|
55
|
+
/*
|
|
56
|
+
* launcher 外壳的默认安装位置。浏览器新版本(2026-08 起)注册的名字是
|
|
57
|
+
* `tabbit-cli`(加固版外壳:校验属主/权限、读 .product 档案、优先选择
|
|
58
|
+
* 「浏览器进程还活着」的实例),旧名 `tabbit-playwright` 一段时间内并存但
|
|
59
|
+
* 已被官方弃用——且旧壳在多实例机器上无法自动选实例(直接报错要求设环境
|
|
60
|
+
* 变量),新壳能自动选中活跃实例。选择顺序:
|
|
61
|
+
* - Windows:%LOCALAPPDATA%\Tabbit\LocalAgent\bin\tabbit-cli.exe
|
|
62
|
+
* (原生可执行文件,浏览器安装时布置);
|
|
63
|
+
* - 其它平台:~/.local/bin/tabbit-cli 存在就用;否则回退旧名
|
|
64
|
+
* tabbit-playwright;两个都不存在时返回新名——让"launcher 缺失"的
|
|
65
|
+
* 报错信息指向当前正确的安装路径。
|
|
66
|
+
* 参数可注入(platform/env/userHome)仅为单元测试;生产调用一律无参。
|
|
67
|
+
*/
|
|
68
|
+
export function defaultLauncherPath({ platform = process.platform, env = process.env, userHome = homedir(), } = {}) {
|
|
69
|
+
if (platform === 'win32') {
|
|
70
|
+
const base = env.LOCALAPPDATA || join(userHome, 'AppData', 'Local');
|
|
71
|
+
return join(base, 'Tabbit', 'LocalAgent', 'bin', 'tabbit-cli.exe');
|
|
72
|
+
}
|
|
73
|
+
const modern = join(userHome, '.local', 'bin', 'tabbit-cli');
|
|
74
|
+
if (existsSync(modern))
|
|
75
|
+
return modern;
|
|
76
|
+
const legacy = join(userHome, '.local', 'bin', 'tabbit-playwright');
|
|
77
|
+
if (existsSync(legacy))
|
|
78
|
+
return legacy;
|
|
79
|
+
return modern;
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
* 从 CLI 路径里提取应用名:macOS 上路径通常形如
|
|
83
|
+
* `/Applications/Tabbit Browser.app/Contents/.../cli`,取 `.app` 那段;
|
|
84
|
+
* 匹配不到就退化为文件名。仅用于展示,不参与任何逻辑判断。
|
|
85
|
+
*/
|
|
86
|
+
function deriveAppName(cliPath) {
|
|
87
|
+
const match = cliPath.match(/\/([^/]+\.app)\//u);
|
|
88
|
+
if (match)
|
|
89
|
+
return match[1].replace(/\.app$/u, '');
|
|
90
|
+
return basename(cliPath);
|
|
91
|
+
}
|
|
92
|
+
/*
|
|
93
|
+
* 读实例的 .product 档案(新版浏览器写的产品名,如 "Tabbit Browser Dev")。
|
|
94
|
+
* 档案缺失/魔法串不对/产品名为空 → undefined(回退到 deriveAppName)。
|
|
95
|
+
*/
|
|
96
|
+
function readProductName(registryDir, id) {
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = readFileSync(join(registryDir, `${id}.product`), 'utf8');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const lines = text.split('\n');
|
|
105
|
+
if (lines[0] !== PRODUCT_MAGIC)
|
|
106
|
+
return undefined;
|
|
107
|
+
const product = (lines[1] ?? '').trim();
|
|
108
|
+
return product !== '' ? product : undefined;
|
|
109
|
+
}
|
|
110
|
+
/*
|
|
111
|
+
* 列出所有合法的已注册实例(平台分派入口)。
|
|
112
|
+
*
|
|
113
|
+
* 有意用【同步】文件 API(readdirSync 等):调用频率低、文件极小,同步读省去
|
|
114
|
+
* 到处传 Promise 的复杂度,也让 core 层的 resolveExecutionInstance() 可以保持
|
|
115
|
+
* 同步签名。
|
|
116
|
+
*
|
|
117
|
+
* 显式传了 registryDir 时按 POSIX 格式解析(历史签名,单元测试与诊断用);
|
|
118
|
+
* 不传时按当前平台选注册表:win32 走 JSON 记录,其余走 .instance 三行文本。
|
|
119
|
+
*/
|
|
120
|
+
export function listInstances(registryDir) {
|
|
121
|
+
if (registryDir === undefined && process.platform === 'win32')
|
|
122
|
+
return listInstancesWindows();
|
|
123
|
+
return listInstancesPosix(registryDir ?? defaultRegistryDir());
|
|
124
|
+
}
|
|
125
|
+
/*
|
|
126
|
+
* POSIX 注册表解析。每个候选文件要闯过 5 道校验(与 launcher 外壳一致),
|
|
127
|
+
* 任何一道不过就整个跳过:
|
|
128
|
+
* 1. 文件名以 .instance 结尾且去后缀后是 16 位大写 hex;
|
|
129
|
+
* 2. 首行等于魔法串;
|
|
130
|
+
* 3. 第 2、3 行都是绝对路径;
|
|
131
|
+
* 4. CLI 路径确实存在且是普通文件;
|
|
132
|
+
* 5. (不淘汰,只标记)endpoint 文件存在与否决定 online。
|
|
133
|
+
*/
|
|
134
|
+
export function listInstancesPosix(registryDir = defaultRegistryDir()) {
|
|
135
|
+
let names;
|
|
136
|
+
try {
|
|
137
|
+
names = readdirSync(registryDir);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// 目录不存在(从没装过 Tabbit):返回空列表而不是抛错。
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const instances = [];
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
if (!name.endsWith('.instance'))
|
|
146
|
+
continue;
|
|
147
|
+
const id = name.slice(0, -'.instance'.length);
|
|
148
|
+
if (!INSTANCE_ID_PATTERN.test(id))
|
|
149
|
+
continue;
|
|
150
|
+
let text;
|
|
151
|
+
try {
|
|
152
|
+
text = readFileSync(join(registryDir, name), 'utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const lines = text.split('\n');
|
|
158
|
+
if (lines[0] !== REGISTRY_MAGIC)
|
|
159
|
+
continue;
|
|
160
|
+
const cliPath = lines[1] ?? '';
|
|
161
|
+
const endpointPath = lines[2] ?? '';
|
|
162
|
+
if (!isAbsolute(cliPath) || !isAbsolute(endpointPath))
|
|
163
|
+
continue;
|
|
164
|
+
let cliOk = false;
|
|
165
|
+
try {
|
|
166
|
+
cliOk = statSync(cliPath).isFile();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!cliOk)
|
|
172
|
+
continue;
|
|
173
|
+
let online = false;
|
|
174
|
+
try {
|
|
175
|
+
online = statSync(endpointPath).isFile();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
online = false;
|
|
179
|
+
}
|
|
180
|
+
instances.push({ id, cliPath, endpointPath, online, appName: readProductName(registryDir, id) ?? deriveAppName(cliPath) });
|
|
181
|
+
}
|
|
182
|
+
// 按 id 排序,保证多次调用输出顺序稳定(诊断输出、错误信息里好对照)。
|
|
183
|
+
instances.sort((a, b) => a.id.localeCompare(b.id));
|
|
184
|
+
return instances;
|
|
185
|
+
}
|
|
186
|
+
/*
|
|
187
|
+
* Windows 注册表解析。校验规则镜像 C++ 写入端(BuildWindowsInstanceRecord):
|
|
188
|
+
* 1. 文件名以 .json 结尾;
|
|
189
|
+
* 2. 记录 version === 1;
|
|
190
|
+
* 3. instanceId 是 16 位大写 hex 且与文件名一致(防止拷贝改名产生分裂身份);
|
|
191
|
+
* 4. cliPath/endpointPath 是非空字符串,cliPath 指向存在的普通文件;
|
|
192
|
+
* 5. endpoint 文件存在与否决定 online(同 POSIX)。
|
|
193
|
+
* 展示名优先记录里的 product 字段(Windows 没有 .product 旁档)。
|
|
194
|
+
*/
|
|
195
|
+
export function listInstancesWindows(registryDir = defaultWindowsRegistryDir()) {
|
|
196
|
+
let names;
|
|
197
|
+
try {
|
|
198
|
+
names = readdirSync(registryDir);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
const instances = [];
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
if (!name.endsWith('.json'))
|
|
206
|
+
continue;
|
|
207
|
+
let record;
|
|
208
|
+
try {
|
|
209
|
+
record = JSON.parse(readFileSync(join(registryDir, name), 'utf8'));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (record.version !== 1)
|
|
215
|
+
continue;
|
|
216
|
+
const id = typeof record.instanceId === 'string' ? record.instanceId : '';
|
|
217
|
+
if (!INSTANCE_ID_PATTERN.test(id) || `${id}.json` !== name)
|
|
218
|
+
continue;
|
|
219
|
+
const cliPath = typeof record.cliPath === 'string' ? record.cliPath : '';
|
|
220
|
+
const endpointPath = typeof record.endpointPath === 'string' ? record.endpointPath : '';
|
|
221
|
+
if (cliPath === '' || endpointPath === '')
|
|
222
|
+
continue;
|
|
223
|
+
let cliOk = false;
|
|
224
|
+
try {
|
|
225
|
+
cliOk = statSync(cliPath).isFile();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!cliOk)
|
|
231
|
+
continue;
|
|
232
|
+
let online = false;
|
|
233
|
+
try {
|
|
234
|
+
online = statSync(endpointPath).isFile();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
online = false;
|
|
238
|
+
}
|
|
239
|
+
instances.push({
|
|
240
|
+
id,
|
|
241
|
+
cliPath,
|
|
242
|
+
endpointPath,
|
|
243
|
+
online,
|
|
244
|
+
appName: typeof record.product === 'string' && record.product !== '' ? record.product : deriveAppName(cliPath),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
instances.sort((a, b) => a.id.localeCompare(b.id));
|
|
248
|
+
return instances;
|
|
249
|
+
}
|
|
250
|
+
/*
|
|
251
|
+
* 决定最终把哪个实例 id 通过环境变量 TABBIT_PLAYWRIGHT_INSTANCE 传给 launcher。
|
|
252
|
+
* 消歧规则刻意镜像 launcher 外壳自己的规则:
|
|
253
|
+
*
|
|
254
|
+
* - 显式指定(settings 配了 tabbit.instance):必须在注册表里,否则抛出
|
|
255
|
+
* 带可用实例清单的错误(帮用户发现是配错了 id 还是没装);
|
|
256
|
+
* - 未指定:
|
|
257
|
+
* · 恰好 1 个在线实例 → 选它;
|
|
258
|
+
* · 0 个在线但总共只装了 1 个 → 选它(launcher 会自动把浏览器拉起来);
|
|
259
|
+
* · 一个都没装 → 抛"请先安装并启动一次 Tabbit";
|
|
260
|
+
* · 多个且无法定夺 → 抛带完整清单的引导错误,提示用户去 settings 里
|
|
261
|
+
* 设置 tabbit.instance。
|
|
262
|
+
*
|
|
263
|
+
* 返回 undefined 的含义:不设置环境变量、裸调 launcher 也一定能成功的场景
|
|
264
|
+
* (机器上只有一个实例时 launcher 自己就能选对)。
|
|
265
|
+
*/
|
|
266
|
+
export function resolveInstanceId(configured, instances) {
|
|
267
|
+
if (configured) {
|
|
268
|
+
const found = instances.find((instance) => instance.id === configured);
|
|
269
|
+
if (!found) {
|
|
270
|
+
const available = instances.map((instance) => `${instance.id} (${instance.appName})`).join(', ') || 'none';
|
|
271
|
+
throw new Error(`Configured Tabbit instance ${configured} is not registered. Available instances: ${available}.`);
|
|
272
|
+
}
|
|
273
|
+
return configured;
|
|
274
|
+
}
|
|
275
|
+
const online = instances.filter((instance) => instance.online);
|
|
276
|
+
if (online.length === 1)
|
|
277
|
+
return online[0].id;
|
|
278
|
+
if (online.length === 0 && instances.length === 1)
|
|
279
|
+
return instances[0].id;
|
|
280
|
+
if (instances.length === 0) {
|
|
281
|
+
throw new Error('No Tabbit Browser instance is registered on this machine (missing ~/.local/share/tabbit-playwright/instances). Install and launch Tabbit Browser first.');
|
|
282
|
+
}
|
|
283
|
+
const listing = instances
|
|
284
|
+
.map((instance) => `${instance.id} (${instance.appName}${instance.online ? ', online' : ''})`)
|
|
285
|
+
.join(', ');
|
|
286
|
+
throw new Error(`Multiple Tabbit Browser instances are registered; set the "tabbit" settings key "instance" to one of: ${listing}.`);
|
|
287
|
+
}
|