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 +1 -1
- package/README.zh.md +1 -1
- package/chromecookies.js +310 -0
- package/docs/ARCHITECTURE.md +17 -3
- package/docs/BENCH.md +29 -1
- package/package.json +3 -2
- package/read.js +87 -17
- package/tools.js +162 -29
- package/vault.js +45 -0
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` 时不再多说。
|
package/chromecookies.js
ADDED
|
@@ -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
|
+
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
172
|
-
|
|
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.
|
|
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
|
|
1128
|
-
* how much of it is links
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
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
|
|
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 >=
|
|
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 =
|
|
1154
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1343
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
3153
|
-
name: { type: 'string', description: 'Account name, for example "work" or "personal". Needed by save, use and
|
|
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
|
-
|
|
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 "
|
|
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.
|