feat(desktop): import cookies from Safari#7262
35 unresolved threads Β· Jump to file
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:34 Β· ### Wrong cookie record header size **Low Severity** <!-- DESCRIPTION START --> `COOKIE_RECORD_HEA
- apps/desktop/src/preview/BrowserImport/Sources.ts:469 Β· ### FDA check skips named Safari jars **Medium Severity** <!-- DESCRIPTION START --> `listSafariPr
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:34 Β· π‘ **Medium** `BrowserImport/SafariCookies.ts:34` A truncated 48β55-byte record can pass validation
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:114 Β· ### SameSite flags ignored **Medium Severity** <!-- DESCRIPTION START --> Safariβs cookie `flags`
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:202 Β· π **High** `BrowserImport/SafariCookies.ts:202` `readSafariCookies` successfully imports a truncat
- apps/web/src/components/settings/IntegrationsSettings.tsx:528 Β· ### Wrong Full Disk Access URL **Medium Severity** <!-- DESCRIPTION START --> `FULL_DISK_ACCESS_SE
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:65β66 Β· <!-- MURMUR_IGNORE --> π‘ **Medium** `BrowserImport/SafariCookies.ts:65` A truncated page is accept
- apps/desktop/src/preview/BrowserImport/Sources.ts:393 Β· ### FDA denial reported as not installed **High Severity** <!-- DESCRIPTION START --> Safariβs `us
- apps/desktop/src/preview/BrowserImport/Sources.ts:187 Β· ### Safari profiles cookies ignored **Medium Severity** <!-- DESCRIPTION START --> Safari has been
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:146β162 Β· <!-- MURMUR_IGNORE --> π **High** `BrowserImport/SafariCookies.ts:146` Host-only Safari cookies ar
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:45β47 Β· <!-- MURMUR_IGNORE --> `BrowserImport/SafariCookies.ts:47` `SafariCookieReadError` carries only `re
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:197β206 Β· <!-- MURMUR_IGNORE --> `BrowserImport/SafariCookies.ts:206` Parse failures still lose the path they
- apps/web/src/localApi.ts:36 Β· Resolving instead of throwing here makes the "Open System Settings" button a silent no-op on any she
- apps/web/src/components/settings/BrowserImportWizard.tsx:115β125 Β· `recheckFullDiskAccess` runs an async round-trip with no step change, unlike its sibling `recheckAft
- apps/web/src/components/settings/IntegrationsSettings.tsx:849β853 Β· This still can't open anything, and after the move into `BrowserImportWizard` the button is now a co
- apps/web/src/components/settings/IntegrationsSettings.tsx:921β927 Β· The dialog's primary action can't open anything today: `ElectronShell.parseSafeExternalUrl` (apps/de
- apps/web/src/components/settings/BrowserImportWizard.tsx:141 Β· The two `onGranted` branches give different feedback for the same user action. `recheckFullDiskAcces
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:100β103 Β· <!-- MURMUR_IGNORE --> π‘ **Medium** `BrowserImport/SafariCookies.ts:100` A page with an invalid he
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:65β68 Β· <!-- MURMUR_IGNORE --> π‘ **Medium** `BrowserImport/SafariCookies.ts:65` A truncated record with no
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:155 Β· ### Host-only cookies widened **High Severity** <!-- DESCRIPTION START --> Safari always sets `dom
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:45β47 Β· <!-- MURMUR_IGNORE --> `BrowserImport/SafariCookies.ts:47` `SafariCookieReadError` carries only `re
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:102β102 Β· π **High** `BrowserImport/SafariCookies.ts:102` `parseBinaryCookies` rejects valid Safari pages, s
- apps/web/src/components/settings/browserImportWizard.logic.ts:62β62 Β· `needsFullDiskAccess` is now also a `BrowserImportUnavailableReason`, but only `outcomeToStep` route
- apps/web/src/components/settings/IntegrationsSettings.tsx:959 Β· ### FDA retry needs restart **Medium Severity** <!-- DESCRIPTION START --> The Full Disk Access di
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:105β114 Β· π‘ **Medium** `BrowserImport/SafariCookies.ts:105` Malformed pages can place `cookieStart` inside t
- apps/desktop/src/preview/BrowserImport/Sources.ts:79 Β· π **High** `BrowserImport/Sources.ts:79` Chromium sources are now reported as unsupported on Windo
- apps/web/src/components/settings/IntegrationsSettings.tsx:528 Β· ### Settings deep link blocked silently **High Severity** <!-- DESCRIPTION START --> `Open System
- apps/desktop/src/preview/BrowserImport/Sources.ts:447β447 Β· π **High** `BrowserImport/Sources.ts:447` When `profiles.ini` lists only profiles without `cookies
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:134 Β· ### TCC denial misdetected **High Severity** <!-- DESCRIPTION START --> `needsFullDiskAccess` is o
- apps/desktop/src/preview/BrowserImport/SafariCookies.ts:176β178 Β· π **High** `BrowserImport/SafariCookies.ts:176` `isPermissionDenied` reports ordinary `EACCES` fai
- apps/desktop/src/preview/BrowserImport/Sources.ts:413 Β· π **High** `BrowserImport/Sources.ts:413` When `Network/Cookies` is a directory and the valid lega
- apps/desktop/src/preview/BrowserImport/Sources.test.ts:365 Β· ### Windows lock test never runs **Medium Severity** <!-- DESCRIPTION START --> The new Windows `p
- apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· File comment Β· π‘ **Medium** https://github.com/pingdotgg/t3code/blob/b99d5dd2476b927e86a541e0bd6e17f712486780/app
- apps/desktop/src/preview/BrowserImport/Sources.ts:312β312 Β· π **High** `BrowserImport/Sources.ts:312` `isLockHeld` reports Firefox as running for a stale read
- apps/desktop/src/electron/ElectronShell.ts:25 Β· ### FDA deep link uses wrong anchor **Medium Severity** <!-- DESCRIPTION START --> The Full Disk A
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;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;π‘ 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around line 34: |
| 2 | |
| 3 | 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. |
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",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.
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)),π 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:
| 1 | In 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. |
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";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
π‘ 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;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,
+ },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/<uuid>/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.
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
π 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
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
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;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.
| 1 | if (!window.desktopBridge?.openSystemSettings) { |
| 2 | throw new Error("Unable to open System Settings."); |
| 3 | } |
Posted via Macroscope β UI Consistency
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
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
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
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
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}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
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
π‘ 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
π‘ 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",
+ });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
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
π 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.
| 1 | const offsetTableEnd = 8 + cookieCount * 4; |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In 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
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.
| 1 | if (source.unavailable === "browserRunning") return { step: "quit" }; |
| 2 | + if (source.unavailable === "needsFullDiskAccess") return { step: "fullDiskAccess" }; |
Posted via Macroscope β UI Consistency
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>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
π‘ 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.
| 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/SafariCookies.ts around lines 105-114: |
| 2 | |
| 3 | 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. |
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] : []),π 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 79: |
| 2 | |
| 3 | 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. |
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";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
π 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.
| 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 447: |
| 2 | |
| 3 | 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. |
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,
+ });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
π 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.
| 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:
| 1 | In 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) =>π 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 413: |
| 2 | |
| 3 | 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. |
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));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
π‘ Medium
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.
| 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:
| 1 | In file @apps/desktop/src/preview/BrowserImport/BrowserImport.ts around lines 193-199: |
| 2 | |
| 3 | 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. |
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
π 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.
| 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:
| 1 | In 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",
+};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.