mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* fix: support Windows Opera and Opera GX cookie imports Fixes #2957 * fix: repair Windows cookie decryption and make Opera import failures actionable - strip the SHA-256(host_key) prefix Chromium adds to v10 values (DB meta v24+) on Windows - keep receipts and name `$B handoff` recovery for App-Bound rows in browsers without native extraction - explain missing browsers, missing profiles and ambiguous profile selection with next steps - add windowsNative/resolveBrowserInfo, the operagx alias and sorted failure reasons in CLI output - cover the Node server runtime with a real-DPAPI Windows test * docs: document Windows Opera cookie import and guard browser lists against drift * chore: file cookie-import follow-ups from the Opera fix wave review * test: keep Opera receipt tests independent of the shared key cache * test: declare generated gstack/llms.txt as a command-reference input for PR selection * chore: release v1.91.4.0 * test: reconstruct the historical cookie-workflow approval after the Opera BROWSER.md additions * test: run the real-DPAPI Windows check with the runner's environment PowerShell launched with a stripped environment took ~18-21s on the Windows runner (measured on a throwaway diagnostics run), past dpapiDecrypt's 10s deadline; with the full environment it returns in ~0.3s. Only APPDATA is redirected to the fixture's Opera root. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
1 parent
01593aa67c
commit
d2a0bbcf4c
21 files changed
+1022
-69
No files matched your search
+1
-1
@@ -199,7 +199,7 @@ Storage reset and authentication verification are independent opt-ins. `--clear-
|
|||||||
|
|
||||||
### Shell injection prevention
|
### Shell injection prevention
|
||||||
|
|
||||||
The browser registry (Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, Dia) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
The browser registry (Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, Dia) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
||||||
|
|
||||||
### Egress receipt ledger (v1.63.0.0)
|
### Egress receipt ledger (v1.63.0.0)
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -218,7 +218,7 @@ What changes when the fallback is active:
|
|||||||
|
|
||||||
| On Aside | On the fallback engine |
|
| On Aside | On the fallback engine |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode |
|
| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode |
|
||||||
| You watch the tabs the agent opens in Aside | `/open-gstack-browser` (or `$B connect`) shows the headed GStack Browser with the side panel |
|
| You watch the tabs the agent opens in Aside | `/open-gstack-browser` (or `$B connect`) shows the headed GStack Browser with the side panel |
|
||||||
| Sign-in wall: sign in inside Aside, say "done" | `$B handoff` opens a visible Chrome at the same page; `$B resume` continues |
|
| Sign-in wall: sign in inside Aside, say "done" | `$B handoff` opens a visible Chrome at the same page; `$B resume` continues |
|
||||||
| One `aside repl` script per flow, fresh session each time | Persistent daemon: cookies, tabs, and localStorage carry over between `$B` calls |
|
| One `aside repl` script per flow, fresh session each time | Persistent daemon: cookies, tabs, and localStorage carry over between `$B` calls |
|
||||||
@@ -568,7 +568,7 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|
|||||||
|
|
||||||
#### Choosing a source and checking sign-in
|
#### Choosing a source and checking sign-in
|
||||||
|
|
||||||
Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes.
|
Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes.
|
||||||
|
|
||||||
For direct import, first navigate to a page matching `--domain`. Example after choosing Chrome's `Profile 2`:
|
For direct import, first navigate to a page matching `--domain`. Example after choosing Chrome's `Profile 2`:
|
||||||
|
|
||||||
@@ -577,13 +577,43 @@ $B goto https://example.com
|
|||||||
$B cookie-import-browser chrome --domain example.com --profile "Profile 2"
|
$B cookie-import-browser chrome --domain example.com --profile "Profile 2"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Windows: Opera and Opera GX.** On Windows, Chrome, Edge and Brave increasingly store App-Bound Encryption cookies that gstack cannot decrypt; Opera and Opera GX still use DPAPI-protected cookies that it can. In Git Bash, with `$B` set as in the quick start above (the Windows build is `browse/dist/browse.exe`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE=app.example.com
|
||||||
|
DOMAIN=example.com
|
||||||
|
$B goto "https://$SITE"
|
||||||
|
$B cookie-import-browser opera-gx --domain "$DOMAIN" --profile Default # or: opera
|
||||||
|
$B reload # confirm the intended account
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `$B cookie-import-browser` with no flags to see which browsers were detected. `--profile` can be omitted when only one profile has cookies for the domain. If the receipt reports App-Bound Encryption, run `$B handoff`, sign in to the intended account in the window that opens, then `$B resume` (needs a display).
|
||||||
|
|
||||||
|
**Receipt failure reasons** (printed after the message as `Failure reasons: key=count`):
|
||||||
|
|
||||||
|
| Key | Meaning | Next step |
|
||||||
|
|---|---|---|
|
||||||
|
| `unsupported_encryption` | App-Bound Encryption (v20) cookies gstack cannot decrypt | `$B handoff`, sign in, `$B resume` |
|
||||||
|
| `decryption_failed` | The cookie could not be decrypted with the browser's key | Close the source browser and retry; otherwise sign in manually |
|
||||||
|
| `native_unrecovered` | Windows native extraction ran but could not recover these cookies | Sign in manually with `$B handoff` |
|
||||||
|
|
||||||
|
**Import errors:**
|
||||||
|
|
||||||
|
| Code | Meaning | Next step |
|
||||||
|
|---|---|---|
|
||||||
|
| `not_installed` | No supported cookie database for that browser or profile; the message lists every path checked and, off-platform, which OS supports the browser | Pick a browser listed as available on this OS, or pass an existing `--profile` |
|
||||||
|
| `profile_required` | Several profiles qualify, none has cookies for the domain, or a profile could not be read | Retry with `--profile "<dir>"` as the message suggests, or run `$B cookie-import-browser <browser>` to use the picker |
|
||||||
|
| `native_unsupported_browser` | Internal guard; not expected in normal use | Sign in manually with `$B handoff` |
|
||||||
|
|
||||||
|
The picker shows receipt messages verbatim; detailed `not_installed` and `profile_required` explanations appear in CLI output.
|
||||||
|
|
||||||
`--all` explicitly selects every non-expired cookie in the chosen source profile; it cannot accompany `--domain` or `--clear-storage`. Cookies are applied to the captured browser context, not isolated to a tab. The receipt distinguishes imported, partial, empty, and failed results, plus separate storage-reset and authentication outcomes. Cookies copied with authentication `not_requested` means **not checked**, not logged in. Zero imports, cookie counts, and HTTP 200 alone never prove sign-in.
|
`--all` explicitly selects every non-expired cookie in the chosen source profile; it cannot accompany `--domain` or `--clear-storage`. Cookies are applied to the captured browser context, not isolated to a tab. The receipt distinguishes imported, partial, empty, and failed results, plus separate storage-reset and authentication outcomes. Cookies copied with authentication `not_requested` means **not checked**, not logged in. Zero imports, cookie counts, and HTTP 200 alone never prove sign-in.
|
||||||
|
|
||||||
`--verify-auth` (also an explicit picker checkbox) reloads the captured target. Configure `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` privately in the **daemon environment before startup**; setting them only on a later CLI call does not reconfigure an existing daemon. Missing configuration rejects before mutation. Verification requires a successful same-origin response and exactly one visible element whose whitespace-normalized text equals the expected identity. A wrong account, login redirect, missing assertion, or changed target is not verified. Do not paste cookie values, passwords, profile/account labels, or expected identity into public logs; report only sanitized outcomes.
|
`--verify-auth` (also an explicit picker checkbox) reloads the captured target. Configure `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` privately in the **daemon environment before startup**; setting them only on a later CLI call does not reconfigure an existing daemon. Missing configuration rejects before mutation. Verification requires a successful same-origin response and exactly one visible element whose whitespace-normalized text equals the expected identity. A wrong account, login redirect, missing assertion, or changed target is not verified. Do not paste cookie values, passwords, profile/account labels, or expected identity into public logs; report only sanitized outcomes.
|
||||||
|
|
||||||
Storage stays intact by default. With explicit approval on a Chromium target, `--clear-storage` clears localStorage for the captured origin (exact scheme, host, and port, shared across that origin's tabs in the context) and sessionStorage for the target tab before applying cookies. Reset runs in an isolated world with a native monotonic deadline, so the site's scripts cannot forge its timeout clock. Other target engines reject reset; ordinary imports and authentication checks remain available. It does not clear other origins, other tabs' sessionStorage, IndexedDB, or service workers. Keep the target open and unchanged. A failed reset may have cleared some storage; a later cookie-application failure does not undo it.
|
Storage stays intact by default. With explicit approval on a Chromium target, `--clear-storage` clears localStorage for the captured origin (exact scheme, host, and port, shared across that origin's tabs in the context) and sessionStorage for the target tab before applying cookies. Reset runs in an isolated world with a native monotonic deadline, so the site's scripts cannot forge its timeout clock. Other target engines reject reset; ordinary imports and authentication checks remain available. It does not clear other origins, other tabs' sessionStorage, IndexedDB, or service workers. Keep the target open and unchanged. A failed reset may have cleared some storage; a later cookie-application failure does not undo it.
|
||||||
|
|
||||||
**Platform limits:** macOS imports may request Keychain approval; Linux `v11` cookies may require libsecret, while `v10` uses Chromium's fallback key. The Windows Node server needs Node.js 22.13 or newer with built-in SQLite enabled for cookie database reads. DPAPI-compatible cookies remain supported, but native App-Bound Encryption extraction is disabled until the browser/runtime passes qualification. Chrome 136+ blocks remote debugging of its default user-data directory, including numbered profiles, over both pipe and TCP; closing Chrome does not remove that protection. There is no TCP fallback or real-profile-copy workaround. If import cannot recover the session, sign in manually in gstack's headed browser when a display is available.
|
**Platform limits:** macOS imports may request Keychain approval; Linux `v11` cookies may require libsecret, while `v10` uses Chromium's fallback key. The Windows Node server needs Node.js 22.13 or newer with built-in SQLite enabled for cookie database reads. DPAPI-compatible cookies remain supported, but native App-Bound Encryption extraction is disabled until the browser/runtime passes qualification. Opera and Opera GX are Windows-only and read from `%APPDATA%\Opera Software\Opera Stable` or `Opera GX Stable`, in `Default` or `Profile N` directories; legacy root-level layouts, Opera side profiles and portable or relocated installs are not detected. Opera has no native extraction, so its App-Bound cookies (if any) need manual sign-in. Chrome 136+ blocks remote debugging of its default user-data directory, including numbered profiles, over both pipe and TCP; closing Chrome does not remove that protection. There is no TCP fallback or real-profile-copy workaround. If import cannot recover the session, sign in manually in gstack's headed browser when a display is available.
|
||||||
|
|
||||||
### Tabs + frames
|
### Tabs + frames
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.91.4.0] - 2026-09-28
|
||||||
|
|
||||||
|
Windows users can copy signed-in cookies from Opera and Opera GX into gstack's browser. On Windows, where Chrome, Edge and Brave increasingly store App-Bound Encryption cookies that gstack cannot decrypt, Opera and Opera GX still use DPAPI-protected cookies, so they may be the browsers where import keeps working.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `cookie-import-browser opera` and `opera-gx` (also `operagx` and "Opera GX") on Windows, read from `%APPDATA%\Opera Software\Opera Stable` or `Opera GX Stable` in `Default` or `Profile N` directories. Legacy root-level layouts, Opera side profiles and portable installs are not detected. Includes the Opera registry and Roaming-root contribution from @mvanhorn in #2980 (refs #2957).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Windows v10 cookies from current Chromium databases decrypted with 32 bytes of hash in front of the value, so imports could report success while the site stayed signed out. The SHA-256(host_key) prefix is now removed when present, for Chrome, Chromium, Edge and Brave as well as Opera.
|
||||||
|
- App-Bound Encryption rows in a browser without native extraction keep their receipt (counts and reasons) and name the recovery: `$B handoff`, sign in, `$B resume`. Partial imports warn that the session may not be restored.
|
||||||
|
- Missing browsers, missing profiles and ambiguous profile selection now say what was checked and what to run next, including which OS supports a browser and the typeable browser names available on this one. CLI receipts list failure reasons.
|
||||||
|
- A relative `APPDATA` no longer redirects the Opera root.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- BROWSER.md has a Windows Opera walkthrough and tables for receipt failure reasons and import error codes; a free test keeps the browser lists in the docs and command reference in step with the registry.
|
||||||
|
- A Windows CI test decrypts a host-bound Opera cookie with real DPAPI through the Node server runtime. Real Opera sessions on Windows are still awaiting confirmation from the issue reporter.
|
||||||
|
|
||||||
## [1.91.2.0] - 2026-09-25
|
## [1.91.2.0] - 2026-09-25
|
||||||
|
|
||||||
`/sync-gbrain` can check whether the current worktree's pages are readable without writing a probe page or deleting guidance when the answer is uncertain.
|
`/sync-gbrain` can check whether the current worktree's pages are readable without writing a probe page or deleting guidance when the answer is uncertain.
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
|||||||
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
|
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
|
||||||
| `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. |
|
| `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. |
|
||||||
| `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. |
|
| `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. |
|
||||||
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
|
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
|
||||||
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||||
| `/spec` | **Spec Author** | Turn vague intent into a precise, executable spec in five phases (why, scope, technical with mandatory code-reading, draft, file). Outside-review quality gate before filing (Claude Code on Codex; Codex on other harnesses; blocks below 7/10), fail-closed secret redaction, dedupe against existing issues, archive to `$GSTACK_STATE_ROOT/projects/$SLUG/specs/` for team-corpus recall. `--execute` spawns `claude -p` in a fresh worktree; `/ship` auto-closes the source issue on merge. Plan-mode aware. |
|
| `/spec` | **Spec Author** | Turn vague intent into a precise, executable spec in five phases (why, scope, technical with mandatory code-reading, draft, file). Outside-review quality gate before filing (Claude Code on Codex; Codex on other harnesses; blocks below 7/10), fail-closed secret redaction, dedupe against existing issues, archive to `$GSTACK_STATE_ROOT/projects/$SLUG/specs/` for team-corpus recall. `--execute` spawns `claude -p` in a fresh worktree; `/ship` auto-closes the source issue on merge. Plan-mode aware. |
|
||||||
| `/learn` | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns, pitfalls, and preferences. Learnings compound across sessions so gstack gets smarter on your codebase over time. |
|
| `/learn` | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns, pitfalls, and preferences. Learnings compound across sessions so gstack gets smarter on your codebase over time. |
|
||||||
|
|||||||
@@ -774,6 +774,81 @@ audit trail lives in Aside.
|
|||||||
**Priority:** P3
|
**Priority:** P3
|
||||||
**Depends on:** None.
|
**Depends on:** None.
|
||||||
|
|
||||||
|
## Browser cookie import follow-ups (filed via /autoplan on the Windows Opera fix wave, #2980/#2957)
|
||||||
|
|
||||||
|
### P2: Preserve receipts when key acquisition fails in a mixed batch
|
||||||
|
|
||||||
|
**What:** `importCookies` derives the key for the whole batch before the row loop, so one v10 row plus a DPAPI/Keychain failure throws a typed key error and loses plaintext and App-Bound counts for the other rows.
|
||||||
|
|
||||||
|
**Why:** A mixed plaintext + v20 + v10 batch with an unavailable key reports only the key error; recoverable plaintext cookies and the unsupported-encryption count disappear.
|
||||||
|
|
||||||
|
**Context:** Raised by the outside Eng voice. Deferred because turning a thrown typed key error into partial receipts changes a cross-platform contract, including macOS Keychain "click Allow and retry" prompts. Start at `getDerivedKeys` call in `browse/src/cookie-import-browser.ts` `importCookies`.
|
||||||
|
|
||||||
|
**Effort:** M (human ~1 day / CC+gstack ~30 min). **Priority:** P2.
|
||||||
|
**Depends on:** a decision on how retry-able key errors surface in a receipt.
|
||||||
|
|
||||||
|
### P3: Use the SHA-256(host_key) check on the macOS/Linux CBC path
|
||||||
|
|
||||||
|
**What:** The CBC branch of `decryptCookieValue` always drops 32 bytes; databases older than Chromium meta version 24 have no prefix, so their values lose 32 real bytes.
|
||||||
|
|
||||||
|
**Why:** Same correctness rule the Windows GCM branch now uses (strip only when the first 32 bytes equal SHA-256(host_key)).
|
||||||
|
|
||||||
|
**Context:** Found during the Opera wave's Eng review; affects only old profiles. yt-dlp keys this on `meta.version >= 24`.
|
||||||
|
|
||||||
|
**Effort:** S (human ~2 h / CC+gstack ~10 min). **Priority:** P3.
|
||||||
|
**Depends on:** nothing.
|
||||||
|
|
||||||
|
### P3: macOS and Linux Opera / Opera GX cookie import
|
||||||
|
|
||||||
|
**What:** Register Opera on macOS (`~/Library/Application Support/com.operasoftware.Opera`, GX `com.operasoftware.OperaGX`) and Linux (`~/.config/opera`).
|
||||||
|
|
||||||
|
**Why:** Opera users off Windows get "available on Windows only".
|
||||||
|
|
||||||
|
**Context:** Paths from yt-dlp's `cookies.py`; Keychain service and libsecret application names are unverified. Needs a person on each OS.
|
||||||
|
|
||||||
|
**Effort:** M (human ~1 day / CC+gstack ~30 min plus hardware verification). **Priority:** P3.
|
||||||
|
**Depends on:** a tester on macOS and Linux.
|
||||||
|
|
||||||
|
### P3: Opera Beta/Developer and Opera GX channel directories
|
||||||
|
|
||||||
|
**What:** Detect `Opera Next`/`Opera Developer`/GX beta user-data directories.
|
||||||
|
|
||||||
|
**Why:** Channel users are currently "not found".
|
||||||
|
|
||||||
|
**Context:** Directory names are unverified; add registry rows once confirmed on hardware.
|
||||||
|
|
||||||
|
**Effort:** S. **Priority:** P3. **Depends on:** confirmed directory names.
|
||||||
|
|
||||||
|
### P3: Opera side profiles (`_side_profiles/<id>/`)
|
||||||
|
|
||||||
|
**What:** Opera GX stores extra profiles under `<root>\_side_profiles\<id>\`, which `listProfiles`, `validateProfile` and the native profile regex do not accept.
|
||||||
|
|
||||||
|
**Why:** Side-profile users only see their main profile.
|
||||||
|
|
||||||
|
**Context:** Needs a profile-naming rule beyond `Default`/`Profile N` and an account-selection safety review.
|
||||||
|
|
||||||
|
**Effort:** M. **Priority:** P3. **Depends on:** a real side-profile layout sample.
|
||||||
|
|
||||||
|
### P3: Legacy root-level Opera layouts
|
||||||
|
|
||||||
|
**What:** Older Opera stored cookies at `<root>\Network\Cookies` with no `Default\`.
|
||||||
|
|
||||||
|
**Why:** Old installs report "not found".
|
||||||
|
|
||||||
|
**Context:** Cut from the wave by both CEO voices: a stale root DB can be imported as the wrong account when side profiles or a migrated `Default\` exist. Sources: yt-dlp, forensics guides. Build only on a real report, with stale-root/side-profile coexistence tests.
|
||||||
|
|
||||||
|
**Effort:** S-M. **Priority:** P3. **Depends on:** a user report with this layout.
|
||||||
|
|
||||||
|
### P3: User-supplied Chromium user-data path option
|
||||||
|
|
||||||
|
**What:** A yt-dlp-style `chrome:PATH` option for portable or relocated installs and unlisted forks.
|
||||||
|
|
||||||
|
**Why:** Each new fork currently needs a registry change and a release.
|
||||||
|
|
||||||
|
**Context:** `ARCHITECTURE.md` prefers a hardcoded registry for safety; needs a threat review (arbitrary paths, key sources) before building.
|
||||||
|
|
||||||
|
**Effort:** M. **Priority:** P3. **Depends on:** threat review.
|
||||||
|
|
||||||
## Test infrastructure
|
## Test infrastructure
|
||||||
|
|
||||||
### P1: skillify gate test red — HOME-override sessions never discover project skills (pre-existing)
|
### P1: skillify gate test red — HOME-override sessions never discover project skills (pre-existing)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.91.2.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.91.4.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
|||||||
| `click <sel>` | Click element |
|
| `click <sel>` | Click element |
|
||||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||||
| `cookie-import <json>` | Import cookies from JSON file |
|
| `cookie-import <json>` | Import cookies from JSON file |
|
||||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||||
| `fill <sel> <val>` | Fill input |
|
| `fill <sel> <val>` | Fill input |
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
|||||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||||
|
|||||||
@@ -10,11 +10,15 @@
|
|||||||
* │ 1. Resolve the cookie DB from the browser profile dir │
|
* │ 1. Resolve the cookie DB from the browser profile dir │
|
||||||
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
||||||
* │ - Linux: ~/.config/<browser>/<profile> │
|
* │ - Linux: ~/.config/<browser>/<profile> │
|
||||||
|
* │ - Windows: %LOCALAPPDATA% or %APPDATA% (Opera) /<browser>/ │
|
||||||
|
* │ <profile>/Network/Cookies, falling back to <profile>/Cookies│
|
||||||
* │ │
|
* │ │
|
||||||
* │ 2. Derive the AES key │
|
* │ 2. Derive the AES key │
|
||||||
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
|
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
|
||||||
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
|
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
|
||||||
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
|
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
|
||||||
|
* │ - Windows v10: DPAPI-unprotect Local State os_crypt key │
|
||||||
|
* │ - Windows v20 (App-Bound): not decryptable here │
|
||||||
* │ │
|
* │ │
|
||||||
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
|
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
|
||||||
* │ "v11": │
|
* │ "v11": │
|
||||||
@@ -24,6 +28,8 @@
|
|||||||
* │ - Remove PKCS7 padding │
|
* │ - Remove PKCS7 padding │
|
||||||
* │ - Skip first 32 bytes of Chromium cookie metadata │
|
* │ - Skip first 32 bytes of Chromium cookie metadata │
|
||||||
* │ - Remaining bytes = cookie value (UTF-8) │
|
* │ - Remaining bytes = cookie value (UTF-8) │
|
||||||
|
* │ Windows v10: AES-256-GCM(nonce=ev[3:15], tag=last 16 bytes); │
|
||||||
|
* │ drop a leading SHA-256(host_key) when present (DB meta v24+) │
|
||||||
* │ │
|
* │ │
|
||||||
* │ 4. If encrypted_value is empty but `value` field is set, │
|
* │ 4. If encrypted_value is empty but `value` field is set, │
|
||||||
* │ use value directly (unencrypted cookie) │
|
* │ use value directly (unencrypted cookie) │
|
||||||
@@ -46,12 +52,14 @@ import { isIP } from 'node:net';
|
|||||||
|
|
||||||
export interface BrowserInfo {
|
export interface BrowserInfo {
|
||||||
name: string;
|
name: string;
|
||||||
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
|
dataDir: string | null; // macOS directory; null when that platform has no supported location
|
||||||
keychainService: string;
|
keychainService: string;
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
linuxDataDir?: string;
|
linuxDataDir?: string;
|
||||||
linuxApplication?: string;
|
linuxApplication?: string;
|
||||||
windowsDataDir?: string;
|
windowsDataDir?: string;
|
||||||
|
windowsDataRoot?: 'local' | 'roaming';
|
||||||
|
windowsNative?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileEntry {
|
export interface ProfileEntry {
|
||||||
@@ -107,12 +115,15 @@ interface BrowserMatch {
|
|||||||
|
|
||||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/', windowsNative: true },
|
||||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/', windowsNative: true },
|
||||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/', windowsNative: true },
|
||||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/', windowsNative: true },
|
||||||
|
// Windows-only. Local State sits directly under the browser root in %APPDATA% — no User Data segment.
|
||||||
|
{ name: 'Opera', dataDir: null, keychainService: 'Opera Safe Storage', aliases: ['opera'], windowsDataDir: 'Opera Software/Opera Stable/', windowsDataRoot: 'roaming' },
|
||||||
|
{ name: 'Opera GX', dataDir: null, keychainService: 'Opera GX Safe Storage', aliases: ['opera-gx', 'operagx'], windowsDataDir: 'Opera Software/Opera GX Stable/', windowsDataRoot: 'roaming' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Key Cache ──────────────────────────────────────────────────
|
// ─── Key Cache ──────────────────────────────────────────────────
|
||||||
@@ -134,14 +145,12 @@ export function findInstalledBrowsers(): BrowserInfo[] {
|
|||||||
for (const platform of getSearchPlatforms()) {
|
for (const platform of getSearchPlatforms()) {
|
||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
if (!dataDir) continue;
|
if (!dataDir) continue;
|
||||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
const browserDir = path.join(getBaseDir(platform, browser), dataDir);
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||||
if (entries.some(e => {
|
if (entries.some(e => {
|
||||||
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
|
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
|
||||||
const profileDir = path.join(browserDir, e.name);
|
return profileCookieCandidates(platform, path.join(browserDir, e.name)).some(candidate => fs.existsSync(candidate));
|
||||||
return fs.existsSync(path.join(profileDir, 'Cookies'))
|
|
||||||
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
|
|
||||||
})) return true;
|
})) return true;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -160,14 +169,14 @@ export function listSupportedBrowserNames(): string[] {
|
|||||||
* List available profiles for a browser.
|
* List available profiles for a browser.
|
||||||
*/
|
*/
|
||||||
export function listProfiles(browserName: string): ProfileEntry[] {
|
export function listProfiles(browserName: string): ProfileEntry[] {
|
||||||
const browser = resolveBrowser(browserName);
|
const browser = resolveBrowserInfo(browserName);
|
||||||
const profiles: ProfileEntry[] = [];
|
const profiles: ProfileEntry[] = [];
|
||||||
|
|
||||||
// Scan each supported platform for profile directories
|
// Scan each supported platform for profile directories
|
||||||
for (const platform of getSearchPlatforms()) {
|
for (const platform of getSearchPlatforms()) {
|
||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
if (!dataDir) continue;
|
if (!dataDir) continue;
|
||||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
const browserDir = path.join(getBaseDir(platform, browser), dataDir);
|
||||||
if (!fs.existsSync(browserDir)) continue;
|
if (!fs.existsSync(browserDir)) continue;
|
||||||
|
|
||||||
let profileNames: Record<string, { name?: unknown }> = {};
|
let profileNames: Record<string, { name?: unknown }> = {};
|
||||||
@@ -185,11 +194,7 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
||||||
// Chrome 80+ on Windows stores cookies under Network/Cookies
|
if (!profileCookieCandidates(platform, path.join(browserDir, entry.name)).some(p => fs.existsSync(p))) continue;
|
||||||
const cookieCandidates = platform === 'win32'
|
|
||||||
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
|
|
||||||
: [path.join(browserDir, entry.name, 'Cookies')];
|
|
||||||
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
|
|
||||||
|
|
||||||
// Avoid duplicates if the same profile appears on multiple platforms
|
// Avoid duplicates if the same profile appears on multiple platforms
|
||||||
if (profiles.some(p => p.name === entry.name)) continue;
|
if (profiles.some(p => p.name === entry.name)) continue;
|
||||||
@@ -274,7 +279,7 @@ export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): P
|
|||||||
* List unique cookie domains + counts from a browser's DB. No decryption.
|
* List unique cookie domains + counts from a browser's DB. No decryption.
|
||||||
*/
|
*/
|
||||||
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
||||||
const browser = resolveBrowser(browserName);
|
const browser = resolveBrowserInfo(browserName);
|
||||||
const match = getBrowserMatch(browser, profile);
|
const match = getBrowserMatch(browser, profile);
|
||||||
const db = openDb(match.dbPath, browser.name);
|
const db = openDb(match.dbPath, browser.name);
|
||||||
try {
|
try {
|
||||||
@@ -306,7 +311,7 @@ export async function importCookies(
|
|||||||
const normalized = normalizeCookieDomain(domain);
|
const normalized = normalizeCookieDomain(domain);
|
||||||
return [normalized, '.' + normalized];
|
return [normalized, '.' + normalized];
|
||||||
}))];
|
}))];
|
||||||
const browser = resolveBrowser(browserName);
|
const browser = resolveBrowserInfo(browserName);
|
||||||
const match = getBrowserMatch(browser, profile);
|
const match = getBrowserMatch(browser, profile);
|
||||||
const db = openDb(match.dbPath, browser.name);
|
const db = openDb(match.dbPath, browser.name);
|
||||||
|
|
||||||
@@ -350,9 +355,17 @@ export async function importCookies(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw the same typed not-installed error an import would raise when the
|
||||||
|
* profile has no cookie database; returns quietly when one exists.
|
||||||
|
*/
|
||||||
|
export function assertCookieDatabase(browserName: string, profile: string): void {
|
||||||
|
getBrowserMatch(resolveBrowserInfo(browserName), profile);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Internal: Browser Resolution ───────────────────────────────
|
// ─── Internal: Browser Resolution ───────────────────────────────
|
||||||
|
|
||||||
function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
export function resolveBrowserInfo(nameOrAlias: string): BrowserInfo {
|
||||||
const needle = nameOrAlias.toLowerCase().trim();
|
const needle = nameOrAlias.toLowerCase().trim();
|
||||||
const found = BROWSER_REGISTRY.find(b =>
|
const found = BROWSER_REGISTRY.find(b =>
|
||||||
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
||||||
@@ -360,13 +373,41 @@ function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
|||||||
if (!found) {
|
if (!found) {
|
||||||
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
|
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
|
||||||
throw new CookieImportError(
|
throw new CookieImportError(
|
||||||
`Unknown browser '${nameOrAlias}'. Supported: ${supported}`,
|
`Unknown browser '${nameOrAlias}'. Supported on this OS: ${hostBrowserTokens()}. All names: ${supported}`,
|
||||||
'unknown_browser',
|
'unknown_browser',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_LABELS: Record<BrowserPlatform, string> = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
||||||
|
|
||||||
|
function joinLabels(labels: string[]): string {
|
||||||
|
return labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostBrowserTokens(): string {
|
||||||
|
const host = getHostPlatform();
|
||||||
|
return BROWSER_REGISTRY
|
||||||
|
.filter(browser => !host || getDataDirForPlatform(browser, host) !== null)
|
||||||
|
.map(browser => `${browser.name} (${browser.aliases[0]})`)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function symbolicLocation(browser: BrowserInfo, platform: BrowserPlatform): string {
|
||||||
|
const dataDir = getDataDirForPlatform(browser, platform)!.replace(/\/$/, '');
|
||||||
|
if (platform === 'darwin') return `~/Library/Application Support/${dataDir}`;
|
||||||
|
if (platform === 'linux') return `~/.config/${dataDir}`;
|
||||||
|
return `${browser.windowsDataRoot === 'roaming' ? '%APPDATA%' : '%LOCALAPPDATA%'}\\${dataDir.replace(/\//g, '\\')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileCookieCandidates(platform: BrowserPlatform, profileDir: string): string[] {
|
||||||
|
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
||||||
|
return platform === 'win32'
|
||||||
|
? [path.join(profileDir, 'Network', 'Cookies'), path.join(profileDir, 'Cookies')]
|
||||||
|
: [path.join(profileDir, 'Cookies')];
|
||||||
|
}
|
||||||
|
|
||||||
function validateProfile(profile: string): void {
|
function validateProfile(profile: string): void {
|
||||||
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
|
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
|
||||||
throw new CookieImportError(
|
throw new CookieImportError(
|
||||||
@@ -393,14 +434,21 @@ function getSearchPlatforms(): BrowserPlatform[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
||||||
if (platform === 'darwin') return browser.dataDir;
|
if (platform === 'darwin') return browser.dataDir || null;
|
||||||
if (platform === 'linux') return browser.linuxDataDir || null;
|
if (platform === 'linux') return browser.linuxDataDir || null;
|
||||||
return browser.windowsDataDir || null;
|
return browser.windowsDataDir || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseDir(platform: BrowserPlatform): string {
|
function windowsBaseDir(browser: BrowserInfo): string {
|
||||||
|
if (browser.windowsDataRoot !== 'roaming') return path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
const appData = process.env.APPDATA;
|
||||||
|
if (typeof appData === 'string' && path.win32.isAbsolute(appData.trim())) return appData.trim();
|
||||||
|
return path.join(os.homedir(), 'AppData', 'Roaming');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseDir(platform: BrowserPlatform, browser: BrowserInfo): string {
|
||||||
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
|
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
|
||||||
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
|
if (platform === 'win32') return windowsBaseDir(browser);
|
||||||
return path.join(os.homedir(), '.config');
|
return path.join(os.homedir(), '.config');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,12 +457,8 @@ function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch |
|
|||||||
for (const platform of getSearchPlatforms()) {
|
for (const platform of getSearchPlatforms()) {
|
||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
if (!dataDir) continue;
|
if (!dataDir) continue;
|
||||||
const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
|
const baseProfile = path.join(getBaseDir(platform, browser), dataDir, profile);
|
||||||
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
for (const dbPath of profileCookieCandidates(platform, baseProfile)) {
|
||||||
const candidates = platform === 'win32'
|
|
||||||
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
|
|
||||||
: [path.join(baseProfile, 'Cookies')];
|
|
||||||
for (const dbPath of candidates) {
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
return { browser, platform, dbPath };
|
return { browser, platform, dbPath };
|
||||||
@@ -429,17 +473,31 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
|||||||
const match = findBrowserMatch(browser, profile);
|
const match = findBrowserMatch(browser, profile);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
|
|
||||||
const attempted = getSearchPlatforms()
|
const platforms = getSearchPlatforms().filter(platform => getDataDirForPlatform(browser, platform) !== null);
|
||||||
.map(platform => {
|
const rootFor = (platform: BrowserPlatform) => path.join(getBaseDir(platform, browser), getDataDirForPlatform(browser, platform)!);
|
||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const checked = `Checked: ${platforms.flatMap(platform => profileCookieCandidates(platform, path.join(rootFor(platform), profile))).join(', ')}.`;
|
||||||
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
|
const host = getHostPlatform();
|
||||||
})
|
|
||||||
.filter((entry): entry is string => entry !== null);
|
|
||||||
|
|
||||||
throw new CookieImportError(
|
if (host && !platforms.includes(host)) {
|
||||||
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
|
const locations = platforms.map(platform => `${symbolicLocation(browser, platform)} on ${PLATFORM_LABELS[platform]}`).join('; ');
|
||||||
'not_installed',
|
throw new CookieImportError(
|
||||||
);
|
`${browser.name} cookie import is available on ${joinLabels(platforms.map(platform => PLATFORM_LABELS[platform]))} only. It reads ${locations}. Browsers available on this OS: ${hostBrowserTokens()}. ${checked}`,
|
||||||
|
'not_installed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platforms.some(platform => fs.existsSync(rootFor(platform)))) {
|
||||||
|
const available = listProfiles(browser.name).map(entry => entry.name);
|
||||||
|
throw new CookieImportError(
|
||||||
|
`${browser.name} profile '${profile}' not found. Available: ${available.length ? available.join(', ') : 'none'}. ${checked}`,
|
||||||
|
'not_installed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = browser.windowsDataRoot === 'roaming'
|
||||||
|
? ` Supported layout: ${symbolicLocation(browser, 'win32')}\\<Default|Profile N>\\Network\\Cookies. Legacy root-level layouts, side profiles and portable installs are not supported; sign in manually with \`$B handoff\` instead.`
|
||||||
|
: '';
|
||||||
|
throw new CookieImportError(`No supported ${browser.name} cookie database found. ${checked}${layout}`, 'not_installed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||||
@@ -563,7 +621,7 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
|||||||
const dataDir = getDataDirForPlatform(browser, platform);
|
const dataDir = getDataDirForPlatform(browser, platform);
|
||||||
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||||
|
|
||||||
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
|
const localStatePath = path.join(getBaseDir(platform, browser), dataDir, 'Local State');
|
||||||
let localState: any;
|
let localState: any;
|
||||||
try {
|
try {
|
||||||
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||||
@@ -784,7 +842,11 @@ function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform:
|
|||||||
const ciphertext = ev.slice(15, ev.length - 16);
|
const ciphertext = ev.slice(15, ev.length - 16);
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
// Cookie DB meta version 24+ prefixes the value with SHA-256(host_key).
|
||||||
|
const hostHash = crypto.createHash('sha256').update(row.host_key).digest();
|
||||||
|
const prefixed = plaintext.length >= 32 && plaintext.subarray(0, 32).equals(hostHash);
|
||||||
|
return (prefixed ? plaintext.subarray(32) : plaintext).toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
|
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
|
||||||
@@ -844,14 +906,17 @@ export async function importCookiesViaCdp(
|
|||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||||
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
||||||
const browser = resolveBrowser(browserName);
|
const browser = resolveBrowserInfo(browserName);
|
||||||
validateProfile(profile);
|
validateProfile(profile);
|
||||||
const dataDir = getDataDirForPlatform(browser, 'win32');
|
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||||
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
||||||
|
if (!browser.windowsNative) {
|
||||||
|
throw new CookieImportError(`${browser.name} has no native cookie extraction. Sign in manually: run \`$B handoff\`, sign in, then \`$B resume\`.`, 'native_unsupported_browser');
|
||||||
|
}
|
||||||
const { importNativeCookies } = await import('./cookie-import-native');
|
const { importNativeCookies } = await import('./cookie-import-native');
|
||||||
const cookies = await importNativeCookies({
|
const cookies = await importNativeCookies({
|
||||||
browserName: browser.name,
|
browserName: browser.name,
|
||||||
userDataDir: path.join(getBaseDir('win32'), dataDir),
|
userDataDir: path.join(getBaseDir('win32', browser), dataDir),
|
||||||
profile,
|
profile,
|
||||||
domains: [...new Set(domains.flatMap(domain => {
|
domains: [...new Set(domains.flatMap(domain => {
|
||||||
const normalized = normalizeCookieDomain(domain);
|
const normalized = normalizeCookieDomain(domain);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import {
|
import {
|
||||||
CookieImportError, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
CookieImportError, assertCookieDatabase, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
||||||
listDomains, listProfiles, normalizeCookieDomain, withCookieReadRetry,
|
listDomains, listProfiles, normalizeCookieDomain, resolveBrowserInfo, withCookieReadRetry,
|
||||||
type ProfileEntry,
|
type ProfileEntry,
|
||||||
} from './cookie-import-browser';
|
} from './cookie-import-browser';
|
||||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
|
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
|
||||||
@@ -78,6 +78,36 @@ export async function getCookieProfiles(browser: string, domains: string[] = [],
|
|||||||
return { profiles, recommendedProfile };
|
return { profiles, recommendedProfile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfileSuggestion = Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }>;
|
||||||
|
|
||||||
|
function profileSelectionError(browser: string, profiles: ProfileSuggestion, domains: string[]): CookieImportError {
|
||||||
|
const { name, aliases } = resolveBrowserInfo(browser);
|
||||||
|
if (!profiles.length) {
|
||||||
|
assertCookieDatabase(browser, 'Default');
|
||||||
|
return new CookieImportError(`${name} profiles changed while selecting. Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`, 'profile_required');
|
||||||
|
}
|
||||||
|
const retry = `Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`;
|
||||||
|
const list = (entries: ProfileSuggestion) => entries.map(entry => entry.name).join(', ');
|
||||||
|
const unreadable = profiles.filter(entry => entry.unavailable);
|
||||||
|
const matching = profiles.filter(entry => entry.matches === true);
|
||||||
|
const scope = domains.join(', ');
|
||||||
|
if (unreadable.length) {
|
||||||
|
const found = matching.length ? ` Profiles with cookies for ${scope}: ${list(matching)}.` : '';
|
||||||
|
return new CookieImportError(`${name} profiles ${list(unreadable)} could not be read (the browser may be locking them). Close ${name} and retry, or pass --profile.${found}`, 'profile_required');
|
||||||
|
}
|
||||||
|
if (!domains.length) return new CookieImportError(`${name} has several profiles: ${list(profiles)}. ${retry}`, 'profile_required');
|
||||||
|
if (matching.length > 1) return new CookieImportError(`${name} has several profiles with cookies for ${scope}: ${list(matching)}. ${retry}`, 'profile_required');
|
||||||
|
return new CookieImportError(`No ${name} profile has cookies for ${scope}. Check the domain and that you are signed in to it in ${name}.`, 'profile_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appBoundMessage(browser: string, result: { count: number; failureReasons?: Record<string, number> }): string {
|
||||||
|
const { name } = resolveBrowserInfo(browser);
|
||||||
|
const recovery = 'Sign in manually: run `$B handoff`, sign in to the intended account in the window that opens, then run `$B resume` (needs a display). See BROWSER.md, Platform limits.';
|
||||||
|
if (result.count) return `Some ${name} cookies use App-Bound Encryption and were skipped, so the session may not be restored. Check with \`$B reload\` (or --verify-auth); if you are signed out, run \`$B handoff\`, sign in, then \`$B resume\`.`;
|
||||||
|
if (result.failureReasons?.decryption_failed) return `${name} cookies could not be imported: some use App-Bound Encryption and others could not be decrypted. ${recovery}`;
|
||||||
|
return `Some selected ${name} cookies use App-Bound Encryption, which gstack cannot decrypt for this browser. ${recovery}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateCookieTarget(target: CookieImportTarget): URL {
|
export function validateCookieTarget(target: CookieImportTarget): URL {
|
||||||
try {
|
try {
|
||||||
const url = new URL(target.url);
|
const url = new URL(target.url);
|
||||||
@@ -119,13 +149,15 @@ export async function runCookieImport(
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
const suggestion = await getCookieProfiles(options.browser, selected);
|
const suggestion = await getCookieProfiles(options.browser, selected);
|
||||||
profile = suggestion.recommendedProfile;
|
profile = suggestion.recommendedProfile;
|
||||||
if (!profile) throw new CookieImportError('Choose a source profile explicitly; matching profiles are ambiguous, unavailable, or empty.', 'profile_required');
|
if (!profile) throw profileSelectionError(options.browser, suggestion.profiles, selected);
|
||||||
}
|
}
|
||||||
const domains = options.all
|
const domains = options.all
|
||||||
? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
|
? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
|
||||||
: selected;
|
: selected;
|
||||||
let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
|
let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
|
||||||
if (result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
const appBound = process.platform === 'win32' && result.failureReasons?.unsupported_encryption && !resolveBrowserInfo(options.browser).windowsNative
|
||||||
|
? appBoundMessage(options.browser, result) : undefined;
|
||||||
|
if (!appBound && result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
||||||
const failed = result.failed;
|
const failed = result.failed;
|
||||||
result = await importCookiesViaCdp(options.browser, domains, profile);
|
result = await importCookiesViaCdp(options.browser, domains, profile);
|
||||||
result.failed = Math.max(result.failed, failed - result.count);
|
result.failed = Math.max(result.failed, failed - result.count);
|
||||||
@@ -141,7 +173,7 @@ export async function runCookieImport(
|
|||||||
outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
|
outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
|
||||||
reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
|
reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
|
||||||
verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
|
verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
|
||||||
message: result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.',
|
message: appBound ?? (result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.'),
|
||||||
};
|
};
|
||||||
if (!result.count) {
|
if (!result.count) {
|
||||||
if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
|
if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
|
||||||
@@ -178,7 +210,7 @@ export async function runCookieImport(
|
|||||||
receipt.imported = result.count;
|
receipt.imported = result.count;
|
||||||
receipt.domainCounts = result.domainCounts;
|
receipt.domainCounts = result.domainCounts;
|
||||||
receipt.outcome = result.failed ? 'partial' : 'imported';
|
receipt.outcome = result.failed ? 'partial' : 'imported';
|
||||||
receipt.message = result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.';
|
receipt.message = appBound ?? (result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.');
|
||||||
if (options.verifyAuth) {
|
if (options.verifyAuth) {
|
||||||
try {
|
try {
|
||||||
validateCookieTarget(target);
|
validateCookieTarget(target);
|
||||||
@@ -194,5 +226,7 @@ export async function runCookieImport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
|
export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
|
||||||
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
const reasons = Object.entries(result.failureReasons).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key}=${count}`);
|
||||||
|
const reasonText = reasons.length ? ` Failure reasons: ${reasons.join(', ')}.` : '';
|
||||||
|
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message}${reasonText} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
@@ -28,6 +29,20 @@ beforeAll(() => {
|
|||||||
mkdirSync(windows, { recursive: true });
|
mkdirSync(windows, { recursive: true });
|
||||||
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
||||||
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
||||||
|
|
||||||
|
const writePlain = (directory: string, domain: string, name: string, value: string) => {
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
expect(realpathSync(directory).startsWith(root + path.sep)).toBe(true);
|
||||||
|
const database = new Database(path.join(directory, 'Cookies'));
|
||||||
|
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||||
|
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 0, 1, 0, 1)', [domain, name, value, Buffer.alloc(0), '/']);
|
||||||
|
database.close();
|
||||||
|
};
|
||||||
|
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera Stable/Default/Network'), '.opera.fixture', 'opera-cookie', 'opera-plaintext');
|
||||||
|
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera Stable/User Data/Default/Network'), '.opera.fixture', 'opera-cookie', 'user-data-decoy');
|
||||||
|
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera GX Stable/Profile 2/Network'), '.gx.fixture', 'gx-cookie', 'gx-plaintext');
|
||||||
|
writePlain(path.join(root, 'AppData/Local/Opera Software/Opera Stable/Default/Network'), '.opera.fixture', 'opera-cookie', 'local-decoy');
|
||||||
|
writePlain(path.join(root, 'AppData/Local/Opera Software/Opera GX Stable/Default/Network'), '.gx.fixture', 'gx-cookie', 'local-gx-decoy');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
@@ -49,6 +64,101 @@ describe('actual Node importer runtime', () => {
|
|||||||
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Path and SQLite coverage for the bundled module. This does not exercise Windows DPAPI.
|
||||||
|
test('discovers and imports plaintext Opera and Opera GX cookies through the bundled module', () => {
|
||||||
|
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||||
|
const { findInstalledBrowsers, listDomains, listProfiles, listSupportedBrowserNames, importCookies } = await import(process.argv[1]);
|
||||||
|
const opera = await importCookies('opera', ['opera.fixture']);
|
||||||
|
const gx = await importCookies('Opera GX', ['gx.fixture'], 'Profile 2');
|
||||||
|
const skipped = await importCookies('opera', ['gx.fixture']);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
platform: process.platform,
|
||||||
|
discovered: findInstalledBrowsers().map(browser => browser.name).sort(),
|
||||||
|
supported: listSupportedBrowserNames(),
|
||||||
|
operaDomains: listDomains('opera').domains.map(entry => entry.domain).sort(),
|
||||||
|
gxProfiles: listProfiles('opera-gx').map(profile => profile.name),
|
||||||
|
gxDomains: listDomains('opera-gx', 'Profile 2').domains.map(entry => entry.domain),
|
||||||
|
opera: { count: opera.count, failed: opera.failed, name: opera.cookies[0]?.name, value: opera.cookies[0]?.value },
|
||||||
|
gx: { count: gx.count, failed: gx.failed, name: gx.cookies[0]?.name, value: gx.cookies[0]?.value },
|
||||||
|
skipped: skipped.count,
|
||||||
|
}));
|
||||||
|
`, pathToFileURL(bundle).href], {
|
||||||
|
encoding: 'utf8', timeout: 15_000,
|
||||||
|
env: {
|
||||||
|
HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'),
|
||||||
|
APPDATA: path.join(root, 'OperaRoaming'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1',
|
||||||
|
PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(child.error).toBeUndefined();
|
||||||
|
expect(child.status).toBe(0);
|
||||||
|
expect(child.stderr).toBe('');
|
||||||
|
const parsed = JSON.parse(child.stdout);
|
||||||
|
expect(parsed.discovered).toEqual(expect.arrayContaining(['Opera', 'Opera GX']));
|
||||||
|
expect(parsed.platform === 'win32'
|
||||||
|
? parsed.supported.includes('Opera') && parsed.supported.includes('Opera GX')
|
||||||
|
: !parsed.supported.includes('Opera') && !parsed.supported.includes('Opera GX')).toBe(true);
|
||||||
|
expect(parsed.operaDomains).toEqual(['.opera.fixture']);
|
||||||
|
expect(parsed.gxProfiles).toEqual(['Profile 2']);
|
||||||
|
expect(parsed.gxDomains).toEqual(['.gx.fixture']);
|
||||||
|
expect(parsed.opera).toEqual({ count: 1, failed: 0, name: 'opera-cookie', value: 'opera-plaintext' });
|
||||||
|
expect(parsed.gx).toEqual({ count: 1, failed: 0, name: 'gx-cookie', value: 'gx-plaintext' });
|
||||||
|
expect(parsed.skipped).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real Windows DPAPI through the production Node polyfill; no credential subprocess mock.
|
||||||
|
test.skipIf(process.platform !== 'win32')('decrypts a host-bound Opera v10 cookie with real DPAPI under the Node server runtime', () => {
|
||||||
|
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||||
|
const powershellDir = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0');
|
||||||
|
const key = randomBytes(32);
|
||||||
|
const protect = spawnSync(path.win32.join(powershellDir, 'powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command',
|
||||||
|
'Add-Type -AssemblyName System.Security; $b = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect($b, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser))',
|
||||||
|
], { input: key.toString('base64'), encoding: 'utf8', timeout: 30_000, windowsHide: true });
|
||||||
|
expect(protect.status).toBe(0);
|
||||||
|
const blob = Buffer.from(protect.stdout.trim(), 'base64');
|
||||||
|
expect(blob.length).toBeGreaterThan(32);
|
||||||
|
|
||||||
|
const appData = path.join(root, 'DpapiRoaming');
|
||||||
|
const browserRoot = path.join(appData, 'Opera Software', 'Opera Stable');
|
||||||
|
const network = path.join(browserRoot, 'Default', 'Network');
|
||||||
|
mkdirSync(network, { recursive: true });
|
||||||
|
expect(realpathSync(network).startsWith(root + path.sep)).toBe(true);
|
||||||
|
writeFileSync(path.join(browserRoot, 'Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.concat([Buffer.from('DPAPI'), blob]).toString('base64') } }));
|
||||||
|
const seal = (sealKey: Buffer, plaintext: Buffer) => {
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', sealKey, nonce);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
return Buffer.concat([Buffer.from('v10'), nonce, ciphertext, cipher.getAuthTag()]);
|
||||||
|
};
|
||||||
|
const expected = 'dpapi-synthetic-session';
|
||||||
|
const hostHash = createHash('sha256').update('.dpapi.fixture').digest();
|
||||||
|
const database = new Database(path.join(network, 'Cookies'));
|
||||||
|
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||||
|
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', ['.dpapi.fixture', 'session', '', seal(key, Buffer.concat([hostHash, Buffer.from(expected)])), '/']);
|
||||||
|
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', ['.dpapi.fixture', 'wrong-key', '', seal(randomBytes(32), Buffer.concat([hostHash, Buffer.from('other')])), '/']);
|
||||||
|
database.close();
|
||||||
|
|
||||||
|
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
createRequire(import.meta.url)(process.argv[1]);
|
||||||
|
const { importCookies } = await import(process.argv[2]);
|
||||||
|
const result = await importCookies('opera', ['dpapi.fixture']);
|
||||||
|
console.log(JSON.stringify({ count: result.count, failed: result.failed, reasons: result.failureReasons, matches: result.cookies[0]?.value === process.argv[3] }));
|
||||||
|
`, path.resolve(import.meta.dir, '../src/bun-polyfill.cjs'), pathToFileURL(bundle).href, expected], {
|
||||||
|
encoding: 'utf8', timeout: 45_000, windowsHide: true,
|
||||||
|
// Keep the runner's full environment: PowerShell started with a stripped
|
||||||
|
// environment takes ~20s on Windows CI (measured), past dpapiDecrypt's 10s
|
||||||
|
// deadline. Only APPDATA moves, to the fixture's Opera root.
|
||||||
|
env: { ...process.env, APPDATA: appData, NODE_NO_WARNINGS: '1' },
|
||||||
|
});
|
||||||
|
expect(child.error).toBeUndefined();
|
||||||
|
expect(child.stderr).toBe('');
|
||||||
|
expect(child.status).toBe(0);
|
||||||
|
expect(JSON.parse(child.stdout)).toEqual({ count: 1, failed: 1, reasons: { decryption_failed: 1 }, matches: true });
|
||||||
|
expect(child.stdout).not.toContain(expected);
|
||||||
|
expect(child.stdout).not.toContain(key.toString('base64'));
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
test('Node server build does not stub away the database', () => {
|
test('Node server build does not stub away the database', () => {
|
||||||
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
||||||
expect(script).not.toContain('const Database = null');
|
expect(script).not.toContain('const Database = null');
|
||||||
|
|||||||
@@ -315,3 +315,138 @@ describe('registered import callers', () => {
|
|||||||
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Windows Opera receipts and profile diagnostics', () => {
|
||||||
|
const operaDb = (profile: string, rows: Array<{ domain: string; name: string; value?: string; encrypted?: Buffer }>) => {
|
||||||
|
const dir = path.join(home, 'AppData/Roaming/Opera Software/Opera Stable', profile, 'Network');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||||
|
const db = new Database(path.join(dir, 'Cookies'));
|
||||||
|
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||||
|
for (const row of rows) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [row.domain, row.name, row.value ?? '', row.encrypted ?? Buffer.alloc(0), '/']);
|
||||||
|
db.close();
|
||||||
|
};
|
||||||
|
const onPlatform = async <T>(value: string, run: () => Promise<T>): Promise<T> => {
|
||||||
|
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||||
|
const appData = process.env.APPDATA;
|
||||||
|
delete process.env.APPDATA;
|
||||||
|
Object.defineProperty(process, 'platform', { value, configurable: true });
|
||||||
|
try { return await run(); } finally {
|
||||||
|
Object.defineProperty(process, 'platform', platform);
|
||||||
|
if (appData === undefined) delete process.env.APPDATA; else process.env.APPDATA = appData;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const recovery = 'Sign in manually: run `$B handoff`, sign in to the intended account in the window that opens, then run `$B resume` (needs a display). See BROWSER.md, Platform limits.';
|
||||||
|
|
||||||
|
test('App-Bound-only Opera rows return a failed receipt without native extraction', async () => {
|
||||||
|
operaDb('Default', [{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') }]);
|
||||||
|
const native = spyOn(importer, 'importCookiesViaCdp');
|
||||||
|
try {
|
||||||
|
for (const verifyAuth of [false, true]) {
|
||||||
|
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'], verifyAuth }, { page, url: currentUrl }, () => {}, verifyAuth ? { identitySelector: '#me', expectedIdentity: 'me' } : {}));
|
||||||
|
expect(result).toEqual({
|
||||||
|
browser: 'opera', profile: 'Default', imported: 0, failed: 1, domainCounts: {},
|
||||||
|
failureReasons: { unsupported_encryption: 1 }, outcome: 'failed', reset: 'not_requested',
|
||||||
|
verification: { verified: false, reason: verifyAuth ? 'no_cookies_imported' : 'not_requested' },
|
||||||
|
message: `Some selected Opera cookies use App-Bound Encryption, which gstack cannot decrypt for this browser. ${recovery}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(native).not.toHaveBeenCalled();
|
||||||
|
expect(context.addCookies).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
native.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed App-Bound and undecryptable Opera rows name both causes', async () => {
|
||||||
|
operaDb('Default', [{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') }]);
|
||||||
|
const imported = spyOn(importer, 'importCookies').mockResolvedValue({ cookies: [], count: 0, failed: 2, domainCounts: {}, failureReasons: { unsupported_encryption: 1, decryption_failed: 1 } });
|
||||||
|
try {
|
||||||
|
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {}));
|
||||||
|
expect(result.failureReasons).toEqual({ decryption_failed: 1, unsupported_encryption: 1 });
|
||||||
|
expect(result.outcome).toBe('failed');
|
||||||
|
expect(result.message).toBe(`Opera cookies could not be imported: some use App-Bound Encryption and others could not be decrypted. ${recovery}`);
|
||||||
|
} finally {
|
||||||
|
imported.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial Opera imports apply readable cookies and warn about skipped App-Bound rows', async () => {
|
||||||
|
operaDb('Default', [
|
||||||
|
{ domain: '.example.test', name: 'plain', value: 'synthetic-plain' },
|
||||||
|
{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') },
|
||||||
|
]);
|
||||||
|
const native = spyOn(importer, 'importCookiesViaCdp');
|
||||||
|
try {
|
||||||
|
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {}));
|
||||||
|
expect(result).toMatchObject({ imported: 1, failed: 1, outcome: 'partial', failureReasons: { unsupported_encryption: 1 } });
|
||||||
|
expect(result.message).toBe('Some Opera cookies use App-Bound Encryption and were skipped, so the session may not be restored. Check with `$B reload` (or --verify-auth); if you are signed out, run `$B handoff`, sign in, then `$B resume`.');
|
||||||
|
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||||
|
expect(native).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
native.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native extraction guard rejects browsers without a native mapping before loading it', async () => {
|
||||||
|
await onPlatform('win32', async () => {
|
||||||
|
await expect(importer.importCookiesViaCdp('opera', ['example.test'])).rejects.toMatchObject({ code: 'native_unsupported_browser' });
|
||||||
|
await expect(importer.importCookiesViaCdp('arc', ['example.test'])).rejects.toMatchObject({ code: 'not_supported' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI receipt text lists sorted failure reasons after the message', async () => {
|
||||||
|
const { formatCookieImportResult } = await import('../src/cookie-import-operation');
|
||||||
|
const base = { browser: 'opera', profile: 'Default', imported: 0, failed: 2, domainCounts: {}, outcome: 'failed' as const, reset: 'not_requested' as const, verification: { verified: false, reason: 'not_requested' }, message: 'Message.' };
|
||||||
|
expect(formatCookieImportResult({ ...base, failureReasons: { unsupported_encryption: 1, decryption_failed: 1 } })).toContain('Message. Failure reasons: decryption_failed=1, unsupported_encryption=1. Storage reset:');
|
||||||
|
expect(formatCookieImportResult({ ...base, failureReasons: {} })).toContain('Message. Storage reset:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing browser reports where it looked instead of asking for a profile', async () => {
|
||||||
|
await onPlatform('darwin', async () => {
|
||||||
|
const error: any = await runCookieImport({ browser: 'opera', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message.startsWith('Opera cookie import is available on Windows only.')).toBe(true);
|
||||||
|
expect(error.message).toContain('%APPDATA%\\Opera Software\\Opera Stable');
|
||||||
|
expect(error.message).toContain('Browsers available on this OS:');
|
||||||
|
expect(error.message).toContain('Chrome (chrome)');
|
||||||
|
});
|
||||||
|
await onPlatform('win32', async () => {
|
||||||
|
const error: any = await runCookieImport({ browser: 'opera-gx', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message).toContain('No supported Opera GX cookie database found.');
|
||||||
|
expect(error.message).toContain(path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network', 'Cookies'));
|
||||||
|
expect(error.message).toContain('Supported layout: %APPDATA%\\Opera Software\\Opera GX Stable\\<Default|Profile N>\\Network\\Cookies');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile diagnostics explain ambiguity, empty matches, unreadable profiles and --all', async () => {
|
||||||
|
installProfile();
|
||||||
|
installProfile('Profile 2');
|
||||||
|
const run = (options: any) => runCookieImport({ browser: 'chromium', ...options }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||||
|
let error = await run({ domains: ['example.test'] });
|
||||||
|
expect(error.code).toBe('profile_required');
|
||||||
|
expect(error.message).toBe('Chromium has several profiles with cookies for example.test: Default, Profile 2. Retry with --profile "<dir>", or run `$B cookie-import-browser chromium` to choose in the picker.');
|
||||||
|
error = await run({ domains: ['missing.test'] });
|
||||||
|
expect(error.message).toBe('No Chromium profile has cookies for missing.test. Check the domain and that you are signed in to it in Chromium.');
|
||||||
|
error = await run({ all: true });
|
||||||
|
expect(error.message).toBe('Chromium has several profiles: Default, Profile 2. Retry with --profile "<dir>", or run `$B cookie-import-browser chromium` to choose in the picker.');
|
||||||
|
fs.writeFileSync(path.join(home, '.config/chromium/Profile 2/Cookies'), 'not a database');
|
||||||
|
error = await run({ domains: ['example.test'] });
|
||||||
|
expect(error.code).toBe('profile_required');
|
||||||
|
expect(error.message).toBe('Chromium profiles Profile 2 could not be read (the browser may be locking them). Close Chromium and retry, or pass --profile. Profiles with cookies for example.test: Default.');
|
||||||
|
expect(error.message).not.toContain('synthetic-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a profile that appears after listing asks for an explicit profile', async () => {
|
||||||
|
const list = spyOn(importer, 'listProfiles').mockReturnValue([]);
|
||||||
|
try {
|
||||||
|
installProfile();
|
||||||
|
const error: any = await runCookieImport({ browser: 'chromium', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||||
|
expect(error.code).toBe('profile_required');
|
||||||
|
expect(error.message).toContain('Chromium profiles changed while selecting.');
|
||||||
|
} finally {
|
||||||
|
list.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,71 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { findInstalledBrowsers, importCookies, listProfiles, cookieDomainMatches, CookieImportError, normalizeCookieDomain, withCookieReadRetry } from '../src/cookie-import-browser';
|
import { findInstalledBrowsers, importCookies, listDomains, listProfiles, listSupportedBrowserNames, cookieDomainMatches, CookieImportError, normalizeCookieDomain, resolveBrowserInfo, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||||
|
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
||||||
|
|
||||||
let home: string;
|
let home: string;
|
||||||
let oldHome: string | undefined;
|
let oldHome: string | undefined;
|
||||||
let oldUserProfile: string | undefined;
|
let oldUserProfile: string | undefined;
|
||||||
|
let oldAppData: string | undefined;
|
||||||
|
let platform: PropertyDescriptor;
|
||||||
let spawn: typeof Bun.spawn;
|
let spawn: typeof Bun.spawn;
|
||||||
let homeMock: ReturnType<typeof spyOn>;
|
let homeMock: ReturnType<typeof spyOn>;
|
||||||
|
let extraDirs: string[] = [];
|
||||||
|
|
||||||
|
type CookieRow = { domain: string; name: string; value?: string; encrypted?: Buffer };
|
||||||
|
|
||||||
|
function tempRoot(prefix: string): string {
|
||||||
|
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
||||||
|
extraDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInside(root: string, target: string): void {
|
||||||
|
const resolvedRoot = fs.realpathSync(root);
|
||||||
|
const resolvedTarget = fs.realpathSync(target);
|
||||||
|
expect(resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCookies(root: string, directory: string, rows: CookieRow[]): string {
|
||||||
|
const target = path.join(root, directory);
|
||||||
|
fs.mkdirSync(target, { recursive: true });
|
||||||
|
assertInside(root, target);
|
||||||
|
const db = new Database(path.join(target, 'Cookies'));
|
||||||
|
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||||
|
for (const row of rows) {
|
||||||
|
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [row.domain, row.name, row.value ?? '', row.encrypted ?? Buffer.alloc(0), '/']);
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDpapiState(root: string, browserDir: string, material: Buffer): void {
|
||||||
|
const target = path.join(root, browserDir);
|
||||||
|
fs.mkdirSync(target, { recursive: true });
|
||||||
|
assertInside(root, target);
|
||||||
|
const encryptedKey = Buffer.concat([Buffer.from('DPAPI'), material]).toString('base64');
|
||||||
|
fs.writeFileSync(path.join(target, 'Local State'), JSON.stringify({ os_crypt: { encrypted_key: encryptedKey } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsV10Cookie(key: Buffer, plaintext: string | Buffer): Buffer {
|
||||||
|
const nonce = Buffer.alloc(12, 0x24);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
return Buffer.concat([Buffer.from('v10'), nonce, ciphertext, cipher.getAuthTag()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedStream(content = ''): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
if (content) controller.enqueue(Buffer.from(content));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||||
const target = path.join(home, dir, name);
|
const target = path.join(home, dir, name);
|
||||||
@@ -24,10 +80,14 @@ function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
||||||
|
extraDirs = [];
|
||||||
oldHome = process.env.HOME;
|
oldHome = process.env.HOME;
|
||||||
oldUserProfile = process.env.USERPROFILE;
|
oldUserProfile = process.env.USERPROFILE;
|
||||||
|
oldAppData = process.env.APPDATA;
|
||||||
|
platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
process.env.USERPROFILE = home;
|
process.env.USERPROFILE = home;
|
||||||
|
delete process.env.APPDATA;
|
||||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||||
spawn = Bun.spawn;
|
spawn = Bun.spawn;
|
||||||
Bun.spawn = ((command: string[]) => {
|
Bun.spawn = ((command: string[]) => {
|
||||||
@@ -38,9 +98,12 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Bun.spawn = spawn;
|
Bun.spawn = spawn;
|
||||||
|
Object.defineProperty(process, 'platform', platform);
|
||||||
homeMock.mockRestore();
|
homeMock.mockRestore();
|
||||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||||
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
||||||
|
if (oldAppData === undefined) delete process.env.APPDATA; else process.env.APPDATA = oldAppData;
|
||||||
|
for (const dir of extraDirs) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
fs.rmSync(home, { recursive: true, force: true });
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,4 +247,413 @@ describe('cookie import reliability', () => {
|
|||||||
})).rejects.toThrow('Locked');
|
})).rejects.toThrow('Locked');
|
||||||
expect(attempts).toBe(3);
|
expect(attempts).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lists Opera only on Windows and keeps other families on their current roots', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||||
|
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge', 'Opera', 'Opera GX']);
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||||
|
expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge']);
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||||
|
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discovers Opera and Opera GX from an overridden roaming root and ignores Local and User Data decoys', async () => {
|
||||||
|
const roaming = tempRoot('cookie-opera-appdata-');
|
||||||
|
process.env.APPDATA = roaming;
|
||||||
|
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'opera-chosen' },
|
||||||
|
{ domain: '.other.test', name: 'skip', value: 'opera-other' },
|
||||||
|
]);
|
||||||
|
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'User Data', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'user-data-decoy' },
|
||||||
|
]);
|
||||||
|
writeCookies(roaming, path.join('Opera Software', 'Opera GX Stable', 'Profile 1', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'gx-chosen' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Profile 9', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'local-profile-decoy' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'home-roaming-decoy' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'local-gx-decoy' },
|
||||||
|
]);
|
||||||
|
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'Guest Profile', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'guest-decoy' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(findInstalledBrowsers().map(browser => browser.name)).toEqual(expect.arrayContaining(['Opera', 'Opera GX']));
|
||||||
|
for (const alias of ['opera', 'OPERA', 'Opera']) {
|
||||||
|
expect(listProfiles(alias).map(profile => profile.name)).toEqual(['Default']);
|
||||||
|
}
|
||||||
|
for (const alias of ['opera-gx', 'Opera GX', 'opera gx', 'OPERA GX']) {
|
||||||
|
expect(listProfiles(alias)).toEqual([{ name: 'Profile 1', displayName: 'Profile 1' }]);
|
||||||
|
}
|
||||||
|
const listed = listDomains('opera');
|
||||||
|
expect(listed.browser).toBe('Opera');
|
||||||
|
expect(listed.domains.map(entry => `${entry.domain}:${entry.count}`).sort()).toEqual(['.chosen.test:1', '.other.test:1']);
|
||||||
|
const imported = await importCookies('opera', ['chosen.test']);
|
||||||
|
expect(imported.count).toBe(1);
|
||||||
|
expect(imported.failed).toBe(0);
|
||||||
|
expect(imported.cookies.map(cookie => ({ name: cookie.name, value: cookie.value, domain: cookie.domain }))).toEqual([
|
||||||
|
{ name: 'keep', value: 'opera-chosen', domain: '.chosen.test' },
|
||||||
|
]);
|
||||||
|
const gx = await importCookies('opera-gx', ['chosen.test'], 'Profile 1');
|
||||||
|
expect(gx.cookies.map(cookie => cookie.value)).toEqual(['gx-chosen']);
|
||||||
|
expect((await importCookies('Opera', ['other.test'])).cookies.map(cookie => cookie.value)).toEqual(['opera-other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the synthetic roaming home when APPDATA is unset, empty, or blank', async () => {
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'fallback-opera' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'fallback-gx' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||||
|
]);
|
||||||
|
for (const value of [undefined, '', ' ']) {
|
||||||
|
if (value === undefined) delete process.env.APPDATA;
|
||||||
|
else process.env.APPDATA = value;
|
||||||
|
expect((await importCookies('opera', ['chosen.test'])).cookies[0].value).toBe('fallback-opera');
|
||||||
|
expect((await importCookies('opera-gx', ['chosen.test'])).cookies[0].value).toBe('fallback-gx');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps Chrome, Chromium, Edge, and Brave on the Local root when APPDATA is overridden', async () => {
|
||||||
|
const roaming = tempRoot('cookie-local-root-');
|
||||||
|
process.env.APPDATA = roaming;
|
||||||
|
const cases = [
|
||||||
|
['chrome', path.join('AppData', 'Local', 'Google', 'Chrome', 'User Data', 'Default'), path.join('Google', 'Chrome', 'User Data', 'Default')],
|
||||||
|
['chromium', path.join('AppData', 'Local', 'Chromium', 'User Data', 'Default'), path.join('Chromium', 'User Data', 'Default')],
|
||||||
|
['brave', path.join('AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data', 'Default'), path.join('BraveSoftware', 'Brave-Browser', 'User Data', 'Default')],
|
||||||
|
['edge', path.join('AppData', 'Local', 'Microsoft', 'Edge', 'User Data', 'Default'), path.join('Microsoft', 'Edge', 'User Data', 'Default')],
|
||||||
|
] as const;
|
||||||
|
for (const [alias, localDir, roamingDir] of cases) {
|
||||||
|
writeCookies(home, localDir, [{ domain: '.chosen.test', name: 'keep', value: `${alias}-local` }]);
|
||||||
|
writeCookies(roaming, roamingDir, [{ domain: '.chosen.test', name: 'keep', value: `${alias}-roaming-decoy` }]);
|
||||||
|
expect((await importCookies(alias, ['chosen.test'])).cookies[0].value).toBe(`${alias}-local`);
|
||||||
|
}
|
||||||
|
expect(findInstalledBrowsers().map(browser => browser.name)).toEqual(expect.arrayContaining(['Chrome', 'Chromium', 'Brave', 'Edge']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers current Opera Local State names for a numbered profile with no Default', () => {
|
||||||
|
const root = path.join(home, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||||
|
for (const name of ['Profile 10', 'Profile 2']) {
|
||||||
|
const dir = writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', name, 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'numbered' },
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(path.join(dir, '..', 'Preferences'), JSON.stringify({ profile: { name: 'Old name' } }));
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(root, 'Local State'), JSON.stringify({ profile: { info_cache: { 'Profile 2': { name: 'Current Opera name' } } } }));
|
||||||
|
expect(listProfiles('opera')).toEqual([
|
||||||
|
{ name: 'Profile 2', displayName: 'Current Opera name' },
|
||||||
|
{ name: 'Profile 10', displayName: 'Old name' },
|
||||||
|
]);
|
||||||
|
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Opera');
|
||||||
|
expect(listProfiles('opera').some(profile => profile.name === 'Default')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed Opera metadata preserves the directory identity', () => {
|
||||||
|
const dir = writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'gx-plain' },
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(path.join(home, 'AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Local State'), '{');
|
||||||
|
fs.writeFileSync(path.join(dir, '..', 'Preferences'), '{');
|
||||||
|
expect(listProfiles('opera-gx')).toEqual([{ name: 'Default', displayName: 'Default' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports missing Opera browsers and profiles and rejects profile traversal', () => {
|
||||||
|
let error: any;
|
||||||
|
try { listDomains('opera'); } catch (caught) { error = caught; }
|
||||||
|
expect(error).toBeInstanceOf(CookieImportError);
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message).toContain(path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'));
|
||||||
|
expect(error.message).not.toContain(path.join('AppData', 'Local'));
|
||||||
|
expect(error.message).not.toContain('User Data');
|
||||||
|
expect(error.message).not.toContain('fixture-secret-sentinel');
|
||||||
|
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'present' },
|
||||||
|
]);
|
||||||
|
try { listDomains('Opera GX', 'Profile 8'); } catch (caught) { error = caught; }
|
||||||
|
expect(error).toBeInstanceOf(CookieImportError);
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message).toContain('Opera GX');
|
||||||
|
expect(error.message).not.toContain('fixture-secret-sentinel');
|
||||||
|
|
||||||
|
expect(() => listDomains('opera', '../etc')).toThrow(/Invalid profile/);
|
||||||
|
expect(() => listDomains('opera-gx', 'Default/../../etc')).toThrow(/Invalid profile/);
|
||||||
|
expect(() => listDomains('Opera GX', 'Profile\x001')).toThrow(/Invalid profile/);
|
||||||
|
expect(() => listDomains('opera', 'Default\\Network')).toThrow(/Invalid profile/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers Network/Cookies and still falls back to a profile-level Cookies file', async () => {
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'profile-level' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'network-level' },
|
||||||
|
]);
|
||||||
|
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['network-level']);
|
||||||
|
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'profile-fallback' },
|
||||||
|
]);
|
||||||
|
expect((await importCookies('opera-gx', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['profile-fallback']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not treat historical browser-root Opera cookie files as profiles', () => {
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'root-cookies' },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'root-network' },
|
||||||
|
]);
|
||||||
|
expect(findInstalledBrowsers().map(browser => browser.name)).not.toContain('Opera');
|
||||||
|
expect(listProfiles('opera')).toEqual([]);
|
||||||
|
let error: any;
|
||||||
|
try { listDomains('opera'); } catch (caught) { error = caught; }
|
||||||
|
expect(error).toBeInstanceOf(CookieImportError);
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports v20 rows as unsupported encryption without invoking native extraction', async () => {
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'plain', value: 'visible-value' },
|
||||||
|
{ domain: '.chosen.test', name: 'bound', value: '', encrypted: Buffer.from('v20synthetic') },
|
||||||
|
]);
|
||||||
|
let spawns = 0;
|
||||||
|
Bun.spawn = ((command: string[]) => {
|
||||||
|
spawns++;
|
||||||
|
throw new Error(`unexpected subprocess ${command[0]}`);
|
||||||
|
}) as typeof Bun.spawn;
|
||||||
|
const result = await importCookies('opera', ['chosen.test']);
|
||||||
|
expect(spawns).toBe(0);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.failureReasons).toEqual({ unsupported_encryption: 1 });
|
||||||
|
expect(result.cookies.map(cookie => cookie.value)).toEqual(['visible-value']);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('v20synthetic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decrypts distinct Opera and Opera GX v10 keys and preserves typed key failures', async () => {
|
||||||
|
const sentinel = 'fixture-secret-sentinel';
|
||||||
|
const operaKey = Buffer.alloc(32, 0x31);
|
||||||
|
const gxKey = Buffer.alloc(32, 0x32);
|
||||||
|
const operaMaterial = Buffer.from('opera-dpapi-material');
|
||||||
|
const gxMaterial = Buffer.from('gx-dpapi-material');
|
||||||
|
const operaPlaintext = 'opera-session-value';
|
||||||
|
const gxPlaintext = 'gx-session-value';
|
||||||
|
const operaRoot = path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||||
|
const gxRoot = path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable');
|
||||||
|
writeCookies(home, path.join(operaRoot, 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', encrypted: windowsV10Cookie(operaKey, operaPlaintext) },
|
||||||
|
{ domain: '.other.test', name: 'skip', encrypted: windowsV10Cookie(operaKey, 'opera-other-value') },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join(gxRoot, 'Profile 1', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', encrypted: windowsV10Cookie(gxKey, gxPlaintext) },
|
||||||
|
]);
|
||||||
|
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(path.join(home, operaRoot, 'Local State.bak'), sentinel);
|
||||||
|
writeDpapiState(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable'), Buffer.from('local-decoy-material'));
|
||||||
|
|
||||||
|
let error: any;
|
||||||
|
try { await importCookies('opera', ['chosen.test']); } catch (caught) { error = caught; }
|
||||||
|
expect(error).toBeInstanceOf(CookieImportError);
|
||||||
|
expect(error.code).toBe('keychain_error');
|
||||||
|
expect(error.message).toBe('Cannot read Local State for Opera');
|
||||||
|
expect(error.message).not.toContain(sentinel);
|
||||||
|
expect(error.message).not.toContain(operaPlaintext);
|
||||||
|
expect(error.message).not.toContain(operaMaterial.toString());
|
||||||
|
|
||||||
|
writeDpapiState(home, gxRoot, gxMaterial);
|
||||||
|
Bun.spawn = ((command: string[]) => {
|
||||||
|
expect(command[0]).toBe('powershell');
|
||||||
|
expect(command).toContain('-NoProfile');
|
||||||
|
return {
|
||||||
|
stdin: { write(_value: string) {}, end() {} },
|
||||||
|
stdout: closedStream(),
|
||||||
|
stderr: closedStream(sentinel),
|
||||||
|
exited: Promise.resolve(1),
|
||||||
|
kill() {},
|
||||||
|
};
|
||||||
|
}) as typeof Bun.spawn;
|
||||||
|
try { await importCookies('opera-gx', ['chosen.test'], 'Profile 1'); } catch (caught) { error = caught; }
|
||||||
|
expect(error).toBeInstanceOf(CookieImportError);
|
||||||
|
expect(error.code).toBe('keychain_error');
|
||||||
|
expect(error.message).toBe('DPAPI decryption failed');
|
||||||
|
expect(error.message).not.toContain(sentinel);
|
||||||
|
expect(error.message).not.toContain(gxPlaintext);
|
||||||
|
expect(error.message).not.toContain(gxMaterial.toString());
|
||||||
|
expect(error.message).not.toContain(gxKey.toString('base64'));
|
||||||
|
|
||||||
|
writeDpapiState(home, operaRoot, operaMaterial);
|
||||||
|
const submitted: string[] = [];
|
||||||
|
const keys = new Map<string, Buffer>([
|
||||||
|
[operaMaterial.toString('base64'), operaKey],
|
||||||
|
[gxMaterial.toString('base64'), gxKey],
|
||||||
|
]);
|
||||||
|
Bun.spawn = ((command: string[]) => {
|
||||||
|
expect(command[0]).toBe('powershell');
|
||||||
|
expect(command).toContain('-NoProfile');
|
||||||
|
let pending = '';
|
||||||
|
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
const stdout = new ReadableStream<Uint8Array>({ start(value) { controller = value; } });
|
||||||
|
return {
|
||||||
|
stdin: {
|
||||||
|
write(value: string) { pending += value; },
|
||||||
|
end() {
|
||||||
|
submitted.push(pending);
|
||||||
|
const key = keys.get(pending);
|
||||||
|
controller.enqueue(Buffer.from((key ?? Buffer.alloc(0)).toString('base64')));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stdout,
|
||||||
|
stderr: closedStream(),
|
||||||
|
exited: Promise.resolve(0),
|
||||||
|
kill() { throw new Error('Unexpected kill'); },
|
||||||
|
};
|
||||||
|
}) as typeof Bun.spawn;
|
||||||
|
const opera = await importCookies('opera', ['chosen.test']);
|
||||||
|
const gx = await importCookies('Opera GX', ['chosen.test'], 'Profile 1');
|
||||||
|
expect(submitted).toEqual([operaMaterial.toString('base64'), gxMaterial.toString('base64')]);
|
||||||
|
expect(opera.failed).toBe(0);
|
||||||
|
expect(gx.failed).toBe(0);
|
||||||
|
expect(opera.cookies.map(cookie => cookie.value)).toEqual([operaPlaintext]);
|
||||||
|
expect(gx.cookies.map(cookie => cookie.value)).toEqual([gxPlaintext]);
|
||||||
|
expect((await importCookies('opera', ['other.test'])).cookies.map(cookie => cookie.value)).toEqual(['opera-other-value']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips the SHA-256(host_key) prefix from Windows v10 values only when it matches', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||||
|
const cases = [
|
||||||
|
{ alias: 'opera', key: Buffer.alloc(32, 0x31), dir: path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'), material: 'opera-dpapi-material' },
|
||||||
|
{ alias: 'chrome', key: Buffer.alloc(32, 0x33), dir: path.join('AppData', 'Local', 'Google', 'Chrome', 'User Data'), material: 'chrome-dpapi-material' },
|
||||||
|
];
|
||||||
|
for (const { alias, key, dir, material } of cases) {
|
||||||
|
const hash = crypto.createHash('sha256').update('.chosen.test').digest();
|
||||||
|
writeCookies(home, path.join(dir, 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'bare', encrypted: windowsV10Cookie(key, 'bare-value') },
|
||||||
|
{ domain: '.chosen.test', name: 'prefixed', encrypted: windowsV10Cookie(key, Buffer.concat([hash, Buffer.from('prefixed-value')])) },
|
||||||
|
]);
|
||||||
|
writeDpapiState(home, dir, Buffer.from(material));
|
||||||
|
Bun.spawn = ((command: string[]) => {
|
||||||
|
expect(command[0]).toBe('powershell');
|
||||||
|
return { stdin: { write() {}, end() {} }, stdout: closedStream(key.toString('base64')), stderr: closedStream(), exited: Promise.resolve(0), kill() {} };
|
||||||
|
}) as typeof Bun.spawn;
|
||||||
|
const result = await importCookies(alias, ['chosen.test']);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(Object.fromEntries(result.cookies.map(cookie => [cookie.name, cookie.value]))).toEqual({ bare: 'bare-value', prefixed: 'prefixed-value' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a 32-byte lead that is not the host digest', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||||
|
const key = Buffer.alloc(32, 0x31);
|
||||||
|
const dir = path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||||
|
const value = 'x'.repeat(32) + '-tail';
|
||||||
|
writeCookies(home, path.join(dir, 'Default', 'Network'), [{ domain: '.chosen.test', name: 'long', encrypted: windowsV10Cookie(key, value) }]);
|
||||||
|
writeDpapiState(home, dir, Buffer.from('opera-dpapi-material'));
|
||||||
|
Bun.spawn = (() => ({ stdin: { write() {}, end() {} }, stdout: closedStream(key.toString('base64')), stderr: closedStream(), exited: Promise.resolve(0), kill() {} })) as unknown as typeof Bun.spawn;
|
||||||
|
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual([value]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves Opera GX aliases and lists typeable tokens for the host OS', () => {
|
||||||
|
for (const alias of ['operagx', 'opera-gx', 'Opera GX', 'opera gx']) expect(resolveBrowserInfo(alias).name).toBe('Opera GX');
|
||||||
|
const unknown = (platform: string) => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
|
||||||
|
try { resolveBrowserInfo('firefox'); } catch (error: any) { return error; }
|
||||||
|
throw new Error('expected unknown_browser');
|
||||||
|
};
|
||||||
|
const win = unknown('win32');
|
||||||
|
expect(win.code).toBe('unknown_browser');
|
||||||
|
const winSupported = win.message.split('Supported on this OS: ')[1].split('. All names:')[0];
|
||||||
|
expect(winSupported).toContain('Opera GX (opera-gx)');
|
||||||
|
expect(winSupported).toContain('Opera (opera)');
|
||||||
|
for (const platform of ['darwin', 'linux']) {
|
||||||
|
const supported = unknown(platform).message.split('Supported on this OS: ')[1].split('. All names:')[0];
|
||||||
|
expect(supported).not.toContain('Opera');
|
||||||
|
expect(unknown(platform).message).toContain('All names: comet');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names the supported OS for host-unsupported browsers', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||||
|
let error: any;
|
||||||
|
try { listDomains('arc'); } catch (caught) { error = caught; }
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message.startsWith('Arc cookie import is available on macOS only.')).toBe(true);
|
||||||
|
expect(error.message).toContain('~/Library/Application Support/Arc/User Data');
|
||||||
|
expect(error.message).toContain('Browsers available on this OS: Chrome (chrome), Chromium (chromium), Brave (brave), Edge (edge).');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinguishes a missing profile from a missing browser', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'present' },
|
||||||
|
]);
|
||||||
|
let error: any;
|
||||||
|
try { listDomains('opera', 'Profile 8'); } catch (caught) { error = caught; }
|
||||||
|
expect(error.code).toBe('not_installed');
|
||||||
|
expect(error.message.startsWith("Opera profile 'Profile 8' not found. Available: Default.")).toBe(true);
|
||||||
|
expect(error.message).not.toContain('Supported layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a relative APPDATA and falls back to the home Roaming root', async () => {
|
||||||
|
process.env.APPDATA = 'relative\\Roaming';
|
||||||
|
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||||
|
{ domain: '.chosen.test', name: 'keep', value: 'home-roaming' },
|
||||||
|
]);
|
||||||
|
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['home-roaming']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowsNative matches exactly the browsers the native extractor maps', () => {
|
||||||
|
const env = { LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local' };
|
||||||
|
for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge', 'Opera', 'Opera GX']) {
|
||||||
|
let mapped = true;
|
||||||
|
try { nativeBrowserPaths(browser, env); } catch { mapped = false; }
|
||||||
|
expect(resolveBrowserInfo(browser).windowsNative === true).toBe(mapped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser lists in docs and the command description name every registry browser', () => {
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const value of ['darwin', 'linux', 'win32']) {
|
||||||
|
Object.defineProperty(process, 'platform', { configurable: true, value });
|
||||||
|
for (const name of listSupportedBrowserNames()) names.add(name);
|
||||||
|
}
|
||||||
|
const repo = path.resolve(import.meta.dir, '../..');
|
||||||
|
const read = (file: string) => fs.readFileSync(path.join(repo, file), 'utf8');
|
||||||
|
const line = (file: string, anchor: string) => {
|
||||||
|
const found = read(file).split('\n').find(entry => entry.includes(anchor));
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
return found!;
|
||||||
|
};
|
||||||
|
const lists = {
|
||||||
|
'commands.ts': line('browse/src/commands.ts', "'cookie-import-browser':"),
|
||||||
|
'README row': line('README.md', '| `/setup-browser-cookies` |'),
|
||||||
|
'BROWSER.md sessions row': line('BROWSER.md', '| Your sessions are already there |'),
|
||||||
|
'BROWSER.md picker': line('BROWSER.md', 'The picker recognizes'),
|
||||||
|
'docs/skills.md row': line('docs/skills.md', '| [`/setup-browser-cookies`]'),
|
||||||
|
'docs/skills.md picker': line('docs/skills.md', 'The picker detects'),
|
||||||
|
'ARCHITECTURE.md registry': line('ARCHITECTURE.md', 'The browser registry ('),
|
||||||
|
};
|
||||||
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
for (const name of names) {
|
||||||
|
const token = resolveBrowserInfo(name).aliases[0];
|
||||||
|
const pattern = new RegExp(`\\b(?:${escape(name)}|${escape(token)})\\b(?![- ]gx)`, 'i');
|
||||||
|
for (const [label, text] of Object.entries(lists)) {
|
||||||
|
expect({ label, name, listed: pattern.test(text) }).toEqual({ label, name, listed: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+2
-2
@@ -28,7 +28,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
|||||||
| [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. |
|
| [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. |
|
||||||
| [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. |
|
| [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. |
|
||||||
| [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Drives your Aside browser first — real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts, and falls back to gstack's own Chromium (~100ms per command) when Aside isn't there. |
|
| [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Drives your Aside browser first — real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts, and falls back to gstack's own Chromium (~100ms per command) when Aside isn't there. |
|
||||||
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. |
|
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. |
|
||||||
| [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
| [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||||
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. |
|
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. |
|
||||||
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. |
|
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. |
|
||||||
@@ -951,7 +951,7 @@ This is my **session manager mode** — for the fallback browser. With Aside ope
|
|||||||
|
|
||||||
For authenticated testing on gstack's own browser, `/setup-browser-cookies` copies selected cookies from your daily browser. Sites may also need storage or a fresh login, so copying cookies is not proof that the session works.
|
For authenticated testing on gstack's own browser, `/setup-browser-cookies` copies selected cookies from your daily browser. Sites may also need storage or a fresh login, so copying cookies is not proof that the session works.
|
||||||
|
|
||||||
The picker detects Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive.
|
The picker detects Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive.
|
||||||
|
|
||||||
```
|
```
|
||||||
You: /setup-browser-cookies
|
You: /setup-browser-cookies
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
|
|||||||
- `click <sel>`: Click element
|
- `click <sel>`: Click element
|
||||||
- `cookie <name>=<value>`: Set cookie on current page domain
|
- `cookie <name>=<value>`: Set cookie on current page domain
|
||||||
- `cookie-import <json>`: Import cookies from JSON file
|
- `cookie-import <json>`: Import cookies from JSON file
|
||||||
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia.
|
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia.
|
||||||
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
|
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
|
||||||
- `dialog-dismiss`: Auto-dismiss next dialog
|
- `dialog-dismiss`: Auto-dismiss next dialog
|
||||||
- `fill <sel> <val>`: Fill input
|
- `fill <sel> <val>`: Fill input
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.91.2",
|
"version": "1.91.4",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ describe('dependency-free CI planner and report execution', () => {
|
|||||||
const skillPath = path.join(fixture, 'setup-browser-cookies/SKILL.md');
|
const skillPath = path.join(fixture, 'setup-browser-cookies/SKILL.md');
|
||||||
const currentSkill = fs.readFileSync(skillPath, 'utf8');
|
const currentSkill = fs.readFileSync(skillPath, 'utf8');
|
||||||
fs.writeFileSync(skillPath, approvedCookieWorkflowSource(currentSkill));
|
fs.writeFileSync(skillPath, approvedCookieWorkflowSource(currentSkill));
|
||||||
|
const approvedBrowserPath = path.join(fixture, 'BROWSER.md');
|
||||||
|
fs.writeFileSync(approvedBrowserPath, approvedCookieWorkflowSource(fs.readFileSync(approvedBrowserPath, 'utf8')));
|
||||||
const reportDir = path.join(fixture, 'manual-report');
|
const reportDir = path.join(fixture, 'manual-report');
|
||||||
const manifestPath = path.join(reportDir, 'manifest.json');
|
const manifestPath = path.join(reportDir, 'manifest.json');
|
||||||
const planned = run(['--emit-plan', manifestPath, '--slices', '1'], 'gate');
|
const planned = run(['--emit-plan', manifestPath, '--slices', '1'], 'gate');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function fixture() {
|
|||||||
for (const file of [COOKIE_MANUAL_REVIEW_FILE, 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) {
|
for (const file of [COOKIE_MANUAL_REVIEW_FILE, 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) {
|
||||||
const target = join(root, file); mkdirSync(resolve(target, '..'), { recursive: true });
|
const target = join(root, file); mkdirSync(resolve(target, '..'), { recursive: true });
|
||||||
const source = readFileSync(join(ROOT, file), 'utf8');
|
const source = readFileSync(join(ROOT, file), 'utf8');
|
||||||
writeFileSync(target, file === 'setup-browser-cookies/SKILL.md' ? approvedCookieWorkflowSource(source) : source);
|
writeFileSync(target, file === COOKIE_MANUAL_REVIEW_FILE ? source : approvedCookieWorkflowSource(source));
|
||||||
}
|
}
|
||||||
const entry = manualReviewFixture(root);
|
const entry = manualReviewFixture(root);
|
||||||
const approval = entry.manual_review!.approval;
|
const approval = entry.manual_review!.approval;
|
||||||
|
|||||||
@@ -5,10 +5,23 @@ import type { EvalTestEntry } from './eval-store';
|
|||||||
import { buildCookieWorkflowJudgeInput } from './cookie-workflow-judge-input';
|
import { buildCookieWorkflowJudgeInput } from './cookie-workflow-judge-input';
|
||||||
import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review';
|
import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review';
|
||||||
|
|
||||||
|
// Later documentation added to the judged BROWSER.md section after the approval
|
||||||
|
// was recorded (v1.91.4.0 Windows Opera wave). Reversing it reconstructs the
|
||||||
|
// exact historical prompt bytes, including the section's line range.
|
||||||
|
const LATER_BROWSER_BLOCK = /\*\*Windows: Opera and Opera GX\.\*\*[\s\S]*?appear in CLI output\.\n\n/;
|
||||||
|
const LATER_BROWSER_EDITS: Array<[string, string]> = [
|
||||||
|
['The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia.', 'The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia.'],
|
||||||
|
[' Opera and Opera GX are Windows-only and read from `%APPDATA%\\Opera Software\\Opera Stable` or `Opera GX Stable`, in `Default` or `Profile N` directories; legacy root-level layouts, Opera side profiles and portable or relocated installs are not detected. Opera has no native extraction, so its App-Bound cookies (if any) need manual sign-in.', ''],
|
||||||
|
];
|
||||||
|
|
||||||
export function approvedCookieWorkflowSource(source: string): string {
|
export function approvedCookieWorkflowSource(source: string): string {
|
||||||
return source
|
let removedLines = 0;
|
||||||
|
let historical = source
|
||||||
.replace('sha256sum < "$tmpfile" | awk \'{print $(1)}\'', 'sha256sum "$tmpfile" | awk \'{print $1}\'')
|
.replace('sha256sum < "$tmpfile" | awk \'{print $(1)}\'', 'sha256sum "$tmpfile" | awk \'{print $1}\'')
|
||||||
.replace('shasum -a 256 < "$tmpfile" | awk \'{print $(1)}\'', 'shasum -a 256 "$tmpfile" | awk \'{print $1}\'');
|
.replace('shasum -a 256 < "$tmpfile" | awk \'{print $(1)}\'', 'shasum -a 256 "$tmpfile" | awk \'{print $1}\'')
|
||||||
|
.replace(LATER_BROWSER_BLOCK, block => { removedLines = block.split('\n').length - 1; return ''; });
|
||||||
|
for (const [later, earlier] of LATER_BROWSER_EDITS) historical = historical.replace(later, earlier);
|
||||||
|
return historical.replace(/(--- BEGIN FILE "BROWSER\.md" \(lines \d+-)(\d+)(; section\) ---)/, (_, head, end, tail) => `${head}${Number(end) - removedLines}${tail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function manualReviewFixture(root = resolve(import.meta.dir, '../..')): EvalTestEntry {
|
export function manualReviewFixture(root = resolve(import.meta.dir, '../..')): EvalTestEntry {
|
||||||
|
|||||||
@@ -1800,7 +1800,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
|||||||
*/
|
*/
|
||||||
export const LLM_JUDGE_TOUCHFILES: Record<string, string[]> = {
|
export const LLM_JUDGE_TOUCHFILES: Record<string, string[]> = {
|
||||||
'setup-browser-cookies/SKILL.md workflow': ['setup-browser-cookies/SKILL.md.tmpl', 'setup-browser-cookies/SKILL.md', 'BROWSER.md', 'test/helpers/cookie-workflow-judge-input.ts', 'test/cookie-workflow-judge-input.test.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/cookie-workflow-manual-review.test.ts', 'test/helpers/manual-judge-review-fixture.ts', '.github/cookie-workflow-manual-review.json', 'test/helpers/workflow-judge-input.ts', 'test/skill-llm-eval.test.ts'],
|
'setup-browser-cookies/SKILL.md workflow': ['setup-browser-cookies/SKILL.md.tmpl', 'setup-browser-cookies/SKILL.md', 'BROWSER.md', 'test/helpers/cookie-workflow-judge-input.ts', 'test/cookie-workflow-judge-input.test.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/cookie-workflow-manual-review.test.ts', 'test/helpers/manual-judge-review-fixture.ts', '.github/cookie-workflow-manual-review.json', 'test/helpers/workflow-judge-input.ts', 'test/skill-llm-eval.test.ts'],
|
||||||
'command reference table': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/commands.ts', 'test/skill-llm-eval.test.ts'],
|
'command reference table': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/commands.ts', 'gstack/llms.txt', 'test/skill-llm-eval.test.ts'],
|
||||||
'snapshot flags reference': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/snapshot.ts', 'test/skill-llm-eval.test.ts'],
|
'snapshot flags reference': ['browse/sections/**', 'SKILL.md', 'SKILL.md.tmpl', 'browse/src/snapshot.ts', 'test/skill-llm-eval.test.ts'],
|
||||||
'browse/SKILL.md reference': ['browse/sections/**', 'browse/SKILL.md', 'browse/SKILL.md.tmpl', 'browse/src/**', 'test/skill-llm-eval.test.ts'],
|
'browse/SKILL.md reference': ['browse/sections/**', 'browse/SKILL.md', 'browse/SKILL.md.tmpl', 'browse/src/**', 'test/skill-llm-eval.test.ts'],
|
||||||
'setup block': ['browse/SKILL.md', 'browse/SKILL.md.tmpl', 'scripts/resolvers/aside.ts', 'scripts/resolvers/browse.ts', 'test/skill-llm-eval.test.ts'],
|
'setup block': ['browse/SKILL.md', 'browse/SKILL.md.tmpl', 'scripts/resolvers/aside.ts', 'scripts/resolvers/browse.ts', 'test/skill-llm-eval.test.ts'],
|
||||||
|
|||||||
Reference in new issue
Block a user