From bd4fed44c280d788ab3fe101471023c312eca223 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 23:30:46 +0200 Subject: [PATCH 1/6] feat(desktop): import cookies from Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safari does not encrypt its cookies. It stores them in a proprietary `Cookies.binarycookies` file whose protection is TCC rather than cryptography: the file sits inside the app container, which only apps with Full Disk Access may read. So the gate is a permission the user grants in System Settings, and a denial is reported as exactly that rather than as a generic read failure. Two details the format forces: Timestamps count seconds from 2001-01-01, not the UNIX epoch, so every expiry needs rebasing or cookies import as long expired. The format predates SameSite and carries no equivalent field. Imported cookies are marked Lax, the modern browser default — claiming "none" would widen the scope of every cookie Safari ever set. Safari keeps one jar for the whole app rather than per-profile, so it exposes a single implicit profile, and it has no observable lock file since the jar is written atomically. The parser is covered by tests that build the binary format byte for byte, including a multi-page file — Safari pages its jar, and a single-page reader would silently return only the first slice. That coverage matters because the real file cannot be read on this machine without the very permission the feature asks for; the TCC path itself was verified against the live file, which denies with EPERM and reports `needsFullDiskAccess`. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 29 +-- .../BrowserImport/SafariCookies.test.ts | 185 ++++++++++++++++++ .../preview/BrowserImport/SafariCookies.ts | 146 ++++++++++++++ .../src/preview/BrowserImport/Sources.ts | 51 ++++- packages/contracts/src/browserImport.ts | 4 + 5 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts create mode 100644 apps/desktop/src/preview/BrowserImport/SafariCookies.ts diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 169a48c3b62d..f080ce825b3a 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -29,6 +29,7 @@ import { type CookieReadResult, } from "./ChromiumCookies.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, @@ -177,22 +178,26 @@ export const make = Effect.gen(function* BrowserImportMake() { // engine — Firefox stores plaintext, so nothing there is ever unreadable. const read: Effect.Effect< CookieReadResult, - ChromiumCookieReadError | FirefoxCookieReadError, + ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = - definition.engine === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) - : readChromiumCookies({ - cookieDatabasePath: databasePath, - // Only Windows reads it; the other platforms take their key from a - // credential store and ignore the path entirely. - localStatePath: localStatePath(definition, pathContext) ?? "", - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - platform, - }); + : definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + // Only Windows reads it; the other platforms take their key from a + // credential store and ignore the path entirely. + localStatePath: localStatePath(definition, pathContext) ?? "", + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + platform, + }); const result = yield* read.pipe( Effect.scoped, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..c677c69b4cd2 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,185 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar +// format byte by byte. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { parseBinaryCookies, readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts"; + +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +interface FixtureCookie { + readonly domain: string; + readonly name: string; + readonly path: string; + readonly value: string; + readonly flags: number; + /** Seconds since 2001-01-01, as Safari stores them. */ + readonly expiry: number; +} + +/** Encodes one cookie exactly as Safari lays it out. */ +function encodeCookie(cookie: FixtureCookie): Buffer { + const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; + const headerSize = 56; + const offsets: number[] = []; + let cursor = headerSize; + for (const value of strings) { + offsets.push(cursor); + cursor += Buffer.byteLength(value) + 1; + } + const size = cursor; + + const buffer = Buffer.alloc(size); + buffer.writeUInt32LE(size, 0); + buffer.writeUInt32LE(0, 4); + buffer.writeUInt32LE(cookie.flags, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(offsets[0]!, 16); + buffer.writeUInt32LE(offsets[1]!, 20); + buffer.writeUInt32LE(offsets[2]!, 24); + buffer.writeUInt32LE(offsets[3]!, 28); + buffer.writeUInt32LE(0, 32); + buffer.writeUInt32LE(0, 36); + buffer.writeDoubleLE(cookie.expiry, 40); + buffer.writeDoubleLE(0, 48); + strings.forEach((value, index) => { + buffer.write(value, offsets[index]!, "utf8"); + }); + return buffer; +} + +/** Builds a single-page `Cookies.binarycookies` file. */ +function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { + const encoded = cookies.map(encodeCookie); + const headerSize = 12 + encoded.length * 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const cookie of encoded) { + offsets.push(cursor); + cursor += cookie.length; + } + + const page = Buffer.alloc(cursor); + page.writeUInt32BE(0x0000_0100, 0); + page.writeUInt32LE(encoded.length, 4); + offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); + encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); + + const header = Buffer.alloc(8 + 4); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(page.length, 8); + return Buffer.concat([header, page]); +} + +describe("parseBinaryCookies", () => { + it("reads Safari's format and rebases its 2001 epoch", () => { + const file = encodeBinaryCookies([ + { + domain: ".apple.com", + name: "session", + path: "/", + value: "abc", + // secure | httpOnly + flags: 0x1 | 0x4, + expiry: 800_000_000, + }, + { + domain: "example.test", + name: "plain", + path: "/app", + value: "v", + flags: 0, + expiry: 0, + }, + ]); + + expect(parseBinaryCookies(file)).toEqual([ + { + url: "https://apple.com/", + name: "session", + value: "abc", + domain: ".apple.com", + path: "/", + secure: true, + httpOnly: true, + // Safari counts from 2001-01-01, Electron from 1970. + expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, + // The format predates SameSite; Lax is the safe modern default. + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + domain: "example.test", + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + it("reads cookies spread across multiple pages", () => { + // Safari pages its cookie file, and a single-page reader would silently + // return only the first slice. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + // Splice the two single-page files into one two-page file. + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(firstPage.length, 8); + header.writeUInt32BE(secondPage.length, 12); + + const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); + + expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); + }); + + it("rejects a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("reports a TCC denial as a permission the user can grant", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + // What Full Disk Access actually looks like: the file is there, the read + // is refused. Reporting that as a generic failure would send the user + // looking for a missing browser instead of a checkbox. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "needsFullDiskAccess"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a missing jar as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + + const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..b787aba88290 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -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", + { + 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 { + 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, + }); + }), + ); + // 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", cause }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index b35129bf0de2..d8cb4298f116 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -23,7 +23,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -export type BrowserImportEngine = "chromium" | "firefox"; +export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than @@ -167,6 +167,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -196,7 +216,12 @@ export const cookieDatabasePath = ( ): string | undefined => { const root = definition.userDataDirectory(context); if (root === undefined) return undefined; - const fileName = definition.engine === "firefox" ? "cookies.sqlite" : "Cookies"; + const fileName = + definition.engine === "firefox" + ? "cookies.sqlite" + : definition.engine === "safari" + ? "Cookies.binarycookies" + : "Cookies"; return context.path.isAbsolute(profileDirectory) ? context.path.join(profileDirectory, fileName) : context.path.join(root, profileDirectory, fileName); @@ -362,6 +387,11 @@ 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(parseFirefoxProfiles), @@ -421,14 +451,17 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") ): Effect.fn.Return { 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; if (definition.engine !== "firefox") { return yield* entryExists(context.path.join(root, "SingletonLock")); } diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index f9804ae94c8d..ac8bec427c7f 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -25,6 +25,7 @@ export const BROWSER_IMPORT_SOURCE_IDS = [ "arc", "helium", "firefox", + "safari", ] as const; export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); @@ -43,6 +44,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ "needsKeychainApproval", "keychainItemMissing", "appBoundEncryption", + "needsFullDiskAccess", "browserRunning", "unsupportedPlatform", ]); @@ -134,6 +136,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< keychainItemMissing: "No encryption key in your Keychain — sign in to that browser once, then retry.", appBoundEncryption: "This browser binds its cookie key to itself, so no other app can read it.", + needsFullDiskAccess: + "Give T3 Code Full Disk Access in System Settings → Privacy & Security, then retry.", browserRunning: "Quit the browser first so its cookie database can be read.", unsupportedPlatform: "Importing from this browser isn't possible on this platform.", }; From c2a20b31138e700ea206a3040f112adfa3ce9865 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:06:38 +0200 Subject: [PATCH 2/6] docs(desktop): explain the remaining node builtin suppressions Every `nodeBuiltinImport:off` in the import module now says which builtin it covers and why Effect has no equivalent, matching the neighbouring preload and Playwright modules. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index ed5a9157e300..70a8ac58f281 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same +// OSCrypt primitives the module under test decrypts. import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; From d2469651395d1246c11d981ef5cbe34ecc4374c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:38:24 +0200 Subject: [PATCH 3/6] fix(desktop): reject a malformed Safari jar instead of importing part of it `Buffer.subarray` clamps rather than throwing, so every declared structure in the binary format was taken on trust. An overlong page swallowed the following page's bytes and pushed the cursor past the end, dropping every cookie after the boundary from an import that still reported success. A record whose declared size overran its page left its string offsets free to read the next record's bytes as this cookie's value. Pages, records, and string offsets are now bounds-checked against what the file actually contains, and a mismatch fails the read. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/SafariCookies.test.ts | 38 ++++++++++++++++++ .../preview/BrowserImport/SafariCookies.ts | 39 +++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index c677c69b4cd2..720fe7149792 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -147,6 +147,44 @@ describe("parseBinaryCookies", () => { expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); }); + it("rejects a page that runs past the end of the file", () => { + // `Buffer.subarray` clamps rather than throwing, so an overlong first page + // swallows the second one's bytes and advances the cursor past the end. + // Every cookie after the boundary then vanishes from a "successful" import. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + // Declares more bytes for page one than the file holds in total. + header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); + header.writeUInt32BE(secondPage.length, 12); + + expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( + SafariCookieReadError, + ); + }); + + it("rejects a record whose declared size runs past its page", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + // The record's own length is what bounds its string offsets; an inflated + // one lets them read the following record's bytes as this cookie's value. + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(0xffff, recordStart); + + expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); + }); + it("rejects a file that is not binarycookies", () => { expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( SafariCookieReadError, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index b787aba88290..4f9c0df72d59 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -28,6 +28,11 @@ 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; @@ -61,6 +66,14 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 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)); @@ -70,16 +83,29 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } 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); + 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 + 48 > page.length) continue; - const cookie = page.subarray(cookieStart); + 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); @@ -88,6 +114,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray offset >= cookie.length) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } const domain = readCString(cookie, urlOffset); const name = readCString(cookie, nameOffset); const path = readCString(cookie, pathOffset); From e9c3a377bef3ea7cd3d45ff5574d437455788845 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 01:23:17 +0200 Subject: [PATCH 4/6] refactor(desktop): name the jar a Safari read failed on Matches the Chromium and Firefox readers: the failure carries which jar it was for, so a Full Disk Access refusal is traceable rather than anonymous. Optional because the parser raises before a path is in hand. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/preview/BrowserImport/BrowserImport.ts | 6 ++++++ .../src/preview/BrowserImport/SafariCookies.ts | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index f080ce825b3a..6b69aab6f2a3 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -214,6 +214,12 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), + // Safari's reasons are already user-facing: a TCC refusal is the Full + // Disk Access prompt, anything else is a read failure. + SafariCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 4f9c0df72d59..32d1b2805358 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -43,12 +43,19 @@ export class SafariCookieReadError extends Schema.TaggedErrorClass isSafariCookieReadError(cause) ? cause - : new SafariCookieReadError({ reason: "readFailed", cause }), + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), }); }); From d1c8d326cf2db85db9c3db3949438c7525dae57a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 10:35:01 +0200 Subject: [PATCH 5/6] fix(desktop): report Safari's TCC denial as needing Full Disk Access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Safari import with no Full Disk Access failed with the generic "cookie database could not be read" instead of telling the user to grant access — and no prompt appears, because macOS never prompts for Full Disk Access; the app is added by hand. The denial arrives as EPERM, which Effect tags `Unknown`, not `PermissionDenied` (that is EACCES), so checking the tag alone never matched. The underlying errno is checked too. Verified against the real jar: the reason is now `needsFullDiskAccess`, which the renderer maps to the System Settings instruction. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/SafariCookies.test.ts | 30 ++++++++++++++++++- .../preview/BrowserImport/SafariCookies.ts | 25 +++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 720fe7149792..03e8b4f2ec29 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -4,8 +4,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; -import { parseBinaryCookies, readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts"; +import { + isPermissionDenied, + parseBinaryCookies, + readSafariCookies, + SafariCookieReadError, +} from "./SafariCookies.ts"; const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; @@ -221,3 +227,25 @@ describe("readSafariCookies", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); + +describe("isPermissionDenied", () => { + // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified + // against Safari's TCC-protected jar, whose denial is EPERM, tagged + // `Unknown` rather than `PermissionDenied`. + const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; + + it("treats a TCC EPERM denial as permission denied", () => { + // The regression: EPERM is tagged `Unknown`, so checking the tag alone + // reported Safari's Full Disk Access refusal as a generic read failure. + expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); + }); + + it("treats an EACCES denial as permission denied", () => { + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(true); + }); + + it("does not treat an unrelated failure as permission denied", () => { + expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 32d1b2805358..a714380ced9c 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -21,6 +21,7 @@ */ 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"; @@ -159,17 +160,33 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { + 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, which is a permission - // the user can grant rather than a missing browser. - const denied = cause.reason._tag === "PermissionDenied"; + // 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: denied ? "needsFullDiskAccess" : "readFailed", + reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", cookieDatabasePath: cookiePath, cause, }); From dbaf71207d5faf39c90b4220cd9554cf83b6a09d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 10:40:09 +0200 Subject: [PATCH 6/6] feat(web): add the Full Disk Access step to the import wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safari's cookies sit behind Full Disk Access, which no one has granted before their first import — so it is a step in the flow, not a failure. When an import comes back needing it, the wizard shows a screen that says what it's for, links to the right System Settings pane, and — from an "I've turned it on" button — runs the import itself, so the user never restarts from the menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/BrowserImportWizard.tsx | 46 +++++++++++++++++-- .../IntegrationsSettings.logic.test.ts | 24 ++++++++++ .../settings/IntegrationsSettings.tsx | 17 ++++++- .../browserImportWizard.logic.test.ts | 6 +++ .../settings/browserImportWizard.logic.ts | 2 + 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/settings/IntegrationsSettings.logic.test.ts diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index f7b58ce0d6a5..8f9670e7444e 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -57,6 +57,8 @@ interface BrowserImportWizardProps { }) => Promise; /** Re-checks the source's availability after the user quits the browser. */ readonly onRefreshSource: () => Promise; + /** Opens the OS setting that grants access to a protected cookie store. */ + readonly onOpenFullDiskAccessSettings: () => void; readonly onClose: () => void; } @@ -73,6 +75,7 @@ export function BrowserImportWizard({ canCreateProfile, onImport, onRefreshSource, + onOpenFullDiskAccessSettings, onClose, }: BrowserImportWizardProps) { const [source, setSource] = useState(initialSource); @@ -121,6 +124,13 @@ export function BrowserImportWizard({ {step.step === "quit" ? ( + ) : step.step === "fullDiskAccess" ? ( + ) : step.step === "importing" ? ( ) : step.step === "done" ? ( @@ -196,9 +206,39 @@ type ConfigureStepProps = { readonly onImport: () => void; }; -// TEMP: an in-dialog layout switcher for comparing directions live — the ui.sh -// picker can't load under the app's CSP. Collapse to the chosen variant and -// delete this switcher before merge. +function FullDiskAccessStep({ + source, + onCancel, + onOpenSettings, + onGranted, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onOpenSettings: () => void; + readonly onGranted: () => void; +}) { + return ( + <> + + Let T3 Code read {source.name}’s cookies + + {source.name} keeps its 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. + + + + + + + + + ); +} + function ConfigureStep({ source, targetProfiles, diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts new file mode 100644 index 000000000000..6a9a52680bb3 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { importFailureReason } from "./IntegrationsSettings"; + +// Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string +// before the renderer sees it. +const failure = (reason: string) => ({ + message: `Importing cookies from safari failed: ${reason}.`, +}); + +describe("importFailureReason", () => { + it("recovers the reason token from the flattened message", () => { + // The whole import error path — including the Full Disk Access dialog — + // depends on this token surviving the trip through IPC. + expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess"); + expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); + expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); + }); + + it("falls back to readFailed for anything it cannot classify", () => { + expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed"); + expect(importFailureReason(undefined)).toBe("readFailed"); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 72632c48a5b0..34d7a1900629 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -49,6 +49,8 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { readLocalApi } from "~/localApi"; + import { toastManager } from "../ui/toast"; import { AlertDialog, @@ -114,7 +116,7 @@ const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; * it. Anything unrecognised reads as a plain read failure rather than leaking * the raw message into a toast. */ -const importFailureReason = (cause: unknown): BrowserImportFailureReason => { +export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); return ( BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? @@ -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"; + function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); @@ -842,6 +852,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) + } onClose={() => setImportSource(null)} /> ) : null} diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts index f8a7cd461dd1..9924fdf127a8 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -56,6 +56,12 @@ describe("outcomeToStep", () => { expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" }); }); + it("routes a Full Disk Access refusal to its own screen", () => { + expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({ + step: "fullDiskAccess", + }); + }); + it("surfaces every other failure on the blocked screen", () => { expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ step: "blocked", diff --git a/apps/web/src/components/settings/browserImportWizard.logic.ts b/apps/web/src/components/settings/browserImportWizard.logic.ts index c61e2a978a61..2f4984ede1e5 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -21,6 +21,7 @@ export type ImportOutcome = */ export type WizardStep = | { readonly step: "quit" } + | { readonly step: "fullDiskAccess" } | { readonly step: "configure" } | { readonly step: "importing" } | { @@ -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" }; return { step: "blocked", reason: outcome.reason }; }