From 9074cce06eb122e11c73e79da18886460705b02c Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Sun, 10 May 2026 20:26:55 -0400 Subject: [PATCH 1/2] fix/macos-packaged-readonly-startup Fixes: #2098 Detect App Translocation bundle paths and non-root read-only volumes using mount(8). Show a blocking dialog before the window or services start. Ignores read-only on sealed macOS to avoid false positives. --- apps/code/src/main/index.ts | 28 +++- .../macos-packaged-install-guard.test.ts | 139 ++++++++++++++++++ .../utils/macos-packaged-install-guard.ts | 108 ++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/main/utils/macos-packaged-install-guard.test.ts create mode 100644 apps/code/src/main/utils/macos-packaged-install-guard.ts 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..14dd3166f --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } 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", () => { + it("is true when translocated", () => { + expect( + isMacosPackagedUnsafeBundleLocation( + "/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + ), + ).toBe(true); + }); + + 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", + ), + ).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..04c4d2efa --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.ts @@ -0,0 +1,108 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +const APP_TRANSLOCATION_SEGMENT = "AppTranslocation"; + +export type DarwinMountEntry = { + mountPoint: string; + options: string; +}; + +/** 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; + const under = resolvedPath === mp || resolvedPath.startsWith(`${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); +} + +function isMacosPathOnReadOnlyNonRootMount( + resolvedAbsolutePath: string, +): boolean { + let output: string; + try { + output = execFileSync("/sbin/mount", { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + } catch { + return false; + } + return isMacosPathOnReadOnlyNonRootMountFromTable( + resolvedAbsolutePath, + output, + ); +} + +/** + * 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)). */ +export function isMacosPackagedUnsafeBundleLocation( + appPath: string, + exePath: string, +): boolean { + if (isMacosAppTranslocationPath(appPath, exePath)) { + return true; + } + return isMacosPathOnReadOnlyNonRootMount(path.resolve(exePath)); +} From 38dcb8b13ccff273b9b50f177418aff7724e6dc3 Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Mon, 11 May 2026 13:01:47 -0400 Subject: [PATCH 2/2] minor fixes --- .../macos-packaged-install-guard.test.ts | 36 ++++++++++++- .../utils/macos-packaged-install-guard.ts | 52 ++++++++++++++----- 2 files changed, 73 insertions(+), 15 deletions(-) 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 index 14dd3166f..35d728b25 100644 --- a/apps/code/src/main/utils/macos-packaged-install-guard.test.ts +++ b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isMacosAppTranslocationPath, isMacosPackagedUnsafeBundleLocation, @@ -119,13 +119,24 @@ describe("isMacosPathOnReadOnlyNonRootMountFromTable", () => { }); describe("isMacosPackagedUnsafeBundleLocation", () => { - it("is true when translocated", () => { + 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", () => { @@ -133,6 +144,27 @@ describe("isMacosPackagedUnsafeBundleLocation", () => { 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 index 04c4d2efa..1bda8adee 100644 --- a/apps/code/src/main/utils/macos-packaged-install-guard.ts +++ b/apps/code/src/main/utils/macos-packaged-install-guard.ts @@ -2,12 +2,19 @@ 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[] = []; @@ -35,7 +42,12 @@ function longestMatchingMount( let best: DarwinMountEntry | null = null; for (const e of entries) { const mp = e.mountPoint; - const under = resolvedPath === mp || resolvedPath.startsWith(`${mp}/`); + // 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; @@ -64,22 +76,22 @@ export function isMacosPathOnReadOnlyNonRootMountFromTable( return mountOptionsImplyReadOnly(best.options); } -function isMacosPathOnReadOnlyNonRootMount( - resolvedAbsolutePath: string, -): boolean { - let output: string; +/** + * 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 { - output = execFileSync("/sbin/mount", { + return execFileSync("/sbin/mount", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, + timeout: MOUNT_READ_TIMEOUT_MS, }); } catch { - return false; + return null; } - return isMacosPathOnReadOnlyNonRootMountFromTable( - resolvedAbsolutePath, - output, - ); } /** @@ -96,13 +108,27 @@ export function isMacosAppTranslocationPath( ); } -/** Packaged macOS: translocated bundle path, or binary on a non-root read-only mount (see mount(8)). */ +/** + * 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; } - return isMacosPathOnReadOnlyNonRootMount(path.resolve(exePath)); + const table = readMountTable(); + if (table === null) { + return false; + } + return isMacosPathOnReadOnlyNonRootMountFromTable( + path.resolve(exePath), + table, + ); }