dsh-browser-playwright-codex 0.7.1 → 0.7.4

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 CHANGED
@@ -1,3 +1,53 @@
1
+ ## 0.7.4 — 2026-10-23
2
+
3
+ 一句话摘要:**设置卡可以切换有头 / 无头** —— 运行时开关,不用改 YAML、不用重启 DSH;切换在**下一次操作**时生效(先导出登录态,再按新模式重开窗口)。默认仍是有头。
4
+
5
+ ### Added
6
+
7
+ - **「浏览器窗口」开关**:设置卡新增单选「显示浏览器窗口(有头)」/「后台运行(无头)」,选择存进 `~/.dsh/dsh-browser-playwright.state.json`(与页面识别方式同一套 runtime-state),重启后仍记住。改完在**下一次浏览器操作**时重开窗口生效——不会打断正在进行的调用;代价是标签页和未提交的表单会丢,登录状态会先导出、不会丢。
8
+ - **`POST /api/browser-playwright/window { headless }`**:校验布尔值,返回生效值与 `changed`;`GET /state` 同时返回 `headless` / `headlessUserChosen` / `headlessConfigured`,卡片据此显示「当前 / 你选的」。
9
+ - **界面明确写清无头的代价**:无头模式下「看得见、能随时接手」的前提不成立——验证码/二次验证只剩提示,没人能在窗口里替你点;只在无人值守且接受这个代价时用。
10
+
11
+ ### Changed
12
+
13
+ - **provider 按「期望模式 vs 实际启动模式」懒重开**:`desiredHeadless()` 读运行时选择(无选择时回退 `launch.headless`);每次 acquire 前比较 `launchedHeadless`,不一致就先 `dispose()`(导出登录态、关窗)再按新模式启动。已经在用的 owner 切换模式也会被重建,而不是「下次重启才生效」。
14
+ - **`launch.headless` 仍是配置默认**:配置里写什么就是没在卡片上选过时的默认值;两处启动路径(持久 context / legacy browser)都改为读解析后的值。
15
+
16
+ ### Fixed
17
+
18
+ - **挑战页提示语随窗口模式变化**:无头模式下原提示仍说「浏览器窗口就在你桌面上,等人去点」——那个窗口不存在。现在无头时改为明确说「HEADLESS、没有窗口可完成验证;要么把窗口模式切回『显示浏览器窗口』让人处理,要么换一种方式」,并保留「不要盲目重试」的纪律。有头模式的措辞不变。
19
+
20
+ ### Tests
21
+
22
+ - 运行时状态:窗口模式持久化、configured 默认只在没有用户选择时生效(2 条)。
23
+ - provider:`desiredHeadless()` 用户选择优先于 `launch.headless`(1 条,无浏览器启动)。
24
+ - 真机验收:有头启动 → 切无头(重开、会话句柄替换)→ 切回有头,状态文件落盘。
25
+
26
+ ## 0.7.3 — 2026-10-20
27
+
28
+ 一句话摘要:**设置卡新增「缓存与磁盘」** —— 把浏览器 profile 的纯缓存、%TEMP% 里的 dsh-* 残留、更新器残留、请求图缓存做成可预览、可逐项清理的面板;顺手修掉三处测试把临时目录泄进 %TEMP% 的根因。运行时代码零改动。
29
+
30
+ ### Added
31
+
32
+ - **设置卡「缓存与磁盘」区块**:四类目标(浏览器缓存 / 临时残留 / 更新器残留 / 图片变体缓存)显示各自字节数与文件数,勾选后先「预演」看能释放多少,再「确认清理」;结果逐项报告释放量与跳过原因(占用中 / 权限 / 其它版本)。
33
+ - **`src/cache-clean.ts`**:纯 `node:fs` 模块,两个入口 `scan()` / `clean()`。安全边界写在代码里而不是提示词里:目标是**枚举 id**(`browser-cache` / `temp-residue` / `updater-residue` / `attachment-cache`),路径一律由 DSH_HOME 与已知系统位置推导,请求体里的字符串永远不拼进路径;浏览器 profile 只删 Chromium 的纯缓存目录(Cache / Code Cache / GPUCache / ShaderCache / GrShaderCache / DawnCache / DawnGraphiteCache / ScriptCache / `*_crx_cache`),Cookies、Local Storage、Service Worker、IndexedDB、Network、Preferences 一律不碰。
34
+ - **`GET /api/browser-playwright/disk/stat`** 与 **`POST /api/browser-playwright/disk/clean`**:沿用已有的 `x-dsh-plugin: browser-toggle` 防伪头(GET 与 POST 都验)、JSON 校验、枚举校验;失败逐条返回 `{ path, reason }`,不折成一个笼统错误。`~/.dsh/cache/attachments` 可清,`~/.dsh/attachments/v1/objects`(会话引用的原图)永不在清理范围内,只在统计里报大小。
35
+ - **`tests/cache-clean.test.ts`**:9 条 fixture 用例——白名单内删干净、Cookies 与 Local Storage 一个不动、`dryRun` 不落盘、新临时目录预演时标 `in-use`、只清当前版本的更新器文件、附件缓存清空而会话原图保留、目标 id 拒绝任意路径、路径推导、字节格式化。
36
+
37
+ ### Fixed
38
+
39
+ - **三处测试泄漏 %TEMP%**:`runtime-state.test.ts`、`provider.test.ts`、`login-state.test.ts` 用 `mkdtempSync` 创建的 `dsh-browser-state-*` / `dsh-provider-state-*` / `dsh-login-state-*` 目录从来没删,跑一次测试就在临时目录里留一堆空壳;现在各自登记并在 `after()` 里删掉。
40
+ - **Windows 只读文件**:清理时先尝试普通删除,失败再 `chmod` 去掉只读位重试;**删成功之后才计入释放量**,不再出现"报了删除但一个没删"的假账。
41
+
42
+ ## 0.7.2 — 2026-10-19
43
+
44
+ 一句话摘要:**设置卡推倒重做** —— 用户要求按 DSH 设置页规范重新设计:首屏只留「主开关 + 三个分区摘要」,所有长说明收进折叠。0.7.1 因卡在 npm staged 状态作废(版本号让给这一版)。
45
+
46
+ ### Changed
47
+
48
+ - **设置卡重做**:主开关一行;「页面读取方式」两个紧凑单选项 + 一个「两种模式到底差在哪」折叠;「工具权限」三个开关(任意 JS / 原始 CDP / 诊断工具)+ 一个「这些开关具体放行什么」折叠,标题行直接显示「N / 3 已开启」;「已登录的站点」默认收起,标题行只给「N 个域 · M 个 cookie」,展开后仍按公司分组折叠。名单每行只保留域名 + 说明 + cookie 数,逐个站点的四个 OWASP 信号不再塞在主视图里。
49
+ - 卡片文案统一为"人话",不再出现 evaluate / cdp / 引擎 / legacy / aria 这些词。
50
+
1
51
  # Changelog
