@ulpi/browse 0.1.0 → 0.2.1
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 +9 -3
- package/bin/browse.ts +10 -1
- package/package.json +9 -8
- package/skill/SKILL.md +163 -26
- package/src/auth-vault.ts +244 -0
- package/src/browser-manager.ts +177 -2
- package/src/cli.ts +159 -25
- package/src/commands/meta.ts +176 -5
- package/src/commands/read.ts +39 -13
- package/src/commands/write.ts +200 -6
- package/src/config.ts +44 -0
- package/src/constants.ts +4 -2
- package/src/domain-filter.ts +134 -0
- package/src/har.ts +66 -0
- package/src/policy.ts +94 -0
- package/src/sanitize.ts +11 -0
- package/src/server.ts +196 -56
- package/src/session-manager.ts +65 -2
- package/src/snapshot.ts +18 -13
package/README.md
CHANGED
|
@@ -172,15 +172,21 @@ bun install -g @ulpi/browse
|
|
|
172
172
|
|
|
173
173
|
Requires [Bun](https://bun.sh). Chromium is installed automatically via Playwright.
|
|
174
174
|
|
|
175
|
-
### Claude Code Skill
|
|
175
|
+
### Claude Code Skill
|
|
176
176
|
|
|
177
|
-
Install
|
|
177
|
+
Install via [skills.sh](https://skills.sh) (works across Claude Code, Cursor, Cline, Windsurf, and 15+ agents):
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npx skills add https://github.com/ulpi-io/skills --skill browse
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Or install directly into your project:
|
|
178
184
|
|
|
179
185
|
```bash
|
|
180
186
|
browse install-skill
|
|
181
187
|
```
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
Both copy the skill definition to `.claude/skills/browse/SKILL.md` and add all browse commands to permissions — no more approval prompts.
|
|
184
190
|
|
|
185
191
|
## Real-World Example: E-Commerce Flow
|
|
186
192
|
|
package/bin/browse.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import '../src/cli';
|
|
2
|
+
import { main } from '../src/cli';
|
|
3
|
+
|
|
4
|
+
if (process.env.__BROWSE_SERVER_MODE === '1') {
|
|
5
|
+
import('../src/server');
|
|
6
|
+
} else {
|
|
7
|
+
main().catch((err) => {
|
|
8
|
+
console.error(`[browse] ${err.message}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulpi/browse",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/ulpi-io/browse"
|
|
@@ -37,18 +37,19 @@
|
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
|
-
"type": "module",
|
|
41
|
-
"devDependencies": {
|
|
42
|
-
"@types/node": "^25.5.0",
|
|
43
|
-
"typescript": "^5.9.3"
|
|
44
|
-
},
|
|
45
40
|
"scripts": {
|
|
46
|
-
"build": "bun build --compile src/cli.ts --outfile dist/browse",
|
|
41
|
+
"build": "bun build --compile --external electron --external chromium-bidi src/cli.ts --outfile dist/browse",
|
|
42
|
+
"build:all": "bash scripts/build-all.sh",
|
|
47
43
|
"dev": "bun run src/cli.ts",
|
|
48
44
|
"server": "bun run src/server.ts",
|
|
49
45
|
"test": "bun test",
|
|
50
46
|
"start": "bun run src/server.ts",
|
|
51
47
|
"postinstall": "bunx playwright install chromium",
|
|
52
48
|
"benchmark": "bun run benchmark.ts"
|
|
49
|
+
},
|
|
50
|
+
"type": "module",
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^25.5.0",
|
|
53
|
+
"typescript": "^5.9.3"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: browse
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
4
|
description: |
|
|
5
5
|
Fast web browsing for Claude Code via persistent headless Chromium daemon. Navigate to any URL,
|
|
6
6
|
read page content, click elements, fill forms, run JavaScript, take screenshots,
|
|
@@ -53,20 +53,29 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
53
53
|
"Bash(browse html:*)", "Bash(browse links:*)", "Bash(browse forms:*)",
|
|
54
54
|
"Bash(browse accessibility:*)", "Bash(browse snapshot:*)",
|
|
55
55
|
"Bash(browse snapshot-diff:*)", "Bash(browse click:*)",
|
|
56
|
-
"Bash(browse
|
|
57
|
-
"Bash(browse
|
|
58
|
-
"Bash(browse
|
|
56
|
+
"Bash(browse dblclick:*)", "Bash(browse fill:*)", "Bash(browse select:*)",
|
|
57
|
+
"Bash(browse hover:*)", "Bash(browse focus:*)",
|
|
58
|
+
"Bash(browse check:*)", "Bash(browse uncheck:*)",
|
|
59
|
+
"Bash(browse type:*)", "Bash(browse press:*)",
|
|
60
|
+
"Bash(browse keydown:*)", "Bash(browse keyup:*)",
|
|
61
|
+
"Bash(browse scroll:*)", "Bash(browse wait:*)",
|
|
62
|
+
"Bash(browse viewport:*)", "Bash(browse upload:*)",
|
|
63
|
+
"Bash(browse drag:*)", "Bash(browse highlight:*)", "Bash(browse download:*)",
|
|
59
64
|
"Bash(browse dialog-accept:*)", "Bash(browse dialog-dismiss:*)",
|
|
60
65
|
"Bash(browse js:*)", "Bash(browse eval:*)", "Bash(browse css:*)",
|
|
61
|
-
"Bash(browse attrs:*)", "Bash(browse state:*)", "Bash(browse dialog:*)",
|
|
66
|
+
"Bash(browse attrs:*)", "Bash(browse element-state:*)", "Bash(browse dialog:*)",
|
|
62
67
|
"Bash(browse console:*)", "Bash(browse network:*)",
|
|
63
68
|
"Bash(browse cookies:*)", "Bash(browse storage:*)", "Bash(browse perf:*)",
|
|
69
|
+
"Bash(browse value:*)", "Bash(browse count:*)",
|
|
64
70
|
"Bash(browse devices:*)", "Bash(browse emulate:*)",
|
|
65
71
|
"Bash(browse screenshot:*)", "Bash(browse pdf:*)",
|
|
66
72
|
"Bash(browse responsive:*)", "Bash(browse diff:*)",
|
|
67
73
|
"Bash(browse chain:*)", "Bash(browse tabs:*)", "Bash(browse tab:*)",
|
|
68
74
|
"Bash(browse newtab:*)", "Bash(browse closetab:*)",
|
|
75
|
+
"Bash(browse frame:*)",
|
|
69
76
|
"Bash(browse sessions:*)", "Bash(browse session-close:*)",
|
|
77
|
+
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)",
|
|
78
|
+
"Bash(browse route:*)", "Bash(browse offline:*)",
|
|
70
79
|
"Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
|
|
71
80
|
"Bash(browse cookie:*)", "Bash(browse header:*)",
|
|
72
81
|
"Bash(browse useragent:*)"
|
|
@@ -80,6 +89,9 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
80
89
|
- The browser persists between calls — cookies, tabs, and state carry over.
|
|
81
90
|
- The server auto-starts on first command. No manual setup needed.
|
|
82
91
|
- Use `--session <id>` for parallel agent isolation. Each session gets its own tabs, refs, cookies.
|
|
92
|
+
- Use `--json` for structured output (`{success, data, command}`).
|
|
93
|
+
- Use `--content-boundaries` for prompt injection defense.
|
|
94
|
+
- Use `--allowed-domains domain1,domain2` to restrict navigation.
|
|
83
95
|
|
|
84
96
|
## Quick Reference
|
|
85
97
|
|
|
@@ -91,7 +103,7 @@ browse goto https://example.com
|
|
|
91
103
|
browse text
|
|
92
104
|
|
|
93
105
|
# Take a screenshot (then Read the image)
|
|
94
|
-
browse screenshot .browse/
|
|
106
|
+
browse screenshot .browse/sessions/default/screenshot.png
|
|
95
107
|
|
|
96
108
|
# Snapshot: accessibility tree with refs
|
|
97
109
|
browse snapshot -i
|
|
@@ -102,12 +114,25 @@ browse click @e3
|
|
|
102
114
|
# Fill by ref
|
|
103
115
|
browse fill @e4 "test@test.com"
|
|
104
116
|
|
|
117
|
+
# Double-click, focus, check/uncheck
|
|
118
|
+
browse dblclick @e3
|
|
119
|
+
browse focus @e5
|
|
120
|
+
browse check @e7
|
|
121
|
+
browse uncheck @e7
|
|
122
|
+
|
|
123
|
+
# Drag and drop
|
|
124
|
+
browse drag @e1 @e2
|
|
125
|
+
|
|
105
126
|
# Run JavaScript
|
|
106
127
|
browse js "document.title"
|
|
107
128
|
|
|
108
129
|
# Get all links
|
|
109
130
|
browse links
|
|
110
131
|
|
|
132
|
+
# Get input value / count elements
|
|
133
|
+
browse value "[id=email]"
|
|
134
|
+
browse count ".search-result"
|
|
135
|
+
|
|
111
136
|
# Click by CSS selector
|
|
112
137
|
browse click "button.submit"
|
|
113
138
|
|
|
@@ -116,17 +141,58 @@ browse fill "[id=email]" "test@test.com"
|
|
|
116
141
|
browse fill "[id=password]" "abc123"
|
|
117
142
|
browse click "button[type=submit]"
|
|
118
143
|
|
|
119
|
-
#
|
|
120
|
-
browse
|
|
144
|
+
# Scroll
|
|
145
|
+
browse scroll up
|
|
146
|
+
browse scroll down
|
|
147
|
+
browse scroll "[id=target]"
|
|
148
|
+
|
|
149
|
+
# Wait for navigation or network
|
|
150
|
+
browse wait ".loaded"
|
|
151
|
+
browse wait --url "**/dashboard"
|
|
152
|
+
browse wait --network-idle
|
|
121
153
|
|
|
122
|
-
#
|
|
123
|
-
browse
|
|
154
|
+
# iframe targeting
|
|
155
|
+
browse frame "[id=my-iframe]"
|
|
156
|
+
browse text # reads from inside the iframe
|
|
157
|
+
browse click @e3 # clicks inside the iframe
|
|
158
|
+
browse frame main # back to main page
|
|
124
159
|
|
|
125
|
-
#
|
|
126
|
-
browse
|
|
160
|
+
# Highlight an element (visual debugging)
|
|
161
|
+
browse highlight @e5
|
|
127
162
|
|
|
128
|
-
#
|
|
129
|
-
browse
|
|
163
|
+
# Download a file
|
|
164
|
+
browse download @e3 ./file.pdf
|
|
165
|
+
|
|
166
|
+
# Network mocking
|
|
167
|
+
browse route "**/*.png" block
|
|
168
|
+
browse route "**/api/data" fulfill 200 '{"mock":true}'
|
|
169
|
+
browse route clear
|
|
170
|
+
|
|
171
|
+
# Offline mode
|
|
172
|
+
browse offline on
|
|
173
|
+
browse offline off
|
|
174
|
+
|
|
175
|
+
# JSON output mode
|
|
176
|
+
browse --json goto https://example.com
|
|
177
|
+
|
|
178
|
+
# Security: content boundaries
|
|
179
|
+
browse --content-boundaries text
|
|
180
|
+
|
|
181
|
+
# Security: domain restriction
|
|
182
|
+
browse --allowed-domains example.com,*.cdn.example.com goto https://example.com
|
|
183
|
+
|
|
184
|
+
# State persistence
|
|
185
|
+
browse state save mysite
|
|
186
|
+
browse state load mysite
|
|
187
|
+
|
|
188
|
+
# Auth vault (credentials never visible to LLM)
|
|
189
|
+
browse auth save github https://github.com/login user pass123
|
|
190
|
+
browse auth login github
|
|
191
|
+
|
|
192
|
+
# HAR recording
|
|
193
|
+
browse har start
|
|
194
|
+
browse goto https://example.com
|
|
195
|
+
browse har stop ./recording.har
|
|
130
196
|
|
|
131
197
|
# Device emulation
|
|
132
198
|
browse emulate iphone
|
|
@@ -183,19 +249,36 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
|
|
183
249
|
### Interaction
|
|
184
250
|
```
|
|
185
251
|
browse click <selector> Click element (CSS selector or @ref)
|
|
252
|
+
browse dblclick <selector> Double-click element
|
|
186
253
|
browse fill <selector> <value> Fill input field
|
|
187
254
|
browse select <selector> <val> Select dropdown value
|
|
188
255
|
browse hover <selector> Hover over element
|
|
256
|
+
browse focus <selector> Focus element
|
|
257
|
+
browse check <selector> Check checkbox
|
|
258
|
+
browse uncheck <selector> Uncheck checkbox
|
|
259
|
+
browse drag <src> <tgt> Drag source to target
|
|
189
260
|
browse type <text> Type into focused element
|
|
190
261
|
browse press <key> Press key (Enter, Tab, Escape, etc.)
|
|
191
|
-
browse
|
|
192
|
-
browse
|
|
262
|
+
browse keydown <key> Hold key down
|
|
263
|
+
browse keyup <key> Release key
|
|
264
|
+
browse scroll [sel|up|down] Scroll element/viewport/bottom
|
|
265
|
+
browse wait <sel|--url|--network-idle> Wait for element, URL, or network
|
|
193
266
|
browse viewport <WxH> Set viewport size (e.g. 375x812)
|
|
194
267
|
browse upload <sel> <files> Upload file(s) to a file input
|
|
268
|
+
browse highlight <selector> Highlight element (visual debugging)
|
|
269
|
+
browse download <sel> [path] Download file triggered by click
|
|
195
270
|
browse dialog-accept [value] Set dialogs to auto-accept
|
|
196
271
|
browse dialog-dismiss Set dialogs to auto-dismiss (default)
|
|
197
272
|
browse emulate <device> Emulate device (iphone, pixel, etc.)
|
|
198
273
|
browse emulate reset Reset to desktop (1920x1080)
|
|
274
|
+
browse offline [on|off] Toggle offline mode
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Network
|
|
278
|
+
```
|
|
279
|
+
browse route <pattern> block Block matching requests
|
|
280
|
+
browse route <pattern> fulfill <s> [b] Mock with status + body
|
|
281
|
+
browse route clear Remove all routes
|
|
199
282
|
```
|
|
200
283
|
|
|
201
284
|
### Inspection
|
|
@@ -204,7 +287,9 @@ browse js <expression> Run JS, print result
|
|
|
204
287
|
browse eval <js-file> Run JS file against page
|
|
205
288
|
browse css <selector> <prop> Get computed CSS property
|
|
206
289
|
browse attrs <selector> Get element attributes as JSON
|
|
207
|
-
browse state <selector>
|
|
290
|
+
browse element-state <selector> Element state (visible/enabled/checked/focused)
|
|
291
|
+
browse value <selector> Get input field value
|
|
292
|
+
browse count <selector> Count matching elements
|
|
208
293
|
browse dialog Last dialog info or "(no dialog detected)"
|
|
209
294
|
browse console [--clear] View/clear console messages
|
|
210
295
|
browse network [--clear] View/clear network requests
|
|
@@ -216,12 +301,18 @@ browse devices [filter] List available device names
|
|
|
216
301
|
|
|
217
302
|
### Visual
|
|
218
303
|
```
|
|
219
|
-
browse screenshot [path] Screenshot (default: .browse/
|
|
304
|
+
browse screenshot [path] Screenshot (default: .browse/sessions/{id}/screenshot.png)
|
|
220
305
|
browse screenshot --annotate [path] Screenshot with numbered badges + legend
|
|
221
306
|
browse pdf [path] Save as PDF
|
|
222
307
|
browse responsive [prefix] Screenshots at mobile/tablet/desktop
|
|
223
308
|
```
|
|
224
309
|
|
|
310
|
+
### Frames (iframe targeting)
|
|
311
|
+
```
|
|
312
|
+
browse frame <selector> Target an iframe (subsequent commands run inside it)
|
|
313
|
+
browse frame main Return to main page
|
|
314
|
+
```
|
|
315
|
+
|
|
225
316
|
### Compare
|
|
226
317
|
```
|
|
227
318
|
browse diff <url1> <url2> Text diff between two pages
|
|
@@ -247,6 +338,26 @@ browse sessions List active sessions
|
|
|
247
338
|
browse session-close <id> Close a session
|
|
248
339
|
```
|
|
249
340
|
|
|
341
|
+
### State persistence
|
|
342
|
+
```
|
|
343
|
+
browse state save [name] Save cookies + localStorage (all origins)
|
|
344
|
+
browse state load [name] Restore saved state
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Auth vault
|
|
348
|
+
```
|
|
349
|
+
browse auth save <name> <url> <user> <pass> Save credentials (encrypted)
|
|
350
|
+
browse auth login <name> Auto-login using saved credentials
|
|
351
|
+
browse auth list List saved credentials
|
|
352
|
+
browse auth delete <name> Delete credentials
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### HAR recording
|
|
356
|
+
```
|
|
357
|
+
browse har start Start recording network traffic
|
|
358
|
+
browse har stop [path] Stop and save HAR file
|
|
359
|
+
```
|
|
360
|
+
|
|
250
361
|
### Server management
|
|
251
362
|
```
|
|
252
363
|
browse status Server health, uptime, session count
|
|
@@ -254,6 +365,15 @@ browse stop Shutdown server
|
|
|
254
365
|
browse restart Kill + restart server
|
|
255
366
|
```
|
|
256
367
|
|
|
368
|
+
## CLI Flags
|
|
369
|
+
|
|
370
|
+
| Flag | Description |
|
|
371
|
+
|------|-------------|
|
|
372
|
+
| `--session <id>` | Named session (isolates tabs, refs, cookies) |
|
|
373
|
+
| `--json` | Wrap output as `{success, data, command}` |
|
|
374
|
+
| `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
|
|
375
|
+
| `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
|
|
376
|
+
|
|
257
377
|
## Speed Rules
|
|
258
378
|
|
|
259
379
|
1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `css`, `screenshot` all run against the loaded page instantly.
|
|
@@ -264,6 +384,8 @@ browse restart Kill + restart server
|
|
|
264
384
|
6. **Use `chain` for multi-step flows.** Avoids CLI overhead per step.
|
|
265
385
|
7. **Use `responsive` for layout checks.** One command = 3 viewport screenshots.
|
|
266
386
|
8. **Use `--session` for parallel work.** Multiple agents can browse simultaneously without interference.
|
|
387
|
+
9. **Use `value`/`count` instead of `js`.** Purpose-built commands are cleaner than `js "document.querySelector(...).value"`.
|
|
388
|
+
10. **Use `frame` for iframes.** Don't try to reach into iframes with CSS — use `frame [id=x]` first.
|
|
267
389
|
|
|
268
390
|
## When to Use What
|
|
269
391
|
|
|
@@ -272,30 +394,45 @@ browse restart Kill + restart server
|
|
|
272
394
|
| Read a page | `goto <url>` then `text` |
|
|
273
395
|
| Interact with elements | `snapshot -i` then `click @e3` |
|
|
274
396
|
| Find hidden clickables | `snapshot -i -C` then `click @e15` |
|
|
275
|
-
| Check if element exists | `
|
|
397
|
+
| Check if element exists | `count ".thing"` |
|
|
398
|
+
| Get input value | `value "[id=email]"` |
|
|
276
399
|
| Extract specific data | `js "document.querySelector('.price').textContent"` |
|
|
277
|
-
| Visual check | `screenshot .browse/x.png` then Read the image |
|
|
400
|
+
| Visual check | `screenshot .browse/sessions/default/x.png` then Read the image |
|
|
278
401
|
| Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` |
|
|
402
|
+
| Check/uncheck boxes | `check @e7` / `uncheck @e7` |
|
|
279
403
|
| Check CSS | `css "selector" "property"` or `css @e3 "property"` |
|
|
280
404
|
| Inspect DOM | `html "selector"` or `attrs @e3` |
|
|
281
405
|
| Debug console errors | `console` |
|
|
282
406
|
| Check network requests | `network` |
|
|
407
|
+
| Mock API responses | `route "**/api/*" fulfill 200 '{"data":[]}'` |
|
|
408
|
+
| Block ads/trackers | `route "**/*.doubleclick.net/*" block` |
|
|
409
|
+
| Test offline behavior | `offline on` → test → `offline off` |
|
|
410
|
+
| Interact in iframe | `frame "[id=payment]"` → `fill @e2 "4242..."` → `frame main` |
|
|
283
411
|
| Check local dev | `goto http://127.0.0.1:3000` |
|
|
284
412
|
| Compare two pages | `diff <url1> <url2>` |
|
|
285
|
-
| Mobile layout check | `responsive .browse/
|
|
413
|
+
| Mobile layout check | `responsive .browse/sessions/default/resp` |
|
|
286
414
|
| Test on mobile device | `emulate iphone` → `goto <url>` → `screenshot` |
|
|
415
|
+
| Save/restore session | `state save mysite` / `state load mysite` |
|
|
416
|
+
| Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
|
|
417
|
+
| Record network | `har start` → browse around → `har stop ./out.har` |
|
|
287
418
|
| Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
|
|
288
419
|
| Multi-step flow | `echo '[...]' \| browse chain` |
|
|
420
|
+
| Secure browsing | `--allowed-domains example.com goto https://example.com` |
|
|
421
|
+
| Scroll through results | `scroll down` → `text` → `scroll down` → `text` |
|
|
422
|
+
| Drag and drop | `drag @e1 @e2` |
|
|
289
423
|
|
|
290
424
|
## Architecture
|
|
291
425
|
|
|
292
|
-
- Persistent Chromium daemon on localhost (port 9400-
|
|
426
|
+
- Persistent Chromium daemon on localhost (port 9400-10400)
|
|
293
427
|
- Bearer token auth per session
|
|
428
|
+
- Auto-instance: each parent process (Claude Code) gets its own server
|
|
294
429
|
- Session multiplexing: multiple agents share one Chromium via isolated BrowserContexts
|
|
295
430
|
- Project-local state: `.browse/` directory at project root (auto-created, self-gitignored)
|
|
296
|
-
- `
|
|
297
|
-
- `
|
|
298
|
-
- `browse-
|
|
299
|
-
- `browse-screenshot.png` — default screenshot location
|
|
431
|
+
- `sessions/{id}/` — per-session screenshots, logs, PDFs
|
|
432
|
+
- `states/{name}.json` — saved browser state (cookies + localStorage)
|
|
433
|
+
- `browse-server-{instance}.json` — server PID, port, auth token
|
|
300
434
|
- Auto-shutdown when all sessions idle past 30 min
|
|
301
435
|
- Chromium crash → server exits → auto-restarts on next command
|
|
436
|
+
- AI-friendly error messages: Playwright errors rewritten to actionable hints
|
|
437
|
+
- CDP remote connection: `BROWSE_CDP_URL` to connect to existing Chrome
|
|
438
|
+
- Policy enforcement: `browse-policy.json` for allow/deny/confirm rules
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential vault — AES-256-GCM encrypted credential storage
|
|
3
|
+
*
|
|
4
|
+
* Encryption key: BROWSE_ENCRYPTION_KEY env var (64-char hex)
|
|
5
|
+
* or auto-generated at .browse/.encryption-key
|
|
6
|
+
*
|
|
7
|
+
* Storage: .browse/auth/<name>.json (mode 0o600)
|
|
8
|
+
* Password never returned in list/get — only hasPassword: true
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as crypto from 'crypto';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import type { BrowserManager } from './browser-manager';
|
|
15
|
+
import { DEFAULTS } from './constants';
|
|
16
|
+
import { sanitizeName } from './sanitize';
|
|
17
|
+
|
|
18
|
+
interface StoredCredential {
|
|
19
|
+
name: string;
|
|
20
|
+
url: string;
|
|
21
|
+
username: string;
|
|
22
|
+
encrypted: true;
|
|
23
|
+
iv: string; // base64
|
|
24
|
+
authTag: string; // base64
|
|
25
|
+
data: string; // base64 (encrypted password)
|
|
26
|
+
usernameSelector?: string;
|
|
27
|
+
passwordSelector?: string;
|
|
28
|
+
submitSelector?: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CredentialInfo {
|
|
34
|
+
name: string;
|
|
35
|
+
url: string;
|
|
36
|
+
username: string;
|
|
37
|
+
hasPassword: boolean;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AuthVault {
|
|
42
|
+
private authDir: string;
|
|
43
|
+
private encryptionKey: Buffer;
|
|
44
|
+
|
|
45
|
+
constructor(localDir: string) {
|
|
46
|
+
this.authDir = path.join(localDir, 'auth');
|
|
47
|
+
this.encryptionKey = this.resolveKey(localDir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private resolveKey(localDir: string): Buffer {
|
|
51
|
+
// 1. Env var (64-char hex = 32 bytes)
|
|
52
|
+
const envKey = process.env.BROWSE_ENCRYPTION_KEY;
|
|
53
|
+
if (envKey) {
|
|
54
|
+
if (envKey.length !== 64) {
|
|
55
|
+
throw new Error('BROWSE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
|
|
56
|
+
}
|
|
57
|
+
return Buffer.from(envKey, 'hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Key file
|
|
61
|
+
const keyPath = path.join(localDir, '.encryption-key');
|
|
62
|
+
if (fs.existsSync(keyPath)) {
|
|
63
|
+
const hex = fs.readFileSync(keyPath, 'utf-8').trim();
|
|
64
|
+
return Buffer.from(hex, 'hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Auto-generate
|
|
68
|
+
const key = crypto.randomBytes(32);
|
|
69
|
+
fs.writeFileSync(keyPath, key.toString('hex') + '\n', { mode: 0o600 });
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
|
|
74
|
+
const iv = crypto.randomBytes(12);
|
|
75
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
|
76
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
77
|
+
return {
|
|
78
|
+
ciphertext: encrypted.toString('base64'),
|
|
79
|
+
iv: iv.toString('base64'),
|
|
80
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private decrypt(ciphertext: string, iv: string, authTag: string): string {
|
|
85
|
+
const decipher = crypto.createDecipheriv(
|
|
86
|
+
'aes-256-gcm',
|
|
87
|
+
this.encryptionKey,
|
|
88
|
+
Buffer.from(iv, 'base64'),
|
|
89
|
+
);
|
|
90
|
+
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
91
|
+
const decrypted = Buffer.concat([
|
|
92
|
+
decipher.update(Buffer.from(ciphertext, 'base64')),
|
|
93
|
+
decipher.final(),
|
|
94
|
+
]);
|
|
95
|
+
return decrypted.toString('utf-8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
save(
|
|
99
|
+
name: string,
|
|
100
|
+
url: string,
|
|
101
|
+
username: string,
|
|
102
|
+
password: string,
|
|
103
|
+
selectors?: { username?: string; password?: string; submit?: string },
|
|
104
|
+
): void {
|
|
105
|
+
fs.mkdirSync(this.authDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
const { ciphertext, iv, authTag } = this.encrypt(password);
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
|
|
110
|
+
const credential: StoredCredential = {
|
|
111
|
+
name,
|
|
112
|
+
url,
|
|
113
|
+
username,
|
|
114
|
+
encrypted: true,
|
|
115
|
+
iv,
|
|
116
|
+
authTag,
|
|
117
|
+
data: ciphertext,
|
|
118
|
+
usernameSelector: selectors?.username,
|
|
119
|
+
passwordSelector: selectors?.password,
|
|
120
|
+
submitSelector: selectors?.submit,
|
|
121
|
+
createdAt: now,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
|
|
126
|
+
fs.writeFileSync(filePath, JSON.stringify(credential, null, 2), { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private load(name: string): StoredCredential {
|
|
130
|
+
const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
|
|
131
|
+
if (!fs.existsSync(filePath)) {
|
|
132
|
+
throw new Error(`Credential "${name}" not found. Run "browse auth list" to see saved credentials.`);
|
|
133
|
+
}
|
|
134
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async login(name: string, bm: BrowserManager): Promise<string> {
|
|
138
|
+
const cred = this.load(name);
|
|
139
|
+
const password = this.decrypt(cred.data, cred.iv, cred.authTag);
|
|
140
|
+
const page = bm.getPage();
|
|
141
|
+
|
|
142
|
+
// Navigate to login URL
|
|
143
|
+
await page.goto(cred.url, {
|
|
144
|
+
waitUntil: 'domcontentloaded',
|
|
145
|
+
timeout: DEFAULTS.COMMAND_TIMEOUT_MS,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Resolve selectors: use stored or auto-detect
|
|
149
|
+
const userSel = cred.usernameSelector || await autoDetectSelector(page, 'username');
|
|
150
|
+
const passSel = cred.passwordSelector || await autoDetectSelector(page, 'password');
|
|
151
|
+
const submitSel = cred.submitSelector || await autoDetectSelector(page, 'submit');
|
|
152
|
+
|
|
153
|
+
// Fill credentials
|
|
154
|
+
await page.fill(userSel, cred.username, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
155
|
+
await page.fill(passSel, password, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
156
|
+
|
|
157
|
+
// Submit
|
|
158
|
+
await page.click(submitSel, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
159
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
160
|
+
|
|
161
|
+
return `Logged in as ${cred.username} at ${page.url()}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
list(): CredentialInfo[] {
|
|
165
|
+
if (!fs.existsSync(this.authDir)) return [];
|
|
166
|
+
|
|
167
|
+
const files = fs.readdirSync(this.authDir).filter(f => f.endsWith('.json'));
|
|
168
|
+
return files.map(f => {
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(fs.readFileSync(path.join(this.authDir, f), 'utf-8'));
|
|
171
|
+
return {
|
|
172
|
+
name: data.name,
|
|
173
|
+
url: data.url,
|
|
174
|
+
username: data.username,
|
|
175
|
+
hasPassword: true,
|
|
176
|
+
createdAt: data.createdAt,
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}).filter(Boolean) as CredentialInfo[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
delete(name: string): void {
|
|
185
|
+
const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
|
|
186
|
+
if (!fs.existsSync(filePath)) {
|
|
187
|
+
throw new Error(`Credential "${name}" not found.`);
|
|
188
|
+
}
|
|
189
|
+
fs.unlinkSync(filePath);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Auto-detect login form selectors by common patterns.
|
|
195
|
+
*/
|
|
196
|
+
async function autoDetectSelector(page: any, field: 'username' | 'password' | 'submit'): Promise<string> {
|
|
197
|
+
if (field === 'username') {
|
|
198
|
+
const candidates = [
|
|
199
|
+
'input[type="email"]',
|
|
200
|
+
'input[name="email"]',
|
|
201
|
+
'input[name="username"]',
|
|
202
|
+
'input[name="user"]',
|
|
203
|
+
'input[name="login"]',
|
|
204
|
+
'input[autocomplete="username"]',
|
|
205
|
+
'input[autocomplete="email"]',
|
|
206
|
+
'input[type="text"]:first-of-type',
|
|
207
|
+
];
|
|
208
|
+
for (const sel of candidates) {
|
|
209
|
+
const count = await page.locator(sel).count();
|
|
210
|
+
if (count > 0) return sel;
|
|
211
|
+
}
|
|
212
|
+
throw new Error('Could not auto-detect username field. Save with explicit selectors: browse auth save <name> <url> <user> <pass> --user-sel <sel> --pass-sel <sel> --submit-sel <sel>');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (field === 'password') {
|
|
216
|
+
const candidates = [
|
|
217
|
+
'input[type="password"]',
|
|
218
|
+
'input[name="password"]',
|
|
219
|
+
'input[name="pass"]',
|
|
220
|
+
'input[autocomplete="current-password"]',
|
|
221
|
+
];
|
|
222
|
+
for (const sel of candidates) {
|
|
223
|
+
const count = await page.locator(sel).count();
|
|
224
|
+
if (count > 0) return sel;
|
|
225
|
+
}
|
|
226
|
+
throw new Error('Could not auto-detect password field.');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// submit
|
|
230
|
+
const candidates = [
|
|
231
|
+
'button[type="submit"]',
|
|
232
|
+
'input[type="submit"]',
|
|
233
|
+
'form button',
|
|
234
|
+
'button:has-text("Log in")',
|
|
235
|
+
'button:has-text("Sign in")',
|
|
236
|
+
'button:has-text("Login")',
|
|
237
|
+
'button:has-text("Submit")',
|
|
238
|
+
];
|
|
239
|
+
for (const sel of candidates) {
|
|
240
|
+
const count = await page.locator(sel).count();
|
|
241
|
+
if (count > 0) return sel;
|
|
242
|
+
}
|
|
243
|
+
throw new Error('Could not auto-detect submit button.');
|
|
244
|
+
}
|