Skip to content

Commit 5f0f3d0

Browse files
adboiotatoalo
andauthored
fix(codex): bundle codex-code-mode-host next to the codex binary (#3603)
Co-authored-by: Alessandro Pogliaghi <apogliaghi@gmail.com>
1 parent 609cc0a commit 5f0f3d0

4 files changed

Lines changed: 172 additions & 66 deletions

File tree

.github/workflows/code-release.yml

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,10 @@ jobs:
199199
echo "OK: $bin"
200200
done
201201
202-
# The native codex CLI must ship as a sibling of codex-acp, or the
203-
# app-server harness silently falls back to codex-acp in production.
204-
for f in codex rg; do
202+
# codex resolves codex-code-mode-host and rg as siblings of its own
203+
# executable; shipping codex without them breaks command execution
204+
# at runtime (code-mode models route all commands through the host).
205+
for f in codex codex-code-mode-host rg; do
205206
if [[ ! -f "$RESOURCES/app.asar.unpacked/.vite/build/codex-acp/$f" ]]; then
206207
echo "FAIL: codex-acp/$f missing in bundled binaries"
207208
exit 1
@@ -348,6 +349,18 @@ jobs:
348349
pnpm exec electron-vite build
349350
pnpm exec electron-builder build --win --x64 --publish never --config electron-builder.ts
350351
352+
- name: Verify package
353+
shell: pwsh
354+
run: |
355+
$dir = "apps/code/out/win-unpacked/resources/app.asar.unpacked/.vite/build/codex-acp"
356+
foreach ($f in @("codex.exe", "codex-code-mode-host.exe", "rg.exe")) {
357+
if (!(Test-Path "$dir/$f")) {
358+
Write-Error "FAIL: codex-acp/$f missing in bundled binaries"
359+
exit 1
360+
}
361+
echo "OK: codex-acp/$f"
362+
}
363+
351364
- name: Upload release artifacts
352365
shell: pwsh
353366
env:
@@ -482,6 +495,23 @@ jobs:
482495
pnpm exec electron-builder build --linux --x64 --publish never --config electron-builder.ts
483496
fi
484497
498+
- name: Verify package
499+
env:
500+
MATRIX_ARCH: ${{ matrix.arch }}
501+
run: |
502+
if [[ "$MATRIX_ARCH" == "arm64" ]]; then
503+
UNPACKED="apps/code/out/linux-arm64-unpacked"
504+
else
505+
UNPACKED="apps/code/out/linux-unpacked"
506+
fi
507+
for f in codex codex-code-mode-host rg; do
508+
if [[ ! -f "$UNPACKED/resources/app.asar.unpacked/.vite/build/codex-acp/$f" ]]; then
509+
echo "FAIL: codex-acp/$f missing in bundled binaries"
510+
exit 1
511+
fi
512+
echo "OK: codex-acp/$f"
513+
done
514+
485515
- name: Upload release artifacts
486516
env:
487517
GH_TOKEN: ${{ steps.app-token.outputs.token }}

apps/code/scripts/download-binaries.mjs

Lines changed: 54 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,56 @@ import { extract } from "tar";
1919
const __dirname = dirname(fileURLToPath(import.meta.url));
2020
const DEST_DIR = join(__dirname, "..", "resources", "codex-acp");
2121

22-
const BINARIES = [
23-
{
24-
name: "codex",
25-
version: "0.144.0",
26-
getUrl: (version, target) => {
27-
if (target.includes("windows")) {
28-
return `https://github.com/openai/codex/releases/download/rust-v${version}/codex-${target}.exe.zip`;
29-
}
30-
return `https://github.com/openai/codex/releases/download/rust-v${version}/codex-${target}.tar.gz`;
22+
const CODEX_VERSION = "0.144.0";
23+
24+
function nativeTarget() {
25+
const { platform, arch } = process;
26+
const targets = {
27+
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
28+
linux: {
29+
arm64: "aarch64-unknown-linux-musl",
30+
x64: "x86_64-unknown-linux-musl",
3131
},
32-
getTarget: () => {
33-
const { platform, arch } = process;
34-
const targets = {
35-
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
36-
linux: {
37-
arm64: "aarch64-unknown-linux-musl",
38-
x64: "x86_64-unknown-linux-musl",
39-
},
40-
win32: {
41-
arm64: "aarch64-pc-windows-msvc",
42-
x64: "x86_64-pc-windows-msvc",
43-
},
44-
};
45-
const platformTargets = targets[platform];
46-
if (!platformTargets)
47-
throw new Error(`Unsupported platform: ${platform}`);
48-
const target = platformTargets[arch];
49-
if (!target) throw new Error(`Unsupported arch: ${arch}`);
50-
return target;
32+
win32: {
33+
arm64: "aarch64-pc-windows-msvc",
34+
x64: "x86_64-pc-windows-msvc",
5135
},
52-
// The codex release archive contains a target-suffixed binary
53-
// (e.g. `codex-aarch64-apple-darwin`); rename it to `codex` after extract.
54-
archiveBinaryName: (target) =>
55-
process.platform === "win32" ? `codex-${target}.exe` : `codex-${target}`,
36+
};
37+
const target = targets[platform]?.[arch];
38+
if (!target) throw new Error(`Unsupported platform: ${platform}/${arch}`);
39+
return target;
40+
}
41+
42+
function codexReleaseUrl(binary, version, target) {
43+
const suffix = target.includes("windows") ? ".exe.zip" : ".tar.gz";
44+
return `https://github.com/openai/codex/releases/download/rust-v${version}/${binary}-${target}${suffix}`;
45+
}
46+
47+
// Codex release archives contain a target-suffixed binary
48+
// (e.g. `codex-aarch64-apple-darwin`); rename it after extract.
49+
const codexArchiveBinaryName = (binary) => (target) =>
50+
target.includes("windows")
51+
? `${binary}-${target}.exe`
52+
: `${binary}-${target}`;
53+
54+
export const BINARIES = [
55+
{
56+
name: "codex",
57+
version: CODEX_VERSION,
58+
getUrl: (version, target) => codexReleaseUrl("codex", version, target),
59+
getTarget: nativeTarget,
60+
archiveBinaryName: codexArchiveBinaryName("codex"),
61+
},
62+
{
63+
// codex resolves this host as a sibling of its own executable and routes
64+
// all command execution through it for code-mode models (gpt-5.6+). It is
65+
// released per codex version, so it must stay in lockstep with `codex`.
66+
name: "codex-code-mode-host",
67+
version: CODEX_VERSION,
68+
getUrl: (version, target) =>
69+
codexReleaseUrl("codex-code-mode-host", version, target),
70+
getTarget: nativeTarget,
71+
archiveBinaryName: codexArchiveBinaryName("codex-code-mode-host"),
5672
},
5773
{
5874
name: "rg",
@@ -61,26 +77,7 @@ const BINARIES = [
6177
const ext = target.includes("windows") ? "zip" : "tar.gz";
6278
return `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${version}/ripgrep-v${version}-${target}.${ext}`;
6379
},
64-
getTarget: () => {
65-
const { platform, arch } = process;
66-
const targets = {
67-
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
68-
linux: {
69-
arm64: "aarch64-unknown-linux-musl",
70-
x64: "x86_64-unknown-linux-musl",
71-
},
72-
win32: {
73-
arm64: "aarch64-pc-windows-msvc",
74-
x64: "x86_64-pc-windows-msvc",
75-
},
76-
};
77-
const platformTargets = targets[platform];
78-
if (!platformTargets)
79-
throw new Error(`Unsupported platform: ${platform}`);
80-
const target = platformTargets[arch];
81-
if (!target) throw new Error(`Unsupported arch: ${arch}`);
82-
return target;
83-
},
80+
getTarget: nativeTarget,
8481
},
8582
];
8683

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

146-
async function downloadBinary(binary) {
143+
export async function downloadBinary(binary, destDir = DEST_DIR) {
147144
const binaryName =
148145
process.platform === "win32" ? `${binary.name}.exe` : binary.name;
149-
const binaryPath = join(DEST_DIR, binaryName);
146+
const binaryPath = join(destDir, binaryName);
150147

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

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

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

165162
await downloadFile(url, archivePath);
166-
await extractArchive(archivePath, DEST_DIR);
163+
await extractArchive(archivePath, destDir);
167164
rmSync(archivePath);
168165

169166
if (binary.archiveBinaryName) {
170-
const extractedPath = join(DEST_DIR, binary.archiveBinaryName(target));
167+
const extractedPath = join(destDir, binary.archiveBinaryName(target));
171168
if (extractedPath !== binaryPath && existsSync(extractedPath)) {
172169
renameSync(extractedPath, binaryPath);
173170
}

apps/code/scripts/download-binaries.test.mjs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { chmodSync, existsSync, renameSync } from "node:fs";
12
import { setTimeout as sleep } from "node:timers/promises";
3+
import { extract } from "tar";
24
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { downloadFile, MAX_DOWNLOAD_ATTEMPTS } from "./download-binaries.mjs";
5+
import {
6+
BINARIES,
7+
downloadBinary,
8+
downloadFile,
9+
MAX_DOWNLOAD_ATTEMPTS,
10+
} from "./download-binaries.mjs";
411

512
vi.mock("node:timers/promises", () => {
613
const setTimeout = vi.fn(() => Promise.resolve());
@@ -10,13 +17,28 @@ vi.mock("node:stream/promises", () => {
1017
const pipeline = vi.fn(() => Promise.resolve());
1118
return { pipeline, default: { pipeline } };
1219
});
20+
vi.mock("tar", () => {
21+
const extract = vi.fn(() => Promise.resolve());
22+
return { extract, default: { extract } };
23+
});
24+
vi.mock("adm-zip", () => {
25+
const extractAllTo = vi.fn();
26+
return {
27+
default: class AdmZip {
28+
extractAllTo(...args) {
29+
extractAllTo(...args);
30+
}
31+
},
32+
};
33+
});
1334
vi.mock("node:fs", () => {
1435
const fns = {
1536
chmodSync: vi.fn(),
1637
createWriteStream: vi.fn(() => ({})),
1738
existsSync: vi.fn(() => true),
1839
mkdirSync: vi.fn(),
1940
realpathSync: vi.fn(() => "/not/the/entrypoint"),
41+
renameSync: vi.fn(),
2042
rmSync: vi.fn(),
2143
};
2244
return { ...fns, default: fns };
@@ -35,7 +57,7 @@ const errorResponse = (status, statusText) => ({
3557
body: null,
3658
});
3759

38-
describe("downloadFile", () => {
60+
describe("download binaries", () => {
3961
let fetchMock;
4062

4163
beforeEach(() => {
@@ -110,4 +132,59 @@ describe("downloadFile", () => {
110132
expect(delay).toBeLessThan(base);
111133
});
112134
});
135+
136+
it("downloads and stages the codex code-mode host beside codex", async () => {
137+
const hostBinary = BINARIES.find(
138+
(binary) => binary.name === "codex-code-mode-host",
139+
);
140+
expect(hostBinary).toBeDefined();
141+
142+
const destination = "/tmp/codex-binaries";
143+
const target = hostBinary.getTarget();
144+
const extractedPath = `${destination}/${hostBinary.archiveBinaryName(target)}`;
145+
const binaryName =
146+
process.platform === "win32"
147+
? "codex-code-mode-host.exe"
148+
: "codex-code-mode-host";
149+
const binaryPath = `${destination}/${binaryName}`;
150+
const archiveSuffix = target.includes("windows") ? ".exe.zip" : ".tar.gz";
151+
const archiveExtension = target.includes("windows") ? ".zip" : ".tar.gz";
152+
const files = new Set([extractedPath]);
153+
154+
existsSync.mockImplementation((path) => files.has(path));
155+
renameSync.mockImplementation((source, targetPath) => {
156+
files.delete(source);
157+
files.add(targetPath);
158+
});
159+
fetchMock.mockResolvedValue(okResponse());
160+
161+
await downloadBinary(hostBinary, destination);
162+
163+
expect(fetchMock).toHaveBeenCalledWith(
164+
`https://github.com/openai/codex/releases/download/rust-v${hostBinary.version}/codex-code-mode-host-${target}${archiveSuffix}`,
165+
{ redirect: "follow" },
166+
);
167+
if (process.platform !== "win32") {
168+
expect(extract).toHaveBeenCalledWith({
169+
file: `${destination}/codex-code-mode-host-archive${archiveExtension}`,
170+
cwd: destination,
171+
});
172+
}
173+
expect(renameSync).toHaveBeenCalledWith(extractedPath, binaryPath);
174+
expect(chmodSync).toHaveBeenCalledWith(binaryPath, 0o755);
175+
});
176+
177+
it.each([
178+
["aarch64-apple-darwin", "codex-code-mode-host-aarch64-apple-darwin"],
179+
[
180+
"x86_64-pc-windows-msvc",
181+
"codex-code-mode-host-x86_64-pc-windows-msvc.exe",
182+
],
183+
])("uses the upstream host archive member for %s", (target, expected) => {
184+
const hostBinary = BINARIES.find(
185+
(binary) => binary.name === "codex-code-mode-host",
186+
);
187+
188+
expect(hostBinary?.archiveBinaryName(target)).toBe(expected);
189+
});
113190
});

apps/code/vite-main-plugins.mts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,12 @@ export function copyCodexAcpBinaries(): Plugin {
592592
const sourceDir = join(__dirname, "resources/codex-acp");
593593
const binaries = [
594594
{ name: "codex", winName: "codex.exe" },
595-
// The native codex CLI must ship next to codex-acp: the app-server
596-
// sub-adapter resolves it as a sibling and silently falls back to
597-
// codex-acp when it's missing.
598-
{ name: "codex", winName: "codex.exe" },
595+
// codex resolves the code-mode host as a sibling of its own executable;
596+
// code-mode models (gpt-5.6+) cannot run commands without it.
597+
{
598+
name: "codex-code-mode-host",
599+
winName: "codex-code-mode-host.exe",
600+
},
599601
{ name: "rg", winName: "rg.exe" },
600602
];
601603

0 commit comments

Comments
 (0)