feat(desktop): import cookies from Safari#7262

merged21
main
browser-import-safari
openedAug 16, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
35 unresolved threads · Jump to file
Selected thread: apps/desktop/src/electron/ElectronShell.ts:25 — ### FDA deep link uses wrong anchor **Medium Severity** <!-- DESCRIPTION START --> The Full Disk Access deep link pairs the Ventura `PrivacySecurity.extension` pane with query `Privacy_AllFilesAccess`, carried over from the legacy `preference.security` URL. The documented anchor for this pane is `Privacy_AllFiles`. An unrecognized anchor typically opens Privacy & Security without jumping to Full Disk Access, so `Open System Settings` can land users on the wrong screen. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: 93220d4e-69aa-492b-837c-13ff593f6efa --> <!-- LOCATIONS START apps/desktop/src/electron/ElectronShell.ts#L21-L25 LOCATIONS END --> <div><a href="https://cursor.com/open?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjI5MjQ2MzExLWMyMWMtNGFhMS04ZGRkLWIxNzc3OTMxODg3NiIsImVuY3J5cHRpb25LZXkiOiJxQ3ZWeUNLbHp0WTFnQ0JLRHJwaGVkM2hoTl9QcGJ5NzZHNDllWTdvdUdrIiwiYnJhbmNoIjoiYnJvd3Nlci1pbXBvcnQtc2FmYXJpIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwcm92aWRlciI6ImdpdGh1YiJ9fQ" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-cursor-light.png"><img alt="Fix in Cursor" width="115" height="28" src="https://cursor.com/assets/images/fix-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjI5MjQ2MzExLWMyMWMtNGFhMS04ZGRkLWIxNzc3OTMxODg3NiIsImVuY3J5cHRpb25LZXkiOiJxQ3ZWeUNLbHp0WTFnQ0JLRHJwaGVkM2hoTl9QcGJ5NzZHNDllWTdvdUdrIiwiYnJhbmNoIjoiYnJvd3Nlci1pbXBvcnQtc2FmYXJpIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwck51bWJlciI6NzI2MiwiY29tbWl0U2hhIjoiNDY0MzBkMGUxODA0ZDhkNDM1MGJhOTg2MjY5ZGJlMjc3Y2IxNzY5MSIsInByb3ZpZGVyIjoiZ2l0aHViIn19" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-web-light.png"><img alt="Fix in Web" width="99" height="28" src="https://cursor.com/assets/images/fix-in-web-dark.png"></picture></a></div> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 46430d0e1804d8d4350ba986269dbe277cb17691. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
0 / 22 viewed22 files changedLine totals unavailable

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 7c940bb6 · right 34

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
C
cursorBotAug 17, 2026

Wrong cookie record header size

Low Severity

COOKIE_RECORD_HEADER_SIZE is 48, but the fixed record layout runs through the creation f64 at bytes 48–55, so the header is 56 bytes. The comment and the test encoder both describe 56. The looser minimum lets truncated records past the malformation checks this parser otherwise enforces.

Additional Locations (1)

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

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 05fc74f3 · right 34

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
M
macroscopeappBotAug 17, 2026

🟡 Medium BrowserImport/SafariCookies.ts:34

A truncated 48–55-byte record can pass validation and be returned as a fabricated cookie instead of producing readFailed. COOKIE_RECORD_HEADER_SIZE must be 56, and string offsets must also be rejected when they are below 56, because the f64 creation field occupies offsets 48–55.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around line 34:
2
3A truncated 48–55-byte record can pass validation and be returned as a fabricated cookie instead of producing `readFailed`. `COOKIE_RECORD_HEADER_SIZE` must be `56`, and string offsets must also be rejected when they are below `56`, because the `f64 creation` field occupies offsets 48–55.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 31db6ab4 · right 114

