Skip to content
Closed
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
16 changes: 14 additions & 2 deletions docs-site/src/content/docs/guides/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,23 @@ bundled binary of the discovered bundle; if that bundle has no app-server binary
Before writing the launcher, `launch` also checks that the bundle and its
app-server binary are owned by you or root, are not writable by group or others,
and pass strict code-signature verification under OpenAI's team ID
(`2DC432GLL2`). A bundle that fails any of these is refused, so a copy placed by
another account cannot be made to run inside your session. The launcher file is
(`2DC432GLL2`). A bundle that fails any of these checks is refused, including
one owned by another account. The launcher file is
written to a temporary file and renamed into place; an existing symbolic link at
that path is replaced, not followed.

`restore` applies the same ownership, permissions, and signature checks to the
bundle and its main app executable before quitting or opening it. It works with
the experimental flag off and without a bundled app-server binary. If trust
verification or relaunch fails, the existing launcher is kept for recovery.

Both commands also check the folders containing the bundle up to the filesystem
root. Folders owned by another account, symbolic links, and ordinary group- or
world-writable parents are refused. Root-owned administrator-group installation
folders and trusted sticky folders retain their normal permissions behavior.
These are ownership, POSIX-permission, and signature checks; native ACL and
volume ownership-policy behavior has not been verified.

This integration installs no certificate, network listener, PAC, or background
watcher. It does not log the app's messages or environment. Status reports whether
the running ChatGPT bundle process carries the expected launcher override.
Expand Down
50 changes: 40 additions & 10 deletions src/chatgpt/app-server-shim/bundle-trust.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { spawnSync } from "node:child_process";
import { lstatSync } from "node:fs";
import { dirname, relative, sep } from "node:path";
import { dirname, join, relative, sep } from "node:path";

/**
* The launcher persistently execs a binary taken from a discovered bundle, so the bundle must be
* one this user can trust: OpenAI-signed, and not replaceable by another local account. Bundle
* Launch and restore execute code from a discovered bundle, so the bundle must be
* OpenAI-signed and pass ownership/permission checks along its filesystem path. Bundle
* identifier alone (what discovery checks) is plain text in Info.plist that anyone can copy.
*/
export const OPENAI_TEAM_ID = "2DC432GLL2";
Expand All @@ -14,17 +14,23 @@ const CODESIGN_TIMEOUT_MS = 30_000;

