@ulpi/browse 0.7.5 → 1.0.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/LICENSE +1 -1
- package/README.md +444 -300
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +114 -7
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -244
- package/src/browser-manager.ts +0 -961
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -70
- package/src/cli.ts +0 -683
- package/src/commands/meta.ts +0 -748
- package/src/commands/read.ts +0 -347
- package/src/commands/write.ts +0 -484
- package/src/config.ts +0 -45
- package/src/constants.ts +0 -14
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -485
- package/src/session-manager.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulpi/browse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/ulpi-io/browse"
|
|
@@ -8,20 +8,20 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@lightpanda/browser": "^1.2.0",
|
|
10
10
|
"@ulpi/browse": "^0.3.0",
|
|
11
|
+
"better-sqlite3": "^11.0.0",
|
|
11
12
|
"diff": "^7.0.0",
|
|
12
13
|
"playwright": "^1.58.2",
|
|
13
14
|
"playwright-core": "^1.58.2"
|
|
14
15
|
},
|
|
15
16
|
"bin": {
|
|
16
|
-
"browse": "
|
|
17
|
+
"browse": "dist/browse.mjs"
|
|
17
18
|
},
|
|
18
19
|
"description": "Fast headless browser CLI — persistent Chromium daemon via Playwright.",
|
|
19
20
|
"engines": {
|
|
20
|
-
"
|
|
21
|
+
"node": ">=18.0.0"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
|
23
|
-
"
|
|
24
|
-
"src/",
|
|
24
|
+
"dist/",
|
|
25
25
|
"skill/",
|
|
26
26
|
"LICENSE",
|
|
27
27
|
"README.md",
|
|
@@ -41,19 +41,23 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"build": "
|
|
44
|
+
"build": "esbuild src/cli.ts --bundle --format=esm --platform=node --target=node18 --outfile=dist/browse.mjs --external:playwright --external:playwright-core --external:better-sqlite3 --external:electron --external:chromium-bidi --banner:js='#!/usr/bin/env node'",
|
|
45
45
|
"build:all": "bash scripts/build-all.sh",
|
|
46
|
-
"dev": "
|
|
47
|
-
"server": "
|
|
48
|
-
"test": "
|
|
49
|
-
"start": "
|
|
50
|
-
"postinstall": "
|
|
51
|
-
"benchmark": "
|
|
46
|
+
"dev": "tsx src/cli.ts",
|
|
47
|
+
"server": "tsx src/server.ts",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"start": "tsx src/server.ts",
|
|
50
|
+
"postinstall": "npx playwright install chromium",
|
|
51
|
+
"benchmark": "tsx benchmark.ts"
|
|
52
52
|
},
|
|
53
53
|
"type": "module",
|
|
54
54
|
"devDependencies": {
|
|
55
|
+
"@types/better-sqlite3": "^7.0.0",
|
|
55
56
|
"@types/node": "^25.5.0",
|
|
56
|
-
"
|
|
57
|
+
"esbuild": "^0.25.0",
|
|
58
|
+
"tsx": "^4.0.0",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
60
|
+
"vitest": "^3.0.0"
|
|
57
61
|
},
|
|
58
62
|
"optionalDependencies": {
|
|
59
63
|
"rebrowser-playwright": "^1.52.0"
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: browse
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.8.0
|
|
4
4
|
description: |
|
|
5
5
|
Fast web browsing for AI coding agents via persistent headless Chromium daemon. Navigate to any URL,
|
|
6
6
|
read page content, click elements, fill forms, run JavaScript, take screenshots,
|
|
@@ -31,8 +31,7 @@ fi
|
|
|
31
31
|
|
|
32
32
|
If `NEEDS_INSTALL`:
|
|
33
33
|
1. Tell the user: "browse needs a one-time install via npm. OK to proceed?"
|
|
34
|
-
2. If they approve: `
|
|
35
|
-
3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`
|
|
34
|
+
2. If they approve: `npm install -g @ulpi/browse`
|
|
36
35
|
|
|
37
36
|
### Permissions check
|
|
38
37
|
|
|
@@ -75,13 +74,21 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
75
74
|
"Bash(browse frame:*)",
|
|
76
75
|
"Bash(browse sessions:*)", "Bash(browse session-close:*)",
|
|
77
76
|
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)", "Bash(browse video:*)",
|
|
77
|
+
"Bash(browse record:*)",
|
|
78
78
|
"Bash(browse route:*)", "Bash(browse offline:*)",
|
|
79
79
|
"Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
|
|
80
80
|
"Bash(browse cookie:*)", "Bash(browse header:*)",
|
|
81
81
|
"Bash(browse useragent:*)",
|
|
82
82
|
"Bash(browse clipboard:*)", "Bash(browse screenshot-diff:*)",
|
|
83
83
|
"Bash(browse find:*)", "Bash(browse inspect:*)",
|
|
84
|
-
"Bash(browse instances:*)", "Bash(browse --headed:*)"
|
|
84
|
+
"Bash(browse instances:*)", "Bash(browse --headed:*)",
|
|
85
|
+
"Bash(browse rightclick:*)", "Bash(browse tap:*)",
|
|
86
|
+
"Bash(browse swipe:*)", "Bash(browse mouse:*)",
|
|
87
|
+
"Bash(browse keyboard:*)", "Bash(browse scrollinto:*)",
|
|
88
|
+
"Bash(browse scrollintoview:*)", "Bash(browse set:*)",
|
|
89
|
+
"Bash(browse box:*)", "Bash(browse errors:*)",
|
|
90
|
+
"Bash(browse doctor:*)", "Bash(browse upgrade:*)",
|
|
91
|
+
"Bash(browse --max-output:*)"
|
|
85
92
|
```
|
|
86
93
|
|
|
87
94
|
## IMPORTANT
|
|
@@ -189,6 +196,28 @@ browse --allowed-domains example.com,*.cdn.example.com goto https://example.com
|
|
|
189
196
|
# State persistence
|
|
190
197
|
browse state save mysite
|
|
191
198
|
browse state load mysite
|
|
199
|
+
browse state clean # delete states older than 7 days
|
|
200
|
+
browse state clean --older-than 30 # custom threshold
|
|
201
|
+
|
|
202
|
+
# Cookie management
|
|
203
|
+
browse cookie clear # clear all cookies
|
|
204
|
+
browse cookie set auth token --domain .example.com # set with options
|
|
205
|
+
browse cookie export ./cookies.json # export to file
|
|
206
|
+
browse cookie import ./cookies.json # import from file
|
|
207
|
+
|
|
208
|
+
# Cookie import from real browsers (macOS — Chrome, Arc, Brave, Edge)
|
|
209
|
+
browse cookie-import --list # show installed browsers
|
|
210
|
+
browse cookie-import chrome --domain .example.com # import cookies for a domain
|
|
211
|
+
browse cookie-import arc --domain .github.com # import from Arc
|
|
212
|
+
browse cookie-import chrome --profile "Profile 1" --domain .site.com # specific Chrome profile
|
|
213
|
+
|
|
214
|
+
# Session auto-persistence (named sessions survive restarts)
|
|
215
|
+
browse --session myapp goto https://app.com/login # login...
|
|
216
|
+
browse session-close myapp # state auto-saved (encrypted if BROWSE_ENCRYPTION_KEY set)
|
|
217
|
+
browse --session myapp goto https://app.com/dashboard # cookies auto-restored
|
|
218
|
+
|
|
219
|
+
# Load state at launch
|
|
220
|
+
browse --state auth.json goto https://app.com # load cookies before first command
|
|
192
221
|
|
|
193
222
|
# Auth vault (credentials never visible to LLM)
|
|
194
223
|
browse auth save github https://github.com/login user pass123
|
|
@@ -199,11 +228,31 @@ browse har start
|
|
|
199
228
|
browse goto https://example.com
|
|
200
229
|
browse har stop ./recording.har
|
|
201
230
|
|
|
202
|
-
# Video recording
|
|
231
|
+
# Video recording (watch a .webm of the session)
|
|
232
|
+
browse video start ./videos
|
|
233
|
+
browse goto https://example.com
|
|
234
|
+
browse click @e3
|
|
235
|
+
browse video stop
|
|
236
|
+
|
|
237
|
+
# Command recording (export replayable scripts)
|
|
238
|
+
browse record start
|
|
239
|
+
browse goto https://example.com
|
|
240
|
+
browse click "a"
|
|
241
|
+
browse fill "[id=search]" "test query"
|
|
242
|
+
browse record stop
|
|
243
|
+
browse record export replay ./recording.json # replay with: npx @puppeteer/replay ./recording.json
|
|
244
|
+
browse record export browse ./steps.json # replay with: cat steps.json | browse chain
|
|
245
|
+
|
|
246
|
+
# Both together (video + replayable script)
|
|
203
247
|
browse video start ./videos
|
|
248
|
+
browse record start
|
|
204
249
|
browse goto https://example.com
|
|
250
|
+
browse snapshot -i
|
|
205
251
|
browse click @e3
|
|
252
|
+
browse fill "[id=email]" "user@test.com"
|
|
253
|
+
browse record stop
|
|
206
254
|
browse video stop
|
|
255
|
+
browse record export replay ./recording.json
|
|
207
256
|
|
|
208
257
|
# Device emulation
|
|
209
258
|
browse emulate iphone
|
|
@@ -286,11 +335,13 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
|
|
286
335
|
```
|
|
287
336
|
browse click <selector> Click element (CSS selector or @ref)
|
|
288
337
|
browse click <x>,<y> Click at page coordinates (e.g. 590,461)
|
|
338
|
+
browse rightclick <selector> Right-click element (context menu)
|
|
289
339
|
browse dblclick <selector> Double-click element
|
|
290
340
|
browse fill <selector> <value> Fill input field
|
|
291
341
|
browse select <selector> <val> Select dropdown value
|
|
292
342
|
browse hover <selector> Hover over element
|
|
293
343
|
browse focus <selector> Focus element
|
|
344
|
+
browse tap <selector> Tap element (requires touch context via emulate)
|
|
294
345
|
browse check <selector> Check checkbox
|
|
295
346
|
browse uncheck <selector> Uncheck checkbox
|
|
296
347
|
browse drag <src> <tgt> Drag source to target
|
|
@@ -298,8 +349,24 @@ browse type <text> Type into focused element
|
|
|
298
349
|
browse press <key> Press key (Enter, Tab, Escape, etc.)
|
|
299
350
|
browse keydown <key> Hold key down
|
|
300
351
|
browse keyup <key> Release key
|
|
352
|
+
browse keyboard inserttext <t> Insert text without key events
|
|
301
353
|
browse scroll [sel|up|down] Scroll element/viewport/bottom
|
|
302
|
-
browse
|
|
354
|
+
browse scrollinto <sel> Scroll element into view (explicit)
|
|
355
|
+
browse swipe <dir> [px] Swipe up/down/left/right (touch events)
|
|
356
|
+
browse mouse move <x> <y> Move mouse to coordinates
|
|
357
|
+
browse mouse down [button] Press mouse button (left/right/middle)
|
|
358
|
+
browse mouse up [button] Release mouse button
|
|
359
|
+
browse mouse wheel <dy> [dx] Scroll wheel
|
|
360
|
+
browse wait <sel> Wait for element to appear
|
|
361
|
+
browse wait <sel> --state hidden Wait for element to disappear
|
|
362
|
+
browse wait <ms> Wait for milliseconds
|
|
363
|
+
browse wait --text "..." Wait for text to appear in page
|
|
364
|
+
browse wait --fn "expr" Wait for JavaScript condition
|
|
365
|
+
browse wait --load <state> Wait for load state
|
|
366
|
+
browse wait --url <pattern> Wait for URL match
|
|
367
|
+
browse wait --network-idle Wait for network idle
|
|
368
|
+
browse set geo <lat> <lng> Set geolocation
|
|
369
|
+
browse set media <scheme> Set color scheme (dark/light/no-preference)
|
|
303
370
|
browse viewport <WxH> Set viewport size (e.g. 375x812)
|
|
304
371
|
browse upload <sel> <files> Upload file(s) to a file input
|
|
305
372
|
browse highlight <selector> Highlight element (visual debugging)
|
|
@@ -327,8 +394,10 @@ browse attrs <selector> Get element attributes as JSON
|
|
|
327
394
|
browse element-state <selector> Element state (visible/enabled/checked/focused)
|
|
328
395
|
browse value <selector> Get input field value
|
|
329
396
|
browse count <selector> Count matching elements
|
|
397
|
+
browse box <selector> Get bounding box as JSON {x, y, width, height}
|
|
330
398
|
browse dialog Last dialog info or "(no dialog detected)"
|
|
331
399
|
browse console [--clear] View/clear console messages
|
|
400
|
+
browse errors [--clear] View/clear page errors (filtered from console)
|
|
332
401
|
browse network [--clear] View/clear network requests
|
|
333
402
|
browse cookies Dump all cookies as JSON
|
|
334
403
|
browse storage [set <k> <v>] View/set localStorage
|
|
@@ -342,6 +411,8 @@ browse clipboard write <text> Write text to system clipboard
|
|
|
342
411
|
```
|
|
343
412
|
browse screenshot [path] Viewport screenshot (default: .browse/sessions/{id}/screenshot.png)
|
|
344
413
|
browse screenshot --full [path] Full-page screenshot (entire scrollable page)
|
|
414
|
+
browse screenshot <sel|@ref> [path] Screenshot specific element
|
|
415
|
+
browse screenshot --clip x,y,w,h [path] Screenshot clipped region
|
|
345
416
|
browse screenshot --annotate [path] Screenshot with numbered badges + legend
|
|
346
417
|
browse pdf [path] Save as PDF
|
|
347
418
|
browse responsive [prefix] Screenshots at mobile/tablet/desktop
|
|
@@ -360,6 +431,11 @@ browse find text <query> Find elements by text content
|
|
|
360
431
|
browse find label <query> Find elements by label
|
|
361
432
|
browse find placeholder <query> Find elements by placeholder
|
|
362
433
|
browse find testid <query> Find elements by test ID
|
|
434
|
+
browse find alt <query> Find elements by alt text
|
|
435
|
+
browse find title <query> Find elements by title attribute
|
|
436
|
+
browse find first <sel> First matching element
|
|
437
|
+
browse find last <sel> Last matching element
|
|
438
|
+
browse find nth <n> <sel> Nth matching element (0-indexed)
|
|
363
439
|
```
|
|
364
440
|
|
|
365
441
|
### Compare
|
|
@@ -394,6 +470,15 @@ browse state save [name] Save cookies + localStorage (all origins)
|
|
|
394
470
|
browse state load [name] Restore saved state
|
|
395
471
|
browse state list List saved states
|
|
396
472
|
browse state show [name] Show contents of saved state
|
|
473
|
+
browse state clean Delete states older than 7 days
|
|
474
|
+
browse state clean --older-than N Custom age threshold (days)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Cookie import (macOS — borrow auth from real browsers)
|
|
478
|
+
```
|
|
479
|
+
browse cookie-import --list List installed browsers
|
|
480
|
+
browse cookie-import <browser> --domain <d> Import cookies for a domain
|
|
481
|
+
browse cookie-import <browser> --profile <p> --domain <d> Specific Chrome profile
|
|
397
482
|
```
|
|
398
483
|
|
|
399
484
|
### Auth vault
|
|
@@ -417,11 +502,22 @@ browse video stop Stop recording and save video files
|
|
|
417
502
|
browse video status Check if recording is active
|
|
418
503
|
```
|
|
419
504
|
|
|
505
|
+
### Command recording & export
|
|
506
|
+
```
|
|
507
|
+
browse record start Start recording commands
|
|
508
|
+
browse record stop Stop recording, keep steps for export
|
|
509
|
+
browse record status Recording state and step count
|
|
510
|
+
browse record export browse [path] Export as chain-compatible JSON (replay with browse chain)
|
|
511
|
+
browse record export replay [path] Export as Chrome DevTools Recorder (Playwright/Puppeteer)
|
|
512
|
+
```
|
|
513
|
+
|
|
420
514
|
### Server management
|
|
421
515
|
```
|
|
422
516
|
browse status Server health, uptime, session count
|
|
423
517
|
browse instances List all running browse servers (instance, PID, port, status)
|
|
424
518
|
browse version Print CLI version
|
|
519
|
+
browse doctor System check (Bun, Playwright, Chromium)
|
|
520
|
+
browse upgrade Self-update via npm
|
|
425
521
|
browse stop Shutdown server
|
|
426
522
|
browse restart Kill + restart server
|
|
427
523
|
browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
@@ -431,10 +527,12 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
431
527
|
|
|
432
528
|
| Flag | Description |
|
|
433
529
|
|------|-------------|
|
|
434
|
-
| `--session <id>` | Named session (isolates tabs, refs, cookies) |
|
|
530
|
+
| `--session <id>` | Named session (isolates tabs, refs, cookies — auto-persists on close) |
|
|
531
|
+
| `--state <path>` | Load state file (cookies/storage) before first command |
|
|
435
532
|
| `--json` | Wrap output as `{success, data, command}` |
|
|
436
533
|
| `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
|
|
437
534
|
| `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
|
|
535
|
+
| `--max-output <n>` | Truncate output to N characters |
|
|
438
536
|
| `--headed` | Run browser in headed (visible) mode |
|
|
439
537
|
| `--runtime <name>` | Browser engine: playwright (default), rebrowser (stealth) |
|
|
440
538
|
|
|
@@ -480,6 +578,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
480
578
|
| Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
|
|
481
579
|
| Record network | `har start` → browse around → `har stop ./out.har` |
|
|
482
580
|
| Record video | `video start ./vids` → browse around → `video stop` |
|
|
581
|
+
| Export automation script | `record start` → browse around → `record export replay ./recording.json` |
|
|
483
582
|
| Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
|
|
484
583
|
| Multi-step flow | `echo '[...]' \| browse chain` |
|
|
485
584
|
| Secure browsing | `--allowed-domains example.com goto https://example.com` |
|
|
@@ -489,6 +588,14 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
489
588
|
| Find by accessibility | `find role button` / `find text "Submit"` |
|
|
490
589
|
| Visual regression | `screenshot-diff baseline.png` |
|
|
491
590
|
| Debug with DevTools | `inspect` (set BROWSE_DEBUG_PORT first) |
|
|
591
|
+
| Get element position | `box @e3` |
|
|
592
|
+
| Check page errors | `errors` |
|
|
593
|
+
| Right-click context menu | `rightclick @e3` |
|
|
594
|
+
| Test mobile gestures | `emulate iphone` → `tap @e1` / `swipe down` |
|
|
595
|
+
| Set dark mode | `set media dark` |
|
|
596
|
+
| Test geolocation | `set geo 37.7 -122.4` → verify in page |
|
|
597
|
+
| Export/import cookies | `cookie export ./cookies.json` / `cookie import ./cookies.json` |
|
|
598
|
+
| Limit output size | `--max-output 5000 text` |
|
|
492
599
|
| See the browser | `browse --headed goto <url>` |
|
|
493
600
|
| Bypass bot detection | `--runtime rebrowser goto <url>` |
|
|
494
601
|
|
package/bin/browse.ts
DELETED
package/src/auth-vault.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
}
|