feat(desktop): resolve Chromium cookie keys on Linux#7261

merged18
browser-import-more-sources
browser-import-linux-windows
openedAug 16, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 1d ago

Stacked on #7260 (browser-import-more-sources). Despite the retained branch name, this PR adds Linux support only; Windows Chromium is intentionally unsupported.

Chromium cookie key handling moves into a platform-specific module:

PlatformSchemeKey source
macOSv10 AES-128-CBCLogin Keychain, with consent
Linuxv10 AES-128-CBCChromium’s basic-storage peanuts passphrase
Linuxv11 AES-128-CBCSecret Service via secret-tool, using the browser’s application attribute

Linux requires secret-tool and a compatible Secret Service backend to import v11 records. Missing keys/tools/backend can yield a partial v10 import; skipped records are reported. Explicit permission denial or cancellation stops the import. Secret bytes are preserved apart from the tool’s output line ending.

There is no DPAPI, PowerShell, App-Bound Encryption bypass, or direct KWallet implementation. macOS keeps the existing in-process consent path.

Validation: synthetic v10/v11 decryption fixtures, mocked Secret Service subprocess results, denied/missing backend behavior, and trailing-whitespace preservation. No live Linux keyring lookup was performed during this audit.

Original implementation: Claude Code. Review fixes: GPT-5.6 Sol agents, coordinated through Codex.

Note

Add Chromium cookie key resolution for Linux and Windows imports

  • Adds the ChromiumKeys module to resolve macOS, Linux, and Windows encryption keys, replacing hardcoded macOS-only keychain logic.
  • Introduces a native C helper bundled with the Linux desktop build to read Chromium secrets from libsecret.
  • Updates the ChromiumCookies value decoder to handle v10/v11 CBC, Windows v10 GCM, and legacy cleartext, preserving partial imports when some keys are missing.
  • Adds Windows support for the Helium browser source and cookie-database running-state detection.
  • Risk: The ChromiumCookieReadReason schema in ChromiumCookies.ts replaces macOS-specific failure literals with shared ChromiumKeyFailure reasons and removes unsupportedPlatform.

Macroscope summarized 0da299e.


Note

High Risk Changes how cookie encryption keys are obtained (keychain, libsecret subprocess, DPAPI) and ships a native helper that reads browser secrets—security- and privacy-sensitive paths with partial-import edge cases.

Overview Enables Chromium-family browser cookie import on Linux by resolving encryption keys per platform instead of treating non-macOS Chromium as unsupported.

Key handling moves into ChromiumKeys: macOS still uses the login keychain; Linux derives v10 keys from Chromium’s keyring-free passphrase, v11 from Secret Service via a bundled t3-browser-secret helper (native libsecret, exit codes for missing/denied/unavailable), plus an empty-passphrase retry for mis-encrypted records. ChromiumCookies decrypts mixed v10/v11 CBC schemes, allows partial imports when only some keys exist, and aligns legacy unprefixed values with Chromium on Linux.

The helper is compiled at dev/build (build-browser-secret.mjs), wired through LinuxBrowserSecret, shipped under resources/browser-secret on Linux artifacts, and CI/release install libsecret-1-dev / pkg-config. Browser source definitions gain linuxSecretApplication; Helium is listed on Linux (and Windows profile paths / running detection via held cookie DBs). Contracts and the import wizard add keychainUnavailable as a retryable failure.

The diff also includes Windows DPAPI key reading and Helium-on-Windows plumbing in the same modules; other Chromium forks on Windows remain excluded in source metadata.

Reviewed by Cursor Bugbot for commit 0da299e5c45c76f1cb4fae974050aeeea5110cdd. Bugbot is set up for automated code reviews on this repo. Configure here.

Checks