Original diff
@@ -0,0 +1,146 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Safari cookies: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+    if (page.length < 12) continue;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + 48 > page.length) continue;
+      const cookie = page.subarray(cookieStart);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        url: `${secure ? "https" : "http"}://${host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
C
cursorBotAug 16, 2026

SameSite flags ignored

Medium Severity

Safari’s cookie flags field encodes SameSite in bits 3–5 (0x20 none, 0x28 lax, 0x38 strict), but every imported cookie is hard-coded to lax. Cross-site None sessions can fail after import, and Strict cookies are widened to lax.

Reviewed by Cursor Bugbot for commit 31db6ab42c3d74b427087748350a1f7156043b26. Configure here.

J
Julius MarmingeSep 4, 2026

Leaving lax. The reference descriptions of bits 3–5 disagree (creachadair's gist says 4/5/7 = None/Lax/Strict; interstateone/BinaryCookies calls them unknown), and the histogram of those bits on a real Safari 27 jar does not match either encoding cleanly. Guessing wrong toward none widens every cookie; lax is the browser default. Corrected the code comment in 9611ffbea so it no longer claims the format has no such field.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 78c57bad · right 202

Original diff
@@ -0,0 +1,216 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 56;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const cookie = page.subarray(cookieStart, cookieStart + recordSize);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some(
+          (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length,
+        )
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        // A bare IPv6 host is invalid in a URL authority, so bracket it the
+        // way the Chromium reader's cookieScope does.
+        url: `${secure ? "https" : "http"}://${host.includes(":") ? `[${host}]` : host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
+      });
+    }
+  }
+
+  return cookies;
+}
+
+/**
+ * Whether a filesystem error is the OS refusing access.
+ *
+ * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
+ * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
+ * too — otherwise a Full Disk Access refusal is reported as a generic read
+ * failure and the user is never told what to grant.
+ */
+export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
+  if (error.reason._tag === "PermissionDenied") return true;
+  const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
+  return code === "EPERM" || code === "EACCES";
+};
+
+export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const contents = yield* fileSystem.readFile(cookiePath).pipe(
+    Effect.mapError((cause) => {
+      // TCC denies the read even though the file exists — a permission the user
+      // grants in System Settings rather than a missing browser. macOS never
+      // prompts for Full Disk Access, so there is no dialog to wait on; the
+      // read just fails, and it fails with EPERM, which Effect surfaces as an
+      // `Unknown` system error rather than `PermissionDenied` (that is EACCES).
+      return new SafariCookieReadError({
+        reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
+        cookieDatabasePath: cookiePath,
+        cause,
+      });
+    }),
+  );
+  // The parser throws on a malformed jar; catch it here so callers see a typed
+  // failure rather than a defect.
+  return yield* Effect.try({
+    try: () => parseBinaryCookies(Buffer.from(contents)),
M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/SafariCookies.ts:202

readSafariCookies successfully imports a truncated/partial jar while silently omitting cookies from complete pages left as trailing bytes. parseBinaryCookies validates each declared page but never checks that the final pageStart equals buffer.length; add that validation so trailing data causes readFailed instead of a partial import.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around line 202:
2
3`readSafariCookies` successfully imports a truncated/partial jar while silently omitting cookies from complete pages left as trailing bytes. `parseBinaryCookies` validates each declared page but never checks that the final `pageStart` equals `buffer.length`; add that validation so trailing data causes `readFailed` instead of a partial import.
J
Julius MarmingeSep 2, 2026

Fixed in 453644b15e. The parser now requires the bytes after the declared pages to be exactly what Safari writes — nothing, the 8-byte checksum, or checksum + u32 length + that many property-list bytes — and refuses anything else, so a jar with undeclared trailing pages is readFailed rather than a partial import. Tests cover the valid trailers and both the extra-page and bad-length cases.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1a6149b7 · right 528

Original diff
@@ -517,6 +519,14 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
  * files, and the answer changes while the app is running (quitting the browser
  * clears `browserRunning`), so a value cached at mount would go stale.
  */
+/**
+ * Opens System Settings → Privacy & Security → Full Disk Access. The scheme is
+ * unchanged from the old System Preferences and still resolves on Ventura and
+ * later.
+ */
+const FULL_DISK_ACCESS_SETTINGS_URL =
+  "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFilesAccess";
C
cursorBotAug 17, 2026

Wrong Full Disk Access URL

Medium Severity

FULL_DISK_ACCESS_SETTINGS_URL uses the query fragment Privacy_AllFilesAccess. Documented macOS deep links for Full Disk Access use Privacy_AllFiles. Even after the scheme is allowlisted, this URL may open the wrong pane or fail to land on Full Disk Access.

Reviewed by Cursor Bugbot for commit 1a6149b764f4000a565e3f5e0abca29797a769de. Configure here.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 88cf9f73 · right 65–66

Original diff
@@ -0,0 +1,130 @@
+// @effect-diagnostics nodeBuiltinImport:off
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as NodeFSP from "node:fs/promises";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export type SafariCookieReadFailure = "needsFullDiskAccess" | "readFailed";
+
+export class SafariCookieReadError extends Error {
+  readonly reason: SafariCookieReadFailure;
+
+  constructor(reason: SafariCookieReadFailure) {
+    super(`Could not read Safari cookies: ${reason}`);
+    this.name = "SafariCookieReadError";
+    this.reason = reason;
+  }
+}
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError("readFailed");
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    const page = buffer.subarray(pageStart, pageStart + pageSize);

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 65–66

M
macroscopeappBotAug 16, 2026

🟡 Medium BrowserImport/SafariCookies.ts:65

A truncated page is accepted as a successful import, so cookies after the truncated boundary are silently omitted. Buffer.subarray clamps pageStart + pageSize to the available buffer length; validate the declared page range and throw SafariCookieReadError("readFailed") before creating the page slice.

✅ Resolved in 3df1bbe3d13a59d55a7fd6213ee9d1e27e66bfbd

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision a8129c16 · right 393

Original diff
@@ -350,14 +380,17 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
 ): 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.
+  // Both engines that hold a lock leave one for as long as an instance holds a
+  // profile, which is far cheaper and more targeted than scanning the process
+  // table. Safari keeps none, and unlike the others writes its jar atomically,
+  // so a running instance is not a hazard there.
   //
-  // 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.
+  // Chromium and Firefox 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 === "safari") return false;
C
cursorBotAug 16, 2026

FDA denial reported as not installed

High Severity

Safari’s userDataDirectory is the TCC-gated Cookies folder. isSourceInstalled treats any probe failure—including PermissionDenied—as missing, so unavailableReason returns notInstalled and importCookies bails before readSafariCookies. Users without Full Disk Access never reach the needsFullDiskAccess path and see “Not installed” instead of the FDA remedy.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit a8129c16f241155f34b7753fffe6c2f2d989201c. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 6b977b68 · right 187

Original diff
@@ -165,6 +165,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
     keychainAccount: "Helium",
     macSegments: ["net.imput.helium"],
   }),
+  {
+    // Safari keeps one cookie jar for the whole app rather than per-profile,
+    // and it sits inside the app container that Full Disk Access gates.
+    id: "safari",
+    name: "Safari",
+    engine: "safari",
+    platforms: ["darwin"],
+    userDataDirectory: (context) =>
+      context.platform === "darwin"
+        ? context.path.join(
+            context.home,
+            "Library",
+            "Containers",
+            "com.apple.Safari",
+            "Data",
+            "Library",
+            "Cookies",
+          )
+        : undefined,
+  },
C
cursorBotAug 17, 2026

Safari profiles cookies ignored

Medium Severity

Safari has been modeled as a single jar under Library/Cookies, with listSourceProfiles always returning one synthetic . profile. Since Safari 17, additional profiles keep cookies under Library/WebKit/WebsiteDataStore/&lt;uuid&gt;/Cookies/. Those jars are never listed or read, so imports miss that profile’s sessions, and a machine that only uses a named profile can look like Safari isn’t installed.

Additional Locations (1)

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

J
Julius MarmingeSep 4, 2026

Intentional for v1: only the container jar is read. Documented in docs/user/browser-import.md (423564cbb) so users with Safari profiles know what to expect.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 2fffb548 · right 146–162

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const cookie = page.subarray(cookieStart, cookieStart + recordSize);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some((offset) => offset >= cookie.length)
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        url: `${secure ? "https" : "http"}://${host}${path || "/"}`,
+        name,
+        value,
+        domain,

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 146–162

