diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 57f06f765..963f69e78 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import os from "node:os"; -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -34,6 +34,7 @@ import { getLogFilePath, readChromiumLogTail, } from "./utils/logger"; +import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; import { createWindow } from "./window"; // Single instance lock must be acquired FIRST before any other app setup @@ -180,6 +181,31 @@ registerDeepLinkHandlers(); initializePostHog(); app.whenReady().then(async () => { + if ( + process.platform === "darwin" && + app.isPackaged && + isMacosPackagedUnsafeBundleLocation(app.getAppPath(), process.execPath) + ) { + const appPath = app.getAppPath(); + const exePath = process.execPath; + log.warn( + "Refusing to start: packaged app is on App Translocation or a read-only non-root volume", + { appPath, exePath }, + ); + dialog.showMessageBoxSync({ + type: "warning", + title: "Read-only install location", + message: + "PostHog Code is running from a location with read-only access (for example App Translocation or a read-only disk image). The exact folder can differ depending on how you opened the app.", + detail: + "This prevents updates and tasks from running correctly. Move PostHog Code to the Applications folder (or another folder on a writable disk), quit the app completely, then open it again from the new location.", + buttons: ["OK"], + defaultId: 0, + }); + app.quit(); + return; + } + const commit = __BUILD_COMMIT__ ?? "dev"; const buildDate = __BUILD_DATE__ ?? "dev"; log.info( diff --git a/apps/code/src/main/utils/macos-packaged-install-guard.test.ts b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts new file mode 100644 index 000000000..35d728b25 --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isMacosAppTranslocationPath, + isMacosPackagedUnsafeBundleLocation, + isMacosPathOnReadOnlyNonRootMountFromTable, + parseDarwinMountTable, +} from "./macos-packaged-install-guard"; + +describe("isMacosAppTranslocationPath", () => { + it("returns true when appPath contains AppTranslocation", () => { + expect( + isMacosAppTranslocationPath( + "/private/var/folders/yf/xx/AppTranslocation/C6283C3C-9D6E-4D81-A7D5-8BA2567ED486/d/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + ), + ).toBe(true); + }); + + it("returns true when exePath contains AppTranslocation", () => { + expect( + isMacosAppTranslocationPath( + "/Applications/PostHog Code.app/Contents/Resources/app.asar", + "/private/var/folders/yf/xx/AppTranslocation/C6283C3C/d/PostHog Code.app/Contents/MacOS/PostHog Code", + ), + ).toBe(true); + }); + + it("returns false for normal /Applications paths", () => { + expect( + isMacosAppTranslocationPath( + "/Applications/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + ), + ).toBe(false); + }); + + it("returns false when neither path is translocated", () => { + expect( + isMacosAppTranslocationPath( + "/Users/dev/PostHog Code.app/Contents/Resources/app.asar", + "/Users/dev/PostHog Code.app/Contents/MacOS/PostHog Code", + ), + ).toBe(false); + }); +}); + +describe("parseDarwinMountTable", () => { + it("parses standard macOS mount lines", () => { + const sample = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/My Dmg (apfs, local, read-only, journaled) +/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled) +`; + const entries = parseDarwinMountTable(sample); + expect(entries).toEqual([ + { mountPoint: "/", options: "apfs, sealed, local, read-only, journaled" }, + { + mountPoint: "/Volumes/My Dmg", + options: "apfs, local, read-only, journaled", + }, + { mountPoint: "/Volumes/Writable", options: "apfs, local, journaled" }, + ]); + }); +}); + +describe("isMacosPathOnReadOnlyNonRootMountFromTable", () => { + const table = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled) +/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled) +`; + + it("returns false for paths only under read-only / (root is ignored)", () => { + expect( + isMacosPathOnReadOnlyNonRootMountFromTable("/Users/me/app", table), + ).toBe(false); + expect( + isMacosPathOnReadOnlyNonRootMountFromTable( + "/Applications/Foo.app", + table, + ), + ).toBe(false); + }); + + it("returns true for paths on a read-only non-root volume", () => { + expect( + isMacosPathOnReadOnlyNonRootMountFromTable( + "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", + table, + ), + ).toBe(true); + }); + + it("returns false for paths on a writable volume", () => { + expect( + isMacosPathOnReadOnlyNonRootMountFromTable( + "/Volumes/Writable/out/PostHog Code.app/Contents/MacOS/PostHog Code", + table, + ), + ).toBe(false); + }); + + it("picks the longest matching mount prefix", () => { + const nested = `/dev/x on / (apfs, read-only) +/dev/y on /Volumes/RW (apfs, local, journaled) +/dev/z on /Volumes/RW/nested (apfs, local, read-only) +`; + expect( + isMacosPathOnReadOnlyNonRootMountFromTable( + "/Volumes/RW/nested/app", + nested, + ), + ).toBe(true); + expect( + isMacosPathOnReadOnlyNonRootMountFromTable( + "/Volumes/RW/other/app", + nested, + ), + ).toBe(false); + }); +}); + +describe("isMacosPackagedUnsafeBundleLocation", () => { + const writableMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk5s1 on /Volumes/build (apfs, local, journaled) +/dev/disk6s1 on /Applications (apfs, local, journaled) +`; + const readOnlyMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled) +`; + + it("is true when translocated (mount table never consulted)", () => { + const readMountTable = vi.fn(() => writableMountTable); + expect( + isMacosPackagedUnsafeBundleLocation( + "/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable, + ), + ).toBe(true); + expect(readMountTable).not.toHaveBeenCalled(); + }); + + it("is false for ordinary non-translocated paths on writable mounts", () => { + expect( + isMacosPackagedUnsafeBundleLocation( + "/Volumes/build/out/PostHog Code.app/Contents/Resources/app.asar", + "/Volumes/build/out/PostHog Code.app/Contents/MacOS/PostHog Code", + () => writableMountTable, + ), + ).toBe(false); + }); + + it("is true when the bundle lives on a read-only non-root volume", () => { + expect( + isMacosPackagedUnsafeBundleLocation( + "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/Resources/app.asar", + "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", + () => readOnlyMountTable, + ), + ).toBe(true); + }); + + it("is false when the mount table cannot be read (degrade to non-blocking)", () => { + expect( + isMacosPackagedUnsafeBundleLocation( + "/Applications/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + () => null, + ), + ).toBe(false); + }); +}); diff --git a/apps/code/src/main/utils/macos-packaged-install-guard.ts b/apps/code/src/main/utils/macos-packaged-install-guard.ts new file mode 100644 index 000000000..1bda8adee --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.ts @@ -0,0 +1,134 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +const APP_TRANSLOCATION_SEGMENT = "AppTranslocation"; +const MOUNT_READ_TIMEOUT_MS = 3000; + +export type DarwinMountEntry = { + mountPoint: string; + options: string; +}; + +/** + * Reads the Darwin mount table. Returns `null` when the table cannot be + * obtained (e.g. `/sbin/mount` is missing, times out, or exits non-zero). + */ +export type ReadDarwinMountTable = () => string | null; + +/** Parse `/sbin/mount` lines: ` on ()` */ +export function parseDarwinMountTable(output: string): DarwinMountEntry[] { + const entries: DarwinMountEntry[] = []; + for (const line of output.split("\n")) { + const onMarker = line.indexOf(" on "); + if (onMarker === -1) continue; + const afterOn = line.slice(onMarker + 4); + const openParen = afterOn.indexOf(" ("); + if (openParen === -1 || !line.endsWith(")")) continue; + const mountPoint = afterOn.slice(0, openParen); + const options = afterOn.slice(openParen + 2, -1); + entries.push({ mountPoint, options }); + } + return entries; +} + +function mountOptionsImplyReadOnly(options: string): boolean { + return options.toLowerCase().includes("read-only"); +} + +function longestMatchingMount( + resolvedPath: string, + entries: DarwinMountEntry[], +): DarwinMountEntry | null { + let best: DarwinMountEntry | null = null; + for (const e of entries) { + const mp = e.mountPoint; + // For `/` we'd otherwise build `//` which no real path starts with, so the + // root mount would silently drop out of the comparison and the + // `best.mountPoint === "/"` guard below would be unreachable. + const under = + resolvedPath === mp || + resolvedPath.startsWith(mp === "/" ? "/" : `${mp}/`); + if (!under) continue; + if (!best || mp.length > best.mountPoint.length) { + best = e; + } + } + return best; +} + +/** + * True when `resolvedAbsolutePath` sits on a **non-root** mount that `mount(8)` + * reports as read-only (e.g. many DMGs, some external volumes). + * + * Ignores read-only `/` — on sealed macOS the system volume is read-only while + * normal apps under /Applications or /Users still work. + */ +export function isMacosPathOnReadOnlyNonRootMountFromTable( + resolvedAbsolutePath: string, + mountTable: string, +): boolean { + const normalized = path.resolve(resolvedAbsolutePath); + const entries = parseDarwinMountTable(mountTable); + const best = longestMatchingMount(normalized, entries); + if (!best || best.mountPoint === "/") { + return false; + } + return mountOptionsImplyReadOnly(best.options); +} + +/** + * Reads `/sbin/mount` synchronously. A short timeout keeps a hung NFS/SMB + * share from freezing app startup — the exact failure mode this guard exists + * to prevent. Returns `null` on any failure so callers can degrade to "don't + * block". + */ +function readDarwinMountTableSync(): string | null { + try { + return execFileSync("/sbin/mount", { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + timeout: MOUNT_READ_TIMEOUT_MS, + }); + } catch { + return null; + } +} + +/** + * True when either path is under macOS App Translocation (read-only runtime). + * Caller should gate on packaged darwin before using this to block startup. + */ +export function isMacosAppTranslocationPath( + appPath: string, + exePath: string, +): boolean { + return ( + appPath.includes(APP_TRANSLOCATION_SEGMENT) || + exePath.includes(APP_TRANSLOCATION_SEGMENT) + ); +} + +/** + * Packaged macOS: translocated bundle path, or binary on a non-root read-only + * mount (see mount(8)). + * + * `readMountTable` is injectable so tests can drive the mount-table branch + * deterministically instead of relying on the host's real `/sbin/mount`. + */ +export function isMacosPackagedUnsafeBundleLocation( + appPath: string, + exePath: string, + readMountTable: ReadDarwinMountTable = readDarwinMountTableSync, +): boolean { + if (isMacosAppTranslocationPath(appPath, exePath)) { + return true; + } + const table = readMountTable(); + if (table === null) { + return false; + } + return isMacosPathOnReadOnlyNonRootMountFromTable( + path.resolve(exePath), + table, + ); +}