Skip to content
Merged
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
36 changes: 33 additions & 3 deletions .github/workflows/code-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ jobs:
echo "OK: $bin"
done

# The native codex CLI must ship as a sibling of codex-acp, or the
# app-server harness silently falls back to codex-acp in production.
for f in codex rg; do
# codex resolves codex-code-mode-host and rg as siblings of its own
# executable; shipping codex without them breaks command execution
# at runtime (code-mode models route all commands through the host).
for f in codex codex-code-mode-host rg; do

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 Linux And Windows Packages Stay Unchecked

This required-binary check only runs in the macOS release job. If staging skips a missing platform-specific source on Linux or Windows, packaging still succeeds and those artifacts can ship without codex-code-mode-host, leaving code-mode sessions unable to run commands.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/code-release.yml
Line: 205

Comment:
**Linux And Windows Packages Stay Unchecked**

This required-binary check only runs in the macOS release job. If staging skips a missing platform-specific source on Linux or Windows, packaging still succeeds and those artifacts can ship without `codex-code-mode-host`, leaving code-mode sessions unable to run commands.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b51839c: added Verify package steps to the publish-windows and publish-linux jobs, checking codex, codex-code-mode-host, and rg in the unpacked electron-builder output (win-unpacked, linux-unpacked / linux-arm64-unpacked) before artifacts upload.

if [[ ! -f "$RESOURCES/app.asar.unpacked/.vite/build/codex-acp/$f" ]]; then
echo "FAIL: codex-acp/$f missing in bundled binaries"
exit 1
Expand Down Expand Up @@ -348,6 +349,18 @@ jobs:
pnpm exec electron-vite build
pnpm exec electron-builder build --win --x64 --publish never --config electron-builder.ts

- name: Verify package
shell: pwsh
run: |
$dir = "apps/code/out/win-unpacked/resources/app.asar.unpacked/.vite/build/codex-acp"
foreach ($f in @("codex.exe", "codex-code-mode-host.exe", "rg.exe")) {
if (!(Test-Path "$dir/$f")) {
Write-Error "FAIL: codex-acp/$f missing in bundled binaries"
exit 1
}
echo "OK: codex-acp/$f"
}

- name: Upload release artifacts
shell: pwsh
env:
Expand Down Expand Up @@ -482,6 +495,23 @@ jobs:
pnpm exec electron-builder build --linux --x64 --publish never --config electron-builder.ts
fi

- name: Verify package
env:
MATRIX_ARCH: ${{ matrix.arch }}
run: |
if [[ "$MATRIX_ARCH" == "arm64" ]]; then
UNPACKED="apps/code/out/linux-arm64-unpacked"
else
UNPACKED="apps/code/out/linux-unpacked"
fi
for f in codex codex-code-mode-host rg; do
if [[ ! -f "$UNPACKED/resources/app.asar.unpacked/.vite/build/codex-acp/$f" ]]; then
echo "FAIL: codex-acp/$f missing in bundled binaries"
exit 1
fi
echo "OK: codex-acp/$f"
done

- name: Upload release artifacts
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
Expand Down
111 changes: 54 additions & 57 deletions apps/code/scripts/download-binaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,56 @@ import { extract } from "tar";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEST_DIR = join(__dirname, "..", "resources", "codex-acp");