M
macroscopeappBotAug 17, 2026

🟠 High BrowserImport/SafariCookies.ts:146

Host-only Safari cookies are imported with ImportedCookie.domain set to the bare host, so Electron treats them as domain cookies and widens them to subdomains (and rejects __Host- cookies). Set domain to undefined unless the source domain starts with ..

No longer relevant as of d7ea73ac1487159ef77bb8662246cf0700efdac3

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 06d5c22c · right 45–47

Original diff
@@ -0,0 +1,179 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 45–47

M
macroscopeappBotAug 16, 2026

BrowserImport/SafariCookies.ts:47

SafariCookieReadError carries only reason and cause, so a TCC denial and a corrupt jar both log without naming the file that was read. The path is available at every construction site (readSafariCookies receives cookiePath, and parseBinaryCookies can take it as a parameter), and the sibling ChromiumCookieReadError already carries cookieDatabasePath for exactly this reason. Consider adding a cookiePath: Schema.String structural attribute and deriving the message from it, e.g. Could not read Safari cookies at ${this.cookiePath}: ${this.reason}.

No longer relevant as of a8129c16f241155f34b7753fffe6c2f2d989201c

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision dbaf7120 · right 197–206

Original diff
@@ -0,0 +1,208 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const cookie = page.subarray(cookieStart, cookieStart + recordSize);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some((offset) => offset >= cookie.length)
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        url: `${secure ? "https" : "http"}://${host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
+      });
+    }
+  }
+
+  return cookies;
+}
+
+/**
+ * Whether a filesystem error is the OS refusing access.
+ *
+ * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
+ * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
+ * too — otherwise a Full Disk Access refusal is reported as a generic read
+ * failure and the user is never told what to grant.
+ */
+export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
+  if (error.reason._tag === "PermissionDenied") return true;
+  const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
+  return code === "EPERM" || code === "EACCES";
+};
+
+export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const contents = yield* fileSystem.readFile(cookiePath).pipe(
+    Effect.mapError((cause) => {
+      // TCC denies the read even though the file exists — a permission the user
+      // grants in System Settings rather than a missing browser. macOS never
+      // prompts for Full Disk Access, so there is no dialog to wait on; the
+      // read just fails, and it fails with EPERM, which Effect surfaces as an
+      // `Unknown` system error rather than `PermissionDenied` (that is EACCES).
+      return new SafariCookieReadError({
+        reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
+        cookieDatabasePath: cookiePath,
+        cause,
+      });
+    }),
+  );
+  // The parser throws on a malformed jar; catch it here so callers see a typed
+  // failure rather than a defect.
+  return yield* Effect.try({
+    try: () => parseBinaryCookies(Buffer.from(contents)),
+    catch: (cause) =>
+      isSafariCookieReadError(cause)
+        ? cause
+        : new SafariCookieReadError({
+            reason: "readFailed",
+            cookieDatabasePath: cookiePath,
+            cause,
+          }),

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 197–206

M
macroscopeappBotAug 17, 2026

BrowserImport/SafariCookies.ts:206

Parse failures still lose the path they occurred on: parseBinaryCookies throws without cookieDatabasePath, and the pass-through branch returns that error unchanged even though cookiePath is in hand here, so a corrupt jar logs Could not read Safari cookies: readFailed. with no file named. Consider threading the path into the parser (e.g. parseBinaryCookies(buffer, cookiePath) used at each throw new SafariCookieReadError({ reason: "readFailed", cookieDatabasePath })), which also lets cookieDatabasePath become required like ChromiumCookieReadError's.

No longer relevant as of 46430d0e1804d8d4350ba986269dbe277cb17691

apps/web/src/localApi.ts · Earlier revision b99d5dd2 · right 36

Original diff
@@ -30,6 +30,15 @@ function createBrowserLocalApi(): LocalApi {
 
         window.open(url, "_blank", "noopener,noreferrer");
       },
+      // Only the desktop shell can reach the OS; the web build (and older
+      // desktop shells that predate this method) have nothing to open.
+      openSystemSettings: async (pane) => {
+        if (!window.desktopBridge?.openSystemSettings) return;
M
macroscopeappBotAug 29, 2026

Resolving instead of throwing here makes the "Open System Settings" button a silent no-op on any shell whose preload predates openSystemSettings: the promise fulfils, so the .catch in IntegrationsSettings.tsx never fires and the "Could not open System Settings" toast never shows. The other guard there doesn't cover it either — readLocalApi() only returns undefined when window is undefined, so the !localApi branch is unreachable in the renderer.

Throwing keeps a single feedback path (the caller's toast telling the user where to click), matching openExternal above.

suggestion
1 if (!window.desktopBridge?.openSystemSettings) {
2 throw new Error("Unable to open System Settings.");
3 }

Posted via Macroscope — UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 0369f309c0 on #7262. An unavailable System Settings bridge rejects with the established error, reaching the caller toast. A focused missing-bridge test covers this path.

apps/web/src/components/settings/BrowserImportWizard.tsx · Earlier revision b99d5dd2 · right 115–125

Original diff
@@ -109,11 +112,30 @@ export function BrowserImportWizard({
       .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
   };
 
+  const recheckFullDiskAccess = () => {
+    void onRefreshSource()
+      .then((refreshed) => {
+        if (refreshed) {
+          setSource(refreshed);
+          setSourceProfileDirectory(refreshed.profiles[0]?.directory ?? sourceProfileDirectory);
+        }
+        setStep(refreshedSourceStep(refreshed));
+      })
+      .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
+  };

apps/web/src/components/settings/BrowserImportWizard.tsx · right 115–125

M
macroscopeappBotAug 29, 2026

recheckFullDiskAccess runs an async round-trip with no step change, unlike its sibling recheckAfterQuit which sets { step: "checking" } first. Pressing "I've turned it on" leaves the same screen mounted with no in-flight state, and when access is still denied refreshedSourceStep returns fullDiskAccess again — so the button looks dead in exactly the case the user is most likely to hit (toggling the checkbox requires relaunching the app on macOS).

Smallest fix: set an in-flight step before awaiting, as the quit path does, and give CheckingStep neutral copy (or a description prop) since "Checking whether the browser has closed." is wrong for this screen; a distinct message when the refresh still reports needsFullDiskAccess would also make the outcome visible.

Also, this path resets the source profile to refreshed.profiles[0] rather than going through refreshedSourceProfileDirectory, which the quit path uses to preserve the user's selection when the refreshed source still lists it.

Posted via Macroscope — UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in cfad01d520 on #7262. Full Disk Access refresh enters an explicit checking state with accurate copy, preserves the selected source profile through the shared helper, and shows a still-required message after denial. Initial permission and retry-import paths remain intact.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1a6149b7 · right 849–853

Original diff
@@ -836,6 +846,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
           canCreateProfile={!atProfileLimit}
           onImport={(input) => runWizardImport(importSource, input)}
           onRefreshSource={() => refreshImportSource(importSource.id)}
+          onOpenFullDiskAccessSettings={() =>
+            void readLocalApi()
+              ?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
+              .catch(() => undefined)
+          }

apps/web/src/components/settings/IntegrationsSettings.tsx · right 849–853

M
macroscopeappBotAug 17, 2026

This still can't open anything, and after the move into BrowserImportWizard the button is now a complete no-op (it no longer dismisses the dialog either). ElectronShell.parseSafeExternalUrl (apps/desktop/src/electron/ElectronShell.ts) allows only http:, https: and the remote editor schemes, so x-apple.systempreferences: is rejected, desktopBridge.openExternal resolves false, localApi.shell.openExternal throws "Unable to open link.", and .catch(() => undefined) discards it. The user clicks the step's action and nothing happens or is said.

Smallest fix: allow the x-apple.systempreferences: scheme in SAFE_EXTERNAL_PROTOCOLS (darwin only), and surface a failure here instead of swallowing it — e.g. a toast in the catch, as ThreadTerminalDrawer does — so the step degrades to readable instructions rather than a dead control.

Posted via Macroscope — UI Consistency

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 675447ae · right 921–927

Original diff
@@ -882,6 +901,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
           </AlertDialogFooter>
         </AlertDialogPopup>
       </AlertDialog>
+      <AlertDialog
+        open={fullDiskAccessSource !== null}
+        onOpenChange={(open) => {
+          if (!open) setFullDiskAccessSource(null);
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>T3 Code needs Full Disk Access</AlertDialogTitle>
+            <AlertDialogDescription>
+              {fullDiskAccessSource ?? "Safari"} keeps its cookies in a protected folder. Turn on
+              Full Disk Access for T3 Code in System Settings, then run the import again. macOS
+              doesn’t ask for this permission — you add the app yourself.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose render={<Button variant="outline" />}>Not now</AlertDialogClose>
+            <AlertDialogClose
+              render={<Button />}
+              onClick={() => {
+                void readLocalApi()
+                  ?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
+                  .catch(() => undefined);
+              }}

apps/web/src/components/settings/IntegrationsSettings.tsx · right 921–927

M
macroscopeappBotAug 17, 2026

The dialog's primary action can't open anything today: ElectronShell.parseSafeExternalUrl (apps/desktop/src/electron/ElectronShell.ts) only allows http:, https: and the editor remote schemes, so x-apple.systempreferences: is rejected, desktopBridge.openExternal resolves false, localApi.shell.openExternal throws "Unable to open link.", and .catch(() => undefined) swallows it. AlertDialogClose still dismisses the dialog, so the user is left with a button that closes the sheet and does nothing — the one recovery path this dialog exists to offer.

Smallest fix: add "x-apple.systempreferences:" to SAFE_EXTERNAL_PROTOCOLS (macOS-only is fine) and, so a future block isn't invisible, replace the bare .catch(() => undefined) with the existing failure toast telling the user to open System Settings → Privacy & Security → Full Disk Access manually.

Posted via Macroscope — UI Consistency

apps/web/src/components/settings/BrowserImportWizard.tsx · Earlier revision b056322b · right 141

Original diff
@@ -109,15 +113,38 @@ export function BrowserImportWizard({
       .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
   };
 
+  const recheckFullDiskAccess = () => {
+    setStep({ step: "checking", check: "fullDiskAccess" });
+    void onRefreshSource()
+      .then((refreshed) => {
+        if (refreshed) {
+          setSource(refreshed);
+          setSourceProfileDirectory((current) =>
+            refreshedSourceProfileDirectory(current, refreshed),
+          );
+        }
+        setStep(fullDiskAccessRecheckStep(refreshed));
+      })
+      .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
+  };
+
   return (
     <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
       <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>
         {step.step === "quit" ? (
           <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "fullDiskAccess" ? (
+          <FullDiskAccessStep
+            source={source}
+            onCancel={onClose}
+            onOpenSettings={onOpenFullDiskAccessSettings}
+            onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
M
macroscopeappBotAug 29, 2026

The two onGranted branches give different feedback for the same user action. recheckFullDiskAccess routes through fullDiskAccessRecheckStep, which sets checked: true and renders the "Full Disk Access is still required" panel; the resume === "import" branch calls runImport, whose failure goes through outcomeToStep and returns { step: "fullDiskAccess", resume: "import" } with no checked. So on the import-retry path the user presses "I've turned it on", sees a brief spinner, and lands back on an unchanged screen with nothing saying access is still denied — which is the common case, since granting Full Disk Access on macOS only takes effect after the app relaunches.

Smallest fix: mark the returned step as checked when the retry came from this screen, e.g. give runImport an afterAccessGrant flag that maps a fullDiskAccess outcome to { ...next, checked: true }, and call () => runImport(true) here. (Alternatively, add checked: true to the needsFullDiskAccess branch of outcomeToStep, which also shows the panel on first arrival.)

Posted via Macroscope — UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 4799270451 on #7262. An import attempt denied Full Disk Access now returns checked:true, showing the same still-required feedback as a permission refresh. The state-transition test covers it.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 7c940bb6 · right 100–103

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 100–103

M
macroscopeappBotAug 17, 2026

🟡 Medium BrowserImport/SafariCookies.ts:100

A page with an invalid header signature is accepted and its cookies are imported instead of failing with readFailed. parseBinaryCookies reads cookieCount without checking the required big-endian 0x00000100 signature; validate that field before parsing the page body.

No longer relevant as of 675447ae50083288dda5f455f8b164b3d6902a5a

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 7c940bb6 · right 65–68

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 65–68

M
macroscopeappBotAug 17, 2026

🟡 Medium BrowserImport/SafariCookies.ts:65

A truncated record with no NUL terminator is accepted and imported, with the affected field containing all remaining record bytes instead of producing the promised readFailed error. readCString falls back to buffer.length when indexOf returns -1; throw SafariCookieReadError when no terminator is found within the record.

No longer relevant as of 675447ae50083288dda5f455f8b164b3d6902a5a

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 51247f43 · right 155

Original diff
@@ -0,0 +1,191 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const cookie = page.subarray(cookieStart, cookieStart + recordSize);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some((offset) => offset >= cookie.length)
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        url: `${secure ? "https" : "http"}://${host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
+      });
C
cursorBotAug 17, 2026

