From ac4266ec1fad6a63ccdf07c4186a19c21b1c2e68 Mon Sep 17 00:00:00 2001 From: Travis Hinton Date: Fri, 1 May 2026 16:13:27 -0600 Subject: [PATCH 1/2] Support codex-app Linux install layout --- packages/installer/src/commands/install.ts | 16 ++-- packages/installer/src/platform.ts | 73 ++++++++++++++++--- packages/installer/test/platform.test.ts | 42 ++++++++++- .../installer/test/tweak-commands.test.ts | 20 +++++ 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/packages/installer/src/commands/install.ts b/packages/installer/src/commands/install.ts index 396e352..9ad631d 100644 --- a/packages/installer/src/commands/install.ts +++ b/packages/installer/src/commands/install.ts @@ -236,14 +236,16 @@ function patchCodexWindowServices(appDir: string, originalMain: string): void { throw new Error("Codex window services hook point not found"); } -function findCodexMainCandidates(appDir: string, originalMain: string): string[] { +export function findCodexMainCandidates(appDir: string, originalMain: string): string[] { const out = [resolve(appDir, originalMain)]; - const buildDir = resolve(appDir, ".vite", "build"); - try { - for (const name of readdirSync(buildDir)) { - if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name)); - } - } catch {} + const originalMainDir = resolve(appDir, originalMain, ".."); + for (const buildDir of [originalMainDir, resolve(appDir, ".vite", "build")]) { + try { + for (const name of readdirSync(buildDir)) { + if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name)); + } + } catch {} + } return [...new Set(out)].filter((p) => existsSync(p)); } diff --git a/packages/installer/src/platform.ts b/packages/installer/src/platform.ts index 53846a7..2b4e662 100644 --- a/packages/installer/src/platform.ts +++ b/packages/installer/src/platform.ts @@ -1,6 +1,6 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { readPlist } from "./plist.js"; export type Platform = "darwin" | "win32" | "linux"; @@ -167,16 +167,24 @@ function locateWin(override?: string): CodexInstall { } function locateLinux(override?: string): CodexInstall { - // Codex isn't yet shipped on Linux at time of writing; assume an Electron-style - // unpacked install or a deb/rpm in /opt. + // Linux builds are distributed by community ports today. Support unpacked + // Electron installs from deb/rpm packages as well as user-local symlinked + // installs used by am-will/codex-app. const candidates = [ override, + "/usr/lib/codex-desktop", + "/opt/codex-desktop/current", + "/opt/codex-desktop", "/opt/Codex", "/opt/codex", + join(homedir(), ".local", "opt", "codex-desktop", "current"), + join(homedir(), ".local", "opt", "codex-desktop"), + join(homedir(), ".local", "share", "codex-desktop", "current"), + join(homedir(), ".local", "share", "codex-desktop"), join(homedir(), ".local", "share", "Codex"), ].filter(Boolean) as string[]; - const appRoot = candidates.find((p) => existsSync(join(p, "resources", "app.asar"))); - if (!appRoot) { + const install = candidates.map(resolveLinuxInstall).find((p): p is LinuxInstallCandidate => p !== null); + if (!install) { throw new Error( `[!] Codex App Not Found\n\n` + `Ensure Codex is installed in a supported Linux location.\n` + @@ -184,17 +192,64 @@ function locateLinux(override?: string): CodexInstall { `If Codex is somewhere else, rerun with --app pointing at its install folder.`, ); } - const resourcesDir = join(appRoot, "resources"); + const { appRoot, resourcesDir, executable } = install; return { appRoot, resourcesDir, asarPath: join(resourcesDir, "app.asar"), metaPath: null, - electronBinary: join(appRoot, "codex"), - executable: join(appRoot, "codex"), + electronBinary: executable, + executable, appName: "Codex", bundleId: null, channel: "stable", platform: "linux", }; } + +interface LinuxInstallCandidate { + appRoot: string; + resourcesDir: string; + executable: string; +} + +function resolveLinuxInstall(candidate: string): LinuxInstallCandidate | null { + let resolved = candidate; + try { + resolved = realpathSync(candidate); + } catch { + // Keep the original path so the directory checks below can fail normally. + } + + const roots: string[] = []; + if (existsSync(resolved)) { + try { + const stat = statSync(resolved); + if (stat.isDirectory()) { + roots.push(resolved); + if (basename(resolved) === "resources") roots.push(resolve(resolved, "..")); + } else if (stat.isFile()) { + roots.push(resolve(resolved, "..")); + } + } catch {} + } + roots.push(resolved); + + for (const root of [...new Set(roots)]) { + const resourcesDir = join(root, "resources"); + if (!existsSync(join(resourcesDir, "app.asar"))) continue; + const executable = findLinuxExecutable(root); + if (!executable) continue; + return { appRoot: root, resourcesDir, executable }; + } + return null; +} + +function findLinuxExecutable(appRoot: string): string | null { + const candidates = [ + "Codex", + "codex-desktop", + "codex", + ].map((name) => join(appRoot, name)); + return candidates.find((p) => existsSync(p)) ?? null; +} diff --git a/packages/installer/test/platform.test.ts b/packages/installer/test/platform.test.ts index 00e747a..f3d8924 100644 --- a/packages/installer/test/platform.test.ts +++ b/packages/installer/test/platform.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -41,3 +41,43 @@ test("locateCodex reads beta bundle metadata from override path on macOS", { ski rmSync(root, { recursive: true, force: true }); } }); + +test("locateCodex supports am-will codex-app Linux install directory", { skip: process.platform !== "linux" }, () => { + const root = mkdtempSync(join(tmpdir(), "codexpp-platform-")); + try { + const app = join(root, "codex-desktop"); + mkdirSync(join(app, "resources"), { recursive: true }); + writeFileSync(join(app, "resources", "app.asar"), ""); + writeFileSync(join(app, "Codex"), ""); + + const codex = locateCodex(app); + assert.equal(codex.appRoot, app); + assert.equal(codex.resourcesDir, join(app, "resources")); + assert.equal(codex.asarPath, join(app, "resources", "app.asar")); + assert.equal(codex.electronBinary, join(app, "Codex")); + assert.equal(codex.executable, join(app, "Codex")); + assert.equal(codex.platform, "linux"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("locateCodex accepts a Linux launcher symlink override", { skip: process.platform !== "linux" }, () => { + const root = mkdtempSync(join(tmpdir(), "codexpp-platform-")); + try { + const app = join(root, "codex-desktop"); + const bin = join(root, "bin"); + mkdirSync(join(app, "resources"), { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(app, "resources", "app.asar"), ""); + writeFileSync(join(app, "Codex"), ""); + symlinkSync(join(app, "Codex"), join(bin, "codex-desktop")); + + const codex = locateCodex(join(bin, "codex-desktop")); + assert.equal(codex.appRoot, app); + assert.equal(codex.resourcesDir, join(app, "resources")); + assert.equal(codex.electronBinary, join(app, "Codex")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/installer/test/tweak-commands.test.ts b/packages/installer/test/tweak-commands.test.ts index cf98b4d..2c50513 100644 --- a/packages/installer/test/tweak-commands.test.ts +++ b/packages/installer/test/tweak-commands.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -10,6 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { buildCliFailureIssueUrl, buildPatchFailureIssueUrl } from "../src/alerts"; +import { findCodexMainCandidates } from "../src/commands/install"; import { createTweak } from "../src/commands/create-tweak"; import { devTweak } from "../src/commands/dev-tweak"; import { safeMode } from "../src/commands/safe-mode"; @@ -265,6 +267,24 @@ test("window services patch ignores unrelated buildFlavor factories", () => { assert.equal(patchCodexWindowServicesSource(source), null); }); +test("main candidate discovery supports nested recovered app bundle layout", () => { + withTempDir((root) => { + const buildDir = join(root, "recovered", "app-asar-extracted", ".vite", "build"); + mkdirSync(buildDir, { recursive: true }); + const bootstrap = join(buildDir, "bootstrap.js"); + const main = join(buildDir, "main-SLemWUtC.js"); + writeFileSync(bootstrap, ""); + writeFileSync(main, ""); + + const candidates = findCodexMainCandidates( + root, + "recovered/app-asar-extracted/.vite/build/bootstrap.js", + ); + + assert.deepEqual(candidates, [bootstrap, main]); + }); +}); + test("patch failure report URL includes a prefilled GitHub issue", () => { const url = new URL(buildPatchFailureIssueUrl("Codex window services hook point not found")); From cb1416bfa7aa9a36650c6e2f880e68e7527b1c32 Mon Sep 17 00:00:00 2001 From: Travis Hinton Date: Thu, 9 Jul 2026 16:19:06 -0600 Subject: [PATCH 2/2] Handle app updates and ChatGPT service factories --- packages/installer/src/backups.ts | 80 ++++++++ .../installer/src/codex-window-services.ts | 3 +- packages/installer/src/commands/install.ts | 10 +- packages/installer/test/backups.test.ts | 180 ++++++++++++++++++ .../installer/test/tweak-commands.test.ts | 14 ++ 5 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 packages/installer/src/backups.ts create mode 100644 packages/installer/test/backups.test.ts diff --git a/packages/installer/src/backups.ts b/packages/installer/src/backups.ts new file mode 100644 index 0000000..28b9b22 --- /dev/null +++ b/packages/installer/src/backups.ts @@ -0,0 +1,80 @@ +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { readFileInAsar, readHeaderHash } from "./asar.js"; +import type { InstallerState } from "./state.js"; + +interface PrepareBackupSetOptions { + appRoot: string; + asarPath: string; + backupDir: string; + previousState: InstallerState | null; +} + +/** + * Keep the global backup set aligned with the app installation being patched. + * A repeated install of an already-patched app must retain its pristine backup; + * a new app build must replace backups from the previous build. + */ +export function prepareBackupSet({ + appRoot, + asarPath, + backupDir, + previousState, +}: PrepareBackupSetOptions): boolean { + mkdirSync(backupDir, { recursive: true }); + + const backupAsar = join(backupDir, "app.asar"); + const currentHash = readHeaderHash(asarPath).headerHash; + const sameInstall = previousState?.appRoot === appRoot; + const currentIsPatched = hasCodexPlusPlusMarker(asarPath); + + if (sameInstall && currentHash === previousState.patchedAsarHash) { + const backupHash = readBackupHash(backupAsar); + if (backupHash !== previousState.originalAsarHash) { + throw new Error( + "Codex++ backup does not match the patched app. Restore or reinstall " + + "Codex before running install again; the current app cannot be safely backed up.", + ); + } + return false; + } + + if (currentIsPatched) { + throw new Error( + "Codex is already patched, but it does not match the saved installer state. " + + "Restore or reinstall Codex before running install again.", + ); + } + + const backupHash = readBackupHash(backupAsar); + if ( + sameInstall && + currentHash === previousState.originalAsarHash && + backupHash === previousState.originalAsarHash + ) { + return false; + } + + const rotated = readdirSync(backupDir).length > 0; + if (rotated) { + rmSync(backupDir, { recursive: true, force: true }); + mkdirSync(backupDir, { recursive: true }); + } + return rotated; +} + +function hasCodexPlusPlusMarker(asarPath: string): boolean { + const pkg = JSON.parse(readFileInAsar(asarPath, "package.json").toString()) as { + __codexpp?: unknown; + }; + return pkg.__codexpp !== undefined; +} + +function readBackupHash(backupAsar: string): string | null { + if (!existsSync(backupAsar)) return null; + try { + return readHeaderHash(backupAsar).headerHash; + } catch { + return null; + } +} diff --git a/packages/installer/src/codex-window-services.ts b/packages/installer/src/codex-window-services.ts index 0ac006f..8206ef0 100644 --- a/packages/installer/src/codex-window-services.ts +++ b/packages/installer/src/codex-window-services.ts @@ -13,7 +13,8 @@ interface ServiceFactoryAssignment { } const IDENT_RE = /^[$A-Za-z_][$A-Za-z0-9_]*$/; -const BUILD_FLAVOR_CALL_RE = /([$A-Za-z_][$A-Za-z0-9_]*)\(\{\s*buildFlavor\s*:/g; +const BUILD_FLAVOR_CALL_RE = + /([$A-Za-z_][$A-Za-z0-9_]*)\(\{[^{}]*?\bbuildFlavor\s*:/g; const WINDOW_SERVICE_FINGERPRINTS = [ "allowDevtools:", "allowDebugMenu:", diff --git a/packages/installer/src/commands/install.ts b/packages/installer/src/commands/install.ts index 9ad631d..7618b81 100644 --- a/packages/installer/src/commands/install.ts +++ b/packages/installer/src/commands/install.ts @@ -10,7 +10,8 @@ import { setIntegrity, getIntegrity } from "../integrity.js"; import { writeFuse } from "../fuses.js"; import { adHocSign, clearQuarantine, signatureInfo } from "../codesign.js"; import { readPlist } from "../plist.js"; -import { writeState } from "../state.js"; +import { readState, writeState } from "../state.js"; +import { prepareBackupSet } from "../backups.js"; import { installWatcher, type WatcherKind } from "../watcher.js"; import { CODEX_PLUSPLUS_VERSION } from "../version.js"; import { installDefaultTweaks } from "../default-tweaks.js"; @@ -59,6 +60,13 @@ export async function install(opts: Opts = {}): Promise { step(formatCliShimResult(installCliShims(paths.binDir))); // 1. Backup originals. + const backupRotated = prepareBackupSet({ + appRoot: codex.appRoot, + asarPath: codex.asarPath, + backupDir: paths.backup, + previousState: readState(paths.stateFile), + }); + if (backupRotated) step("Retired backup from previous Codex build"); const pristineAppBackup = codex.platform === "darwin" ? join(paths.backup, "Codex.app") : null; const backupAsar = join(paths.backup, "app.asar"); const backupAsarUnpacked = join(paths.backup, "app.asar.unpacked"); diff --git a/packages/installer/test/backups.test.ts b/packages/installer/test/backups.test.ts new file mode 100644 index 0000000..bb1212b --- /dev/null +++ b/packages/installer/test/backups.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; +import asar from "@electron/asar"; +import { backupOnce, readFileInAsar, readHeaderHash } from "../src/asar.js"; +import { prepareBackupSet } from "../src/backups.js"; +import type { InstallerState } from "../src/state.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +test("new app installation replaces a stale backup set", async () => { + const root = makeRoot(); + const oldAsar = await createAsar(root, "old.asar", { version: "1" }); + const currentAsar = await createAsar(root, "current.asar", { version: "2" }); + const backupDir = join(root, "backup"); + mkdirSync(backupDir); + cpSync(oldAsar, join(backupDir, "app.asar")); + writeFileSync(join(backupDir, "Electron Framework"), "old"); + + const rotated = prepareBackupSet({ + appRoot: "/apps/new", + asarPath: currentAsar, + backupDir, + previousState: stateFor("/apps/old", oldAsar, oldAsar), + }); + backupOnce(currentAsar, join(backupDir, "app.asar")); + + assert.equal(rotated, true); + assert.equal(readPackageVersion(join(backupDir, "app.asar")), "2"); + assert.equal(existsSync(join(backupDir, "Electron Framework")), false); +}); + +test("in-place app update replaces the previous build backup", async () => { + const root = makeRoot(); + const oldAsar = await createAsar(root, "old.asar", { version: "1" }); + const currentAsar = await createAsar(root, "current.asar", { version: "2" }); + const backupDir = join(root, "backup"); + mkdirSync(backupDir); + cpSync(oldAsar, join(backupDir, "app.asar")); + + const rotated = prepareBackupSet({ + appRoot: "/apps/current", + asarPath: currentAsar, + backupDir, + previousState: stateFor("/apps/current", oldAsar, oldAsar), + }); + + assert.equal(rotated, true); +}); + +test("repeated install preserves a matching pristine backup", async () => { + const root = makeRoot(); + const pristineAsar = await createAsar(root, "pristine.asar", { version: "2" }); + const patchedAsar = await createAsar(root, "patched.asar", { + version: "2", + main: "codex-plusplus-loader.cjs", + __codexpp: { originalMain: "bootstrap.js" }, + }); + const backupDir = join(root, "backup"); + mkdirSync(backupDir); + cpSync(pristineAsar, join(backupDir, "app.asar")); + + const rotated = prepareBackupSet({ + appRoot: "/apps/current", + asarPath: patchedAsar, + backupDir, + previousState: stateFor("/apps/current", pristineAsar, patchedAsar), + }); + + assert.equal(rotated, false); + assert.equal(readPackageVersion(join(backupDir, "app.asar")), "2"); +}); + +test("repeated install rejects a stale pristine backup", async () => { + const root = makeRoot(); + const staleAsar = await createAsar(root, "stale.asar", { version: "1" }); + const pristineAsar = await createAsar(root, "pristine.asar", { version: "2" }); + const patchedAsar = await createAsar(root, "patched.asar", { + version: "2", + main: "codex-plusplus-loader.cjs", + __codexpp: { originalMain: "bootstrap.js" }, + }); + const backupDir = join(root, "backup"); + mkdirSync(backupDir); + cpSync(staleAsar, join(backupDir, "app.asar")); + + assert.throws( + () => + prepareBackupSet({ + appRoot: "/apps/current", + asarPath: patchedAsar, + backupDir, + previousState: stateFor("/apps/current", pristineAsar, patchedAsar), + }), + /backup does not match the patched app/, + ); + assert.equal(readPackageVersion(join(backupDir, "app.asar")), "1"); +}); + +test("invalid target metadata does not retire the existing backup", async () => { + const root = makeRoot(); + const staleAsar = await createAsar(root, "stale.asar", { version: "1" }); + const invalidAsar = await createAsarWithPackage(root, "invalid.asar", "{invalid"); + const backupDir = join(root, "backup"); + mkdirSync(backupDir); + cpSync(staleAsar, join(backupDir, "app.asar")); + + assert.throws(() => + prepareBackupSet({ + appRoot: "/apps/new", + asarPath: invalidAsar, + backupDir, + previousState: null, + }), + ); + assert.equal(readPackageVersion(join(backupDir, "app.asar")), "1"); +}); + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), "codexpp-backups-")); + roots.push(root); + return root; +} + +async function createAsar( + root: string, + name: string, + pkg: Record, +): Promise { + const source = join(root, `${name}.source`); + const destination = join(root, name); + mkdirSync(source); + writeFileSync( + join(source, "package.json"), + JSON.stringify({ name: "test-codex", main: "bootstrap.js", ...pkg }), + ); + await asar.createPackage(source, destination); + return destination; +} + +async function createAsarWithPackage( + root: string, + name: string, + packageSource: string, +): Promise { + const source = join(root, `${name}.source`); + const destination = join(root, name); + mkdirSync(source); + writeFileSync(join(source, "package.json"), packageSource); + await asar.createPackage(source, destination); + return destination; +} + +function stateFor(appRoot: string, originalAsar: string, patchedAsar: string): InstallerState { + return { + version: "test", + installedAt: new Date(0).toISOString(), + appRoot, + originalAsarHash: readHeaderHash(originalAsar).headerHash, + patchedAsarHash: readHeaderHash(patchedAsar).headerHash, + codexVersion: null, + fuseFlipped: false, + resigned: false, + originalEntryPoint: "bootstrap.js", + watcher: "none", + }; +} + +function readPackageVersion(asarPath: string): string { + const pkg = JSON.parse(readFileInAsar(asarPath, "package.json").toString()) as { + version: string; + }; + return pkg.version; +} diff --git a/packages/installer/test/tweak-commands.test.ts b/packages/installer/test/tweak-commands.test.ts index 2c50513..0481bcb 100644 --- a/packages/installer/test/tweak-commands.test.ts +++ b/packages/installer/test/tweak-commands.test.ts @@ -251,6 +251,20 @@ test("window services patch does not depend on Codex minified function names", ( ); }); +test("window services patch supports appBrand before buildFlavor", () => { + const source = + "let R=Une({appBrand:a.U(),buildFlavor:o,allowDevtools:g,allowDebugMenu:v,allowInspectElement:_,globalState:I.globalState,settingsStore:I.settingsStore,desktopRoot:I.desktopRoot,preloadPath:I.preloadPath,repoRoot:I.repoRoot,disposables:P}),trusted=e=>R.isTrustedIpcSender(e.sender);H6({buildFlavor:o,isTrustedIpcEvent:trusted})"; + + const patched = patchCodexWindowServicesSource(source); + + assert.ok(patched); + assert.equal(patched.serviceVar, "R"); + assert.match( + patched.source, + /;globalThis\.__codexpp_window_services__=R;H6\(\{buildFlavor:o/, + ); +}); + test("window services patch is idempotent when the marker is already present", () => { const source = `let M=FM({buildFlavor:a,allowDevtools:p,globalState:j.globalState,getGlobalStateForHost:j.getGlobalStateForHost,desktopRoot:j.desktopRoot,preloadPath:j.preloadPath,repoRoot:j.repoRoot,disposables:k});globalThis.${CODEX_WINDOW_SERVICES_KEY}=M;wD({buildFlavor:a})`;