feat(desktop): import from Chrome, Edge, Brave, Vivaldi, Opera, Arc and Firefox#7260

merged23
browser-profile-import
browser-import-more-sources
openedAug 16, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
34 unresolved threads · Jump to file
Selected thread: apps/desktop/src/preview/BrowserImport/Sources.ts:628 — `HostProcessAddresses` now holds an unmemoized `Effect`, so this `yield* yield*` re-runs the hostname resolution on every call — `isSourceRunning` invokes `firefoxProfileIsHeld` once per profile inside `Effect.forEach`, so a profile list of _n_ costs _n_ DNS lookups. It also runs before the `win32` early return, where `windowsLockIsHeld` never uses the set. Consider acquiring the addresses once in `isSourceRunning` and passing them into `firefoxProfileIsHeld`, the way `firefoxSymlinkLockIsHeld` already takes `localAddresses` as a parameter; that also removes the `yield* yield*` from the per-profile path. <sup>Posted via Macroscope — <a href="https://github.com/pingdotgg/t3code/runs/100434302118">Effect Service Conventions</a></sup> <!-- macroscope-meta: {"kind":"check_run","config":".macroscope/check-run-agents/effect-service-conventions.md","check":"Effect Service Conventions"} -->
0 / 13 viewed13 files changedLine totals unavailable

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision b5a06280 · right 108–108

