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.
Files changed (39) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +141 -0
  4. package/README.md +70 -76
  5. package/client/client.js +390 -0
  6. package/cordis.patch.yml +76 -5
  7. package/lib/core/index.js +756 -0
  8. package/lib/installer/detect.js +374 -0
  9. package/lib/installer/download.js +247 -0
  10. package/lib/installer/index.js +254 -0
  11. package/lib/mentions/index.js +595 -0
  12. package/lib/permissions/index.js +136 -0
  13. package/lib/runtime/cli.js +229 -0
  14. package/lib/runtime/client.js +454 -0
  15. package/lib/runtime/codec.js +126 -0
  16. package/lib/runtime/endpoint.js +248 -0
  17. package/lib/runtime/errors.js +126 -0
  18. package/lib/runtime/instances.js +287 -0
  19. package/lib/runtime/net.js +143 -0
  20. package/lib/runtime/peer.js +132 -0
  21. package/lib/tool-browser/index.js +476 -0
  22. package/lib/update-check.js +343 -0
  23. package/lib/web-fetch/index.js +219 -0
  24. package/package.json +55 -16
  25. package/skills/tabbit/SKILL.md +66 -0
  26. package/skills/tabbit/references/interaction-helpers.md +150 -0
  27. package/skills/tabbit/references/platform-invocation.md +174 -0
  28. package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
  29. package/skills/tabbit/references/runtime-recovery.md +104 -0
  30. package/README.zh-CN.md +0 -114
  31. package/index.js +0 -352
  32. package/installer.js +0 -568
  33. package/skills/tabbit-browser/SKILL.md +0 -274
  34. package/skills/tabbit-browser/agents/openai.yaml +0 -4
  35. package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
  36. package/skills/tabbit-browser/references/platform-invocation.md +0 -45
  37. package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
  38. package/update-check.js +0 -177
  39. /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