2
52
 
3
53
  Bilingual by intent: the version headings and the summary of each release are given in both
package/README.md CHANGED
@@ -281,6 +281,16 @@ Both are honest trades, not free wins: a diff is smaller but requires the model
281
281
  tree, and an interactive-only snapshot hides the prose the model may want to read. Measure before
282
282
  turning them on in a deployment that shares prompts across many users.
283
283
 
284
+ ### Window mode (headful / headless)
285
+
286
+ The Settings card has a **浏览器窗口** choice: show the window (headful, the default) or run it in the background (headless). The pick is stored in `~/.dsh/dsh-browser-playwright.state.json` and survives restarts; `launch.headless` stays the configured default for anyone who never picks.
287
+
288
+ Switching takes effect on the **next browser operation**: the provider compares the mode it launched with against the desired one and relaunches (login state is exported first, so the login survives; tabs and unsaved page state do not). Headless is honest about its cost — with no visible window, the “watch it and take over” guarantee for sign-ins and CAPTCHAs no longer holds, so use it only for unattended work where you accept that.
289
+
290
+ ### Cache and disk
291
+
292
+ The Settings card also carries a **缓存与磁盘** section: Chromium's pure caches inside the plugin's profile, `dsh-*` residue in `%TEMP%`, updater installers of the installed version, and the request-image cache are listed with their sizes; you preview first (“先预演”) and only then confirm. Cookies, Local Storage, Service Worker, IndexedDB, Network, Preferences and `~/.dsh/attachments` are never in scope — the target list is a closed enum in `src/cache-clean.ts`, and no path is ever taken from the request.
293
+
284
294
  ### Prefer a switch to editing config?
285
295
 
286
296
  The Settings card ships **inside this package** (it moved out of the companion package `dsh-browser-toggle`, which is retired) and puts
