Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
171 changes: 171 additions & 0 deletions apps/code/src/main/utils/macos-packaged-install-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});
134 changes: 134 additions & 0 deletions apps/code/src/main/utils/macos-packaged-install-guard.ts
Original file line number Diff line number Diff line change
@@ -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: `<device> on <mountPoint> (<opts>)` */
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(" (");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mount points whose display names contain ( — e.g. /Volumes/My Backup (2) — are parsed incorrectly. indexOf(" (") stops at the first occurrence inside the name, so mountPoint is truncated and the options field picks up the tail of the display name. Using lastIndexOf anchors to the options block that macOS always places at the end of the line, matching the line.endsWith(")") invariant already enforced above.

Suggested change
const openParen = afterOn.indexOf(" (");
const openParen = afterOn.lastIndexOf(" (");
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/utils/macos-packaged-install-guard.ts
Line: 25

Comment:
Mount points whose display names contain ` (` — e.g. `/Volumes/My Backup (2)` — are parsed incorrectly. `indexOf(" (")` stops at the first occurrence inside the name, so `mountPoint` is truncated and the options field picks up the tail of the display name. Using `lastIndexOf` anchors to the options block that macOS always places at the end of the line, matching the `line.endsWith(")")` invariant already enforced above.

```suggestion
    const openParen = afterOn.lastIndexOf(" (");
```

How can I resolve this? If you propose a fix, please make it concise.

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,
);
}