From 3731aace4a160aa6d6e36900e70c237e7082a2bb Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:28 +1000 Subject: [PATCH] fix(ci): fail releases that bundle native code for the wrong CPU (#54982) --- packages/cli/script/build-node.ts | 2 + packages/cli/script/build.ts | 2 + packages/desktop/electron-builder.config.ts | 28 +++++- script/native-arch.ts | 102 ++++++++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 script/native-arch.ts diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index b3fc2e8f1b67..f7c64eeddc16 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -14,6 +14,7 @@ import { appArchiveAsset, mainConfig } from "../vite.node.config" import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target" import { buildAppArchive } from "./app-assets" import { verifyArtifact } from "./verify-artifact" +import { NativeArch } from "../../../script/native-arch" const NODE_VERSION = "26.4.0" const dir = path.resolve(import.meta.dirname, "..") @@ -133,6 +134,7 @@ for (const target of targets) { await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`) run(builder, ["--build-sea", "dist-node/sea.json"]) if (target.platform !== "win32") await chmod(output, 0o755) + await NativeArch.verify(output, target.arch) if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output]) if (target.platform === "darwin" && process.platform !== "darwin") { console.warn(`${output} must be signed on macOS before it can run`) diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e00c004be676..3cac51e9c2a5 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -12,6 +12,7 @@ import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@openc import { buildAppArchive } from "./app-assets" import { verifyArtifact, verifySimulationGraph } from "./verify-artifact" import { resolveOpencodePty } from "./opencode-pty" +import { NativeArch } from "../../../script/native-arch" const dir = path.resolve(import.meta.dirname, "..") const binary = "opencode" @@ -204,6 +205,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh process.exit(1) } verifySimulationGraph(simulationInputs) + await NativeArch.verify(path.join(outdir, name, "bin", item.os === "win32" ? `${binary}.exe` : binary), item.arch) await Bun.write( path.join(outdir, name, "package.json"), diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 9e5f17ade1fc..f6d8de6db2a5 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -1,11 +1,12 @@ import { execFile } from "node:child_process" -import { stat } from "node:fs/promises" +import { readdir, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" import { promisify } from "node:util" import type { CustomMacSignOptions } from "app-builder-lib" -import type { Configuration } from "electron-builder" +import { Arch, type Configuration } from "electron-builder" +import { NativeArch } from "../../script/native-arch" import { CLI_BINARIES, getCurrentCli, nodePtyPackage } from "./scripts/utils" const execFileAsync = promisify(execFile) @@ -116,28 +117,47 @@ const getBase = (appId: string): Configuration => { }, ], afterPack: async (context) => { + const arch = Arch[context.arch] + + // The --arm64 flag picks Electron while OPENCODE_CLI_TARGET picks the CLI and node-pty, so they can disagree. + if (arch !== "x64" && arch !== "arm64") throw new Error(`Unsupported desktop architecture: ${arch}`) + + if (arch !== target.cpu) throw new Error(`Packaging ${arch} Electron with the ${target.target} CLI`) const resources = context.packager.getResourcesDir(context.appOutDir) const cli = path.join(resources, context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli") const file = await stat(cli) if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`) + await NativeArch.verify(cli, arch) const version = path.join(path.dirname(cli), "opencode-cli.version") if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`) if (context.electronPlatformName !== "win32") return - const addon = path.join( + const prebuilds = path.join( resources, "app.asar.unpacked/node_modules", nodePtyPackage(target.target), "prebuilds", `${target.os}-${target.cpu}`, - "conpty.node", ) + const addon = path.join(prebuilds, "conpty.node") + if ((await stat(addon)).size === 0) throw new Error(`Bundled node-pty must be a non-empty file: ${addon}`) + const entries = await readdir(prebuilds, { recursive: true, withFileTypes: true }) + + await Promise.all( + entries + .filter((entry) => entry.isFile()) + .map(async (entry) => { + const file = path.join(entry.parentPath, entry.name) + + if (await NativeArch.isNative(file)) await NativeArch.verify(file, arch) + }), + ) }, mac: { category: "public.app-category.developer-tools", diff --git a/script/native-arch.ts b/script/native-arch.ts new file mode 100644 index 000000000000..0609856c7785 --- /dev/null +++ b/script/native-arch.ts @@ -0,0 +1,102 @@ +import { readFile } from "node:fs/promises" + +type Image = { + readonly offset: number + readonly format: "PE" | "ELF" | "Mach-O" + readonly arches: readonly string[] + // A universal Mach-O ends after its last slice; the thin slices inside it are not separate images. + readonly end?: number +} + +const PE_MACHINES: Record = { 0x8664: "x64", 0xaa64: "arm64", 0x014c: "x86", 0x01c4: "arm" } +const ELF_MACHINES: Record = { 62: "x64", 183: "arm64", 3: "x86", 40: "arm" } +const MACHO_CPUS: Record = { 0x01000007: "x64", 0x0100000c: "arm64", 7: "x86", 12: "arm" } + +export namespace NativeArch { + /** Every PE, ELF and Mach-O image in `bytes`, including the libraries Bun embeds in a compiled executable. */ + export function images(bytes: Buffer) { + const universal = find(bytes, "\xca\xfe\xba\xbe", fat) + const thin = find(bytes, "\xcf\xfa\xed\xfe", macho).filter( + (image) => !universal.some((parent) => image.offset > parent.offset && image.offset < (parent.end ?? 0)), + ) + return [...find(bytes, "MZ", pe), ...find(bytes, "\x7fELF", elf), ...thin, ...universal].sort( + (left, right) => left.offset - right.offset, + ) + } + + /** + * Fails unless `file` is a native image for `arch` and every image embedded in it targets `arch` too. A + * wrong-architecture library loads only on the machine that needs it, so it ships unnoticed: 2.0.27's ARM64 + * Windows CLI embedded an x64 rust_pty.dll and could not open a terminal. + */ + export async function verify(file: string, arch: "x64" | "arm64") { + const found = images(await readFile(file)) + if (found[0]?.offset !== 0) throw new Error(`${file} is not a native executable or library`) + const wrong = found.filter((image) => !image.arches.includes(arch)) + if (wrong.length === 0) return + throw new Error( + `${file} must contain only ${arch} code, but has ${wrong + .map((image) => `${image.format} ${image.arches.join("+")} at byte ${image.offset}`) + .join(", ")}`, + ) + } + + /** Whether `file` starts with a native image, so a package scan can skip scripts, data and debug symbols. */ + export async function isNative(file: string) { + const bytes = await readFile(file) + return images(bytes.subarray(0, Math.min(bytes.length, 64 * 1024))).some((image) => image.offset === 0) + } +} + +function find(bytes: Buffer, magic: string, parse: (bytes: Buffer, offset: number) => Image | undefined) { + const needle = Buffer.from(magic, "latin1") + const found: Image[] = [] + for (let offset = bytes.indexOf(needle); offset !== -1; offset = bytes.indexOf(needle, offset + 1)) { + const image = parse(bytes, offset) + if (image) found.push(image) + } + return found +} + +function pe(bytes: Buffer, offset: number): Image | undefined { + if (offset + 0x40 > bytes.length) return + const header = offset + bytes.readUInt32LE(offset + 0x3c) + if (header - offset < 0x40 || header - offset > 0x1000 || header + 6 > bytes.length) return + if (bytes.readUInt32LE(header) !== 0x4550) return + const machine = bytes.readUInt16LE(header + 4) + return { offset, format: "PE", arches: [PE_MACHINES[machine] ?? `machine 0x${machine.toString(16)}`] } +} + +function elf(bytes: Buffer, offset: number): Image | undefined { + if (offset + 24 > bytes.length) return + const elfClass = bytes[offset + 4] + const data = bytes[offset + 5] + if ((elfClass !== 1 && elfClass !== 2) || (data !== 1 && data !== 2) || bytes[offset + 6] !== 1) return + const machine = data === 1 ? bytes.readUInt16LE(offset + 18) : bytes.readUInt16BE(offset + 18) + const version = data === 1 ? bytes.readUInt32LE(offset + 20) : bytes.readUInt32BE(offset + 20) + if (version !== 1) return + return { offset, format: "ELF", arches: [ELF_MACHINES[machine] ?? `machine ${machine}`] } +} + +function macho(bytes: Buffer, offset: number): Image | undefined { + if (offset + 8 > bytes.length) return + const arch = MACHO_CPUS[bytes.readUInt32LE(offset + 4)] + if (!arch) return + return { offset, format: "Mach-O", arches: [arch] } +} + +function fat(bytes: Buffer, offset: number): Image | undefined { + if (offset + 8 > bytes.length) return + const count = bytes.readUInt32BE(offset + 4) + // Java class files share this magic; their version field reads as 45 or more here. + if (count < 1 || count > 8 || offset + 8 + count * 20 > bytes.length) return + const slices = Array.from({ length: count }, (_, index) => offset + 8 + index * 20) + const arches = slices.map((slice) => MACHO_CPUS[bytes.readUInt32BE(slice)]) + if (arches.some((arch) => arch === undefined)) return + return { + offset, + format: "Mach-O", + arches: arches.filter((arch) => arch !== undefined), + end: offset + Math.max(...slices.map((slice) => bytes.readUInt32BE(slice + 8) + bytes.readUInt32BE(slice + 12))), + } +}