Original diff
@@ -0,0 +1,112 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // Already seconds since the UNIX epoch, unlike Chromium's 1601-based
+  // microseconds, so no conversion is needed.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.Number,
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
+  cookieDatabasePath: string,
+) {
+  const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe(
+    Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
+  );
+
+  const rows = yield* Effect.gen(function* () {
+    const sql = yield* SqlClient.SqlClient;
+    // Only the default container. Firefox isolates cookies per container and
+    // per private window via `originAttributes` (`^userContextId=2`,
+    // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
+    // all would collapse several identities onto one host/name/path and hand
+    // the profile an arbitrary container's session.
+    const raw = yield* sql`
+      select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
+        from moz_cookies
+       where originAttributes = ''
+    `;
+    return yield* decodeCookieRows(raw);
+  }).pipe(
+    Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })),
+    Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
+  );
+
+  return rows.map((row) => {
+    const secure = row.isSecure === 1;
+    const scope = cookieScope(row.host, row.path, secure);
+    return {
+      url: scope.url,
+      name: row.name,
+      value: row.value,
+      domain: scope.domain,
+      path: row.path,
+      secure,
+      httpOnly: row.isHttpOnly === 1,
+      expirationDate: row.expiry > 0 ? row.expiry : undefined,

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · right 108–108

M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/FirefoxCookies.ts:108

Persistent Firefox cookies are imported with expiration timestamps about 1000× too far in the future because row.expiry is stored in milliseconds while Electron's expirationDate expects seconds. Convert row.expiry to seconds before assigning it.

suggestion
1 expirationDate: row.expiry > 0 ? row.expiry / 1000 : undefined,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts around line 108:
2
3Persistent Firefox cookies are imported with expiration timestamps about 1000× too far in the future because `row.expiry` is stored in milliseconds while Electron's `expirationDate` expects seconds. Convert `row.expiry` to seconds before assigning it.
J
Julius MarmingeSep 2, 2026

Fixed in 5b13dd381e. Verified against Firefox's CookiePersistentStorage.cpp: schema 16 (Firefox 129) migrated expiry to milliseconds (UPDATE moz_cookies SET expiry = expiry * 1000), while older profiles still hold seconds — so the reader now reads PRAGMA user_version and divides only for schema ≥16, rather than assuming either unit. Regression test covers both a schema-16 and a schema-15 database.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision bc1215bd · right 156

Original diff
@@ -1,38 +1,167 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 // @effect-diagnostics nodeBuiltinImport:off
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
 import * as NodeFSP from "node:fs/promises";
-import * as NodeOS from "node:os";
 import * as NodePath from "node:path";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
+/**
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
+ */
+export interface BrowserImportPathContext {
+  readonly platform: NodeJS.Platform;
+  readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
+}
+
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: () => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => NodePath.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork across the three platforms. The macOS and Linux leaves
+ * differ per fork, and Windows nests everything under a `User Data` directory.
+ * Omitting a platform's segments marks the fork as unavailable there.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly windowsSegments?: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      return input.windowsSegments && context.localAppData
+        ? NodePath.join(context.localAppData, ...input.windowsSegments, "User Data")
+        : undefined;
+    }
+    return input.linuxSegments
+      ? NodePath.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    windowsSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    windowsSegments: ["Microsoft", "Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    windowsSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    windowsSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    windowsSegments: ["Programs", "Opera"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: () =>
-      NodePath.join(NodeOS.homedir(), "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
M
macroscopeappBotAug 16, 2026

🟠 High BrowserImport/Sources.ts:156

A running Firefox is reported as available, so cookie import proceeds while its database is being written and can produce readFailed or an inconsistent cookie set instead of browserRunning. isSourceRunning checks lock/parent.lock under the Firefox root, but Firefox creates them inside each profile directory returned by listSourceProfiles; check the profile directories before allowing import.

Also found in 1 other location(s)

apps/desktop/src/preview/BrowserImport/BrowserImport.ts:75

unavailableReason trusts isSourceRunning for Firefox, but that helper checks lock/parent.lock under the Firefox installation root while Firefox creates them inside each profile directory (the same directories returned by listSourceProfiles). Consequently a normally running Firefox is treated as available, and importing can copy the live database/WAL files non-atomically, yielding readFailed or an inconsistent cookie set instead of reporting browserRunning.

✅ Resolved in 1b132e45546ca54fb62fd0bde080ac4d3f656263

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 1f8e906e · right 172–174

Original diff
@@ -1,87 +1,292 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
 import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess";
+import {
+  HostProcessEnvironment,
+  HostProcessAddresses,
+  HostProcessHostname,
+  HostProcessPlatform,
+} from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
 import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
+ * platform's segments marks the fork as unavailable there. Windows is left out
+ * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
+ * identity (App-Bound Encryption), so no other process can read them.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: ({ path, home }) =>
-      path.join(home, "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
+    platforms: ["darwin", "win32", "linux"],
+    userDataDirectory: (context) => {
+      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
+      if (context.platform === "win32") {
+        return context.appData
+          ? context.path.join(context.appData, "Mozilla", "Firefox")
+          : undefined;
+      }
+      return context.path.join(context.home, ".mozilla", "firefox");
+    },
   },
 ];
 
+/**
 /**
  * Where a profile's cookie database may live, most current first. Chromium 96

apps/desktop/src/preview/BrowserImport/Sources.ts · right 172–174

M
macroscopeappBotSep 2, 2026

A stray second /** opener was introduced above this doc block while the registry was reworked, so the comment now starts twice. Consider dropping the extra line.

suggestion
1/**
2 * Where a profile's cookie database may live, most current first. Chromium 96

Posted via Macroscope — Effect Service Conventions

J
Julius MarmingeSep 2, 2026

Fixed in 3f1dbc880d — stray opener dropped.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 33435655 · right 180

Original diff
@@ -1,62 +1,275 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork across the three platforms. The macOS and Linux leaves
+ * differ per fork, and Windows usually nests everything under a `User Data`
+ * directory in local AppData. Omitting a platform's segments marks the fork as
+ * unavailable there.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly windowsSegments?: ReadonlyArray<string>;
+  /**
+   * Forks that sit under roaming `%APPDATA%` with no `User Data` level. Opera
+   * is the one that does this; everything else follows the local-AppData
+   * convention above.
+   */
+  readonly windowsRoamingSegments?: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.windowsSegments || input.windowsRoamingSegments ? ["win32" as NodeJS.Platform] : []),
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      if (input.windowsRoamingSegments) {
+        return context.appData
+          ? context.path.join(context.appData, ...input.windowsRoamingSegments)
+          : undefined;
+      }
+      return input.windowsSegments && context.localAppData
+        ? context.path.join(context.localAppData, ...input.windowsSegments, "User Data")
+        : undefined;
+    }
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    windowsSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    windowsSegments: ["Microsoft", "Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    windowsSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    windowsSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    windowsRoamingSegments: ["Opera Software", "Opera Stable"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: ({ path, home }) =>
-      path.join(home, "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
+    platforms: ["darwin", "win32", "linux"],
+    userDataDirectory: (context) => {
+      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
+      if (context.platform === "win32") {
+        return context.appData
+          ? context.path.join(context.appData, "Mozilla", "Firefox")
+          : undefined;
+      }
+      return context.path.join(context.home, ".mozilla", "firefox");
C
cursorBotAug 16, 2026

Firefox Linux path ignores XDG

High Severity

Firefox’s Linux userDataDirectory always resolves to ~/.mozilla/firefox. Since Firefox 147 (Jan 2026), fresh installs keep profiles.ini and profiles under $XDG_CONFIG_HOME/mozilla/firefox (usually ~/.config/mozilla/firefox) when the legacy tree is absent, so those installs are reported as notInstalled and never importable on Linux.

Reviewed by Cursor Bugbot for commit 33435655377e6cd6f406368baff28613d4c7308c. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 18520a83 · right 328

Original diff
@@ -79,26 +295,44 @@ const isSafeProfileDirectory = (directory: string): boolean =>
   !directory.includes("\u0000");
 
 /**
- * Profiles the source browser knows about, read from its `Local State`.
+ * Profiles the source browser knows about.
  *
- * When that file is missing, unreadable or malformed, the user-data directory
- * is scanned for directories that hold a cookie database. Assuming `Default`
- * instead would report a browser whose cookies live in `Profile 1` as having
+ * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
+ * that metadata is missing, unreadable or malformed, the directories that
+ * actually hold a cookie database are scanned instead. Assuming a single
+ * `Default` would report a browser whose cookies live in `Profile 1` as having
  * nothing to import — and it is then left out of the menu entirely.
  */
 export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
+  context: BrowserImportPathContext,
+): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
   const fileSystem = yield* FileSystem.FileSystem;
-  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return [];
+
+  if (definition.engine === "firefox") {
+    const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
+      Effect.map(parseFirefoxProfiles),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
+    if (declared.length > 0) return declared;
+
+    // No readable `profiles.ini`, so fall back to scanning the directory the
+    // profiles actually live in.
+    return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
+      Effect.map((entries) =>
+        entries.map((entry) => ({ directory: context.path.join("Profiles", entry), name: entry })),
+      ),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
C
cursorBotAug 17, 2026

Firefox fallback lists empty profiles

Low Severity

When profiles.ini is missing or unreadable, the Firefox fallback lists every entry under Profiles/ without checking for cookies.sqlite. The Chromium fallback only keeps directories that actually hold a cookie database, so Firefox can surface phantom profiles that later fail with readFailed.

Reviewed by Cursor Bugbot for commit 18520a83abfea8f7c05fcf2c7f85bcd885b9f4b3. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 973b17ea · right 337

Original diff
@@ -75,60 +276,71 @@ const DEFAULT_PROFILES: ReadonlyArray<BrowserImportSourceProfile> = [
 ];
 
 /**
- * Profiles the source browser knows about, read from its `Local State`.
+ * Profiles the source browser knows about.
  *
- * Falls back to the `Default` directory when that file is unreadable or has no
- * profile cache: a browser that has only ever had one profile is the common
- * case, and failing the whole import over a missing display name would be
- * disproportionate.
+ * Falls back to the single default directory when the metadata is unreadable:
+ * a browser that has only ever had one profile is the common case, and failing
+ * the whole import over a missing display name would be disproportionate.
  */
 export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
+  context: BrowserImportPathContext,
+): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
   const fileSystem = yield* FileSystem.FileSystem;
-  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return [];
+
+  if (definition.engine === "firefox") {
+    const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
+      Effect.map(parseFirefoxProfiles),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
+    if (declared.length > 0) return declared;
+
+    // No readable `profiles.ini`, so fall back to scanning the directory the
+    // profiles actually live in.
+    return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
+      Effect.map((entries) =>
+        entries.map((entry) => ({ directory: context.path.join("Profiles", entry), name: entry })),
+      ),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
+  }
 
-  const profiles = yield* fileSystem.readFileString(localStatePath).pipe(
+  const profiles = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe(
     Effect.flatMap(decodeLocalState),
-    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
-    Effect.map((entries) =>
-      entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
+    Effect.map((state) =>
+      Object.entries(state.profile?.info_cache ?? {}).map(([directory, info]) => ({
+        directory,
+        name: info.name?.trim() || directory,
+      })),
     ),
     Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
   );
-
   return profiles.length === 0 ? DEFAULT_PROFILES : profiles;
 });
 
 /** Whether the browser is running, which leaves its cookie DB mid-write. */
 export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
-  const fileSystem = yield* FileSystem.FileSystem;
-  const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
-  // Chromium writes a `SingletonLock` symlink for as long as an instance holds
-  // the profile. Its presence is a far cheaper and more targeted signal than
-  // scanning the process table for a name.
-  //
-  // The link points at `<host>-<pid>`, a target that never exists, and both
-  // `stat` and `exists` follow links — so they report every running browser as
-  // closed, which would let an import read a live, mid-write database.
-  // `readLink` is the one probe that answers for the entry itself.
-  return yield* fileSystem.stat(lock).pipe(
-    Effect.catchCause(() => fileSystem.readLink(lock)),
-    Effect.as(true),
-    Effect.orElseSucceed(() => false),
+  context: BrowserImportPathContext,
+): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return false;
+  // Chromium writes a `SingletonLock` symlink and Firefox a `lock` /
+  // `parent.lock` for as long as an instance holds the profile. Far cheaper
+  // and more targeted than scanning the process table for a name.
+  const locks = definition.engine === "firefox" ? ["lock", "parent.lock"] : ["SingletonLock"];
+  const found = yield* Effect.forEach(locks, (lock) =>
+    entryExists(context.path.join(root, lock)),
   );
+  return found.some(Boolean);
C
cursorBotAug 16, 2026

Firefox running check misses locks

High Severity

isSourceRunning looks for Firefox lock / parent.lock under the Firefox user-data root, but those files live inside each profile directory (and on macOS/Linux the persistent file is .parentlock). A running Firefox therefore never reports browserRunning, so import can proceed against a live cookies.sqlite and observe torn WAL state or fail the read.

Reviewed by Cursor Bugbot for commit 973b17eae96dbcfe60139c81d6ee680b98933508. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 341268fc · right 504–505

Original diff
@@ -250,28 +491,167 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+/** Windows sharing and lock violations are translated by libuv to `Busy`. */
+export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
+  error.reason._tag === "Busy";
+
+/**
+ * Whether a regular lock file is actually held by a running process. Windows
+ * `parent.lock` is opened with no sharing, so it persists on disk after the
+ * process exits and `stat` always succeeds; only trying to open it for write
+ * reveals an active holder, which surfaces as `Busy`.
+ */
+const isLockHeld = Effect.fnUntraced(function* (lockPath: string, platform: NodeJS.Platform) {
+  if (platform !== "win32") return yield* entryExists(lockPath);

apps/desktop/src/preview/BrowserImport/Sources.ts · right 504–505

M
macroscopeappBotSep 2, 2026

isLockHeld's only caller guards on win32 (line 606), so the platform !== "win32" branch never runs — and it is the last remaining use of entryExists, whose documented rationale (the dangling Chromium SingletonLock and Firefox lock symlinks) no longer matches any caller either, since both probes now call readLink directly.

Consider narrowing this to the Windows probe its doc block describes — drop the platform parameter and the early return — and deleting entryExists along with it.

Posted via Macroscope — Effect Service Conventions

J
Julius MarmingeSep 2, 2026

Fixed in ba690c491a. Narrowed to windowsLockIsHeld (no platform parameter, no dead POSIX branch) and deleted entryExists, which that branch was the last use of.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 3cac0530 · right 153

Original diff
@@ -1,64 +1,257 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
 import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
+ * platform's segments marks the fork as unavailable there. Windows is left out
+ * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
+ * identity (App-Bound Encryption), so no other process can read them.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: ({ path, home }) =>
-      path.join(home, "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
+    platforms: ["darwin", "win32", "linux"],
+    userDataDirectory: (context) => {
+      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
M
macroscopeappBotAug 28, 2026

🟡 Medium BrowserImport/Sources.ts:153

Firefox profiles returned from listSourceProfiles never receive a cookieCount, so the wizard shows an unknown count even when cookies.sqlite is readable. The declared-profile branch returns before calling withCookieCounts; pass those profiles through the same counting step used for Chromium.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 153:
2
3Firefox profiles returned from `listSourceProfiles` never receive a `cookieCount`, so the wizard shows an unknown count even when `cookies.sqlite` is readable. The declared-profile branch returns before calling `withCookieCounts`; pass those profiles through the same counting step used for Chromium.
J
Julius MarmingeAug 29, 2026

Fixed in the updated #7260. Declared and fallback Firefox profiles receive cookie counts from moz_cookies filtered to originAttributes = empty string, matching the importer’s default-container policy. Unreadable schemas retain an unknown count. Fixtures cover all paths.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 973b17ea · right 140

Original diff
@@ -1,62 +1,263 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork across the three platforms. The macOS and Linux leaves
+ * differ per fork, and Windows nests everything under a `User Data` directory.
+ * Omitting a platform's segments marks the fork as unavailable there.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly windowsSegments?: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      return input.windowsSegments && context.localAppData
+        ? context.path.join(context.localAppData, ...input.windowsSegments, "User Data")
+        : undefined;
+    }
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    windowsSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    windowsSegments: ["Microsoft", "Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    windowsSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    windowsSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    windowsSegments: ["Programs", "Opera"],
+    linuxSegments: ["opera"],
+  }),
C
cursorBotAug 16, 2026

Wrong Opera Windows data path

Low Severity

Opera on Windows is registered as %LOCALAPPDATA%\Programs\Opera\User Data, but stable Opera keeps its Chromium profile under %APPDATA%\Opera Software\Opera Stable (no trailing User Data). The shared chromiumSource helper always uses localAppData and appends User Data, so this layout cannot be expressed as written and will mis-resolve once non-macOS Chromium import is enabled.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 973b17eae96dbcfe60139c81d6ee680b98933508. Configure here.

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision a0bec9dd · right 128–136

Original diff
@@ -0,0 +1,169 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+/**
+ * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax,
+ * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute
+ * at all. Unset is not the same thing as None — None is an explicit opt-in to
+ * cross-site use — so it is imported as Electron's `unspecified`, which lets
+ * the target browser apply its own default exactly as Firefox did. Anything
+ * unrecognised also lands there rather than on `no_restriction`, since
+ * guessing "none" would widen a cookie's scope on import.
+ */
+const SAMESITE_NONE = 0;
+const SAMESITE_LAX = 1;
+const SAMESITE_STRICT = 2;
+
+/**
+ * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie
+ * actually declared, beside a `sameSite` that Firefox had already defaulted to
+ * Lax. The schema-15 migration folded them back together with
+ * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row
+ * that "is Lax" only because nothing was declared. Reading such a database
+ * before Firefox has migrated it must apply the same rule, or an undeclared
+ * cookie is imported as an explicit Lax.
+ */
+const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10;
+const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14;
+
+const sameSiteFromColumn = (
+  value: number | null,
+  rawValue: number | null,
+): ImportedCookie["sameSite"] => {
+  // Schema 9 added the column with no default, so older rows carry NULL.
+  if (value === null) return "unspecified";
+  if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified";
+  if (value === SAMESITE_NONE) return "no_restriction";
+  if (value === SAMESITE_LAX) return "lax";
+  if (value === SAMESITE_STRICT) return "strict";
+  return "unspecified";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the
+  // unit depends on the schema version; see `expiryToSeconds`.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.NullOr(Schema.Number),
+  // Present only for schemas 10–14; selected as NULL elsewhere.
+  rawSameSite: Schema.NullOr(Schema.Number),
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+/**
+ * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds
+ * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron
+ * wants seconds, so the unit is decided by `PRAGMA user_version` rather than
+ * assumed: importing a pre-16 profile as milliseconds would expire every cookie
+ * at once, and a post-16 one as seconds would keep them for ~1000× too long.
+ */
+const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16;
+
+const UserVersionRow = Schema.Struct({ user_version: Schema.Number });
+const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow));
+
+const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => {
+  if (expiry <= 0) return undefined;
+  return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry;
+};
+
+export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
+  cookieDatabasePath: string,
+) {
+  const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe(
+    Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
+  );
+
+  const { rows, schemaVersion } = yield* Effect.gen(function* () {
+    const sql = yield* SqlClient.SqlClient;
+    const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`);
+    const schemaVersion = versionRow?.user_version ?? 0;
+    // Only the default container. Firefox isolates cookies per container and
+    // per private window via `originAttributes` (`^userContextId=2`,
+    // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
+    // all would collapse several identities onto one host/name/path and hand
+    // the profile an arbitrary container's session.
+    const hasRawSameSite =
+      schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA &&
+      schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA;
+    const raw = hasRawSameSite

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · right 128–136

M
macroscopeappBotSep 2, 2026

The default-container rationale now heads the hasRawSameSite computation, which it does not describe — the where originAttributes = '' predicate it explains moved down to the queries below. Consider keeping it next to that predicate.

suggestion
1 const hasRawSameSite =
2 schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA &&
3 schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA;
4 // Only the default container. Firefox isolates cookies per container and
5 // per private window via `originAttributes` (`^userContextId=2`,
6 // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
7 // all would collapse several identities onto one host/name/path and hand
8 // the profile an arbitrary container's session.
9 const raw = hasRawSameSite

Posted via Macroscope — Effect Service Conventions

J
Julius MarmingeSep 2, 2026

Moved back beside the where originAttributes = '' predicate in a66f319cbd.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 973b17ea · right 138

Original diff
@@ -1,62 +1,263 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork across the three platforms. The macOS and Linux leaves
+ * differ per fork, and Windows nests everything under a `User Data` directory.
+ * Omitting a platform's segments marks the fork as unavailable there.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly windowsSegments?: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      return input.windowsSegments && context.localAppData
+        ? context.path.join(context.localAppData, ...input.windowsSegments, "User Data")
+        : undefined;
+    }
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    windowsSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    windowsSegments: ["Microsoft", "Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    windowsSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    windowsSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    windowsSegments: ["Programs", "Opera"],
M
macroscopeappBotAug 16, 2026

🟡 Medium BrowserImport/Sources.ts:138

Opera is never detected on Windows because windowsSegments resolves it to %LOCALAPPDATA%\Programs\Opera\User Data, while Opera stores profiles under %APPDATA%\Opera Software\Opera Stable (or Opera GX Stable). Update the Windows path resolution to use the roaming AppData locations.

✅ Resolved in 1b132e45546ca54fb62fd0bde080ac4d3f656263

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision ba690c49 · right 514

Original diff
@@ -235,28 +472,166 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+/** Windows sharing and lock violations are translated by libuv to `Busy`. */
+export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
+  error.reason._tag === "Busy";
+
+/**
+ * Whether a Windows `parent.lock` is actually held by a running process. It
+ * is opened with no sharing, so it persists on disk after the process exits
+ * and `stat` always succeeds; only trying to open it for write reveals an
+ * active holder, which surfaces as `Busy`.
+ */
+const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
+  // Permission failures are distinct: they do not prove a browser owns the
+  // lock, so they must not hide the source as running.
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
+    Effect.as(false),
+    Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
+    Effect.orElseSucceed(() => false),
+    Effect.scoped,
+  );
+});
+
+/**
+ * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
+ * live owner. Firefox writes this symlink beside the profile while it runs and
+ * unlinks it on a clean exit, so a dangling one is either live or a crash.
+ */
+export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
+  target: string,
+  isProcessAlive: ProcessLivenessProbe,
+) {
+  const separator = target.lastIndexOf(":");
+  if (separator < 0) return true;
+  // The owner half is the address Firefox resolved for its own hostname, so a
+  // pid is only meaningful when it is ours: a shared (NFS) profile locked from
+  // another machine names a foreign address and its pid cannot be probed here
+  // — nor could a reused local pid vouch for it. Anything but loopback stays
+  // conservatively held.
+  const owner = target.slice(0, separator);
+  if (owner !== "127.0.0.1" && owner !== "::1") return true;
C
cursorBotSep 2, 2026

Local Firefox locks treated as foreign

Medium Severity

firefoxSymlinkLockIsHeld treats only 127.0.0.1 and ::1 as local owners. Firefox writes the IP it resolved for the machine hostname, which is often 127.0.1.1, 127.0.0.2, or a LAN address. A leftover lock symlink after a crash then stays held and blocks import.

Reviewed by Cursor Bugbot for commit ba690c491aaf5852c02a5f0b91a1c2edbeb9457a. Configure here.

J
Julius MarmingeSep 2, 2026

Fixed in 8360b0f6ca. Verified against nsProfileLock.cpp: the address is whatever PR_GetHostByName returns for the hostname, falling back to 127.0.0.1 only when the lookup fails — so 127.0.1.1 and LAN addresses are routine. The owner is now matched against every address the machine answers to (new injectable HostProcessAddresses, mirroring HostProcessHostname), so a crashed Firefox on such a host no longer leaves a permanently-held lock. Tests cover loopback, 127.0.1.1, a LAN address, dead pids on each, and a genuinely foreign owner.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision f6a437d7 · right 519

Original diff
@@ -237,28 +487,36 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+const FIREFOX_LOCK_NAMES = ["lock", ".parentlock", "parent.lock"] as const;
 /** Whether the browser is running, which leaves its cookie DB mid-write. */
 export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
+  context: BrowserImportPathContext,
+): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
   const fileSystem = yield* FileSystem.FileSystem;
-  const currentHost = yield* HostProcessHostname;
-  const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
-  // Chromium writes a `SingletonLock` symlink for as long as an instance holds
-  // the profile. Its presence is a far cheaper and more targeted signal than
-  // scanning the process table for a name.
-  //
-  // The link points at `<host>-<pid>`, a target that never exists, and both
-  // `stat` and `exists` follow links — so they report every running browser as
-  // closed, which would let an import read a live, mid-write database.
-  // `readLink` is the one probe that answers for the entry itself. Chromium
-  // can leave this link behind after a crash, so a positively dead local PID
-  // is stale. Every ambiguous target or liveness result stays conservative.
-  return yield* fileSystem.readLink(lock).pipe(
-    Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
-    Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
-  );
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return false;
+  // Both engines leave a lock file for as long as an instance holds a profile,
+  // which is far cheaper and more targeted than scanning the process table.
+  if (definition.engine !== "firefox") {
+    const currentHost = yield* HostProcessHostname;
+    const lock = context.path.join(root, "SingletonLock");
+    return yield* fileSystem.readLink(lock).pipe(
+      Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
+      Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
+    );
+  }
+
+  const profiles = yield* listSourceProfiles(definition, context);
+  const found = yield* Effect.forEach(profiles, (profile) => {
+    const directory = context.path.isAbsolute(profile.directory)
+      ? profile.directory
+      : context.path.join(root, profile.directory);
+    return Effect.forEach(FIREFOX_LOCK_NAMES, (lock) =>
+      entryExists(context.path.join(directory, lock)),
+    ).pipe(Effect.map((results) => results.some(Boolean)));
+  });
+  return found.some(Boolean);
C
cursorBotSep 2, 2026

Firefox lock files false-positive running

High Severity

isSourceRunning treats the presence of .parentlock or parent.lock as proof Firefox still holds the profile. Firefox leaves those files on disk after a clean exit and only releases the OS lock (fcntl/flock or a Windows handle). After Firefox has been used once, the source stays browserRunning and cookie import never proceeds.

Reviewed by Cursor Bugbot for commit f6a437d70cf414f2749037e2062edb39956e4374. Configure here.

J
Julius MarmingeSep 2, 2026

Fixed in 1ce9a337af. Verified against Firefox's nsProfileLock.cpp: .parentlock/parent.lock are deliberately left on disk after a clean exit (as a last-used marker) and only the OS-level fcntl/handle lock is released, so their presence is not evidence. Only the Linux lock symlink (target <ip>:[+]<pid>) is unlinked on exit — that plus a live pid is now the POSIX signal, and a dead pid means a crash. Node exposes no fcntl probe, so .parentlock carries no signal on POSIX; on Windows the held handle denies our open as Busy, which is the signal there. Regression tests cover the stale-file, live-symlink, and crashed-pid cases.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision c2131c3a · right 342–347

Original diff
@@ -91,59 +284,74 @@ const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow
  */
 const countProfileCookies = Effect.fnUntraced(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
+  context: BrowserImportPathContext,
   directory: string,
 ): Effect.fn.Return<number | undefined, never> {
+  const databasePath = cookieDatabasePath(definition, context, directory);
+  if (databasePath === undefined) return undefined;
   return yield* Effect.gen(function* () {
     const sql = yield* SqlClient.SqlClient;
     const rows = yield* sql`select count(*) as count from cookies`;
     const [row] = yield* decodeCookieCount(rows);
     return row?.count;
   }).pipe(
-    Effect.provide(
-      NodeSqliteClient.layer({
-        filename: cookieDatabasePath(definition, paths, directory),
-        readonly: true,
-      }),
-    ),
+    Effect.provide(NodeSqliteClient.layer({ filename: databasePath, readonly: true })),
     Effect.orElseSucceed(() => undefined),
   );
 });
 
 const withCookieCounts = (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
+  context: BrowserImportPathContext,
   profiles: ReadonlyArray<BrowserImportSourceProfile>,
 ) =>
   Effect.forEach(profiles, (profile) =>
-    countProfileCookies(definition, paths, profile.directory).pipe(
+    countProfileCookies(definition, context, profile.directory).pipe(
       Effect.map((cookieCount) =>
         cookieCount === undefined ? profile : { ...profile, cookieCount },
       ),
     ),
   );
 
 /**
- * Profiles the source browser knows about, read from its `Local State`.
+ * Profiles the source browser knows about.
  *
- * When that file is missing, unreadable or malformed, the user-data directory
- * is scanned for directories that hold a cookie database. Assuming `Default`
- * instead would report a browser whose cookies live in `Profile 1` as having
+ * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
+ * that metadata is missing, unreadable or malformed, the directories that
+ * actually hold a cookie database are scanned instead. Assuming a single
+ * `Default` would report a browser whose cookies live in `Profile 1` as having
  * nothing to import — and it is then left out of the menu entirely.
  */
 export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
+  context: BrowserImportPathContext,
+): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
   const fileSystem = yield* FileSystem.FileSystem;
-  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return [];
+
+  if (definition.engine === "firefox") {
+    const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
+      Effect.map(parseFirefoxProfiles),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
+    if (declared.length > 0) return declared;
+
+    // No readable `profiles.ini`, so fall back to scanning the directory the
+    // profiles actually live in.
+    return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
+      Effect.map((entries) =>
+        entries.map((entry) => ({ directory: context.path.join("Profiles", entry), name: entry })),
+      ),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );

apps/desktop/src/preview/BrowserImport/Sources.ts · right 342–347

M
macroscopeappBotAug 29, 2026

🟠 High BrowserImport/Sources.ts:342

When profiles.ini is missing or malformed, the Firefox fallback presents every Profiles entry, including stale profiles and files, as selectable. Selecting one without cookies.sqlite then reaches readFirefoxCookies and fails instead of omitting the profile. Filter the fallback entries by checking for their cookies.sqlite database, as the Chromium fallback does.

diff
1- return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
2- Effect.map((entries) =>
3- entries.map((entry) => ({ directory: context.path.join("Profiles", entry), name: entry })),
4- ),
5- Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
6- );
7+ return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
8+ Effect.flatMap((entries) =>
9+ Effect.forEach(entries, (entry) =>
10+ entryExists(context.path.join(root, "Profiles", entry, "cookies.sqlite")).pipe(
11+ Effect.map((exists) => exists
12+ ? { directory: context.path.join("Profiles", entry), name: entry }
13+ : undefined),
14+ )
15+ )
16+ ),
17+ Effect.map((profiles) => profiles.filter((profile) => profile !== undefined)),
18+ Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
19+ );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around lines 342-347:
2
3When `profiles.ini` is missing or malformed, the Firefox fallback presents every `Profiles` entry, including stale profiles and files, as selectable. Selecting one without `cookies.sqlite` then reaches `readFirefoxCookies` and fails instead of omitting the profile. Filter the fallback entries by checking for their `cookies.sqlite` database, as the Chromium fallback does.

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision 973b17ea · right 53

Original diff
@@ -0,0 +1,77 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // Already seconds since the UNIX epoch, unlike Chromium's 1601-based
+  // microseconds, so no conversion is needed.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.Number,
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
+  cookieDatabasePath: string,
+) {
+  const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath);
+
+  const rows = yield* Effect.gen(function* () {
+    const sql = yield* SqlClient.SqlClient;
+    const raw = yield* sql`
+      select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
M
macroscopeappBotAug 16, 2026

🟡 Medium BrowserImport/FirefoxCookies.ts:53

Firefox cookies from different containers with the same host, name, and path overwrite one another during rows.map, so the imported profile receives an arbitrary container's session and mixes isolated browsing identities. The query omits originAttributes, so the importer cannot distinguish these rows; carry that attribute through extraction and define a collision policy for Electron, which has no equivalent container key.

✅ Resolved in 1b132e45546ca54fb62fd0bde080ac4d3f656263

apps/desktop/src/preview/BrowserImport/CookieDatabase.ts · Earlier revision 973b17ea · right 48

Original diff
@@ -0,0 +1,52 @@
+/**
+ * Shared pieces of cookie extraction: the shape both engines produce, and the
+ * snapshot every reader takes before touching a live database.
+ *
+ * @module CookieDatabase
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+/** A cookie in the shape Electron's `session.cookies.set` accepts. */
+export interface ImportedCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  readonly domain: string;
+  readonly path: string;
+  readonly secure: boolean;
+  readonly httpOnly: boolean;
+  /** Seconds since the UNIX epoch, or undefined for a session cookie. */
+  readonly expirationDate: number | undefined;
+  readonly sameSite: "no_restriction" | "lax" | "strict";
+}
+
+/**
+ * Copies a cookie database, and its write-ahead sidecars, to a temporary
+ * directory before reading, and returns the copy's path.
+ *
+ * Both engines keep the file open with WAL while the browser runs, so reading
+ * in place can observe a torn write. Copying also guarantees we never open the
+ * browser's own file for writing.
+ *
+ * Scoped: the temporary directory goes away when the caller's scope closes.
+ */
+export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(
+  function* (cookiePath: string) {
+    const fileSystem = yield* FileSystem.FileSystem;
+    const path = yield* Path.Path;
+
+    const directory = yield* fileSystem.makeTempDirectoryScoped({
+      prefix: "t3code-cookie-import-",
+    });
+    const target = path.join(directory, path.basename(cookiePath));
+    yield* fileSystem.copyFile(cookiePath, target);
+    // The sidecars only exist while the browser holds the database open, so a
+    // missing one is normal rather than a failure.
+    yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
+      fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore),
M
macroscopeappBotAug 16, 2026

🟡 Medium BrowserImport/CookieDatabase.ts:48

An existing -wal or -shm sidecar that fails to copy is silently ignored, so SQLite reads the snapshot without those transactions and the import can omit current cookies instead of failing. Effect.ignore suppresses permission and I/O errors as well as file-not-found; only suppress the expected missing-sidecar error.

✅ Resolved in 1b132e45546ca54fb62fd0bde080ac4d3f656263

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision 1f8e906e · right 55

Original diff
@@ -0,0 +1,131 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/FirefoxCookies.ts:55

Cookies with Firefox’s valid SAMESITE_UNSET value (0) are imported as sameSite: "no_restriction", changing ordinary cookies with no SameSite attribute to cross-site unrestricted cookies. Map SAMESITE_UNSET to Electron’s "unspecified" value instead, and update ImportedCookie to allow that value.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts around line 55:
2
3Cookies with Firefox’s valid `SAMESITE_UNSET` value (`0`) are imported as `sameSite: "no_restriction"`, changing ordinary cookies with no `SameSite` attribute to cross-site unrestricted cookies. Map `SAMESITE_UNSET` to Electron’s `"unspecified"` value instead, and update `ImportedCookie` to allow that value.

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision fcc96300 · right 75

Original diff
@@ -0,0 +1,112 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // Already seconds since the UNIX epoch, unlike Chromium's 1601-based
+  // microseconds, so no conversion is needed.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.Number,
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
+  cookieDatabasePath: string,
+) {
+  const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe(
M
macroscopeappBotAug 29, 2026

🟠 High BrowserImport/FirefoxCookies.ts:75

readFirefoxCookies can fail or silently omit committed cookies when Firefox writes or checkpoints between the separate copies performed by snapshotCookieDatabase. Because the main database and -wal sidecar can come from different SQLite generations, the later read is not a consistent snapshot; use SQLite's backup/read-transaction facility (or otherwise hold a stable snapshot) instead of independent file copies.

Also found in 1 other location(s)

apps/desktop/src/preview/BrowserImport/CookieDatabase.ts:77

snapshotCookieDatabase does not take a consistent SQLite snapshot when a browser starts (or is already writing) between the earlier running-state check and these copies: it copies the main database at line 77 and the WAL later at line 83. A concurrent checkpoint can change the database/WAL boundary between those reads, so SQLite may open a mismatched pair and either fail the import or silently omit committed cookies. The lock preflight cannot close this time-of-check/time-of-use race; use SQLite's backup/snapshot facility or acquire a stable read transaction before copying.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts around line 75:
2
3`readFirefoxCookies` can fail or silently omit committed cookies when Firefox writes or checkpoints between the separate copies performed by `snapshotCookieDatabase`. Because the main database and `-wal` sidecar can come from different SQLite generations, the later read is not a consistent snapshot; use SQLite's backup/read-transaction facility (or otherwise hold a stable snapshot) instead of independent file copies.
4
5Also found in 1 other location(s):
6- apps/desktop/src/preview/BrowserImport/CookieDatabase.ts:77 -- `snapshotCookieDatabase` does not take a consistent SQLite snapshot when a browser starts (or is already writing) between the earlier running-state check and these copies: it copies the main database at line 77 and the WAL later at line 83. A concurrent checkpoint can change the database/WAL boundary between those reads, so SQLite may open a mismatched pair and either fail the import or silently omit committed cookies. The lock preflight cannot close this time-of-check/time-of-use race; use SQLite's backup/snapshot facility or acquire a stable read transaction before copying.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts · Earlier revision 7487b3c1 · right 186–191

Original diff
@@ -144,17 +152,45 @@ export const make = Effect.gen(function* BrowserImportMake() {
       });
     }
 
-    const cookies = yield* readChromiumCookies({
-      cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
-      keychainService: definition.keychainService,
-      keychainAccount: definition.keychainAccount,
-      platform,
-    }).pipe(
+    const databasePath = cookieDatabasePath(definition, pathContext, requestedProfile.directory);
+    if (databasePath === undefined) {
+      return yield* new BrowserImportFailedError({
+        sourceId: definition.id,
+        reason: "unsupportedPlatform",
+      });
+    }
+
+    // Both branches fail with a tagged error carrying a `reason`, so the union
+    // stays structurally identifiable rather than collapsing to an anonymous
+    // shape that `Effect.catchTags` could not tell apart.
+    const read: Effect.Effect<
+      ReadonlyArray<ImportedCookie>,
+      ChromiumCookieReadError | FirefoxCookieReadError,
+      FileSystem.FileSystem | Path.Path | Scope.Scope
+    > = definition.engine === "firefox"
+      ? readFirefoxCookies(databasePath)
+      : readChromiumCookies({
+          cookieDatabasePath: databasePath,
+          // Only reached on macOS: `unavailableReason` rejects Chromium
+          // elsewhere until those key stores are implemented.
+          keychainService: definition.keychainService ?? "",
+          keychainAccount: definition.keychainAccount ?? "",
+          platform,
+        });
+
+    const cookies = yield* read.pipe(
       Effect.scoped,
       Effect.provide(platformServices),
       Effect.mapError(
         (cause) =>
-          new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
+          new BrowserImportFailedError({
+            sourceId: definition.id,
+            // Firefox has one failure mode — its plaintext database would not
+            // open — so its error carries no reason of its own and the
+            // user-facing one is supplied here.
+            reason: cause._tag === "FirefoxCookieReadError" ? "readFailed" : cause.reason,

apps/desktop/src/preview/BrowserImport/BrowserImport.ts · right 186–191

M
macroscopeappBotAug 17, 2026

BrowserImport/BrowserImport.ts:191

Both failures are statically known tagged errors, so the branch belongs in Effect.catchTags rather than a hand-rolled cause._tag ternary inside mapError. That also keeps each handler's reason next to the tag it applies to.

The comment above read ("Both branches fail with a tagged error carrying a reason") is now stale too — FirefoxCookieReadError deliberately has no reason.

diff
1 const cookies = yield* read.pipe(
2 Effect.scoped,
3 Effect.provide(platformServices),
4- Effect.mapError(
5- (cause) =>
6- new BrowserImportFailedError({
7- sourceId: definition.id,
8- reason: cause._tag === "FirefoxCookieReadError" ? "readFailed" : cause.reason,
9- cause,
10- }),
11- ),
12+ Effect.catchTags({
13+ // Firefox has one failure mode — its plaintext database would not
14+ // open — so the user-facing reason is supplied here.
15+ FirefoxCookieReadError: (cause) =>
16+ new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
17+ ChromiumCookieReadError: (cause) =>
18+ new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
19+ }),
20 );

No longer relevant as of 8538b6cede1f0d126bce980caf52b2dda2e9c359

packages/shared/src/hostProcess.ts · Earlier revision 8360b0f6 · right 70

Original diff
@@ -51,6 +51,24 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
   },
 );
 
+/**
+ * Every IP address this machine answers to, loopback included. A resolver
+ * may map the hostname to any of them (127.0.1.1 on Debian-style hosts, a
+ * LAN address elsewhere), so "is this address ours" has to check the whole
+ * set rather than assume 127.0.0.1.
+ */
+export const HostProcessAddresses = Context.Reference<ReadonlySet<string>>(
+  "@t3tools/shared/hostProcess/HostProcessAddresses",
+  {
+    defaultValue: () =>
+      new Set(
+        Object.values(NodeOS.networkInterfaces())
+          .flat()
+          .flatMap((entry) => (entry ? [entry.address] : [])),
+      ),
+  },
+);
C
cursorBotSep 2, 2026

Hostname IPs treated as foreign locks

Medium Severity

HostProcessAddresses only collects addresses from os.networkInterfaces(), so Debian-style hostname IPs such as 127.0.1.1 never count as local. firefoxSymlinkLockIsHeld then treats that owner as foreign and reports the profile held without checking the pid, so a leftover lock symlink after a crash blocks Firefox import indefinitely.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 8360b0f6ca4aac215cbc9e119bf1601999f75205. Configure here.

J
Julius MarmingeSep 2, 2026

Fixed in 1f8e906e7f. HostProcessAddresses now unions the interface addresses with whatever the resolver returns for the machine's own hostname — which is exactly what Firefox writes into the lock — so a /etc/hosts mapping like 127.0.1.1 counts as local. It's an Effect now (the lookup is async), still injectable for tests.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 11c7e86a · right 413

Original diff
@@ -153,58 +361,56 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
     ),
     Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
   );
-  if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
+  if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);
 
-  // `Local State` is missing, unreadable or malformed. Scanning for
-  // directories that hold a cookie database finds the profiles anyway;
-  // assuming `Default` would report a browser whose cookies live in
-  // `Profile 1` as having nothing to import, and it is then hidden entirely.
+  // `Local State` is missing, unreadable or malformed. Scanning for directories
+  // that hold a cookie database finds the profiles anyway.
   const entries = yield* fileSystem
     .readDirectory(root)
     .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
-  const candidates = entries.filter(isSafeProfileDirectory);
-  const found = yield* Effect.forEach(candidates, (directory) =>
-    entryExists(cookieDatabasePath(definition, paths, directory)).pipe(
+  const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) =>
+    entryExists(cookieDatabasePath(definition, context, directory) ?? "").pipe(
       Effect.map((exists) => (exists ? { directory, name: directory } : undefined)),
     ),
   );
   return yield* withCookieCounts(
     definition,
-    paths,
+    context,
     found.filter((profile) => profile !== undefined),
   );
 });
 
-/**
- * Whether a directory entry exists, without following it or opening it.
- *
- * `stat` resolves symlinks and the locks below deliberately dangle, so
- * `readLink` is the probe that answers for the entry itself.
- */
-const entryExists = Effect.fnUntraced(function* (path: string) {
-  const fileSystem = yield* FileSystem.FileSystem;
-  return yield* fileSystem.stat(path).pipe(
-    Effect.catchCause(() => fileSystem.readLink(path)),
-    Effect.as(true),
-    Effect.orElseSucceed(() => false),
-  );
-});
+const FIREFOX_LOCK_NAMES = ["lock", ".parentlock", "parent.lock"] as const;
 
 /** Whether the browser is running, which leaves its cookie DB mid-write. */
 export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
-  const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
-  // Chromium writes a `SingletonLock` symlink for as long as an instance holds
-  // the profile. Its presence is a far cheaper and more targeted signal than
-  // scanning the process table for a name.
+  context: BrowserImportPathContext,
+): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return false;
+  // Both engines leave a lock file for as long as an instance holds a profile,
+  // which is far cheaper and more targeted than scanning the process table.
   //
-  // The link points at `<host>-<pid>`, a target that never exists, and both
-  // `stat` and `exists` follow links — so they report every running browser as
-  // closed, which would let an import read a live, mid-write database.
-  // `readLink` is the one probe that answers for the entry itself.
-  return yield* entryExists(lock);
+  // They differ in where: Chromium keeps one `SingletonLock` for the whole
+  // user-data directory, Firefox keeps its locks inside each profile, under
+  // three names across platforms (`lock` on macOS and Linux, `.parentlock`
+  // beside it, `parent.lock` on Windows). Looking for Firefox's at the root
+  // finds nothing and reports a running browser as importable.
+  if (definition.engine !== "firefox") {
+    return yield* entryExists(context.path.join(root, "SingletonLock"));
+  }
+
+  const profiles = yield* listSourceProfiles(definition, context);
+  const found = yield* Effect.forEach(profiles, (profile) => {
+    const directory = context.path.isAbsolute(profile.directory)
+      ? profile.directory
+      : context.path.join(root, profile.directory);
+    return Effect.forEach(FIREFOX_LOCK_NAMES, (lock) =>
+      entryExists(context.path.join(directory, lock)),
+    ).pipe(Effect.map((results) => results.some(Boolean)));
+  });
+  return found.some(Boolean);
C
cursorBotAug 28, 2026

Firefox lock files block all imports

High Severity

isSourceRunning treats existence of .parentlock or parent.lock as Firefox being open. Firefox leaves those files behind after quit so it can read last-used time; only the Unix lock symlink is removed. After a profile has been used once, listing and import both fail with browserRunning, so Firefox import cannot succeed.

Reviewed by Cursor Bugbot for commit 11c7e86a3d4d784ae486b9a3acc55acd577dc5f8. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision fcc96300 · right 342–342

Original diff
@@ -91,59 +284,74 @@ const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow
  */
 const countProfileCookies = Effect.fnUntraced(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
+  context: BrowserImportPathContext,
   directory: string,
 ): Effect.fn.Return<number | undefined, never> {
+  const databasePath = cookieDatabasePath(definition, context, directory);
+  if (databasePath === undefined) return undefined;
   return yield* Effect.gen(function* () {
     const sql = yield* SqlClient.SqlClient;
     const rows = yield* sql`select count(*) as count from cookies`;
     const [row] = yield* decodeCookieCount(rows);
     return row?.count;
   }).pipe(
-    Effect.provide(
-      NodeSqliteClient.layer({
-        filename: cookieDatabasePath(definition, paths, directory),
-        readonly: true,
-      }),
-    ),
+    Effect.provide(NodeSqliteClient.layer({ filename: databasePath, readonly: true })),
     Effect.orElseSucceed(() => undefined),
   );
 });
 
 const withCookieCounts = (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
+  context: BrowserImportPathContext,
   profiles: ReadonlyArray<BrowserImportSourceProfile>,
 ) =>
   Effect.forEach(profiles, (profile) =>
-    countProfileCookies(definition, paths, profile.directory).pipe(
+    countProfileCookies(definition, context, profile.directory).pipe(
       Effect.map((cookieCount) =>
         cookieCount === undefined ? profile : { ...profile, cookieCount },
       ),
     ),
   );
 
 /**
- * Profiles the source browser knows about, read from its `Local State`.
+ * Profiles the source browser knows about.
  *
- * When that file is missing, unreadable or malformed, the user-data directory
- * is scanned for directories that hold a cookie database. Assuming `Default`
- * instead would report a browser whose cookies live in `Profile 1` as having
+ * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
+ * that metadata is missing, unreadable or malformed, the directories that
+ * actually hold a cookie database are scanned instead. Assuming a single
+ * `Default` would report a browser whose cookies live in `Profile 1` as having
  * nothing to import — and it is then left out of the menu entirely.
  */
 export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
+  context: BrowserImportPathContext,
+): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
   const fileSystem = yield* FileSystem.FileSystem;
-  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return [];
+
+  if (definition.engine === "firefox") {
+    const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
+      Effect.map(parseFirefoxProfiles),
+      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+    );
+    if (declared.length > 0) return declared;
+
+    // No readable `profiles.ini`, so fall back to scanning the directory the
+    // profiles actually live in.
+    return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(

apps/desktop/src/preview/BrowserImport/Sources.ts · right 342–342

M
macroscopeappBotAug 29, 2026

🟠 High BrowserImport/Sources.ts:342

On Linux, the Firefox fallback returns no profiles even when cookies.sqlite exists, so isSourceInstalled reports Firefox as absent and the browser cannot be imported. The fallback always scans <root>/Profiles, but Linux profiles are direct children of <root>; scan <root> on Linux instead.

diff
1- return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe(
2+ const profilesRoot = context.platform === "linux" ? root : context.path.join(root, "Profiles");
3+ return yield* fileSystem.readDirectory(profilesRoot).pipe(
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 342:
2
3On Linux, the Firefox fallback returns no profiles even when `cookies.sqlite` exists, so `isSourceInstalled` reports Firefox as absent and the browser cannot be imported. The fallback always scans `<root>/Profiles`, but Linux profiles are direct children of `<root>`; scan `<root>` on Linux instead.
J
Julius MarmingeAug 29, 2026

Fixed in the updated #7260. Linux Firefox fallback scans the root directory; macOS and Windows retain Profiles/. Fixtures cover the platform layouts and require cookies.sqlite.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 56220187 · right 160

Original diff
@@ -1,64 +1,257 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
 import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
+ * platform's segments marks the fork as unavailable there. Windows is left out
+ * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
+ * identity (App-Bound Encryption), so no other process can read them.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: ({ path, home }) =>
-      path.join(home, "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
+    platforms: ["darwin", "win32", "linux"],
+    userDataDirectory: (context) => {
+      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
+      if (context.platform === "win32") {
+        return context.appData
+          ? context.path.join(context.appData, "Mozilla", "Firefox")
+          : undefined;
+      }
+      return context.path.join(context.home, ".mozilla", "firefox");
+    },
C
cursorBotAug 28, 2026

Linux Firefox path misses Snap installs

Medium Severity

Linux Firefox resolution only looks at ~/.mozilla/firefox. Default Ubuntu Snap profiles live under ~/snap/firefox/common/.mozilla/firefox (and Flatpak under ~/.var/app/org.mozilla.firefox/.mozilla/firefox), so the source is reported notInstalled on the most common Linux desktop even when Firefox is present.

Reviewed by Cursor Bugbot for commit 56220187d6f51dc4eb89e5772a328c4934314390. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 8360b0f6 · right 564

Original diff
@@ -233,28 +471,169 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+/** Windows sharing and lock violations are translated by libuv to `Busy`. */
+export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
+  error.reason._tag === "Busy";
+
+/**
+ * Whether a Windows `parent.lock` is actually held by a running process. It
+ * is opened with no sharing, so it persists on disk after the process exits
+ * and `stat` always succeeds; only trying to open it for write reveals an
+ * active holder, which surfaces as `Busy`.
+ */
+const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
+  // Permission failures are distinct: they do not prove a browser owns the
+  // lock, so they must not hide the source as running.
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
+    Effect.as(false),
+    Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
+    Effect.orElseSucceed(() => false),
+    Effect.scoped,
+  );
+});
+
+/**
+ * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
+ * live owner. Firefox writes this symlink beside the profile while it runs and
+ * unlinks it on a clean exit, so a dangling one is either live or a crash.
+ */
+export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
+  target: string,
+  localAddresses: ReadonlySet<string>,
+  isProcessAlive: ProcessLivenessProbe,
+) {
+  const separator = target.lastIndexOf(":");
+  if (separator < 0) return true;
+  // The owner half is whatever Firefox's resolver returned for the machine's
+  // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN
+  // address — so a pid is only meaningful when that address is one of ours.
+  // A shared (NFS) profile locked from another machine names a foreign
+  // address whose pid cannot be probed here, nor could a reused local pid
+  // vouch for it, so it stays conservatively held.
+  const owner = target.slice(0, separator);
+  if (!localAddresses.has(owner)) return true;
+  // A `+` marks an fcntl-holding owner; the pid follows either way.
+  const pidText = target.slice(separator + 1).replace(/^\+/, "");
+  if (!/^\d+$/.test(pidText)) return true;
+  const pid = Number(pidText);
+  if (!Number.isSafeInteger(pid) || pid <= 0) return true;
+  return yield* isProcessAlive(pid);
+});
+
+/**
+ * Whether another process holds an fcntl write lock on `path`.
+ *
+ * Firefox's `.parentlock` is an empty file whose only signal is the kernel
+ * lock, and Node exposes no fcntl, so a throwaway interpreter tries a
+ * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never
+ * acquired for real: on success the child exits and the kernel drops it.
+ * Anything other than a clean "held" or "free" answer — no interpreter, a
+ * crash, a missing file — is treated as *held*, so a probe failure can never
+ * let an import read a live, mid-write database.
+ */
+export const posixLockIsHeld = Effect.fnUntraced(function* (path: string) {
+  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+  return yield* Effect.scoped(
+    Effect.gen(function* () {
+      const handle = yield* spawner.spawn(
+        ChildProcess.make(
+          "python3",
+          [
+            "-c",
+            "import fcntl,os,sys\n" +
+              "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
+              "try:\n" +
+              "  fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
+              "except BlockingIOError:\n" +
+              "  print('held')\n" +
+              "else:\n" +
+              "  print('free')",
+            path,
+          ],
+          { stdin: "ignore" },
+        ),
+      );
+      const [stdout, exitCode] = yield* Effect.all(
+        [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
+        { concurrency: "unbounded" },
+      );
+      if (Number(exitCode) !== 0) return true;
+      return stdout.trim() !== "free";
+    }),
+  ).pipe(Effect.orElseSucceed(() => true));
C
cursorBotSep 2, 2026

macOS Firefox import needs python3

High Severity

macOS Firefox running detection always probes .parentlock with a python3 child. That file is left on disk after a clean exit, and any probe failure — including a missing interpreter — counts as held. Packaged apps launched from the Dock rarely have python3 on PATH, so Firefox import stays blocked after the browser has been used once.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 8360b0f6ca4aac215cbc9e119bf1601999f75205. Configure here.

J
Julius MarmingeSep 2, 2026

Fixed in 1f8e906e7f. Two changes: /usr/bin/python3 is named absolutely first so launchd's bare PATH still finds it; and "held" is now only ever the script's own printed verdict. A probe that never ran — no interpreter on any candidate, or Apple's shim on a Mac without the developer tools (exits non-zero after an install prompt, never reaching the script) — is the probe being unavailable, not evidence about the lock, and falls back to not-held rather than blocking Firefox import on that machine for good; the SQLite snapshot copes with a live WAL as it does for every other engine. Tests cover a real fcntl holder, a missing interpreter, and a shim-like non-zero exit.

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision dc81cda2 · right 61

Original diff
@@ -0,0 +1,139 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+/**
+ * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax,
+ * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute
+ * at all. Unset is not the same thing as None — None is an explicit opt-in to
+ * cross-site use — so it is imported as Electron's `unspecified`, which lets
+ * the target browser apply its own default exactly as Firefox did. Anything
+ * unrecognised also lands there rather than on `no_restriction`, since
+ * guessing "none" would widen a cookie's scope on import.
+ */
+const SAMESITE_NONE = 0;
+const SAMESITE_LAX = 1;
+const SAMESITE_STRICT = 2;
+
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === SAMESITE_NONE) return "no_restriction";
+  if (value === SAMESITE_LAX) return "lax";
M
macroscopeappBotSep 2, 2026

🟡 Medium BrowserImport/FirefoxCookies.ts:61

Pre-15 Firefox profiles import cookies with sameSite = 1 and rawSameSite = 0 as Electron lax, even though those rows had no SameSite attribute and must be imported as unspecified. sameSiteFromColumn ignores rawSameSite, so importing a schema 10–14 database before Firefox runs its schema-15 migration changes the cookies’ SameSite semantics. Read rawSameSite and apply the migration’s SAMESITE_UNSET rule before mapping the value.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts around line 61:
2
3Pre-15 Firefox profiles import cookies with `sameSite = 1` and `rawSameSite = 0` as Electron `lax`, even though those rows had no SameSite attribute and must be imported as `unspecified`. `sameSiteFromColumn` ignores `rawSameSite`, so importing a schema 10–14 database before Firefox runs its schema-15 migration changes the cookies’ SameSite semantics. Read `rawSameSite` and apply the migration’s `SAMESITE_UNSET` rule before mapping the value.
J
Julius MarmingeSep 2, 2026

Confirmed and fixed in 89623849ea. The reader now selects rawSameSite only when user_version is 10–14 (the column does not exist outside that range) and applies Firefox's own schema-15 migration rule: sameSite = LAX && rawSameSite = NONE → unspecified. Test covers defaulted-Lax, declared-Lax and explicit-None rows on a schema-14 fixture.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts · File comment

M
macroscopeappBotSep 2, 2026

🟡 Medium

https://github.com/pingdotgg/t3code/blob/341268fce7fd31b07c92b124226c7e1323573cec/apps/desktop/src/preview/BrowserImport/BrowserImport.ts#L204

Every Firefox import logs Reading browser cookie key from the keychain and includes executablePath, even though Firefox uses readFirefoxCookies and never accesses a keychain. Move this log into the Chromium branch so support and audit logs do not report a false security-sensitive operation.

🚀 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 204:
2
3Every Firefox import logs `Reading browser cookie key from the keychain` and includes `executablePath`, even though Firefox uses `readFirefoxCookies` and never accesses a keychain. Move this log into the Chromium branch so support and audit logs do not report a false security-sensitive operation.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision dc81cda2 · right 628–628

Original diff
@@ -254,28 +487,195 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+/** Windows sharing and lock violations are translated by libuv to `Busy`. */
+export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
+  error.reason._tag === "Busy";
+
+/**
+ * Whether a Windows `parent.lock` is actually held by a running process. It
+ * is opened with no sharing, so it persists on disk after the process exits
+ * and `stat` always succeeds; only trying to open it for write reveals an
+ * active holder, which surfaces as `Busy`.
+ */
+const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
+  // Permission failures are distinct: they do not prove a browser owns the
+  // lock, so they must not hide the source as running.
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
+    Effect.as(false),
+    Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
+    Effect.orElseSucceed(() => false),
+    Effect.scoped,
+  );
+});
+
+/**
+ * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
+ * live owner. Firefox writes this symlink beside the profile while it runs and
+ * unlinks it on a clean exit, so a dangling one is either live or a crash.
+ */
+export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
+  target: string,
+  localAddresses: ReadonlySet<string>,
+  isProcessAlive: ProcessLivenessProbe,
+) {
+  const separator = target.lastIndexOf(":");
+  if (separator < 0) return true;
+  // The owner half is whatever Firefox's resolver returned for the machine's
+  // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN
+  // address — so a pid is only meaningful when that address is one of ours.
+  // A shared (NFS) profile locked from another machine names a foreign
+  // address whose pid cannot be probed here, nor could a reused local pid
+  // vouch for it, so it stays conservatively held.
+  const owner = target.slice(0, separator);
+  if (!localAddresses.has(owner)) return true;
+  // A `+` marks an fcntl-holding owner; the pid follows either way.
+  const pidText = target.slice(separator + 1).replace(/^\+/, "");
+  if (!/^\d+$/.test(pidText)) return true;
+  const pid = Number(pidText);
+  if (!Number.isSafeInteger(pid) || pid <= 0) return true;
+  return yield* isProcessAlive(pid);
+});
+
+/**
+ * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3`
+ * is named absolutely first so a Dock-launched app with launchd's bare `PATH`
+ * still finds it without depending on the login-shell PATH merge; Linux
+ * distributions carry python3 on the default path.
+ */
+const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const;
+
+/**
+ * The probe prints exactly one of these. Anything else means the script never
+ * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac
+ * without the Command Line Tools exits non-zero after printing an install
+ * prompt, without ever reaching our code.
+ */
+const FCNTL_PROBE_SCRIPT =
+  "import fcntl,os,sys\n" +
+  "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
+  "try:\n" +
+  "  fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
+  "except BlockingIOError:\n" +
+  "  print('held')\n" +
+  "else:\n" +
+  "  print('free')";
+
+/**
+ * Whether another process holds an fcntl write lock on `path`.
+ *
+ * Firefox's `.parentlock` is an empty file whose only signal is the kernel
+ * lock, and Node exposes no fcntl, so a throwaway interpreter tries a
+ * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never
+ * acquired for real: on success the child exits and the kernel drops it.
+ *
+ * The answer is trusted only when the script itself spoke. A verdict of
+ * `held` or `free` on stdout is the probe's own, and stands. Anything else —
+ * no interpreter on any candidate path, or one that refused to run the script
+ * (Apple's shim without the developer tools) — is the probe being unavailable,
+ * not evidence about the lock. That case falls back to "not held" rather than
+ * "held": reporting every profile as locked forever would block Firefox import
+ * outright on such machines, and the SQLite snapshot already copes with a
+ * live database's WAL, as it does for every other engine.
+ */
+export const posixLockIsHeld = Effect.fnUntraced(function* (
+  path: string,
+  interpreters: ReadonlyArray<string> = FCNTL_PROBE_INTERPRETERS,
+) {
+  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+  const environment = yield* HostProcessEnvironment;
+  for (const interpreter of interpreters) {
+    const verdict = yield* Effect.scoped(
+      Effect.gen(function* () {
+        const handle = yield* spawner.spawn(
+          ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], {
+            stdin: "ignore",
+            env: environment,
+          }),
+        );
+        const [stdout] = yield* Effect.all(
+          [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
+          { concurrency: "unbounded" },
+        );
+        return stdout.trim();
+      }),
+    ).pipe(Effect.orElseSucceed(() => ""));
+    if (verdict === "held") return true;
+    if (verdict === "free") return false;
+  }
+  return false;
+});
+
+/**
+ * Whether Firefox holds a profile.
+ *
+ * Firefox leaves two kinds of lock behind, and they mean different things.
+ * On Linux the `lock` symlink (target `<ip>:+<pid>`) is removed on a clean
+ * exit, so its presence is evidence — provided the pid it names is alive. But
+ * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files
+ * held with fcntl or a Windows handle and are *deliberately left on disk*
+ * after exit, as a last-used marker; treating them as proof of a running
+ * browser blocks every import after Firefox has been used once. On POSIX the
+ * fcntl lock itself is the truth, and macOS in particular writes nothing else
+ * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On
+ * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`.
+ */
+const firefoxProfileIsHeld = Effect.fnUntraced(function* (
+  directory: string,
+  context: BrowserImportPathContext,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localAddresses = yield* yield* HostProcessAddresses;

apps/desktop/src/preview/BrowserImport/Sources.ts · right 628–628

M
macroscopeappBotSep 2, 2026

HostProcessAddresses now holds an unmemoized Effect, so this yield* yield* re-runs the hostname resolution on every call — isSourceRunning invokes firefoxProfileIsHeld once per profile inside Effect.forEach, so a profile list of n costs n DNS lookups. It also runs before the win32 early return, where windowsLockIsHeld never uses the set.

Consider acquiring the addresses once in isSourceRunning and passing them into firefoxProfileIsHeld, the way firefoxSymlinkLockIsHeld already takes localAddresses as a parameter; that also removes the yield* yield* from the per-profile path.

Posted via Macroscope — Effect Service Conventions

J
Julius MarmingeSep 2, 2026

Fixed in a0bec9dd1c: isSourceRunning resolves HostProcessAddresses once (and not at all on win32, where only parent.lock is consulted) and passes the set into firefoxProfileIsHeld, so the hostname lookup no longer runs per profile.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision ba4ce168 · right 278–281

Original diff
@@ -343,13 +278,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
  * rows it could not. The count reaches the user as part of the skipped total
  * rather than disappearing.
  */
-export interface CookieReadResult {
-  readonly cookies: ReadonlyArray<ChromiumCookie>;
-  readonly undecryptable: number;
-  /** Distinct hosts of the rows that could not be decrypted. */
-  readonly undecryptableHosts: ReadonlyArray<string>;
-}
-
 export interface ChromiumCookieSource {

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · right 278–281

M
macroscopeappBotAug 29, 2026

This doc block described CookieReadResult, which moved to CookieDatabase.ts; it stayed behind and now reads as documentation for ChromiumCookieSource, which it does not describe. Consider deleting it here and folding the "count reaches the user as part of the skipped total" note into CookieReadResult's (currently one-line) comment in CookieDatabase.ts.

diff
1-/**
2- * What a reader produces: the cookies it could recover, and how many stored
3- * rows it could not. The count reaches the user as part of the skipped total
4- * rather than disappearing.
5- */
6 export interface ChromiumCookieSource {

Posted via Macroscope — Effect Service Conventions

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision 1b132e45 · right 75

Original diff
@@ -0,0 +1,83 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // Already seconds since the UNIX epoch, unlike Chromium's 1601-based
+  // microseconds, so no conversion is needed.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.Number,
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
+  cookieDatabasePath: string,
+) {
+  const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath);
+
+  const rows = yield* Effect.gen(function* () {
+    const sql = yield* SqlClient.SqlClient;
+    // Only the default container. Firefox isolates cookies per container and
+    // per private window via `originAttributes` (`^userContextId=2`,
+    // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
+    // all would collapse several identities onto one host/name/path and hand
+    // the profile an arbitrary container's session.
+    const raw = yield* sql`
+      select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
+        from moz_cookies
+       where originAttributes = ''
+    `;
+    return yield* decodeCookieRows(raw);
+  }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
+
+  return rows.map((row) => {
+    const secure = row.isSecure === 1;
+    // Electron matches cookies to a URL rather than a bare domain, so a
+    // host-only entry keeps its leading dot stripped for the URL but not for
+    // the domain it is registered under.
+    const host = row.host.startsWith(".") ? row.host.slice(1) : row.host;
+    return {
+      url: `${secure ? "https" : "http"}://${host}${row.path}`,
+      name: row.name,
+      value: row.value,
+      domain: row.host,
M
macroscopeappBotAug 16, 2026

🟠 High BrowserImport/FirefoxCookies.ts:75

Host-only Firefox cookies are imported with domain: row.host, so Electron normalizes example.com to a domain cookie and exposes it to every subdomain. Omit domain when row.host has no leading dot (and update the cookie type if needed) to preserve the original scope.

✅ Resolved in 5f17f5b058ed672e12c08ce390038b23f18fe7a2

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision 56f17058 · right 33–44

Original diff
@@ -0,0 +1,109 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium
+ * there is no "unspecified" sentinel, but treat anything unrecognised as lax:
+ * that is the modern default, and guessing "none" would widen a cookie's scope
+ * on import.
+ */
+export const FirefoxCookieReadReason = Schema.Literals(["readFailed"]);
+export type FirefoxCookieReadReason = typeof FirefoxCookieReadReason.Type;
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    reason: FirefoxCookieReadReason,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies: ${this.reason}.`;
+  }
+}

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · right 33–44

M
macroscopeappBotAug 16, 2026

BrowserImport/FirefoxCookies.ts:44

FirefoxCookieReadError ends up carrying no variable context: reason has a single literal value, so it only restates what the tag already says, and the cookieDatabasePath that is in scope at both wrapping sites is dropped — ChromiumCookieReadError keeps that field precisely so a user with several browsers can tell which read failed. cause is also optional even though every construction here wraps a real failure.

Consider dropping the singleton reason, adding a required cookieDatabasePath: Schema.String plus a required cause: Schema.Defect(), and deriving message from the path. BrowserImport.importCookies would then map the two engines with Effect.catchTags (supplying reason: "readFailed" for the Firefox tag) instead of reading cause.reason off the union.

No longer relevant as of 7a8cf253130c2b6b13101e72a3b6b113077ac98c

apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts · Earlier revision dc81cda2 · right 78

Original diff
@@ -0,0 +1,139 @@
+/**
+ * Firefox cookie extraction.
+ *
+ * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
+ * to fetch and no consent prompt — the file is readable by anything running as
+ * the user. That is Mozilla's design choice, not a control being circumvented,
+ * which is why this path works identically on macOS, Windows, and Linux while
+ * the Chromium one needs a per-platform credential store.
+ *
+ * @module FirefoxCookies
+ */
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
+
+/**
+ * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
+ * the service can tell apart, rather than one of them widening the channel to
+ * an anonymous shape.
+ *
+ * No `reason` field: unlike Chromium there is only one way this fails — the
+ * plaintext database would not open — and the tag already says which engine it
+ * was. `BrowserImport` supplies the user-facing reason when it maps the union.
+ */
+export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
+  "FirefoxCookieReadError",
+  {
+    /**
+     * Which database the read was for. Firefox keeps one per profile, so
+     * without it a failure cannot be traced back to the profile that caused
+     * it.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Always present: every construction site wraps a real failure. */
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
+  }
+}
+
+/**
+ * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax,
+ * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute
+ * at all. Unset is not the same thing as None — None is an explicit opt-in to
+ * cross-site use — so it is imported as Electron's `unspecified`, which lets
+ * the target browser apply its own default exactly as Firefox did. Anything
+ * unrecognised also lands there rather than on `no_restriction`, since
+ * guessing "none" would widen a cookie's scope on import.
+ */
+const SAMESITE_NONE = 0;
+const SAMESITE_LAX = 1;
+const SAMESITE_STRICT = 2;
+
+const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
+  if (value === SAMESITE_NONE) return "no_restriction";
+  if (value === SAMESITE_LAX) return "lax";
+  if (value === SAMESITE_STRICT) return "strict";
+  return "unspecified";
+};
+
+const CookieRow = Schema.Struct({
+  host: Schema.String,
+  name: Schema.String,
+  value: Schema.String,
+  path: Schema.String,
+  // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the
+  // unit depends on the schema version; see `expiryToSeconds`.
+  expiry: Schema.Number,
+  isSecure: Schema.Number,
+  isHttpOnly: Schema.Number,
+  sameSite: Schema.Number,
+});
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/FirefoxCookies.ts:78

Reading a valid Firefox schema-9 database fails for the entire cookie set when any pre-existing cookie has sameSite = NULL, so no cookies are imported. The schema-9 migration adds sameSite without a default, and decodeCookieRows requires every value to be a number; allow NULL and normalize it to "unspecified" (for example, with COALESCE in the query or in the decoder).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts around line 78:
2
3Reading a valid Firefox schema-9 database fails for the entire cookie set when any pre-existing cookie has `sameSite = NULL`, so no cookies are imported. The schema-9 migration adds `sameSite` without a default, and `decodeCookieRows` requires every value to be a `number`; allow `NULL` and normalize it to `"unspecified"` (for example, with `COALESCE` in the query or in the decoder).

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision d4849a94 · right 536–540

Original diff
@@ -280,11 +531,12 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
  */
 export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
-) {
-  const profiles = yield* listSourceProfiles(definition, paths);
-  const found = yield* Effect.forEach(profiles, (profile) =>
-    entryExists(cookieDatabasePath(definition, paths, profile.directory)),
-  );
+  context: BrowserImportPathContext,
+): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
+  const profiles = yield* listSourceProfiles(definition, context);
+  const found = yield* Effect.forEach(profiles, (profile) => {
+    const database = cookieDatabasePath(definition, context, profile.directory);
+    return database === undefined ? Effect.succeed(false) : entryExists(database);
+  });

apps/desktop/src/preview/BrowserImport/Sources.ts · right 536–540

M
macroscopeappBotAug 29, 2026

🟠 High BrowserImport/Sources.ts:536

isSourceInstalled returns true for a Firefox profile whose cookies.sqlite is only a dangling symlink, so the source is shown as importable but selecting it later fails when the reader opens the missing target. Use a target-following stat check for Firefox databases while retaining entryExists for the Safari/TCC case.

diff
1 const profiles = yield* listSourceProfiles(definition, context);
2+ const fileSystem = yield* FileSystem.FileSystem;
3- const found = yield* Effect.forEach(profiles, (profile) => {
4+ const found = yield* Effect.forEach(profiles, (profile) => {
5 const database = cookieDatabasePath(definition, context, profile.directory);
6- return database === undefined ? Effect.succeed(false) : entryExists(database);
7+ return database === undefined
8+ ? Effect.succeed(false)
9+ : definition.engine === "firefox"
10+ ? fileSystem.stat(database).pipe(Effect.as(true), Effect.orElseSucceed(() => false))
11+ : entryExists(database);
12 });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around lines 536-540:
2
3`isSourceInstalled` returns `true` for a Firefox profile whose `cookies.sqlite` is only a dangling symlink, so the source is shown as importable but selecting it later fails when the reader opens the missing target. Use a target-following `stat` check for Firefox databases while retaining `entryExists` for the Safari/TCC case.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 7a8cf253 · right 200

Original diff
@@ -1,62 +1,275 @@
 /**
  * Importable browser sources.
  *
- * Each entry pins its own on-disk and keychain coordinates rather than
- * deriving them: Chromium forks do not agree on the convention. Helium, for
- * instance, uses the keychain service "Helium Storage Key" / account "Helium"
- * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * encrypted SQLite database whose key lives in an OS credential store; Firefox
+ * keeps them in plain SQLite with no key at all, so it needs no keychain and
+ * works the same on every platform.
+ *
+ * Each entry pins its own paths and keychain coordinates rather than deriving
+ * them, because the forks do not agree. Helium uses the keychain service
+ * "Helium Storage Key" / account "Helium" where Chrome and its closer
+ * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
+ * differs per fork and per platform.
  *
  * @module BrowserImportSources
  */
 import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
-import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 
+export type BrowserImportEngine = "chromium" | "firefox";
+
 /**
- * Where a source's files live, resolved once per call rather than read from
- * the ambient process so the registry stays testable.
+ * Directory roots a definition builds its paths from. Passed in rather than
+ * read from `process`, so source resolution stays testable for platforms the
+ * host is not currently running.
  */
-export interface SourcePaths {
+export interface BrowserImportPathContext {
   readonly path: Path.Path;
+  readonly platform: NodeJS.Platform;
   readonly home: string;
+  /** `%APPDATA%` on Windows; unused elsewhere. */
+  readonly appData: string | undefined;
+  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
+  readonly localAppData: string | undefined;
 }
 
-export const sourcePaths = Effect.gen(function* () {
-  const path = yield* Path.Path;
-  const environment = yield* HostProcessEnvironment;
-  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
-});
-
 export interface BrowserImportSourceDefinition {
   readonly id: BrowserImportSourceId;
   readonly name: string;
-  /** Platforms the definition's paths are valid for. */
+  readonly engine: BrowserImportEngine;
+  /** Platforms the definition has paths for. */
   readonly platforms: ReadonlyArray<NodeJS.Platform>;
-  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
+  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
+  readonly keychainService?: string;
+  readonly keychainAccount?: string;
+}
+
+const macApplicationSupport = (
+  context: BrowserImportPathContext,
+  ...segments: ReadonlyArray<string>
+) => context.path.join(context.home, "Library", "Application Support", ...segments);
+
+/**
+ * One Chromium fork across the three platforms. The macOS and Linux leaves
+ * differ per fork, and Windows usually nests everything under a `User Data`
+ * directory in local AppData. Omitting a platform's segments marks the fork as
+ * unavailable there.
+ */
+const chromiumSource = (input: {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
   readonly keychainService: string;
   readonly keychainAccount: string;
-}
+  readonly macSegments: ReadonlyArray<string>;
+  readonly windowsSegments?: ReadonlyArray<string>;
+  /**
+   * Forks that sit under roaming `%APPDATA%` with no `User Data` level. Opera
+   * is the one that does this; everything else follows the local-AppData
+   * convention above.
+   */
+  readonly windowsRoamingSegments?: ReadonlyArray<string>;
+  readonly linuxSegments?: ReadonlyArray<string>;
+}): BrowserImportSourceDefinition => ({
+  id: input.id,
+  name: input.name,
+  engine: "chromium",
+  platforms: [
+    "darwin" as NodeJS.Platform,
+    ...(input.windowsSegments || input.windowsRoamingSegments ? ["win32" as NodeJS.Platform] : []),
+    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+  ],
+  keychainService: input.keychainService,
+  keychainAccount: input.keychainAccount,
+  userDataDirectory: (context) => {
+    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      if (input.windowsRoamingSegments) {
+        return context.appData
+          ? context.path.join(context.appData, ...input.windowsRoamingSegments)
+          : undefined;
+      }
+      return input.windowsSegments && context.localAppData
+        ? context.path.join(context.localAppData, ...input.windowsSegments, "User Data")
+        : undefined;
+    }
+    return input.linuxSegments
+      ? context.path.join(context.home, ".config", ...input.linuxSegments)
+      : undefined;
+  },
+});
 
 export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
-  {
+  chromiumSource({
+    id: "chrome",
+    name: "Chrome",
+    keychainService: "Chrome Safe Storage",
+    keychainAccount: "Chrome",
+    macSegments: ["Google", "Chrome"],
+    windowsSegments: ["Google", "Chrome"],
+    linuxSegments: ["google-chrome"],
+  }),
+  chromiumSource({
+    id: "edge",
+    name: "Microsoft Edge",
+    keychainService: "Microsoft Edge Safe Storage",
+    keychainAccount: "Microsoft Edge",
+    macSegments: ["Microsoft Edge"],
+    windowsSegments: ["Microsoft", "Edge"],
+    linuxSegments: ["microsoft-edge"],
+  }),
+  chromiumSource({
+    id: "brave",
+    name: "Brave",
+    keychainService: "Brave Safe Storage",
+    keychainAccount: "Brave",
+    macSegments: ["BraveSoftware", "Brave-Browser"],
+    windowsSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSegments: ["BraveSoftware", "Brave-Browser"],
+  }),
+  chromiumSource({
+    id: "vivaldi",
+    name: "Vivaldi",
+    keychainService: "Vivaldi Safe Storage",
+    keychainAccount: "Vivaldi",
+    macSegments: ["Vivaldi"],
+    windowsSegments: ["Vivaldi"],
+    linuxSegments: ["vivaldi"],
+  }),
+  chromiumSource({
+    id: "opera",
+    name: "Opera",
+    keychainService: "Opera Safe Storage",
+    keychainAccount: "Opera",
+    macSegments: ["com.operasoftware.Opera"],
+    windowsRoamingSegments: ["Opera Software", "Opera Stable"],
+    linuxSegments: ["opera"],
+  }),
+  // Arc and Helium ship macOS-only builds.
+  chromiumSource({
+    id: "arc",
+    name: "Arc",
+    keychainService: "Arc Safe Storage",
+    keychainAccount: "Arc",
+    macSegments: ["Arc", "User Data"],
+  }),
+  chromiumSource({
     id: "helium",
     name: "Helium",
-    platforms: ["darwin"],
-    userDataDirectory: ({ path, home }) =>
-      path.join(home, "Library", "Application Support", "net.imput.helium"),
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
+    macSegments: ["net.imput.helium"],
+  }),
+  {
+    id: "firefox",
+    name: "Firefox",
+    engine: "firefox",
+    platforms: ["darwin", "win32", "linux"],
+    userDataDirectory: (context) => {
+      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
+      if (context.platform === "win32") {
+        return context.appData
+          ? context.path.join(context.appData, "Mozilla", "Firefox")
+          : undefined;
+      }
+      return context.path.join(context.home, ".mozilla", "firefox");
+    },
   },
 ];
 
+/**
+ * Chromium stores the database as `Cookies` under the profile directory;
+ * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
+ * may already be absolute.
+ */
 export const cookieDatabasePath = (
   definition: BrowserImportSourceDefinition,
-  paths: SourcePaths,
+  context: BrowserImportPathContext,
   profileDirectory: string,
-): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+): string | undefined => {
+  const root = definition.userDataDirectory(context);
+  if (root === undefined) return undefined;
+  const fileName = definition.engine === "firefox" ? "cookies.sqlite" : "Cookies";
+  return context.path.isAbsolute(profileDirectory)
+    ? context.path.join(profileDirectory, fileName)
+    : context.path.join(root, profileDirectory, fileName);
C
cursorBotAug 16, 2026

Wrong Chromium cookie database path

High Severity

cookieDatabasePath always joins the Chromium profile directory with Cookies, but current Chrome, Edge, Brave, Vivaldi, and Arc keep the live database under Network/Cookies. Helium (and possibly some Opera builds) still use the legacy root path, so a single hardcoded filename breaks import for the newly added mainstream forks—either as readFailed, or as a silent zero-cookie success if a stale empty root Cookies file remains.

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

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 1ce9a337 · right 555–558

Original diff
@@ -250,28 +492,104 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
   return yield* isProcessAlive(pid);
 });
 
+/** Windows sharing and lock violations are translated by libuv to `Busy`. */
+export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
+  error.reason._tag === "Busy";
+
+/**
+ * Whether a regular lock file is actually held by a running process. Windows
+ * `parent.lock` is opened with no sharing, so it persists on disk after the
+ * process exits and `stat` always succeeds; only trying to open it for write
+ * reveals an active holder, which surfaces as `Busy`.
+ */
+const isLockHeld = Effect.fnUntraced(function* (lockPath: string, platform: NodeJS.Platform) {
+  if (platform !== "win32") return yield* entryExists(lockPath);
+  // Permission failures are distinct: they do not prove a browser owns the
+  // lock, so they must not hide the source as running.
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
+    Effect.as(false),
+    Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
+    Effect.orElseSucceed(() => false),
+    Effect.scoped,
+  );
+});
+
+/**
+ * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
+ * live owner. Firefox writes this symlink beside the profile while it runs and
+ * unlinks it on a clean exit, so a dangling one is either live or a crash.
+ */
+export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
+  target: string,
+  isProcessAlive: ProcessLivenessProbe,
+) {
+  const separator = target.lastIndexOf(":");
+  if (separator < 0) return true;
+  // A `+` marks an fcntl-holding owner; the pid follows either way.
+  const pidText = target.slice(separator + 1).replace(/^\+/, "");
+  if (!/^\d+$/.test(pidText)) return true;
+  const pid = Number(pidText);
+  if (!Number.isSafeInteger(pid) || pid <= 0) return true;
+  return yield* isProcessAlive(pid);
+});
+
+/**
+ * Whether Firefox holds a profile.
+ *
+ * Firefox leaves two kinds of lock behind, and they mean different things.
+ * On Linux the `lock` symlink (target `<ip>:+<pid>`) is removed on a clean
+ * exit, so its presence is evidence — provided the pid it names is alive. But
+ * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files
+ * held with fcntl or a Windows handle and are *deliberately left on disk*
+ * after exit, as a last-used marker. Treating those as proof of a running
+ * browser blocks every import after Firefox has been used once. Node exposes
+ * no fcntl probe, so on POSIX those files carry no signal here; on Windows the
+ * held handle denies our open, which `isLockHeld` reads as `Busy`.
+ */
+const firefoxProfileIsHeld = Effect.fnUntraced(function* (
+  directory: string,
+  context: BrowserImportPathContext,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  if (context.platform === "win32") {
+    return yield* isLockHeld(context.path.join(directory, "parent.lock"), context.platform);
+  }
+  return yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe(

apps/desktop/src/preview/BrowserImport/Sources.ts · right 555–558

M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/Sources.ts:555

On macOS, firefoxProfileIsHeld returns false while Firefox is running, so unavailableReason allows importing a live, mid-write cookie database. The non-Windows branch only reads lock, but macOS uses .parentlock; handle the macOS lock marker in this branch.

diff
1 if (context.platform === "win32") {
2 return yield* isLockHeld(context.path.join(directory, "parent.lock"), context.platform);
3 }
4+ if (context.platform === "darwin") {
5+ return yield* entryExists(context.path.join(directory, ".parentlock"));
6+ }
7 return yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe(
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around lines 555-558:
2
3On macOS, `firefoxProfileIsHeld` returns `false` while Firefox is running, so `unavailableReason` allows importing a live, mid-write cookie database. The non-Windows branch only reads `lock`, but macOS uses `.parentlock`; handle the macOS lock marker in this branch.