diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index 7873459447..82aa0be5fb 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -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 if [[ ! -f "$RESOURCES/app.asar.unpacked/.vite/build/codex-acp/$f" ]]; then echo "FAIL: codex-acp/$f missing in bundled binaries" exit 1 @@ -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: @@ -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 }} diff --git a/apps/code/scripts/download-binaries.mjs b/apps/code/scripts/download-binaries.mjs index 28325ade02..13a1ed2d87 100644 --- a/apps/code/scripts/download-binaries.mjs +++ b/apps/code/scripts/download-binaries.mjs @@ -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}`; + +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", @@ -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, }, ]; @@ -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}`); @@ -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); } diff --git a/apps/code/scripts/download-binaries.test.mjs b/apps/code/scripts/download-binaries.test.mjs index 85e01b8085..945370cc0e 100644 --- a/apps/code/scripts/download-binaries.test.mjs +++ b/apps/code/scripts/download-binaries.test.mjs @@ -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()); @@ -10,6 +17,20 @@ 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(), @@ -17,6 +38,7 @@ vi.mock("node:fs", () => { existsSync: vi.fn(() => true), mkdirSync: vi.fn(), realpathSync: vi.fn(() => "/not/the/entrypoint"), + renameSync: vi.fn(), rmSync: vi.fn(), }; return { ...fns, default: fns }; @@ -35,7 +57,7 @@ const errorResponse = (status, statusText) => ({ body: null, }); -describe("downloadFile", () => { +describe("download binaries", () => { let fetchMock; beforeEach(() => { @@ -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); + }); }); diff --git a/apps/code/vite-main-plugins.mts b/apps/code/vite-main-plugins.mts index 8d3d23e295..1fe97279c7 100644 --- a/apps/code/vite-main-plugins.mts +++ b/apps/code/vite-main-plugins.mts @@ -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" }, ];