Files
gstack/browse/src/cookie-import-browser.ts
T
28d59ad56c v1.68.0.0 fix: next tracker wave — 16 verified fixes in, 90 stale PRs and 21 issues closed with receipts (#2632)
* fix(plan-tune): reject never-ask on one-way ids at --write

--check already ignored those prefs; --write still stored them and
--stats counted them as a working NEVER_ASK. Refuse the write and
count leftover on-disk prefs as INERT_ONE_WAY.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: gstack-config get returns "" with exit 0 for keys that have no default

Skill preambles read configuration with

    VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")

and that fallback only fires on a non-zero exit. lookup_default ended in a
catch-all that echoed "" and returned 0, so for any key missing from the table
VAR came back empty and the default written right there in the preamble was
unreachable. The skill then branched on a value it never specified: "skip
entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="".

Four keys that skills actually read had no entry and took that path:

    question_tuning         -> callers assume "false"
    repo_mode               -> callers assume "unknown"
    team_mode               -> callers assume "false"
    transcript_ingest_mode  -> callers assume "off"

Each default above is the value the call sites already substitute in their own
`|| echo` fallback, so this only makes reachable what was already intended.

The catch-all now returns non-zero. That is deliberately scoped to the
unknown-key arm alone: keys whose default is intentionally empty still exit 0,
because "" is their real answer and their callers depend on it --
cross_project_learnings ("unset triggers the first-time prompt"),
redact_repo_visibility ("empty falls through to gh/glab detection"),
salience_allowlist, user_slug_at_*. Making every empty answer an error would
have broken those.

test/gstack-config-defaults.test.ts pins the class rather than the four
instances: it parses the case arms and asserts every `gstack-config get <key>`
site in the tree is covered, so adding a read without a default fails CI. It
also pins the exit-code contract in both directions. Verified failing against
the pre-fix script, where it names exactly those four keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redact): a typo'd subcommand no longer exits 0 having done nothing

main() recognised exactly two subcommands and let everything else fall through
to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so:

    $ gstack-redact install-prepush-hooks    # plural typo
    gstack-redact scan — repo UNKNOWN
      (no findings)
    $ echo $?
    0

No hook was installed, and the operator has every reason to believe the
credential guard is armed. A guard that silently no-ops must never exit 0.

Two smaller faults in the same dispatch, both of which lead people here:

- There was no --help handler, so `gstack-redact --help` fell through to the
  scanner. Piping a credential to it scanned the secret and exited 3.
- With no piped input and no --from-file, readInput() blocks on readSync(fd 0)
  until an EOF that an interactive terminal never sends. That prints nothing
  at all, so it reads as a hang rather than as "this is a filter, feed it".

Now: --help/-h/help prints usage and exits 0; an unrecognised positional
prints the offender and exits 1; a TTY with nothing piped in prints usage
instead of blocking. "scan" stays accepted, because the human output header
reads "gstack-redact scan — repo …" and that is what people type.

Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH
findings and callers gate dispatch on them, so a usage error exiting 2 would
be read as "medium findings — prompt the user". A test pins that.

Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(browse): one ambiguous ref no longer kills the whole annotated screenshot

`snapshot -a` exits 1 with "Selector matched multiple elements" on most real
pages, so /qa, /canary and /land-and-deploy silently produce reports whose
screenshots do not exist. Plain `screenshot <path>` is unaffected.

Refs are built as getByRole(role, {name}) and disambiguated with .nth() when
role+name repeats. That disambiguation cannot fire for a node with NO accessible
name: the locator degrades to getByRole(role) with no name filter, and the count
driving .nth() is taken from the FILTERED aria snapshot while getByRole matches
the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed
paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are
correctly unnamed per ARIA, so this is the common case rather than an edge case.

boundingBox() then hits Playwright strict mode, and the catch allowlisted only
timeout/closed/Target/Execution-context messages — so the strict-mode error was
re-thrown and aborted every remaining annotation.

Two changes:

- `.first()` before boundingBox(), so an ambiguous ref draws a box on its first
  match instead of aborting. The heatmap path below has always tolerated this via
  a bare `catch {}`; annotate was the only path that could be killed outright.
- the catch no longer re-throws on unrecognised messages. A box we cannot measure
  is a box we do not draw, never a reason to lose the rest of the page. Set
  BROWSE_DEBUG to see what was skipped.

Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which
reads as "screenshots are broken" rather than "you forgot a flag". It now warns
and points at `browse screenshot <path>`.

Verified by rebuilding both ways against the same page with 51 refs present:
  before — "Selector matched multiple elements", no file written
  after  — exit 0, 229KB PNG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json

repair now fails with exit 2 when the VERSION file is absent or empty
instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and
regressed package.json below where it started. classify gains an additive
versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated
one. Re-derived from PR #2612 under the generated-file screening rule.

Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531).
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses

probeMode previously stat'd every walked file, so setup-gbrain gated its
silent bulk ingest on pre-filter counts that the write path would never
ingest (#2394). The attribution decision now lives in ONE shared gate
(sessionIsAttributable — cheap-parse: cwd extraction + memoized
resolveGitRemote, never a full page build) used by BOTH probeMode and
preparePages, so the two stages' post-attribution counts are structurally
identical. ProbeReport gains skipped_unattributed; the probe prints what it
excluded and --include-unattributed restores raw counts. The parity is
pinned at the prepare stage (probe post-attribution == transcripts reaching
import), deliberately NOT == final written.

Re-derived from PR #2612 under the generated-file screening rule; the
shared-gate design and the remote memo are additions from the plan review.

Fixes #2394.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): allow CPU and network throttling for performance measurement

Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to
CDP_ALLOWLIST.

Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only
machine available was a fast developer workstation. Client-side processing
measured 1.4s where the user experienced minutes, so the conclusion had to be
reached arithmetically rather than observed. Throttling would have let the
measurement reproduce the reporter's conditions directly.

Both fit the existing posture rather than widening it:
  - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride
    and setUserAgentOverride, which are equally mutating and scoped to the tab.
  - Neither method reads page content. setCPUThrottlingRate affects only timing;
    emulateNetworkConditions constrains traffic rather than inspecting it, so no
    request bodies, headers or cookies are exposed. Both are output: 'trusted'
    because they return no page-derived data.

scope 'tab' for both, matching the surrounding Emulation entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613)