Host-only cookies widened

High Severity

Safari always sets domain on imported cookies, including host-only ones (no leading dot). Chromium and Firefox go through cookieScope, which leaves domain undefined so Electron keeps them host-only. Passing domain makes Electron treat them as domain cookies (re-adding the dot), which widens scope to subdomains and causes __Host- cookies to fail at session.cookies.set.

Reviewed by Cursor Bugbot for commit 51247f434e35aa6abc8b4410d17b50ae0bbf73b9. Configure here.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision d66d4fbf · right 45–47

Original diff
@@ -0,0 +1,179 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 48;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 45–47

M
macroscopeappBotAug 16, 2026

BrowserImport/SafariCookies.ts:47

SafariCookieReadError carries only reason and cause, so a TCC denial and a corrupt jar both log without naming the file that was read. The path is available at every construction site (readSafariCookies receives cookiePath, and parseBinaryCookies can take it as a parameter), and the sibling ChromiumCookieReadError already carries cookieDatabasePath for exactly this reason. Consider adding a cookiePath: Schema.String structural attribute and deriving the message from it, e.g. Could not read Safari cookies at ${this.cookiePath}: ${this.reason}.

No longer relevant as of a8129c16f241155f34b7753fffe6c2f2d989201c

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 69182179 · right 102–102

