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 +1 -1
- package/README.zh.md +1 -1
- package/chromecookies.js +310 -0
- package/docs/ARCHITECTURE.md +8 -0
- package/docs/BENCH.md +15 -1
- package/package.json +2 -1
- 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
|
@@ -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.
|
|
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
|
|
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.
|