dsh-inner-browser-use 0.38.0 → 0.40.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 +3 -2
- package/README.zh.md +3 -2
- package/docs/ARCHITECTURE.md +60 -0
- package/docs/ARTIFACTS.md +3 -1
- package/docs/BENCH.md +31 -0
- package/index.js +2 -0
- package/package.json +3 -2
- package/read.js +153 -3
- package/sidecar.js +64 -10
- package/tools.js +135 -0
- package/vault.js +333 -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
|
|
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. |
|
|
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,8 @@ 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
|
-
| `
|
|
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. |
|
|
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. |
|
|
25
26
|
|
|
26
27
|
`browser_read` on the same page is the words only, in Markdown (measured: 4,520
|
|
27
28
|
characters of an about page against 2,987 characters of snapshot — and the
|
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` 只回能回答的那几句(段落排序后再按句子打分,标题、位置、链接密度做先验,重复句去掉,按字符预算装箱);问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
|
|
15
15
|
| `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
|
|
16
16
|
| `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
|
|
17
17
|
| `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
|
|
@@ -20,7 +20,8 @@ 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
|
-
| `
|
|
23
|
+
| `browser_login` | 按名字留住登录:`save` 存下当前窗口的 cookie 和各站的 localStorage,`list` 看有哪些,`use` 切回那个账号并重开页面,`drop` 忘掉。名字跟着浏览器走、不跟着一个会话走,重启也在;同一站点两个账号就存两个名字。磁盘上那一份是加密的,钥匙在系统钥匙串里。 |
|
|
24
|
+
| `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题、有几个右栏在看它,以及它此刻占多少内存、没人用的话还有多久关闭。 |
|
|
24
25
|
|
|
25
26
|
点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
|
|
26
27
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -165,10 +165,70 @@ picture comes back on the mutation that satisfies it. A page that changes 137 ms
|
|
|
165
165
|
starts answers at 138 ms, not at the next 100 ms tick — the same reason `idle` (a mutation-free
|
|
166
166
|
window) costs one round trip on an animation-heavy page instead of one per frame.
|
|
167
167
|
|
|
168
|
+
## Reading for an answer
|
|
169
|
+
|
|
170
|
+
`browser_read` with a `query` does not return the page; it returns the sentences that answer the
|
|
171
|
+
question. The page-side block reader already returns paragraphs with their heading and link
|
|
172
|
+
density, so the work stays in `read.js` and costs no extra round trip.
|
|
173
|
+
|
|
174
|
+
Paragraphs are ranked first (BM25 over the question's terms, link-heavy menus down-weighted), then
|
|
175
|
+
split into sentences. Each sentence is scored on its own with the same saturation and length
|
|
176
|
+
normalisation, and that score is multiplied by three priors: the words of its heading, the rank of
|
|
177
|
+
the paragraph it sits in, and inverse link density. Words the page says everywhere are dropped
|
|
178
|
+
before scoring — a term in most sentences cannot point at anything — and a sentence that matches
|
|
179
|
+
only part of the question is scaled by the share of the question's idf weight it actually covers,
|
|
180
|
+
which is what stops a paragraph containing "to" from competing with one containing "refund".
|
|
181
|
+
|
|
182
|
+
Selection is not "the top N scores": each pick subtracts its Jaccard overlap with what is already
|
|
183
|
+
chosen (MMR), selection stops when the best remaining sentence is worth nothing, and picked
|
|
184
|
+
sentences are packed to a character budget and restored to document order. Terms are folded
|
|
185
|
+
(plurals, trailing `-ing`/`-ed`) so a question can use a different inflection than the page.
|
|
186
|
+
|
|
187
|
+
`sentences: false` keeps the paragraph path for callers who want the whole section.
|
|
188
|
+
|
|
189
|
+
## Sign-ins and reclaim
|
|
190
|
+
|
|
168
191
|
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
169
192
|
which Chrome would otherwise drop on exit — are saved next to it and restored on the next
|
|
170
193
|
launch, so a restart of the app does not sign the person out.
|
|
171
194
|
|
|
195
|
+
Three layers, each adding one thing the previous cannot do:
|
|
196
|
+
|
|
197
|
+
- **The profile** keeps everything Chrome writes: dated cookies, origin storage, service
|
|
198
|
+
workers. It survives a restart of the app and a restart of the machine.
|
|
199
|
+
- **The session jar** (`session-cookies.json`) covers the part Chrome throws away on exit.
|
|
200
|
+
Every cookie that comes back from `page.cookies()` without an expiry is written when the
|
|
201
|
+
browser closes, and put back on the next launch. A cookie the browser already holds is
|
|
202
|
+
never overwritten from the jar — a rotation happened in the live browser, and the jar is
|
|
203
|
+
the older truth.
|
|
204
|
+
- **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
|
|
205
|
+
back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
|
|
206
|
+
dropped. `list` is a directory read, so it costs nothing while the browser is down.
|
|
207
|
+
Restoring writes cookies first, then navigates to each stored origin to write its local
|
|
208
|
+
storage (the API is origin-bound), then reopens the page that was saved.
|
|
209
|
+
|
|
210
|
+
Everything written at rest is sealed with AES-256-GCM (`{v, alg, iv, tag, data}`, all
|
|
211
|
+
base64). The key comes from `DSH_INNER_BROWSER_VAULT_KEY` when a test sets one, otherwise
|
|
212
|
+
from the macOS keychain, otherwise from `vault.key` in the state root — 0600, and a fallback
|
|
213
|
+
rather than the intended home, because an unattended `security add-generic-password` can
|
|
214
|
+
block on a prompt. A tampered blob, a wrong key and a truncated file all come back empty;
|
|
215
|
+
nothing trusts what it cannot verify. A plaintext jar from a version before this one is
|
|
216
|
+
still read and re-sealed on the next write, so an upgrade does not sign anyone out.
|
|
217
|
+
|
|
218
|
+
The reclaim ladder is three separate timers, because they answer three different questions:
|
|
219
|
+
|
|
220
|
+
| Question | Timer | What happens |
|
|
221
|
+
| --- | --- | --- |
|
|
222
|
+
| Did the person close the column? | `REST_MS` = 60 s | The browser is put away a minute after the last pane lets go. |
|
|
223
|
+
| Has nobody touched it at all? | `IDLE_MS` = 15 min | The whole process is reclaimed, but never while a pane is still watching a frame. |
|
|
224
|
+
| Is this one tab stale? | `TAB_IDLE_MS` = 10 min, `MAX_TABS` = 6 | The tab is closed, not the browser; a returning chat gets a fresh page instead of a dead one (`expired`). |
|
|
225
|
+
|
|
226
|
+
`browser_state` reports both what the process costs right now (`ps` over its process tree,
|
|
227
|
+
which includes renderers, not just the parent) and `closesIn`, so the model can decide
|
|
228
|
+
between keeping the page and letting it go instead of guessing. A freeze
|
|
229
|
+
(`Page.setWebLifecycleState`) is deliberately not wired in: closing a stale tab is already
|
|
230
|
+
cheap, and a frozen page that misses a mutation would make the event-driven wait lie.
|
|
231
|
+
|
|
172
232
|
A system-prompt section (`browser-in-this-chat`) tells the model to reach for these tools
|
|
173
233
|
without asking first when someone wants something done on a site.
|
|
174
234
|
|
package/docs/ARTIFACTS.md
CHANGED
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
~/.dsh-inner-browser-use/
|
|
23
23
|
artifacts/<项目>/<会话>/<种类>/ 给人看的产出,只在这里(见上)
|
|
24
24
|
chrome/ 浏览器自己的数据:登录态、Cookie、缓存
|
|
25
|
+
logins/ 按名字留住的登录,一个名字一个文件,加密
|
|
26
|
+
vault.key 钥匙串拿不到钥匙时的替代文件,只有本人可读
|
|
25
27
|
flash/ Flash 播放器运行时,第一次用 flash:true 才装
|
|
26
28
|
browser.log 最近一次浏览器启动和页面记录
|
|
27
29
|
asks.log 人问过什么、答了什么
|
|
28
30
|
tabs.json / mirror.json / flash.json / watcher.json 上次开着哪些页
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
`chrome/`
|
|
33
|
+
`chrome/session-cookies.json` 是 Chrome 退出时会丢的会话 cookie,加密后存在 profile 旁边;`logins/` 与 `vault.key` 由 `browser_login` 和启动时自动保存的登录备份共用,不要手动改或删,要清就用 `browser_login({action:"drop"})`。`chrome/` 其余部分是浏览器进程自己在读写,不要手动改;要清登录态,用 `browser_open` 的档位或插件自己的入口,别删目录里的某个文件。上面这些都在一个根目录下,卸载插件前想留产出就拷 `artifacts/`。
|
|
32
34
|
|
|
33
35
|
测试里用 `DSH_INNER_BROWSER_ARTIFACTS` 指向临时目录,不写真实 home。
|
package/docs/BENCH.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## 只读答案那几句(0.40.0)
|
|
2
|
+
|
|
3
|
+
同一张本地故事页:24 段会议记录里夹着一段账单说明,问题是「退款多久到账」。一条路按段
|
|
4
|
+
落读(`sentences: false`),另一条走默认的句子级证据。
|
|
5
|
+
|
|
6
|
+
| 项 | 整页段落 | 句子证据 |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| 回来的字符数 | 1172 | 90 |
|
|
9
|
+
| 折合 token | 345 | 26 |
|
|
10
|
+
| 答案还在 | 是 | 是(`30 days`) |
|
|
11
|
+
| 命中的块 / 总块 | 8 / 49 | 1 / 49 |
|
|
12
|
+
| 读这一趟 | — | 11 ms |
|
|
13
|
+
|
|
14
|
+
省下 92%,`read-answer` 预算 250 ms。句子级这一步没有新增进程、没有模型、没有依赖:分句、
|
|
15
|
+
打分、去重都在已有的阅读调用里完成。同一批里的 `read` / `read-repeat` / `read-changed` 行与
|
|
16
|
+
0.39.0 持平(`dense` 6 ms / 337 token,`full` 6 ms / 624 token)。
|
|
17
|
+
|
|
18
|
+
## 登录能留、能切、关得掉(0.39.0)
|
|
19
|
+
|
|
20
|
+
本地页签在加载时写一个会话 cookie 和一个 `localStorage` 令牌,然后用工具层实际走一遍:保存 → 清空浏览器里的一切 → 切回。磁盘上那一份同时用字符串搜一遍。
|
|
21
|
+
|
|
22
|
+
| 项 | 结果 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `browser_login {action:"save"}` | 2 ms,8 个 cookie |
|
|
25
|
+
| `browser_login {action:"use"}`(清空后切回、重开页面) | 105 ms |
|
|
26
|
+
| 切回后 cookie 与 `localStorage` | 都在(`signedIn: true`) |
|
|
27
|
+
| 密文里能否搜到 cookie 值 / 令牌 | 搜不到(`sealed: true`) |
|
|
28
|
+
| 预算 | `login-vault: 1600 ms`,19 项全过 |
|
|
29
|
+
|
|
30
|
+
`browser_state` 另外报两行:当前进程树占多少内存(含渲染进程),以及没人用的话还有多久关闭——两者都来自 `ps` 一次采样和已有的空闲计时器,不额外起进程。关闭策略没变:右栏关掉约 1 分钟、没人碰约 15 分钟回收整个浏览器,页签 10 分钟不动、最多 6 个。
|
|
31
|
+
|
|
1
32
|
## 等到变化,而不是去问(0.38.0)
|
|
2
33
|
|
|
3
34
|
`browser_wait` 以前在宿主侧每 100 毫秒读一次页面判断条件,命中与否都被凑到下一个整拍上。
|
package/index.js
CHANGED
|
@@ -941,6 +941,8 @@ export function apply(ctx) {
|
|
|
941
941
|
'不要用它:读公开文章、单个链接正文、文档、搜索结果摘要、GitHub 仓库或 PR 页面——这些能用 `web_fetch` / envoy 读就不用浏览器;一打开就是一个后台 Chrome 进程加一个页签,会话多了很占内存。',
|
|
942
942
|
'也不要用它做连通性、可达性、服务是否起来的检查:那是一个 HTTP 请求的事,`web_fetch` 或 `curl` 就够,开浏览器只会多一个进程和一栏画面。只有「这一步必须在真实浏览器里点/填/登录」才轮到它。',
|
|
943
943
|
'人点名之后不问、不另起外部 Chrome;画面留在本会话右栏,做完保留,不主动关。',
|
|
944
|
+
'登录态属于浏览器,不属于某个会话:`browser_login` 的 `save` 把当前登录按名字留存(`list` 看、`use` 切账号、`drop` 忘掉),换会话、重启都还在;同一站点要多个账号就存成不同名字。留存的登录凭据在磁盘上是加密的,密钥在系统钥匙串里。人让你登录时先 `browser_login` 的 `list` 看一眼有没有可复用的,再决定要不要重新登。',
|
|
945
|
+
'浏览器不用时自己让位:右栏关掉约一分钟后、没人碰约一刻钟后回收整个进程,页签十分钟不动、最多六个;`browser_state` 会给出它现在占多少内存和还有多久关闭。人还在看的时候不关。',
|
|
944
946
|
'先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
|
|
945
947
|
'页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
|
|
946
948
|
'要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-inner-browser-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"read.js",
|
|
19
19
|
"review.js",
|
|
20
20
|
"media.js",
|
|
21
|
+
"vault.js",
|
|
21
22
|
"client.js",
|
|
22
23
|
"locale/*.json",
|
|
23
24
|
"cordis.patch.yml",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"dsh": ">=0.2.0"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
|
-
"test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs",
|
|
53
|
+
"test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
|
|
53
54
|
"doctor": "node scripts/doctor.mjs",
|
|
54
55
|
"breadth": "node scripts/breadth.mjs",
|
|
55
56
|
"live": "node scripts/live.mjs",
|
package/read.js
CHANGED
|
@@ -1185,7 +1185,22 @@ const tokenPattern = /[a-z0-9]+|[\u3400-\u9fff]/g
|
|
|
1185
1185
|
* @returns {string[]} lower-case terms.
|
|
1186
1186
|
*/
|
|
1187
1187
|
export function terms(text) {
|
|
1188
|
-
|
|
1188
|
+
const raw = String(text ?? '').toLowerCase().match(tokenPattern) ?? []
|
|
1189
|
+
return raw.map(fold)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** "refunds" and "refund" are the same idea; a light fold lets a question that
|
|
1193
|
+
* says one word find a page that says the other. Deliberately not a stemmer —
|
|
1194
|
+
* no dictionary, no suffix table beyond plurals and a trailing -ing/-ed. */
|
|
1195
|
+
function fold(word) {
|
|
1196
|
+
if (word.length <= 3 || /[0-9]/.test(word) || /[^a-z]/.test(word)) return word
|
|
1197
|
+
if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`
|
|
1198
|
+
if (word.endsWith('sses')) return word.slice(0, -2)
|
|
1199
|
+
if (word.endsWith('ing') && word.length > 5) return word.slice(0, -3)
|
|
1200
|
+
if (word.endsWith('ed') && word.length > 4 && !word.endsWith('eed')) return word.slice(0, -2)
|
|
1201
|
+
if (word.endsWith('es') && word.length > 4) return word.slice(0, -2)
|
|
1202
|
+
if (word.endsWith('s') && !word.endsWith('ss') && !word.endsWith('us') && !word.endsWith('is')) return word.slice(0, -1)
|
|
1203
|
+
return word
|
|
1189
1204
|
}
|
|
1190
1205
|
|
|
1191
1206
|
/** How often a term may occur before it stops adding meaning. */
|
|
@@ -1230,6 +1245,134 @@ export function rank(blocks, query) {
|
|
|
1230
1245
|
return scored
|
|
1231
1246
|
}
|
|
1232
1247
|
|
|
1248
|
+
/** A sentence ends at a full stop, a question mark, an exclamation, or their
|
|
1249
|
+
* CJK equivalents; a very short fragment joins its neighbour rather than
|
|
1250
|
+
* travelling alone. */
|
|
1251
|
+
const SENTENCE_BREAK = /(?<=[。!?;])|(?<=[.!?;])\s+/u
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* @param {string} text - a paragraph.
|
|
1255
|
+
* @returns {string[]} its sentences, trimmed.
|
|
1256
|
+
*/
|
|
1257
|
+
export function sentences(text) {
|
|
1258
|
+
const raw = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
1259
|
+
if (raw === '') return []
|
|
1260
|
+
const out = []
|
|
1261
|
+
for (const piece of raw.split(SENTENCE_BREAK)) {
|
|
1262
|
+
const one = piece.trim()
|
|
1263
|
+
if (one === '') continue
|
|
1264
|
+
/* A full stop inside "e.g." leaves a fragment whose continuation starts
|
|
1265
|
+
lowercase; that is the one case where the break was not real. */
|
|
1266
|
+
const continues = /^[a-z(]/.test(one) || out[out.length - 1]?.length < 4
|
|
1267
|
+
if (out.length > 0 && continues) out[out.length - 1] = `${out[out.length - 1]} ${one}`
|
|
1268
|
+
else out.push(one)
|
|
1269
|
+
}
|
|
1270
|
+
return out
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* The sentences that answer a query, chosen the way a retriever picks
|
|
1275
|
+
* evidence: BM25 over sentences, a prior for the paragraph and heading they
|
|
1276
|
+
* sit in, and maximal-marginal-relevance so the second sentence adds
|
|
1277
|
+
* something the first did not say. Purely local — no model, no network.
|
|
1278
|
+
* @param {{h?: string, t?: string, d?: number}[]} blocks - paragraphs.
|
|
1279
|
+
* @param {string} query - what the caller wants to know.
|
|
1280
|
+
* @param {{maxChars?: number, limit?: number, blocks?: number, lambda?: number}} [options] - ceilings.
|
|
1281
|
+
* @returns {{rows: {text: string, heading: string, at: number, step: number}[], total: number, matched: number}} picked sentences in page order.
|
|
1282
|
+
*/
|
|
1283
|
+
export function evidence(blocks, query, options = {}) {
|
|
1284
|
+
const typed = terms(query)
|
|
1285
|
+
if (typed.length === 0) return { rows: [], total: blocks.length, matched: 0 }
|
|
1286
|
+
const near = rank(blocks, query).slice(0, Math.max(1, Math.min(60, Number(options.blocks ?? 40))))
|
|
1287
|
+
const units = []
|
|
1288
|
+
for (const row of near) {
|
|
1289
|
+
const heading = String(row.block.h ?? '')
|
|
1290
|
+
const body = String(row.block.t ?? '')
|
|
1291
|
+
const parts = sentences(body)
|
|
1292
|
+
for (let step = 0; step < parts.length; step += 1) {
|
|
1293
|
+
const spoken = terms(parts[step])
|
|
1294
|
+
if (spoken.length === 0) continue
|
|
1295
|
+
units.push({ text: parts[step], terms: spoken, heading, at: blocks.indexOf(row.block), step, prior: row.score, block: row.block })
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (units.length === 0) return { rows: [], total: blocks.length, matched: 0 }
|
|
1299
|
+
const total = Math.max(1, units.length)
|
|
1300
|
+
const seen = new Map()
|
|
1301
|
+
for (const unit of units) for (const term of new Set(unit.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
|
|
1302
|
+
const average = units.reduce((sum, unit) => sum + unit.terms.length, 0) / total
|
|
1303
|
+
/* A word the page says everywhere cannot point at anything: "to" is in every
|
|
1304
|
+
paragraph of a long article. Keep the terms that actually narrow the page;
|
|
1305
|
+
if the question is all common words, keep them all. */
|
|
1306
|
+
const weight = (term) => {
|
|
1307
|
+
const inHowMany = seen.get(term) ?? 0
|
|
1308
|
+
return inHowMany === 0 ? 0 : Math.log(1 + (total - inHowMany + 0.5) / (inHowMany + 0.5))
|
|
1309
|
+
}
|
|
1310
|
+
const sharp = typed.filter((term) => weight(term) >= 0.5)
|
|
1311
|
+
const asked = sharp.length > 0 ? sharp : typed
|
|
1312
|
+
const askedWeight = asked.reduce((sum, term) => sum + weight(term), 0)
|
|
1313
|
+
for (const unit of units) {
|
|
1314
|
+
const counts = new Map()
|
|
1315
|
+
for (const term of unit.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
|
|
1316
|
+
let score = 0
|
|
1317
|
+
let covered = 0
|
|
1318
|
+
for (const term of asked) {
|
|
1319
|
+
const tf = counts.get(term) ?? 0
|
|
1320
|
+
if (tf === 0) continue
|
|
1321
|
+
covered += weight(term)
|
|
1322
|
+
score += weight(term) * ((tf * (SATURATION + 1)) / (tf + SATURATION * (1 - 0.75 + (0.75 * unit.terms.length) / average)))
|
|
1323
|
+
}
|
|
1324
|
+
/* Matching one frequent word of a nine-word question is not evidence; the
|
|
1325
|
+
share of the question's weight the sentence actually answers is. */
|
|
1326
|
+
score *= covered / Math.max(1e-6, askedWeight)
|
|
1327
|
+
/* The paragraph's own relevance and the words of the heading it sits under
|
|
1328
|
+
both say "this is the right place", so they lift their sentences. */
|
|
1329
|
+
const headingHit = asked.filter((term) => terms(unit.heading).includes(term)).length
|
|
1330
|
+
/* A menu sentence can match the words without saying anything about them;
|
|
1331
|
+
the paragraph's link density is what tells the two apart. */
|
|
1332
|
+
const linkish = 1 - 0.8 * Number(unit.block?.d ?? 0)
|
|
1333
|
+
unit.score = score * linkish * (1 + 0.25 * headingHit) * (1 + 0.5 * (unit.prior > 0 ? 1 : 0))
|
|
1334
|
+
}
|
|
1335
|
+
/* Below a fifth of the best sentence, a match is coincidence rather than
|
|
1336
|
+
evidence; carrying it only costs tokens. */
|
|
1337
|
+
const best = units.reduce((top, unit) => Math.max(top, unit.score), 0)
|
|
1338
|
+
for (const unit of units) if (unit.score < best * 0.2) unit.score = 0
|
|
1339
|
+
const lambda = Math.max(0, Math.min(0.9, Number(options.lambda ?? 0.35)))
|
|
1340
|
+
const ceiling = Math.max(200, Math.min(24_000, Number(options.maxChars ?? 4000)))
|
|
1341
|
+
const limit = Math.max(1, Math.min(60, Number(options.limit ?? 24)))
|
|
1342
|
+
const chosen = []
|
|
1343
|
+
const left = units.filter((unit) => unit.score > 0)
|
|
1344
|
+
let used = 0
|
|
1345
|
+
let lastHeading = ''
|
|
1346
|
+
while (left.length > 0 && chosen.length < limit) {
|
|
1347
|
+
let best = -1
|
|
1348
|
+
let bestValue = -Infinity
|
|
1349
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
1350
|
+
const candidate = left[i]
|
|
1351
|
+
const spoken = new Set(candidate.terms)
|
|
1352
|
+
let overlap = 0
|
|
1353
|
+
for (const picked of chosen) {
|
|
1354
|
+
const other = new Set(picked.terms)
|
|
1355
|
+
let shared = 0
|
|
1356
|
+
for (const term of spoken) if (other.has(term)) shared += 1
|
|
1357
|
+
overlap = Math.max(overlap, shared / Math.max(1, spoken.size + other.size - shared))
|
|
1358
|
+
}
|
|
1359
|
+
const value = candidate.score * (1 - lambda) - lambda * overlap
|
|
1360
|
+
if (value > bestValue) { bestValue = value; best = i }
|
|
1361
|
+
}
|
|
1362
|
+
/* Once the best sentence left is no better than nothing — or is simply a
|
|
1363
|
+
repeat of one already picked — the answer is complete. */
|
|
1364
|
+
if (best < 0 || bestValue <= 0) break
|
|
1365
|
+
const pick = left.splice(best, 1)[0]
|
|
1366
|
+
const heading = pick.heading !== lastHeading && pick.heading !== '' ? pick.heading.length + 8 : 0
|
|
1367
|
+
if (used + pick.text.length + heading > ceiling && chosen.length > 0) break
|
|
1368
|
+
lastHeading = pick.heading
|
|
1369
|
+
used += pick.text.length + heading
|
|
1370
|
+
chosen.push(pick)
|
|
1371
|
+
}
|
|
1372
|
+
chosen.sort((a, b) => a.at - b.at || a.step - b.step)
|
|
1373
|
+
return { rows: chosen.map((one) => ({ text: one.text, heading: one.heading, at: one.at, step: one.step })), total: blocks.length, matched: chosen.length }
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1233
1376
|
/**
|
|
1234
1377
|
* Read only the passages of a page that answer a query, so a long page costs
|
|
1235
1378
|
* what the answer costs instead of what the page costs.
|
|
@@ -1248,12 +1391,19 @@ export async function readRelevant(page, query, options = {}) {
|
|
|
1248
1391
|
if (ranked.length === 0) {
|
|
1249
1392
|
return { title: String(said?.title ?? page.title ?? ''), url: String(page.url ?? ''), text: '', chars: 0, matched: 0, total: all.length }
|
|
1250
1393
|
}
|
|
1251
|
-
|
|
1394
|
+
/* Passages cost whole paragraphs; sentences cost only the ones that say
|
|
1395
|
+
something about the query, which on a long page is most of the saving. */
|
|
1396
|
+
let pieces = ranked
|
|
1397
|
+
if (options.sentences !== false) {
|
|
1398
|
+
const picked = evidence(all, query, { maxChars: budget, limit: options.limit, blocks: options.blocks, lambda: options.lambda })
|
|
1399
|
+
pieces = picked.rows.map((row) => ({ block: { h: row.heading, t: row.text }, score: 0 }))
|
|
1400
|
+
}
|
|
1401
|
+
const limit = Math.max(1, Math.min(60, Number(options.limit ?? (options.sentences === false ? 8 : 24))))
|
|
1252
1402
|
const lines = []
|
|
1253
1403
|
let used = 0
|
|
1254
1404
|
let matched = 0
|
|
1255
1405
|
let lastHeading = ''
|
|
1256
|
-
for (const row of
|
|
1406
|
+
for (const row of pieces.slice(0, limit)) {
|
|
1257
1407
|
const body = String(row.block.t ?? '')
|
|
1258
1408
|
if (used + body.length > budget) continue
|
|
1259
1409
|
const heading = String(row.block.h ?? '')
|
package/sidecar.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* shows it as a screencast, so the tab is a viewer, not the browser.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, spawn, spawnSync } from 'node:child_process'
|
|
9
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync
|
|
9
|
+
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { readyFlash } from './runtime/flash-setup.mjs'
|
|
14
|
+
import { readSealed, tightenDir, writeSealed } from './vault.js'
|
|
14
15
|
import { isMedia, kindOf } from './media.js?m=1'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -113,6 +114,44 @@ const TAB_IDLE_MS = 10 * 60_000
|
|
|
113
114
|
/** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
|
|
114
115
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
115
116
|
|
|
117
|
+
/** @param {number} root - a process id. @returns {number} bytes of RSS the process tree holds. */
|
|
118
|
+
function treeBytes(root) {
|
|
119
|
+
if (!Number.isFinite(root) || root <= 0) return 0
|
|
120
|
+
try {
|
|
121
|
+
const rows = execFileSync('ps', ['-Ao', 'pid=,ppid=,rss='], { encoding: 'utf8', timeout: 3000 })
|
|
122
|
+
.trim()
|
|
123
|
+
.split('\n')
|
|
124
|
+
.map((line) => line.trim().split(/\s+/u))
|
|
125
|
+
.filter((cols) => cols.length >= 3)
|
|
126
|
+
/** @type {Map<number, number[]>} */
|
|
127
|
+
const byParent = new Map()
|
|
128
|
+
/** @type {Map<number, number>} */
|
|
129
|
+
const size = new Map()
|
|
130
|
+
for (const [pid, ppid, rss] of rows) {
|
|
131
|
+
const parent = Number(ppid)
|
|
132
|
+
const kids = byParent.get(parent) ?? []
|
|
133
|
+
kids.push(Number(pid))
|
|
134
|
+
byParent.set(parent, kids)
|
|
135
|
+
size.set(Number(pid), Number(rss) * 1024)
|
|
136
|
+
}
|
|
137
|
+
let total = size.get(root) ?? 0
|
|
138
|
+
const queue = [root]
|
|
139
|
+
const seen = new Set()
|
|
140
|
+
while (queue.length > 0) {
|
|
141
|
+
const pid = queue.shift()
|
|
142
|
+
if (seen.has(pid)) continue
|
|
143
|
+
seen.add(pid)
|
|
144
|
+
for (const kid of byParent.get(pid) ?? []) {
|
|
145
|
+
total += size.get(kid) ?? 0
|
|
146
|
+
queue.push(kid)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return total
|
|
150
|
+
} catch {
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
116
155
|
/** @param {string} path - candidate. @returns {string | undefined} the one that exists. */
|
|
117
156
|
function firstBinary() {
|
|
118
157
|
return CHROME_BINARIES.find((path) => existsSync(path))
|
|
@@ -348,9 +387,19 @@ export function snapshotLogin(dir) {
|
|
|
348
387
|
const files = COOKIE_FILES.map((name) => ({ name, from: join(dir, name) })).filter((row) => existsSync(row.from))
|
|
349
388
|
if (files.length === 0) return 0
|
|
350
389
|
const to = loginBackupDir(dir)
|
|
351
|
-
|
|
390
|
+
/* A copy of a cookie store is a copy of every sign-in in it: nobody else gets
|
|
391
|
+
to read the folder or the files, even on a shared machine. */
|
|
392
|
+
tightenDir(to)
|
|
352
393
|
const stamp = String(Date.now())
|
|
353
|
-
for (const row of files)
|
|
394
|
+
for (const row of files) {
|
|
395
|
+
const target = join(to, `${stamp}-${row.name.replace(/\//g, '_')}`)
|
|
396
|
+
copyFileSync(row.from, target)
|
|
397
|
+
try {
|
|
398
|
+
chmodSync(target, 0o600)
|
|
399
|
+
} catch {
|
|
400
|
+
/* best effort */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
354
403
|
const mine = readdirSync(to).filter((name) => name.startsWith(`${stamp}-`))
|
|
355
404
|
const older = readdirSync(to).filter((name) => !mine.includes(name)).sort()
|
|
356
405
|
const generations = 5
|
|
@@ -2996,6 +3045,7 @@ export class Sidecar {
|
|
|
2996
3045
|
* @param {number} [ms] - how long a browser nobody watches may stay up.
|
|
2997
3046
|
*/
|
|
2998
3047
|
touch(ms = IDLE_MS) {
|
|
3048
|
+
this.idleAt = Date.now() + ms
|
|
2999
3049
|
if (this.idle !== null) clearTimeout(this.idle)
|
|
3000
3050
|
this.idle = setTimeout(() => {
|
|
3001
3051
|
this.idle = null
|
|
@@ -3087,6 +3137,7 @@ export class Sidecar {
|
|
|
3087
3137
|
}
|
|
3088
3138
|
|
|
3089
3139
|
async shutdown() {
|
|
3140
|
+
this.idleAt = 0
|
|
3090
3141
|
/* A browser this process did not start belongs to somebody else — another
|
|
3091
3142
|
app on the same profile, or a script that connected to it — and closing
|
|
3092
3143
|
it would close the person's window. Let go of it instead: drop the socket
|
|
@@ -3158,7 +3209,7 @@ export class Sidecar {
|
|
|
3158
3209
|
rmSync(file, { force: true })
|
|
3159
3210
|
return
|
|
3160
3211
|
}
|
|
3161
|
-
|
|
3212
|
+
writeSealed(file, jar)
|
|
3162
3213
|
} catch {
|
|
3163
3214
|
/* a browser that is already gone has nothing to save */
|
|
3164
3215
|
}
|
|
@@ -3166,12 +3217,9 @@ export class Sidecar {
|
|
|
3166
3217
|
|
|
3167
3218
|
/** @returns {Promise<void>} puts back what saveSessionCookies kept. */
|
|
3168
3219
|
async restoreSessionCookies() {
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
} catch {
|
|
3173
|
-
return
|
|
3174
|
-
}
|
|
3220
|
+
/* Sealed by this version; plain JSON by one before it. Both are read, so an
|
|
3221
|
+
update never signs anyone out. */
|
|
3222
|
+
const jar = readSealed(sessionJar(profileDir(this.profile)))
|
|
3175
3223
|
if (!Array.isArray(jar)) return
|
|
3176
3224
|
/* A cookie the browser already holds is newer than the note: the site may
|
|
3177
3225
|
have rotated its session id since the note was written, and putting the
|
|
@@ -3250,6 +3298,12 @@ export class Sidecar {
|
|
|
3250
3298
|
screenshot is in this space, not in the window's. */
|
|
3251
3299
|
viewport: this.page?.view ?? null,
|
|
3252
3300
|
profiles,
|
|
3301
|
+
/* What this browser costs right now: the whole process tree, renderers
|
|
3302
|
+
included. Reported so a chat can decide whether to let it rest. */
|
|
3303
|
+
memory: this.alive ? treeBytes(Number(this.child?.pid ?? 0)) : 0,
|
|
3304
|
+
/* When it would close itself if nobody came back, and why: 0 means the
|
|
3305
|
+
browser is either watched or not on a clock. */
|
|
3306
|
+
closesIn: this.idle === null || this.stopping === true ? 0 : Math.max(0, Number(this.idleAt ?? 0) - Date.now()),
|
|
3253
3307
|
error: this.alive ? '' : this.lastError,
|
|
3254
3308
|
}
|
|
3255
3309
|
}
|
package/tools.js
CHANGED
|
@@ -16,6 +16,7 @@ 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
20
|
import { readPage, renderPage, readArticle, readRelevant, findOnPage, locate, locateFirst, FRAME_BASE, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, clickInDom, writeInDom, REF_GONE } from './read.js?v=1'
|
|
20
21
|
import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
|
|
21
22
|
|
|
@@ -965,6 +966,56 @@ async function evaluate(page, expression, route, raceMs = 2000) {
|
|
|
965
966
|
return result?.value
|
|
966
967
|
}
|
|
967
968
|
|
|
969
|
+
/**
|
|
970
|
+
* The local storage a page is holding, per origin: some sites keep the sign-in
|
|
971
|
+
* itself there and cookies alone would come back signed out.
|
|
972
|
+
* @param {any} page - sidecar page.
|
|
973
|
+
* @returns {Promise<Array<{origin: string, items: Record<string, string>}>>} what it holds.
|
|
974
|
+
*/
|
|
975
|
+
async function storageOf(page) {
|
|
976
|
+
const raw = await evaluate(
|
|
977
|
+
page,
|
|
978
|
+
'(() => { try { const items = {}; for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i); if (key !== null) items[key] = String(localStorage.getItem(key)).slice(0, 4000); } return JSON.stringify([{ origin: location.origin, items }]) } catch { return "[]" } })()',
|
|
979
|
+
null,
|
|
980
|
+
4000,
|
|
981
|
+
)
|
|
982
|
+
let rows = []
|
|
983
|
+
try {
|
|
984
|
+
rows = JSON.parse(String(raw ?? '[]'))
|
|
985
|
+
} catch {
|
|
986
|
+
return []
|
|
987
|
+
}
|
|
988
|
+
if (!Array.isArray(rows)) return []
|
|
989
|
+
return rows
|
|
990
|
+
.filter((row) => row !== null && typeof row === 'object' && typeof row.origin === 'string' && row.items !== null && typeof row.items === 'object')
|
|
991
|
+
.map((row) => ({ origin: String(row.origin), items: Object.fromEntries(Object.entries(row.items).slice(0, 200).map(([key, value]) => [key, String(value)])) }))
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Puts one origin's local storage back. The page has to be on that origin to
|
|
996
|
+
* write to it, so this navigates there first — a sign-in that lives in local
|
|
997
|
+
* storage is worth the extra step.
|
|
998
|
+
* @param {any} page - sidecar page.
|
|
999
|
+
* @param {{origin: string, items: Record<string, string>}} row - what to put back.
|
|
1000
|
+
* @returns {Promise<number>} how many entries went in.
|
|
1001
|
+
*/
|
|
1002
|
+
async function restoreStorage(page, row) {
|
|
1003
|
+
if (String(row?.origin ?? '') === '' || page === null) return 0
|
|
1004
|
+
try {
|
|
1005
|
+
const here = String(page.url ?? '')
|
|
1006
|
+
if (!here.startsWith(String(row.origin))) await page.navigate(String(row.origin))
|
|
1007
|
+
} catch {
|
|
1008
|
+
return 0
|
|
1009
|
+
}
|
|
1010
|
+
const written = await evaluate(
|
|
1011
|
+
page,
|
|
1012
|
+
`(() => { try { const items = ${JSON.stringify(row.items ?? {})}; for (const [key, value] of Object.entries(items)) localStorage.setItem(key, value); return Object.keys(items).length } catch { return 0 } })()`,
|
|
1013
|
+
null,
|
|
1014
|
+
4000,
|
|
1015
|
+
)
|
|
1016
|
+
return Number(written) || 0
|
|
1017
|
+
}
|
|
1018
|
+
|
|
968
1019
|
/**
|
|
969
1020
|
* What the page looks like in one cheap string: address, title, how many
|
|
970
1021
|
* elements and how much text. Two of these equal says the click did nothing
|
|
@@ -3050,6 +3101,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3050
3101
|
`mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
|
|
3051
3102
|
owner === '' ? 'no owner' : `owner ${owner}`,
|
|
3052
3103
|
`refs followed ${String(follows.count)}`,
|
|
3104
|
+
/* What it costs and when it would let go: the two facts that decide
|
|
3105
|
+
whether to keep using it or let it rest. */
|
|
3106
|
+
state.alive === true ? `memory ${Math.max(1, Math.round(Number(state.memory ?? 0) / 1048576))} MB` : 'memory 0 MB',
|
|
3107
|
+
state.closesIn > 0 ? `closes in ${Math.max(1, Math.round(Number(state.closesIn) / 60_000))} min unless used` : 'no close scheduled',
|
|
3053
3108
|
holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
|
|
3054
3109
|
]
|
|
3055
3110
|
if (state.alive === true && viewerNote(state.viewers) !== '') rows.push(NO_VIEWER)
|
|
@@ -3057,6 +3112,86 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
3057
3112
|
},
|
|
3058
3113
|
},
|
|
3059
3114
|
|
|
3115
|
+
{
|
|
3116
|
+
name: 'browser_login', // ui-copy-ignore
|
|
3117
|
+
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".',
|
|
3118
|
+
parameters: { // ui-copy-ignore
|
|
3119
|
+
type: 'object',
|
|
3120
|
+
properties: {
|
|
3121
|
+
action: { type: 'string', enum: ['list', 'save', 'use', 'drop'], description: 'list, save, use or drop.' },
|
|
3122
|
+
name: { type: 'string', description: 'Account name, for example "work" or "personal". Needed by save, use and drop.' },
|
|
3123
|
+
},
|
|
3124
|
+
required: ['action'], // ui-copy-ignore
|
|
3125
|
+
additionalProperties: false,
|
|
3126
|
+
},
|
|
3127
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
3128
|
+
timeoutMs: 25_000,
|
|
3129
|
+
isConcurrencySafe: () => false,
|
|
3130
|
+
/** @param {any} args - action and name. @returns {Promise<{text: string}>} what happened. */
|
|
3131
|
+
execute: async (args) => {
|
|
3132
|
+
const action = String(args?.action ?? '')
|
|
3133
|
+
if (action === 'list') {
|
|
3134
|
+
const rows = listLogins()
|
|
3135
|
+
const shape = vaultState()
|
|
3136
|
+
if (rows.length === 0) {
|
|
3137
|
+
return { text: 'Nothing saved yet. Sign in on the site, then browser_login {action:"save", name:"work"}.' }
|
|
3138
|
+
}
|
|
3139
|
+
const head = `${rows.length} kept sign-in${rows.length === 1 ? '' : 's'}, ${Math.max(1, Math.round(shape.bytes / 1024))} KB sealed${shape.keyInKeychain ? ', key in the system keystore' : ''}`
|
|
3140
|
+
const lines = rows.map((row) => {
|
|
3141
|
+
const sites = row.origins.length === 0 ? '' : `, ${row.origins.join(' ')}`
|
|
3142
|
+
return `${row.name} — ${row.cookieCount} cookies${sites}, saved ${new Date(row.savedAt).toLocaleString()}`
|
|
3143
|
+
})
|
|
3144
|
+
return { text: [head, ...lines].join('\n') }
|
|
3145
|
+
}
|
|
3146
|
+
const name = String(args?.name ?? '').trim()
|
|
3147
|
+
if (name === '') return { text: 'Name the account: browser_login {action:"save", name:"work"}.' }
|
|
3148
|
+
if (action === 'drop') {
|
|
3149
|
+
return { text: dropLogin(name) ? `Forgot "${name}".` : `Nothing saved as "${name}".` }
|
|
3150
|
+
}
|
|
3151
|
+
if (action === 'save') {
|
|
3152
|
+
const page = await sidecar.ensure({ session: currentSession() })
|
|
3153
|
+
const cookies = await page.cookies()
|
|
3154
|
+
const storage = await storageOf(page)
|
|
3155
|
+
const kept = saveLogin(name, { cookies, storage, url: String(page.url ?? '') })
|
|
3156
|
+
const sites = kept.origins.length === 0 ? '' : ` and ${kept.origins.length} site${kept.origins.length === 1 ? '' : 's'} of local storage`
|
|
3157
|
+
return { text: `Saved "${kept.name}": ${kept.cookieCount} cookies${sites}, sealed on disk.` }
|
|
3158
|
+
}
|
|
3159
|
+
if (action === 'use') {
|
|
3160
|
+
const held = readLogin(name)
|
|
3161
|
+
if (held === null) return { text: `Nothing saved as "${name}". browser_login {action:"list"} shows what is kept.` }
|
|
3162
|
+
const page = await sidecar.ensure({ session: currentSession() })
|
|
3163
|
+
let put = 0
|
|
3164
|
+
for (const cookie of (Array.isArray(held.cookies) ? held.cookies : []).slice(0, 400)) {
|
|
3165
|
+
const domain = String(cookie?.domain ?? '').replace(/^\./u, '')
|
|
3166
|
+
if (domain === '' || cookie?.name === undefined) continue
|
|
3167
|
+
try {
|
|
3168
|
+
await page.setCookie({
|
|
3169
|
+
name: String(cookie.name),
|
|
3170
|
+
value: String(cookie.value ?? ''),
|
|
3171
|
+
domain,
|
|
3172
|
+
path: String(cookie.path ?? '/'),
|
|
3173
|
+
secure: cookie.secure === true,
|
|
3174
|
+
httpOnly: cookie.httpOnly === true,
|
|
3175
|
+
sameSite: cookie.sameSite,
|
|
3176
|
+
expires: cookie.session === true ? undefined : Number(cookie.expires) || undefined,
|
|
3177
|
+
})
|
|
3178
|
+
put += 1
|
|
3179
|
+
} catch {
|
|
3180
|
+
/* one cookie a site refuses is not a reason to stop */
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
let stored = 0
|
|
3184
|
+
for (const row of (Array.isArray(held.storage) ? held.storage : []).slice(0, 4)) stored += await restoreStorage(page, row)
|
|
3185
|
+
const back = String(held.url ?? '')
|
|
3186
|
+
if (back !== '') await page.navigate(back)
|
|
3187
|
+
else await page.reload()
|
|
3188
|
+
const said = stored === 0 ? '' : `, ${stored} local storage entries`
|
|
3189
|
+
return { text: `Signed in as "${name}": ${put} cookies${said}, page reopened.` }
|
|
3190
|
+
}
|
|
3191
|
+
return { text: 'Pass action "list", "save", "use" or "drop".' }
|
|
3192
|
+
},
|
|
3193
|
+
},
|
|
3194
|
+
|
|
3060
3195
|
{
|
|
3061
3196
|
name: 'browser_resume', // ui-copy-ignore
|
|
3062
3197
|
description: 'Take the page back from the person at the mirror: clears the hold so the next action is sent at once, instead of waiting the few seconds out.',
|
package/vault.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in material at rest.
|
|
3
|
+
*
|
|
4
|
+
* A cookie jar is a password: whoever reads the file is signed in as the person.
|
|
5
|
+
* Chrome keeps its own store encrypted, but the copy this plugin makes when a
|
|
6
|
+
* session cookie would otherwise be dropped is a plain file, and a plain file in
|
|
7
|
+
* a home directory is one backup or one shared folder away from being someone
|
|
8
|
+
* else's session. Everything this plugin writes about a sign-in is therefore
|
|
9
|
+
* sealed with AES-256-GCM, and the key lives in the system Keychain — not next
|
|
10
|
+
* to the ciphertext, not in the profile, not in an environment variable by
|
|
11
|
+
* default.
|
|
12
|
+
*
|
|
13
|
+
* What stays unsealed is a name and a timestamp, so the list of accounts can be
|
|
14
|
+
* shown without opening the vault.
|
|
15
|
+
*/
|
|
16
|
+
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
|
|
22
|
+
const KEYCHAIN_SERVICE = 'dsh-inner-browser-use-vault'
|
|
23
|
+
const KEY_BYTES = 32
|
|
24
|
+
const IV_BYTES = 12
|
|
25
|
+
|
|
26
|
+
/** @returns {string} where the browser state, and so the vault, lives. */
|
|
27
|
+
export function vaultRoot() {
|
|
28
|
+
return process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The key, in order of preference: the one this run was handed (tests, or a
|
|
33
|
+
* person who keeps it in their own secret store), the one in the Keychain, the
|
|
34
|
+
* one on disk with owner-only permissions. A missing key is created once and
|
|
35
|
+
* then never rotated silently, because rotating it would sign everyone out.
|
|
36
|
+
* @returns {Buffer} 32 bytes.
|
|
37
|
+
*/
|
|
38
|
+
export function vaultKey() {
|
|
39
|
+
const given = String(process.env.DSH_INNER_BROWSER_VAULT_KEY ?? '')
|
|
40
|
+
if (given !== '') {
|
|
41
|
+
const raw = Buffer.from(given, 'base64')
|
|
42
|
+
if (raw.length === KEY_BYTES) return raw
|
|
43
|
+
}
|
|
44
|
+
const fromKeychain = readKeychain()
|
|
45
|
+
if (fromKeychain !== null) return fromKeychain
|
|
46
|
+
const file = join(vaultRoot(), 'vault.key')
|
|
47
|
+
if (existsSync(file)) {
|
|
48
|
+
const raw = Buffer.from(String(readFileSync(file, 'utf8')).trim(), 'base64')
|
|
49
|
+
if (raw.length === KEY_BYTES) return raw
|
|
50
|
+
}
|
|
51
|
+
const made = randomBytes(KEY_BYTES)
|
|
52
|
+
const written = writeKeychain(made)
|
|
53
|
+
if (written === true) return made
|
|
54
|
+
mkdirSync(vaultRoot(), { recursive: true })
|
|
55
|
+
writeFileSync(file, `${made.toString('base64')}\n`, { mode: 0o600 })
|
|
56
|
+
chmodSync(file, 0o600)
|
|
57
|
+
return made
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns {Buffer|null} the Keychain copy, if this platform has one. */
|
|
61
|
+
function readKeychain() {
|
|
62
|
+
if (process.platform !== 'darwin') return null
|
|
63
|
+
try {
|
|
64
|
+
const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'vault', '-w'], {
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
timeout: 4000,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
+
})
|
|
69
|
+
const raw = Buffer.from(out.trim(), 'base64')
|
|
70
|
+
return raw.length === KEY_BYTES ? raw : null
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {Buffer} key - the material. @returns {boolean} whether the Keychain took it. */
|
|
77
|
+
function writeKeychain(key) {
|
|
78
|
+
if (process.platform !== 'darwin') return false
|
|
79
|
+
try {
|
|
80
|
+
execFileSync('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', 'vault', '-w', key.toString('base64')], {
|
|
81
|
+
timeout: 4000,
|
|
82
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
83
|
+
})
|
|
84
|
+
return true
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @returns {'env'|'keychain'|'file'|'none'} which of the three the key in use came from. */
|
|
91
|
+
function keySource() {
|
|
92
|
+
const given = String(process.env.DSH_INNER_BROWSER_VAULT_KEY ?? '')
|
|
93
|
+
if (given !== '' && Buffer.from(given, 'base64').length === KEY_BYTES) return 'env'
|
|
94
|
+
if (readKeychain() !== null) return 'keychain'
|
|
95
|
+
const file = join(vaultRoot(), 'vault.key')
|
|
96
|
+
if (existsSync(file)) {
|
|
97
|
+
const raw = Buffer.from(String(readFileSync(file, 'utf8')).trim(), 'base64')
|
|
98
|
+
if (raw.length === KEY_BYTES) return 'file'
|
|
99
|
+
}
|
|
100
|
+
return 'none'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {unknown} value - anything JSON can hold.
|
|
105
|
+
* @returns {{v: number, alg: string, iv: string, tag: string, data: string}} the sealed blob.
|
|
106
|
+
*/
|
|
107
|
+
export function seal(value) {
|
|
108
|
+
const key = vaultKey()
|
|
109
|
+
const iv = randomBytes(IV_BYTES)
|
|
110
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
111
|
+
const data = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(value), 'utf8')), cipher.final()])
|
|
112
|
+
return {
|
|
113
|
+
v: 1,
|
|
114
|
+
alg: 'aes-256-gcm',
|
|
115
|
+
iv: iv.toString('base64'),
|
|
116
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
117
|
+
data: data.toString('base64'),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Opens a sealed blob. A tampered blob, a different key, or a plain array all
|
|
123
|
+
* come back as `undefined` rather than as data that might be trusted.
|
|
124
|
+
* @param {unknown} blob - what `seal` returned, or its JSON text.
|
|
125
|
+
* @returns {any} the value, or undefined.
|
|
126
|
+
*/
|
|
127
|
+
export function unseal(blob) {
|
|
128
|
+
let held = blob
|
|
129
|
+
if (typeof held === 'string') {
|
|
130
|
+
try {
|
|
131
|
+
held = JSON.parse(held)
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (held === null || typeof held !== 'object') return undefined
|
|
137
|
+
const row = /** @type {any} */ (held)
|
|
138
|
+
if (row.v !== 1 || row.alg !== 'aes-256-gcm') return undefined
|
|
139
|
+
try {
|
|
140
|
+
const key = vaultKey()
|
|
141
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(String(row.iv), 'base64'))
|
|
142
|
+
decipher.setAuthTag(Buffer.from(String(row.tag), 'base64'))
|
|
143
|
+
const text = Buffer.concat([decipher.update(Buffer.from(String(row.data), 'base64')), decipher.final()])
|
|
144
|
+
return JSON.parse(text.toString('utf8'))
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reads a file that is either sealed or, from an older version, plain JSON.
|
|
152
|
+
* @param {string} path - the file.
|
|
153
|
+
* @returns {any} its value, or undefined.
|
|
154
|
+
*/
|
|
155
|
+
export function readSealed(path) {
|
|
156
|
+
let text = ''
|
|
157
|
+
try {
|
|
158
|
+
text = String(readFileSync(path, 'utf8'))
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
const opened = unseal(text)
|
|
163
|
+
if (opened !== undefined) return opened
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(text)
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Writes a sealed file and makes sure nobody but the owner can read it, even if
|
|
173
|
+
* it was created earlier with looser permissions.
|
|
174
|
+
* @param {string} path - where.
|
|
175
|
+
* @param {unknown} value - what.
|
|
176
|
+
* @returns {void}
|
|
177
|
+
*/
|
|
178
|
+
export function writeSealed(path, value) {
|
|
179
|
+
writeFileSync(path, `${JSON.stringify(seal(value))}\n`, { mode: 0o600 })
|
|
180
|
+
try {
|
|
181
|
+
chmodSync(path, 0o600)
|
|
182
|
+
} catch {
|
|
183
|
+
/* a file system without permissions is not a reason to fail the save */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {string} path - a directory. @returns {void} owner-only, best effort. */
|
|
188
|
+
export function tightenDir(path) {
|
|
189
|
+
try {
|
|
190
|
+
mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
191
|
+
chmodSync(path, 0o700)
|
|
192
|
+
} catch {
|
|
193
|
+
/* best effort */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ------------------------------------------------------------------ logins */
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A name becomes a file name. Anything that could climb out of the directory or
|
|
201
|
+
* collide on a case-insensitive file system is folded to a safe character.
|
|
202
|
+
* @param {string} name - what the person called the account.
|
|
203
|
+
* @returns {string} the file stem.
|
|
204
|
+
*/
|
|
205
|
+
export function loginSlug(name) {
|
|
206
|
+
const raw = String(name ?? '').trim()
|
|
207
|
+
if (raw === '') return ''
|
|
208
|
+
return raw
|
|
209
|
+
.replace(/[^\w. -]/gu, '_')
|
|
210
|
+
.replace(/\s+/gu, '-')
|
|
211
|
+
.replace(/^[.-]+/u, '')
|
|
212
|
+
.slice(0, 48)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @returns {string} where named accounts live. */
|
|
216
|
+
export function loginDir() {
|
|
217
|
+
const dir = join(vaultRoot(), 'logins')
|
|
218
|
+
tightenDir(dir)
|
|
219
|
+
return dir
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} name - account name.
|
|
224
|
+
* @returns {string} the file for it.
|
|
225
|
+
*/
|
|
226
|
+
function loginFile(name) {
|
|
227
|
+
const slug = loginSlug(name)
|
|
228
|
+
return slug === '' ? '' : join(loginDir(), `${slug}.json`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Keeps one account: cookies plus the local storage of the sites it was saved
|
|
233
|
+
* on. Sealed as a whole, so even the list of sites is not readable off disk.
|
|
234
|
+
* @param {string} name - account name.
|
|
235
|
+
* @param {{cookies: object[], storage: Array<{origin: string, items: Record<string, string>}>, url?: string}} payload - what to keep.
|
|
236
|
+
* @returns {{name: string, savedAt: number, cookieCount: number, origins: string[]}} what was written.
|
|
237
|
+
*/
|
|
238
|
+
export function saveLogin(name, payload) {
|
|
239
|
+
const file = loginFile(name)
|
|
240
|
+
if (file === '') throw new Error('a login needs a name')
|
|
241
|
+
const cookies = Array.isArray(payload?.cookies) ? payload.cookies.slice(0, 400) : []
|
|
242
|
+
const storage = Array.isArray(payload?.storage) ? payload.storage.slice(0, 40) : []
|
|
243
|
+
const savedAt = Date.now()
|
|
244
|
+
writeSealed(file, {
|
|
245
|
+
name: String(name).trim().slice(0, 80),
|
|
246
|
+
savedAt,
|
|
247
|
+
url: String(payload?.url ?? ''),
|
|
248
|
+
cookies,
|
|
249
|
+
storage,
|
|
250
|
+
})
|
|
251
|
+
return {
|
|
252
|
+
name: String(name).trim().slice(0, 80),
|
|
253
|
+
savedAt,
|
|
254
|
+
cookieCount: cookies.length,
|
|
255
|
+
origins: storage.map((row) => String(row?.origin ?? '')).filter(Boolean),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {string} name - account name.
|
|
261
|
+
* @returns {any|null} the kept account, or null.
|
|
262
|
+
*/
|
|
263
|
+
export function readLogin(name) {
|
|
264
|
+
const file = loginFile(name)
|
|
265
|
+
if (file === '' || !existsSync(file)) return null
|
|
266
|
+
const held = readSealed(file)
|
|
267
|
+
return held === undefined ? null : held
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Everything in the vault, newest first. Only the envelope is needed for the
|
|
272
|
+
* list, but the envelope is sealed too, so this opens each file.
|
|
273
|
+
* @returns {Array<{name: string, savedAt: number, cookieCount: number, origins: string[]}>} the accounts.
|
|
274
|
+
*/
|
|
275
|
+
export function listLogins() {
|
|
276
|
+
const dir = loginDir()
|
|
277
|
+
const rows = []
|
|
278
|
+
let names = []
|
|
279
|
+
try {
|
|
280
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.json'))
|
|
281
|
+
} catch {
|
|
282
|
+
return rows
|
|
283
|
+
}
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
const held = readSealed(join(dir, name))
|
|
286
|
+
if (held === null || typeof held !== 'object') continue
|
|
287
|
+
rows.push({
|
|
288
|
+
name: String(held.name ?? name.replace(/\.json$/u, '')),
|
|
289
|
+
savedAt: Number(held.savedAt ?? 0),
|
|
290
|
+
cookieCount: Array.isArray(held.cookies) ? held.cookies.length : 0,
|
|
291
|
+
origins: Array.isArray(held.storage) ? held.storage.map((row) => String(row?.origin ?? '')).filter(Boolean) : [],
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
return rows.sort((a, b) => b.savedAt - a.savedAt)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {string} name - account name.
|
|
299
|
+
* @returns {boolean} whether something was there to remove.
|
|
300
|
+
*/
|
|
301
|
+
export function dropLogin(name) {
|
|
302
|
+
const file = loginFile(name)
|
|
303
|
+
if (file === '' || !existsSync(file)) return false
|
|
304
|
+
rmSync(file, { force: true })
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* How big the vault is, and whether the key is outside it: the two facts a
|
|
310
|
+
* person wants when deciding whether a sign-in is safe to leave here.
|
|
311
|
+
* @returns {{files: number, bytes: number, keyInKeychain: boolean, keySource: string}} the shape.
|
|
312
|
+
*/
|
|
313
|
+
export function vaultState() {
|
|
314
|
+
const dir = loginDir()
|
|
315
|
+
let files = 0
|
|
316
|
+
let bytes = 0
|
|
317
|
+
try {
|
|
318
|
+
for (const name of readdirSync(dir)) {
|
|
319
|
+
if (!name.endsWith('.json')) continue
|
|
320
|
+
files += 1
|
|
321
|
+
bytes += statSync(join(dir, name)).size
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
/* an empty vault is a valid vault */
|
|
325
|
+
}
|
|
326
|
+
const source = keySource()
|
|
327
|
+
return { files, bytes, keyInKeychain: source === 'keychain', keySource: source }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @param {string} path - two files. @param {Buffer} a - one. @param {Buffer} b - the other. @returns {boolean} same bytes. */
|
|
331
|
+
export function sameKey(a, b) {
|
|
332
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
333
|
+
}
|