Original diff
@@ -0,0 +1,247 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 56;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4;

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 102–102

M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/SafariCookies.ts:102

parseBinaryCookies rejects valid Safari pages, so ordinary one-cookie jars fail to import. offsetTableEnd should start at byte 8, but the current 12 + cookieCount * 4 makes a record offset of 12 fail the cookieStart < offsetTableEnd check; use 8 + cookieCount * 4 instead.

suggestion
1 const offsetTableEnd = 8 + cookieCount * 4;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around line 102:
2
3`parseBinaryCookies` rejects valid Safari pages, so ordinary one-cookie jars fail to import. `offsetTableEnd` should start at byte 8, but the current `12 + cookieCount * 4` makes a record offset of 12 fail the `cookieStart < offsetTableEnd` check; use `8 + cookieCount * 4` instead.

apps/web/src/components/settings/browserImportWizard.logic.ts · Earlier revision e6497eef · right 62–62

Original diff
@@ -58,6 +59,7 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep {
   // other failure surfaces on the blocked screen, which offers a retry when
   // one could help.
   if (outcome.reason === "browserRunning") return { step: "quit" };
+  if (outcome.reason === "needsFullDiskAccess") return { step: "fullDiskAccess" };

apps/web/src/components/settings/browserImportWizard.logic.ts · right 62–62

M
macroscopeappBotAug 29, 2026

needsFullDiskAccess is now also a BrowserImportUnavailableReason, but only outcomeToStep routes it to the new screen. initialWizardStep still falls through to { step: "blocked", reason: source.unavailable }, and isRetryableReason returns false for it — so a source listed as unavailable for this reason opens on a dead-end screen whose copy says "…then retry" with no retry and no "Open System Settings" button, while the identical condition discovered at import time gets the recoverable step. It's latent today (the desktop only reports unsupportedPlatform/notInstalled/browserRunning pre-flight), but the two entry points should agree, the same way browserRunning maps to quit in both.

diff
1 if (source.unavailable === "browserRunning") return { step: "quit" };
2+ if (source.unavailable === "needsFullDiskAccess") return { step: "fullDiskAccess" };

Posted via Macroscope — UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in the updated #7262. Initial Full Disk Access requirements route into the permission step and return to configuration after refresh; permission failure during a selected import preserves its retry path. Opening settings failures show actionable feedback rather than disappearing. Focused state-transition tests pass.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 47e56cdf · right 959

Original diff
@@ -882,6 +913,50 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
           </AlertDialogFooter>
         </AlertDialogPopup>
       </AlertDialog>
+      <AlertDialog
+        open={pendingFullDiskAccessImport !== null}
+        onOpenChange={(open) => {
+          if (!open) setPendingFullDiskAccessImport(null);
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Let T3 Code read Safari's cookies</AlertDialogTitle>
+            <AlertDialogDescription>
+              Safari keeps your cookies somewhere only apps with Full Disk Access can reach. Turn
+              that on for T3 Code in System Settings, then come back and finish the import.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <Button
+              variant="outline"
+              onClick={() =>
+                void readLocalApi()
+                  ?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
+                  .catch(() => undefined)
+              }
+            >
+              Open System Settings
+            </Button>
+            <Button
+              onClick={() => {
+                const pending = pendingFullDiskAccessImport;
+                setPendingFullDiskAccessImport(null);
+                if (pending) {
+                  runImport(
+                    pending.source,
+                    pending.sourceProfileDirectory,
+                    pending.targetProfileId,
+                    pending.targetName,
+                  );
+                }
+              }}
+            >
+              I've turned it on
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogPopup>
+      </AlertDialog>
C
cursorBotAug 17, 2026

FDA retry needs restart

Medium Severity

The Full Disk Access dialog keeps the failed import and offers “I've turned it on” to retry immediately. macOS Full Disk Access only applies after the app quits and relaunches, which also clears this in-memory pending state. Immediate retries keep failing with needsFullDiskAccess, and a real relaunch drops the held import so the button cannot complete the flow.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 47e56cdf71a05215bc9906432d81bb772477b504. Configure here.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision ffc45f7f · right 105–114

Original diff
@@ -0,0 +1,236 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 56;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 105–114

M
macroscopeappBotSep 2, 2026

🟡 Medium BrowserImport/SafariCookies.ts:105

Malformed pages can place cookieStart inside the page header/offset table or overlap a previously parsed record, allowing those bytes to be interpreted as a fabricated cookie. The check at line 107 only verifies page bounds; require cookieStart to start after the complete offset table and reject overlapping record ranges before parsing the record.

diff
1 for (let index = 0; index < cookieCount; index += 1) {
2+ const cookieTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4;
3+ const recordRanges: Array<readonly [number, number]> = [];
4 const cookieStart = page.readUInt32LE(8 + index * 4);
5- if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
6+ if (cookieStart < cookieTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
7 throw new SafariCookieReadError({ reason: "readFailed" });
8 }
9@@
10- if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
11+ if (
12+ recordSize < COOKIE_RECORD_HEADER_SIZE ||
13+ cookieStart + recordSize > page.length ||
14+ recordRanges.some(([start, end]) => cookieStart < end && cookieStart + recordSize > start)
15+ ) {
16 throw new SafariCookieReadError({ reason: "readFailed" });
17 }
18+ recordRanges.push([cookieStart, cookieStart + recordSize]);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around lines 105-114:
2
3Malformed pages can place `cookieStart` inside the page header/offset table or overlap a previously parsed record, allowing those bytes to be interpreted as a fabricated cookie. The check at line 107 only verifies page bounds; require `cookieStart` to start after the complete offset table and reject overlapping record ranges before parsing the record.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision d7ea73ac · right 79

Original diff
@@ -69,51 +69,36 @@ const chromiumSource = (input: {
   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] : []),
M
macroscopeappBotAug 18, 2026

🟠 High BrowserImport/Sources.ts:79

Chromium sources are now reported as unsupported on Windows, so BrowserImport rejects them before the reader can import still-readable DPAPI v10 cookies from databases that also contain unreadable App-Bound v20 records. Restore Windows platform/path definitions for Chromium sources so the supported partial-import path remains reachable.

🚀 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 79:
2
3Chromium sources are now reported as unsupported on Windows, so `BrowserImport` rejects them before the reader can import still-readable DPAPI `v10` cookies from databases that also contain unreadable App-Bound `v20` records. Restore Windows platform/path definitions for Chromium sources so the supported partial-import path remains reachable.
J
Julius MarmingeAug 29, 2026

Reviewed against the current scope and the Windows-tested contribution #7323. Windows Chromium import was deliberately removed at the maintainer’s request; restoring the retired DPAPI path is not a correction for this stack. Firefox remains supported on Windows. Resolving this as an intentional unsupported capability, not an outstanding regression.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1a6149b7 · right 528

Original diff
@@ -517,6 +519,14 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
  * files, and the answer changes while the app is running (quitting the browser
  * clears `browserRunning`), so a value cached at mount would go stale.
  */
+/**
+ * Opens System Settings → Privacy & Security → Full Disk Access. The scheme is
+ * unchanged from the old System Preferences and still resolves on Ventura and
+ * later.
+ */
+const FULL_DISK_ACCESS_SETTINGS_URL =
+  "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFilesAccess";
C
cursorBotAug 17, 2026

Settings deep link blocked silently

High Severity

Open System Settings calls shell.openExternal with an x-apple.systempreferences: URL. Desktop openExternal only allows http/https and editor schemes, so the call returns false, readLocalApi throws, and the handler’s .catch swallows it — the button does nothing.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 1a6149b764f4000a565e3f5e0abca29797a769de. Configure here.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 8dfa5698 · right 447–447

Original diff
@@ -381,28 +423,50 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
   const root = definition.userDataDirectory(context);
   if (root === undefined) return [];
 
+  if (definition.engine === "safari") {
+    // One jar, no profiles: the directory is the profile.
+    return [{ directory: ".", name: "Safari" }];
+  }
+
   if (definition.engine === "firefox") {
     const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
       Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
       Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
     );
-    if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);
+    // `profiles.ini` also lists profiles the installer created but the user
+    // never launched, which hold no cookie database and nothing to import.
+    // Only keep the ones a database proves exist, like the directory scans
+    // below do.
+    if (declared.length > 0) {
+      const found = yield* Effect.forEach(declared, (profile) =>
+        Effect.forEach(
+          cookieDatabaseCandidatePaths(definition, context, profile.directory),
+          (candidate) => databaseFileExists(candidate),
+        ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))),
+      );
+      return yield* withCookieCounts(

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

M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/Sources.ts:447

When profiles.ini lists only profiles without cookies.sqlite, listSourceProfiles returns an empty list without scanning for other profiles, so isSourceInstalled returns false and hides cookies in an unlisted profile. Continue to the fallback directory scan when found is empty, rather than returning whenever declared is non-empty.

diff
1- return yield* withCookieCounts(
2+ if (found.some((profile) => profile !== undefined)) return yield* withCookieCounts(
🚀 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 447:
2
3When `profiles.ini` lists only profiles without `cookies.sqlite`, `listSourceProfiles` returns an empty list without scanning for other profiles, so `isSourceInstalled` returns `false` and hides cookies in an unlisted profile. Continue to the fallback directory scan when `found` is empty, rather than returning whenever `declared` is non-empty.
J
Julius MarmingeSep 2, 2026

Fixed in 78c57bad1d. When none of the declared profiles has a cookie database, listing now falls through to the Profiles directory scan instead of returning empty, so cookies in an undeclared profile are found and isSourceInstalled stays true. Regression test covers a profiles.ini that names only a never-launched profile.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 31db6ab4 · right 134

Original diff
@@ -0,0 +1,146 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Safari cookies: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+    if (page.length < 12) continue;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + 48 > page.length) continue;
+      const cookie = page.subarray(cookieStart);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        url: `${secure ? "https" : "http"}://${host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
+      });
+    }
+  }
+
+  return cookies;
+}
+
+export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const contents = yield* fileSystem.readFile(cookiePath).pipe(
+    Effect.mapError((cause) => {
+      // TCC denies the read even though the file exists, which is a permission
+      // the user can grant rather than a missing browser.
+      const denied = cause.reason._tag === "PermissionDenied";
+      return new SafariCookieReadError({
+        reason: denied ? "needsFullDiskAccess" : "readFailed",
+        cause,
+      });
C
cursorBotAug 16, 2026

TCC denial misdetected

High Severity

needsFullDiskAccess is only returned when Effect reports PermissionDenied, which maps from EACCES. macOS TCC blocks on Cookies.binarycookies with EPERM, which Effect treats as Unknown, so real Full Disk Access denials surface as generic readFailed instead of the FDA remedy. The test covers chmod (EACCES), not TCC.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 31db6ab42c3d74b427087748350a1f7156043b26. Configure here.

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · Earlier revision 78c57bad · right 176–178

Original diff
@@ -0,0 +1,216 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import type { ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 56;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const cookie = page.subarray(cookieStart, cookieStart + recordSize);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some(
+          (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length,
+        )
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const host = domain.startsWith(".") ? domain.slice(1) : domain;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        // A bare IPv6 host is invalid in a URL authority, so bracket it the
+        // way the Chromium reader's cookieScope does.
+        url: `${secure ? "https" : "http"}://${host.includes(":") ? `[${host}]` : host}${path || "/"}`,
+        name,
+        value,
+        domain,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // The format predates SameSite and carries no equivalent field. Lax is
+        // the modern browser default; claiming "none" would widen every
+        // imported cookie's scope.
+        sameSite: "lax",
+      });
+    }
+  }
+
+  return cookies;
+}
+
+/**
+ * Whether a filesystem error is the OS refusing access.
+ *
+ * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
+ * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
+ * too — otherwise a Full Disk Access refusal is reported as a generic read
+ * failure and the user is never told what to grant.
+ */
+export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
+  if (error.reason._tag === "PermissionDenied") return true;
+  const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
+  return code === "EPERM" || code === "EACCES";