export interface BundleTrustDeps {
readonly uid: number;
stat(path: string): { uid: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null;
readonly adminGroupId?: number;
stat(path: string): { uid: number; gid?: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null;
codesign(args: readonly string[]): { status: number | null; output: string };
}

export function defaultBundleTrustDeps(): BundleTrustDeps {
const admin = spawnSync("/usr/bin/dscl", [".", "-read", "/Groups/admin", "PrimaryGroupID"], {
encoding: "utf8", timeout: 5000,
});
const adminId = admin.status === 0 ? /^PrimaryGroupID: ([0-9]+)\s*$/.exec(admin.stdout)?.[1] : undefined;
return {
adminGroupId: adminId === undefined ? undefined : Number(adminId),
uid: typeof process.getuid === "function" ? process.getuid() : -1,
stat(path) {
try {
const s = lstatSync(path);
return { uid: s.uid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() };
return { uid: s.uid, gid: s.gid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() };
} catch {
return null;
}
Expand All @@ -46,26 +52,50 @@ function ownedSafely(deps: BundleTrustDeps, path: string, kind: "file" | "direct
return null;
}

/** Protect the bundle's directory entry all the way to the filesystem root. */
function untrustedAncestorReason(bundleRoot: string, deps: BundleTrustDeps): string | null {
for (let dir = dirname(bundleRoot); ; dir = dirname(dir)) {
const s = deps.stat(dir);
if (!s || !s.isDirectory || s.isSymbolicLink) return `${dir} is not a regular ancestor directory`;
if (s.uid !== 0 && s.uid !== deps.uid) return `${dir} is owned by another user`;
// A trusted sticky parent protects the already-checked child from replacement.
// Root-owned admin-group install containers are privileged OS-controlled paths;
// resolve that group from the local directory service rather than assuming a GID.
const sticky = (s.mode & 0o1000) !== 0;
const privileged = s.uid === 0 && (s.mode & 0o002) === 0
&& deps.adminGroupId !== undefined && s.gid === deps.adminGroupId;
if ((s.mode & 0o022) !== 0 && !sticky && !privileged) return `${dir} is writable by group or others`;
if (dirname(dir) === dir) return null;
}
}

/**
* Null when the bundle root and the app-server binary inside it may be exec'd by this user;
* otherwise a short reason. Every directory from the binary up to the bundle root, and the
* Null when the bundle root and the selected executable may be run by this user.
* Restore omits the app-server binary and validates the app shell instead, so a missing
* app-server does not prevent returning to a normal launch. Otherwise returns a short reason.
* Every directory from the binary up to the bundle root, and the
* binary itself, must be owned by root or this user and not group/other-writable, and both the
* bundle and the binary must pass strict code-signature verification under OpenAI's team ID.
* Ancestors above the bundle are also checked for POSIX directory-entry replacement rights.
*/
export function untrustedChatgptBundleReason(
bundleRoot: string,
binary: string,
binary: string | undefined = undefined,
deps: BundleTrustDeps = defaultBundleTrustDeps(),
): string | null {
const label = binary === undefined ? "app executable" : "app-server binary";
binary ??= join(bundleRoot, "Contents", "MacOS", "ChatGPT");
const inside = relative(bundleRoot, binary);
if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return "the app-server binary is outside the bundle";
if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return `the ${label} is outside the bundle`;
const fileProblem = ownedSafely(deps, binary, "file");
if (fileProblem) return `the app-server binary ${fileProblem}`;
if (fileProblem) return `the ${label} ${fileProblem}`;
for (let dir = dirname(binary); ; dir = dirname(dir)) {
const dirProblem = ownedSafely(deps, dir, "directory");
if (dirProblem) return `${dir} ${dirProblem}`;
if (dir === bundleRoot || dirname(dir) === dir) break;
}
const ancestorProblem = untrustedAncestorReason(bundleRoot, deps);
if (ancestorProblem) return ancestorProblem;
for (const target of [bundleRoot, binary]) {
if (deps.codesign(["--verify", "--strict", target]).status !== 0) return `${target} failed code-signature verification`;
const details = deps.codesign(["-dv", "--verbose=2", target]);
Expand Down
17 changes: 10 additions & 7 deletions src/cli/chatgpt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,26 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
console.error("ChatGPT (com.openai.codex) was not found; install or open it once, then retry.");
return 1;
}
let binary: string | undefined;
if (sub === "launch") {
if (config.chatgptDesktop?.appServerShim !== true) {
console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.');
return 1;
}
const binary = resolveChatgptCodexBinary(install.root);
binary = resolveChatgptCodexBinary(install.root) ?? undefined;
if (!binary) {
console.error(`No bundled app-server binary was found in ${install.root}; the shim cannot launch this build.`);
return 1;
}
const untrusted = untrustedChatgptBundleReason(install.root, binary);
if (untrusted) {
console.error(`Refusing to launch the shim: ${untrusted}.`);
return 1;
}
writeChatgptShimLauncher(undefined, binary);
}
// Both relaunch paths execute the discovered bundle. Restore validates the app
// shell without requiring an app-server binary or the experimental opt-in flag.
const untrusted = untrustedChatgptBundleReason(install.root, binary);
if (untrusted) {
console.error(`Refusing to ${sub === "launch" ? "launch the shim" : "restore ChatGPT"}: ${untrusted}.`);
return 1;
}
if (binary) writeChatgptShimLauncher(undefined, binary);
if (!(await quitApp(install, launcher))) {
console.error("ChatGPT did not quit; quit it manually and retry.");
return 1;
Expand Down
10 changes: 8 additions & 2 deletions structure/clients/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ OpenAI's. It writes the mode-0755 executable through an exclusive temp file and
rename (never through a symbolic link), quits the bundle by id, waits for this user's
instance to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH.
The launcher itself exits 127 with a stderr hint when the recorded binary is gone.
Restore relaunches without
that override and removes the launcher only after open succeeds; when no
Restore uses the same trust policy for the bundle and its main app executable before
quit or open. Both relaunch paths check ancestor ownership and POSIX replacement permissions
through the filesystem root. Ancestors must be owned by this user or root; group/other write
is accepted only for trusted sticky ancestors or root-owned, non-world-writable containers
whose group matches the local directory service's admin group. An unreadable admin-group lookup
does not grant that exception. These checks do not attest ACL or mount-policy restrictions.
Restore does not require the experimental flag or a bundled
app-server binary. It relaunches without that override and removes the launcher only after open succeeds; when no
`com.openai.codex` bundle is found it removes the launcher, relaunches nothing and
exits 1. Status reports
the experimental flag, launcher presence, and the verified bundle process's override
Expand Down
122 changes: 121 additions & 1 deletion tests/clients/desktop-app-server-shim-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
writeChatgptShimLauncher,
} from "../../src/chatgpt/app-server-shim/launcher";
import { selfLaunchArgv } from "../../src/lib/self-launch-argv";
import { repoPath } from "../helpers/repo-root";
import { helperPath, repoPath } from "../helpers/repo-root";
import { removeTreeWithRetry } from "../helpers/remove-tree";

const bashAvailable = process.platform !== "win32"
Expand Down Expand Up @@ -172,3 +172,123 @@ describe("bundle trust before the launcher is written", () => {
expect(untrustedChatgptBundleReason(root, "/tmp/codex", deps())).toContain("outside the bundle");
});
});


describe("restore validates the discovered app before any quit or open", () => {
const run = (scenario: Record<string, unknown>) => {
const dir = mkdtempSync(join(tmpdir(), "ocx-shim-command-"));
try {
writeFileSync(join(dir, "launcher.sh"), "preserve until restore succeeds");
const result = spawnSync(process.execPath, [helperPath("desktop-app-server-shim-command-child.ts"), dir, JSON.stringify(scenario)], {
encoding: "utf8", timeout: 10000, env: { ...process.env, CODEX_CLI_PATH: "inherited-override" },
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
return { ...JSON.parse(result.stdout.trim().split("\n").at(-1)!), stderr: result.stderr } as {
code: number; calls: Array<{ command: string; args: string[]; override?: string }>;
launcherExists: boolean; root: string; shell: string; binary: string; stderr: string;
};
} finally { removeTreeWithRetry(dir); }
};
for (const trust of ["foreign-owner", "foreign-parent", "writable", "unsigned", "foreign-team", "symlink"]) {
test(`restore refuses ${trust} before quit or open`, () => {
const result = run({ trust, running: true, flag: false, missingBinary: true });
expect(result.code).toBe(1);
expect(result.launcherExists).toBe(true);
expect(result.stderr).toContain("Refusing to restore");
expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]);
});
}
test("valid restore works with the flag off and no bundled app-server", () => {
const result = run({ flag: false, missingBinary: true, running: true });
expect(result.code).toBe(0);
expect(result.launcherExists).toBe(false);
expect(result.calls.filter(call => call.command === "/usr/bin/codesign" && call.args[0] === "--verify").map(call => call.args.at(-1))).toEqual([result.root, result.shell]);
const quit = result.calls.findIndex(call => call.command === "/usr/bin/osascript");
const open = result.calls.findIndex(call => call.command === "/usr/bin/open");
expect(quit).toBeGreaterThan(0);
expect(open).toBeGreaterThan(quit);
expect(result.calls[open]).toEqual({ command: "/usr/bin/open", args: ["-a", result.root] });
});
test("admin-writable parents need a successful local admin-group lookup", () => {
const trusted = run({ adminParent: true });
expect(trusted.code).toBe(0);
expect(trusted.calls.find(call => call.command === "/usr/bin/dscl")?.args).toEqual([".", "-read", "/Groups/admin", "PrimaryGroupID"]);
const unknown = run({ adminParent: true, adminLookupFails: true });
expect(unknown.code).toBe(1);
expect(unknown.launcherExists).toBe(true);
expect(unknown.calls.some(call => call.command === "/usr/bin/open")).toBe(false);
});
test("failed open preserves the launcher", () => {
const result = run({ openFails: true });
expect(result.code).toBe(1);
expect(result.launcherExists).toBe(true);
expect(result.calls.some(call => call.command === "/usr/bin/open")).toBe(true);
});
test("no installed app still removes only the launcher and returns an error", () => {
const result = run({ noInstall: true });
expect(result.code).toBe(1);
expect(result.launcherExists).toBe(false);
expect(result.calls).toEqual([]);
});
test("status remains read-only even when bundle trust would fail", () => {
const result = run({ sub: "status", trust: "foreign-owner", running: true });
expect(result.code).toBe(0);
expect(result.launcherExists).toBe(true);
expect(result.calls.map(call => call.command)).toEqual(["pgrep", "ps"]);
});
test("launch still requires opt-in and an app-server binary", () => {
for (const scenario of [{ flag: false }, { flag: true, missingBinary: true }]) {
const result = run({ sub: "launch", ...scenario });
expect(result.code).toBe(1);
expect(result.calls).toEqual([]);
}
const result = run({ sub: "launch", flag: true });
expect(result.code).toBe(0);
expect(result.calls.filter(call => call.command === "/usr/bin/codesign" && call.args[0] === "--verify").map(call => call.args.at(-1))).toEqual([result.root, result.binary]);
expect(result.calls.find(call => call.command === "/usr/bin/open")?.args[2]).toBe("--env");
});
});


describe("bundle ancestor replacement boundary", () => {
const root = "/Applications/ChatGPT.app";
const shell = `${root}/Contents/MacOS/ChatGPT`;
function check(parent: Partial<NonNullable<ReturnType<BundleTrustDeps["stat"]>>> = {}, adminGroupId: number | null = 80) {
const observed: string[] = [];
const reason = untrustedChatgptBundleReason(root, undefined, {
uid: 501, adminGroupId: adminGroupId ?? undefined,
stat(path) {
observed.push(path);
return { uid: path === shell || path.startsWith(`${root}/`) || path === root ? 501 : 0,
gid: 80, mode: 0o755, isFile: path === shell, isDirectory: path !== shell, isSymbolicLink: false,
...(path === "/Applications" ? parent : {}),
};
},
codesign: () => ({ status: 0, output: `TeamIdentifier=${OPENAI_TEAM_ID}\n` }),
});
return { reason, observed };
}
test("ordinary owned ancestry is checked through the filesystem root", () => {
const result = check();
expect(result.reason).toBeNull();
expect(result.observed.slice(-2)).toEqual(["/Applications", "/"]);
});
test("root-owned admin-group installation directories retain compatibility", () => {
expect(check({ mode: 0o775 }).reason).toBeNull();
expect(check({ mode: 0o775, gid: 456 }, 456).reason).toBeNull();
expect(check({ mode: 0o775, gid: 20 }).reason).toContain("writable");
expect(check({ mode: 0o775 }, null).reason).toContain("writable");
});
test("trusted sticky parents protect an owned child; foreign owners do not", () => {
expect(check({ mode: 0o1777 }).reason).toBeNull();
expect(check({ uid: 501, mode: 0o1777 }).reason).toBeNull();
expect(check({ uid: 502, mode: 0o1777 }).reason).toContain("another user");
});
test("foreign-owned, writable, and symbolic-link ancestors are refused", () => {
expect(check({ uid: 502 }).reason).toContain("another user");
expect(check({ mode: 0o777 }).reason).toContain("writable");
expect(check({ uid: 501, mode: 0o775 }).reason).toContain("writable");
expect(check({ isSymbolicLink: true }).reason).toContain("ancestor directory");
});
});
Loading
Loading