Some checks failed
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a0d1595-8e5e-4594-b3da-057657d6ef1a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tslines 153–155Outdated · 1d228daOpen thread
……@@ -71,86 +70,91 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
152+ } catch (cause) {
153+ throw new ChromiumCookieReadError({
154+ reason: cause instanceof ChromiumKeyError ? cause.reason : "unsupportedPlatform",
155+ });
MmacroscopeappBoton lines 153–155Aug 16, 2026

🟡 Medium BrowserImport/ChromiumCookies.ts:153

readChromiumCookies reports unsupportedPlatform for every non-ChromiumKeyError thrown by resolveChromiumKeys, so Windows failures such as DPAPI rejection or malformed key output are misclassified on a supported platform instead of reaching BrowserImport as readFailed. Preserve the original error for unexpected failures and only wrap ChromiumKeyError instances.

✅ Resolved in 6a2afea3ed6cc28722026fb50b2134b11a590dad

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire—13.4 KiB—15.1 KiB✅
CodexThread snapshot wire—7.0 KiB—7.3 KiB✅
CodexLive turn WebSocket wire—6.4 KiB—7.8 KiB✅
CodexLive turn WebSocket decoded—56.2 KiB—66.4 KiB✅
CodexLive turn messages—8—21✅
ClaudeTotal thread wire—13.5 KiB—15.1 KiB✅
ClaudeThread snapshot wire—7.0 KiB—7.3 KiB✅
ClaudeLive turn WebSocket wire—6.5 KiB—7.8 KiB✅
ClaudeLive turn WebSocket decoded—57.8 KiB—66.4 KiB✅
ClaudeLive turn messages—8—21✅

Baseline: unavailable · PR result: 0da299e · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.8 KiB
  • Claude decoded thread snapshot: 114.5 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 158–159Outdated · 6a2afeaOpen thread
……@@ -0,0 +1,234 @@
156+ return yield* fileSystem.readFileString(localStatePath).pipe(
157+ Effect.flatMap(decodeLocalState),
158+ Effect.orElseSucceed(() => ({}) as typeof LocalState.Type),
159+ );
MmacroscopeappBoton lines 158–159Aug 16, 2026

🟡 Medium BrowserImport/ChromiumKeys.ts:158

readLocalState turns missing, unreadable, and corrupt Local State files into {}, so Windows reports these failures as keychainItemMissing instead of surfacing the metadata read/decoding error. Remove the Effect.orElseSucceed fallback so the caller can report readFailed or an equivalent non-keychain error.

✅ Resolved in 65f945a4fe6761b0856dc6a95989079abc100bb0

MmacroscopeappBotreviewed · 2 inline threadsAug 16, 2026
Synced from GitHub

Reviewed the new ChromiumKeys module and its call sites against the Effect service conventions. Imports, Effect.fn.Return requirement typing, and dependency acquisition (yield* FileSystem.FileSystem, ChildProcessSpawner surfaced in the requirement channel and threaded through BrowserImport's captured context) all look right. Two error-modelling issues in ChromiumKeys.ts: an underlying failure is discarded instead of being preserved as cause, and the Windows DPAPI path can turn a real failure into a silently empty key reported under a misleading reason.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 156–159Outdated · 6a2afeaOpen thread
……@@ -0,0 +1,234 @@
156+ return yield* fileSystem.readFileString(localStatePath).pipe(
157+ Effect.flatMap(decodeLocalState),
158+ Effect.orElseSucceed(() => ({}) as typeof LocalState.Type),
159+ );
MmacroscopeappBoton lines 156–159Aug 16, 2026

BrowserImport/ChromiumKeys.ts:159

readLocalState swallows both the file-read and the JSON/schema decode failure, so a missing, unreadable, or malformed Local State reaches the caller as an empty object and is then reported as ChromiumKeyError({ reason: "keychainItemMissing" }) with no cause and no path context. Consider mapping the failure into the error instead, so the underlying error chain survives and a genuinely absent os_crypt.encrypted_key stays distinguishable from an unreadable file.

suggestion
1 return yield* fileSystem.readFileString(localStatePath).pipe(
2 Effect.flatMap(decodeLocalState),
3 Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainItemMissing", cause })),
4 );

No longer relevant as of 65f945a4fe6761b0856dc6a95989079abc100bb0

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 139–140Outdated · 6a2afeaOpen thread
……@@ -0,0 +1,234 @@
137+ script,
138+ ]);
139+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
140+ return Buffer.from(stdout.trim(), "base64");
MmacroscopeappBoton lines 139–140Aug 16, 2026

BrowserImport/ChromiumKeys.ts:140

The PowerShell exit code is never inspected, so a failed Unprotect (or a missing powershell.exe path that still exits) yields empty stdout and this returns a zero-length Buffer. That buffer is truthy, so gcmV10 is populated with an unusable key, every GCM record fails to decrypt in decryptChromiumValue, and the import silently reports zero cookies instead of a typed failure. Consider surfacing it as a ChromiumKeyError (the repo checks exit codes this way in DesktopLinuxUrlHandler.setDefaultHandler):

suggestion
1 const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
2 const exitCode = yield* process.exitCode;
3 if (Number(exitCode) !== 0 || stdout.trim() === "") {
4 return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });
5 }
6 return Buffer.from(stdout.trim(), "base64");

If you adopt this, the mapper at line 228 should pass an existing ChromiumKeyError through rather than re-wrapping it. Separately, that mapper classifies every spawn/unwrap failure as keychainItemMissing, which renders as "No encryption key in your Keychain — sign in to that browser once, then retry" on Windows; a reason that structurally identifies the unwrap failure (e.g. a readFailed-style literal) would keep the caller-visible copy accurate.

No longer relevant as of 65f945a4fe6761b0856dc6a95989079abc100bb0

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 140Outdated · 6a2afeaOpen thread
……@@ -0,0 +1,234 @@
137+ script,
138+ ]);
139+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
140+ return Buffer.from(stdout.trim(), "base64");
CcursorBoton line 140Aug 16, 2026

DPAPI ignores PowerShell exit status

High Severity

unprotectWithDpapi reads PowerShell stdout and never checks exitCode. Effect does not treat a non-zero exit as failure, so a failed ProtectedData.Unprotect still yields an empty/garbage buffer as gcmV10. Every cookie then decrypts to null and the import returns success with zero cookies instead of a ChromiumKeyError.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 6a2afea3ed6cc28722026fb50b2134b11a590dad. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 206Outdated · 6a2afeaOpen thread
……@@ -0,0 +1,234 @@
203+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(
204+ // No Secret Service, locked keyring, or a differently-keyed entry.
205+ Effect.orElseSucceed(() => undefined),
206+ )
CcursorBoton line 206Aug 16, 2026

Linux keyring lookup uses wrong attributes

High Severity