echo $$ inside the backgrounded subshell recorded the PARENT hook's PID —
which exits immediately — so every subsequent session judged the lock stale
and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each
other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS
bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell).

Staleness is now two independent detectors: PID liveness (as before, but
against the real holder), and a 30-minute hard TTL on the heartbeat mtime —
reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge
the lock forever. The holder touches the pidfile after the pull and after
setup, so a legitimately-slow run keeps itself alive. Empty and missing
pidfiles are respected inside the TTL window (the mkdir→echo race) and
reclaimed past it.

Fixes #2613.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual)

Bun.spawn sites were structurally outside the windowsHide census (it swept
child_process bindings only). The runtime was already safe — native Bun hides
consoles by default and bun-polyfill.cjs defaults windowsHide !== false since
#2523/#2539 — but implicit defaults are exactly what regress silently. Every
Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on
unix-only sites like Xvfb/xattr/open), and a second SWEEP in
windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516)

The daily pull refreshed only ~/.gstack itself, never the detached worktree
at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the
brain served stale pages forever unless setup-gbrain/sync-gbrain happened to
run. brain-sync --once now advances the worktree once per 24h behind an
ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance
warns once a day, not at every skill boundary), inside the existing run lock
and before any ingest step touches the worktree.

The new gstack-gbrain-source-wireup --advance-only is built for the
unattended cadence: git-only (no gbrain prereqs), pins every operation to the
managed worktree (refuses paths that are not worktrees of the artifacts
repo), refuses dirty worktrees, and never runs the force-remove recovery — a
cron path must not be able to delete local changes. A static pin keeps the
force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence.

Fixes #2516.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392)

Transcript ingest now respects the same trust store as code import — the gate
existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily
ingested transcripts from deny-listed repos. preparePages filters prepared
transcript pages through ONE batch policy lookup (new 'get --batch' verb on
bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client
adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora
never pay a 10s-timeout subprocess per remote).

Outcomes match code-import semantics: read-only → clean skip
(skipped_policy_readonly), deny → counted refusal (skipped_policy_deny),
corrupted/unreadable store → HARD ERROR before any write (state, staging,
egress receipt, and import all untouched) with the recovery command named —
policy corruption must never read as successful ingestion. Artifacts are
never policy-filtered (their git_remote is a project slug, not a remote).

Fixes #2392.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up)

The ported defaults table synthesized repo_mode → "unknown", but EMPTY is
load-bearing for that key: gstack-repo-mode treats any non-empty answer as a
user override and skips its own repo classification — the synthesized default
turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by
test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol).
repo_mode joins the empty-is-real carve-outs (empty output, exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): consent before killing a healthy headless daemon

The pair-agent headed switch spawned 'connect --force-restart'
unconditionally — auto-killing a live headless daemon (open tabs, cookies,
logins) in direct contradiction of the iron rule it sits beside ('only an
explicit --force-restart may kill a live daemon'). The CLI now captures
daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon)
and relaunches only when the user passed --force-restart to pair-agent;
otherwise it prints the tab count and continues against the existing daemon.
The /pair-agent skill gains a matching one-way-door consent question
(template half rides the wave's template block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope

hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json,
so one project's remote gbrain registration reclassified broken local engines
as thin-client machine-wide. It now reads user scope plus only the cwd's
nearest-ancestor project key.

The precedence itself was verified empirically and hermetically (fake HOME +
CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining
gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL
WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint
resolution flips to nearest-ancestor-project-first, and the stale user-first
pin in brain-cache-roundtrip now pins the verified precedence. (The user-first
jq in the brain-sync preamble resolver gets the same swap in the template
block.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug)

Found live during this wave's CEO review: bin/gstack-slug emitted
SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly
gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were
filing into the wrong project store (observed polluting Context Recovery with
another repo's decisions). Root cause: a stray empty ~/.git directory made
the walk-up crown $HOME as the outermost project root; the remote lookup ran
only against that root, failed silently, and the basename fallback cached
'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo
ancestor triggered it.

Fix: the walk now finds the outermost ancestor whose .git actually resolves
an origin remote and derives owner-repo with remote-slug's byte-identical
parse; marker-only ancestors keep anchoring the basename fallback but can no
longer shadow a real remote. A new cache self-heal recomputes the poisoned
shape (cached == basename of a marker root while a remote-bearing repo exists
below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and
non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned
by a 10-case parity suite. Store migration for pre-fix data is tracked in
TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally

Producers appended lines to .brain-queue.jsonl while the drain re-read and
os.replace'd it; the in-code comment admitted a lockless append between the
re-read and the replace was lost. Locks and rename-rotation designs were both
reviewed and rejected (each retained a tail race); the shipped design is a
maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic
rename), the drain snapshots filenames, processes, and deletes exactly what
it snapshotted. Writer and drainer never share an inode — nothing to race.

Semantics: at-least-once (a crash between process and unlink re-drains;
downstream content-hash dedup absorbs duplicates); retained (privacy-held)
records keep their files; unparseable records are kept + warned, never
destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain
(crash-leftover .migrating files recovered too); status/drop-queue count both
surfaces; discover-new writes spool records and advances its cursor
per-record-written. The preamble's queue-depth line switches to spool count
in this wave's template block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin-context): native slug fallback walks up like bash gstack-slug

slugFromEnvironment derived the slug from the INNERMOST repo's origin while
bash gstack-slug walks to the outermost project root — nested/vendored repos
split their stores across the bash/native boundary (win32 hits the native
path constantly). The native fallback now ports _outermost_project_root
faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap,
fixed-point termination) plus the full resolution order: env override →
walk-up → sticky cache with the #1125 self-heal → remote get-url → basename.
Twelve mirrored scenarios drive BOTH implementations against the same
fixtures and pin identical slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width

The degraded path counted every remote-tracking ref on every remote — stale
experiment branches and second remotes inflated version allocation, and a
failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote
--heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref
mutation); on failure, local refs/remotes/origin ONLY with an explicit
stale-refs warning; a failed base read zeroes at the LOCAL version file's
width so a 3-digit repo allocates 0.0.1, not 0.0.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): hooks register the global-install path and re-point stale ones