@@ -496,3 +506,5 @@ MIT
496
506
  - **新增两只诊断工具**:`browser_console_messages`(控制台报错)与 `browser_network_requests`(网络请求),用于"点了没反应"时快速定位原因。
497
507
  - **接口与实现同步**:`BrowserSession` 补齐 9 个实现里已有的方法;`errors.ts` 补齐 12 个实际在用的错误码;`BrowserSnapshot` 补 `dialogNote` / `landingNote`;`TabInfo` 补 `active`;`runtime-state` / `cdp-policy` 新增类型声明。
498
508
  - **依赖范围修正**:`devDependencies` 与 `peerDependencies` 对齐,可在当前 DSH 版本下安装。
509
+
510
+ **有问题直接去[讨论区](https://github.com/miqian-nomad/dsh-browser-playwright-codex/discussions)开个帖说,用得顺手也可以来吭一声——就我一个人维护,回得慢,但每条都看。**
package/SECURITY.md CHANGED
@@ -11,6 +11,10 @@ this package ships switched off** — it is not registered at all until a deploy
11
11
 
12
12
  ---
13
13
 
14
+ ## 0. Window mode
15
+
16
+ Headful is the default and the safety story depends on it: the window is visible, you can watch it and take over for sign-ins and CAPTCHAs. **Headless removes that**: the agent still parks native dialogs and reports challenge pages as notes, but nobody can act in a window that does not exist. Treat headless as an unattended mode, not as a quieter headful one.
17
+
14
18
  ## 1. What it can and cannot do
15
19
 
16
20
  **It drives your browser, as you.** The provider opens a visible, profile-backed Chromium-family
@@ -28,7 +28,8 @@ export async function ensureContext(provider) {
28
28
  provider.context = undefined;
29
29
  }
30
30
  fs.mkdirSync(provider.profileDir, { recursive: true });
31
- const { executablePath, channel, headless } = provider.config.launch;
31
+ const { executablePath, channel } = provider.config.launch;
32
+ const headless = provider.desiredHeadless();
32
33
  const headful = !headless;
33
34
  const attempts = [];
34
35
  const baseOptions = {
@@ -56,6 +57,7 @@ export async function ensureContext(provider) {
56
57
  const start = async (extra) => {
57
58
  try {
58
59
  provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
60
+ provider.launchedHeadless = headless;
59
61
  }
60
62
  catch (error) {
61
63
  // A stale Chrome singleton lock from a dirty shutdown breaks a
@@ -64,6 +66,8 @@ export async function ensureContext(provider) {
64
66
  if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
65
67
  await provider.clearChromeLocks();
66
68
  provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
69
+ provider.launchedHeadless = headless;
70
+ provider.launchedHeadless = headless;
67
71
  }
68
72
  else {
69
73
  throw error;
@@ -147,11 +151,13 @@ export async function clearChromeLocks(provider) {
147
151
  export async function ensureBrowser(provider) {
148
152
  if (provider.browser !== undefined)
149
153
  return provider.browser;
150
- const { executablePath, channel, headless } = provider.config.launch;
154
+ const { executablePath, channel } = provider.config.launch;
155
+ const headless = provider.desiredHeadless();
151
156
  const attempts = [];
152
157
  if (executablePath !== undefined) {
153
158
  try {
154
159
  provider.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
160
+ provider.launchedHeadless = headless;
155
161
  return provider.browser;
156
162
  }
157
163
  catch (error) {
@@ -164,6 +170,7 @@ export async function ensureBrowser(provider) {
164
170
  for (const candidate of channels) {
165
171
  try {
166
172
  provider.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
173
+ provider.launchedHeadless = headless;
167
174
  return provider.browser;
168
175
  }
169
176
  catch (error) {
@@ -0,0 +1,90 @@
1
+ /** The four buckets the card can report and clean. */
2
+ export type TargetId = 'browser-cache' | 'temp-residue' | 'updater-residue' | 'attachment-cache';
3
+ /** Display order and labels for every target. */
4
+ export declare const TARGETS: readonly {
5
+ id: TargetId;
6
+ label: string;
7
+ }[];
8
+ /** Is this value one of the enum ids? Unknown strings are rejected, never joined into a path. */
9
+ export declare function isTargetId(value: unknown): value is TargetId;
10
+ /** Absolute locations this module may inspect. */
11
+ export interface CachePaths {
12
+ /** DSH home (`~/.dsh` unless DSH_HOME overrides it). */
13
+ dshHome: string;
14
+ /** Persistent Chromium profile directory of the browser capability. */
15
+ browserProfileDir: string;
16
+ /** OS temp directory. */
17
+ tmpDir: string;
18
+ /** Electron updater residue directory for the DSH desktop app. */
19
+ updaterDir: string;
20
+ /** Version currently installed — used to refuse other versions' installers. */
21
+ installedVersion: string;
22
+ }
23
+ /** Resolve the default paths from the environment (no path ever comes from the request). */
24
+ export declare function resolvePaths(env: NodeJS.ProcessEnv | undefined, version: string, platform?: NodeJS.Platform): CachePaths;
25
+ /** One row of the disk panel. */
26
+ export interface DiskItem {
27
+ id: TargetId;
28
+ label: string;
29
+ bytes: number;
30
+ files: number;
31
+ /** Optional human note (e.g. "浏览器正在运行,整段跳过"). */
32
+ note?: string;
33
+ }
34
+ /** What `scan()` reports. */
35
+ export interface ScanReport {
36
+ items: DiskItem[];
37
+ totalBytes: number;
38
+ attachments: {
39
+ objectsBytes: number;
40
+ requestImagesBytes: number;
41
+ };
42
+ skipped: {
43
+ id: TargetId;
44
+ reason: string;
45
+ }[];
46
+ }
47
+ /** One skipped entry inside a target. */
48
+ export interface SkippedEntry {
49
+ path: string;
50
+ reason: 'in-use' | 'permission' | 'other-version' | 'unknown-version' | 'missing';
51
+ }
52
+ /** Per-target outcome of a clean pass. */
53
+ export interface CleanTargetReport {
54
+ id: TargetId;
55
+ freedBytes: number;
56
+ skipped: SkippedEntry[];
57
+ }
58
+ /** What `clean()` reports. */
59
+ export interface CleanReport {
60
+ dryRun: boolean;
61
+ freedBytes: number;
62
+ perTarget: CleanTargetReport[];
63
+ }
64
+ /** Options for one clean pass. */
65
+ export interface CleanOptions extends CachePaths {
66
+ targets: readonly TargetId[];
67
+ dryRun: boolean;
68
+ /** True when the capability is enabled — reported, not used to skip silently. */
69
+ browserActive?: boolean;
70
+ /** Clock override for tests. */
71
+ now?: number;
72
+ }
73
+ /**
74
+ * Inspect every bucket without touching anything.
75
+ * @param paths - resolved locations
76
+ * @returns per-target sizes plus the attachment totals kept apart from the cache
77
+ */
78
+ export declare function scan(paths: CachePaths): Promise<ScanReport>;
79
+ /**
80
+ * Clean the requested buckets. With `dryRun` the same walk happens but nothing
81
+ * is renamed or removed, so the preview shows real numbers.
82
+ *
83
+ * @param options - resolved paths, enum targets and dry-run flag
84
+ * @returns per-target freed bytes and every skipped entry with a reason
85
+ */
86
+ export declare function clean(options: CleanOptions): Promise<CleanReport>;
87
+ /** Human-readable size for the card and the CLI. */
88
+ export declare function formatBytes(bytes: number): string;
89
+ /** Basename helper re-exported for the route's skipped-entry display. */
90
+ export declare function displayName(path: string): string;
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Cache-residue scanning and cleaning for the Settings card.
3
+ *
4
+ * Design rules (these are the safety boundary, not implementation details):
5
+ * - **Targets are enum ids, never paths.** The caller can only ask for
6
+ * `browser-cache` / `temp-residue` / `updater-residue` / `attachment-cache`;
7
+ * every path below is derived here from DSH_HOME and known OS locations, so a
8
+ * request can never point deletion at an arbitrary directory.
9
+ * - **Whitelist only.** Inside the browser profile only Chromium's pure caches
10
+ * are touched; Cookies, Local Storage, Service Worker, IndexedDB, Network,
11
+ * Preferences and Login Data are never opened.
12
+ * - **Rename before delete.** A directory that is in use fails the rename on
13
+ * Windows; that is treated as "in use" and reported instead of retried.
14
+ * - **Count after success.** Bytes are measured before removal but only added to
15
+ * `freedBytes` once the entry is confirmed gone — the earlier disk-cleanup rule
16
+ * "count += size before deleting and swallow errors" produced fake accounting.
17
+ * - Pure `node:fs` — no dependencies, so it is testable without a DSH host.
18
+ *
19
+ * @module dsh-browser-playwright-codex/cache-clean
20
+ */
21
+ import { chmod, lstat, readdir, rename, rm } from 'node:fs/promises';
22
+ import { homedir, tmpdir } from 'node:os';
23
+ import { basename, join } from 'node:path';
24
+ /** Display order and labels for every target. */
25
+ export const TARGETS = [
26
+ { id: 'browser-cache', label: '浏览器缓存' },
27
+ { id: 'temp-residue', label: '临时残留' },
28
+ { id: 'updater-residue', label: '更新器残留' },
29
+ { id: 'attachment-cache', label: '图片变体缓存' },
30
+ ];
31
+ /** Is this value one of the enum ids? Unknown strings are rejected, never joined into a path. */
32
+ export function isTargetId(value) {
33
+ return typeof value === 'string' && TARGETS.some((target) => target.id === value);
34
+ }
35
+ /** Resolve the default paths from the environment (no path ever comes from the request). */
36
+ export function resolvePaths(env = process.env, version, platform = process.platform) {
37
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
38
+ const profileOverride = env.DSH_BROWSER_PROFILE_DIR;
39
+ const localAppData = env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local');
40
+ return {
41
+ dshHome: home,
42
+ browserProfileDir: profileOverride !== undefined && profileOverride !== ''
43
+ ? profileOverride
44
+ : join(home, 'browser-profiles', 'playwright'),
45
+ tmpDir: tmpdir(),
46
+ updaterDir: join(localAppData, '@deepseek-aidsh-desktop-updater'),
47
+ installedVersion: version,
48
+ };
49
+ }
50
+ /** Chromium's pure caches. Everything else in the profile is off limits. */
51
+ const BROWSER_CACHE_DIRS = [
52
+ 'Cache',
53
+ 'Code Cache',
54
+ 'GPUCache',
55
+ 'ShaderCache',
56
+ 'GrShaderCache',
57
+ 'DawnCache',
58
+ 'DawnGraphiteCache',
59
+ 'ScriptCache',
60
+ ];
61
+ /** Temp entries this plugin and the harness leave behind. */
62
+ const TEMP_PREFIXES = [
63
+ 'dsh-browser-state-',
64
+ 'dsh-provider-state-',
65
+ 'dsh-subprocess-',
66
+ 'dsh-spill-',
67
+ 'dsh-acl-skill-',
68
+ 'dsh-login-state-',
69
+ ];
70
+ /** A temp entry younger than this is assumed to belong to a running task. */
71
+ const TEMP_MIN_AGE_MS = 30 * 60 * 1000;
72
+ /** Measure a file or directory tree. Missing paths measure as zero. */
73
+ async function measure(path) {
74
+ const st = await lstat(path).catch(() => undefined);
75
+ if (st === undefined)
76
+ return { bytes: 0, files: 0 };
77
+ if (!st.isDirectory())
78
+ return { bytes: st.size, files: 1 };
79
+ let bytes = 0;
80
+ let files = 0;
81
+ const stack = [path];
82
+ while (stack.length > 0) {
83
+ const dir = stack.pop();
84
+ if (dir === undefined)
85
+ break;
86
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => undefined);
87
+ if (entries === undefined)
88
+ continue;
89
+ for (const entry of entries) {
90
+ const child = join(dir, entry.name);
91
+ if (entry.isDirectory()) {
92
+ stack.push(child);
93
+ continue;
94
+ }
95
+ const size = await lstat(child).catch(() => undefined);
96
+ if (size !== undefined) {
97
+ bytes += size.size;
98
+ files += 1;
99
+ }
100
+ }
101
+ }
102
+ return { bytes, files };
103
+ }
104
+ /** Windows refuses to delete read-only files even with `force`; clear the attribute first. */
105
+ async function makeWritable(path) {
106
+ const st = await lstat(path).catch(() => undefined);
107
+ if (st === undefined)
108
+ return;
109
+ if (st.isDirectory()) {
110
+ const entries = await readdir(path).catch(() => []);
111
+ for (const name of entries)
112
+ await makeWritable(join(path, name));
113
+ await chmod(path, 0o777).catch(() => undefined);
114
+ return;
115
+ }
116
+ await chmod(path, 0o666).catch(() => undefined);
117
+ }
118
+ /** Remove an entry, retrying once after clearing read-only attributes. */
119
+ async function removeEntry(path) {
120
+ try {
121
+ await rm(path, { recursive: true, force: true, maxRetries: 2 });
122
+ }
123
+ catch {
124
+ await makeWritable(path);
125
+ await rm(path, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
126
+ }
127
+ return (await lstat(path).catch(() => undefined)) === undefined;
128
+ }
129
+ /**
130
+ * Detach an entry by renaming it first, then delete the renamed copy.
131
+ * A rename failure means something holds it open (Windows locks the directory),
132
+ * which is exactly the "in use" signal we want instead of guessing.
133
+ */
134
+ async function detachAndRemove(path) {
135
+ const detached = `${path}.dsh-delete-${process.pid}-${Date.now()}`;
136
+ try {
137
+ await rename(path, detached);
138
+ }
139
+ catch {
140
+ return { ok: false, reason: 'in-use' };
141
+ }
142
+ const removed = await removeEntry(detached);
143
+ return removed ? { ok: true } : { ok: false, reason: 'permission' };
144
+ }
145
+ /** Browser-profile cache directories that currently exist. */
146
+ async function browserCacheDirs(profileDir) {
147
+ const defaultDir = join(profileDir, 'Default');
148
+ const entries = await readdir(defaultDir, { withFileTypes: true }).catch(() => undefined);
149
+ if (entries === undefined)
150
+ return [];
151
+ return entries
152
+ .filter((entry) => entry.isDirectory())
153
+ .map((entry) => entry.name)
154
+ .filter((name) => BROWSER_CACHE_DIRS.includes(name) || /_crx_cache$/.test(name))
155
+ .map((name) => join(defaultDir, name));
156
+ }
157
+ /** Temp entries matching the known residue prefixes. */
158
+ async function tempResidueDirs(tmpDir) {
159
+ const entries = await readdir(tmpDir, { withFileTypes: true }).catch(() => undefined);
160
+ if (entries === undefined)
161
+ return [];
162
+ return entries
163
+ .filter((entry) => entry.isDirectory())
164
+ .filter((entry) => TEMP_PREFIXES.some((prefix) => entry.name.startsWith(prefix)))
165
+ .map((entry) => join(tmpDir, entry.name));
166
+ }
167
+ /** Updater files that belong to the currently installed version. */
168
+ async function updaterResidue(updaterDir, installedVersion) {
169
+ const files = [];
170
+ const skipped = [];
171
+ const entries = await readdir(updaterDir, { withFileTypes: true }).catch(() => undefined);
172
+ if (entries === undefined)
173
+ return { files, skipped };
174
+ const versionFrom = (name) => {
175
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)/.exec(name);
176
+ return match?.[1];
177
+ };
178
+ for (const entry of entries) {
179
+ const path = join(updaterDir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ // Only `pending/` is updater residue; the directory itself stays.
182
+ if (entry.name !== 'pending')
183
+ continue;
184
+ const pending = await readdir(path, { withFileTypes: true }).catch(() => []);
185
+ for (const child of pending) {
186
+ if (!child.isFile())
187
+ continue;
188
+ const childPath = join(path, child.name);
189
+ const version = versionFrom(child.name);
190
+ if (version === undefined)
191
+ skipped.push({ path: childPath, reason: 'unknown-version' });
192
+ else if (version !== installedVersion)
193
+ skipped.push({ path: childPath, reason: 'other-version' });
194
+ else
195
+ files.push(childPath);
196
+ }
197
+ continue;
198
+ }
199
+ if (!entry.isFile())
200
+ continue;
201
+ const isUpdaterFile = /^installer.*\.exe$/i.test(entry.name) || /\.blockmap$/i.test(entry.name);
202
+ if (!isUpdaterFile)
203
+ continue;
204
+ const version = versionFrom(entry.name);
205
+ if (version === undefined) {
206
+ skipped.push({ path, reason: 'unknown-version' });
207
+ continue;
208
+ }
209
+ if (version !== installedVersion) {
210
+ skipped.push({ path, reason: 'other-version' });
211
+ continue;
212
+ }
213
+ files.push(path);
214
+ }
215
+ return { files, skipped };
216
+ }
217
+ /** Everything under the request-image cache (kept apart from session attachments). */
218
+ async function attachmentCacheFiles(dshHome) {
219
+ const root = join(dshHome, 'cache', 'attachments');
220
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => undefined);
221
+ if (entries === undefined)
222
+ return [];
223
+ return entries.map((entry) => join(root, entry.name));
224
+ }
225
+ /**
226
+ * Inspect every bucket without touching anything.
227
+ * @param paths - resolved locations
228
+ * @returns per-target sizes plus the attachment totals kept apart from the cache
229
+ */
230
+ export async function scan(paths) {
231
+ const items = [];
232
+ const skipped = [];
233
+ const browserDirs = await browserCacheDirs(paths.browserProfileDir);
234
+ const browser = { bytes: 0, files: 0 };
235
+ for (const dir of browserDirs) {
236
+ const own = await measure(dir);
237
+ browser.bytes += own.bytes;
238
+ browser.files += own.files;
239
+ }
240
+ items.push({ id: 'browser-cache', label: '浏览器缓存', ...browser });
241
+ const temp = { bytes: 0, files: 0 };
242
+ for (const dir of await tempResidueDirs(paths.tmpDir)) {
243
+ const own = await measure(dir);
244
+ temp.bytes += own.bytes;
245
+ temp.files += own.files;
246
+ }
247
+ items.push({ id: 'temp-residue', label: '临时残留', ...temp });
248
+ const updater = await updaterResidue(paths.updaterDir, paths.installedVersion);
249
+ const updaterSize = { bytes: 0, files: 0 };
250
+ for (const file of updater.files) {
251
+ const own = await measure(file);
252
+ updaterSize.bytes += own.bytes;
253
+ updaterSize.files += own.files;
254
+ }
255
+ items.push({
256
+ id: 'updater-residue',
257
+ label: '更新器残留',
258
+ ...updaterSize,
259
+ ...(updater.skipped.length > 0 ? { note: `${String(updater.skipped.length)} 项属于其它版本,跳过` } : {}),
260
+ });
261
+ const attachments = await measure(join(paths.dshHome, 'cache', 'attachments'));
262
+ items.push({
263
+ id: 'attachment-cache',
264
+ label: '图片变体缓存',
265
+ ...attachments,
266
+ note: '发给模型前的缩放副本,可重建',
267
+ });
268
+ const objects = await measure(join(paths.dshHome, 'attachments', 'v1', 'objects'));
269
+ const requestImages = attachments;
270
+ return {
271
+ items,
272
+ totalBytes: items.reduce((sum, item) => sum + item.bytes, 0),
273
+ attachments: { objectsBytes: objects.bytes, requestImagesBytes: requestImages.bytes },
274
+ skipped,
275
+ };
276
+ }
277
+ /**
278
+ * Clean the requested buckets. With `dryRun` the same walk happens but nothing
279
+ * is renamed or removed, so the preview shows real numbers.
280
+ *
281
+ * @param options - resolved paths, enum targets and dry-run flag
282
+ * @returns per-target freed bytes and every skipped entry with a reason
283
+ */
284
+ export async function clean(options) {
285
+ const now = options.now ?? Date.now();
286
+ const perTarget = [];
287
+ let freedBytes = 0;
288
+ for (const id of options.targets) {
289
+ const report = { id, freedBytes: 0, skipped: [] };
290
+ if (id === 'browser-cache') {
291
+ for (const dir of await browserCacheDirs(options.browserProfileDir)) {
292
+ const size = await measure(dir);
293
+ if (options.dryRun) {
294
+ report.freedBytes += size.bytes;
295
+ continue;
296
+ }
297
+ const outcome = await detachAndRemove(dir);
298
+ if (outcome.ok)
299
+ report.freedBytes += size.bytes;
300
+ else
301
+ report.skipped.push({ path: dir, reason: outcome.reason ?? 'permission' });
302
+ }
303
+ }
304
+ if (id === 'temp-residue') {
305
+ for (const dir of await tempResidueDirs(options.tmpDir)) {
306
+ const stat = await lstat(dir).catch(() => undefined);
307
+ const age = stat === undefined ? 0 : now - stat.mtimeMs;
308
+ const size = await measure(dir);
309
+ if (stat !== undefined && age < TEMP_MIN_AGE_MS) {
310
+ // Too fresh to tell "left behind" from "this run is using it": prove it by
311
+ // renaming first; only a successful rename means the owner let go.
312
+ if (options.dryRun) {
313
+ report.skipped.push({ path: dir, reason: 'in-use' });
314
+ continue;
315
+ }
316
+ const outcome = await detachAndRemove(dir);
317
+ if (outcome.ok)
318
+ report.freedBytes += size.bytes;
319
+ else
320
+ report.skipped.push({ path: dir, reason: outcome.reason ?? 'in-use' });
321
+ continue;
322
+ }
323
+ if (options.dryRun) {
324
+ report.freedBytes += size.bytes;
325
+ continue;
326
+ }
327
+ const outcome = await detachAndRemove(dir);
328
+ if (outcome.ok)
329
+ report.freedBytes += size.bytes;
330
+ else
331
+ report.skipped.push({ path: dir, reason: outcome.reason ?? 'permission' });
332
+ }
333
+ }
334
+ if (id === 'updater-residue') {
335
+ const residue = await updaterResidue(options.updaterDir, options.installedVersion);
336
+ for (const skipped of residue.skipped)
337
+ report.skipped.push(skipped);
338
+ for (const file of residue.files) {
339
+ const size = await measure(file);
340
+ if (options.dryRun) {
341
+ report.freedBytes += size.bytes;
342
+ continue;
343
+ }
344
+ const removed = await removeEntry(file);
345
+ if (removed)
346
+ report.freedBytes += size.bytes;
347
+ else
348
+ report.skipped.push({ path: file, reason: 'permission' });
349
+ }
350
+ }
351
+ if (id === 'attachment-cache') {
352
+ for (const path of await attachmentCacheFiles(options.dshHome)) {
353
+ const size = await measure(path);
354
+ if (options.dryRun) {
355
+ report.freedBytes += size.bytes;
356
+ continue;
357
+ }
358
+ const removed = await removeEntry(path);
359
+ if (removed)
360
+ report.freedBytes += size.bytes;
361
+ else
362
+ report.skipped.push({ path, reason: 'permission' });
363
+ }
364
+ }
365
+ freedBytes += report.freedBytes;
366
+ perTarget.push(report);
367
+ }
368
+ return { dryRun: options.dryRun, freedBytes, perTarget };
369
+ }
370
+ /** Human-readable size for the card and the CLI. */
371
+ export function formatBytes(bytes) {
372
+ if (bytes < 1024)
373
+ return String(bytes) + ' B';
374
+ const units = ['KB', 'MB', 'GB', 'TB'];
375
+ let value = bytes / 1024;
376
+ let unit = 0;
377
+ while (value >= 1024 && unit < units.length - 1) {
378
+ value /= 1024;
379
+ unit += 1;
380
+ }
381
+ return value.toFixed(value >= 100 ? 0 : 1) + ' ' + (units[unit] ?? 'TB');
382
+ }
383
+ /** Basename helper re-exported for the route's skipped-entry display. */
384
+ export function displayName(path) {
385
+ return basename(path);
386
+ }
@@ -21,6 +21,14 @@ export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeM
21
21
  * challenge. The browser window is visible on the user's desktop: the agent
22
22
  * should pause and ask the user to complete it by hand, then re-snapshot.
23
23
  */
24
+ /**
25
+ * The hint appended when a challenge page is recognised. It has to know the
26
+ * window mode: headless has no window to complete a slider in, so telling the
27
+ * user to "finish it in the open window" would be false.
28
+ *
29
+ * @param headless - the mode the provider is running in
30
+ */
31
+ export declare function challengeNote(headless: boolean): string;
24
32
  export declare const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop \u2014 tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
25
33
  /**
26
34
  * Appended after an action that left the page byte-for-byte unchanged on a page
package/lib/challenge.js CHANGED
@@ -213,6 +213,22 @@ export function detectChallenge(snapshot) {
213
213
  * challenge. The browser window is visible on the user's desktop: the agent
214
214
  * should pause and ask the user to complete it by hand, then re-snapshot.
215
215
  */
216
+ /**
217
+ * The hint appended when a challenge page is recognised. It has to know the
218
+ * window mode: headless has no window to complete a slider in, so telling the
219
+ * user to "finish it in the open window" would be false.
220
+ *
221
+ * @param headless - the mode the provider is running in
222
+ */
223
+ export function challengeNote(headless) {
224
+ if (headless) {
225
+ return ('[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. ' +
226
+ 'The browser is running HEADLESS, so there is no window to finish it in — do not wait for a human to click anything here. ' +
227
+ 'Stop and tell the user: either switch the window mode to 显示浏览器窗口 in Settings → 浏览器 and let them complete the verification, ' +
228
+ 'or handle this site another way. Once a clearance cookie is obtained (in a headful window) it is saved to the profile and keeps working for the session.');
229
+ }
230
+ return CHALLENGE_NOTE;
231
+ }
216
232
  export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
217
233
  /**
218
234
  * Appended after an action that left the page byte-for-byte unchanged on a page