Skip to content

Commit 5bcf25f

Browse files
fix/macos-packaged-readonly-startup (#2122)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 15f4a8d commit 5bcf25f

3 files changed

Lines changed: 367 additions & 1 deletion

File tree

apps/code/src/main/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "reflect-metadata";
22
import os from "node:os";
3-
import { app, BrowserWindow } from "electron";
3+
import { app, BrowserWindow, dialog } from "electron";
44
import log from "electron-log/main";
55
import "./utils/logger";
66
import "./services/index.js";
@@ -34,6 +34,7 @@ import {
3434
getLogFilePath,
3535
readChromiumLogTail,
3636
} from "./utils/logger";
37+
import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard";
3738
import { createWindow } from "./window";
3839

3940
// Single instance lock must be acquired FIRST before any other app setup
@@ -180,6 +181,31 @@ registerDeepLinkHandlers();
180181
initializePostHog();
181182

182183
app.whenReady().then(async () => {
184+
if (
185+
process.platform === "darwin" &&
186+
app.isPackaged &&
187+
isMacosPackagedUnsafeBundleLocation(app.getAppPath(), process.execPath)
188+
) {
189+
const appPath = app.getAppPath();
190+
const exePath = process.execPath;
191+
const bundleRoot = exePath.replace(/\/Contents\/MacOS\/[^/]+$/, "");
192+
log.warn(
193+
"Refusing to start: packaged app is on App Translocation or a read-only non-root volume",
194+
{ appPath, exePath },
195+
);
196+
dialog.showMessageBoxSync({
197+
type: "warning",
198+
title: "Move PostHog Code to Applications",
199+
message: `PostHog Code is running from a location with read-only access:\n\n${bundleRoot}`,
200+
detail:
201+
"After quitting, move PostHog Code to your Applications folder, then open it from there.",
202+
buttons: ["Quit"],
203+
defaultId: 0,
204+
});
205+
app.quit();
206+
return;
207+
}
208+
183209
const commit = __BUILD_COMMIT__ ?? "dev";
184210
const buildDate = __BUILD_DATE__ ?? "dev";
185211
log.info(
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
type DarwinMountEntry,
4+
isMacosAppTranslocationPath,
5+
isMacosPackagedUnsafeBundleLocation,
6+
isMacosPathOnReadOnlyNonRootMountFromTable,
7+
parseDarwinMountTable,
8+
type ReadDarwinMountTable,
9+
} from "./macos-packaged-install-guard";
10+
11+
describe("isMacosAppTranslocationPath", () => {
12+
it.each([
13+
{
14+
case: "appPath is translocated",
15+
appPath:
16+
"/private/var/folders/yf/xx/AppTranslocation/C6283C3C-9D6E-4D81-A7D5-8BA2567ED486/d/PostHog Code.app/Contents/Resources/app.asar",
17+
exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
18+
expected: true,
19+
},
20+
{
21+
case: "exePath is translocated",
22+
appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar",
23+
exePath:
24+
"/private/var/folders/yf/xx/AppTranslocation/C6283C3C/d/PostHog Code.app/Contents/MacOS/PostHog Code",
25+
expected: true,
26+
},
27+
{
28+
case: "neither path is translocated (/Applications)",
29+
appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar",
30+
exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
31+
expected: false,
32+
},
33+
{
34+
case: "neither path is translocated (/Users)",
35+
appPath: "/Users/dev/PostHog Code.app/Contents/Resources/app.asar",
36+
exePath: "/Users/dev/PostHog Code.app/Contents/MacOS/PostHog Code",
37+
expected: false,
38+
},
39+
])("$case → $expected", ({ appPath, exePath, expected }) => {
40+
expect(isMacosAppTranslocationPath(appPath, exePath)).toBe(expected);
41+
});
42+
});
43+
44+
describe("parseDarwinMountTable", () => {
45+
it.each<{
46+
case: string;
47+
input: string;
48+
expected: DarwinMountEntry[];
49+
}>([
50+
{
51+
case: "standard macOS mount lines",
52+
input: `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
53+
/dev/disk7s1 on /Volumes/My Dmg (apfs, local, read-only, journaled)
54+
/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled)
55+
`,
56+
expected: [
57+
{
58+
mountPoint: "/",
59+
options: "apfs, sealed, local, read-only, journaled",
60+
},
61+
{
62+
mountPoint: "/Volumes/My Dmg",
63+
options: "apfs, local, read-only, journaled",
64+
},
65+
{ mountPoint: "/Volumes/Writable", options: "apfs, local, journaled" },
66+
],
67+
},
68+
{
69+
case: "mount point name contains ' (' — anchors to trailing options",
70+
input:
71+
"/dev/disk9s1 on /Volumes/My Backup (2) (apfs, local, read-only, journaled)\n",
72+
expected: [
73+
{
74+
mountPoint: "/Volumes/My Backup (2)",
75+
options: "apfs, local, read-only, journaled",
76+
},
77+
],
78+
},
79+
])("parses: $case", ({ input, expected }) => {
80+
expect(parseDarwinMountTable(input)).toEqual(expected);
81+
});
82+
});
83+
84+
describe("isMacosPathOnReadOnlyNonRootMountFromTable", () => {
85+
const baseTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
86+
/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled)
87+
/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled)
88+
`;
89+
const nestedTable = `/dev/x on / (apfs, read-only)
90+
/dev/y on /Volumes/RW (apfs, local, journaled)
91+
/dev/z on /Volumes/RW/nested (apfs, local, read-only)
92+
`;
93+
94+
it.each([
95+
{
96+
case: "path under read-only / is ignored (Users)",
97+
table: baseTable,
98+
path: "/Users/me/app",
99+
expected: false,
100+
},
101+
{
102+
case: "path under read-only / is ignored (Applications)",
103+
table: baseTable,
104+
path: "/Applications/Foo.app",
105+
expected: false,
106+
},
107+
{
108+
case: "read-only non-root volume",
109+
table: baseTable,
110+
path: "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code",
111+
expected: true,
112+
},
113+
{
114+
case: "writable non-root volume",
115+
table: baseTable,
116+
path: "/Volumes/Writable/out/PostHog Code.app/Contents/MacOS/PostHog Code",
117+
expected: false,
118+
},
119+
{
120+
case: "nested read-only mount wins over writable parent",
121+
table: nestedTable,
122+
path: "/Volumes/RW/nested/app",
123+
expected: true,
124+
},
125+
{
126+
case: "writable parent wins when no deeper match",
127+
table: nestedTable,
128+
path: "/Volumes/RW/other/app",
129+
expected: false,
130+
},
131+
])("$case → $expected", ({ table, path, expected }) => {
132+
expect(isMacosPathOnReadOnlyNonRootMountFromTable(path, table)).toBe(
133+
expected,
134+
);
135+
});
136+
});
137+
138+
describe("isMacosPackagedUnsafeBundleLocation", () => {
139+
const writableMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
140+
/dev/disk5s1 on /Volumes/build (apfs, local, journaled)
141+
/dev/disk6s1 on /Applications (apfs, local, journaled)
142+
`;
143+
const readOnlyMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
144+
/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled)
145+
`;
146+
147+
it.each<{
148+
case: string;
149+
appPath: string;
150+
exePath: string;
151+
readMountTable: ReadDarwinMountTable;
152+
expected: boolean;
153+
}>([
154+
{
155+
case: "translocated bundle",
156+
appPath:
157+
"/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar",
158+
exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
159+
readMountTable: () => writableMountTable,
160+
expected: true,
161+
},
162+
{
163+
case: "ordinary non-translocated path on a writable mount",
164+
appPath:
165+
"/Volumes/build/out/PostHog Code.app/Contents/Resources/app.asar",
166+
exePath:
167+
"/Volumes/build/out/PostHog Code.app/Contents/MacOS/PostHog Code",
168+
readMountTable: () => writableMountTable,
169+
expected: false,
170+
},
171+
{
172+
case: "bundle on a read-only non-root volume",
173+
appPath:
174+
"/Volumes/ReadOnlyVol/PostHog Code.app/Contents/Resources/app.asar",
175+
exePath:
176+
"/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code",
177+
readMountTable: () => readOnlyMountTable,
178+
expected: true,
179+
},
180+
{
181+
case: "mount table cannot be read (degrade to non-blocking)",
182+
appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar",
183+
exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
184+
readMountTable: () => null,
185+
expected: false,
186+
},
187+
])("$case → $expected", ({ appPath, exePath, readMountTable, expected }) => {
188+
expect(
189+
isMacosPackagedUnsafeBundleLocation(appPath, exePath, readMountTable),
190+
).toBe(expected);
191+
});
192+
193+
it("short-circuits on translocation without reading the mount table", () => {
194+
const readMountTable = vi.fn(() => writableMountTable);
195+
isMacosPackagedUnsafeBundleLocation(
196+
"/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar",
197+
"/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
198+
readMountTable,
199+
);
200+
expect(readMountTable).not.toHaveBeenCalled();
201+
});
202+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { execFileSync } from "node:child_process";
2+
import path from "node:path";
3+
4+
const APP_TRANSLOCATION_SEGMENT = "AppTranslocation";
5+
const MOUNT_READ_TIMEOUT_MS = 3000;
6+
7+
export type DarwinMountEntry = {
8+
mountPoint: string;
9+
options: string;
10+
};
11+
12+
/**
13+
* Reads the Darwin mount table. Returns `null` when the table cannot be
14+
* obtained (e.g. `/sbin/mount` is missing, times out, or exits non-zero).
15+
*/
16+
export type ReadDarwinMountTable = () => string | null;
17+
18+
/** Parse `/sbin/mount` lines: `<device> on <mountPoint> (<opts>)` */
19+
export function parseDarwinMountTable(output: string): DarwinMountEntry[] {
20+
const entries: DarwinMountEntry[] = [];
21+
for (const line of output.split("\n")) {
22+
const onMarker = line.indexOf(" on ");
23+
if (onMarker === -1) continue;
24+
const afterOn = line.slice(onMarker + 4);
25+
// `lastIndexOf` anchors to the trailing options block, so mount points
26+
// whose display names contain " (" (e.g. "/Volumes/My Backup (2)") still
27+
// parse correctly. The `line.endsWith(")")` check guarantees those parens
28+
// really are the options.
29+
const openParen = afterOn.lastIndexOf(" (");
30+
if (openParen === -1 || !line.endsWith(")")) continue;
31+
const mountPoint = afterOn.slice(0, openParen);
32+
const options = afterOn.slice(openParen + 2, -1);
33+
entries.push({ mountPoint, options });
34+
}
35+
return entries;
36+
}
37+
38+
function mountOptionsImplyReadOnly(options: string): boolean {
39+
return options.toLowerCase().includes("read-only");
40+
}
41+
42+
function longestMatchingMount(
43+
resolvedPath: string,
44+
entries: DarwinMountEntry[],
45+
): DarwinMountEntry | null {
46+
let best: DarwinMountEntry | null = null;
47+
for (const e of entries) {
48+
const mp = e.mountPoint;
49+
// For `/` we'd otherwise build `//` which no real path starts with, so the
50+
// root mount would silently drop out of the comparison and the
51+
// `best.mountPoint === "/"` guard below would be unreachable.
52+
const under =
53+
resolvedPath === mp ||
54+
resolvedPath.startsWith(mp === "/" ? "/" : `${mp}/`);
55+
if (!under) continue;
56+
if (!best || mp.length > best.mountPoint.length) {
57+
best = e;
58+
}
59+
}
60+
return best;
61+
}
62+
63+
/**
64+
* True when `resolvedAbsolutePath` sits on a **non-root** mount that `mount(8)`
65+
* reports as read-only (e.g. many DMGs, some external volumes).
66+
*
67+
* Ignores read-only `/` — on sealed macOS the system volume is read-only while
68+
* normal apps under /Applications or /Users still work.
69+
*/
70+
export function isMacosPathOnReadOnlyNonRootMountFromTable(
71+
resolvedAbsolutePath: string,
72+
mountTable: string,
73+
): boolean {
74+
const normalized = path.resolve(resolvedAbsolutePath);
75+
const entries = parseDarwinMountTable(mountTable);
76+
const best = longestMatchingMount(normalized, entries);
77+
if (!best || best.mountPoint === "/") {
78+
return false;
79+
}
80+
return mountOptionsImplyReadOnly(best.options);
81+
}
82+
83+
/**
84+
* Reads `/sbin/mount` synchronously. A short timeout keeps a hung NFS/SMB
85+
* share from freezing app startup — the exact failure mode this guard exists
86+
* to prevent. Returns `null` on any failure so callers can degrade to "don't
87+
* block".
88+
*/
89+
function readDarwinMountTableSync(): string | null {
90+
try {
91+
return execFileSync("/sbin/mount", {
92+
encoding: "utf8",
93+
maxBuffer: 10 * 1024 * 1024,
94+
timeout: MOUNT_READ_TIMEOUT_MS,
95+
});
96+
} catch {
97+
return null;
98+
}
99+
}
100+
101+
/**
102+
* True when either path is under macOS App Translocation (read-only runtime).
103+
* Caller should gate on packaged darwin before using this to block startup.
104+
*/
105+
export function isMacosAppTranslocationPath(
106+
appPath: string,
107+
exePath: string,
108+
): boolean {
109+
return (
110+
appPath.includes(APP_TRANSLOCATION_SEGMENT) ||
111+
exePath.includes(APP_TRANSLOCATION_SEGMENT)
112+
);
113+
}
114+
115+
/**
116+
* Packaged macOS: translocated bundle path, or binary on a non-root read-only
117+
* mount (see mount(8)).
118+
*
119+
* `readMountTable` is injectable so tests can drive the mount-table branch
120+
* deterministically instead of relying on the host's real `/sbin/mount`.
121+
*/
122+
export function isMacosPackagedUnsafeBundleLocation(
123+
appPath: string,
124+
exePath: string,
125+
readMountTable: ReadDarwinMountTable = readDarwinMountTableSync,
126+
): boolean {
127+
if (isMacosAppTranslocationPath(appPath, exePath)) {
128+
return true;
129+
}
130+
const table = readMountTable();
131+
if (table === null) {
132+
return false;
133+
}
134+
return isMacosPathOnReadOnlyNonRootMountFromTable(
135+
path.resolve(exePath),
136+
table,
137+
);
138+
}

0 commit comments

Comments
 (0)