dsh-inner-browser-use 0.41.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 +2 -2
- package/README.zh.md +2 -2
- package/chromecookies.js +310 -0
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/BENCH.md +29 -0
- package/package.json +2 -1
- package/read.js +63 -5
- package/sidecar.js +45 -0
- package/tools.js +187 -31
- package/vault.js +45 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ live picture of that browser, and your own cursor keeps working on the picture.
|
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
|
|
14
14
|
| `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
|
|
15
|
-
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the sentences that answer it come back: BM25 over Readability-style blocks, then per-sentence scoring with heading, position and link-density priors, idf-weighted coverage, MMR de-duplication and a character budget — 92% fewer tokens on a long page in the bench. A question the page cannot answer returns the nearest passages and says so instead of pasting the page (79% fewer tokens in the bench). |
|
|
15
|
+
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the sentences that answer it come back: BM25 over Readability-style blocks, then per-sentence scoring with heading, position and link-density priors, idf-weighted coverage, MMR de-duplication and a character budget — 92% fewer tokens on a long page in the bench. A question the page cannot answer returns the nearest passages and says so instead of pasting the page (79% fewer tokens in the bench). When the tab has no answer, the other tabs this chat holds are read in the same call and the answer comes back with the tab it came from — one read instead of switch-and-read-again (90% fewer characters in the bench); `across: false` stays on this tab. |
|
|
16
16
|
| `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
|
|
17
17
|
| `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
|
|
18
18
|
| `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
|
|
@@ -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
|
@@ -11,7 +11,7 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
|
|
|
11
11
|
| --- | --- |
|
|
12
12
|
| `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
|
|
13
13
|
| `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
|
|
14
|
-
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query`
|
|
14
|
+
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的那几句(段落排序后再按句子打分,标题、位置、链接密度做先验,重复句去掉,按字符预算装箱);问不到时只回最近的三段并明说没答上,不再把整页端回来;当前页答不上来还会顺手读这个会话自己开着的其它页签,答案在哪页就从哪页回并标出页签标题(bench 里少 90% 字符),`across: false` 只读当前页。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
|
|
15
15
|
| `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
|
|
16
16
|
| `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
|
|
17
17
|
| `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
|
|
@@ -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
|
@@ -192,6 +192,22 @@ characters) and no text, and the tool turns that into a line saying nothing dire
|
|
|
192
192
|
question plus those passages. The whole page is still one `browser_view` away, so the expensive
|
|
193
193
|
fallback — a page pasted into the context for a question it never addressed — is gone.
|
|
194
194
|
|
|
195
|
+
## Answering across tabs
|
|
196
|
+
|
|
197
|
+
A question can be answered by a tab that is not the one on screen. Moving the mirror there and
|
|
198
|
+
reading again costs two calls, a screencast teardown, and the person's view moved for nothing, so
|
|
199
|
+
a miss tries the tabs instead: `elsewhere(session)` lists the tabs the calling chat holds (its own
|
|
200
|
+
mirror excluded), opens one read-only CDP handle per tab — `Cdp` on that tab's own
|
|
201
|
+
`webSocketUrl`, never a full `SidecarPage`, never `cast()`, never a screencast stop — and
|
|
202
|
+
`readTabs` pools blocks from all of them under one `maxChars` ceiling and runs the same
|
|
203
|
+
`evidence()` pass once, so a merged answer costs the same characters as a single-tab one. Every
|
|
204
|
+
row keeps the title of the tab its text came from, and every handle is closed in a `finally`.
|
|
205
|
+
|
|
206
|
+
Only the calling session's claimed tabs are candidates: another chat's tab, and an unclaimed one,
|
|
207
|
+
are not read. At most three extra tabs are opened, and the path only runs when the current tab has
|
|
208
|
+
no answer, so a normal question pays nothing for it. `across: false` turns it off. `pruneStrays`
|
|
209
|
+
counts claimed tabs as owned, because a tab a chat holds is not a stray.
|
|
210
|
+
|
|
195
211
|
## Sign-ins and reclaim
|
|
196
212
|
|
|
197
213
|
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
@@ -207,6 +223,14 @@ Three layers, each adding one thing the previous cannot do:
|
|
|
207
223
|
browser closes, and put back on the next launch. A cookie the browser already holds is
|
|
208
224
|
never overwritten from the jar — a rotation happened in the live browser, and the jar is
|
|
209
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.
|
|
210
234
|
- **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
|
|
211
235
|
back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
|
|
212
236
|
dropped. `list` is a directory read, so it costs nothing while the browser is down.
|
package/docs/BENCH.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
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
|
+
|
|
15
|
+
## 答案在另一个页签(0.42.0)
|
|
16
|
+
|
|
17
|
+
两个页签:前台一页在讲别的事,后台另一页才有答案。左边是旧做法——切过去、再读一遍;
|
|
18
|
+
右边是一次跨页读。
|
|
19
|
+
|
|
20
|
+
| 项 | 切页签读两遍 | 一次跨页读 |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| 调用次数 | 2 | 1 |
|
|
23
|
+
| 回来的字符数 | 879 | 90 |
|
|
24
|
+
| 省下 | — | 90% |
|
|
25
|
+
| 出处 | 要自己记 | 每段标页签标题 |
|
|
26
|
+
| 读这一趟 | 7 ms | 5–6 ms |
|
|
27
|
+
|
|
28
|
+
跨页只在当前页答不上来时发生,正常命中路径的延迟不变(`read-answer` 仍是 1172 → 90 字)。
|
|
29
|
+
|
|
1
30
|
## 问了但页面没答(0.41.0)
|
|
2
31
|
|
|
3
32
|
同一张故事页,问一个页面里根本没有的词(`quantum chromodynamics`)。左边是旧的兜底:
|
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/read.js
CHANGED
|
@@ -1206,6 +1206,17 @@ function fold(word) {
|
|
|
1206
1206
|
/** How often a term may occur before it stops adding meaning. */
|
|
1207
1207
|
const SATURATION = 1.5
|
|
1208
1208
|
|
|
1209
|
+
/**
|
|
1210
|
+
* A passage short enough to show when nothing answers: one sentence or so,
|
|
1211
|
+
* whitespace collapsed, with an ellipsis saying it was cut.
|
|
1212
|
+
* @param {unknown} value - block text.
|
|
1213
|
+
* @returns {string} the passage.
|
|
1214
|
+
*/
|
|
1215
|
+
function brief(value) {
|
|
1216
|
+
const body = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
1217
|
+
return body.length > 260 ? `${body.slice(0, 260).replace(/\s+\S*$/, '')}…` : body
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1209
1220
|
/**
|
|
1210
1221
|
* Rank blocks against a query the way a search engine ranks a passage: BM25,
|
|
1211
1222
|
* with the heading counted as a place a term can match. Then read only the
|
|
@@ -1386,13 +1397,10 @@ export async function readRelevant(page, query, options = {}) {
|
|
|
1386
1397
|
/* A short, untrimmed paragraph is what a miss shows: enough to say what the
|
|
1387
1398
|
page is about, never enough to be the page. */
|
|
1388
1399
|
const near = (blocks) => blocks
|
|
1389
|
-
.map((block) =>
|
|
1400
|
+
.map((block) => brief(block?.t))
|
|
1390
1401
|
.filter((body) => body !== '')
|
|
1391
1402
|
.slice(0, 3)
|
|
1392
|
-
.map((body) => {
|
|
1393
|
-
const cut = body.length > 260 ? `${body.slice(0, 260).replace(/\s+\S*$/, '')}…` : body
|
|
1394
|
-
return { heading: '', text: cut }
|
|
1395
|
-
})
|
|
1403
|
+
.map((body) => ({ heading: '', text: body }))
|
|
1396
1404
|
try {
|
|
1397
1405
|
const raw = String((await evaluateIn(page, blocks(budget * 8))) ?? '{}')
|
|
1398
1406
|
const said = JSON.parse(raw)
|
|
@@ -1441,6 +1449,56 @@ export async function readRelevant(page, query, options = {}) {
|
|
|
1441
1449
|
}
|
|
1442
1450
|
}
|
|
1443
1451
|
|
|
1452
|
+
/**
|
|
1453
|
+
* Answer one question from several tabs at once: each tab is ranked on its own
|
|
1454
|
+
* words, then the sentences are pooled and packed under one ceiling, so an
|
|
1455
|
+
* answer that lives in a background tab costs one read instead of a tab switch
|
|
1456
|
+
* and a second read.
|
|
1457
|
+
* @param {Array<{title?: string, url?: string, cdp: any}>} pages - read-only handles.
|
|
1458
|
+
* @param {string} query - what the caller wants to know.
|
|
1459
|
+
* @param {{maxChars?: number, limit?: number}} [options] - ceiling across all tabs.
|
|
1460
|
+
* @returns {Promise<{rows: Array<{text: string, heading: string, at: number, tab: string}>, tabs: Array<{title: string, url: string, blocks: number, fault?: string}>, chars: number, matched: number, total: number, nearby: Array<{tab: string, text: string}>}>} pooled evidence.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function readTabs(pages, query, options = {}) {
|
|
1463
|
+
const ceiling = Math.max(400, Math.min(24_000, Number(options.maxChars ?? 4000)))
|
|
1464
|
+
const list = Array.isArray(pages) ? pages.filter((page) => page !== null && page !== undefined && page.cdp !== undefined) : []
|
|
1465
|
+
const share = Math.max(400, Math.floor(ceiling / Math.max(1, list.length)))
|
|
1466
|
+
const tabs = []
|
|
1467
|
+
const pool = []
|
|
1468
|
+
const owner = []
|
|
1469
|
+
for (let at = 0; at < list.length; at += 1) {
|
|
1470
|
+
const page = list[at]
|
|
1471
|
+
const title = String(page.title ?? '') === '' ? String(page.url ?? '') : String(page.title)
|
|
1472
|
+
try {
|
|
1473
|
+
const raw = String((await evaluateIn(page, blocks(share * 8))) ?? '{}')
|
|
1474
|
+
const said = JSON.parse(raw)
|
|
1475
|
+
const found = Array.isArray(said?.blocks) ? said.blocks : []
|
|
1476
|
+
for (const block of found) { pool.push(block); owner.push(at) }
|
|
1477
|
+
tabs.push({ title, url: String(page.url ?? ''), blocks: found.length })
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
/* One tab that will not answer is not a failed question: the others are
|
|
1480
|
+
still worth reading, and the caller is told which one stayed quiet. */
|
|
1481
|
+
tabs.push({ title, url: String(page.url ?? ''), blocks: 0, fault: String(error?.message ?? error).split('\n')[0].slice(0, 160) })
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
const limit = Math.max(1, Math.min(60, Number(options.limit ?? 12)))
|
|
1485
|
+
const found = evidence(pool, query, { maxChars: ceiling, limit })
|
|
1486
|
+
const rows = found.rows.map((row) => {
|
|
1487
|
+
const tab = tabs[owner[row.at] ?? 0]?.title ?? ''
|
|
1488
|
+
return Object.assign({}, row, { tab })
|
|
1489
|
+
})
|
|
1490
|
+
/* Nothing answered anywhere: the nearest passage of every tab says what each
|
|
1491
|
+
one is about, which is what makes the next look cheap. */
|
|
1492
|
+
const nearby = rows.length > 0 ? [] : tabs
|
|
1493
|
+
.map((tab, at) => {
|
|
1494
|
+
const first = pool.find((block, slot) => owner[slot] === at && brief(block?.t) !== '')
|
|
1495
|
+
return first === undefined ? null : { tab: tab.title, text: brief(first.t) }
|
|
1496
|
+
})
|
|
1497
|
+
.filter((row) => row !== null)
|
|
1498
|
+
const chars = rows.reduce((sum, row) => sum + row.text.length, 0)
|
|
1499
|
+
return { rows, tabs, chars, matched: rows.length, total: pool.length, nearby }
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1444
1502
|
/**
|
|
1445
1503
|
* Read the page as Markdown, through the same deadline as a snapshot.
|
|
1446
1504
|
* @param {any} page - sidecar page.
|
package/sidecar.js
CHANGED
|
@@ -2337,6 +2337,11 @@ export class Sidecar {
|
|
|
2337
2337
|
const id = String(page?.targetId ?? '')
|
|
2338
2338
|
if (id !== '') owned.add(id)
|
|
2339
2339
|
}
|
|
2340
|
+
/* A tab a chat claimed is a tab that chat holds, even when the mirror is on
|
|
2341
|
+
another one: pruning it would take away the tab a cross-tab read needs. */
|
|
2342
|
+
for (const set of this.claims.values()) {
|
|
2343
|
+
for (const id of set) owned.add(String(id))
|
|
2344
|
+
}
|
|
2340
2345
|
let remembered = []
|
|
2341
2346
|
try {
|
|
2342
2347
|
remembered = Object.values(JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}).map(String)
|
|
@@ -2873,6 +2878,46 @@ export class Sidecar {
|
|
|
2873
2878
|
return shown
|
|
2874
2879
|
}
|
|
2875
2880
|
|
|
2881
|
+
/**
|
|
2882
|
+
* Read-only links to the other tabs this chat holds, so one question can be
|
|
2883
|
+
* answered by the tab that has the answer. A handle can read (it has a
|
|
2884
|
+
* connection) and must be closed; it never starts a screencast, moves the
|
|
2885
|
+
* mirror or replaces the page this chat is driving.
|
|
2886
|
+
* @param {string} session - chat asking.
|
|
2887
|
+
* @param {number} [limit] - how many other tabs at most, 1 to 3.
|
|
2888
|
+
* @returns {Promise<Array<{title: string, url: string, cdp: any, close: () => void}>>} handles.
|
|
2889
|
+
*/
|
|
2890
|
+
async elsewhere(session, limit = 3) {
|
|
2891
|
+
const most = Math.max(1, Math.min(3, Number(limit) || 3))
|
|
2892
|
+
const shown = await this.windowsFor(session)
|
|
2893
|
+
const rows = await this.tabs(session)
|
|
2894
|
+
/* The chat-level tab list drops the connection address, so the browser's own
|
|
2895
|
+
list is what a read-only handle is opened from. */
|
|
2896
|
+
const source = this.pageFor(String(session ?? '')) ?? this.page
|
|
2897
|
+
const raw = typeof source?.tabs === 'function' ? await source.tabs() : []
|
|
2898
|
+
const byId = new Map(raw.map((tab) => [String(tab.targetId), tab]))
|
|
2899
|
+
const out = []
|
|
2900
|
+
for (const row of shown) {
|
|
2901
|
+
if (out.length >= most) break
|
|
2902
|
+
if (row.current === true) continue
|
|
2903
|
+
const tab = byId.get(String(rows[row.index]?.targetId ?? ''))
|
|
2904
|
+
if (tab === undefined || String(tab.webSocketUrl ?? '') === '') continue
|
|
2905
|
+
try {
|
|
2906
|
+
const cdp = new Cdp(tab.webSocketUrl)
|
|
2907
|
+
await cdp.ready
|
|
2908
|
+
out.push({
|
|
2909
|
+
title: String(row.title === '' ? tab.title : row.title),
|
|
2910
|
+
url: String(row.url === '' ? tab.url : row.url),
|
|
2911
|
+
cdp,
|
|
2912
|
+
close: () => cdp.close(),
|
|
2913
|
+
})
|
|
2914
|
+
} catch {
|
|
2915
|
+
/* A tab that will not open is one tab less; the read still happens. */
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
return out
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2876
2921
|
/**
|
|
2877
2922
|
* Move the mirror to another tab, leaving the old one open where it was.
|
|
2878
2923
|
* @param {number} index - a row from `tabs()`.
|
package/tools.js
CHANGED
|
@@ -16,8 +16,9 @@ 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'
|
|
20
|
-
import {
|
|
19
|
+
import { dropLogin, listLogins, readLogin, saveLogin, signInsFor, vaultState } from './vault.js'
|
|
20
|
+
import { readBrowserCookies, siteOf } from './chromecookies.js'
|
|
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
|
|
|
23
24
|
/**
|
|
@@ -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.
|
|
@@ -2233,6 +2334,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2233
2334
|
max_chars: { type: 'integer', description: 'Character ceiling. Default 6000; the result says when it was cut.' },
|
|
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.' },
|
|
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.' },
|
|
2236
2339
|
},
|
|
2237
2340
|
additionalProperties: false,
|
|
2238
2341
|
},
|
|
@@ -2260,6 +2363,50 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2260
2363
|
const found = await readRelevant(page, asked, { maxChars: ceiling })
|
|
2261
2364
|
if (found.fault !== undefined) return `the page did not answer when read — ${String(found.fault).split('\n')[0].slice(0, 200)}`
|
|
2262
2365
|
if (found.text === '') {
|
|
2366
|
+
/* The answer may be in the tab behind this one. Reading every tab
|
|
2367
|
+
this chat holds is one call, where switching and reading again is
|
|
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)
|
|
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))
|
|
2392
|
+
if (args?.across !== false) {
|
|
2393
|
+
const others = await sidecar.elsewhere(currentSession()).catch(() => [])
|
|
2394
|
+
if (others.length > 0) {
|
|
2395
|
+
try {
|
|
2396
|
+
const pooled = await readTabs(others, asked, { maxChars: ceiling })
|
|
2397
|
+
if (pooled.rows.length > 0) {
|
|
2398
|
+
const body = pooled.rows
|
|
2399
|
+
.map((row) => `## ${String(row.tab)}\n${String(row.text)}`)
|
|
2400
|
+
.join('\n\n')
|
|
2401
|
+
const from = pooled.tabs.length === 1 ? 'another tab' : `${String(pooled.tabs.length)} other tabs`
|
|
2402
|
+
return `${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\nthis tab does not answer ${JSON.stringify(asked)}; the answer is in ${from} this chat holds (${String(pooled.matched)} passages):\n\n${body}`
|
|
2403
|
+
}
|
|
2404
|
+
note = ` (${String(pooled.tabs.length)} other tab${pooled.tabs.length === 1 ? '' : 's'} read too, none answers it)`
|
|
2405
|
+
} finally {
|
|
2406
|
+
for (const one of others) one.close()
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2263
2410
|
/* A question the page does not answer costs the nearest passages and
|
|
2264
2411
|
a sentence saying so, not the whole page: whole pages are what
|
|
2265
2412
|
this tool exists to avoid, and the caller can ask for one. */
|
|
@@ -2268,13 +2415,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2268
2415
|
const body = closest
|
|
2269
2416
|
.map((row) => `${row.heading === '' ? '' : `## ${String(row.heading)}\n`}${String(row.text)}`)
|
|
2270
2417
|
.join('\n\n')
|
|
2271
|
-
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.`)
|
|
2272
2419
|
}
|
|
2273
2420
|
const whole = await readArticle(page, { maxChars: ceiling })
|
|
2274
2421
|
if (whole.fault === undefined && whole.text !== '') {
|
|
2275
|
-
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)` : ''}`)
|
|
2276
2423
|
}
|
|
2277
|
-
return `${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\nnothing here answers ${JSON.stringify(asked)} (${String(found.total)} blocks read)
|
|
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.`
|
|
2278
2425
|
}
|
|
2279
2426
|
return keep(`${found.url}\n# ${found.title === '' ? '(untitled)' : found.title}\n\n${found.text}\n\n(${String(found.matched)} of ${String(found.total)} blocks, ranked for ${JSON.stringify(asked)})`)
|
|
2280
2427
|
}
|
|
@@ -2283,6 +2430,16 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
2283
2430
|
if (read.text === '') {
|
|
2284
2431
|
const pdf = await pdfNote(page)
|
|
2285
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
|
+
}
|
|
2286
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.`
|
|
2287
2444
|
}
|
|
2288
2445
|
const head = `${read.url}\n# ${read.title === '' ? '(untitled)' : read.title}`
|
|
@@ -3122,12 +3279,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3122
3279
|
|
|
3123
3280
|
{
|
|
3124
3281
|
name: 'browser_login', // ui-copy-ignore
|
|
3125
|
-
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".',
|
|
3126
3283
|
parameters: { // ui-copy-ignore
|
|
3127
3284
|
type: 'object',
|
|
3128
3285
|
properties: {
|
|
3129
|
-
action: { type: 'string', enum: ['list', 'save', 'use', 'drop'], description: 'list, save, use or
|
|
3130
|
-
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.' },
|
|
3131
3289
|
},
|
|
3132
3290
|
required: ['action'], // ui-copy-ignore
|
|
3133
3291
|
additionalProperties: false,
|
|
@@ -3156,6 +3314,25 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3156
3314
|
if (action === 'drop') {
|
|
3157
3315
|
return { text: dropLogin(name) ? `Forgot "${name}".` : `Nothing saved as "${name}".` }
|
|
3158
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
|
+
}
|
|
3159
3336
|
if (action === 'save') {
|
|
3160
3337
|
const page = await sidecar.ensure({ session: currentSession() })
|
|
3161
3338
|
const cookies = await page.cookies()
|
|
@@ -3168,35 +3345,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3168
3345
|
const held = readLogin(name)
|
|
3169
3346
|
if (held === null) return { text: `Nothing saved as "${name}". browser_login {action:"list"} shows what is kept.` }
|
|
3170
3347
|
const page = await sidecar.ensure({ session: currentSession() })
|
|
3171
|
-
|
|
3172
|
-
for (const cookie of (Array.isArray(held.cookies) ? held.cookies : []).slice(0, 400)) {
|
|
3173
|
-
const domain = String(cookie?.domain ?? '').replace(/^\./u, '')
|
|
3174
|
-
if (domain === '' || cookie?.name === undefined) continue
|
|
3175
|
-
try {
|
|
3176
|
-
await page.setCookie({
|
|
3177
|
-
name: String(cookie.name),
|
|
3178
|
-
value: String(cookie.value ?? ''),
|
|
3179
|
-
domain,
|
|
3180
|
-
path: String(cookie.path ?? '/'),
|
|
3181
|
-
secure: cookie.secure === true,
|
|
3182
|
-
httpOnly: cookie.httpOnly === true,
|
|
3183
|
-
sameSite: cookie.sameSite,
|
|
3184
|
-
expires: cookie.session === true ? undefined : Number(cookie.expires) || undefined,
|
|
3185
|
-
})
|
|
3186
|
-
put += 1
|
|
3187
|
-
} catch {
|
|
3188
|
-
/* one cookie a site refuses is not a reason to stop */
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
let stored = 0
|
|
3192
|
-
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)
|
|
3193
3349
|
const back = String(held.url ?? '')
|
|
3194
3350
|
if (back !== '') await page.navigate(back)
|
|
3195
3351
|
else await page.reload()
|
|
3196
3352
|
const said = stored === 0 ? '' : `, ${stored} local storage entries`
|
|
3197
3353
|
return { text: `Signed in as "${name}": ${put} cookies${said}, page reopened.` }
|
|
3198
3354
|
}
|
|
3199
|
-
return { text: 'Pass action "list", "save", "use" or "
|
|
3355
|
+
return { text: 'Pass action "list", "save", "use", "drop" or "import".' }
|
|
3200
3356
|
},
|
|
3201
3357
|
},
|
|
3202
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.
|