dsh-inner-browser-use 0.38.0 → 0.39.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/README.md CHANGED
@@ -21,7 +21,8 @@ live picture of that browser, and your own cursor keeps working on the picture.
21
21
  | `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
22
22
  | `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
23
23
  | `browser_media` | Lists the video and sound the page already fetched, with sizes, the one its own player shows first and marked `main`; `save: <row>`, `save: main` or part of the address keeps one. Ads and beacons stay out unless `all: true`; `watch: false` stops remembering the page. A stream is followed to its pieces and joined, so nothing has to be installed. |
24
- | `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
24
+ | `browser_login` | Keeps a sign-in by name: `save` stores this window's cookies and each site's local storage, `list` shows what is kept, `use` signs that account back in and reopens the page, `drop` forgets it. Names survive a restart and belong to the browser rather than to one chat, so two accounts on one site are two names. What sits on disk is encrypted, with the key in the system keychain. |
25
+ | `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, how many columns are showing it, how much memory it holds right now and when it would close on its own if left alone. |
25
26
 
26
27
  `browser_read` on the same page is the words only, in Markdown (measured: 4,520
27
28
  characters of an about page against 2,987 characters of snapshot — and the
package/README.zh.md CHANGED
@@ -20,7 +20,8 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
20
20
  | `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
21
21
  | `browser_shot` | 存一张 PNG 到 `~/.dsh/inner-browser-use/screenshots`,返回路径;`region: [x, y, w, h]` 只截一块(游戏、图表、看局部)。 |
22
22
  | `browser_media` | 列出页面已经请求过的视频和声音(带大小),页面自己播放器里那个排第一、标 `main`;`save: <行号>`、`save: main` 或地址片段存下来。广告和统计默认不出现(`all: true` 才看),`watch: false` 关掉这个页面的记录。流会顺着播放列表把片段取回拼好,不用装任何东西。 |
23
- | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
23
+ | `browser_login` | 按名字留住登录:`save` 存下当前窗口的 cookie 和各站的 localStorage,`list` 看有哪些,`use` 切回那个账号并重开页面,`drop` 忘掉。名字跟着浏览器走、不跟着一个会话走,重启也在;同一站点两个账号就存两个名字。磁盘上那一份是加密的,钥匙在系统钥匙串里。 |
24
+ | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题、有几个右栏在看它,以及它此刻占多少内存、没人用的话还有多久关闭。 |
24
25
 
25
26
  点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
26
27
 
@@ -165,10 +165,49 @@ picture comes back on the mutation that satisfies it. A page that changes 137 ms
165
165
  starts answers at 138 ms, not at the next 100 ms tick — the same reason `idle` (a mutation-free
166
166
  window) costs one round trip on an animation-heavy page instead of one per frame.
167
167
 
168
+ ## Sign-ins and reclaim
169
+
168
170
  Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
169
171
  which Chrome would otherwise drop on exit — are saved next to it and restored on the next
170
172
  launch, so a restart of the app does not sign the person out.
171
173
 
174
+ Three layers, each adding one thing the previous cannot do:
175
+
176
+ - **The profile** keeps everything Chrome writes: dated cookies, origin storage, service
177
+ workers. It survives a restart of the app and a restart of the machine.
178
+ - **The session jar** (`session-cookies.json`) covers the part Chrome throws away on exit.
179
+ Every cookie that comes back from `page.cookies()` without an expiry is written when the
180
+ browser closes, and put back on the next launch. A cookie the browser already holds is
181
+ never overwritten from the jar — a rotation happened in the live browser, and the jar is
182
+ the older truth.
183
+ - **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
184
+ back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
185
+ dropped. `list` is a directory read, so it costs nothing while the browser is down.
186
+ Restoring writes cookies first, then navigates to each stored origin to write its local
187
+ storage (the API is origin-bound), then reopens the page that was saved.
188
+
189
+ Everything written at rest is sealed with AES-256-GCM (`{v, alg, iv, tag, data}`, all
190
+ base64). The key comes from `DSH_INNER_BROWSER_VAULT_KEY` when a test sets one, otherwise
191
+ from the macOS keychain, otherwise from `vault.key` in the state root — 0600, and a fallback
192
+ rather than the intended home, because an unattended `security add-generic-password` can
193
+ block on a prompt. A tampered blob, a wrong key and a truncated file all come back empty;
194
+ nothing trusts what it cannot verify. A plaintext jar from a version before this one is
195
+ still read and re-sealed on the next write, so an upgrade does not sign anyone out.
196
+
197
+ The reclaim ladder is three separate timers, because they answer three different questions:
198
+
199
+ | Question | Timer | What happens |
200
+ | --- | --- | --- |
201
+ | Did the person close the column? | `REST_MS` = 60 s | The browser is put away a minute after the last pane lets go. |
202
+ | Has nobody touched it at all? | `IDLE_MS` = 15 min | The whole process is reclaimed, but never while a pane is still watching a frame. |
203
+ | Is this one tab stale? | `TAB_IDLE_MS` = 10 min, `MAX_TABS` = 6 | The tab is closed, not the browser; a returning chat gets a fresh page instead of a dead one (`expired`). |
204
+
205
+ `browser_state` reports both what the process costs right now (`ps` over its process tree,
206
+ which includes renderers, not just the parent) and `closesIn`, so the model can decide
207
+ between keeping the page and letting it go instead of guessing. A freeze
208
+ (`Page.setWebLifecycleState`) is deliberately not wired in: closing a stale tab is already
209
+ cheap, and a frozen page that misses a mutation would make the event-driven wait lie.
210
+
172
211
  A system-prompt section (`browser-in-this-chat`) tells the model to reach for these tools
173
212
  without asking first when someone wants something done on a site.
174
213
 
package/docs/ARTIFACTS.md CHANGED
@@ -22,12 +22,14 @@
22
22
  ~/.dsh-inner-browser-use/
23
23
  artifacts/<项目>/<会话>/<种类>/ 给人看的产出,只在这里(见上)
24
24
  chrome/ 浏览器自己的数据:登录态、Cookie、缓存
25
+ logins/ 按名字留住的登录,一个名字一个文件,加密
26
+ vault.key 钥匙串拿不到钥匙时的替代文件,只有本人可读
25
27
  flash/ Flash 播放器运行时,第一次用 flash:true 才装
26
28
  browser.log 最近一次浏览器启动和页面记录
27
29
  asks.log 人问过什么、答了什么
28
30
  tabs.json / mirror.json / flash.json / watcher.json 上次开着哪些页
29
31
  ```
30
32
 
31
- `chrome/` 是浏览器进程自己在读写,不要手动改;要清登录态,用 `browser_open` 的档位或插件自己的入口,别删目录里的某个文件。上面这些都在一个根目录下,卸载插件前想留产出就拷 `artifacts/`。
33
+ `chrome/session-cookies.json` 是 Chrome 退出时会丢的会话 cookie,加密后存在 profile 旁边;`logins/` 与 `vault.key` 由 `browser_login` 和启动时自动保存的登录备份共用,不要手动改或删,要清就用 `browser_login({action:"drop"})`。`chrome/` 其余部分是浏览器进程自己在读写,不要手动改;要清登录态,用 `browser_open` 的档位或插件自己的入口,别删目录里的某个文件。上面这些都在一个根目录下,卸载插件前想留产出就拷 `artifacts/`。
32
34
 
33
35
  测试里用 `DSH_INNER_BROWSER_ARTIFACTS` 指向临时目录,不写真实 home。
package/docs/BENCH.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 登录能留、能切、关得掉(0.39.0)
2
+
3
+ 本地页签在加载时写一个会话 cookie 和一个 `localStorage` 令牌,然后用工具层实际走一遍:保存 → 清空浏览器里的一切 → 切回。磁盘上那一份同时用字符串搜一遍。
4
+
5
+ | 项 | 结果 |
6
+ | --- | --- |
7
+ | `browser_login {action:"save"}` | 2 ms,8 个 cookie |
8
+ | `browser_login {action:"use"}`(清空后切回、重开页面) | 105 ms |
9
+ | 切回后 cookie 与 `localStorage` | 都在(`signedIn: true`) |
10
+ | 密文里能否搜到 cookie 值 / 令牌 | 搜不到(`sealed: true`) |
11
+ | 预算 | `login-vault: 1600 ms`,19 项全过 |
12
+
13
+ `browser_state` 另外报两行:当前进程树占多少内存(含渲染进程),以及没人用的话还有多久关闭——两者都来自 `ps` 一次采样和已有的空闲计时器,不额外起进程。关闭策略没变:右栏关掉约 1 分钟、没人碰约 15 分钟回收整个浏览器,页签 10 分钟不动、最多 6 个。
14
+
1
15
  ## 等到变化,而不是去问(0.38.0)
2
16
 
3
17
  `browser_wait` 以前在宿主侧每 100 毫秒读一次页面判断条件,命中与否都被凑到下一个整拍上。
package/index.js CHANGED
@@ -941,6 +941,8 @@ export function apply(ctx) {
941
941
  '不要用它:读公开文章、单个链接正文、文档、搜索结果摘要、GitHub 仓库或 PR 页面——这些能用 `web_fetch` / envoy 读就不用浏览器;一打开就是一个后台 Chrome 进程加一个页签,会话多了很占内存。',
942
942
  '也不要用它做连通性、可达性、服务是否起来的检查:那是一个 HTTP 请求的事,`web_fetch` 或 `curl` 就够,开浏览器只会多一个进程和一栏画面。只有「这一步必须在真实浏览器里点/填/登录」才轮到它。',
943
943
  '人点名之后不问、不另起外部 Chrome;画面留在本会话右栏,做完保留,不主动关。',
944
+ '登录态属于浏览器,不属于某个会话:`browser_login` 的 `save` 把当前登录按名字留存(`list` 看、`use` 切账号、`drop` 忘掉),换会话、重启都还在;同一站点要多个账号就存成不同名字。留存的登录凭据在磁盘上是加密的,密钥在系统钥匙串里。人让你登录时先 `browser_login` 的 `list` 看一眼有没有可复用的,再决定要不要重新登。',
945
+ '浏览器不用时自己让位:右栏关掉约一分钟后、没人碰约一刻钟后回收整个进程,页签十分钟不动、最多六个;`browser_state` 会给出它现在占多少内存和还有多久关闭。人还在看的时候不关。',
944
946
  '先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
945
947
  '页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
946
948
  '要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -18,6 +18,7 @@
18
18
  "read.js",
19
19
  "review.js",
20
20
  "media.js",
21
+ "vault.js",
21
22
  "client.js",
22
23
  "locale/*.json",
23
24
  "cordis.patch.yml",
@@ -49,7 +50,7 @@
49
50
  "dsh": ">=0.2.0"
50
51
  },
51
52
  "scripts": {
52
- "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs",
53
+ "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
53
54
  "doctor": "node scripts/doctor.mjs",
54
55
  "breadth": "node scripts/breadth.mjs",
55
56
  "live": "node scripts/live.mjs",
package/sidecar.js CHANGED
@@ -6,11 +6,12 @@
6
6
  * shows it as a screencast, so the tab is a viewer, not the browser.
7
7
  */
8
8
  import { execFileSync, spawn, spawnSync } from 'node:child_process'
9
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
10
10
  import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { readyFlash } from './runtime/flash-setup.mjs'
14
+ import { readSealed, tightenDir, writeSealed } from './vault.js'
14
15
  import { isMedia, kindOf } from './media.js?m=1'
15
16
 
16
17
  /**
@@ -113,6 +114,44 @@ const TAB_IDLE_MS = 10 * 60_000
113
114
  /** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
114
115
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
115
116
 
117
+ /** @param {number} root - a process id. @returns {number} bytes of RSS the process tree holds. */
118
+ function treeBytes(root) {
119
+ if (!Number.isFinite(root) || root <= 0) return 0
120
+ try {
121
+ const rows = execFileSync('ps', ['-Ao', 'pid=,ppid=,rss='], { encoding: 'utf8', timeout: 3000 })
122
+ .trim()
123
+ .split('\n')
124
+ .map((line) => line.trim().split(/\s+/u))
125
+ .filter((cols) => cols.length >= 3)
126
+ /** @type {Map<number, number[]>} */
127
+ const byParent = new Map()
128
+ /** @type {Map<number, number>} */
129
+ const size = new Map()
130
+ for (const [pid, ppid, rss] of rows) {
131
+ const parent = Number(ppid)
132
+ const kids = byParent.get(parent) ?? []
133
+ kids.push(Number(pid))
134
+ byParent.set(parent, kids)
135
+ size.set(Number(pid), Number(rss) * 1024)
136
+ }
137
+ let total = size.get(root) ?? 0
138
+ const queue = [root]
139
+ const seen = new Set()
140
+ while (queue.length > 0) {
141
+ const pid = queue.shift()
142
+ if (seen.has(pid)) continue
143
+ seen.add(pid)
144
+ for (const kid of byParent.get(pid) ?? []) {
145
+ total += size.get(kid) ?? 0
146
+ queue.push(kid)
147
+ }
148
+ }
149
+ return total
150
+ } catch {
151
+ return 0
152
+ }
153
+ }
154
+
116
155
  /** @param {string} path - candidate. @returns {string | undefined} the one that exists. */
117
156
  function firstBinary() {
118
157
  return CHROME_BINARIES.find((path) => existsSync(path))
@@ -348,9 +387,19 @@ export function snapshotLogin(dir) {
348
387
  const files = COOKIE_FILES.map((name) => ({ name, from: join(dir, name) })).filter((row) => existsSync(row.from))
349
388
  if (files.length === 0) return 0
350
389
  const to = loginBackupDir(dir)
351
- mkdirSync(to, { recursive: true })
390
+ /* A copy of a cookie store is a copy of every sign-in in it: nobody else gets
391
+ to read the folder or the files, even on a shared machine. */
392
+ tightenDir(to)
352
393
  const stamp = String(Date.now())
353
- for (const row of files) copyFileSync(row.from, join(to, `${stamp}-${row.name.replace(/\//g, '_')}`))
394
+ for (const row of files) {
395
+ const target = join(to, `${stamp}-${row.name.replace(/\//g, '_')}`)
396
+ copyFileSync(row.from, target)
397
+ try {
398
+ chmodSync(target, 0o600)
399
+ } catch {
400
+ /* best effort */
401
+ }
402
+ }
354
403
  const mine = readdirSync(to).filter((name) => name.startsWith(`${stamp}-`))
355
404
  const older = readdirSync(to).filter((name) => !mine.includes(name)).sort()
356
405
  const generations = 5
@@ -2996,6 +3045,7 @@ export class Sidecar {
2996
3045
  * @param {number} [ms] - how long a browser nobody watches may stay up.
2997
3046
  */
2998
3047
  touch(ms = IDLE_MS) {
3048
+ this.idleAt = Date.now() + ms
2999
3049
  if (this.idle !== null) clearTimeout(this.idle)
3000
3050
  this.idle = setTimeout(() => {
3001
3051
  this.idle = null
@@ -3087,6 +3137,7 @@ export class Sidecar {
3087
3137
  }
3088
3138
 
3089
3139
  async shutdown() {
3140
+ this.idleAt = 0
3090
3141
  /* A browser this process did not start belongs to somebody else — another
3091
3142
  app on the same profile, or a script that connected to it — and closing
3092
3143
  it would close the person's window. Let go of it instead: drop the socket
@@ -3158,7 +3209,7 @@ export class Sidecar {
3158
3209
  rmSync(file, { force: true })
3159
3210
  return
3160
3211
  }
3161
- writeFileSync(file, JSON.stringify(jar), { mode: 0o600 })
3212
+ writeSealed(file, jar)
3162
3213
  } catch {
3163
3214
  /* a browser that is already gone has nothing to save */
3164
3215
  }
@@ -3166,12 +3217,9 @@ export class Sidecar {
3166
3217
 
3167
3218
  /** @returns {Promise<void>} puts back what saveSessionCookies kept. */
3168
3219
  async restoreSessionCookies() {
3169
- let jar = []
3170
- try {
3171
- jar = JSON.parse(String(readFileSync(sessionJar(profileDir(this.profile)), 'utf8')))
3172
- } catch {
3173
- return
3174
- }
3220
+ /* Sealed by this version; plain JSON by one before it. Both are read, so an
3221
+ update never signs anyone out. */
3222
+ const jar = readSealed(sessionJar(profileDir(this.profile)))
3175
3223
  if (!Array.isArray(jar)) return
3176
3224
  /* A cookie the browser already holds is newer than the note: the site may
3177
3225
  have rotated its session id since the note was written, and putting the
@@ -3250,6 +3298,12 @@ export class Sidecar {
3250
3298
  screenshot is in this space, not in the window's. */
3251
3299
  viewport: this.page?.view ?? null,
3252
3300
  profiles,
3301
+ /* What this browser costs right now: the whole process tree, renderers
3302
+ included. Reported so a chat can decide whether to let it rest. */
3303
+ memory: this.alive ? treeBytes(Number(this.child?.pid ?? 0)) : 0,
3304
+ /* When it would close itself if nobody came back, and why: 0 means the
3305
+ browser is either watched or not on a clock. */
3306
+ closesIn: this.idle === null || this.stopping === true ? 0 : Math.max(0, Number(this.idleAt ?? 0) - Date.now()),
3253
3307
  error: this.alive ? '' : this.lastError,
3254
3308
  }
3255
3309
  }
package/tools.js CHANGED
@@ -16,6 +16,7 @@ import { basename, join } from 'node:path'
16
16
  import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
17
17
  import { VIEW, DESKTOP_UA } from './sidecar.js?v=1'
18
18
  import { planOf, gather, sizeText, coreRows } from './media.js?m=2'
19
+ import { dropLogin, listLogins, readLogin, saveLogin, vaultState } from './vault.js'
19
20
  import { readPage, renderPage, readArticle, readRelevant, findOnPage, locate, locateFirst, FRAME_BASE, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, clickInDom, writeInDom, REF_GONE } from './read.js?v=1'
20
21
  import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
21
22
 
@@ -965,6 +966,56 @@ async function evaluate(page, expression, route, raceMs = 2000) {
965
966
  return result?.value
966
967
  }
967
968
 
969
+ /**
970
+ * The local storage a page is holding, per origin: some sites keep the sign-in
971
+ * itself there and cookies alone would come back signed out.
972
+ * @param {any} page - sidecar page.
973
+ * @returns {Promise<Array<{origin: string, items: Record<string, string>}>>} what it holds.
974
+ */
975
+ async function storageOf(page) {
976
+ const raw = await evaluate(
977
+ page,
978
+ '(() => { try { const items = {}; for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i); if (key !== null) items[key] = String(localStorage.getItem(key)).slice(0, 4000); } return JSON.stringify([{ origin: location.origin, items }]) } catch { return "[]" } })()',
979
+ null,
980
+ 4000,
981
+ )
982
+ let rows = []
983
+ try {
984
+ rows = JSON.parse(String(raw ?? '[]'))
985
+ } catch {
986
+ return []
987
+ }
988
+ if (!Array.isArray(rows)) return []
989
+ return rows
990
+ .filter((row) => row !== null && typeof row === 'object' && typeof row.origin === 'string' && row.items !== null && typeof row.items === 'object')
991
+ .map((row) => ({ origin: String(row.origin), items: Object.fromEntries(Object.entries(row.items).slice(0, 200).map(([key, value]) => [key, String(value)])) }))
992
+ }
993
+
994
+ /**
995
+ * Puts one origin's local storage back. The page has to be on that origin to
996
+ * write to it, so this navigates there first — a sign-in that lives in local
997
+ * storage is worth the extra step.
998
+ * @param {any} page - sidecar page.
999
+ * @param {{origin: string, items: Record<string, string>}} row - what to put back.
1000
+ * @returns {Promise<number>} how many entries went in.
1001
+ */
1002
+ async function restoreStorage(page, row) {
1003
+ if (String(row?.origin ?? '') === '' || page === null) return 0
1004
+ try {
1005
+ const here = String(page.url ?? '')
1006
+ if (!here.startsWith(String(row.origin))) await page.navigate(String(row.origin))
1007
+ } catch {
1008
+ return 0
1009
+ }
1010
+ const written = await evaluate(
1011
+ page,
1012
+ `(() => { try { const items = ${JSON.stringify(row.items ?? {})}; for (const [key, value] of Object.entries(items)) localStorage.setItem(key, value); return Object.keys(items).length } catch { return 0 } })()`,
1013
+ null,
1014
+ 4000,
1015
+ )
1016
+ return Number(written) || 0
1017
+ }
1018
+
968
1019
  /**
969
1020
  * What the page looks like in one cheap string: address, title, how many
970
1021
  * elements and how much text. Two of these equal says the click did nothing
@@ -3050,6 +3101,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3050
3101
  `mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
3051
3102
  owner === '' ? 'no owner' : `owner ${owner}`,
3052
3103
  `refs followed ${String(follows.count)}`,
3104
+ /* What it costs and when it would let go: the two facts that decide
3105
+ whether to keep using it or let it rest. */
3106
+ state.alive === true ? `memory ${Math.max(1, Math.round(Number(state.memory ?? 0) / 1048576))} MB` : 'memory 0 MB',
3107
+ state.closesIn > 0 ? `closes in ${Math.max(1, Math.round(Number(state.closesIn) / 60_000))} min unless used` : 'no close scheduled',
3053
3108
  holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
3054
3109
  ]
3055
3110
  if (state.alive === true && viewerNote(state.viewers) !== '') rows.push(NO_VIEWER)
@@ -3057,6 +3112,86 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3057
3112
  },
3058
3113
  },
3059
3114
 
3115
+ {
3116
+ name: 'browser_login', // ui-copy-ignore
3117
+ description: 'Sign-ins belong to the browser, not to a chat: every chat uses them and they survive a restart. "save" keeps the sign-in showing right now under a name, "list" shows what is kept, "use" signs the browser back in as that account and reopens the page, "drop" forgets it. What is written is sealed, and the key is the one in the system keystore. One site can hold several accounts: save them under different names and switch with "use".',
3118
+ parameters: { // ui-copy-ignore
3119
+ type: 'object',
3120
+ properties: {
3121
+ action: { type: 'string', enum: ['list', 'save', 'use', 'drop'], description: 'list, save, use or drop.' },
3122
+ name: { type: 'string', description: 'Account name, for example "work" or "personal". Needed by save, use and drop.' },
3123
+ },
3124
+ required: ['action'], // ui-copy-ignore
3125
+ additionalProperties: false,
3126
+ },
3127
+ output: TEXT_OUTPUT, // ui-copy-ignore
3128
+ timeoutMs: 25_000,
3129
+ isConcurrencySafe: () => false,
3130
+ /** @param {any} args - action and name. @returns {Promise<{text: string}>} what happened. */
3131
+ execute: async (args) => {
3132
+ const action = String(args?.action ?? '')
3133
+ if (action === 'list') {
3134
+ const rows = listLogins()
3135
+ const shape = vaultState()
3136
+ if (rows.length === 0) {
3137
+ return { text: 'Nothing saved yet. Sign in on the site, then browser_login {action:"save", name:"work"}.' }
3138
+ }
3139
+ const head = `${rows.length} kept sign-in${rows.length === 1 ? '' : 's'}, ${Math.max(1, Math.round(shape.bytes / 1024))} KB sealed${shape.keyInKeychain ? ', key in the system keystore' : ''}`
3140
+ const lines = rows.map((row) => {
3141
+ const sites = row.origins.length === 0 ? '' : `, ${row.origins.join(' ')}`
3142
+ return `${row.name} — ${row.cookieCount} cookies${sites}, saved ${new Date(row.savedAt).toLocaleString()}`
3143
+ })
3144
+ return { text: [head, ...lines].join('\n') }
3145
+ }
3146
+ const name = String(args?.name ?? '').trim()
3147
+ if (name === '') return { text: 'Name the account: browser_login {action:"save", name:"work"}.' }
3148
+ if (action === 'drop') {
3149
+ return { text: dropLogin(name) ? `Forgot "${name}".` : `Nothing saved as "${name}".` }
3150
+ }
3151
+ if (action === 'save') {
3152
+ const page = await sidecar.ensure({ session: currentSession() })
3153
+ const cookies = await page.cookies()
3154
+ const storage = await storageOf(page)
3155
+ const kept = saveLogin(name, { cookies, storage, url: String(page.url ?? '') })
3156
+ const sites = kept.origins.length === 0 ? '' : ` and ${kept.origins.length} site${kept.origins.length === 1 ? '' : 's'} of local storage`
3157
+ return { text: `Saved "${kept.name}": ${kept.cookieCount} cookies${sites}, sealed on disk.` }
3158
+ }
3159
+ if (action === 'use') {
3160
+ const held = readLogin(name)
3161
+ if (held === null) return { text: `Nothing saved as "${name}". browser_login {action:"list"} shows what is kept.` }
3162
+ const page = await sidecar.ensure({ session: currentSession() })
3163
+ let put = 0
3164
+ for (const cookie of (Array.isArray(held.cookies) ? held.cookies : []).slice(0, 400)) {
3165
+ const domain = String(cookie?.domain ?? '').replace(/^\./u, '')
3166
+ if (domain === '' || cookie?.name === undefined) continue
3167
+ try {
3168
+ await page.setCookie({
3169
+ name: String(cookie.name),
3170
+ value: String(cookie.value ?? ''),
3171
+ domain,
3172
+ path: String(cookie.path ?? '/'),
3173
+ secure: cookie.secure === true,
3174
+ httpOnly: cookie.httpOnly === true,
3175
+ sameSite: cookie.sameSite,
3176
+ expires: cookie.session === true ? undefined : Number(cookie.expires) || undefined,
3177
+ })
3178
+ put += 1
3179
+ } catch {
3180
+ /* one cookie a site refuses is not a reason to stop */
3181
+ }
3182
+ }
3183
+ let stored = 0
3184
+ for (const row of (Array.isArray(held.storage) ? held.storage : []).slice(0, 4)) stored += await restoreStorage(page, row)
3185
+ const back = String(held.url ?? '')
3186
+ if (back !== '') await page.navigate(back)
3187
+ else await page.reload()
3188
+ const said = stored === 0 ? '' : `, ${stored} local storage entries`
3189
+ return { text: `Signed in as "${name}": ${put} cookies${said}, page reopened.` }
3190
+ }
3191
+ return { text: 'Pass action "list", "save", "use" or "drop".' }
3192
+ },
3193
+ },
3194
+
3060
3195
  {
3061
3196
  name: 'browser_resume', // ui-copy-ignore
3062
3197
  description: 'Take the page back from the person at the mirror: clears the hold so the next action is sent at once, instead of waiting the few seconds out.',
package/vault.js ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Sign-in material at rest.
3
+ *
4
+ * A cookie jar is a password: whoever reads the file is signed in as the person.
5
+ * Chrome keeps its own store encrypted, but the copy this plugin makes when a
6
+ * session cookie would otherwise be dropped is a plain file, and a plain file in
7
+ * a home directory is one backup or one shared folder away from being someone
8
+ * else's session. Everything this plugin writes about a sign-in is therefore
9
+ * sealed with AES-256-GCM, and the key lives in the system Keychain — not next
10
+ * to the ciphertext, not in the profile, not in an environment variable by
11
+ * default.
12
+ *
13
+ * What stays unsealed is a name and a timestamp, so the list of accounts can be
14
+ * shown without opening the vault.
15
+ */
16
+ import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import { execFileSync } from 'node:child_process'
21
+
22
+ const KEYCHAIN_SERVICE = 'dsh-inner-browser-use-vault'
23
+ const KEY_BYTES = 32
24
+ const IV_BYTES = 12
25
+
26
+ /** @returns {string} where the browser state, and so the vault, lives. */
27
+ export function vaultRoot() {
28
+ return process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
29
+ }
30
+
31
+ /**
32
+ * The key, in order of preference: the one this run was handed (tests, or a
33
+ * person who keeps it in their own secret store), the one in the Keychain, the
34
+ * one on disk with owner-only permissions. A missing key is created once and
35
+ * then never rotated silently, because rotating it would sign everyone out.
36
+ * @returns {Buffer} 32 bytes.
37
+ */
38
+ export function vaultKey() {
39
+ const given = String(process.env.DSH_INNER_BROWSER_VAULT_KEY ?? '')
40
+ if (given !== '') {
41
+ const raw = Buffer.from(given, 'base64')
42
+ if (raw.length === KEY_BYTES) return raw
43
+ }
44
+ const fromKeychain = readKeychain()
45
+ if (fromKeychain !== null) return fromKeychain
46
+ const file = join(vaultRoot(), 'vault.key')
47
+ if (existsSync(file)) {
48
+ const raw = Buffer.from(String(readFileSync(file, 'utf8')).trim(), 'base64')
49
+ if (raw.length === KEY_BYTES) return raw
50
+ }
51
+ const made = randomBytes(KEY_BYTES)
52
+ const written = writeKeychain(made)
53
+ if (written === true) return made
54
+ mkdirSync(vaultRoot(), { recursive: true })
55
+ writeFileSync(file, `${made.toString('base64')}\n`, { mode: 0o600 })
56
+ chmodSync(file, 0o600)
57
+ return made
58
+ }
59
+
60
+ /** @returns {Buffer|null} the Keychain copy, if this platform has one. */
61
+ function readKeychain() {
62
+ if (process.platform !== 'darwin') return null
63
+ try {
64
+ const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'vault', '-w'], {
65
+ encoding: 'utf8',
66
+ timeout: 4000,
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ })
69
+ const raw = Buffer.from(out.trim(), 'base64')
70
+ return raw.length === KEY_BYTES ? raw : null
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ /** @param {Buffer} key - the material. @returns {boolean} whether the Keychain took it. */
77
+ function writeKeychain(key) {
78
+ if (process.platform !== 'darwin') return false
79
+ try {
80
+ execFileSync('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', 'vault', '-w', key.toString('base64')], {
81
+ timeout: 4000,
82
+ stdio: ['ignore', 'ignore', 'ignore'],
83
+ })
84
+ return true
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ /** @returns {'env'|'keychain'|'file'|'none'} which of the three the key in use came from. */
91
+ function keySource() {
92
+ const given = String(process.env.DSH_INNER_BROWSER_VAULT_KEY ?? '')
93
+ if (given !== '' && Buffer.from(given, 'base64').length === KEY_BYTES) return 'env'
94
+ if (readKeychain() !== null) return 'keychain'
95
+ const file = join(vaultRoot(), 'vault.key')
96
+ if (existsSync(file)) {
97
+ const raw = Buffer.from(String(readFileSync(file, 'utf8')).trim(), 'base64')
98
+ if (raw.length === KEY_BYTES) return 'file'
99
+ }
100
+ return 'none'
101
+ }
102
+
103
+ /**
104
+ * @param {unknown} value - anything JSON can hold.
105
+ * @returns {{v: number, alg: string, iv: string, tag: string, data: string}} the sealed blob.
106
+ */
107
+ export function seal(value) {
108
+ const key = vaultKey()
109
+ const iv = randomBytes(IV_BYTES)
110
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
111
+ const data = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(value), 'utf8')), cipher.final()])
112
+ return {
113
+ v: 1,
114
+ alg: 'aes-256-gcm',
115
+ iv: iv.toString('base64'),
116
+ tag: cipher.getAuthTag().toString('base64'),
117
+ data: data.toString('base64'),
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Opens a sealed blob. A tampered blob, a different key, or a plain array all
123
+ * come back as `undefined` rather than as data that might be trusted.
124
+ * @param {unknown} blob - what `seal` returned, or its JSON text.
125
+ * @returns {any} the value, or undefined.
126
+ */
127
+ export function unseal(blob) {
128
+ let held = blob
129
+ if (typeof held === 'string') {
130
+ try {
131
+ held = JSON.parse(held)
132
+ } catch {
133
+ return undefined
134
+ }
135
+ }
136
+ if (held === null || typeof held !== 'object') return undefined
137
+ const row = /** @type {any} */ (held)
138
+ if (row.v !== 1 || row.alg !== 'aes-256-gcm') return undefined
139
+ try {
140
+ const key = vaultKey()
141
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(String(row.iv), 'base64'))
142
+ decipher.setAuthTag(Buffer.from(String(row.tag), 'base64'))
143
+ const text = Buffer.concat([decipher.update(Buffer.from(String(row.data), 'base64')), decipher.final()])
144
+ return JSON.parse(text.toString('utf8'))
145
+ } catch {
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Reads a file that is either sealed or, from an older version, plain JSON.
152
+ * @param {string} path - the file.
153
+ * @returns {any} its value, or undefined.
154
+ */
155
+ export function readSealed(path) {
156
+ let text = ''
157
+ try {
158
+ text = String(readFileSync(path, 'utf8'))
159
+ } catch {
160
+ return undefined
161
+ }
162
+ const opened = unseal(text)
163
+ if (opened !== undefined) return opened
164
+ try {
165
+ return JSON.parse(text)
166
+ } catch {
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Writes a sealed file and makes sure nobody but the owner can read it, even if
173
+ * it was created earlier with looser permissions.
174
+ * @param {string} path - where.
175
+ * @param {unknown} value - what.
176
+ * @returns {void}
177
+ */
178
+ export function writeSealed(path, value) {
179
+ writeFileSync(path, `${JSON.stringify(seal(value))}\n`, { mode: 0o600 })
180
+ try {
181
+ chmodSync(path, 0o600)
182
+ } catch {
183
+ /* a file system without permissions is not a reason to fail the save */
184
+ }
185
+ }
186
+
187
+ /** @param {string} path - a directory. @returns {void} owner-only, best effort. */
188
+ export function tightenDir(path) {
189
+ try {
190
+ mkdirSync(path, { recursive: true, mode: 0o700 })
191
+ chmodSync(path, 0o700)
192
+ } catch {
193
+ /* best effort */
194
+ }
195
+ }
196
+
197
+ /* ------------------------------------------------------------------ logins */
198
+
199
+ /**
200
+ * A name becomes a file name. Anything that could climb out of the directory or
201
+ * collide on a case-insensitive file system is folded to a safe character.
202
+ * @param {string} name - what the person called the account.
203
+ * @returns {string} the file stem.
204
+ */
205
+ export function loginSlug(name) {
206
+ const raw = String(name ?? '').trim()
207
+ if (raw === '') return ''
208
+ return raw
209
+ .replace(/[^\w. -]/gu, '_')
210
+ .replace(/\s+/gu, '-')
211
+ .replace(/^[.-]+/u, '')
212
+ .slice(0, 48)
213
+ }
214
+
215
+ /** @returns {string} where named accounts live. */
216
+ export function loginDir() {
217
+ const dir = join(vaultRoot(), 'logins')
218
+ tightenDir(dir)
219
+ return dir
220
+ }
221
+
222
+ /**
223
+ * @param {string} name - account name.
224
+ * @returns {string} the file for it.
225
+ */
226
+ function loginFile(name) {
227
+ const slug = loginSlug(name)
228
+ return slug === '' ? '' : join(loginDir(), `${slug}.json`)
229
+ }
230
+
231
+ /**
232
+ * Keeps one account: cookies plus the local storage of the sites it was saved
233
+ * on. Sealed as a whole, so even the list of sites is not readable off disk.
234
+ * @param {string} name - account name.
235
+ * @param {{cookies: object[], storage: Array<{origin: string, items: Record<string, string>}>, url?: string}} payload - what to keep.
236
+ * @returns {{name: string, savedAt: number, cookieCount: number, origins: string[]}} what was written.
237
+ */
238
+ export function saveLogin(name, payload) {
239
+ const file = loginFile(name)
240
+ if (file === '') throw new Error('a login needs a name')
241
+ const cookies = Array.isArray(payload?.cookies) ? payload.cookies.slice(0, 400) : []
242
+ const storage = Array.isArray(payload?.storage) ? payload.storage.slice(0, 40) : []
243
+ const savedAt = Date.now()
244
+ writeSealed(file, {
245
+ name: String(name).trim().slice(0, 80),
246
+ savedAt,
247
+ url: String(payload?.url ?? ''),
248
+ cookies,
249
+ storage,
250
+ })
251
+ return {
252
+ name: String(name).trim().slice(0, 80),
253
+ savedAt,
254
+ cookieCount: cookies.length,
255
+ origins: storage.map((row) => String(row?.origin ?? '')).filter(Boolean),
256
+ }
257
+ }
258
+
259
+ /**
260
+ * @param {string} name - account name.
261
+ * @returns {any|null} the kept account, or null.
262
+ */
263
+ export function readLogin(name) {
264
+ const file = loginFile(name)
265
+ if (file === '' || !existsSync(file)) return null
266
+ const held = readSealed(file)
267
+ return held === undefined ? null : held
268
+ }
269
+
270
+ /**
271
+ * Everything in the vault, newest first. Only the envelope is needed for the
272
+ * list, but the envelope is sealed too, so this opens each file.
273
+ * @returns {Array<{name: string, savedAt: number, cookieCount: number, origins: string[]}>} the accounts.
274
+ */
275
+ export function listLogins() {
276
+ const dir = loginDir()
277
+ const rows = []
278
+ let names = []
279
+ try {
280
+ names = readdirSync(dir).filter((name) => name.endsWith('.json'))
281
+ } catch {
282
+ return rows
283
+ }
284
+ for (const name of names) {
285
+ const held = readSealed(join(dir, name))
286
+ if (held === null || typeof held !== 'object') continue
287
+ rows.push({
288
+ name: String(held.name ?? name.replace(/\.json$/u, '')),
289
+ savedAt: Number(held.savedAt ?? 0),
290
+ cookieCount: Array.isArray(held.cookies) ? held.cookies.length : 0,
291
+ origins: Array.isArray(held.storage) ? held.storage.map((row) => String(row?.origin ?? '')).filter(Boolean) : [],
292
+ })
293
+ }
294
+ return rows.sort((a, b) => b.savedAt - a.savedAt)
295
+ }
296
+
297
+ /**
298
+ * @param {string} name - account name.
299
+ * @returns {boolean} whether something was there to remove.
300
+ */
301
+ export function dropLogin(name) {
302
+ const file = loginFile(name)
303
+ if (file === '' || !existsSync(file)) return false
304
+ rmSync(file, { force: true })
305
+ return true
306
+ }
307
+
308
+ /**
309
+ * How big the vault is, and whether the key is outside it: the two facts a
310
+ * person wants when deciding whether a sign-in is safe to leave here.
311
+ * @returns {{files: number, bytes: number, keyInKeychain: boolean, keySource: string}} the shape.
312
+ */
313
+ export function vaultState() {
314
+ const dir = loginDir()
315
+ let files = 0
316
+ let bytes = 0
317
+ try {
318
+ for (const name of readdirSync(dir)) {
319
+ if (!name.endsWith('.json')) continue
320
+ files += 1
321
+ bytes += statSync(join(dir, name)).size
322
+ }
323
+ } catch {
324
+ /* an empty vault is a valid vault */
325
+ }
326
+ const source = keySource()
327
+ return { files, bytes, keyInKeychain: source === 'keychain', keySource: source }
328
+ }
329
+
330
+ /** @param {string} path - two files. @param {Buffer} a - one. @param {Buffer} b - the other. @returns {boolean} same bytes. */
331
+ export function sameKey(a, b) {
332
+ return a.length === b.length && timingSafeEqual(a, b)
333
+ }