dsh-inner-browser-use 0.38.0 → 0.39.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 -1
- package/README.zh.md +2 -1
- package/docs/ARCHITECTURE.md +39 -0
- package/docs/ARTIFACTS.md +3 -1
- package/docs/BENCH.md +14 -0
- package/index.js +2 -0
- package/package.json +3 -2
- package/sidecar.js +64 -10
- package/tools.js +135 -0
- package/vault.js +333 -0
package/README.md
CHANGED
|
@@ -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
|
@@ -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,49 @@ 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
|
+
## Sign-ins and reclaim
|
|
169
|
+
|
|
168
170
|
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
169
171
|
which Chrome would otherwise drop on exit — are saved next to it and restored on the next
|
|
170
172
|
launch, so a restart of the app does not sign the person out.
|
|
171
173
|
|
|
174
|
+
Three layers, each adding one thing the previous cannot do:
|
|
175
|
+
|
|
176
|
+
- **The profile** keeps everything Chrome writes: dated cookies, origin storage, service
|
|
177
|
+
workers. It survives a restart of the app and a restart of the machine.
|
|
178
|
+
- **The session jar** (`session-cookies.json`) covers the part Chrome throws away on exit.
|
|
179
|
+
Every cookie that comes back from `page.cookies()` without an expiry is written when the
|
|
180
|
+
browser closes, and put back on the next launch. A cookie the browser already holds is
|
|
181
|
+
never overwritten from the jar — a rotation happened in the live browser, and the jar is
|
|
182
|
+
the older truth.
|
|
183
|
+
- **`browser_login`** (`vault.js`) turns that state into named snapshots a chat can call
|
|
184
|
+
back: cookies plus per-origin `localStorage`, saved under a name, listed, restored, or
|
|
185
|
+
dropped. `list` is a directory read, so it costs nothing while the browser is down.
|
|
186
|
+
Restoring writes cookies first, then navigates to each stored origin to write its local
|
|
187
|
+
storage (the API is origin-bound), then reopens the page that was saved.
|
|
188
|
+
|
|
189
|
+
Everything written at rest is sealed with AES-256-GCM (`{v, alg, iv, tag, data}`, all
|
|
190
|
+
base64). The key comes from `DSH_INNER_BROWSER_VAULT_KEY` when a test sets one, otherwise
|
|
191
|
+
from the macOS keychain, otherwise from `vault.key` in the state root — 0600, and a fallback
|
|
192
|
+
rather than the intended home, because an unattended `security add-generic-password` can
|
|
193
|
+
block on a prompt. A tampered blob, a wrong key and a truncated file all come back empty;
|
|
194
|
+
nothing trusts what it cannot verify. A plaintext jar from a version before this one is
|
|
195
|
+
still read and re-sealed on the next write, so an upgrade does not sign anyone out.
|
|
196
|
+
|
|
197
|
+
The reclaim ladder is three separate timers, because they answer three different questions:
|
|
198
|
+
|
|
199
|
+
| Question | Timer | What happens |
|
|
200
|
+
| --- | --- | --- |
|
|
201
|
+
| Did the person close the column? | `REST_MS` = 60 s | The browser is put away a minute after the last pane lets go. |
|
|
202
|
+
| 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. |
|
|
203
|
+
| 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`). |
|
|
204
|
+
|
|
205
|
+
`browser_state` reports both what the process costs right now (`ps` over its process tree,
|
|
206
|
+
which includes renderers, not just the parent) and `closesIn`, so the model can decide
|
|
207
|
+
between keeping the page and letting it go instead of guessing. A freeze
|
|
208
|
+
(`Page.setWebLifecycleState`) is deliberately not wired in: closing a stale tab is already
|
|
209
|
+
cheap, and a frozen page that misses a mutation would make the event-driven wait lie.
|
|
210
|
+
|
|
172
211
|
A system-prompt section (`browser-in-this-chat`) tells the model to reach for these tools
|
|
173
212
|
without asking first when someone wants something done on a site.
|
|
174
213
|
|
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,17 @@
|
|
|
1
|
+
## 登录能留、能切、关得掉(0.39.0)
|
|
2
|
+
|
|
3
|
+
本地页签在加载时写一个会话 cookie 和一个 `localStorage` 令牌,然后用工具层实际走一遍:保存 → 清空浏览器里的一切 → 切回。磁盘上那一份同时用字符串搜一遍。
|
|
4
|
+
|
|
5
|
+
| 项 | 结果 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `browser_login {action:"save"}` | 2 ms,8 个 cookie |
|
|
8
|
+
| `browser_login {action:"use"}`(清空后切回、重开页面) | 105 ms |
|
|
9
|
+
| 切回后 cookie 与 `localStorage` | 都在(`signedIn: true`) |
|
|
10
|
+
| 密文里能否搜到 cookie 值 / 令牌 | 搜不到(`sealed: true`) |
|
|
11
|
+
| 预算 | `login-vault: 1600 ms`,19 项全过 |
|
|
12
|
+
|
|
13
|
+
`browser_state` 另外报两行:当前进程树占多少内存(含渲染进程),以及没人用的话还有多久关闭——两者都来自 `ps` 一次采样和已有的空闲计时器,不额外起进程。关闭策略没变:右栏关掉约 1 分钟、没人碰约 15 分钟回收整个浏览器,页签 10 分钟不动、最多 6 个。
|
|
14
|
+
|
|
1
15
|
## 等到变化,而不是去问(0.38.0)
|
|
2
16
|
|
|
3
17
|
`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.39.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/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
|
+
}
|