From ddfa4cc59d5d963480431eb964a97d67503da67a Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Sat, 16 May 2026 15:04:32 +0300 Subject: [PATCH] fix(windows): harden packaging and codex smoke --- scripts/electron-builder/afterPack.cjs | 107 +++++++++++++++++- scripts/electron-builder/verifyBundle.cjs | 26 ++++- scripts/smoke/agent-attachments-smoke.mjs | 86 +++++++++++++- .../build/electronBuilderAfterPack.test.ts | 72 +++++++++++- 4 files changed, 281 insertions(+), 10 deletions(-) diff --git a/scripts/electron-builder/afterPack.cjs b/scripts/electron-builder/afterPack.cjs index c4ca053c..3ddcff23 100644 --- a/scripts/electron-builder/afterPack.cjs +++ b/scripts/electron-builder/afterPack.cjs @@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) { return removedPaths; } +function findNodeModulesSequence(segments, sequence) { + for (let index = 0; index <= segments.length - sequence.length; index += 1) { + let matches = true; + for (let offset = 0; offset < sequence.length; offset += 1) { + if (segments[index + offset] !== sequence[offset]) { + matches = false; + break; + } + } + if (matches) { + return index; + } + } + return -1; +} + +function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) { + if (targetPlatform !== 'win32') { + return null; + } + + const relativePath = path.relative(appOutDir, filePath); + const segments = relativePath.split(path.sep); + + const conptyIndex = findNodeModulesSequence(segments, [ + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + ]); + const conptyArchIndex = conptyIndex + 5; + const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null; + if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) { + return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1)); + } + + return null; +} + +function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) { + const normalizedPath = relativePath.split(path.sep).join('/'); + const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe'; + + return ( + targetPlatform === 'win32' && + (normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) && + format === 'pe' && + archs.size === 1 && + archs.has('ia32') + ); +} + +async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) { + const files = await walkFiles(appOutDir); + const rootsToRemove = new Set(); + + for (const filePath of files) { + const rootToRemove = getKnownPrunableNativeArtifactRoot( + appOutDir, + filePath, + targetPlatform, + targetArch + ); + if (!rootToRemove) { + continue; + } + + const metadata = await detectBinaryMetadata(filePath); + if (!metadata) { + continue; + } + + if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) { + continue; + } + + rootsToRemove.add(rootToRemove); + } + + const removedPaths = []; + for (const absolutePath of rootsToRemove) { + await fs.promises.rm(absolutePath, { recursive: true, force: true }); + removedPaths.push(absolutePath); + } + return removedPaths; +} + function mapMachOCpuType(cpuType) { switch (cpuType >>> 0) { case 0x00000007: @@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { const files = await walkFiles(appOutDir); for (const filePath of files) { + const relativePath = path.relative(appOutDir, filePath); const metadata = await detectBinaryMetadata(filePath); if (!metadata) { continue; @@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { continue; } + if ( + isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform) + ) { + continue; + } + mismatches.push({ - path: path.relative(appOutDir, filePath), + path: relativePath, format: metadata.format, archs: [...metadata.archs].sort(), }); @@ -358,7 +452,14 @@ async function afterPack(context) { const targetPlatform = context.electronPlatformName; const targetArch = getArchLabel(context.arch); - const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch); + const removedPaths = [ + ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)), + ...(await pruneKnownIncompatibleNativeArtifacts( + context.appOutDir, + targetPlatform, + targetArch + )), + ]; const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch); if (mismatches.length > 0) { @@ -383,9 +484,11 @@ module.exports._internal = { detectBinaryMetadata, getArchLabel, isBinaryCompatible, + isKnownAllowedNativeMismatch, parseElf, parseMachO, parsePortableExecutable, + pruneKnownIncompatibleNativeArtifacts, pruneNodePtyArtifacts, validateNativeBinaries, walkFiles, diff --git a/scripts/electron-builder/verifyBundle.cjs b/scripts/electron-builder/verifyBundle.cjs index 29ec81db..c4e68520 100644 --- a/scripts/electron-builder/verifyBundle.cjs +++ b/scripts/electron-builder/verifyBundle.cjs @@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs'); const { validateNativeBinaries } = afterPackModule._internal; +function isAllowedPostPackMismatch(mismatch, platform, arch) { + const relativePath = mismatch.path.split(path.sep).join('/'); + return ( + platform === 'win32' && + arch === 'x64' && + relativePath === 'resources/elevate.exe' && + mismatch.format === 'pe' && + mismatch.archs.length === 1 && + mismatch.archs[0] === 'ia32' + ); +} + async function main() { const [bundlePathArg, platform, arch] = process.argv.slice(2); @@ -14,16 +26,22 @@ async function main() { const bundlePath = path.resolve(bundlePathArg); const mismatches = await validateNativeBinaries(bundlePath, platform, arch); + const blockingMismatches = mismatches.filter( + (mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch) + ); - if (mismatches.length === 0) { - console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`); + if (blockingMismatches.length === 0) { + const allowedCount = mismatches.length - blockingMismatches.length; + const suffix = + allowedCount > 0 ? ` (${allowedCount} allowed post-pack helper mismatch ignored)` : ''; + console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}${suffix}`); return; } console.error( - `[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` + `[verifyBundle] Found ${blockingMismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` ); - for (const mismatch of mismatches.slice(0, 50)) { + for (const mismatch of blockingMismatches.slice(0, 50)) { console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`); } process.exit(1); diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs index dc83b686..13f323d2 100644 --- a/scripts/smoke/agent-attachments-smoke.mjs +++ b/scripts/smoke/agent-attachments-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -326,12 +327,93 @@ function parseArgs(argv) { return { all, jsonPath, list, selected }; } +function hasPathSeparator(value) { + return value.includes('/') || value.includes('\\'); +} + +function resolveWindowsSpawnBinary(binary) { + if (process.platform !== 'win32') { + return binary; + } + + if (hasPathSeparator(binary)) { + if (!path.extname(binary) && existsSync(`${binary}.cmd`)) { + return `${binary}.cmd`; + } + return binary; + } + + const whereResult = spawnSync('where.exe', [binary], { + encoding: 'utf8', + windowsHide: true, + }); + if (whereResult.status !== 0 || !whereResult.stdout) { + return binary; + } + + const candidates = whereResult.stdout + .split(/\r?\n/) + .map((candidate) => candidate.trim()) + .filter(Boolean); + const extensionlessShim = candidates.find( + (candidate) => !path.extname(candidate) && existsSync(`${candidate}.cmd`) + ); + if (extensionlessShim) { + return `${extensionlessShim}.cmd`; + } + return ( + candidates.find((candidate) => /\.exe$/i.test(candidate)) ?? + candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate)) ?? + candidates[0] ?? + binary + ); +} + +function quoteWindowsCmdArg(value) { + const text = String(value); + if (text.length === 0) { + return '""'; + } + if (!/[ \t\r\n"&|<>^()%!]/.test(text)) { + return text; + } + return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`; +} + +function buildSpawnInvocation(command) { + if (process.platform !== 'win32') { + return { + bin: command.bin, + args: command.args, + options: { windowsHide: true }, + }; + } + + const resolvedBin = resolveWindowsSpawnBinary(command.bin); + if (/\.(?:cmd|bat)$/i.test(resolvedBin)) { + const commandLine = [resolvedBin, ...command.args].map(quoteWindowsCmdArg).join(' '); + return { + bin: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', commandLine], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }; + } + + return { + bin: resolvedBin, + args: command.args, + options: { windowsHide: true }, + }; +} + function runCommand(command) { return new Promise((resolve) => { - const child = spawn(command.bin, command.args, { + const spawnInvocation = buildSpawnInvocation(command); + const child = spawn(spawnInvocation.bin, spawnInvocation.args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, cwd: command.cwd, + ...spawnInvocation.options, }); let stdout = ''; let stderr = ''; diff --git a/test/main/build/electronBuilderAfterPack.test.ts b/test/main/build/electronBuilderAfterPack.test.ts index 7b8428e4..8835dbfc 100644 --- a/test/main/build/electronBuilderAfterPack.test.ts +++ b/test/main/build/electronBuilderAfterPack.test.ts @@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer { return buffer; } -function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer { - const machine = arch === 'arm64' ? 0xaa64 : 0x8664; +function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer { + const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664; const buffer = Buffer.alloc(256); buffer[0] = 0x4d; buffer[1] = 0x5a; @@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => { ) ).toBe(false); }); + + it('accepts a clean x64 Windows bundle with optional standalone helper binaries', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + writeFile(path.join(tempDir, 'Agent Teams UI.exe'), createPortableExecutableBuffer('x64')); + writeFile( + path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'build', + 'Release', + 'pty.node' + ), + createPortableExecutableBuffer('x64') + ); + const pageantPath = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'ssh2', + 'util', + 'pagent.exe' + ); + const armConptyDir = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '1.23.251008001', + 'win10-arm64' + ); + writeFile(pageantPath, createPortableExecutableBuffer('ia32')); + writeFile(path.join(armConptyDir, 'conpty.dll'), createPortableExecutableBuffer('arm64')); + writeFile(path.join(armConptyDir, 'OpenConsole.exe'), createPortableExecutableBuffer('arm64')); + + await afterPackModule({ + appOutDir: tempDir, + electronPlatformName: 'win32', + arch: 1, + }); + + expect(fs.existsSync(pageantPath)).toBe(true); + expect(fs.existsSync(armConptyDir)).toBe(false); + }); + + it('still reports unrelated ia32 Windows binaries in an x64 bundle', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + const badBinaryPath = path.join(tempDir, 'resources', 'app.asar.unpacked', 'bad-helper.exe'); + writeFile(badBinaryPath, createPortableExecutableBuffer('ia32')); + + await expect(validateNativeBinaries(tempDir, 'win32', 'x64')).resolves.toEqual([ + { + path: path.join('resources', 'app.asar.unpacked', 'bad-helper.exe'), + format: 'pe', + archs: ['ia32'], + }, + ]); + }); });