apps/desktop/src/preview/BrowserImport/SafariCookies.ts · right 176–178

M
macroscopeappBotSep 2, 2026

🟠 High BrowserImport/SafariCookies.ts:176

isPermissionDenied reports ordinary EACCES failures as needsFullDiskAccess, so jars blocked by POSIX permissions or ACLs send users to a grant that cannot fix the failure. Since the Safari TCC path is identified by EPERM, only classify that errno as needsFullDiskAccess and let other permission errors remain readFailed.

suggestion
1 const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
2 return code === "EPERM";
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around lines 176-178:
2
3`isPermissionDenied` reports ordinary `EACCES` failures as `needsFullDiskAccess`, so jars blocked by POSIX permissions or ACLs send users to a grant that cannot fix the failure. Since the Safari TCC path is identified by `EPERM`, only classify that errno as `needsFullDiskAccess` and let other permission errors remain `readFailed`.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 5c9c06f9 · right 413

Original diff
@@ -343,8 +408,11 @@ const countProfileCookies = Effect.fnUntraced(function* (
   definition: BrowserImportSourceDefinition,
   context: BrowserImportPathContext,
   directory: string,
-): Effect.fn.Return<number | undefined, never> {
-  const databasePath = cookieDatabasePath(definition, context, directory);
+): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {
+  const candidates = cookieDatabaseCandidatePaths(definition, context, directory);
+  const databasePath = yield* Effect.forEach(candidates, (candidate) =>
M
macroscopeappBotSep 1, 2026

🟠 High BrowserImport/Sources.ts:413

When Network/Cookies is a directory and the valid legacy Cookies database exists, countProfileCookies selects the directory first, the SQLite query fails, and the legacy candidate is never tried. The importer uses the same candidate selection and therefore also fails instead of importing the profile; filter candidates to regular files in both paths.

🚀 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 413:
2
3When `Network/Cookies` is a directory and the valid legacy `Cookies` database exists, `countProfileCookies` selects the directory first, the SQLite query fails, and the legacy candidate is never tried. The importer uses the same candidate selection and therefore also fails instead of importing the profile; filter candidates to regular files in both paths.

apps/desktop/src/preview/BrowserImport/Sources.test.ts · Earlier revision d7ea73ac · right 365

Original diff
@@ -232,35 +343,47 @@ describe("isSourceRunning for Firefox", () => {
       }),
     ),
   );
-});
 
