dsh-inner-browser-use 0.42.0 → 0.44.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,7 @@ 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_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. |
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, `import` takes a sign-in that is already in Chrome, Brave, Edge or Chromium on this Mac and keeps it under a name, `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
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. |
26
26
 
27
27
  `browser_read` on the same page is the words only, in Markdown (measured: 4,520
package/README.zh.md CHANGED
@@ -20,7 +20,7 @@ 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_login` | 按名字留住登录:`save` 存下当前窗口的 cookie 和各站的 localStorage,`list` 看有哪些,`use` 切回那个账号并重开页面,`drop` 忘掉。名字跟着浏览器走、不跟着一个会话走,重启也在;同一站点两个账号就存两个名字。磁盘上那一份是加密的,钥匙在系统钥匙串里。 |
23
+ | `browser_login` | 按名字留住登录:`save` 存下当前窗口的 cookie 和各站的 localStorage,`list` 看有哪些,`use` 切回那个账号并重开页面,`import` 把本机 Chrome/Brave/Edge 里已经登着的站直接留成一个名字,`drop` 忘掉。名字跟着浏览器走、不跟着一个会话走,重启也在;同一站点两个账号就存两个名字。磁盘上那一份是加密的,钥匙在系统钥匙串里。 |
24
24
  | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题、有几个右栏在看它,以及它此刻占多少内存、没人用的话还有多久关闭。 |
25
25
 
26
26
  点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