Linux v11 key resolution reuses macOS keychainService / keychainAccount with @napi-rs/keyring, which looks up Secret Service by service + username. Chromium stores the OSCrypt secret under schema chrome_libsecret_os_crypt_password_v2 with attribute application (e.g. chrome, msedge). The lookup misses, orElseSucceed drops the error, and only the peanuts v10 key remains—so normal keyring-backed Linux profiles import no v11 cookies.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 6a2afea3ed6cc28722026fb50b2134b11a590dad. Configure here.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial cross-platform browser credential and cookie-import capability, including native keyring access and sensitive-data handling, rather than a small isolated change. An unresolved runtime concern about the bundled helper path, plus a universal-architecture build issue and newly added static-analysis suppressions, requires human review.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

No code changes detected at 4bc257e. Prior analysis still applies.

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

One finding: a typed ChromiumKeyError is re-wrapped at a translation boundary in ChromiumKeys.ts, losing its reason and nesting the error inside itself.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 232Outdated · 0ad3689Open thread
……@@ -0,0 +1,263 @@
229+ // reach the keyring is not fatal — it just leaves those records skipped.
230+ const keyringSecret =
231+ request.keychainService && request.keychainAccount
232+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(
MmacroscopeappBoton line 232Aug 16, 2026

🟠 High BrowserImport/ChromiumKeys.ts:232

Linux imports silently omit every v11 cookie when Chromium stores its OSCrypt secret in Secret Service, making the import incomplete. readKeychainSecret uses Keyring.Entry(service, account) with the macOS-style coordinates, but Linux Chromium stores the secret under its custom Secret Service schema (for example, chrome_libsecret_os_crypt_password_v2 with an application attribute); the lookup fails and Effect.orElseSucceed suppresses the error. Use the Linux-specific Secret Service lookup and derive cbcV11 from that result.

✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b

Fixed in the updated #7261. Linux v11 key lookup uses Secret Service via secret-tool lookup application with per-browser application attributes, not macOS service/account labels. Missing backend/key can retain partial v10 support; explicit denial or cancellation propagates. Subprocess and whitespace-preservation fixtures pass.

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 252Outdated · 0ad3689Open thread
……@@ -0,0 +1,263 @@
249+ reason: localState.os_crypt?.app_bound_encrypted_key
250+ ? "appBoundEncryption"
251+ : "keychainItemMissing",
252+ });
CcursorBoton line 252Aug 16, 2026

App-bound import looks successful

High Severity

appBoundEncryption is raised only when encrypted_key is missing. Upgraded Chrome still keeps a legacy DPAPI encrypted_key beside app_bound_encrypted_key, so key resolution succeeds, every v20 cookie is skipped, and the import returns success with little or nothing imported and no user-facing app-bound reason.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 0ad3689596c830e05d8bdf0a0d16bdf564fb7ab2. Configure here.

MmacroscopeappBotreviewed · 2 inline threadsAug 16, 2026
Synced from GitHub

Reviewed the new ChromiumKeys module and its call sites in ChromiumCookies/BrowserImport against the Effect service conventions. Two findings on error modelling in ChromiumKeys.ts; the layer/import/dependency-acquisition side looks fine (resolveChromiumKeys takes FileSystem/ChildProcessSpawner from the environment and scopes the child process locally).

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 148–153Outdated · 032f5daOpen thread
……@@ -0,0 +1,263 @@
150+ reason: "keychainItemMissing",
151+ cause: new Error(`powershell.exe exited with ${exitCode}`),
152+ });
153+ }
MmacroscopeappBoton lines 148–153Aug 16, 2026

BrowserImport/ChromiumKeys.ts:153

These branches manufacture an Error purely to carry the exit code as text, and label a PowerShell failure keychainItemMissing — the one reason whose user copy tells the user the browser has no key. The exit code is stable, serializable domain context and belongs in a structural attribute (the repo already does this in DesktopLinuxUrlHandlerRegistrationError with exitCode: Schema.optionalKey(Schema.Number) and a message derived from it), and a non-zero exit is readFailed rather than a missing item. Consider adding exitCode to ChromiumKeyError, folding it into message, and dropping the synthetic cause.