-describe("Windows user-data directories", () => {
-  it.effect("puts Opera under roaming AppData without a User Data level", () =>
+  it.effect("does not treat a stale parent.lock file as a running browser", () =>
     run(
       Effect.gen(function* () {
+        const fileSystem = yield* FileSystem.FileSystem;
+        const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
         const context = yield* sourcePathContext.pipe(
-          Effect.provideService(HostProcessEnvironment, {
-            USERPROFILE: "C:\\Users\\u",
-            APPDATA: "C:\\Users\\u\\AppData\\Roaming",
-            LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local",
-          }),
+          Effect.provideService(HostProcessEnvironment, { HOME: home }),
           Effect.provideService(HostProcessPlatform, "win32"),
         );
+        const root = firefox.userDataDirectory(context)!;
+        const profile = `${root}/Profiles/gx7x7fqx.default-release`;
+        yield* fileSystem.makeDirectory(profile, { recursive: true });
+        yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
 
-        // Opera does not follow the local-AppData `User Data` convention its
-        // Chromium relatives use, so deriving it that way never found it.
-        assert.include(opera.userDataDirectory(context) ?? "", "Roaming");
-        assert.include(opera.userDataDirectory(context) ?? "", "Opera Stable");
-        assert.notInclude(opera.userDataDirectory(context) ?? "", "User Data");
-
-        const chrome = BROWSER_IMPORT_SOURCES.find((source) => source.id === "chrome")!;
-        assert.include(chrome.userDataDirectory(context) ?? "", "Local");
-        assert.include(chrome.userDataDirectory(context) ?? "", "User Data");
+        // On Windows, Firefox creates parent.lock as a regular file that
+        // persists after the process exits. The file is only locked while
+        // Firefox is running; the old stat-based check always found it.
+        yield* fileSystem.writeFileString(`${profile}/parent.lock`, "");
+        assert.isFalse(yield* isSourceRunning(firefox, context));
C
cursorBotAug 18, 2026

Windows lock test never runs

Medium Severity

The new Windows parent.lock test only supplies HOME, but Firefox on win32 resolves its user-data directory from APPDATA. With that missing, userDataDirectory is undefined and isSourceRunning returns false before isLockHeld runs, so the assertion passes without covering the stale-lock regression it describes.

Reviewed by Cursor Bugbot for commit d7ea73ac1487159ef77bb8662246cf0700efdac3. Configure here.

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

M
macroscopeappBotAug 29, 2026

🟡 Medium

https://github.com/pingdotgg/t3code/blob/b99d5dd2476b927e86a541e0bd6e17f712486780/apps/desktop/src/preview/BrowserImport/BrowserImport.ts#L193-L199

Safari imports emit Reading browser cookie key from the keychain even though Safari never obtains a key, misleading logs and keychain-prompt diagnostics on every Safari import. Emit this log only for Chromium imports.

diff
1- // macOS attributes the Keychain prompt and the resulting ACL grant to the
2- // executable that asks, so record which one that was — in a packaged build
3- // it is the signed app, in dev whatever binary hosts the main process.
4- yield* Effect.logInfo("Reading browser cookie key from the keychain", {
5- sourceId: definition.id,
6- executablePath,
7- });
8+ if (definition.engine === "chromium") {
9+ // macOS attributes the Keychain prompt and the resulting ACL grant to the
10+ // executable that asks, so record which one that was — in a packaged build
11+ // it is the signed app, in dev whatever binary hosts the main process.
12+ yield* Effect.logInfo("Reading browser cookie key from the keychain", {
13+ sourceId: definition.id,
14+ executablePath,
15+ });
16+ }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/BrowserImport.ts around lines 193-199:
2
3Safari imports emit `Reading browser cookie key from the keychain` even though Safari never obtains a key, misleading logs and keychain-prompt diagnostics on every Safari import. Emit this log only for Chromium imports.
J
Julius MarmingeAug 29, 2026

Fixed in b056322b87 on #7262. Keychain-read diagnostics now run only for macOS Chromium imports; Safari, Firefox, and Linux no longer emit the misleading message.

apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision 1453d76b · right 312–312

Original diff
@@ -253,6 +292,31 @@ const entryExists = Effect.fnUntraced(function* (path: string) {
   );
 });
 
+/**
+ * Whether a lock file is actually held by a running process. On macOS and
+ * Linux Firefox lock files are dangling symlinks that disappear when the
+ * process exits, so `entryExists` is sufficient. On Windows the lock file
+ * (`parent.lock`) is a regular file opened with no sharing, so it persists on
+ * disk after the process exits; `stat` always succeeds, and the probe must try
+ * to open the file with write access to detect the active lock.
+ */
+const isLockHeld = Effect.fnUntraced(function* (lockPath: string, platform: NodeJS.Platform) {
+  if (platform !== "win32") return yield* entryExists(lockPath);
+  // A lock held with no sharing denies the write access we need, surfacing as
+  // `Busy` or `PermissionDenied`; anything else (missing file, permissions we
+  // cannot read) just means no active browser holds it.
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
+    Effect.as(false),
+    Effect.catchIf(
+      (error) => error.reason._tag === "Busy" || error.reason._tag === "PermissionDenied",

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

M
macroscopeappBotAug 28, 2026

🟠 High BrowserImport/Sources.ts:312

isLockHeld reports Firefox as running for a stale read-only parent.lock or a profile directory without write access, even when no Firefox process holds the lock. The r+ probe maps those EACCES cases to PermissionDenied, and treating that error like Busy makes isSourceRunning return true and blocks cookie imports; only an actual sharing conflict should indicate an active lock.

diff
1- (error) => error.reason._tag === "Busy" || error.reason._tag === "PermissionDenied",
2+ (error) => error.reason._tag === "Busy",
🚀 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 312:
2
3`isLockHeld` reports Firefox as running for a stale read-only `parent.lock` or a profile directory without write access, even when no Firefox process holds the lock. The `r+` probe maps those `EACCES` cases to `PermissionDenied`, and treating that error like `Busy` makes `isSourceRunning` return `true` and blocks cookie imports; only an actual sharing conflict should indicate an active lock.

apps/desktop/src/electron/ElectronShell.ts · Earlier revision 46430d0e · right 25

Original diff
@@ -1,11 +1,29 @@
-import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
+import {
+  REMOTE_CAPABLE_EDITOR_IDS,
+  remoteSchemeForEditor,
+  type SystemSettingsPane,
+} from "@t3tools/contracts";
 import * as Context from "effect/Context";
 import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
 import * as Option from "effect/Option";
 
 import * as Electron from "electron";
 
+/**
+ * Deep links to individual System Settings panes. These are app-fixed, not
+ * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep
+ * arbitrary link schemes from reaching the OS handler — and open through their
+ * own path below. The pane rather than the URL crosses the IPC boundary, so a
+ * renderer can only ask for one of these known destinations.
+ *
+ * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor.
+ */
+const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
+  "full-disk-access":
+    "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFilesAccess",
+};
C
cursorBotAug 28, 2026

FDA deep link uses wrong anchor

Medium Severity

The Full Disk Access deep link pairs the Ventura PrivacySecurity.extension pane with query Privacy_AllFilesAccess, carried over from the legacy preference.security URL. The documented anchor for this pane is Privacy_AllFiles. An unrecognized anchor typically opens Privacy & Security without jumping to Full Disk Access, so Open System Settings can land users on the wrong screen.

Reviewed by Cursor Bugbot for commit 46430d0e1804d8d4350ba986269dbe277cb17691. Configure here.