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,343 @@
1
+ /*
2
+ * ============================================================================
3
+ * 文件职责:插件自身的「每日更新检查」——纯逻辑层
4
+ * ============================================================================
5
+ *
6
+ * 出处:自 github:Tabbit-Browser/dsh-tabbit(本包 npm 0.2.x 世代)的
7
+ * update-check.js 移植。0.2.x 起线上用户就带着这套机制:skill 每次加载时
8
+ * 顺带比对「已装版本 vs 发布仓库 CHANGELOG.md 的最新版本段」,有新版就在
9
+ * skill 正文顶部插一段更新通知,由模型转告用户并询问是否升级。
10
+ * 【0.3.0 起 CHANGELOG 的最新段落就是老用户看到的升级文案】——写它时开头
11
+ * 500 字符要能独立成文(见 truncateChangelog 的截断规则)。
12
+ *
13
+ * 设计约束(缓存/退避/拒绝记忆沿袭 0.2.x,保证升级用户行为连续):
14
+ * - 每天最多查一轮:成功结果缓存 24 小时;失败也静默退避 24 小时
15
+ * (离线用户不该每次会话都白等超时);
16
+ * - 【更新源自 0.3.0 起改为 npm】
17
+ * - 用户拒绝过的版本记下来不再重复announce(更新的版本出现才再提);
18
+ * - 缓存文件路径【必须与 0.2.x 相同】(XDG 缓存下 tabbit-dsh/),
19
+ * 这样 0.2.x → 0.3.x 升级后 dismissed 记录、退避状态原样继续生效。
20
+ *
21
+ * 本文件是纯逻辑(不依赖任何 dsh 包),两处接线在别的文件:
22
+ * - core/index.ts 的 skill provider:get() 返回正文前调 prependUpdateNotice;
23
+ * - installer/index.ts:注册 `tabbit_plugin_update` 工具(记录拒绝/强制重查)。
24
+ *
25
+ * 【浏览器托管形态的静默】:Tabbit Browser 预装(vendored)形态下插件版本
26
+ * 由浏览器随自身更新管理,若照通知里的 `dsh plugin add` 跑一遍,会把浏览器
27
+ * 托管的安装覆盖成 npm 版、脱离浏览器的版本管理。嵌入形态的权威信号是
28
+ * 浏览器启动 dsh 时注入的 TABBIT_PLAYWRIGHT_INSTANCE 环境变量——检测到它
29
+ * 就整套静默(不发请求、不插通知;用户手动设了该变量的极小众场景会被误伤,
30
+ * 代价可接受)。
31
+ */
32
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
33
+ import { homedir } from 'node:os';
34
+ import { dirname, join } from 'node:path';
35
+ const DAY_MS = 24 * 60 * 60 * 1000;
36
+ /* 单个网络请求限时 1.5 秒:更新检查是锦上添花,绝不能拖慢 skill 加载。
37
+ * 最坏路径是两个串行请求(有新版时才有第二个),每天最多一轮。 */
38
+ const FETCH_TIMEOUT_MS = 1500;
39
+ /* 通知里塞给模型的变更摘要上限(CHANGELOG 版本段压平空白后截断到此长度)。 */
40
+ const CHANGELOG_MAX_CHARS = 500;
41
+ /*
42
+ * 版本真相源:npm registry 的 latest 版本清单(标准公开端点,无需鉴权、
43
+ * 无 GitHub API 式的匿名配额问题)。可用环境变量 TABBIT_PLUGIN_UPDATE_URL
44
+ * 覆盖(测试/私有镜像用;覆盖的是这个 JSON 清单地址)。
45
+ * 注意:0.2.x 存量安装轮询的是旧仓库 Tabbit-Browser/dsh-tabbit main 的
46
+ * raw CHANGELOG.md——想通知到他们,旧仓库的 CHANGELOG.md 需要镜像新版本段
47
+ * (见 AGENTS.md 的发布流程)。
48
+ */
49
+ const DEFAULT_MANIFEST_URL = 'https://registry.npmjs.org/dsh-tabbit/latest';
50
+ /* 变更说明源:jsdelivr 按版本锚定的发布 tarball 内 CHANGELOG.md(锚定 URL
51
+ * 内容不可变、可永久缓存;解析新鲜度由上面的 registry 清单保证)。 */
52
+ function defaultChangelogUrl(version) {
53
+ return `https://cdn.jsdelivr.net/npm/dsh-tabbit@${encodeURIComponent(version)}/CHANGELOG.md`;
54
+ }
55
+ /* 本包 package.json 的位置(编译后本文件在 lib/ 下,包根在上一级)。 */
56
+ const PACKAGE_URL = new URL('../package.json', import.meta.url);
57
+ /* 进程级缓存已读到的本包版本(null = 读过但失败,undefined = 还没读过)。 */
58
+ let cachedLocalVersion;
59
+ /* "1.9.2" / "v1.9" → [1,9,2];解析不出(空/乱格式)→ undefined。 */
60
+ function numericVersion(version) {
61
+ const match = String(version ?? '').trim().match(/^v?(\d+(?:\.\d+)*)/i);
62
+ return match?.[1] !== undefined ? match[1].split('.').map(Number) : undefined;
63
+ }
64
+ /*
65
+ * 数字段逐段比较两个版本号:1 = left 新,-1 = right 新,0 = 相等;
66
+ * 任一方解析不出 → undefined(调用方把 undefined 当"不可比、不提示"处理)。
67
+ */
68
+ export function compareVersions(left, right) {
69
+ const leftParts = numericVersion(left);
70
+ const rightParts = numericVersion(right);
71
+ if (!leftParts || !rightParts)
72
+ return undefined;
73
+ const length = Math.max(leftParts.length, rightParts.length);
74
+ for (let index = 0; index < length; index += 1) {
75
+ const a = leftParts[index] ?? 0;
76
+ const b = rightParts[index] ?? 0;
77
+ if (a !== b)
78
+ return a > b ? 1 : -1;
79
+ }
80
+ return 0;
81
+ }
82
+ /* 压平所有空白(含换行)为单个空格——CHANGELOG 段落要塞进单行通知。 */
83
+ export function flattenChangelog(text) {
84
+ return String(text ?? '').replace(/\s+/g, ' ').trim();
85
+ }
86
+ /* 压平后截断到 500 字符(尾部加省略号)。 */
87
+ export function truncateChangelog(text) {
88
+ const value = flattenChangelog(text);
89
+ if (value.length <= CHANGELOG_MAX_CHARS)
90
+ return value;
91
+ return `${value.slice(0, CHANGELOG_MAX_CHARS - 1).trimEnd()}…`;
92
+ }
93
+ /*
94
+ * 从 CHANGELOG.md 全文里抠出【最新一个版本段】:
95
+ * 找第一个 `## <版本号>` 标题行,取它到下一个 `## ` 之间的内容作为变更摘要。
96
+ * 没有版本标题 = 文件坏了,抛错(调用方把抛错当"检查失败"静默处理)。
97
+ */
98
+ export function parseLatestChangelog(markdown) {
99
+ const match = String(markdown ?? '').match(/^## +(v?\d+(?:\.\d+)+).*$/m);
100
+ if (!match || match.index === undefined)
101
+ throw new Error('Latest changelog has no version heading.');
102
+ const version = numericVersion(match[1])?.join('.');
103
+ if (version === undefined)
104
+ throw new Error('Latest changelog heading has no usable version.');
105
+ const sectionStart = match.index + match[0].length;
106
+ const rest = String(markdown).slice(sectionStart);
107
+ const nextSection = rest.search(/^## +/m);
108
+ const section = nextSection === -1 ? rest : rest.slice(0, nextSection);
109
+ return { version, changelog: truncateChangelog(section) };
110
+ }
111
+ /*
112
+ * 缓存文件的默认位置(【与 0.2.x 完全一致】,保证升级后状态连续):
113
+ * XDG_CACHE_HOME(设了就用)> Windows 的 LOCALAPPDATA > ~/.cache,
114
+ * 之下 tabbit-dsh/update-check.json。
115
+ */
116
+ export function defaultCacheFile(env = process.env, platform = process.platform) {
117
+ const base = env.XDG_CACHE_HOME ||
118
+ (platform === 'win32' ? env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local') : undefined) ||
119
+ join(homedir(), '.cache');
120
+ return join(base, 'tabbit-dsh', 'update-check.json');
121
+ }
122
+ /* 读本包已安装版本(package.json 的 version;读不到 → undefined,检查报 unknown)。 */
123
+ export async function readLocalVersion() {
124
+ if (cachedLocalVersion !== undefined)
125
+ return cachedLocalVersion ?? undefined;
126
+ try {
127
+ const parsed = JSON.parse(await readFile(PACKAGE_URL, 'utf8'));
128
+ cachedLocalVersion = typeof parsed.version === 'string' ? parsed.version : null;
129
+ }
130
+ catch {
131
+ cachedLocalVersion = null;
132
+ }
133
+ return cachedLocalVersion ?? undefined;
134
+ }
135
+ export async function readCachedCheck(cacheFile) {
136
+ try {
137
+ const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
138
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
139
+ }
140
+ catch {
141
+ return {};
142
+ }
143
+ }
144
+ async function writeCachedCheck(cacheFile, state) {
145
+ await mkdir(dirname(cacheFile), { recursive: true });
146
+ await writeFile(cacheFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
147
+ }
148
+ /* 限时取回一个 URL 的完整文本(含响应体读取都在超时保护内)。 */
149
+ async function fetchTextWithTimeout(url, timeoutMs, fetchImpl) {
150
+ const controller = new AbortController();
151
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
152
+ try {
153
+ const response = await fetchImpl(url, { signal: controller.signal });
154
+ if (!response.ok)
155
+ throw new Error(`Update check failed with HTTP ${response.status}.`);
156
+ return await response.text();
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+ /*
163
+ * 从 CHANGELOG 全文抠出【指定版本】的段落(压平+截断后返回)。
164
+ * 与 parseLatestChangelog(取最新段)的分工:这里按 registry 报的版本精确
165
+ * 定位——锚定 tarball 里最新段理应就是该版本,但万一发布时忘了加条目、
166
+ * 或段落顺序有误,精确匹配能避免把别的版本的说明安到这个版本头上。
167
+ * 精确段缺失时退回"最新段",且仅当最新段的版本号与目标一致才采用;
168
+ * 都不成立返回 undefined(通知降级为无摘要)。
169
+ */
170
+ export function changelogSectionFor(markdown, version) {
171
+ const source = String(markdown ?? '');
172
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
173
+ const heading = source.match(new RegExp(`^## +v?${escaped}(?!\\S).*$`, 'm'));
174
+ if (heading !== null && heading.index !== undefined) {
175
+ const rest = source.slice(heading.index + heading[0].length);
176
+ const next = rest.search(/^## +/m);
177
+ const section = truncateChangelog(next === -1 ? rest : rest.slice(0, next));
178
+ return section !== '' ? section : undefined;
179
+ }
180
+ try {
181
+ const latest = parseLatestChangelog(source);
182
+ if (compareVersions(latest.version, version) === 0 && latest.changelog !== '')
183
+ return latest.changelog;
184
+ }
185
+ catch {
186
+ // 整个文件没有版本标题:没有可用摘要。
187
+ }
188
+ return undefined;
189
+ }
190
+ /*
191
+ * 取最新发布:
192
+ * 1. 问 npm registry latest 清单拿版本号(版本真相,失败即整个检查失败、
193
+ * 进入 24h 退避);
194
+ * 2. 只有确认比已装新,才去 jsdelivr 拉【该版本锚定】的 CHANGELOG.md 抠
195
+ * 对应段落——这步任何失败都只降级为"无摘要",绝不挡升级提示(jsdelivr
196
+ * 在部分网络环境下可达性弱于 registry,不能让它拖垮主通道)。
197
+ */
198
+ export async function fetchLatestRelease({ manifestUrl = process.env.TABBIT_PLUGIN_UPDATE_URL || DEFAULT_MANIFEST_URL, changelogUrlFor = defaultChangelogUrl, timeoutMs = FETCH_TIMEOUT_MS, fetchImpl = fetch, currentVersion, } = {}) {
199
+ const manifest = JSON.parse(await fetchTextWithTimeout(manifestUrl, timeoutMs, fetchImpl));
200
+ const version = typeof manifest.version === 'string' ? manifest.version.trim() : '';
201
+ if (numericVersion(version) === undefined)
202
+ throw new Error('npm manifest has no usable version.');
203
+ if (currentVersion !== undefined && compareVersions(version, currentVersion) !== 1)
204
+ return { version };
205
+ try {
206
+ const changelog = changelogSectionFor(await fetchTextWithTimeout(changelogUrlFor(version), timeoutMs, fetchImpl), version);
207
+ return changelog !== undefined ? { version, changelog } : { version };
208
+ }
209
+ catch {
210
+ return { version };
211
+ }
212
+ }
213
+ function isRecent(timestamp, now) {
214
+ return typeof timestamp === 'number' && now - timestamp < DAY_MS;
215
+ }
216
+ /*
217
+ * 把"已装版本 + 已知最新版本 + 拒绝记录"归纳成结论:
218
+ * - 任一版本缺失/不可比 → unknown(静默,不打扰);
219
+ * - 最新 ≤ 已装,或最新恰是用户拒绝过的 → current;
220
+ * - 否则 → update-available(带变更摘要)。
221
+ */
222
+ export function summarizeUpdate({ currentVersion, latestVersion, changelog, dismissedVersion, }) {
223
+ if (currentVersion === undefined || latestVersion === undefined)
224
+ return { status: 'unknown', currentVersion };
225
+ if (compareVersions(latestVersion, currentVersion) !== 1 || latestVersion === dismissedVersion) {
226
+ return { status: 'current', currentVersion, latestVersion };
227
+ }
228
+ return { status: 'update-available', currentVersion, latestVersion, changelog };
229
+ }
230
+ function summaryFromCache(currentVersion, cached) {
231
+ return summarizeUpdate({
232
+ currentVersion,
233
+ latestVersion: cached.latestVersion,
234
+ changelog: cached.changelog,
235
+ dismissedVersion: cached.dismissedVersion,
236
+ });
237
+ }
238
+ async function fetchAndCacheRelease({ currentVersion, cached, cacheFile, now, fetchRelease, }) {
239
+ const state = { ...cached, lastAttemptAt: now };
240
+ try {
241
+ const release = await fetchRelease({ currentVersion });
242
+ state.checkedAt = now;
243
+ state.latestVersion = release.version;
244
+ // 有意整体覆盖(包括写入 undefined→序列化时丢弃):旧版本的摘要绝不能
245
+ // 残留下来配错到新版本号头上。
246
+ state.changelog = release.changelog;
247
+ }
248
+ catch {
249
+ // 保留 lastAttemptAt:失败也静默退避一天再试(离线用户不被反复拖慢)。
250
+ }
251
+ try {
252
+ await writeCachedCheck(cacheFile, state);
253
+ }
254
+ catch {
255
+ // 缓存目录只读等写失败不影响本次结论(只是下次会重查)。
256
+ }
257
+ return summaryFromCache(currentVersion, state);
258
+ }
259
+ /*
260
+ * 检查主入口。非 force 时的省流顺序:
261
+ * 1. 24 小时内成功查过 → 直接用缓存归纳;
262
+ * 2. 24 小时内失败过 → unknown(退避中,不发请求);
263
+ * 3. 否则才真正发请求并写缓存。
264
+ */
265
+ export async function checkPluginUpdate({ now = Date.now(), cacheFile = defaultCacheFile(), fetchRelease = fetchLatestRelease, readVersion = readLocalVersion, force = false, } = {}) {
266
+ const currentVersion = await readVersion();
267
+ const cached = await readCachedCheck(cacheFile);
268
+ if (!force) {
269
+ if (isRecent(cached.checkedAt, now) && cached.latestVersion !== undefined) {
270
+ return summaryFromCache(currentVersion, cached);
271
+ }
272
+ if (isRecent(cached.lastAttemptAt, now)) {
273
+ return { status: 'unknown', currentVersion };
274
+ }
275
+ }
276
+ return fetchAndCacheRelease({ currentVersion, cached, cacheFile, now, fetchRelease });
277
+ }
278
+ /* 记录"用户拒绝了这个版本"(skill 从此不再announce它;更新的版本出现才再提)。 */
279
+ export async function dismissUpdate(version, { cacheFile = defaultCacheFile() } = {}) {
280
+ const cached = await readCachedCheck(cacheFile);
281
+ const state = { ...cached, dismissedVersion: String(version ?? '').trim() };
282
+ await writeCachedCheck(cacheFile, state);
283
+ return state;
284
+ }
285
+ /*
286
+ * 浏览器托管形态判定(见文件头):TABBIT_PLAYWRIGHT_INSTANCE 是浏览器启动
287
+ * 嵌入 dsh 时注入的权威通道,出现即认为插件版本归浏览器管、整套更新检查静默。
288
+ */
289
+ export function isBrowserManagedInstall(env = process.env) {
290
+ const value = env.TABBIT_PLAYWRIGHT_INSTANCE;
291
+ return value !== undefined && value !== '';
292
+ }
293
+ /*
294
+ * 生成插到 skill 正文顶部的更新通知(Markdown 引用块)。
295
+ * 【只用本地模板拼接】:changelog 摘要来自我们自己仓库的 CHANGELOG,且以
296
+ * 数据形式呈现——通知里的行动指令(问用户、跑哪条命令、怎么 dismiss)全部
297
+ * 是这里硬编码的模板文字,摘要影响不了指令本身。
298
+ */
299
+ export function formatUpdateNotice({ currentVersion, latestVersion, changelog, }) {
300
+ return [
301
+ `> **Plugin update available**: dsh-tabbit ${latestVersion} (installed ${currentVersion}).`,
302
+ changelog !== undefined && changelog !== '' ? `> New in ${latestVersion}: ${changelog}` : null,
303
+ '> Show the offered version and these changes to the user, then ask whether to update now.',
304
+ '> If they agree, tell them to rerun the install command below over the current install and restart the DSH session afterwards:',
305
+ '> ```bash',
306
+ '> dsh plugin --profile web add dsh-tabbit',
307
+ '> ```',
308
+ `> If they decline, call \`tabbit_plugin_update\` with \`dismiss: "${latestVersion}"\`, then continue the task.`,
309
+ ]
310
+ .filter(Boolean)
311
+ .join('\n');
312
+ }
313
+ /*
314
+ * 给 skill 正文加前缀通知(core 的 skill provider get() 调用):
315
+ * - 浏览器托管形态:原样返回(版本归浏览器管,见文件头);
316
+ * - 检查抛错/没有新版:原样返回;
317
+ * - 有新版:通知 + 空行 + 原正文。
318
+ */
319
+ export async function prependUpdateNotice(content, checkUpdate = checkPluginUpdate, env = process.env) {
320
+ if (isBrowserManagedInstall(env))
321
+ return content;
322
+ let update;
323
+ try {
324
+ update = await checkUpdate();
325
+ }
326
+ catch {
327
+ return content;
328
+ }
329
+ if (update.status !== 'update-available')
330
+ return content;
331
+ return `${formatUpdateNotice(update)}\n\n${content}`;
332
+ }
333
+ /* `tabbit_plugin_update` 工具的结果文案(installer/index.ts 注册处调用)。 */
334
+ export function messageForUpdate(update) {
335
+ if (update.status === 'update-available') {
336
+ const changes = update.changelog !== undefined && update.changelog !== '' ? update.changelog : 'see the release notes';
337
+ return `dsh-tabbit ${update.latestVersion} is available (installed ${update.currentVersion}). New in this version: ${changes}. Ask the user whether to update now.`;
338
+ }
339
+ if (update.status === 'current') {
340
+ return `The dsh-tabbit plugin is up to date (${update.currentVersion}).`;
341
+ }
342
+ return 'Could not determine the latest dsh-tabbit plugin version. The check stays silent for a day before retrying.';
343
+ }
@@ -0,0 +1,219 @@
1
+ /*
2
+ * ============================================================================
3
+ * 文件职责:浏览器代理的 `web_fetch` provider
4
+ * ============================================================================
5
+ *
6
+ * 为什么存在(背景知识):dsh 内置了 `web_fetch` 工具(模型抓网页正文用),
7
+ * 并自 0.1.2-alpha.1 起自带一个匿名直连的 HTTP 抓取 provider
8
+ * (@deepseek-ai/dsh-web-fetch-http,id `http`):先解析域名、要求答案全部
9
+ * 是公网单播才放行,再把连接钉在验证过的 IP 上直连。这套 SSRF 防护很严谨,
10
+ * 但对真实网页力不从心——无 JS 渲染、无登录态、不走系统代理;在 fake-ip
11
+ * 模式代理的机器上(国内常见),一切域名都解析进保留网段,它会把所有请求
12
+ * 判成"非公网 IP"拒绝(WEB_BLOCKED_URL)。dsh 的 `ctx.web` 服务开放了
13
+ * registerFetchProvider 扩展点:本文件注册一个更能打的替代者。
14
+ *
15
+ * 本文件的做法:
16
+ * - 用【用户的真实 Tabbit 浏览器】取页(顺带白赚 JS 渲染和登录墙内容);
17
+ * - 安全性与 permissions 模块的双闸配对:pageAccess 总闸 + intranetFetch
18
+ * 内网附加闸(默认逐 origin 审批)——这是"敢启用 fetch"的全部底气;
19
+ * - 请求前的内网判定在 permissions 里做;本文件负责【事后半段】:拿到
20
+ * 最终 URL(重定向后的落点)再查一次,公网请求跳到内网目标一律拒绝
21
+ * 返回内容(redirect containment,防"公网 URL 302 进内网"绕闸)。
22
+ *
23
+ * 执行模型:所有 fetch 共用一个固定只读任务(FETCH_TASK_NAME,在用户浏览器
24
+ * 里显示为 "DeepSeek Harness · Web Fetch" 标签组);每个请求开一个新页
25
+ * (用户可见、不抢焦点),提取完 finally 关页——任务本身常驻复用。
26
+ *
27
+ * 选择机制:dsh 的 `ctx.web` 按【钉死的 provider id】选 provider——没有
28
+ * 优先级链,config 里钉的 id 还优先于环境变量 DSH_WEB_FETCH_PROVIDER。基座
29
+ * 把 fetchProvider 钉为内置的 `http`,本 bundle 的 cordis.patch.yml 把它改
30
+ * 钉为本 provider 的 id `tabbit-browser`——因此本文件注册后就是唯一会被使
31
+ * 用的抓取通道;浏览器不可用时 dsh 报 WEB_PROVIDER_CONFIGURED_UNAVAILABLE
32
+ * (工具仍可见,调用时才报错),不会回落到内置直连抓取器。工具开关
33
+ * tool-web.fetch 由 web 形态的标准 preset(alpha.1 起自带 true)和本 bundle
34
+ * 补丁(headless 形态)打开。
35
+ */
36
+ import { existsSync } from 'node:fs';
37
+ // WebError:dsh-web 定义的标准错误类型(带错误码),fetch 失败必须抛它,
38
+ // dsh 才能把错误规整地渲染给模型。
39
+ import { WebError } from '@deepseek-ai/dsh-web';
40
+ import { FETCH_TASK_NAME } from '../core/index.js';
41
+ import { TabbitCliError } from '../runtime/errors.js';
42
+ import { hostnameLooksLocal, ipIsPrivate } from '../runtime/net.js';
43
+ /* 提取正文的字符上限(在浏览器侧就截断,见 buildFetchCode)。 */
44
+ const MAX_TEXT_CHARS = 180_000;
45
+ /* 页面导航(goto)超时。 */
46
+ const NAV_TIMEOUT_MS = 25_000;
47
+ /* 整段求值超时(导航 + 稳定等待 + 提取,留了余量)。 */
48
+ const EVAL_TIMEOUT_MS = 45_000;
49
+ /* dsh WebFetchProvider 接口的实现(id 是 provider 的注册标识)。 */
50
+ class TabbitFetchProvider {
51
+ tabbit;
52
+ id = 'tabbit-browser';
53
+ /* available() 的 5 秒结果缓存(该方法会被频繁调用,别每次都摸文件系统)。 */
54
+ availabilityCache;
55
+ constructor(tabbit) {
56
+ this.tabbit = tabbit;
57
+ }
58
+ /*
59
+ * dsh 用它判断"这个 provider 现在能不能用"。刻意只做【零副作用的本地文件
60
+ * 检查】:launcher 文件存在 + 实例注册表非空。不真连浏览器(那会把离线的
61
+ * 浏览器拉起来),也不管在不在线(fetch 真跑的时候 launcher 自会拉起浏览器)。
62
+ */
63
+ available() {
64
+ const now = Date.now();
65
+ if (this.availabilityCache !== undefined && now - this.availabilityCache.at < 5000) {
66
+ return this.availabilityCache.value;
67
+ }
68
+ const value = existsSync(this.tabbit.launcherPath()) && this.tabbit.instances().length > 0;
69
+ this.availabilityCache = { at: now, value };
70
+ return value;
71
+ }
72
+ /* 一次抓取。全流程:校验 URL → 浏览器里取页提取 → 重定向围堵 → 组装结果。 */
73
+ async fetch(request, signal) {
74
+ let url;
75
+ try {
76
+ url = new URL(request.url);
77
+ }
78
+ catch {
79
+ throw new WebError(`invalid URL: ${request.url}`, 'FETCH_INVALID_URL');
80
+ }
81
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
82
+ throw new WebError(`unsupported URL scheme "${url.protocol}" (http/https only)`, 'FETCH_INVALID_URL');
83
+ }
84
+ // 记住请求【本来就是】内网目标的情况(此时用户已经通过 intranetFetch 闸
85
+ // 批准过了)——这种请求最终落在内网属正常,不该被下面的重定向围堵误杀。
86
+ const requestWasPrivate = hostIsObviouslyPrivate(url.hostname);
87
+ const client = this.tabbit.client();
88
+ let outcome;
89
+ try {
90
+ outcome = await client.evaluate({
91
+ task: FETCH_TASK_NAME,
92
+ readOnly: true, // 抓取声明为只读:中断不会把共享任务打进隔离状态
93
+ timeoutMs: EVAL_TIMEOUT_MS,
94
+ code: buildFetchCode(url.href),
95
+ ...(signal ? { signal } : {}),
96
+ });
97
+ }
98
+ catch (error) {
99
+ // CLI 层错误 → 翻译成 dsh 的 WebError(错误码映射见 webErrorCode)。
100
+ if (error instanceof TabbitCliError) {
101
+ throw new WebError(`Tabbit browser fetch failed: ${error.message}`, webErrorCode(error));
102
+ }
103
+ throw error;
104
+ }
105
+ // 登记共享任务实际用的实例(插件卸载时 releaseAll 要在对的实例上 finish 它)。
106
+ this.tabbit.markFetchTaskUsed(client.resolvedInstanceId());
107
+ if (outcome.status === 'failed') {
108
+ const message = outcome.errorMessage ?? 'navigation failed';
109
+ const code = /timeout/iu.test(message) ? 'FETCH_TIMEOUT' : 'FETCH_FAILED';
110
+ throw new WebError(`Tabbit browser fetch failed: ${message}`, code);
111
+ }
112
+ const value = (outcome.result?.value ?? {});
113
+ const finalUrl = typeof value.finalUrl === 'string' && value.finalUrl !== '' ? value.finalUrl : url.href;
114
+ // 【重定向围堵】:公网发起的请求,最终 URL 若落在私有目标上,拒绝返回
115
+ // 内容——否则攻击者可用一个公网短链 302 到 http://127.0.0.1:8080/admin
116
+ // 之类,绕过 intranetFetch 闸把内网内容送进模型上下文。
117
+ if (!requestWasPrivate) {
118
+ try {
119
+ const finalHost = new URL(finalUrl).hostname;
120
+ if (hostIsObviouslyPrivate(finalHost)) {
121
+ throw new WebError(`fetch of ${url.href} redirected to a private target (${finalHost}); refusing to return its content`, 'FETCH_BLOCKED_REDIRECT');
122
+ }
123
+ }
124
+ catch (error) {
125
+ if (error instanceof WebError)
126
+ throw error;
127
+ /* 最终 URL 解析不了:按原始 URL 继续(不因解析失败误杀) */
128
+ }
129
+ }
130
+ // 组装 dsh 期望的 WebFetchResult:标题拼成 Markdown 一级标题打头。
131
+ const title = typeof value.title === 'string' ? value.title : '';
132
+ const text = typeof value.text === 'string' ? value.text : '';
133
+ const content = title !== '' ? `# ${title}\n\n${text}` : text;
134
+ return {
135
+ url: finalUrl,
136
+ statusCode: typeof value.status === 'number' && value.status > 0 ? value.status : 200,
137
+ body: { kind: 'text', content },
138
+ truncated: value.truncated === true || outcome.result?.truncated === true,
139
+ };
140
+ }
141
+ }
142
+ /* "一眼可判"的私有主机检查(字面 IP 私有段 / 本地域名形态;不做 DNS——同步、够快)。 */
143
+ function hostIsObviouslyPrivate(hostname) {
144
+ const bare = hostname.replace(/^\[|\]$/gu, '');
145
+ return ipIsPrivate(bare) || hostnameLooksLocal(bare);
146
+ }
147
+ /* TabbitCliError 类别 → dsh WebError 错误码的映射。 */
148
+ function webErrorCode(error) {
149
+ switch (error.kind) {
150
+ case 'launcher-missing':
151
+ case 'instance-selection':
152
+ case 'browser-unavailable':
153
+ return 'TABBIT_UNAVAILABLE';
154
+ case 'busy':
155
+ return 'TABBIT_BUSY';
156
+ case 'timeout':
157
+ return 'FETCH_TIMEOUT';
158
+ default:
159
+ return 'FETCH_FAILED';
160
+ }
161
+ }
162
+ /*
163
+ * 生成在浏览器任务里执行的取页脚本(作为 evaluate 的 code 参数)。
164
+ * URL 经 JSON.stringify 嵌入——这是防注入的关键:URL 里的引号/反斜杠都会被
165
+ * 正确转义,不可能"逃出"字符串字面量变成代码。
166
+ *
167
+ * 脚本逻辑(在浏览器进程里跑,可用注入全局 context 等):
168
+ * 1. context.newPage() 开新页(挂在共享 fetch 任务的标签组下);
169
+ * 2. goto 导航,等到 domcontentloaded(DOM 就绪即可,不等全部资源);
170
+ * 3. 再等 400ms 让常见的客户端渲染稳一稳;
171
+ * 4. p.evaluate(...) 进入【页面 JS 环境】提取:优先 article/main/[role=main]
172
+ * 语义正文容器,退化到 body;取 innerText(渲染后的可见文本,自动剔除
173
+ * script/style/隐藏元素——比 innerHTML 干净得多);
174
+ * 5. 超 18 万字符截断并标记;
175
+ * 6. 返回 {finalUrl(重定向后落点,围堵检查用), status, title, text, truncated};
176
+ * 7. finally 无论成败必关页——不留孤儿标签页。
177
+ *
178
+ * ⚠️ 模板字符串内是下发浏览器的代码原文,勿在其中加注释/改动。
179
+ */
180
+ function buildFetchCode(url) {
181
+ return `const target = ${JSON.stringify(url)};
182
+ const p = await context.newPage();
183
+ try {
184
+ let resp = null;
185
+ try {
186
+ resp = await p.goto(target, { waitUntil: 'domcontentloaded', timeout: ${NAV_TIMEOUT_MS} });
187
+ } catch (error) {
188
+ throw new Error('navigation failed: ' + String((error && error.message) || error));
189
+ }
190
+ await p.waitForTimeout(400);
191
+ const data = await p.evaluate(() => {
192
+ const root = document.querySelector('article, main, [role="main"]') || document.body;
193
+ const title = document.title || '';
194
+ const text = root ? root.innerText || '' : '';
195
+ return { title, text };
196
+ });
197
+ let text = data.text || '';
198
+ let truncated = false;
199
+ if (text.length > ${MAX_TEXT_CHARS}) {
200
+ text = text.slice(0, ${MAX_TEXT_CHARS});
201
+ truncated = true;
202
+ }
203
+ return {
204
+ finalUrl: p.url(),
205
+ status: resp ? resp.status() : 0,
206
+ title: data.title,
207
+ text,
208
+ truncated,
209
+ };
210
+ } finally {
211
+ try { await p.close(); } catch {}
212
+ }`;
213
+ }
214
+ export const name = 'tabbit-web-fetch';
215
+ export const inject = ['web', 'tabbit'];
216
+ /* 插件入口:把 provider 注册进 dsh 的 web 服务(补丁层已把 fetchProvider 钉到它的 id 上)。 */
217
+ export function apply(ctx) {
218
+ ctx.web.registerFetchProvider(new TabbitFetchProvider(ctx.tabbit));
219
+ }
package/package.json CHANGED
@@ -1,41 +1,80 @@
1
1
  {
2
2
  "name": "dsh-tabbit",
3
- "version": "0.2.3",
4
- "description": "DSH bundle that packages the Tabbit Browser skill and background installer",
3
+ "version": "0.3.2",
4
+ "description": "Tabbit Browser bundle for DeepSeek Harness: a code-first tabbit_browser tool, browser-backed web_fetch, @tab composer mentions, a dedicated page-access permission, an environment preflight with background installer download, and a daily plugin update check.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Tabbit-Browser/dsh-tabbit.git"
8
8
  },
9
9
  "type": "module",
10
- "main": "./index.js",
10
+ "main": "./lib/core/index.js",
11
11
  "exports": {
12
- ".": "./index.js"
12
+ ".": "./lib/core/index.js",
13
+ "./core": "./lib/core/index.js",
14
+ "./permissions": "./lib/permissions/index.js",
15
+ "./tool-browser": "./lib/tool-browser/index.js",
16
+ "./web-fetch": "./lib/web-fetch/index.js",
17
+ "./mentions": "./lib/mentions/index.js",
18
+ "./installer": "./lib/installer/index.js",
19
+ "./client": "./client/client.js",
20
+ "./runtime": "./lib/runtime/client.js",
21
+ "./cordis.patch.yml": "./cordis.patch.yml",
22
+ "./package.json": "./package.json"
13
23
  },
14
24
  "files": [
15
- "index.js",
16
- "installer.js",
17
- "update-check.js",
25
+ "lib",
26
+ "client",
27
+ "skills",
18
28
  "cordis.patch.yml",
19
- "skills/**",
20
29
  "README.md",
21
- "README.zh-CN.md"
30
+ "README.en.md",
31
+ "CHANGELOG.md"
22
32
  ],
23
- "scripts": {
24
- "test": "node --test"
25
- },
26
33
  "dsh": {
27
34
  "bundle": {
28
35
  "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-input-trigger"
41
+ ]
29
42
  }
30
43
  },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "prepack": "npm run build",
47
+ "test": "npm run build && node --test",
48
+ "link-harness": "node scripts/link-harness.mjs"
49
+ },
31
50
  "engines": {
32
- "node": ">=20"
51
+ "node": "^22.19.0 || >=24.0.0"
33
52
  },
34
53
  "peerDependencies": {
35
54
  "@deepseek-ai/cordis": ">=4.0.0",
36
- "@deepseek-ai/dsh-jobs": ">=0.1.0-rc.5",
37
- "@deepseek-ai/dsh-skill": ">=0.1.0-rc.5",
38
- "@deepseek-ai/dsh-tools": ">=0.1.0-rc.5"
55
+ "@deepseek-ai/dsh-agent": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
56
+ "@deepseek-ai/dsh-attachment": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
57
+ "@deepseek-ai/dsh-commands": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
58
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
59
+ "@deepseek-ai/dsh-jobs": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
60
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
61
+ "@deepseek-ai/dsh-settings": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
62
+ "@deepseek-ai/dsh-skill": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
63
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
64
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
65
+ "@deepseek-ai/dsh-user-approval": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
66
+ "@deepseek-ai/dsh-util-values": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
67
+ "@deepseek-ai/dsh-web": ">=0.1.1-rc.2 || >=0.1.2-alpha.1",
68
+ "@deepseek-ai/schemastery": ">=3.18.0"
69
+ },
70
+ "devDependencies": {
71
+ "@deepseek-ai/dsh-llm": "link:./.dsh-harness/packages/llm/llm",
72
+ "@deepseek-ai/dsh-session": "link:./.dsh-harness/packages/core/session",
73
+ "@deepseek-ai/dsh-tools": "link:./.dsh-harness/packages/core/tools",
74
+ "@deepseek-ai/dsh-web": "link:./.dsh-harness/packages/web/web",
75
+ "@deepseek-ai/schemastery": "link:./.dsh-harness/vendor/schemastery",
76
+ "@types/node": "^24.0.0",
77
+ "typescript": "^5.7.0"
39
78
  },
40
79
  "license": "MIT"
41
80
  }