const BINARIES = [
{
name: "codex",
version: "0.144.0",
getUrl: (version, target) => {
if (target.includes("windows")) {
return `https://github.com/openai/codex/releases/download/rust-v${version}/codex-${target}.exe.zip`;
}
return `https://github.com/openai/codex/releases/download/rust-v${version}/codex-${target}.tar.gz`;
const CODEX_VERSION = "0.144.0";

function nativeTarget() {
const { platform, arch } = process;
const targets = {
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
linux: {
arm64: "aarch64-unknown-linux-musl",
x64: "x86_64-unknown-linux-musl",
},
getTarget: () => {
const { platform, arch } = process;
const targets = {
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
linux: {
arm64: "aarch64-unknown-linux-musl",
x64: "x86_64-unknown-linux-musl",
},
win32: {
arm64: "aarch64-pc-windows-msvc",
x64: "x86_64-pc-windows-msvc",
},
};
const platformTargets = targets[platform];
if (!platformTargets)
throw new Error(`Unsupported platform: ${platform}`);
const target = platformTargets[arch];
if (!target) throw new Error(`Unsupported arch: ${arch}`);
return target;
win32: {
arm64: "aarch64-pc-windows-msvc",
x64: "x86_64-pc-windows-msvc",
},
// The codex release archive contains a target-suffixed binary
// (e.g. `codex-aarch64-apple-darwin`); rename it to `codex` after extract.
archiveBinaryName: (target) =>
process.platform === "win32" ? `codex-${target}.exe` : `codex-${target}`,
};
const target = targets[platform]?.[arch];
if (!target) throw new Error(`Unsupported platform: ${platform}/${arch}`);
return target;
}

function codexReleaseUrl(binary, version, target) {
const suffix = target.includes("windows") ? ".exe.zip" : ".tar.gz";
return `https://github.com/openai/codex/releases/download/rust-v${version}/${binary}-${target}${suffix}`;
}

// Codex release archives contain a target-suffixed binary
// (e.g. `codex-aarch64-apple-darwin`); rename it after extract.
const codexArchiveBinaryName = (binary) => (target) =>
target.includes("windows")
? `${binary}-${target}.exe`
: `${binary}-${target}`;
Comment on lines +49 to +52

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.

P2 Windows Archive Name Is Assumed

The new Windows host download assumes the ZIP member is named codex-code-mode-host-${target}.exe. If the release archive contains the target-suffixed host without .exe, extraction succeeds but the rename is skipped and downloadBinary throws Binary not found after extraction, blocking Windows setup and release builds. The current tests cover HTTP retries but not this Windows archive contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/scripts/download-binaries.mjs
Line: 49-52

Comment:
**Windows Archive Name Is Assumed**

The new Windows host download assumes the ZIP member is named `codex-code-mode-host-${target}.exe`. If the release archive contains the target-suffixed host without `.exe`, extraction succeeds but the rename is skipped and `downloadBinary` throws `Binary not found after extraction`, blocking Windows setup and release builds. The current tests cover HTTP retries but not this Windows archive contract.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified against the actual rust-v0.144.0 release asset: codex-code-mode-host-x86_64-pc-windows-msvc.exe.zip contains exactly one member, codex-code-mode-host-x86_64-pc-windows-msvc.exe, matching archiveBinaryName (same target-suffixed convention as the codex zip we already ship from). If upstream ever changes the member name, downloadBinary throws Binary not found after extraction at postinstall/CI time — a loud failure at build time, not a broken package at runtime — so no defensive handling added.


export const BINARIES = [
{
name: "codex",
version: CODEX_VERSION,
getUrl: (version, target) => codexReleaseUrl("codex", version, target),
getTarget: nativeTarget,
archiveBinaryName: codexArchiveBinaryName("codex"),
},
{
// codex resolves this host as a sibling of its own executable and routes
// all command execution through it for code-mode models (gpt-5.6+). It is
// released per codex version, so it must stay in lockstep with `codex`.
name: "codex-code-mode-host",
version: CODEX_VERSION,
getUrl: (version, target) =>
codexReleaseUrl("codex-code-mode-host", version, target),
getTarget: nativeTarget,
archiveBinaryName: codexArchiveBinaryName("codex-code-mode-host"),
},
{
name: "rg",
Expand All @@ -61,26 +77,7 @@ const BINARIES = [
const ext = target.includes("windows") ? "zip" : "tar.gz";
return `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${version}/ripgrep-v${version}-${target}.${ext}`;
},
getTarget: () => {
const { platform, arch } = process;
const targets = {
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
linux: {
arm64: "aarch64-unknown-linux-musl",
x64: "x86_64-unknown-linux-musl",
},
win32: {
arm64: "aarch64-pc-windows-msvc",
x64: "x86_64-pc-windows-msvc",
},
};
const platformTargets = targets[platform];
if (!platformTargets)
throw new Error(`Unsupported platform: ${platform}`);
const target = platformTargets[arch];
if (!target) throw new Error(`Unsupported arch: ${arch}`);
return target;
},
getTarget: nativeTarget,
},
];

Expand Down Expand Up @@ -143,10 +140,10 @@ function signForMacOS(binaryPath) {
execSync(`codesign --force --sign - "${binaryPath}"`, { stdio: "pipe" });
}

async function downloadBinary(binary) {
export async function downloadBinary(binary, destDir = DEST_DIR) {
const binaryName =
process.platform === "win32" ? `${binary.name}.exe` : binary.name;
const binaryPath = join(DEST_DIR, binaryName);
const binaryPath = join(destDir, binaryName);

console.log(`\n[${binary.name}] v${binary.version}`);

Expand All @@ -158,16 +155,16 @@ async function downloadBinary(binary) {
const target = binary.getTarget();
const url = binary.getUrl(binary.version, target);
const archiveName = `${binary.name}-archive${url.endsWith(".zip") ? ".zip" : ".tar.gz"}`;
const archivePath = join(DEST_DIR, archiveName);
const archivePath = join(destDir, archiveName);

console.log(` Platform: ${process.platform}/${process.arch} -> ${target}`);

await downloadFile(url, archivePath);
await extractArchive(archivePath, DEST_DIR);
await extractArchive(archivePath, destDir);
rmSync(archivePath);

if (binary.archiveBinaryName) {
const extractedPath = join(DEST_DIR, binary.archiveBinaryName(target));
const extractedPath = join(destDir, binary.archiveBinaryName(target));
if (extractedPath !== binaryPath && existsSync(extractedPath)) {
renameSync(extractedPath, binaryPath);
}
Expand Down
81 changes: 79 additions & 2 deletions apps/code/scripts/download-binaries.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { chmodSync, existsSync, renameSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { extract } from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { downloadFile, MAX_DOWNLOAD_ATTEMPTS } from "./download-binaries.mjs";
import {
BINARIES,
downloadBinary,
downloadFile,
MAX_DOWNLOAD_ATTEMPTS,
} from "./download-binaries.mjs";

vi.mock("node:timers/promises", () => {
const setTimeout = vi.fn(() => Promise.resolve());
Expand All @@ -10,13 +17,28 @@ vi.mock("node:stream/promises", () => {
const pipeline = vi.fn(() => Promise.resolve());
return { pipeline, default: { pipeline } };
});
vi.mock("tar", () => {
const extract = vi.fn(() => Promise.resolve());
return { extract, default: { extract } };
});
vi.mock("adm-zip", () => {
const extractAllTo = vi.fn();
return {
default: class AdmZip {
extractAllTo(...args) {
extractAllTo(...args);
}
},
};
});
vi.mock("node:fs", () => {
const fns = {
chmodSync: vi.fn(),
createWriteStream: vi.fn(() => ({})),
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
realpathSync: vi.fn(() => "/not/the/entrypoint"),
renameSync: vi.fn(),
rmSync: vi.fn(),
};
return { ...fns, default: fns };
Expand All @@ -35,7 +57,7 @@ const errorResponse = (status, statusText) => ({
body: null,
});

describe("downloadFile", () => {
describe("download binaries", () => {
let fetchMock;

beforeEach(() => {
Expand Down Expand Up @@ -110,4 +132,59 @@ describe("downloadFile", () => {
expect(delay).toBeLessThan(base);
});
});

it("downloads and stages the codex code-mode host beside codex", async () => {
const hostBinary = BINARIES.find(
(binary) => binary.name === "codex-code-mode-host",
);
expect(hostBinary).toBeDefined();

const destination = "/tmp/codex-binaries";
const target = hostBinary.getTarget();
const extractedPath = `${destination}/${hostBinary.archiveBinaryName(target)}`;
const binaryName =
process.platform === "win32"
? "codex-code-mode-host.exe"
: "codex-code-mode-host";
const binaryPath = `${destination}/${binaryName}`;
const archiveSuffix = target.includes("windows") ? ".exe.zip" : ".tar.gz";
const archiveExtension = target.includes("windows") ? ".zip" : ".tar.gz";
const files = new Set([extractedPath]);

existsSync.mockImplementation((path) => files.has(path));
renameSync.mockImplementation((source, targetPath) => {
files.delete(source);
files.add(targetPath);
});
fetchMock.mockResolvedValue(okResponse());

await downloadBinary(hostBinary, destination);

expect(fetchMock).toHaveBeenCalledWith(
`https://github.com/openai/codex/releases/download/rust-v${hostBinary.version}/codex-code-mode-host-${target}${archiveSuffix}`,
{ redirect: "follow" },
);
if (process.platform !== "win32") {
expect(extract).toHaveBeenCalledWith({
file: `${destination}/codex-code-mode-host-archive${archiveExtension}`,
cwd: destination,
});
}
expect(renameSync).toHaveBeenCalledWith(extractedPath, binaryPath);
expect(chmodSync).toHaveBeenCalledWith(binaryPath, 0o755);
});

it.each([
["aarch64-apple-darwin", "codex-code-mode-host-aarch64-apple-darwin"],
[
"x86_64-pc-windows-msvc",
"codex-code-mode-host-x86_64-pc-windows-msvc.exe",
],
])("uses the upstream host archive member for %s", (target, expected) => {
const hostBinary = BINARIES.find(
(binary) => binary.name === "codex-code-mode-host",
);

expect(hostBinary?.archiveBinaryName(target)).toBe(expected);
});
});
10 changes: 6 additions & 4 deletions apps/code/vite-main-plugins.mts
Original file line number Diff line number Diff line change
Expand Up @@ -592,10 +592,12 @@ export function copyCodexAcpBinaries(): Plugin {
const sourceDir = join(__dirname, "resources/codex-acp");
const binaries = [
{ name: "codex", winName: "codex.exe" },
// The native codex CLI must ship next to codex-acp: the app-server
// sub-adapter resolves it as a sibling and silently falls back to
// codex-acp when it's missing.
{ name: "codex", winName: "codex.exe" },
// codex resolves the code-mode host as a sibling of its own executable;
// code-mode models (gpt-5.6+) cannot run commands without it.
{
name: "codex-code-mode-host",
winName: "codex-code-mode-host.exe",
},
{ name: "rg", winName: "rg.exe" },
];

Expand Down
Loading