@@ -0,0 +1,136 @@
1
+ import { privateTargetReason } from '../runtime/net.js';
2
+ /* 受本门管辖的两个工具。 */
3
+ const GATED_TOOLS = new Set(['tabbit_browser', 'web_fetch']);
4
+ /*
5
+ * 把"想发起一次询问"折算成当前模式下的正确结论:
6
+ * - 会话审批策略覆盖为 `never`(= Full access 模式)→ 'bypass' 放行
7
+ * (理由见文件头:ask 注定被自动拒,发出去只会造成误伤);
8
+ * - 审批服务根本没挂载(某些 headless 组合)→ 返回一个【解释了出路】的
9
+ * 拒绝:告诉用户去 settings 把 tabbit.<key> 设为 always。若不接手,
10
+ * dsh 工具层的通用拒绝文案会把这条出路藏没;
11
+ * - 其它情况 → 正常返回 ask,交给审批 UI 问用户。
12
+ */
13
+ function resolveAsk(ctx, exec, askReason, settingsKey) {
14
+ // ctx.get('approval'):按名取服务的"软"方式——不在 inject 里声明硬依赖,
15
+ // 服务缺席时拿到 undefined 而不是崩溃。
16
+ const approval = ctx.get('approval');
17
+ if (approval !== undefined && exec.agent !== undefined) {
18
+ try {
19
+ // overrideOf(session):读该会话的审批策略覆盖(dsh-user-approval API)。
20
+ // ⚠️ 这个 API 只能在"开着的 turn"内调用(本处理器正是在 turn 内跑的)。
21
+ if (approval.overrideOf(exec.agent.session) === 'never')
22
+ return 'bypass';
23
+ }
24
+ catch {
25
+ /* 会话形状不认识:当普通情况处理,照常询问 */
26
+ }
27
+ }
28
+ if (approval === undefined) {
29
+ return {
30
+ kind: 'deny',
31
+ reason: `This action needs user confirmation (settings tabbit.${settingsKey} = ask), but no approval channel is mounted in this composition. Set settings tabbit.${settingsKey} to "always" to allow it without prompts.`,
32
+ };
33
+ }
34
+ return { kind: 'ask', reason: askReason };
35
+ }
36
+ export const name = 'tabbit-permissions';
37
+ export const inject = ['tools', 'tabbit'];
38
+ export function apply(ctx) {
39
+ // "挂账本":已发出的内网询问,callId → 该在调用成功后授信的 (会话, origin)。
40
+ // 只在 ask 路径上挂账——这保证授权永远不可能发给"决策当时是公网"的目标
41
+ // (防 DNS rebinding 继承授权,见文件头)。
42
+ const pendingIntranetGrants = new Map();
43
+ // ── 事前拦截:tools/pre-execute 瀑布 ──────────────────────────────────
44
+ ctx.on('tools/pre-execute', async function (exec, next) {
45
+ // 不归我们管的工具:立刻放行给下一个处理器。
46
+ if (!GATED_TOOLS.has(exec.name))
47
+ return next();
48
+ const settings = ctx.tabbit.currentSettings();
49
+ // 总闸 never:两个工具一律拒绝。
50
+ if (settings.pageAccess === 'never') {
51
+ return {
52
+ kind: 'deny',
53
+ reason: 'Tabbit Browser page access is disabled (settings tabbit.pageAccess = never).',
54
+ };
55
+ }
56
+ // ── 附加闸:web_fetch 的内网目标检查(在总闸之前查,因为它更具体)──
57
+ if (exec.name === 'web_fetch') {
58
+ const url = urlArgumentOf(exec.arguments);
59
+ if (url !== undefined) {
60
+ // 内网判定(net.ts):返回原因字符串 = 是内网;undefined = 公网/未知。
61
+ const reason = await privateTargetReason(url);
62
+ if (reason !== undefined) {
63
+ if (settings.intranetFetch === 'never') {
64
+ return {
65
+ kind: 'deny',
66
+ reason: `web_fetch to a private/intranet target is disabled (${reason}; settings tabbit.intranetFetch = never).`,
67
+ };
68
+ }
69
+ if (settings.intranetFetch === 'ask') {
70
+ const agentId = exec.agent !== undefined ? String(exec.agent.id) : undefined;
71
+ const origin = originOf(url);
72
+ // 本会话对这个 origin 是否已授权过?
73
+ const granted = agentId !== undefined && origin !== undefined && ctx.tabbit.hasIntranetGrant(agentId, origin);
74
+ if (!granted) {
75
+ const outcome = resolveAsk(ctx, exec, `web_fetch targets a PRIVATE/INTRANET address through your browser: ${reason}. First request to ${origin ?? 'this target'} in this session — allow it? (Set settings tabbit.intranetFetch to "always" to skip these prompts.)`, 'intranetFetch');
76
+ if (outcome !== 'bypass') {
77
+ // 真的发出了询问:按 callId 挂账,等 tools/result 里对账。
78
+ if (outcome.kind === 'ask' && agentId !== undefined && origin !== undefined) {
79
+ pendingIntranetGrants.set(String(exec.callId), { agentId, origin });
80
+ }
81
+ return outcome;
82
+ }
83
+ // bypass(Full access):内网闸放行,继续走下面的总闸检查。
84
+ }
85
+ }
86
+ // intranetFetch === 'always':不拦,继续。
87
+ }
88
+ }
89
+ }
90
+ // ── 总闸:pageAccess = ask 且本会话还没授权过 → 发起询问 ──────────
91
+ if (settings.pageAccess === 'ask' && exec.agent !== undefined && !ctx.tabbit.hasPageAccessGrant(String(exec.agent.id))) {
92
+ const outcome = resolveAsk(ctx, exec, 'First Tabbit Browser page access in this session: the agent will browse with your real browser profile, including logged-in sessions. Allow browser access for this session? (Set settings tabbit.pageAccess to "always" to skip this prompt.)', 'pageAccess');
93
+ if (outcome !== 'bypass')
94
+ return outcome;
95
+ }
96
+ // 所有门都过了:放行执行。
97
+ return next();
98
+ });
99
+ // ── 事后记账:tools/result ────────────────────────────────────────────
100
+ // 一次被门拦过的调用最终【成功】,说明用户点了允许(或策略放行)——把授权
101
+ // 记进会话记忆,让询问做到"页面访问每会话一次、内网每会话每 origin 一次",
102
+ // 而不是每次调用都问。失败什么都不记(用户拒绝/执行出错,下次重试再问)。
103
+ ctx.on('tools/result', (exec, result) => {
104
+ // 先对内网挂账:找到本 callId 的账,成功才授信,成败都销账。
105
+ const pending = pendingIntranetGrants.get(String(exec.callId));
106
+ if (pending !== undefined) {
107
+ pendingIntranetGrants.delete(String(exec.callId));
108
+ if (!result.isError)
109
+ ctx.tabbit.grantIntranet(pending.agentId, pending.origin);
110
+ }
111
+ // 再记总闸授权:任何受管工具的成功调用都算"用户认可过页面访问"。
112
+ if (!GATED_TOOLS.has(exec.name))
113
+ return;
114
+ if (result.isError)
115
+ return;
116
+ if (exec.agent === undefined)
117
+ return;
118
+ ctx.tabbit.grantPageAccess(String(exec.agent.id));
119
+ });
120
+ }
121
+ /* 提取 URL 的 origin(协议+主机+端口)——内网授权的记忆粒度。 */
122
+ function originOf(url) {
123
+ try {
124
+ return new URL(url).origin;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ /* 从工具参数里安全地抠出 url 字段(参数是模型给的,形状不可信,逐层判断)。 */
131
+ function urlArgumentOf(args) {
132
+ if (typeof args !== 'object' || args === null)
133
+ return undefined;
134
+ const url = args.url;
135
+ return typeof url === 'string' ? url : undefined;
136
+ }
@@ -0,0 +1,229 @@
1
+ /*
2
+ * ============================================================================
3
+ * 文件职责:Tabbit CLI launcher(tabbit-cli,旧名 tabbit-playwright)的底层子进程调用
4
+ * ============================================================================
5
+ *
6
+ * 这是插件与 Tabbit Browser 通信的两条物理通道之一(另一条是 endpoint.ts 的
7
+ * 直连 socket,仅覆盖无任务的清单/健康检查类读取):每次请求都
8
+ * 启动一个 launcher 子进程,把参数放在命令行、代码放在 stdin,从 stdout/stderr
9
+ * 收结果。没有常驻连接、没有 socket——launcher 内部才去连浏览器。
10
+ *
11
+ * launcher 的输入输出契约(对真机 CLI 实测得出):
12
+ * - 成功:stdout 打印一个 JSON 值,退出码 0;
13
+ * - 失败(应用层):stderr 打印【一行 JSON】`{"ok":false,"error":{name,code,message}}`,
14
+ * 退出码 64/69/70;
15
+ * - 失败(外壳层,实例选择问题):stderr 打印【纯文本】,退出码 69;
16
+ * - ⚠️ 每次调用 launcher 都会把 stdin 读到 EOF 才继续——所以哪怕命令不需要
17
+ * stdin(如 `tasks`),也必须写完就关闭 stdin,否则子进程会永远挂着等输入;
18
+ * - 任何命令都可能触发"浏览器自动拉起":浏览器没在跑时,launcher 会先启动它
19
+ * 并重试约 20 秒——这就是为什么下层超时要留足余量(见 client.ts 的常量)。
20
+ *
21
+ * 本文件只做"进程调用 + 输出解码 + 超时/取消",不理解任何业务语义;
22
+ * 业务层的重试、队列、信封解码都在 client.ts。
23
+ */
24
+ import { spawn } from 'node:child_process';
25
+ import { TabbitCliError, classifyAppError } from './errors.js';
26
+ /* stdout/stderr 各自最多缓存 32 MiB,防御子进程疯狂输出把内存打爆。 */
27
+ const STDIO_CAP_BYTES = 32 * 1024 * 1024;
28
+ /*
29
+ * 带上限地累积输出块:超过上限后新数据直接丢弃(保留前面的部分即可,
30
+ * 排障时头部信息最有用)。total 用对象包一层是为了跨调用共享计数。
31
+ */
32
+ function capConcat(chunks, next, total) {
33
+ if (total.bytes >= STDIO_CAP_BYTES)
34
+ return;
35
+ const room = STDIO_CAP_BYTES - total.bytes;
36
+ chunks.push(next.byteLength > room ? next.subarray(0, room) : next);
37
+ total.bytes += Math.min(next.byteLength, room);
38
+ }
39
+ /*
40
+ * 执行一次 launcher 调用。
41
+ *
42
+ * @param argv 命令行参数(不含程序名本身),如 ['nodejs', '--task', 'xxx']。
43
+ * @param stdin 要写进子进程 stdin 的内容(求值代码;无内容的命令传空串)。
44
+ * @returns stdout 解析出的 JSON 值(stdout 为空则是 undefined)。
45
+ * @throws TabbitCliError——所有失败路径都归一为这一种错误类型。
46
+ *
47
+ * 实现要点(Node 子进程管理的标准套路,逐段解释):
48
+ * - spawn() 启动子进程,stdio 三路都接管为管道(pipe);
49
+ * - settled 布尔量保证 resolve/reject 只发生一次(多个事件可能竞争触发);
50
+ * - 超时/取消都是先发 SIGTERM(温和终止),2 秒后没死再补 SIGKILL(强杀);
51
+ * - timer.unref() 让这个定时器不阻止 Node 进程退出;
52
+ * - 'close' 事件(而非 'exit')在【stdio 流全部关闭后】触发,此时输出已收齐,
53
+ * 是做最终判定的正确时机。
54
+ */
55
+ export async function runCli(argv, stdin, options) {
56
+ // 复制当前环境变量,按需叠加实例选择变量——launcher 靠它路由到具体实例。
57
+ const env = { ...process.env };
58
+ if (options.instanceId)
59
+ env.TABBIT_PLAYWRIGHT_INSTANCE = options.instanceId;
60
+ return await new Promise((resolve, reject) => {
61
+ let child;
62
+ try {
63
+ child = spawn(options.launcherPath, argv, { env, stdio: ['pipe', 'pipe', 'pipe'] });
64
+ }
65
+ catch (error) {
66
+ // spawn 同步抛错的少见路径(参数非法等);常见的 ENOENT 走 'error' 事件。
67
+ reject(spawnFailure(options.launcherPath, error));
68
+ return;
69
+ }
70
+ const stdout = [];
71
+ const stderr = [];
72
+ const outTotal = { bytes: 0 };
73
+ const errTotal = { bytes: 0 };
74
+ let settled = false; // Promise 是否已敲定(防止重复 resolve/reject)
75
+ let timedOut = false; // 是否因超时被我们杀掉
76
+ let aborted = false; // 是否因调用方取消被我们杀掉
77
+ // 墙钟超时保护:到点先 SIGTERM,再补刀 SIGKILL。
78
+ const timer = setTimeout(() => {
79
+ timedOut = true;
80
+ child.kill('SIGTERM');
81
+ setTimeout(() => child.kill('SIGKILL'), 2000).unref();
82
+ }, options.timeoutMs);
83
+ timer.unref();
84
+ // 调用方取消(比如用户在 dsh UI 里停止了这条消息):同样杀进程。
85
+ const onAbort = () => {
86
+ aborted = true;
87
+ child.kill('SIGTERM');
88
+ setTimeout(() => child.kill('SIGKILL'), 2000).unref();
89
+ };
90
+ if (options.signal?.aborted)
91
+ onAbort(); // 传进来时就已取消的情况
92
+ options.signal?.addEventListener('abort', onAbort, { once: true });
93
+ // 统一的"只敲定一次"出口:清定时器、摘监听器,再执行真正的 resolve/reject。
94
+ const settle = (fn) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ clearTimeout(timer);
99
+ options.signal?.removeEventListener('abort', onAbort);
100
+ fn();
101
+ };
102
+ child.stdout.on('data', (chunk) => capConcat(stdout, chunk, outTotal));
103
+ child.stderr.on('data', (chunk) => capConcat(stderr, chunk, errTotal));
104
+ // 'error':进程根本没起来(最典型:launcher 文件不存在)。
105
+ child.on('error', (error) => settle(() => reject(spawnFailure(options.launcherPath, error))));
106
+ // 'close':进程退出且 stdio 全部排空——在这里做最终判定。
107
+ child.on('close', (exitCode) => {
108
+ settle(() => {
109
+ const stdoutText = Buffer.concat(stdout).toString('utf8').trim();
110
+ const stderrText = Buffer.concat(stderr).toString('utf8').trim();
111
+ // 优先级:取消 > 超时 > 正常退出码判定。
112
+ if (aborted) {
113
+ reject(new TabbitCliError({
114
+ kind: 'timeout',
115
+ code: 'CANCELLED',
116
+ message: 'Tabbit CLI call was cancelled',
117
+ exitCode,
118
+ stderrRaw: stderrText,
119
+ }));
120
+ return;
121
+ }
122
+ if (timedOut) {
123
+ reject(new TabbitCliError({
124
+ kind: 'timeout',
125
+ code: 'CLIENT_TIMEOUT',
126
+ message: `Tabbit CLI did not respond within ${options.timeoutMs}ms and was killed`,
127
+ exitCode,
128
+ stderrRaw: stderrText,
129
+ }));
130
+ return;
131
+ }
132
+ if (exitCode === 0) {
133
+ // 成功路径:stdout 要么为空(无返回值命令),要么是一个 JSON 值。
134
+ if (stdoutText.length === 0) {
135
+ resolve(undefined);
136
+ return;
137
+ }
138
+ try {
139
+ resolve(JSON.parse(stdoutText));
140
+ }
141
+ catch {
142
+ reject(new TabbitCliError({
143
+ kind: 'protocol',
144
+ code: 'BAD_STDOUT',
145
+ message: `Tabbit CLI printed non-JSON output: ${stdoutText.slice(0, 300)}`,
146
+ exitCode,
147
+ stderrRaw: stderrText,
148
+ }));
149
+ }
150
+ return;
151
+ }
152
+ // 非零退出码:去 stderr 里解码失败原因。
153
+ reject(decodeFailure(exitCode, stderrText));
154
+ });
155
+ });
156
+ child.stdin.on('error', () => {
157
+ /* EPIPE:CLI 没读完 stdin 就退出了(比如立刻报错)。写入失败无所谓,
158
+ 最终结论由 close 处理器根据退出码/输出决定,这里只需吞掉异常防止崩溃。 */
159
+ });
160
+ // 写入代码并【必须关闭 stdin】——launcher 每次都读 stdin 到 EOF(见文件头契约)。
161
+ child.stdin.end(stdin, 'utf8');
162
+ });
163
+ }
164
+ /*
165
+ * 把"进程起不来"翻译成分类错误。
166
+ * ENOENT(文件不存在)/EACCES(无执行权限)= 用户没装或没启动过 Tabbit
167
+ * (launcher 是浏览器首次启动时注册的),给出可操作的提示。
168
+ */
169
+ function spawnFailure(launcherPath, error) {
170
+ const code = error?.code;
171
+ if (code === 'ENOENT' || code === 'EACCES') {
172
+ return new TabbitCliError({
173
+ kind: 'launcher-missing',
174
+ code: 'LAUNCHER_MISSING',
175
+ message: `Tabbit CLI launcher not found or not executable at ${launcherPath}. Install and launch Tabbit Browser once to register it.`,
176
+ });
177
+ }
178
+ return new TabbitCliError({
179
+ kind: 'protocol',
180
+ code: 'SPAWN_FAILED',
181
+ message: `Failed to start Tabbit CLI: ${String(error?.message ?? error)}`,
182
+ });
183
+ }
184
+ /*
185
+ * 解码非零退出码的失败。两种来源(见文件头契约):
186
+ * 1. 原生 CLI:stderr 里找以 '{' 开头的那一行,按 JSON 解析出
187
+ * {error:{name,code,message}},交给 classifyAppError 归类;
188
+ * 2. 外壳脚本:纯文本 stderr——退出码 69 时归类为"实例选择"问题
189
+ * (外壳只在这种情况下自己报错),其余归为协议层未知失败。
190
+ */
191
+ function decodeFailure(exitCode, stderrText) {
192
+ // stderr 可能混有日志行,只认以 '{' 开头的那一行 JSON。
193
+ const jsonLine = stderrText
194
+ .split('\n')
195
+ .map((line) => line.trim())
196
+ .find((line) => line.startsWith('{'));
197
+ if (jsonLine) {
198
+ try {
199
+ const parsed = JSON.parse(jsonLine);
200
+ const error = parsed.error ?? {};
201
+ return new TabbitCliError({
202
+ kind: classifyAppError(error),
203
+ code: error.code ?? 'REQUEST_FAILED',
204
+ message: error.message ?? 'Unknown error',
205
+ exitCode,
206
+ stderrRaw: stderrText,
207
+ });
208
+ }
209
+ catch {
210
+ /* JSON 解析失败:落回下面的纯文本处理 */
211
+ }
212
+ }
213
+ if (exitCode === 69) {
214
+ return new TabbitCliError({
215
+ kind: 'instance-selection',
216
+ code: 'INSTANCE_SELECTION',
217
+ message: stderrText || 'The Tabbit CLI launcher could not select a Tabbit Browser instance.',
218
+ exitCode,
219
+ stderrRaw: stderrText,
220
+ });
221
+ }
222
+ return new TabbitCliError({
223
+ kind: 'protocol',
224
+ code: 'CLI_FAILED',
225
+ message: stderrText || `Tabbit CLI exited with code ${exitCode}`,
226
+ exitCode,
227
+ stderrRaw: stderrText,
228
+ });
229
+ }