Registering hooks from a dev worktree baked that worktree's absolute path
into settings.json — deleting the worktree left a dead hook erroring on
every session stop, and the presence-only dedup (list-sources | grep) could
never re-point it. setup's hook paths now route through _hook_install_path
(global install preferred, source dir fallback), and the new ensure-event
verb on gstack-settings-hook compares the registered command payload against
canonical: identical → no write, different → single atomic replacement
(never zero or two registrations). The plan-tune hooks had the same stale
pattern and get the same fix without re-triggering their consent prompt.

Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a
SILENT exit 0 — the registrar's write path now catches, prints, and exits 1,
so a failed update can never report fake-green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): learnings capture is unconditional at completion (#2402)

43 of 44 learnings entries came from explicit /learn — the completion-status
prose read 'if you discovered a durable project quirk... log it', which
models treated as optional. The step now ALWAYS runs: review the session for
durable learnings, log each one, and state 'No durable learnings this
session' explicitly when the review comes up empty — an empty result, never
a skipped step. Re-derived from PR #2612 under the generated-file screening
rule.

Fixes #2402.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scrape): untrusted-content warning on the page-fetching skills (#2441)

/scrape and /skillify consumed page content with zero injection guidance —
the CHANGELOG claimed coverage the skills didn't have. The warning now lives
in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts),
embedded in the browse COMMAND_REFERENCE as before AND injected standalone
into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single
source, wording can never drift between surfaces. Re-derived from PR #2612
under the generated-file screening rule. (Structural isolation for
skillify-generated code is tracked as its own TODO.)

Fixes #2441.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): checklist paths resolve from the installed skill root (#2518)

/review Step 2 read .claude/skills/review/checklist.md — a path relative to
the TARGET repo, which only resolves in gstack's own checkout. Every
checklist/greptile-triage/TODOS-format reference (six across five templates —
two more than the issue named, same class) now uses the installed-root form
~/.claude/skills/gstack/review/... that the templates' other references
already use. The install-root class itself (non-default install dirs) is
#1882, deliberately its own PR.

Fixes #2518.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): one-way-door consent question before a daemon relaunch (template half)