No longer relevant as of 1961e3fff8f9c682c259404e9477593109e221ee

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 187Outdated · 032f5daOpen thread
……@@ -0,0 +1,263 @@
184+ const fileSystem = yield* FileSystem.FileSystem;
185+ return yield* fileSystem.readFileString(localStatePath).pipe(
186+ Effect.flatMap(decodeLocalState),
187+ Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
MmacroscopeappBoton line 187Aug 16, 2026

BrowserImport/ChromiumKeys.ts:187

readFailed is raised here without the path that failed, so the only path that survives to the log is cookieDatabasePath, attached by the wrapper in ChromiumCookies — a corrupt or unreadable Local State is then reported against the cookie database instead. localStatePath is in scope at this wrapping site; consider adding a path attribute to ChromiumKeyError and setting it here so the failing resource is identifiable.

No longer relevant as of 1961e3fff8f9c682c259404e9477593109e221ee

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tsline 238Outdated · cfbd7b7Open thread
……@@ -232,7 +234,7 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
234236 for (const row of rows) {
235- const value = decryptValue(row.encrypted_value, key, row.host_key);
237+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);
236238 if (value === null) continue;
MmacroscopeappBoton line 238Aug 16, 2026

🟡 Medium BrowserImport/ChromiumCookies.ts:238

readChromiumCookies silently omits every row for which decryptChromiumValue returns null, so Linux v11 and Windows v20 cookies are excluded while the import still reports them as successfully handled (skipped: 0). Track these undecryptable rows in the skipped count (or propagate that count) so partial imports are reported accurately.

✅ Resolved in 8f80c3c6e5aefaab66ab38824050cf361e8f52ae

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 159Outdated · ce7944dOpen thread
……@@ -0,0 +1,263 @@
156+ return yield* new ChromiumKeyError({
157+ reason: "keychainItemMissing",
158+ cause: new Error("powershell.exe returned no key material"),
159+ });
CcursorBoton line 159Aug 16, 2026

Wrong Windows failure copy

Medium Severity

DPAPI / PowerShell failures are mapped to keychainItemMissing, whose user copy talks about Keychain and signing into the browser. On Windows that message is misleading for a DPAPI unwrap or Local State problem and steers users toward the wrong fix.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit ce7944dedd00e2013d48fedf6b869b6daf1e147b. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tslines 116–118Outdated · 2f2f7d0Open thread
……@@ -102,100 +108,96 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
115+ const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
116+ return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
117+ ? plaintext.subarray(32)
118+ : plaintext;
MmacroscopeappBoton lines 116–118Aug 16, 2026

🟡 Medium BrowserImport/ChromiumCookies.ts:116

A record with a domain hash that does not match domain is returned as plaintext, so the 32-byte hash is decoded into the cookie value and the corrupted or moved cookie is imported instead of rejected. Make stripDomainBinding reject mismatched hashes; the surrounding decryptors will then return null.

✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b

Fixed downstack and retained in #7261. Domain binding is required according to meta.version >=24; mismatched bound records are skipped instead of imported as corrupted values. Older long plaintext is preserved. Fixtures use TEXT-affinity version metadata as Chromium does.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tsline 237Outdated · 2f2f7d0Open thread
……@@ -232,7 +234,7 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
233235 const cookies: ChromiumCookie[] = [];
234236 for (const row of rows) {
235- const value = decryptValue(row.encrypted_value, key, row.host_key);
237+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);
CcursorBoton line 237Aug 16, 2026

Decrypt skips never counted

Medium Severity

Records skipped for missing keys or v20 are dropped with continue and never reach BrowserImportResult.skipped, which only tracks session.cookies.set failures. Callers therefore cannot distinguish an empty database from thousands of intentionally undecryptable cookies, despite the module promising an honestly reported partial result.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 2f2f7d0c2f8bf17bdb9872e5975a8ea937fe2ba5. Configure here.

MmacroscopeappBotreviewed · 1 inline threadAug 17, 2026
Synced from GitHub

One convention finding on the new ChromiumKeys module: the known-tag recovery uses catchIf with a schema predicate rather than Effect.catchTags. Everything else in the new key/decrypt split (error structure, structural exitCode/localStatePath context, preserved cause, requirements exposed through Effect.fn.Return) matches the conventions.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 269–272Outdated · 1be05caOpen thread
……@@ -0,0 +1,278 @@
269+ Effect.catchIf(
270+ (error) => !isChromiumKeyError(error),
271+ (cause) => new ChromiumKeyError({ reason: "readFailed", cause }),
272+ ),
MmacroscopeappBoton lines 269–272Aug 17, 2026

BrowserImport/ChromiumKeys.ts:272

unprotectWithDpapi fails with ChromiumKeyError | PlatformError — both statically known tags — so this is a known-tag recovery expressed as a negated schema predicate. Effect.catchTags on the single tag being wrapped says the same thing directly, lets the domain error pass through by construction, and makes the module-private isChromiumKeyError (line 86) unnecessary, so it can be deleted along with this change.

diff
1 const unwrapped = yield* unprotectWithDpapi(stripDpapiMarker(encodedKey)).pipe(
2- Effect.catchIf(
3- (error) => !isChromiumKeyError(error),
4- (cause) => new ChromiumKeyError({ reason: "readFailed", cause }),
5- ),
6+ Effect.catchTags({
7+ PlatformError: (cause) => new ChromiumKeyError({ reason: "readFailed", cause }),
8+ }),
9 );

No longer relevant as of 7f41045e6ac1f2ef3af6a0f6ef1b824e0a553487

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 152Outdated · 7f41045Open thread
……@@ -0,0 +1,276 @@
149+ script,
150+ ]);
151+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
152+ const exitCode = yield* process.exitCode;
CcursorBoton line 152Aug 17, 2026

PowerShell may hang on stdin

Medium Severity

ChildProcess.make for DPAPI leaves default stdin: "pipe" and never closes or ignores it. Elsewhere in the desktop app, fire-and-forget spawns set stdin: "ignore". An open stdin pipe can leave powershell.exe blocked, so Windows key unwrap never finishes and cookie import hangs.

Reviewed by Cursor Bugbot for commit 7f41045e6ac1f2ef3af6a0f6ef1b824e0a553487. Configure here.

……@@ -0,0 +1 @@
1+db
CcursorBoton line 1Aug 28, 2026

Accidental profile fixture committed

Low Severity

A dummy cookies.sqlite was committed under apps/desktop/undefined/Profiles/. That undefined segment is a leftover from a run whose home directory was unset, and nothing in the import code or tests reads this path.