@@ -0,0 +1,310 @@
1
+ /**
2
+ * A sign-in that is already on this Mac, brought into the vault without an
3
+ * extension, a pairing code, or a copy of anybody's profile: the cookie store
4
+ * the browser itself keeps, opened, filtered to the one site being asked about
5
+ * and sealed again by the vault. Nothing here talks to the network.
6
+ *
7
+ * The store is read from a copy, never from the live file: the browser holds a
8
+ * lock on it, and a lock is not a reason to ask somebody to quit their browser.
9
+ *
10
+ * @module dsh-inner-browser-use/chromecookies
11
+ */
12
+
13
+ import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto'
14
+ import { existsSync, mkdtempSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { spawnSync } from 'node:child_process'
18
+
19
+ /** The browsers this can read, most likely first. */
20
+ export const BROWSERS = [
21
+ { id: 'chrome', label: 'Chrome', root: 'Google/Chrome' },
22
+ { id: 'brave', label: 'Brave', root: 'BraveSoftware/Brave-Browser' },
23
+ { id: 'edge', label: 'Edge', root: 'Microsoft Edge' },
24
+ { id: 'chromium', label: 'Chromium', root: 'Chromium' },
25
+ ]
26
+
27
+ /**
28
+ * @param {any} error - a failed file operation.
29
+ * @returns {boolean} whether the system refused a directory the app exists beside.
30
+ */
31
+ function denied(error) {
32
+ const code = String(error?.code ?? '')
33
+ return code === 'EPERM' || code === 'EACCES'
34
+ }
35
+
36
+ /** Profile directories that are not a person's browser. */
37
+ const NOT_A_PROFILE = new Set(['System Profile', 'Guest Profile', 'Crashpad', 'ShaderCache', 'GrShaderCache', 'BrowserMetrics', 'component_crx_cache', 'extensions_crx_cache', 'Snapshots'])
38
+
39
+ /**
40
+ * @param {string} id - a browser id.
41
+ * @returns {string} where its profiles live, or '' on a platform this cannot read.
42
+ */
43
+ export function browserRoot(id = 'chrome') {
44
+ const one = BROWSERS.find((row) => row.id === id)
45
+ if (one === undefined) return ''
46
+ /* A profile root other than the standard one is only ever set by someone
47
+ testing this, or by a person who moved their browser data. */
48
+ const home = String(process.env.DSH_INNER_BROWSER_CHROME_HOME ?? process.env.HOME ?? '')
49
+ if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', one.root)
50
+ if (process.platform === 'win32') return join(String(process.env.LOCALAPPDATA ?? ''), one.root.replace(/\//gu, '\\'))
51
+ return join(home, '.config', one.root.replace('Google/', 'google-').replace('BraveSoftware/Brave-Browser', 'BraveSoftware/Brave-Browser'))
52
+ }
53
+
54
+ /**
55
+ * Every profile of a browser that keeps cookies, newest use first.
56
+ * @param {string} id - a browser id.
57
+ * @returns {Array<{profile: string, db: string, at: number}>} what can be read.
58
+ */
59
+ export function cookieStores(id = 'chrome') {
60
+ const root = browserRoot(id)
61
+ if (root === '' || !existsSync(root)) return []
62
+ let names = []
63
+ try {
64
+ names = readdirSync(root)
65
+ } catch (error) {
66
+ if (denied(error)) throw new Error('macOS will not let this app read the browser’s cookies: give it Full Disk Access, then try again')
67
+ return []
68
+ }
69
+ const rows = []
70
+ for (const name of names) {
71
+ if (NOT_A_PROFILE.has(name) || name.startsWith('.')) continue
72
+ const dir = join(root, name)
73
+ /* Chrome 96 moved the store under Network/; older ones kept it beside the profile. */
74
+ for (const rel of ['Network/Cookies', 'Cookies']) {
75
+ const db = join(dir, rel)
76
+ try {
77
+ const stat = statSync(db)
78
+ rows.push({ profile: name, db, at: Number(stat.mtimeMs) })
79
+ break
80
+ } catch {
81
+ /* this profile does not keep its cookies here */
82
+ }
83
+ }
84
+ }
85
+ return rows.sort((a, b) => b.at - a.at)
86
+ }
87
+
88
+ /**
89
+ * The password the browser used for its cookie encryption, from the macOS
90
+ * keychain. Linux and Windows keep theirs elsewhere; this refuses rather than
91
+ * guessing.
92
+ * @returns {string} the password, or '' when it cannot be had.
93
+ */
94
+ export function safeStorageKey() {
95
+ if (process.platform !== 'darwin') return ''
96
+ for (const args of [['find-generic-password', '-w', '-s', 'Chrome Safe Storage', '-a', 'Chrome'], ['find-generic-password', '-w', '-s', 'Chrome Safe Storage']]) {
97
+ const out = spawnSync('security', args, { encoding: 'utf8', timeout: 5000 })
98
+ const password = String(out.stdout ?? '').trim()
99
+ if (out.status === 0 && password !== '') return password
100
+ }
101
+ return ''
102
+ }
103
+
104
+ /**
105
+ * @param {string} password - the browser's safe-storage password.
106
+ * @returns {Buffer} the AES key it derives.
107
+ */
108
+ export function storeKey(password) {
109
+ return pbkdf2Sync(String(password), 'saltysalt', 1003, 16, 'sha1')
110
+ }
111
+
112
+ /**
113
+ * One stored cookie value, decrypted. Chrome prefixes the ciphertext with the
114
+ * scheme; from v24 the plaintext also starts with the site's host hash. // ui-copy-ignore
115
+ * @param {Buffer} blob - the encrypted_value bytes.
116
+ * @param {Buffer} key - from storeKey.
117
+ * @param {string} host - the cookie's host_key. // ui-copy-ignore
118
+ * @returns {string} the value, or '' when this browser sealed it beyond reach.
119
+ */
120
+ export function unsealCookie(blob, key, host) { // ui-copy-ignore
121
+ if (!Buffer.isBuffer(blob) || blob.length === 0) return ''
122
+ const scheme = String(blob.subarray(0, 3).toString('latin1'))
123
+ if (scheme !== 'v10' && scheme !== 'v11') return ''
124
+ const iv = Buffer.alloc(16, ' ')
125
+ let plain = Buffer.alloc(0)
126
+ try {
127
+ const decipher = createDecipheriv('aes-128-cbc', key, iv)
128
+ plain = Buffer.concat([decipher.update(blob.subarray(3)), decipher.final()])
129
+ } catch {
130
+ return ''
131
+ }
132
+ const printed = createHash('sha256').update(String(host)).digest() // ui-copy-ignore
133
+ if (plain.length > 32 && plain.subarray(0, 32).equals(printed)) plain = plain.subarray(32)
134
+ return plain.toString('utf8')
135
+ }
136
+
137
+ /**
138
+ * Copies a store somewhere it can be read while the browser is running, and
139
+ * takes the copy away again even when the read fails.
140
+ * @param {string} db - the live store.
141
+ * @param {(copy: string) => any} work - what to do with the copy.
142
+ * @returns {any} whatever work returned.
143
+ */
144
+ function onCopy(db, work) {
145
+ const dir = mkdtempSync(join(tmpdir(), 'dshib-cookies-'))
146
+ try {
147
+ const copy = join(dir, 'Cookies')
148
+ copyFileSync(db, copy)
149
+ for (const extra of ['-wal', '-shm']) {
150
+ if (existsSync(`${db}${extra}`)) copyFileSync(`${db}${extra}`, `${copy}${extra}`)
151
+ }
152
+ return work(copy)
153
+ } finally {
154
+ rmSync(dir, { recursive: true, force: true })
155
+ }
156
+ }
157
+
158
+ /**
159
+ * @param {string} copy - the store copy.
160
+ * @param {string} sql - one SELECT.
161
+ * @returns {Array<object>} rows.
162
+ */
163
+ function query(copy, sql) {
164
+ try {
165
+ /* Node 22+ ships SQLite; the CLI is the fallback so an older host still works. */ // ui-copy-ignore
166
+ const mod = process.getBuiltinModule?.('node:sqlite')
167
+ if (typeof mod?.DatabaseSync === 'function') {
168
+ const db = new mod.DatabaseSync(copy, { readOnly: true })
169
+ try {
170
+ return db.prepare(sql).all()
171
+ } finally {
172
+ db.close()
173
+ }
174
+ }
175
+ } catch {
176
+ /* fall through to the command line */
177
+ }
178
+ const out = spawnSync('/usr/bin/sqlite3', ['-readonly', '-json', copy, sql], { encoding: 'utf8', timeout: 8000, maxBuffer: 4_000_000 })
179
+ if (out.status !== 0) return []
180
+ try {
181
+ const rows = JSON.parse(String(out.stdout ?? '[]'))
182
+ return Array.isArray(rows) ? rows : []
183
+ } catch {
184
+ return []
185
+ }
186
+ }
187
+
188
+ /**
189
+ * @param {number} micro - Chrome's expiry, microseconds since 1601.
190
+ * @returns {number} milliseconds since 1970, or 0 for a session cookie.
191
+ */
192
+ function expiresAt(micro) {
193
+ const value = Number(micro ?? 0)
194
+ if (!Number.isFinite(value) || value <= 0) return 0
195
+ return Math.round(value / 1000) - 11644473600000
196
+ }
197
+
198
+ /** @param {number} value - Chrome's samesite column. @returns {string|undefined} the CDP name. */
199
+ function sameSite(value) {
200
+ const at = Number(value ?? -1)
201
+ if (at === 0) return 'None'
202
+ if (at === 1) return 'Lax'
203
+ if (at === 2) return 'Strict'
204
+ return undefined
205
+ }
206
+
207
+ /**
208
+ * The cookies of one site, ready for the vault. Values never leave this call.
209
+ * @param {string} db - a store path.
210
+ * @param {Buffer} key - from storeKey.
211
+ * @param {string} host - the site, without a leading www. or a port. // ui-copy-ignore
212
+ * @param {number} [cap] - how many to keep.
213
+ * @returns {{cookies: object[], sealed: number}} the cookies and how many could not be opened.
214
+ */
215
+ export function cookiesFor(db, key, host, cap = 80) { // ui-copy-ignore
216
+ const site = String(host).replace(/^\./u, '').toLowerCase() // ui-copy-ignore
217
+ if (site === '') return { cookies: [], sealed: 0 }
218
+ const rows = onCopy(db, (copy) =>
219
+ query(copy, 'SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite FROM cookies'),
220
+ )
221
+ const cookies = []
222
+ let sealed = 0
223
+ for (const row of rows) {
224
+ const domain = String(row?.host_key ?? '').replace(/^\./u, '').toLowerCase()
225
+ if (domain === '' || !(site === domain || site.endsWith(`.${domain}`) || domain.endsWith(`.${site}`))) continue
226
+ const raw = String(row?.name ?? '')
227
+ if (raw === '') continue
228
+ let value = String(row?.value ?? '')
229
+ if (value === '') value = unsealCookie(normalizeBlob(row?.encrypted_value), key, String(row?.host_key ?? ''))
230
+ if (value === '') {
231
+ sealed += 1
232
+ continue
233
+ }
234
+ const expires = expiresAt(row?.expires_utc)
235
+ const cookie = {
236
+ name: raw,
237
+ value,
238
+ domain,
239
+ path: String(row?.path ?? '/'),
240
+ secure: Number(row?.is_secure ?? 0) === 1,
241
+ httpOnly: Number(row?.is_httponly ?? 0) === 1,
242
+ session: expires === 0,
243
+ }
244
+ const same = sameSite(row?.samesite)
245
+ if (same !== undefined) cookie.sameSite = same
246
+ if (expires !== 0) cookie.expires = expires
247
+ cookies.push(cookie)
248
+ if (cookies.length >= cap) break
249
+ }
250
+ return { cookies, sealed }
251
+ }
252
+
253
+ /** @param {any} value - a blob column, from either SQLite driver. @returns {Buffer} its bytes. */
254
+ function normalizeBlob(value) {
255
+ if (Buffer.isBuffer(value)) return value
256
+ if (value instanceof Uint8Array) return Buffer.from(value)
257
+ if (typeof value === 'string') return Buffer.from(value, 'latin1')
258
+ if (value !== null && typeof value === 'object' && value?.data !== undefined) return Buffer.from(value.data)
259
+ return Buffer.alloc(0)
260
+ }
261
+
262
+ /**
263
+ * @param {string} url - the page a sign-in is wanted for.
264
+ * @returns {string} its host, without www., or ''. // ui-copy-ignore
265
+ */
266
+ export function siteOf(url) {
267
+ try {
268
+ return String(new URL(String(url)).hostname).replace(/^www\./u, '').toLowerCase()
269
+ } catch {
270
+ return ''
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Reads a site's sign-in out of the browser already on this Mac and says what
276
+ * it found. Refuses with a reason when macOS will not let the cookie store be
277
+ * read, which is the one failure a person can actually fix.
278
+ * @param {{browser?: string, url?: string, host?: string, cap?: number}} options - which site, which browser. // ui-copy-ignore
279
+ * @returns {{cookies: object[], browser: string, label: string, profile: string, sealed: number, at: number}} what was read.
280
+ */
281
+ export function readBrowserCookies(options = {}) {
282
+ const id = String(options.browser ?? 'chrome')
283
+ const host = String(options.host ?? siteOf(String(options.url ?? ''))).toLowerCase() // ui-copy-ignore
284
+ if (host === '') throw new Error('there is no site to import: open the page first') // ui-copy-ignore
285
+ const stores = cookieStores(id)
286
+ if (stores.length === 0) {
287
+ const one = BROWSERS.find((row) => row.id === id)
288
+ const where = browserRoot(id)
289
+ const blocked = where !== '' && existsSync(where) === false
290
+ throw new Error(blocked ? `${one?.label ?? id} is not installed on this Mac` : `no ${one?.label ?? id} profile keeps cookies here`)
291
+ }
292
+ /* The password the browser derived its key from. It is only ever taken from
293
+ the keychain, or handed in by someone who keeps the key elsewhere. */
294
+ const password = String(process.env.DSH_INNER_BROWSER_COOKIE_KEY ?? '') || safeStorageKey()
295
+ if (password === '') throw new Error('the browser’s key is not in the keychain, so its cookies cannot be opened')
296
+ const key = storeKey(password)
297
+ for (const store of stores) {
298
+ let read = { cookies: [], sealed: 0 }
299
+ try {
300
+ read = cookiesFor(store.db, key, host, Number(options.cap ?? 80)) // ui-copy-ignore
301
+ } catch (error) {
302
+ if (denied(error)) throw new Error('macOS will not let this app read the browser’s cookies: give it Full Disk Access, then try again')
303
+ throw error
304
+ }
305
+ if (read.cookies.length === 0) continue
306
+ const one = BROWSERS.find((row) => row.id === id)
307
+ return { cookies: read.cookies, browser: id, label: one?.label ?? id, profile: store.profile, sealed: read.sealed, at: Date.now() }
308
+ }
309
+ throw new Error(`no ${BROWSERS.find((row) => row.id === id)?.label ?? id} profile is signed in to ${host}`) // ui-copy-ignore
310
+ }
@@ -168,8 +168,12 @@ window) costs one round trip on an animation-heavy page instead of one per frame
168
168
  ## Reading for an answer
169
169
 
170
170
  `browser_read` with a `query` does not return the page; it returns the sentences that answer the
171
- question. The page-side block reader already returns paragraphs with their heading and link
172
- density, so the work stays in `read.js` and costs no extra round trip.
171
+ question. The page-side block reader already returns paragraphs with their heading trail
172
+ (`Plans \u203a Trial`), their link density, a 0-1 quality score and table rows as one
173
+ `column: value | column: value` line, so the work stays in `read.js` and costs no extra round trip.
174
+ When the page's own menu would eat the budget, the blocks past the ceiling are chosen by quality and
175
+ then put back in page order, so prose survives where document order would have spent everything
176
+ above it.
173
177
 
174
178
  Paragraphs are ranked first (BM25 over the question's terms, link-heavy menus down-weighted), then
175
179
  split into sentences. Each sentence is scored on its own with the same saturation and length
@@ -177,7 +181,9 @@ normalisation, and that score is multiplied by three priors: the words of its he
177
181
  the paragraph it sits in, and inverse link density. Words the page says everywhere are dropped
178
182
  before scoring — a term in most sentences cannot point at anything — and a sentence that matches
179
183
  only part of the question is scaled by the share of the question's idf weight it actually covers,
180
- which is what stops a paragraph containing "to" from competing with one containing "refund".
184
+ which is what stops a paragraph containing "to" from competing with one containing "refund". The
185
+ block's quality score and its being a table row are priors as well, so a menu that repeats the
186
+ question's words cannot outrank the sentence that explains them.
181
187
 
182
188
  Selection is not "the top N scores": each pick subtracts its Jaccard overlap with what is already
183
189
  chosen (MMR), selection stops when the best remaining sentence is worth nothing, and picked
@@ -223,6 +229,14 @@ Three layers, each adding one thing the previous cannot do:
223
229
  browser closes, and put back on the next launch. A cookie the browser already holds is
224
230
  never overwritten from the jar — a rotation happened in the live browser, and the jar is
225
231
  the older truth.
232
+ - **`chromecookies.js`** reads a browser's own cookie store off disk, in memory only: copy `Cookies`
233
+ (with `-wal`/`-shm`, since the browser holds the lock) to a temp dir, then `node:sqlite` or
234
+ `/usr/bin/sqlite3 -readonly`. Values are sealed with the browser's Safe Storage password from the
235
+ macOS keychain: PBKDF2-SHA1 (1003 iterations, `saltysalt`) → AES-128-CBC with a space IV, and the
236
+ SHA-256 host prefix newer Chromium builds prepend is checked and stripped. Values it cannot open
237
+ (Chrome 127+ app-bound `v20`) are counted, never guessed; cookie values are never returned to the
238
+ model or logged. On macOS the process needs Full Disk Access for the profile root, and the tool says
239
+ exactly that instead of pretending there is no profile.
226
240
  - **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
227
241
  back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
228
242
  dropped. `list` is a directory read, so it costs nothing while the browser is down.
package/docs/BENCH.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 读页先读正文(0.44.0,本地页面,`scripts/bench.mjs`)
2
+
3
+ 同一台机器、同一档预算。旧法是按页面顺序把预算灌满的整页读。
4
+
5
+ | 页面 | 旧法字符 | 新法字符 | 省下 | 答案 |
6
+ | --- | --- | --- | --- | --- |
7
+ | 九成是菜单、答案在正文(900 字预算) | 1500 | 88 | 94% | 旧法读不到,新法读到 |
8
+ | 定价表(900 字预算) | 679 | 109 | 84% | 价格与列名同一行 |
9
+
10
+ - `read-noise` 2 ms、`read-table` 2 ms,两项预算 300 ms 全过(26 项全过)
11
+ - 表格行形如 `Plan: Pro | Price per month: $84`,列名来自表头,一行就是一条记录
12
+ - 标题路径 `Plans \u203a Trial` 印在证据上方;与最近标题相同、或超过 90 字时退回最近标题
13
+ - 既有数字没有回退:`read-answer` 90 字 / 92%、`read-miss` 438 字 / 79%、`read-tabs` 90 字 / 90%(与 0.43.0 相同)
14
+
15
+ ## 一次读穿过登录墙(0.43.0,本地页面,`scripts/bench.mjs`)
16
+
17
+ 同一页:带 cookie 时是账户页,不带时是一个只有密码框的登录页。留一个登录(vault),清掉页面的 cookie 与 localStorage 后读它。
18
+
19
+ | 路径 | 调用 | 毫秒 | 字 |
20
+ | --- | --- | --- | --- |
21
+ | 撞墙后读→list→use→再读 | 4 | 217 | 139 |
22
+ | 撞墙后只读一次 | 1 | 112 | 220 |
23
+
24
+ - 一路上 `recovered: true`:内容来自恢复后的页面(「forty two days」),不是墙上的文字
25
+ - 两个名字都覆盖这个站时:`namesBoth: true`、`guessed: false`——名字都列出来,一个都不放回去
26
+ - 导入本机浏览器登录(合成库,真实钥匙串路径):`login-import` 1 次调用 / 111 毫秒 / 1 个 cookie 封存 / 回复里不含任何 cookie 值
27
+ - 预算:`login-wall: 1600 ms`、`login-import: 700 ms`,24 项全过
28
+
1
29
  ## 答案在另一个页签(0.42.0)
2
30
 
3
31
  两个页签:前台一页在讲别的事,后台另一页才有答案。左边是旧做法——切过去、再读一遍;
@@ -9,7 +37,7 @@
9
37
  | 回来的字符数 | 879 | 90 |
10
38
  | 省下 | — | 90% |
11
39
  | 出处 | 要自己记 | 每段标页签标题 |
12
- | 读这一趟 | 7 ms | 6 ms |
40
+ | 读这一趟 | 7 ms | 5–6 ms |
13
41
 
14
42
  跨页只在当前页答不上来时发生,正常命中路径的延迟不变(`read-answer` 仍是 1172 → 90 字)。
15
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.42.0",
3
+ "version": "0.44.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",
@@ -19,6 +19,7 @@
19
19
  "review.js",
20
20
  "media.js",
21
21
  "vault.js",
22
+ "chromecookies.js",
22
23
  "client.js",
23
24
  "locale/*.json",
24
25
  "cordis.patch.yml",
@@ -50,7 +51,7 @@
50
51
  "dsh": ">=0.2.0"
51
52
  },
52
53
  "scripts": {
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",
54
+ "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/blocks.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",
54
55
  "doctor": "node scripts/doctor.mjs",
55
56
  "breadth": "node scripts/breadth.mjs",
56
57
  "live": "node scripts/live.mjs",
package/read.js CHANGED
@@ -1124,10 +1124,12 @@ export async function findOnPage(page, query, options = {}) {
1124
1124
  }
1125
1125
 
1126
1126
  /**
1127
- * Collect the page as blocks of prose, each with the heading it sits under and
1128
- * how much of it is links. Navigation, sidebars and footers are mostly links;
1129
- * Readability's oldest signal is that such a block is not the content, and it
1130
- * costs one number to carry instead of a guess.
1127
+ * Collect the page as blocks of prose. Each block carries the trail of headings
1128
+ * it sits under, how much of it is links, and one number for how much of it
1129
+ * reads like written content: navigation, sidebars and footers are mostly links
1130
+ * and mostly short, which is Readability's oldest signal. A table row is one
1131
+ * block, with its column names written into it, so a fact that lives in a table
1132
+ * costs a line instead of a page.
1131
1133
  * @param {number} budget - characters of block text to collect.
1132
1134
  * @returns {string} an expression to evaluate.
1133
1135
  */
@@ -1136,32 +1138,66 @@ export function blocks(budget) {
1136
1138
  const root = document.querySelector('article, main, [role="main"]') || document.body
1137
1139
  if (!root) return JSON.stringify({ blocks: [] })
1138
1140
  const limit = ${budget}
1139
- const BLOCK = new Set(['P', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1141
+ const guard = limit * 6
1142
+ const BLOCK = new Set(['P', 'LI', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1140
1143
  const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA'])
1141
1144
  const out = []
1142
1145
  const seen = new Set()
1146
+ const stack = []
1143
1147
  let chars = 0
1144
- let heading = ''
1145
1148
  const flatten = (text) => String(text ?? '').replace(/\\s+/g, ' ').trim()
1149
+ const trail = () => stack.filter((one) => one !== '').join(' \\u203a ')
1150
+ const said = () => stack.filter((one) => one !== '').pop() ?? ''
1151
+ /* How much of a block reads like prose rather than a list of links: ends of
1152
+ sentences per word, plus a length floor. One number, no dictionary. */
1153
+ const written = (text) => {
1154
+ const words = text.toLowerCase().match(/[a-z0-9]+|[\\u3400-\\u9fff]/g) ?? []
1155
+ if (words.length === 0) return 0
1156
+ const ends = text.match(/[.!?\\u3002\\uff01\\uff1f;\\uff1b]/g) ?? []
1157
+ return Math.min(1, (ends.length / words.length) * 12)
1158
+ }
1159
+ const quality = (text, density) => Math.round((0.45 * (1 - density) + 0.35 * written(text) + 0.2 * Math.min(1, text.length / 180)) * 100) / 100
1146
1160
  const push = (el) => {
1147
- if (chars >= limit) return
1161
+ if (chars >= guard) return
1148
1162
  const text = flatten(el.innerText ?? el.textContent ?? '')
1149
1163
  if (text === '' || text.length > 4000) return
1150
1164
  let linkChars = 0
1151
1165
  for (const link of el.querySelectorAll('a')) linkChars += flatten(link.innerText ?? '').length
1152
1166
  if (el.tagName === 'A') linkChars += text.length
1153
- const density = text.length === 0 ? 0 : Math.min(1, linkChars / text.length)
1154
- out.push({ h: heading, t: text, d: Math.round(density * 100) / 100 })
1167
+ const density = Math.min(1, linkChars / text.length)
1168
+ /* A block that is almost all link and short is a menu item, not a sentence;
1169
+ carrying it only spends the budget. */
1170
+ if (density >= 0.8 && text.length < 80) return
1171
+ const path = trail()
1172
+ out.push({ h: said(), p: path, t: text, d: Math.round(density * 100) / 100, q: quality(text, density) })
1155
1173
  chars += text.length
1156
1174
  }
1175
+ const row = (tr) => {
1176
+ if (chars >= guard) return
1177
+ const cells = Array.from(tr.children).filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
1178
+ const words = cells.map((cell) => flatten(cell.innerText ?? ''))
1179
+ if (words.filter((one) => one !== '').length < 2) return
1180
+ const table = tr.closest('table')
1181
+ const first = table === null ? null : table.querySelector('tr')
1182
+ const head = first === null || first === tr ? [] : Array.from(first.children).map((cell) => flatten(cell.innerText ?? ''))
1183
+ const line = words.map((one, at) => (head[at] === undefined || head[at] === '' || head[at] === one ? one : head[at] + ': ' + one)).join(' | ')
1184
+ out.push({ h: said(), p: trail(), t: line, d: 0, q: 0.95, r: 1 })
1185
+ chars += line.length
1186
+ }
1157
1187
  const walk = (el) => {
1158
- if (chars >= limit || el === null) return
1188
+ if (el === null || chars >= guard) return
1159
1189
  const tag = el.tagName
1160
1190
  if (SKIP.has(tag) || el.hidden === true || el.getAttribute?.('aria-hidden') === 'true') return
1161
1191
  const style = getComputedStyle(el)
1162
1192
  if (style.display === 'none' || style.visibility === 'hidden') return
1163
1193
  if (/^H[1-6]$/.test(tag)) {
1164
- heading = flatten(el.innerText ?? '')
1194
+ const level = Number(tag.slice(1))
1195
+ stack.length = level
1196
+ stack[level - 1] = flatten(el.innerText ?? '')
1197
+ return
1198
+ }
1199
+ if (tag === 'TR') {
1200
+ if (!seen.has(el)) { seen.add(el); row(el) }
1165
1201
  return
1166
1202
  }
1167
1203
  if (BLOCK.has(tag)) {
@@ -1172,7 +1208,23 @@ export function blocks(budget) {
1172
1208
  }
1173
1209
  walk(root)
1174
1210
  const title = document.title
1175
- return JSON.stringify({ title, blocks: out })
1211
+ /* The page arrives in document order, so a menu at the top would spend the
1212
+ whole budget before the article starts. Keep the blocks that read like
1213
+ content, still in page order, until the budget is spent. */
1214
+ let kept = out
1215
+ const total = out.reduce((sum, block) => sum + block.t.length, 0)
1216
+ if (total > limit) {
1217
+ const byQuality = out.map((block, at) => ({ block, at })).sort((a, b) => b.block.q - a.block.q || a.at - b.at)
1218
+ const keep = new Set()
1219
+ let used = 0
1220
+ for (const one of byQuality) {
1221
+ if (used + one.block.t.length > limit) continue
1222
+ keep.add(one.at)
1223
+ used += one.block.t.length
1224
+ }
1225
+ kept = out.filter((block, at) => keep.has(at))
1226
+ }
1227
+ return JSON.stringify({ title, blocks: kept })
1176
1228
  })()`
1177
1229
  }
1178
1230
 
@@ -1217,6 +1269,20 @@ function brief(value) {
1217
1269
  return body.length > 260 ? `${body.slice(0, 260).replace(/\s+\S*$/, '')}…` : body
1218
1270
  }
1219
1271
 
1272
+ /**
1273
+ * The trail of headings a block sits under, when it says more than the nearest
1274
+ * one: "Pricing › Refunds" tells a caller where a fact lives, which a bare
1275
+ * sentence cannot. Kept short so location never costs more than it explains.
1276
+ * @param {{h?: string, p?: string}} block - the block.
1277
+ * @returns {string} what to print above the passage.
1278
+ */
1279
+ export function trailOf(block) {
1280
+ const path = String(block?.p ?? '')
1281
+ const near = String(block?.h ?? '')
1282
+ if (path === '' || path === near || path.length > 90) return near
1283
+ return path
1284
+ }
1285
+
1220
1286
  /**
1221
1287
  * Rank blocks against a query the way a search engine ranks a passage: BM25,
1222
1288
  * with the heading counted as a place a term can match. Then read only the
@@ -1228,7 +1294,7 @@ function brief(value) {
1228
1294
  export function rank(blocks, query) {
1229
1295
  const asked = terms(query)
1230
1296
  if (asked.length === 0) return []
1231
- const docs = blocks.map((block) => ({ block, terms: terms(`${block.h ?? ''} ${block.t}`) }))
1297
+ const docs = blocks.map((block) => ({ block, terms: terms(`${block.p ?? ''} ${block.h ?? ''} ${block.t}`) }))
1232
1298
  const average = docs.reduce((sum, one) => sum + one.terms.length, 0) / Math.max(1, docs.length)
1233
1299
  const seen = new Map()
1234
1300
  for (const one of docs) for (const term of new Set(one.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
@@ -1250,6 +1316,9 @@ export function rank(blocks, query) {
1250
1316
  /* A block that is nearly all link is a menu: it matches words without
1251
1317
  saying anything about them. */
1252
1318
  score *= 1 - 0.8 * Number(one.block.d ?? 0)
1319
+ /* Prose that answers a question outranks a fragment of navigation that
1320
+ happens to contain the same words; the quality number says which. */
1321
+ score *= 0.85 + 0.15 * Number(one.block.q ?? 0.5)
1253
1322
  if (score > 0) scored.push({ block: one.block, score })
1254
1323
  }
1255
1324
  scored.sort((a, b) => b.score - a.score)
@@ -1297,7 +1366,7 @@ export function evidence(blocks, query, options = {}) {
1297
1366
  const near = rank(blocks, query).slice(0, Math.max(1, Math.min(60, Number(options.blocks ?? 40))))
1298
1367
  const units = []
1299
1368
  for (const row of near) {
1300
- const heading = String(row.block.h ?? '')
1369
+ const heading = trailOf(row.block)
1301
1370
  const body = String(row.block.t ?? '')
1302
1371
  const parts = sentences(body)
1303
1372
  for (let step = 0; step < parts.length; step += 1) {
@@ -1339,8 +1408,9 @@ export function evidence(blocks, query, options = {}) {
1339
1408
  both say "this is the right place", so they lift their sentences. */
1340
1409
  const headingHit = asked.filter((term) => terms(unit.heading).includes(term)).length
1341
1410
  /* A menu sentence can match the words without saying anything about them;
1342
- the paragraph's link density is what tells the two apart. */
1343
- const linkish = 1 - 0.8 * Number(unit.block?.d ?? 0)
1411
+ the paragraph's link density and how much it reads like prose tell the
1412
+ two apart, and a table row is data rather than a sentence to distrust. */
1413
+ const linkish = (1 - 0.8 * Number(unit.block?.d ?? 0)) * (unit.block?.r === 1 ? 1 : 0.9 + 0.1 * Number(unit.block?.q ?? 0.5))
1344
1414
  unit.score = score * linkish * (1 + 0.25 * headingHit) * (1 + 0.5 * (unit.prior > 0 ? 1 : 0))
1345
1415
  }
1346
1416
  /* Below a fifth of the best sentence, a match is coincidence rather than
@@ -1424,7 +1494,7 @@ export async function readRelevant(page, query, options = {}) {
1424
1494
  for (const row of pieces.slice(0, limit)) {
1425
1495
  const body = String(row.block.t ?? '')
1426
1496
  if (used + body.length > budget) continue
1427
- const heading = String(row.block.h ?? '')
1497
+ const heading = trailOf(row.block)
1428
1498
  if (heading !== '' && heading !== lastHeading) { lines.push(`## ${heading}`); lastHeading = heading }
1429
1499
  lines.push(body)
1430
1500
  used += body.length
package/tools.js CHANGED
@@ -16,7 +16,8 @@ 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
+ import { dropLogin, listLogins, readLogin, saveLogin, signInsFor, vaultState } from './vault.js'
20
+ import { readBrowserCookies, siteOf } from './chromecookies.js'
20
21
  import { readPage, renderPage, readArticle, readRelevant, readTabs, findOnPage, locate, locateFirst, FRAME_BASE, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, clickInDom, writeInDom, REF_GONE } from './read.js?v=1'
21
22
  import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
22
23
 
@@ -966,6 +967,106 @@ async function evaluate(page, expression, route, raceMs = 2000) {
966
967
  return result?.value
967
968
  }
968
969
 
970
+ /**
971
+ * A password field and little else is the one shape of page that can never
972
+ * answer a read: it is the site asking who this is, not the site's content.
973
+ * @param {any} page - sidecar page.
974
+ * @returns {Promise<boolean>} whether the page is asking for a sign-in.
975
+ */
976
+ async function signInWall(page) {
977
+ const raw = await evaluate(
978
+ page,
979
+ '(() => { try { if (document.querySelector("input[type=password]") === null) return "0"; const text = document.body === null ? "" : String(document.body.innerText); return text.length > 3000 ? "0" : "1" } catch { return "0" } })()',
980
+ null,
981
+ 2000,
982
+ )
983
+ return String(raw ?? '') === '1'
984
+ }
985
+
986
+ /**
987
+ * Puts a kept sign-in back on the page: cookies first, then the local storage of
988
+ * the sites it kept, which is where some sites keep the sign-in itself.
989
+ * @param {any} page - sidecar page.
990
+ * @param {any} held - what the vault opened.
991
+ * @returns {Promise<{put: number, stored: number}>} cookies and storage entries written.
992
+ */
993
+ async function applySignIn(page, held) {
994
+ let put = 0
995
+ for (const cookie of (Array.isArray(held?.cookies) ? held.cookies : []).slice(0, 400)) {
996
+ const domain = String(cookie?.domain ?? '').replace(/^\./u, '')
997
+ if (domain === '' || cookie?.name === undefined) continue
998
+ try {
999
+ await page.setCookie({
1000
+ name: String(cookie.name),
1001
+ value: String(cookie.value ?? ''),
1002
+ domain,
1003
+ path: String(cookie.path ?? '/'),
1004
+ secure: cookie.secure === true,
1005
+ httpOnly: cookie.httpOnly === true,
1006
+ sameSite: cookie.sameSite,
1007
+ expires: cookie.session === true ? undefined : Number(cookie.expires) || undefined,
1008
+ })
1009
+ put += 1
1010
+ } catch {
1011
+ /* one cookie a site refuses is not a reason to stop */
1012
+ }
1013
+ }
1014
+ let stored = 0
1015
+ for (const row of (Array.isArray(held?.storage) ? held.storage : []).slice(0, 4)) stored += await restoreStorage(page, row)
1016
+ return { put, stored }
1017
+ }
1018
+
1019
+ /**
1020
+ * Waits, briefly, for a navigation to actually land: the page reports its own
1021
+ * address and load state, so a read after it never runs against the page being
1022
+ * left behind.
1023
+ * @param {any} page - sidecar page.
1024
+ * @param {string} url - where it was sent.
1025
+ * @returns {Promise<boolean>} whether it got there.
1026
+ */
1027
+ async function arrived(page, url, was) {
1028
+ for (let i = 0; i < 20; i += 1) {
1029
+ const raw = await evaluate(page, 'JSON.stringify([location.href, document.readyState, performance.timeOrigin])', null, 1500)
1030
+ if (typeof raw === 'string' && raw !== '') {
1031
+ try {
1032
+ const [here, state, born] = JSON.parse(raw)
1033
+ if (String(here) === String(url) && String(state) === 'complete' && Number(born) !== Number(was)) return true
1034
+ } catch {
1035
+ /* a page mid-navigation can answer with half a document; ask again */
1036
+ }
1037
+ }
1038
+ await new Promise((settle) => setTimeout(settle, 100))
1039
+ }
1040
+ return false
1041
+ }
1042
+
1043
+ /**
1044
+ * A sign-in form answers nothing, and the sign-in may already be kept. Putting
1045
+ * it back and reading once is one call where naming the account, using it and
1046
+ * reading again is three. Never guesses: one site with one kept sign-in only.
1047
+ * @param {any} page - sidecar page.
1048
+ * @param {string} url - the address that showed the form.
1049
+ * @param {boolean} [wall] - already known to be a sign-in form.
1050
+ * @returns {Promise<{name: string, note: string, wall: boolean}>} the account put back, what to say when none was, and whether a form is what is there.
1051
+ */
1052
+ async function signInFor(page, url, wall) {
1053
+ if (page === null || page === undefined) return { name: '', note: '', wall: false }
1054
+ const form = wall === undefined ? await signInWall(page) : wall === true
1055
+ if (!form) return { name: '', note: '', wall: false }
1056
+ const matches = signInsFor(String(url ?? page.url ?? ''))
1057
+ const names = matches.map((one) => one.name)
1058
+ if (names.length === 0) return { name: '', note: '', wall: true }
1059
+ if (names.length > 1) return { name: '', note: ` (${String(names.length)} saved sign-ins cover this site: ${names.join(', ')} — browser_login {action:"use", name:"…"} picks one)`, wall: true }
1060
+ const held = readLogin(names[0])
1061
+ if (held === null) return { name: '', note: '', wall: true }
1062
+ const { put } = await applySignIn(page, held)
1063
+ if (put === 0) return { name: '', note: ` (the saved sign-in "${names[0]}" had nothing this site accepts)`, wall: true }
1064
+ const born = await evaluate(page, 'performance.timeOrigin', null, 1500).catch(() => undefined)
1065
+ await page.navigate(String(url ?? page.url ?? held.url ?? ''))
1066
+ await arrived(page, String(url ?? page.url ?? held.url ?? ''), born)
1067
+ return { name: names[0], note: '', wall: true }
1068
+ }
1069
+
969
1070
  /**
970
1071
  * The local storage a page is holding, per origin: some sites keep the sign-in
971
1072
  * itself there and cookies alone would come back signed out.
@@ -2234,6 +2335,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2234
2335
  query: { type: 'string', description: 'What you want to know. With a query, only the passages that answer it come back, ranked — the cheapest way to read a long page. Without one, the whole page comes back.' },
2235
2336
  force: { type: 'boolean', description: 'Read again even if the page has not changed since this chat last read it.' },
2236
2337
  across: { type: 'boolean', description: 'When this tab has no answer, also read the other tabs this chat holds and answer from whichever has it. Default true; pass false to stay on this one.' },
2338
+ signin: { type: 'boolean', description: 'When the page is showing a sign-in form and the vault holds exactly one sign-in for the site, put it back and read once. Default true; pass false to read the form itself.' },
2237
2339
  },
2238
2340
  additionalProperties: false,
2239
2341
  },
@@ -2264,7 +2366,29 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2264
2366
  /* The answer may be in the tab behind this one. Reading every tab
2265
2367
  this chat holds is one call, where switching and reading again is
2266
2368
  two, so the miss tries the others before it gives up. */
2369
+ /* A sign-in form is the one page that can never answer: it is asking
2370
+ who this is. If the vault holds exactly one sign-in for the site,
2371
+ put it back and read once, where looking it up, using it and
2372
+ reading again is three calls. */
2373
+ const wall = await signInWall(page)
2267
2374
  let note = ''
2375
+ if (wall && args?.signin !== false) {
2376
+ const put = await signInFor(page, String(found.url ?? ''), true)
2377
+ if (put.name !== '') {
2378
+ const after = await readRelevant(page, asked, { maxChars: ceiling })
2379
+ if (after.text !== '') {
2380
+ return `${after.url}\n# ${after.title === '' ? '(untitled)' : after.title}\n\nsigned back in as "${put.name}" before reading (the page was showing a sign-in form):\n\n${after.text}\n\n(${String(after.matched)} of ${String(after.total)} blocks, ranked for ${JSON.stringify(asked)})`
2381
+ }
2382
+ note = ` (put the saved sign-in "${put.name}" back, and the page still does not answer)`
2383
+ } else {
2384
+ note = put.note
2385
+ }
2386
+ }
2387
+ /* A page still showing a sign-in form is not worth remembering as
2388
+ read: the same text on the next call would hide that the form is
2389
+ all that is there, and a sign-in that arrives later would never be
2390
+ tried. Only the miss on a real page is kept. */
2391
+ const save = (text) => (wall ? text : keep(text))
2268
2392
  if (args?.across !== false) {
2269
2393
  const others = await sidecar.elsewhere(currentSession()).catch(() => [])
2270
2394
  if (others.length > 0) {
@@ -2291,11 +2415,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2291
2415
  const body = closest
2292
2416
  .map((row) => `${row.heading === '' ? '' : `## ${String(row.heading)}\n`}${String(row.text)}`)
2293
2417
  .join('\n\n')
2294
- return keep(`${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\nnothing directly answers ${JSON.stringify(asked)} (${String(found.total)} blocks read).${note} The closest passages:\n\n${body}\n\nask for the whole page with browser_view, or try other words.`)
2418
+ return save(`${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\nnothing directly answers ${JSON.stringify(asked)} (${String(found.total)} blocks read).${note} The closest passages:\n\n${body}\n\nask for the whole page with browser_view, or try other words.`)
2295
2419
  }
2296
2420
  const whole = await readArticle(page, { maxChars: ceiling })
2297
2421
  if (whole.fault === undefined && whole.text !== '') {
2298
- return keep(`${whole.url}\n# ${whole.title === '' ? '(untitled)' : whole.title}\n\nnothing here answers ${JSON.stringify(asked)} (${String(found.total)} blocks read).${note} so the whole page follows:\n\n${whole.text}${whole.truncated ? `\n\n(cut at ${String(whole.chars)} characters; raise max_chars for more)` : ''}`)
2422
+ return save(`${whole.url}\n# ${whole.title === '' ? '(untitled)' : whole.title}\n\nnothing here answers ${JSON.stringify(asked)} (${String(found.total)} blocks read).${note} so the whole page follows:\n\n${whole.text}${whole.truncated ? `\n\n(cut at ${String(whole.chars)} characters; raise max_chars for more)` : ''}`)
2299
2423
  }
2300
2424
  return `${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\nnothing here answers ${JSON.stringify(asked)} (${String(found.total)} blocks read).${note} Call browser_view for the whole page, or try other words.`
2301
2425
  }
@@ -2306,6 +2430,16 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2306
2430
  if (read.text === '') {
2307
2431
  const pdf = await pdfNote(page)
2308
2432
  if (pdf !== '') return `${read.url}\n# ${read.title === '' ? '(untitled)' : read.title}${pdf}`
2433
+ if (args?.signin !== false) {
2434
+ const put = await signInFor(page, String(read.url ?? ''))
2435
+ if (put.name !== '') {
2436
+ const again = await readArticle(page, { maxChars: ceiling })
2437
+ if (again.text !== '') {
2438
+ const tail2 = again.truncated ? `\n\n(cut at ${String(again.chars)} characters; raise max_chars for more)` : ''
2439
+ return `${again.url}\n# ${again.title === '' ? '(untitled)' : again.title}\n\nsigned back in as "${put.name}" before reading (the page was showing a sign-in form):\n\n${again.text}${tail2}`
2440
+ }
2441
+ }
2442
+ }
2309
2443
  return `${read.url}\n# ${read.title === '' ? '(untitled)' : read.title}\n\nnothing to read here: the page draws its content, or it is still loading. Call browser_view to see what is on screen.`
2310
2444
  }
2311
2445
  const head = `${read.url}\n# ${read.title === '' ? '(untitled)' : read.title}`
@@ -3145,12 +3279,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3145
3279
 
3146
3280
  {
3147
3281
  name: 'browser_login', // ui-copy-ignore
3148
- 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".',
3282
+ 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, "import" takes a sign-in that is already in Chrome on this Mac and keeps it under a name. 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".',
3149
3283
  parameters: { // ui-copy-ignore
3150
3284
  type: 'object',
3151
3285
  properties: {
3152
- action: { type: 'string', enum: ['list', 'save', 'use', 'drop'], description: 'list, save, use or drop.' },
3153
- name: { type: 'string', description: 'Account name, for example "work" or "personal". Needed by save, use and drop.' },
3286
+ action: { type: 'string', enum: ['list', 'save', 'use', 'drop', 'import'], description: 'list, save, use, drop or import.' },
3287
+ name: { type: 'string', description: 'Account name, for example "work" or "personal". Needed by save, use, drop and import.' },
3288
+ browser: { type: 'string', enum: ['chrome', 'brave', 'edge', 'chromium'], description: 'Where import looks for the sign-in. Default chrome.' },
3154
3289
  },
3155
3290
  required: ['action'], // ui-copy-ignore
3156
3291
  additionalProperties: false,
@@ -3179,6 +3314,25 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3179
3314
  if (action === 'drop') {
3180
3315
  return { text: dropLogin(name) ? `Forgot "${name}".` : `Nothing saved as "${name}".` }
3181
3316
  }
3317
+ if (action === 'import') {
3318
+ if (name === '') return { text: 'Name the account: browser_login {action:"import", name:"work"}.' }
3319
+ const page = await sidecar.ensure({ session: currentSession() })
3320
+ const url = String(page.url ?? '')
3321
+ if (!/^https?:/u.test(url)) return { text: 'Open the site first, then import its sign-in.' }
3322
+ let found
3323
+ try {
3324
+ found = readBrowserCookies({ browser: String(args?.browser ?? 'chrome'), url })
3325
+ } catch (error) {
3326
+ return { text: String(error?.message ?? error) }
3327
+ }
3328
+ const kept = saveLogin(name, { cookies: found.cookies, storage: [], url })
3329
+ const was = await evaluate(page, 'performance.timeOrigin', null, 1500).catch(() => undefined)
3330
+ await applySignIn(page, readLogin(name))
3331
+ await page.navigate(url)
3332
+ await arrived(page, url, was)
3333
+ const skipped = found.sealed === 0 ? '' : ', ' + String(found.sealed) + ' could not be opened'
3334
+ return { text: 'Imported "' + kept.name + '" from ' + found.label + ': ' + String(kept.cookieCount) + ' cookies for ' + siteOf(url) + skipped + ', sealed on disk. The page is open again.' }
3335
+ }
3182
3336
  if (action === 'save') {
3183
3337
  const page = await sidecar.ensure({ session: currentSession() })
3184
3338
  const cookies = await page.cookies()
@@ -3191,35 +3345,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3191
3345
  const held = readLogin(name)
3192
3346
  if (held === null) return { text: `Nothing saved as "${name}". browser_login {action:"list"} shows what is kept.` }
3193
3347
  const page = await sidecar.ensure({ session: currentSession() })
3194
- let put = 0
3195
- for (const cookie of (Array.isArray(held.cookies) ? held.cookies : []).slice(0, 400)) {
3196
- const domain = String(cookie?.domain ?? '').replace(/^\./u, '')
3197
- if (domain === '' || cookie?.name === undefined) continue
3198
- try {
3199
- await page.setCookie({
3200
- name: String(cookie.name),
3201
- value: String(cookie.value ?? ''),
3202
- domain,
3203
- path: String(cookie.path ?? '/'),
3204
- secure: cookie.secure === true,
3205
- httpOnly: cookie.httpOnly === true,
3206
- sameSite: cookie.sameSite,
3207
- expires: cookie.session === true ? undefined : Number(cookie.expires) || undefined,
3208
- })
3209
- put += 1
3210
- } catch {
3211
- /* one cookie a site refuses is not a reason to stop */
3212
- }
3213
- }
3214
- let stored = 0
3215
- for (const row of (Array.isArray(held.storage) ? held.storage : []).slice(0, 4)) stored += await restoreStorage(page, row)
3348
+ const { put, stored } = await applySignIn(page, held)
3216
3349
  const back = String(held.url ?? '')
3217
3350
  if (back !== '') await page.navigate(back)
3218
3351
  else await page.reload()
3219
3352
  const said = stored === 0 ? '' : `, ${stored} local storage entries`
3220
3353
  return { text: `Signed in as "${name}": ${put} cookies${said}, page reopened.` }
3221
3354
  }
3222
- return { text: 'Pass action "list", "save", "use" or "drop".' }
3355
+ return { text: 'Pass action "list", "save", "use", "drop" or "import".' }
3223
3356
  },
3224
3357
  },
3225
3358
 
package/vault.js CHANGED
@@ -305,6 +305,51 @@ export function dropLogin(name) {
305
305
  return true
306
306
  }
307
307
 
308
+ /** @param {string} value - an address. @returns {string} its host, without a leading www. */ // ui-copy-ignore
309
+ function hostOf(value) {
310
+ try {
311
+ return new URL(String(value)).hostname.replace(/^www\./u, '').toLowerCase()
312
+ } catch {
313
+ return ''
314
+ }
315
+ }
316
+
317
+ /** @param {string} host - the page. @param {string} domain - a cookie domain or an address host. @returns {boolean} whether the page is on it. */ // ui-copy-ignore
318
+ function onSite(host, domain) { // ui-copy-ignore
319
+ const bare = String(domain ?? '').replace(/^\./u, '').toLowerCase()
320
+ if (bare === '' || host === '') return false // ui-copy-ignore
321
+ return host === bare || host.endsWith(`.${bare}`) // ui-copy-ignore
322
+ }
323
+
324
+ /**
325
+ * The kept sign-ins that could sign this address in: a cookie domain of the
326
+ * page's host or its parent, or the very site it was saved on. Names and counts // ui-copy-ignore
327
+ * only — the cookies stay sealed until one of them is actually put back.
328
+ * @param {string} url - the page asking.
329
+ * @returns {Array<{name: string, savedAt: number, cookieCount: number}>} matches, newest first.
330
+ */
331
+ export function signInsFor(url) {
332
+ const host = hostOf(url) // ui-copy-ignore
333
+ if (host === '') return [] // ui-copy-ignore
334
+ const dir = loginDir()
335
+ let names = []
336
+ try {
337
+ names = readdirSync(dir).filter((name) => name.endsWith('.json'))
338
+ } catch {
339
+ return []
340
+ }
341
+ const rows = []
342
+ for (const name of names) {
343
+ const held = readSealed(join(dir, name))
344
+ if (held === null || typeof held !== 'object') continue
345
+ const cookies = Array.isArray(held.cookies) ? held.cookies : []
346
+ const covers = cookies.some((cookie) => onSite(host, String(cookie?.domain ?? ''))) || onSite(host, hostOf(held.url)) // ui-copy-ignore
347
+ if (!covers) continue
348
+ rows.push({ name: String(held.name ?? name.replace(/\.json$/u, '')), savedAt: Number(held.savedAt ?? 0), cookieCount: cookies.length })
349
+ }
350
+ return rows.sort((a, b) => b.savedAt - a.savedAt)
351
+ }
352
+
308
353
  /**
309
354
  * How big the vault is, and whether the key is outside it: the two facts a
310
355
  * person wants when deciding whether a sign-in is safe to leave here.