dsh-inner-browser-use 0.42.0 → 0.43.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
+ }
@@ -223,6 +223,14 @@ Three layers, each adding one thing the previous cannot do:
223
223
  browser closes, and put back on the next launch. A cookie the browser already holds is
224
224
  never overwritten from the jar — a rotation happened in the live browser, and the jar is
225
225
  the older truth.
226
+ - **`chromecookies.js`** reads a browser's own cookie store off disk, in memory only: copy `Cookies`
227
+ (with `-wal`/`-shm`, since the browser holds the lock) to a temp dir, then `node:sqlite` or
228
+ `/usr/bin/sqlite3 -readonly`. Values are sealed with the browser's Safe Storage password from the
229
+ macOS keychain: PBKDF2-SHA1 (1003 iterations, `saltysalt`) → AES-128-CBC with a space IV, and the
230
+ SHA-256 host prefix newer Chromium builds prepend is checked and stripped. Values it cannot open
231
+ (Chrome 127+ app-bound `v20`) are counted, never guessed; cookie values are never returned to the
232
+ model or logged. On macOS the process needs Full Disk Access for the profile root, and the tool says
233
+ exactly that instead of pretending there is no profile.
226
234
  - **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
227
235
  back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
228
236
  dropped. `list` is a directory read, so it costs nothing while the browser is down.
package/docs/BENCH.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 一次读穿过登录墙(0.43.0,本地页面,`scripts/bench.mjs`)
2
+
3
+ 同一页:带 cookie 时是账户页,不带时是一个只有密码框的登录页。留一个登录(vault),清掉页面的 cookie 与 localStorage 后读它。
4
+
5
+ | 路径 | 调用 | 毫秒 | 字 |
6
+ | --- | --- | --- | --- |
7
+ | 撞墙后读→list→use→再读 | 4 | 217 | 139 |
8
+ | 撞墙后只读一次 | 1 | 112 | 220 |
9
+
10
+ - 一路上 `recovered: true`:内容来自恢复后的页面(「forty two days」),不是墙上的文字
11
+ - 两个名字都覆盖这个站时:`namesBoth: true`、`guessed: false`——名字都列出来,一个都不放回去
12
+ - 导入本机浏览器登录(合成库,真实钥匙串路径):`login-import` 1 次调用 / 111 毫秒 / 1 个 cookie 封存 / 回复里不含任何 cookie 值
13
+ - 预算:`login-wall: 1600 ms`、`login-import: 700 ms`,24 项全过
14
+
1
15
  ## 答案在另一个页签(0.42.0)
2
16
 
3
17
  两个页签:前台一页在讲别的事,后台另一页才有答案。左边是旧做法——切过去、再读一遍;
@@ -9,7 +23,7 @@
9
23
  | 回来的字符数 | 879 | 90 |
10
24
  | 省下 | — | 90% |
11
25
  | 出处 | 要自己记 | 每段标页签标题 |
12
- | 读这一趟 | 7 ms | 6 ms |
26
+ | 读这一趟 | 7 ms | 5–6 ms |
13
27
 
14
28
  跨页只在当前页答不上来时发生,正常命中路径的延迟不变(`read-answer` 仍是 1172 → 90 字)。
15
29
 
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.43.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",
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.