Reviewed by Cursor Bugbot for commit 3a26a9dc586bfe8e1253a2d25ff619edaa9262db. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tsline 237Outdated · 4a75fb2Open thread
……@@ -254,12 +234,13 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
255235 const undecryptableHosts = new Set<string>();
256236 for (const row of rows) {
257- const value = decryptValue(row.encrypted_value, key, row.host_key);
237+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);
MmacroscopeappBoton line 237Aug 28, 2026

🟠 High BrowserImport/ChromiumCookies.ts:237

Linux imports blank out plaintext cookies: when encrypted_value is empty, decryptChromiumValue returns "" even though Chromium stores the real value in the value column. The query does not select that column, so use row.value for empty encrypted blobs (while decrypting non-empty blobs) and include it in CookieRow.

✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b

MmacroscopeappBotreviewed · 1 inline threadAug 29, 2026
Synced from GitHub

Reviewed the new ChromiumKeys module, the ChromiumCookies refactor, and the BrowserImport call site against the Effect service conventions. Structure (namespace imports, Schema.TaggedErrorClass with attribute-derived messages, cause preserved at the translation boundary, Effect.catchTags at the call site) looks consistent. One error-modelling nit below.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 39Outdated · 57d2beaOpen thread
……@@ -0,0 +1,145 @@
36+ "keychainItemMissing",
37+ "unsupportedPlatform",
38+ /** The key store itself could not be read, as opposed to holding no key. */
39+ "readFailed",

BrowserImport/ChromiumKeys.ts:39

readFailed is never constructed in this module — resolveChromiumKeys/readKeychainSecret only raise keychainItemMissing, needsKeychainApproval, and unsupportedPlatform. ChromiumCookies spreads ChromiumKeyFailure.literals (ChromiumCookies.ts:49-50) specifically to pick it up for its own snapshot/SQL failures, so this failure set advertises a reason ChromiumKeyError cannot carry, and the cookie module's reason set depends on a literal that belongs to it rather than to the key store.

Consider dropping readFailed here and listing it explicitly in ChromiumCookieReadReason alongside browserRunning, so each error's reason set describes only the failures that error can actually represent.

No longer relevant as of c188be9758bab40e0a9d93176c917030b2936b4b