The skill flow now checks daemon liveness before Step 4 and asks an explicit
one-way-door question (tabs/cookies/logins are lost) before passing
--force-restart — never proceeding on a vague reply. Pairs with the CLI-half
commit that stopped pair-agent auto-killing live daemons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(codex): resume does not amortize the ~21K session prelude (#2387)

Measured (#2387): every codex exec call pays Codex's session prelude, and a
resumed call came in slightly ABOVE a fresh one — resume buys continuity,
never token savings. The skill now says so where the resume flow lives:
prefer one codex call per skill, batch questions into it.

Fixes #2387.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517)

/gstack-upgrade went straight to stash + reset --hard origin/main. Now it
tries git pull --ff-only --autostash first (the same policy session-update's
auto-upgrade uses). The destructive fallback runs unprompted ONLY when both
git status --porcelain AND git rev-list origin/main..HEAD are empty — a
clean tree with unpushed local commits is NOT safe, reset destroys them.
Anything else requires an explicit one-way-door confirmation that lists every
dirty file and unpushed commit being discarded.

Fixes #2517.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first

Two resolver halves deferred from earlier wave commits: the queue-depth line
counts .brain-queue.d/*.json spool records (plus legacy lines until the
drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to
nearest-ancestor-project-first — matching the empirically verified Claude
Code precedence (project-local beats user scope) instead of the backwards
user-first assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block)

Pure generator output for the six template/resolver commits above (learnings
capture, untrusted-content warning, review paths, pair-agent consent, codex
resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs +
--host codex + --host factory, with the three ship golden fixtures refreshed
per the documented procedure. The three sidecar-path pins in
gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract
(#2518). Restores template freshness; full suite green from here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups

The v1.67 adversarial-review residuals section shrinks to the one item the
wave couldn't reach (iOS tap routing — needs real-device verification). New
entries: skillify structural isolation (a prose warning is not a boundary for
page-derived generated code) and the slug store migration (pre-fix sessions
on stray-marker machines filed data under the degraded slug; post-fix reads
go to the correct store, so history needs a merge/alias).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align cross-cutting pins with the wave's contracts

Three suites pinned pre-wave behavior: browse's gstack-config test asserted
the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths
suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same
invariant via tmp + os.replace, one file per record — pinned in its new
form); and nine carve-guard skeleton ceilings absorbed the #2402
unconditional-learnings prose (~450B per skill), bumped with measured values
per the guard's own protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs

The self-check pinned the review checklist as a class-1 alias-relative ref;
#2518 moved those refs to the installed gstack root (class 2). The guard now
proves the scanner sees them in their new class, so the class-2 assertion
can't go vacuous.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure)

The coverage audit found one regression-shaped gap: nothing pinned that the
upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a
future template edit reverting to reset-first would fail nothing. Pinned:
the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned
the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in
scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened

The ship review army (4 specialists + red-team + checklist, 29 findings)
produced 8 mechanical auto-fixes and 11 decisions; the accepted set:

- win32 slug parity completed: lib/bin-context.ts gains the remote-first
  outermost walk + degraded-cache self-heal the bash side got this wave —
  the two implementations now agree on the stray-marker live-bug shape,
  pinned by shared fixtures (multi-specialist 9/10 finding).
- probe honors the plan's bounded-read decision: 256KB prefix, extraction
  semantics mirrored from parseTranscriptJsonl so probe/prepare can never
  diverge on the same file (>1MB transcript test).
- policy normalize parity: bash normalize() now matches canonicalizeRemote
  on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two
  ways) — a deny for those shapes could previously slip the transcript gate.
- session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches).
- settings-hook: unparseable settings.json errors instead of being replaced
  with {}; ensure-event keys on (event, source) so matcher changes update
  in place — never zero or two registrations.
- dot-only slug guard at both parse sites (hostile 'url = ..' can't escape
  projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock);
  brain-sync .migrating never clobbered; drop-queue/status count .migrating;
  snapshot -o warning correct + surfaced in diff mode; version-bump test
  order-dependence removed; uninstall clears the advance stamp.

Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK
conflation (noted, misdiagnosis-only).

270 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings

Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured
with P1 gate) on the full wave diff. Multi-source findings, all fixed:

- P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY
  when classification proves it staged or dropped; a classifier crash, a
  missing class file, or a malformed pulled .brain-privacy-map.json (which
  previously nuked the whole snapshotted queue, remotely triggerable) now
  retains everything, warns, and re-drains next run. load_privacy_map treats
  corrupt maps as retain-all, never as empty.
- next-version cannot silently drop a live claim: unreadable advertised refs
  get a targeted --depth=1 fetch + retry; still-unreadable claims surface as
  UNKNOWN warnings instead of duplicate-version silence.
- session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder
  can no longer delete the new holder's lock) + a 5-min background heartbeat
  so a legitimately-slow pull/setup is never reclaimed while alive.
- ensure-event collapses ALL same-(event,source) duplicates to one canonical
  entry; unique per-process tmp path; setup call sites surface (not swallow)
  the hardened refusals.
- memory-ingest: --limit counts only policy-permitted pages (denied records
  no longer starve permitted ones); --probe applies the same policy filter as
  --bulk (skipped_policy_* fields on the report).
- version-bump repair accepts a genuine literal 0.0.0.0 VERSION file.
- slug heal restricted to the stray-.git shape — package.json-anchored
  wrapper roots keep their legit sticky identity (#2212 preserved).
- brain-sync: idle fast path sees leftover .migrating records; unparseable
  spool records quarantine instead of warning forever; migration comment
  stops overclaiming the transition-window race.
- CDP throttling justifications document override persistence (callers own
  restoration), pinned in the allowlist test.

Deferred with record: deny retroactivity for already-ingested pages (P2 TODO,
same semantics as the code-import gate); legacy-migration tail race
(transition-window, requires pre-spool writers).

288 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix)

Pure generator output for the brain-sync block's jq ancestor match now
accepting backslash-formed Windows project keys — previously project-scoped
brains were invisible on Windows while the TS scope resolvers saw them.
Golden ship fixtures refreshed per the documented procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth

The verify re-review passed the P1 gate (0 P1s) and left three residuals,
all applied: transcriptCwdFromPrefix reads in chunks until one complete
record (4MB cap) so a giant first prompt can't truncate mid-JSON and break
probe/bulk parity; partial_pages derives from the FINAL prepared set instead
of the whole scanned corpus; the preamble queue-depth line counts leftover
.brain-queue.jsonl.migrating records like the status path does (regen + goldens included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.68.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.68.0.0

BROWSER.md: fix the $B cdp example (positional JSON params, not --json;
depth is the real CDP param) and add the new perf-throttling examples
(Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with
their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the
state-files table row for the sync queue now names the maildir-style
spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align memory-pipeline probe pins with the #2394 stage-count contract

The paid-tier E2E pinned the pre-fix contract (probe headline = raw
discovered). Probe now counts post-attribution — the same gate --bulk
uses — with an explicit unattributed-skip line. Adds the
--include-unattributed companion pin so all 9 fixtures stay accounted for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl

The targeted-fetch retry for branches whose advertised tip has no local
object ran ONE git fetch per branch (10s cap each). On a shallow clone
against a busy remote that crawls the network for minutes — CI's shard
deadline killed the free suite mid-file. Missing tips now collect into a
single batched shallow fetch (15s cap); refs still missing after the
batch (one unservable ref fails the whole transfer) get a capped
per-branch retry, and anything past the cap warns as an UNKNOWN claim
instead of fetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the batched fetch + make the offline-contract tests hermetic

Two new G2 pins: N unfetched claim branches resolve with exactly ONE
fetch spawn (PATH-shimmed git counts invocations), and one unservable
ref no longer poisons the batch — live claims resolve via the bounded
retry while only the ghost warns UNKNOWN.

The #2545 offline-contract tests now run the CLI in a local fixture repo
instead of the repo's own checkout: the checkout path did a live
ls-remote against the real origin (operator-network-dependent, and the
CI shard-deadline hang). The online-contract test gains a succeeding gh
stub, so fallback:null is asserted deterministically instead of only
when the operator happens to be authed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source

The CI quality gate scans every ADDED diff line with the redact engine,
so the #2610 port's raw fixture literals failed the very gate they
exist to test. The fixture is now assembled at runtime; the scanner
still receives the identical bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence

The hermetic offline-contract fixture had no origin remote, so
detectHost() fell through to auth probes: a machine with glab authed
passed via the gitlab path while a bare CI runner read host:unknown
(offline stays false there) and failed. The fixture now pushes to a
local bare origin at a path containing github.com — the URL sniff pins
host:github identically everywhere, asserted explicitly in both tests,
with every git call still local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: y$un_ <forrest.sun527@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ricky <ricky@kinokostudio.com.hk>
Co-authored-by: Connex Client Access <paul@paulkortman.com>
Co-authored-by: henbima <henbima@gmail.com>
2026-08-19 11:42:55 -07:00

1047 lines
38 KiB
TypeScript

/**
* Chromium browser cookie import — read and decrypt cookies from real browsers
*
* Supports macOS, Linux, and Windows Chromium-based browsers.
* Pure logic module — no Playwright dependency, no HTTP concerns.
*
* Decryption pipeline:
*
* ┌──────────────────────────────────────────────────────────────────┐
* │ 1. Resolve the cookie DB from the browser profile dir │
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
* │ - Linux: ~/.config/<browser>/<profile> │
* │ │
* │ 2. Derive the AES key │
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
* │ │
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
* │ "v11": │
* │ - Ciphertext = encrypted_value[3:] │
* │ - IV = 16 bytes of 0x20 (space character) │
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
* │ - Remove PKCS7 padding │
* │ - Skip first 32 bytes of Chromium cookie metadata │
* │ - Remaining bytes = cookie value (UTF-8) │
* │ │
* │ 4. If encrypted_value is empty but `value` field is set, │
* │ use value directly (unencrypted cookie) │
* │ │
* │ 5. Chromium epoch: microseconds since 1601-01-01 │
* │ Unix seconds = (epoch - 11644473600000000) / 1000000 │
* │ │
* │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │
* └──────────────────────────────────────────────────────────────────┘
*/
import { Database } from 'bun:sqlite';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { TEMP_DIR } from './platform';
// ─── Types ──────────────────────────────────────────────────────
export interface BrowserInfo {
name: string;
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
keychainService: string;
aliases: string[];
linuxDataDir?: string;
linuxApplication?: string;
windowsDataDir?: string;
}
export interface ProfileEntry {
name: string; // e.g. "Default", "Profile 1", "Profile 3"
displayName: string; // human-friendly name from Preferences, or falls back to dir name
}
export interface DomainEntry {
domain: string;
count: number;
}
export interface ImportResult {
cookies: PlaywrightCookie[];
count: number;
failed: number;
domainCounts: Record<string, number>;
}
export interface PlaywrightCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}
export class CookieImportError extends Error {
constructor(
message: string,
public code: string,
public action?: 'retry',
) {
super(message);
this.name = 'CookieImportError';
}
}
type BrowserPlatform = 'darwin' | 'linux' | 'win32';
interface BrowserMatch {
browser: BrowserInfo;
platform: BrowserPlatform;
dbPath: string;
}
// ─── Browser Registry ───────────────────────────────────────────
// Hardcoded — NEVER interpolate user input into shell commands.
const BROWSER_REGISTRY: BrowserInfo[] = [
{ 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: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ 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: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
];
// ─── Key Cache ──────────────────────────────────────────────────
// Cache derived AES keys per browser. First import per browser does
// Keychain + PBKDF2. Subsequent imports reuse the cached key.
const keyCache = new Map<string, Buffer>();
// ─── Public API ─────────────────────────────────────────────────
/**
* Find which browsers are installed (have a cookie DB on disk in any profile).
*/
export function findInstalledBrowsers(): BrowserInfo[] {
return BROWSER_REGISTRY.filter(browser => {
// Check Default profile on any platform
if (findBrowserMatch(browser, 'Default') !== null) return true;
// Check numbered profiles (Profile 1, Profile 2, etc.)
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const browserDir = path.join(getBaseDir(platform), dataDir);
try {
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
if (entries.some(e => {
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
const profileDir = path.join(browserDir, e.name);
return fs.existsSync(path.join(profileDir, 'Cookies'))
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
})) return true;
} catch {}
}
return false;
});
}
export function listSupportedBrowserNames(): string[] {
const hostPlatform = getHostPlatform();
return BROWSER_REGISTRY
.filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
.map(browser => browser.name);
}
/**
* List available profiles for a browser.
*/
export function listProfiles(browserName: string): ProfileEntry[] {
const browser = resolveBrowser(browserName);
const profiles: ProfileEntry[] = [];
// Scan each supported platform for profile directories
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const browserDir = path.join(getBaseDir(platform), dataDir);
if (!fs.existsSync(browserDir)) continue;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(browserDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
// Chrome 80+ on Windows stores cookies under Network/Cookies
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
if (profiles.some(p => p.name === entry.name)) continue;
// Try to read display name from Preferences.
// Prefer account email — signed-in Chrome profiles often have generic
// names like "Person 2" while the email is far more readable.
let displayName = entry.name;
try {
const prefsPath = path.join(browserDir, entry.name, 'Preferences');
if (fs.existsSync(prefsPath)) {
const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
const email = prefs?.account_info?.[0]?.email;
if (email && typeof email === 'string') {
displayName = email;
} else {
const profileName = prefs?.profile?.name;
if (profileName && typeof profileName === 'string') {
displayName = profileName;
}
}
}
} catch {
// Ignore — fall back to directory name
}
profiles.push({ name: entry.name, displayName });
}
// Found profiles on this platform — no need to check others
if (profiles.length > 0) break;
}
return profiles;
}
/**
* List unique cookie domains + counts from a browser's DB. No decryption.
*/
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
const browser = resolveBrowser(browserName);
const match = getBrowserMatch(browser, profile);
const db = openDb(match.dbPath, browser.name);
try {
const now = chromiumNow();
const rows = db.query(
`SELECT host_key AS domain, COUNT(*) AS count
FROM cookies
WHERE has_expires = 0 OR expires_utc > ?
GROUP BY host_key
ORDER BY count DESC`
).all(now) as DomainEntry[];
return { domains: rows, browser: browser.name };
} finally {
db.close();
}
}
/**
* Decrypt and return Playwright-compatible cookies for specific domains.
*/
export async function importCookies(
browserName: string,
domains: string[],
profile = 'Default',
): Promise<ImportResult> {
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
const browser = resolveBrowser(browserName);
const match = getBrowserMatch(browser, profile);
const derivedKeys = await getDerivedKeys(match);
const db = openDb(match.dbPath, browser.name);
try {
const now = chromiumNow();
// Parameterized query — no SQL injection
const placeholders = domains.map(() => '?').join(',');
const rows = db.query(
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
is_secure, is_httponly, has_expires, samesite
FROM cookies
WHERE host_key IN (${placeholders})
AND (has_expires = 0 OR expires_utc > ?)
ORDER BY host_key, name`
).all(...domains, now) as RawCookie[];
const cookies: PlaywrightCookie[] = [];
let failed = 0;
const domainCounts: Record<string, number> = {};
for (const row of rows) {
try {
const value = decryptCookieValue(row, derivedKeys, match.platform);
const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
} catch {
failed++;
}
}
return { cookies, count: cookies.length, failed, domainCounts };
} finally {
db.close();
}
}
// ─── Internal: Browser Resolution ───────────────────────────────
function resolveBrowser(nameOrAlias: string): BrowserInfo {
const needle = nameOrAlias.toLowerCase().trim();
const found = BROWSER_REGISTRY.find(b =>
b.aliases.includes(needle) || b.name.toLowerCase() === needle
);
if (!found) {
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
throw new CookieImportError(
`Unknown browser '${nameOrAlias}'. Supported: ${supported}`,
'unknown_browser',
);
}
return found;
}
function validateProfile(profile: string): void {
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
throw new CookieImportError(
`Invalid profile name: '${profile}'`,
'bad_request',
);
}
}
function getHostPlatform(): BrowserPlatform | null {
const p = process.platform;
if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
return null;
}
function getSearchPlatforms(): BrowserPlatform[] {
const current = getHostPlatform();
const order: BrowserPlatform[] = [];
if (current) order.push(current);
for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
if (!order.includes(platform)) order.push(platform);
}
return order;
}
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
if (platform === 'darwin') return browser.dataDir;
if (platform === 'linux') return browser.linuxDataDir || null;
return browser.windowsDataDir || null;
}
function getBaseDir(platform: BrowserPlatform): string {
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
return path.join(os.homedir(), '.config');
}
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
validateProfile(profile);
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
const candidates = platform === 'win32'
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
: [path.join(baseProfile, 'Cookies')];
for (const dbPath of candidates) {
try {
if (fs.existsSync(dbPath)) {
return { browser, platform, dbPath };
}
} catch {}
}
}
return null;
}
function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
const match = findBrowserMatch(browser, profile);
if (match) return match;
const attempted = getSearchPlatforms()
.map(platform => {
const dataDir = getDataDirForPlatform(browser, platform);
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
})
.filter((entry): entry is string => entry !== null);
throw new CookieImportError(
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
'not_installed',
);
}
// ─── Internal: SQLite Access ────────────────────────────────────
function openDb(dbPath: string, browserName: string): Database {
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
// The readonly open may "succeed" but return empty results because the WAL
// (where all actual data lives) can't be replayed. Always use the copy
// approach on Windows so we can open read-write and process the WAL.
if (process.platform === 'win32') {
return openDbFromCopy(dbPath, browserName);
}
try {
return new Database(dbPath, { readonly: true });
} catch (err: any) {
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
return openDbFromCopy(dbPath, browserName);
}
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
throw new CookieImportError(
`Cookie database for ${browserName} is corrupt`,
'db_corrupt',
);
}
throw err;
}
}
function openDbFromCopy(dbPath: string, browserName: string): Database {
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
try {
fs.copyFileSync(dbPath, tmpPath);
// Also copy WAL and SHM if they exist (for consistent reads)
const walPath = dbPath + '-wal';
const shmPath = dbPath + '-shm';
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
const db = new Database(tmpPath, { readonly: true });
// Schedule cleanup after the DB is closed
const origClose = db.close.bind(db);
db.close = () => {
origClose();
try { fs.unlinkSync(tmpPath); } catch {}
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
};
return db;
} catch {
// Clean up on failure
try { fs.unlinkSync(tmpPath); } catch {}
throw new CookieImportError(
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
'db_locked',
'retry',
);
}
}
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
function deriveKey(password: string, iterations: number): Buffer {
return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
}
function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
const cached = keyCache.get(cacheKey);
if (cached) return cached;
const derived = deriveKey(password, iterations);
keyCache.set(cacheKey, derived);
return derived;
}
async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>> {
if (match.platform === 'darwin') {
const password = await getMacKeychainPassword(match.browser.keychainService);
return new Map([
['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)],
]);
}
if (match.platform === 'win32') {
const key = await getWindowsAesKey(match.browser);
return new Map([['v10', key]]);
}
const keys = new Map<string, Buffer>();
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
const linuxPassword = await getLinuxSecretPassword(match.browser);
if (linuxPassword) {
keys.set(
'v11',
getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1),
);
}
return keys;
}
async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
const cacheKey = `win32:${browser.keychainService}`;
const cached = keyCache.get(cacheKey);
if (cached) return cached;
const platform = 'win32' as const;
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
let localState: any;
try {
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
} catch (err) {
const reason = err instanceof Error ? `: ${err.message}` : '';
throw new CookieImportError(
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
'keychain_error',
);
}
const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
if (!encryptedKeyB64) {
throw new CookieImportError(
`No encrypted key in Local State for ${browser.name}`,
'keychain_not_found',
);
}
// The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
const key = await dpapiDecrypt(encryptedKey);
keyCache.set(cacheKey, key);
return key;
}
async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
const script = [
'Add-Type -AssemblyName System.Security',
'$stdin = [Console]::In.ReadToEnd().Trim()',
'$bytes = [System.Convert]::FromBase64String($stdin)',
'$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
'Write-Output ([System.Convert]::ToBase64String($dec))',
].join('; ');
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
windowsHide: true,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
proc.stdin.write(encryptedBytes.toString('base64'));
proc.stdin.end();
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
}, 10_000),
);
try {
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
}
return Buffer.from(stdout.trim(), 'base64');
} catch (err) {
if (err instanceof CookieImportError) throw err;
throw new CookieImportError(
`DPAPI decryption failed: ${(err as Error).message}`,
'keychain_error',
);
}
}
async function getMacKeychainPassword(service: string): Promise<string> {
// Use async Bun.spawn with timeout to avoid blocking the event loop.
// macOS may show an Allow/Deny dialog that blocks until the user responds.
const proc = Bun.spawn(
['security', 'find-generic-password', '-s', service, '-w'],
{ stdout: 'pipe', stderr: 'pipe', windowsHide: true },
);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new CookieImportError(
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
'keychain_timeout',
'retry',
));
}, 10_000),
);
try {
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
// Distinguish denied vs not found vs other
const errText = stderr.trim().toLowerCase();
if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) {
throw new CookieImportError(
`Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`,
'keychain_denied',
'retry',
);
}
if (errText.includes('could not be found') || errText.includes('not found')) {
throw new CookieImportError(
`No Keychain entry for "${service}". Is this a Chromium-based browser?`,
'keychain_not_found',
);
}
throw new CookieImportError(
`Could not read Keychain: ${stderr.trim()}`,
'keychain_error',
'retry',
);
}
return stdout.trim();
} catch (err) {
if (err instanceof CookieImportError) throw err;
throw new CookieImportError(
`Could not read Keychain: ${(err as Error).message}`,
'keychain_error',
'retry',
);
}
}
async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | null> {
const attempts: string[][] = [
['secret-tool', 'lookup', 'Title', browser.keychainService],
];
if (browser.linuxApplication) {
attempts.push(
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication],
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication],
);
}
for (const cmd of attempts) {
const password = await runPasswordLookup(cmd, 3_000);
if (password) return password;
}
return null;
}
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
try {
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new Error('timeout'));
}, timeoutMs),
);
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
if (exitCode !== 0) return null;
const password = stdout.trim();
return password.length > 0 ? password : null;
} catch {
return null;
}
}
// ─── Internal: Cookie Decryption ────────────────────────────────
interface RawCookie {
host_key: string;
name: string;
value: string;
encrypted_value: Buffer | Uint8Array;
path: string;
expires_utc: number | bigint;
is_secure: number;
is_httponly: number;
has_expires: number;
samesite: number;
}
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
// Prefer unencrypted value if present
if (row.value && row.value.length > 0) return row.value;
const ev = Buffer.from(row.encrypted_value);
if (ev.length === 0) return '';
const prefix = ev.slice(0, 3).toString('utf-8');
// Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
// outside the Chrome process. Caller should fall back to CDP extraction.
if (prefix === 'v20') throw new CookieImportError(
'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
'v20_encryption',
);
const key = keys.get(prefix);
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
if (platform === 'win32' && prefix === 'v10') {
// Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
const nonce = ev.slice(3, 15);
const tag = ev.slice(ev.length - 16);
const ciphertext = ev.slice(15, ev.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
}
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 0x20); // 16 space characters
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
// Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
if (plaintext.length <= 32) return '';
return plaintext.slice(32).toString('utf-8');
}
function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie {
return {
name: row.name,
value,
domain: row.host_key,
path: row.path || '/',
expires: chromiumEpochToUnix(row.expires_utc, row.has_expires),
secure: row.is_secure === 1,
httpOnly: row.is_httponly === 1,
sameSite: mapSameSite(row.samesite),
};
}
// ─── Internal: Chromium Epoch Conversion ────────────────────────
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
function chromiumNow(): bigint {
// Current time in Chromium epoch (microseconds since 1601-01-01)
return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET;
}
function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number {
if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie
const epochBig = BigInt(epoch);
const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET;
return Number(unixMicro / 1000000n);
}
function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
switch (value) {
case 0: return 'None';
case 1: return 'Lax';
case 2: return 'Strict';
default: return 'Lax';
}
}
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
// with remote debugging and extract cookies via the DevTools Protocol.
// This only works when Chrome is NOT already running (profile lock).
const CHROME_PATHS_WIN = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
];
const EDGE_PATHS_WIN = [
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
];
function findBrowserExe(browserName: string): string | null {
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
function isBrowserRunning(browserName: string): Promise<boolean> {
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();
resolve(out.toLowerCase().includes(exe));
}).catch(() => resolve(false));
});
}
/**
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
* remote debugging on the user's real profile directory. Requires Chrome to be
* closed first (profile lock).
*
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
* path, so a temp copy of the profile won't work — Chrome silently discards
* cookies it can't decrypt. We must use the real profile.
*/
export async function importCookiesViaCdp(
browserName: string,
domains: string[],
profile = 'Default',
): Promise<ImportResult> {
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
if (process.platform !== 'win32') {
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
}
const browser = resolveBrowser(browserName);
const exePath = findBrowserExe(browser.name);
if (!exePath) {
throw new CookieImportError(
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
'not_installed',
);
}
if (await isBrowserRunning(browser.name)) {
throw new CookieImportError(
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
'browser_running',
'retry',
);
}
// Must use the real user data dir — v20 ABE keys are path-bound
const dataDir = getDataDirForPlatform(browser, 'win32');
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const userDataDir = path.join(getBaseDir('win32'), dataDir);
// Launch Chrome headless with remote debugging on the real profile.
//
// Security posture of the debug port:
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The
// port is NOT exposed to the network. Baseline threat: a local
// process running as the same user can connect.
// - Port is randomized in [9222, 9321] to avoid collisions with other
// Chrome-based tools. Not cryptographic — security relies on
// same-user-access baseline, not port secrecy.
// - Chrome is always killed in the finally block below (even on crash).
//
// KNOWN NON-GOAL (tracked as a separate hardening task for the next
// security wave):
// On Windows 10.15+ with App-Bound Encryption (v20) enabled, a
// same-user process that opens the cookie DB directly cannot decrypt
// v20 values — the DPAPI context is bound to the browser process.
// The CDP port bypasses that: `Network.getAllCookies` runs inside the
// browser, so any same-user process that connects to the debug port
// before we kill Chrome could exfiltrate decrypted v20 cookies.
// Fix direction: switch to `--remote-debugging-pipe` so the CDP
// transport is a parent/child stdio pipe, not TCP. Requires
// restructuring the extractCookiesViaCdp WebSocket client; deferred
// to a follow-up because the transport swap is non-trivial and the
// baseline threat is still "attacker already has same-user access."
//
// Debugging note: if this path starts failing after a Chrome update,
// check the Chrome version logged below — Chrome's ABE key format (v20)
// or /json/list shape can change between major versions.
const debugPort = 9222 + Math.floor(Math.random() * 100);
const chromeProc = Bun.spawn([
exePath,
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
`--profile-directory=${profile}`,
'--headless=new',
'--no-first-run',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--no-default-browser-check',
], { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
// Wait for Chrome to start, then find a page target's WebSocket URL.
// Network.getAllCookies is only available on page targets, not browser.
let wsUrl: string | null = null;
const startTime = Date.now();
let loggedVersion = false;
while (Date.now() - startTime < 15_000) {
try {
// One-time version log for future diagnostics when Chrome changes v20 format.
if (!loggedVersion) {
try {
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
if (versionResp.ok) {
const v = await versionResp.json() as { Browser?: string };
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
loggedVersion = true;
}
} catch {}
}
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
if (resp.ok) {
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
const page = targets.find(t => t.type === 'page');
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl;
break;
}
}
} catch {
// Not ready yet
}
await new Promise(r => setTimeout(r, 300));
}
if (!wsUrl) {
chromeProc.kill();
throw new CookieImportError(
`${browser.name} headless did not start within 15s`,
'cdp_timeout',
'retry',
);
}
try {
// Connect via CDP WebSocket
const cookies = await extractCookiesViaCdp(wsUrl, domains);
const domainCounts: Record<string, number> = {};
for (const c of cookies) {
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
}
return { cookies, count: cookies.length, failed: 0, domainCounts };
} finally {
chromeProc.kill();
}
}
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let msgId = 1;
const timeout = setTimeout(() => {
ws.close();
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
}, 10_000);
ws.onopen = () => {
// Enable Network domain first, then request all cookies
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(String(event.data));
// After Network.enable succeeds, request all cookies
if (data.id === 1 && !data.error) {
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
return;
}
if (data.id === msgId && data.result?.cookies) {
clearTimeout(timeout);
ws.close();
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
const domainSet = new Set<string>();
for (const d of domains) {
domainSet.add(d);
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
}
const matched: PlaywrightCookie[] = [];
for (const c of data.result.cookies as CdpCookie[]) {
if (!domainSet.has(c.domain)) continue;
matched.push({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
expires: c.expires === -1 ? -1 : c.expires,
secure: c.secure,
httpOnly: c.httpOnly,
sameSite: cdpSameSite(c.sameSite),
});
}
resolve(matched);
} else if (data.id === msgId && data.error) {
clearTimeout(timeout);
ws.close();
reject(new CookieImportError(
`CDP error: ${data.error.message}`,
'cdp_error',
));
}
};
ws.onerror = (err) => {
clearTimeout(timeout);
reject(new CookieImportError(
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
'cdp_error',
));
};
});
}
interface CdpCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
size: number;
httpOnly: boolean;
secure: boolean;
session: boolean;
sameSite: string;
}
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
switch (value) {
case 'Strict': return 'Strict';
case 'Lax': return 'Lax';
case 'None': return 'None';
default: return 'Lax';
}
}
/**
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
* Quick check — reads a small sample, no decryption attempted.
*/
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
if (process.platform !== 'win32') return false;
try {
const browser = resolveBrowser(browserName);
const match = getBrowserMatch(browser, profile);
const db = openDb(match.dbPath, browser.name);
try {
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
return rows.some(row => {
const ev = Buffer.from(row.encrypted_value);
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
});
} finally {
db.close();
}
} catch {
return false;
}
}