The earlier unused-reason observation was valid for that implementation. The replacement Linux Secret Service implementation now constructs readFailed for real subprocess/backend failures, so removing the reason would be incorrect at the updated head. Its error paths are covered by ChromiumKeys tests.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tslines 183–184Outdated · c188be9Open thread
……@@ -116,136 +118,106 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
204- function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) {
205- const rows = yield* Effect.gen(function* () {
183+ function* (snapshotPath: string, keys: ChromiumKeyMaterial | Buffer, platform: NodeJS.Platform) {
184+ const keyMaterial = Buffer.isBuffer(keys) ? { cbcV10: keys } : keys;
MmacroscopeappBoton lines 183–184Aug 29, 2026

The | Buffer arm plus the Buffer.isBuffer normalization is a compatibility shim for the pre-ChromiumKeyMaterial signature: the only remaining caller that passes a bare Buffer is ChromiumCookies.test.ts (lines 84, 126, 162, 182, 217-218, 271-272) — production (readChromiumCookies, line 291) always passes ChromiumKeyMaterial. Consider narrowing the parameter to keys: ChromiumKeyMaterial, dropping the keyMaterial normalization, and updating those test call sites to { cbcV10: key }, so the runtime type test disappears and the key shape has one representation.

Posted via Macroscope — Effect Service Conventions

MmacroscopeappBotreviewed · 1 inline threadAug 29, 2026
Synced from GitHub

One finding: the new per-prefix key selection in ChromiumCookies.ts (the central behavior change of this PR) has no focused test. Everything else — the ChromiumKeys module layout, ChromiumKeyError structure, ChildProcessSpawner acquisition from the environment, and the test-only Layer.succeed spawner seam — follows the Effect service conventions.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tslines 169–174Outdated · 2eff79bOpen thread
……@@ -116,136 +118,105 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
171+ }
172+ if (prefix === "v11") {
173+ return keys.cbcV11 ? decryptCbc(payload, keys.cbcV11, domain, schemaVersion) : null;
174+ }
MmacroscopeappBoton lines 169–174Aug 29, 2026

This prefix dispatch is the behavior change this PR is about (a Linux database mixing v10/v11, with unkeyed records skipped rather than failing the import), but no test exercises it: ChromiumKeys.test.ts only asserts which keys get derived, and every ChromiumCookies.test.ts case still uses a v10 fixture with { cbcV10 }. Consider adding a focused case to ChromiumCookies.test.ts — a database holding one v10 and one v11 record read with { cbcV10, cbcV11 } (both decrypt), and the same database read with { cbcV10 } only (the v11 row lands in undecryptable/undecryptableHosts instead of failing the read). The existing encryptV10 helper generalises by parameterising the prefix and key.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tsline 115Outdated · eb74f8fOpen thread
……@@ -0,0 +1,211 @@
112+ if (stderr.trim() === "" || /no such secret|not found/i.test(stderr)) {
113+ return "keychainItemMissing";
114+ }
115+ if (/denied|locked|cancel|dismiss|permission/i.test(stderr)) {
MmacroscopeappBoton line 115Aug 29, 2026

🟡 Medium BrowserImport/ChromiumKeys.ts:115

A localized keyring denial is classified as readFailed by secretToolFailure, so resolveChromiumKeys silently falls back to v10-only import even after the user denies access. The classification depends on English words in stderr; force secret-tool to use a stable locale or use a locale-independent result to preserve the needsKeychainApproval path.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts around line 115:
2
3A localized keyring denial is classified as `readFailed` by `secretToolFailure`, so `resolveChromiumKeys` silently falls back to v10-only import even after the user denies access. The classification depends on English words in `stderr`; force `secret-tool` to use a stable locale or use a locale-independent result to preserve the `needsKeychainApproval` path.

Fixed in bae4ae9fb414e14abe8a7e1ea6ea8eecc5bcacdc. secret-tool inherits the existing session environment with LC_ALL=C, giving denial classification stable diagnostics without changing the consent flow. The focused test checks that the command preserves inherited environment values and that a denied request remains needsKeychainApproval instead of taking the fallback path. ChromiumKeys tests and desktop typecheck pass.

MmacroscopeappBotreviewed · 1 inline threadSep 1, 2026
Synced from GitHub

One finding, on the new test file's diagnostic suppression. The rest of the change — the ChromiumKeys module layout, ChromiumKeyError structure with preserved cause, ChildProcessSpawner/HostProcessEnvironment acquired from the environment, requirements surfaced through Effect.fn.Return, and the now-uniform ChromiumKeyMaterial key shape with focused mixed v10/v11 coverage — matches the conventions.

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.tslines 1–2Outdated · 506b87eOpen thread
……@@ -0,0 +1,180 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+import { describe, expect, it } from "@effect/vitest";
MmacroscopeappBoton lines 1–2Sep 1, 2026

This new suppression directive has no adjacent explanation, unlike every sibling in this directory (ChromiumCookies.test.ts, Sources.test.ts, FirefoxCookies.test.ts, ChromiumKeys.ts all state why). It also looks unnecessary here: the file imports no node: builtin and uses no Node global, so the diagnostic it disables cannot fire. Consider dropping the line; if it is needed for something I am missing, please add the reason next to it.

suggestion
1import { describe, expect, it } from "@effect/vitest";

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/ChromiumCookies.tsline 170Outdated · 506b87eOpen thread
……@@ -116,136 +118,105 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
167+ const payload = buffer.subarray(3);
168+
169+ if (prefix === "v10") {
170+ return keys.cbcV10 ? decryptCbc(payload, keys.cbcV10, domain, schemaVersion) : null;
MmacroscopeappBoton line 170Sep 1, 2026

🟠 High BrowserImport/ChromiumCookies.ts:170

Legacy Linux v10/v11 cookies encrypted with the empty-passphrase fallback are returned as null and skipped, even though Chromium can decrypt them. decryptChromiumValue only attempts the normal cbcV10/cbcV11 key and does not retry with Chromium’s PBKDF2-derived empty-passphrase key used for crbug 1195256 compatibility. Add that fallback key and retry prefixed decryption when the normal key fails.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts around line 170:
2
3Legacy Linux `v10`/`v11` cookies encrypted with the empty-passphrase fallback are returned as `null` and skipped, even though Chromium can decrypt them. `decryptChromiumValue` only attempts the normal `cbcV10`/`cbcV11` key and does not retry with Chromium’s PBKDF2-derived empty-passphrase key used for crbug 1195256 compatibility. Add that fallback key and retry prefixed decryption when the normal key fails.

Fixed in c0f7f052fd. Verified against Chromium’s os_crypt_linux.cc: after a record’s own key fails, Chromium retries with a PBKDF2 key over the empty passphrase (crbug.com/1195256). The Linux key material now carries that key and decryptChromiumValue retries with it on v10/v11 failure — and only when the record’s own key exists and failed, matching Chromium (a missing key still skips). Regression test covers both prefixes plus the no-retry-without-primary-key case.

apps/desktop/src/preview/BrowserImport/BrowserImport.tsline 88Outdated · e9e173cOpen thread
……@@ -85,11 +86,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
8586 context: BrowserImportPathContext,
8687): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
8788 if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
MmacroscopeappBoton line 88Sep 2, 2026

🟡 Medium BrowserImport/BrowserImport.ts:88

Linux imports now skip legacy Chromium cookie rows as undecryptable, so otherwise readable cookies are omitted. decryptChromiumValue returns null for non-v10/v11 blobs, but Linux Chromium treats blobs without either prefix as legacy cleartext; add that cleartext fallback before counting the cookie as undecryptable.

Also found in 1 other location(s)

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:190

On Linux, decryptChromiumValue returns null for every non-v10/v11 nonempty blob. Chromium's Linux OSCrypt::DecryptString treats a blob without either prefix as legacy cleartext, so importing a profile with such rows now skips otherwise readable cookies and counts them as undecryptable. This is reachable because the change enables Linux imports.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/BrowserImport.ts around line 88:
2
3Linux imports now skip legacy Chromium cookie rows as undecryptable, so otherwise readable cookies are omitted. `decryptChromiumValue` returns `null` for non-`v10`/`v11` blobs, but Linux Chromium treats blobs without either prefix as legacy cleartext; add that cleartext fallback before counting the cookie as undecryptable.
4
5Also found in 1 other location(s):
6- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:190 -- On Linux, `decryptChromiumValue` returns `null` for every non-`v10`/`v11` nonempty blob. Chromium's Linux `OSCrypt::DecryptString` treats a blob without either prefix as legacy cleartext, so importing a profile with such rows now skips otherwise readable cookies and counts them as undecryptable. This is reachable because the change enables Linux imports.

Fixed in ca1cd02480. Verified against Chromium's os_crypt_linux.cc — same as os_crypt_mac.mm, an unprefixed blob is returned as-is ("old data saved as clear text"). The fallback now applies on Linux too; Windows stays excluded since its app-bound blobs also lack the prefix. Test updated to assert Linux reads it.

Confirmed: Linux now imports unprefixed legacy cleartext blobs, while Windows remains excluded. The original concern is resolved.

apps/desktop/src/preview/BrowserImport/ChromiumKeys.tslines 143–143Outdated · 8860a7fOpen thread
……@@ -0,0 +1,222 @@
140+ const environment = yield* HostProcessEnvironment;
141+ const handle = yield* spawner
142+ .spawn(
143+ ChildProcess.make("secret-tool", ["lookup", "application", application], {
MmacroscopeappBoton lines 143–143Sep 2, 2026

🟡 Medium BrowserImport/ChromiumKeys.ts:143

readLinuxSecret can select a non-Chromium Secret Service item, derive the wrong cbcV11, and silently skip every real v11 cookie. The lookup filters only on application; include xdg:schema=chrome_libsecret_os_crypt_password_v2 in the secret-tool predicates.

suggestion
1 ChildProcess.make("secret-tool", ["lookup", "xdg:schema", "chrome_libsecret_os_crypt_password_v2", "application", application], {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts around line 143:
2
3`readLinuxSecret` can select a non-Chromium Secret Service item, derive the wrong `cbcV11`, and silently skip every real v11 cookie. The lookup filters only on `application`; include `xdg:schema=chrome_libsecret_os_crypt_password_v2` in the `secret-tool` predicates.

Confirmed and fixed in 3c1d584a94: the lookup now passes xdg:schema chrome_libsecret_os_crypt_password_v2 alongside application, so only Chromium's own item can match. Test updated.

scripts/build-desktop-artifact.tslines 2230–2230Outdated · 296acd5Open thread
……@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*
2227+ [
2228+ path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),
2229+ "--arch",
2230+ input.arch,
MmacroscopeappBoton lines 2230–2230Sep 3, 2026

🟠 High scripts/build-desktop-artifact.ts:2230

Linux builds requested with arch: "universal" fail while staging stageBrowserSecret, because build-browser-secret.mjs rejects that value even though BuildArch accepts it. Map "universal" to the concrete helper architecture used by the Linux resource monitor (x64), or reject the option before spawning the helper.

suggestion
1 input.arch === "arm64" ? "arm64" : "x64",
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @scripts/build-desktop-artifact.ts around line 2230:
2
3Linux builds requested with `arch: "universal"` fail while staging `stageBrowserSecret`, because `build-browser-secret.mjs` rejects that value even though `BuildArch` accepts it. Map `"universal"` to the concrete helper architecture used by the Linux resource monitor (x64), or reject the option before spawning the helper.

Addressed in #9516 (766885b5df): universal maps to x64 before the helper is invoked, mirroring the Linux resource-monitor target resolution; test added.

apps/desktop/src/main.tsline 156Outdated · 296acd5Open thread
……@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
153154 // Merged rather than provided so the IPC handlers can reach the import
154155 // service alongside the manager; both sit on the same BrowserSession.
155- Layer.provideMerge(BrowserImport.layer),
156+ Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
CcursorBoton line 156Sep 3, 2026

Helper path dropped at runtime

High Severity

Layer.provide(LinuxBrowserSecret.layer) only makes LinuxBrowserSecretPath available while BrowserImport is constructed, and make never reads it. readLinuxSecret later uses the tag's default undefined, so the bundled helper is never launched and v11 cookies cannot be decrypted.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 296acd56139acca917c5c1fa9e193b4775906994. Configure here.

CcursorBotreviewed · 1 inline threadSep 3, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit a771eb69ba4c518dcffaf64da29e3429f8fa1702. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.tsline 91Outdated · a771eb6Open thread
……@@ -78,18 +79,29 @@ const chromiumSource = (input: {
8588 platforms: [
8689 "darwin" as NodeJS.Platform,
8790 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
91+ ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
CcursorBoton line 91Sep 3, 2026

Windows Helium running state undetected

Medium Severity

Enabling Helium on Windows still uses isSourceRunning's POSIX SingletonLock symlink check. Windows Chromium holds lockfile instead, so a running Helium looks idle. The quit-browser prompt never appears and import can VACUUM INTO a live jar, failing as readFailed or snapshotting mid-write cookies.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit a771eb69ba4c518dcffaf64da29e3429f8fa1702. Configure here.

……@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*
2221+}) {
2222+ if (input.platform !== "linux") return;
2223+ const path = yield* Path.Path;
2224+ yield* runCommand(
MmacroscopeappBoton line 2224Sep 3, 2026

🟠 High scripts/build-desktop-artifact.ts:2224

Linux builds from macOS or Windows hosts succeed without creating stageResourcesDir/browser-secret/t3-browser-secret, so the packaged artifact lacks the configured Linux helper and cannot resolve v11 keys. build-browser-secret.mjs is a no-op on non-Linux hosts; fail these cross-host builds explicitly or provide and stage a target-compatible helper.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @scripts/build-desktop-artifact.ts around line 2224:
2
3Linux builds from macOS or Windows hosts succeed without creating `stageResourcesDir/browser-secret/t3-browser-secret`, so the packaged artifact lacks the configured Linux helper and cannot resolve v11 keys. `build-browser-secret.mjs` is a no-op on non-Linux hosts; fail these cross-host builds explicitly or provide and stage a target-compatible helper.

Addressed in #9516 (766885b5df): stageBrowserSecret now fails with LinuxBrowserSecretHostError when the host is not Linux, instead of packaging an artifact without the helper; test added.

I can’t find LinuxBrowserSecretHostError or the host-platform guard in the current code; stageBrowserSecret still invokes the helper for every Linux target. The concern remains unresolved in this PR state.

native/browser-secret/main.clines 10–10Open thread
……@@ -0,0 +1,57 @@
7+
8+static const SecretSchema chromium_schema = {
9+ .name = "chrome_libsecret_os_crypt_password_v2",
10+ .flags = SECRET_SCHEMA_NONE,
MmacroscopeappBoton lines 10–10Sep 3, 2026

🟠 High browser-secret/main.c:10

Pre-existing Chromium v11 keys stored without a schema-name attribute are reported as missing, so all cookies using those keys are skipped. SECRET_SCHEMA_NONE requires the stored schema name to match, unlike Chromium's lookup; use SECRET_SCHEMA_DONT_MATCH_NAME to retrieve keys created by libgnome-keyring and other compatible backends.

suggestion
1 .flags = SECRET_SCHEMA_DONT_MATCH_NAME,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @native/browser-secret/main.c around line 10:
2
3Pre-existing Chromium v11 keys stored without a schema-name attribute are reported as missing, so all cookies using those keys are skipped. `SECRET_SCHEMA_NONE` requires the stored schema name to match, unlike Chromium's lookup; use `SECRET_SCHEMA_DONT_MATCH_NAME` to retrieve keys created by libgnome-keyring and other compatible backends.

Addressed in #9516 (766885b5df): SECRET_SCHEMA_DONT_MATCH_NAME, so items written without a schema name still match on attributes.

apps/desktop/src/main.tslines 156–156Open thread
……@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
153154 // Merged rather than provided so the IPC handlers can reach the import
154155 // service alongside the manager; both sit on the same BrowserSession.
155- Layer.provideMerge(BrowserImport.layer),
156+ Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
MmacroscopeappBoton lines 156–156Sep 3, 2026

🟠 High src/main.ts:156

Linux cookie imports always report keychainUnavailable when readLinuxSecret runs, even if the bundled helper exists. Layer.provide(LinuxBrowserSecret.layer) hides LinuxBrowserSecretPath after BrowserImport construction, so the later readLinuxSecret effect sees the reference default (undefined); keep the service available with Layer.provideMerge.

suggestion
1 Layer.provideMerge(LinuxBrowserSecret.layer),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/main.ts around line 156:
2
3Linux cookie imports always report `keychainUnavailable` when `readLinuxSecret` runs, even if the bundled helper exists. `Layer.provide(LinuxBrowserSecret.layer)` hides `LinuxBrowserSecretPath` after `BrowserImport` construction, so the later `readLinuxSecret` effect sees the reference default (`undefined`); keep the service available with `Layer.provideMerge`.

Checked and not a bug. BrowserImport.make captures its context with Effect.context at construction and re-provides it to every read (Effect.provide(platformServices) around readChromiumCookies), so LinuxBrowserSecretPath is resolved from the captured context, not the caller's. Verified with a direct test that a Context.Reference supplied via Layer.provide is present in a context captured inside the layer. Leaving as Layer.provide.

The captured platformServices context contains only FileSystem, Path, and ChildProcessSpawner. It does not retain LinuxBrowserSecretPath, so the later explicit Effect.provide(platformServices) masks it. The issue remains; provideMerge is needed.

Added 18 commits

  • feat(desktop): resolve Chromium cookie keys on Linux6df3337
  • fix(desktop): read Linux Chromium secrets from libsecret99abc41
  • fix(desktop): preserve Linux Chromium secret whitespacea9599d2
  • fix(desktop): preserve Linux keyring denial5b71d0a
  • refactor(desktop): require Chromium key material5f6dc48
  • fix(desktop): preserve Chromium cookie formatscfe9f55
  • fix(desktop): drain secret tool output concurrently260103a
  • test(desktop): cover mixed Chromium Linux keys62654c1
  • style(desktop): format Chromium key reader039e6b2
  • refactor(desktop): inject secret tool environment6124d03
  • fix(desktop): stabilize secret tool errors85d0aa8
  • fix(desktop): recover Linux cookies written with the empty-passphrase key08b41dc
  • fix(desktop): match Chromium's libsecret schema when reading the Linux key1446db0
  • fix(desktop): read legacy cleartext Chromium cookies on Linux too6a4f2a1
  • fix(desktop): enable Helium browser import on Linux262c9c3
  • fix(desktop): bundle the Linux browser keyring reader7e4788b
  • fix(desktop): detect running Helium on Windows0da299e
  • feat(desktop): import Helium cookies on Windows756d99d