diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index 690635b6a0..bc9f0b5056 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -44,6 +44,10 @@ env: PEEKABOO_SHA256: '5be06117ed861ac7a87ea1d1e552122db4231bf2cd618ec516d77c66acd39620' PEEKABOO_BINARY_SHA256: '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383' PEEKABOO_LICENSE_SHA256: '62316704df7426e5a79d2827ff8aca36e9abb3a73b8e68557030749ebefec667' + PYTHON_RUNTIME_VERSION: '3.12.13' + PYTHON_RUNTIME_RELEASE: '20260510' + PYTHON_RUNTIME_ARM64_SHA256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17' + PYTHON_RUNTIME_X64_SHA256: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894' jobs: code-quality: @@ -666,6 +670,15 @@ jobs: echo "EVAOS_REQUIRED_PEEKABOO_SOURCE_SHA256=$PEEKABOO_BINARY_SHA256" } >> "$GITHUB_ENV" + - name: Install pinned evaOS desktop bridge Python runtime (macOS only) + if: startsWith(matrix.platform, 'macos') + shell: bash + env: + TARGET_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + scripts/prepareEvaosDesktopBridgePythonRuntime.sh "$TARGET_ARCH" "$GITHUB_ENV" + # macOS: Build with notarization - DMG failure = CI failure, notarization failure = warning only # macOS: 构建并公证 - DMG 失败 = CI 失败,公证失败 = 仅警告 - name: Build with electron-builder (macOS) diff --git a/.github/workflows/workbench-functional-smoke.yml b/.github/workflows/workbench-functional-smoke.yml index a9207c8a1b..5111ceb07f 100644 --- a/.github/workflows/workbench-functional-smoke.yml +++ b/.github/workflows/workbench-functional-smoke.yml @@ -41,6 +41,10 @@ env: PEEKABOO_SHA256: '5be06117ed861ac7a87ea1d1e552122db4231bf2cd618ec516d77c66acd39620' PEEKABOO_BINARY_SHA256: '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383' PEEKABOO_LICENSE_SHA256: '62316704df7426e5a79d2827ff8aca36e9abb3a73b8e68557030749ebefec667' + PYTHON_RUNTIME_VERSION: '3.12.13' + PYTHON_RUNTIME_RELEASE: '20260510' + PYTHON_RUNTIME_ARM64_SHA256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17' + PYTHON_RUNTIME_X64_SHA256: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894' WORKBENCH_SMOKE_BRIDGE_REF: ${{ inputs.bridge_ref || vars.EVAOS_DESKTOP_BRIDGE_SOURCE_REF }} WORKBENCH_SMOKE_REF: ${{ inputs.ref }} @@ -66,6 +70,15 @@ jobs: echo "short=$SHORT" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate immutable bridge ref + shell: bash + run: | + set -euo pipefail + if [[ ! "$WORKBENCH_SMOKE_BRIDGE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Workbench functional smoke requires a full immutable evaos-desktop-bridge commit SHA." + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -149,6 +162,12 @@ jobs: echo "EVAOS_REQUIRED_PEEKABOO_SOURCE_SHA256=$PEEKABOO_BINARY_SHA256" } >> "$GITHUB_ENV" + - name: Install pinned desktop bridge Python runtime + shell: bash + run: | + set -euo pipefail + scripts/prepareEvaosDesktopBridgePythonRuntime.sh arm64 "$GITHUB_ENV" + - name: evaOS guardrail audit if: ${{ inputs.run_guardrails }} shell: bash @@ -188,6 +207,8 @@ jobs: BRIDGE_HELPER="$APP_PATH/Contents/Resources/Bridge/bin/evaos-connector-helper" BRIDGE_PEEKABOO_LICENSE="$APP_PATH/Contents/Resources/Bridge/licenses/Peekaboo-LICENSE.txt" BRIDGE_MANIFEST="$APP_PATH/Contents/Resources/Bridge/manifest.json" + BRIDGE_PYTHON="$APP_PATH/Contents/Resources/Bridge/python/bin/python3" + BRIDGE_PYTHON_LICENSE="$APP_PATH/Contents/Resources/Bridge/licenses/CPython-LICENSE.txt" AIONCORE_DIR="$APP_PATH/Contents/Resources/bundled-aioncore" HUB_DIR="$APP_PATH/Contents/Resources/hub" @@ -196,6 +217,8 @@ jobs: test -x "$BRIDGE_HELPER" test -f "$BRIDGE_PEEKABOO_LICENSE" test -f "$BRIDGE_MANIFEST" + test -x "$BRIDGE_PYTHON" + test -f "$BRIDGE_PYTHON_LICENSE" test -d "$AIONCORE_DIR" test -d "$HUB_DIR" @@ -209,6 +232,8 @@ jobs: BUNDLED_PEEKABOO_LICENSE_SHA256="$(node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(m.bundledTools?.peekaboo?.licenseSha256 || 'unknown')" "$BRIDGE_MANIFEST")" PACKAGED_PEEKABOO_SHA256="$(shasum -a 256 "$BRIDGE_PEEKABOO" | awk '{print $1}')" ACTUAL_PEEKABOO_LICENSE_SHA256="$(shasum -a 256 "$BRIDGE_PEEKABOO_LICENSE" | awk '{print $1}')" + BUNDLED_PYTHON_VERSION="$(node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(m.bundledTools?.python?.version || 'unknown')" "$BRIDGE_MANIFEST")" + BUNDLED_PYTHON_SOURCE_SHA256="$(node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(m.bundledTools?.python?.sourceSha256 || 'unknown')" "$BRIDGE_MANIFEST")" if [ "$BRIDGE_VERSION" != "$WORKBENCH_SMOKE_BRIDGE_REF" ]; then echo "::error::Bundled bridge manifest ($BRIDGE_VERSION) does not match requested bridge ref ($WORKBENCH_SMOKE_BRIDGE_REF)" @@ -238,6 +263,14 @@ jobs: echo "::error::Bundled Peekaboo license digest does not match the bridge manifest." exit 1 fi + if [ "$BUNDLED_PYTHON_VERSION" != "$PYTHON_RUNTIME_VERSION" ] || [ "$BUNDLED_PYTHON_SOURCE_SHA256" != "$PYTHON_RUNTIME_ARM64_SHA256" ]; then + echo "::error::Bundled Python runtime identity does not match the pinned arm64 release." + exit 1 + fi + test "$(readlink "$BRIDGE_PYTHON")" = python3.12 + test "$(lipo -archs "$APP_PATH/Contents/Resources/Bridge/python/bin/python3.12")" = arm64 + env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin "$BRIDGE_PYTHON" -I -c 'import ApplicationServices, Cocoa, CoreText, Quartz' + env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin "$BRIDGE" status --json | jq -e '.ok == true or .ok == false' >/dev/null echo "app_path=$APP_PATH" >> "$GITHUB_OUTPUT" echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index e5ac5eaf01..62e3c99532 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,7 @@ docs/superpowers/ resources/bundled-bun resources/bundled-aioncore resources/Bridge +resources/Bridge-* resources/hub # Server build output diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5a22456a..5a9bd53a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ ### Pristine Mac Enrollment +- Bundles a pinned, checksum-verified CPython 3.12 runtime with the desktop + bridge, including the pinned PyObjC frameworks used by direct Accessibility + control, so connector setup no longer depends on Homebrew, a system Python, + or a customer-installed Python package; release packaging fails closed if + the runtime, architecture, dependency provenance, native signing closure, or + license notices are missing. - Adds an authenticated, customer-scoped Workbench action for the typed `unenrolled` secure-network state; wrong-control-plane, ACL, missing-client, and incomplete-evidence states remain fail closed. diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 201790605e..7794e80620 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -114,7 +114,7 @@ extraResources: - from: resources/hub to: hub # evaOS Mac connector bridge, generated from evaos-desktop-bridge by scripts/prepareEvaosDesktopBridgeResource.js - - from: resources/Bridge + - from: resources/Bridge-${arch} to: Bridge win: executableName: EvaOSWorkbench diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 78aa689f6b..0d19819c7c 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,5 +1,6 @@ const { Arch } = require('builder-util'); const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); const os = require('os'); const { @@ -9,13 +10,24 @@ const { getModulesToRebuild, } = require('./rebuildNativeModules'); const { normalizeManagedResourcesBundle } = require('../packages/shared-scripts/src/prepare-aioncore.js'); +const { clearDmgRetryCompletionMarkers, markCompletedAfterPack } = require('./dmgRetryEligibility'); +const { verifyPythonRuntimeInventory } = require('./prepareEvaosDesktopBridgeResource'); /** * afterPack hook for electron-builder * Rebuilds native modules for cross-architecture builds */ -const MACHO_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'cafebabf']); +const MACHO_MAGICS = new Set([ + 'feedface', + 'feedfacf', + 'cefaedfe', + 'cffaedfe', + 'cafebabe', + 'bebafeca', + 'cafebabf', + 'bfbafeca', +]); function isTruthy(value) { return ['1', 'true', 'yes', 'on', 'evaos-beta'].includes( @@ -142,6 +154,15 @@ function isMachOExecutable(filePath) { if (!fs.existsSync(filePath)) return false; try { fs.accessSync(filePath, fs.constants.X_OK); + return isMachOFile(filePath); + } catch { + return false; + } +} + +function isMachOFile(filePath) { + if (!fs.existsSync(filePath)) return false; + try { const header = fs.readFileSync(filePath, { encoding: null, flag: 'r' }).subarray(0, 4).toString('hex'); return MACHO_MAGICS.has(header); } catch { @@ -178,13 +199,120 @@ function requireManagedNodeRuntime(resourcesDir, runtimeKey, electronPlatformNam } } -function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { +const PYTHON_RUNTIME_SOURCE_SHA256_BY_ARCH = { + arm64: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17', + x64: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894', +}; +const PYTHON_RUNTIME_SOURCE_URL_BY_ARCH = { + arm64: + 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-aarch64-apple-darwin-install_only.tar.gz', + x64: 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-x86_64-apple-darwin-install_only.tar.gz', +}; +const PYTHON_RUNTIME_LICENSE_SHA256 = '3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf'; +const PYTHON_RUNTIME_PACKAGES = [ + ['pyobjc-core', '12.2.1', 'a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b'], + ['pyobjc-framework-Cocoa', '12.2.1', '28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080'], + ['pyobjc-framework-Quartz', '12.2.1', 'de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9'], + [ + 'pyobjc-framework-ApplicationServices', + '12.2.1', + 'f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b', + ], + ['pyobjc-framework-CoreText', '12.2.1', 'ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6'], +].map(([name, version, sha256]) => ({ name, version, sha256 })); + +function thinMachOArchitecture(filePath) { + const header = fs.readFileSync(filePath).subarray(0, 8).toString('hex'); + if (header.startsWith('cffaedfe0c000001')) return 'arm64'; + if (header.startsWith('cffaedfe07000001')) return 'x64'; + return undefined; +} + +function readUInt32(buffer, offset, byteOrder) { + return byteOrder === 'little' ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); +} + +function thinMachOCpuType(buffer) { + if (buffer.length < 8) return undefined; + const magic = buffer.subarray(0, 4).toString('hex'); + if (magic === 'cffaedfe' || magic === 'cefaedfe') return buffer.readUInt32LE(4); + if (magic === 'feedfacf' || magic === 'feedface') return buffer.readUInt32BE(4); + return undefined; +} + +function machOContainsArchitecture(filePath, targetArch) { + const expectedCpuType = targetArch === 'arm64' ? 0x0100000c : targetArch === 'x64' ? 0x01000007 : undefined; + if (!expectedCpuType) return false; + const buffer = fs.readFileSync(filePath); + const thinCpuType = thinMachOCpuType(buffer); + if (thinCpuType !== undefined) return thinCpuType === expectedCpuType; + if (buffer.length < 8) return false; + + const magic = buffer.subarray(0, 4).toString('hex'); + const fatShape = { + cafebabe: { byteOrder: 'big', recordSize: 20, fat64: false }, + bebafeca: { byteOrder: 'little', recordSize: 20, fat64: false }, + cafebabf: { byteOrder: 'big', recordSize: 32, fat64: true }, + bfbafeca: { byteOrder: 'little', recordSize: 32, fat64: true }, + }[magic]; + if (!fatShape) return false; + + const count = readUInt32(buffer, 4, fatShape.byteOrder); + if (count === 0 || count > 64 || 8 + count * fatShape.recordSize > buffer.length) return false; + for (let index = 0; index < count; index += 1) { + const recordOffset = 8 + index * fatShape.recordSize; + const cpuType = readUInt32(buffer, recordOffset, fatShape.byteOrder); + if (cpuType !== expectedCpuType) continue; + const sliceOffset = fatShape.fat64 + ? Number( + fatShape.byteOrder === 'little' + ? buffer.readBigUInt64LE(recordOffset + 8) + : buffer.readBigUInt64BE(recordOffset + 8) + ) + : readUInt32(buffer, recordOffset + 8, fatShape.byteOrder); + const sliceSize = fatShape.fat64 + ? Number( + fatShape.byteOrder === 'little' + ? buffer.readBigUInt64LE(recordOffset + 16) + : buffer.readBigUInt64BE(recordOffset + 16) + ) + : readUInt32(buffer, recordOffset + 12, fatShape.byteOrder); + if (sliceSize >= 8 && sliceOffset + sliceSize <= buffer.length) { + return thinMachOCpuType(buffer.subarray(sliceOffset, sliceOffset + sliceSize)) === expectedCpuType; + } + } + return false; +} + +function verifyPythonMachOClosureArchitecture(pythonRuntimeDir, targetArch) { + const pending = [pythonRuntimeDir]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile() && isMachOFile(entryPath)) { + if (!machOContainsArchitecture(entryPath, targetArch)) { + throw new Error( + `Packaged evaOS desktop bridge Python native runtime does not contain target architecture ${targetArch}: ${entryPath}` + ); + } + } + } + } +} + +function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, targetArch) { if (electronPlatformName !== 'darwin') return; const bridgePath = path.join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'); const peekabooPath = path.join(resourcesDir, 'Bridge', 'bin', 'peekaboo'); const helperPath = path.join(resourcesDir, 'Bridge', 'bin', 'evaos-connector-helper'); const manifestPath = path.join(resourcesDir, 'Bridge', 'manifest.json'); + const pythonPath = path.join(resourcesDir, 'Bridge', 'python', 'bin', 'python3'); + const versionedPythonPath = path.join(resourcesDir, 'Bridge', 'python', 'bin', 'python3.12'); + const pythonLicensePath = path.join(resourcesDir, 'Bridge', 'licenses', 'CPython-LICENSE.txt'); const missing = []; if (!fs.existsSync(bridgePath)) { missing.push(path.join('Bridge', 'evaos-desktop-bridge')); @@ -216,6 +344,27 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { if (!fs.existsSync(manifestPath)) { missing.push(path.join('Bridge', 'manifest.json')); } + if (!fs.existsSync(pythonPath)) { + missing.push(path.join('Bridge', 'python', 'bin', 'python3')); + } else { + try { + fs.accessSync(pythonPath, fs.constants.X_OK); + } catch { + throw new Error(`Packaged evaOS desktop bridge Python runtime is not executable: ${pythonPath}`); + } + } + if (!fs.existsSync(versionedPythonPath)) { + missing.push(path.join('Bridge', 'python', 'bin', 'python3.12')); + } else { + try { + fs.accessSync(versionedPythonPath, fs.constants.X_OK); + } catch { + throw new Error(`Packaged evaOS desktop bridge Python runtime is not executable: ${versionedPythonPath}`); + } + } + if (!fs.existsSync(pythonLicensePath)) { + missing.push(path.join('Bridge', 'licenses', 'CPython-LICENSE.txt')); + } if (missing.length > 0) { throw new Error(`Packaged app is missing required evaOS desktop bridge resource(s): ${missing.join(', ')}`); } @@ -229,8 +378,43 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { + if (!targetArch || !PYTHON_RUNTIME_SOURCE_SHA256_BY_ARCH[targetArch]) { + throw new Error('Packaged evaOS desktop bridge target architecture is required for strict release validation.'); + } requireMachOExecutable(peekabooPath, path.join('Bridge', 'bin', 'peekaboo')); requireMachOExecutable(helperPath, path.join('Bridge', 'bin', 'evaos-connector-helper')); + requireMachOExecutable(pythonPath, path.join('Bridge', 'python', 'bin', 'python3')); + requireMachOExecutable(versionedPythonPath, path.join('Bridge', 'python', 'bin', 'python3.12')); + const pythonMetadata = manifest.bundledTools?.python; + if ( + !pythonMetadata?.version || + !/^[0-9a-f]{64}$/i.test(String(pythonMetadata.sourceSha256 || '')) || + pythonMetadata.license !== 'Python-2.0' || + pythonMetadata.licensePath !== 'licenses/CPython-LICENSE.txt' || + pythonMetadata.licenseSha256 !== PYTHON_RUNTIME_LICENSE_SHA256 || + crypto.createHash('sha256').update(fs.readFileSync(pythonLicensePath)).digest('hex') !== + PYTHON_RUNTIME_LICENSE_SHA256 || + JSON.stringify(pythonMetadata.packages) !== JSON.stringify(PYTHON_RUNTIME_PACKAGES) + ) { + throw new Error('Packaged evaOS desktop bridge manifest is missing pinned bundled Python runtime provenance.'); + } + const expectedDigest = PYTHON_RUNTIME_SOURCE_SHA256_BY_ARCH[targetArch]; + if ( + pythonMetadata.architecture !== targetArch || + pythonMetadata.sourceSha256 !== expectedDigest || + thinMachOArchitecture(versionedPythonPath) !== targetArch + ) { + throw new Error(`Packaged evaOS desktop bridge Python runtime does not match target architecture ${targetArch}.`); + } + if (pythonMetadata.sourceUrl !== PYTHON_RUNTIME_SOURCE_URL_BY_ARCH[targetArch]) { + throw new Error('Packaged evaOS desktop bridge manifest is missing pinned bundled Python runtime provenance.'); + } + verifyPythonMachOClosureArchitecture(path.join(resourcesDir, 'Bridge', 'python'), targetArch); + const pythonLink = fs.lstatSync(pythonPath); + if (!pythonLink.isSymbolicLink() || fs.readlinkSync(pythonPath) !== 'python3.12') { + throw new Error('Packaged evaOS desktop bridge Python launcher symlink is not relocatable.'); + } + verifyPythonRuntimeInventory(path.join(resourcesDir, 'Bridge'), pythonMetadata); } console.log(' ✓ evaOS desktop bridge resource verified'); @@ -270,6 +454,7 @@ module.exports = async function afterPack(context) { const { arch, electronPlatformName, appOutDir, packager } = context; const targetArch = normalizeArch(typeof arch === 'string' ? arch : Arch[arch] || process.arch); const buildArch = normalizeArch(os.arch()); + clearDmgRetryCompletionMarkers(appOutDir); console.log(`\n🔧 afterPack hook started`); console.log(` Platform: ${electronPlatformName}, Build arch: ${buildArch}, Target arch: ${targetArch}`); @@ -302,12 +487,13 @@ module.exports = async function afterPack(context) { } verifyBundledResources(resourcesDir, electronPlatformName, targetArch); - verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName); + verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, targetArch); } else { throw new Error(`resources directory not found: ${resourcesDir}`); } if (!isCrossCompile && !needsSameArchRebuild && !forceRebuild) { + markCompletedAfterPack(appOutDir); console.log(` ✓ Same architecture, rebuild skipped (set FORCE_NATIVE_REBUILD=true to override)\n`); return; } @@ -452,9 +638,12 @@ module.exports = async function afterPack(context) { throw new Error(`Failed to rebuild modules for ${electronPlatformName}-${targetArch}: ${failedModules.join(', ')}`); } + markCompletedAfterPack(appOutDir); console.log(`✅ All native modules rebuilt successfully for ${targetArch}\n`); }; module.exports.verifyBundledResources = verifyBundledResources; module.exports.verifyEvaosDesktopBridgeResource = verifyEvaosDesktopBridgeResource; +module.exports.thinMachOArchitecture = thinMachOArchitecture; +module.exports.machOContainsArchitecture = machOContainsArchitecture; module.exports.isMachOExecutable = isMachOExecutable; diff --git a/scripts/afterSign.js b/scripts/afterSign.js index 4ef4fdd6a0..b49f91b8bb 100644 --- a/scripts/afterSign.js +++ b/scripts/afterSign.js @@ -7,6 +7,7 @@ const { getEnvValue, isStrictPublicBetaReleaseEnv, } = require('./evaosBetaReleaseGate'); +const { withAfterSignCompletion } = require('./dmgRetryEligibility'); const AMBIENT_APPLE_API_ENV_KEYS = [ 'APPLE_API_KEY', @@ -23,7 +24,16 @@ const DEFAULT_APP_NOTARY_COMMAND_PROCESS_TIMEOUT_MS = 90 * 1000; const DEFAULT_APP_NOTARY_POLL_INTERVAL_MS = 15 * 1000; const DEFAULT_APP_TRUST_PROCESS_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_EXPECTED_TEAM_ID = 'TC6MS3T6NN'; -const MACHO_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'cafebabf']); +const MACHO_MAGICS = new Set([ + 'feedface', + 'feedfacf', + 'cefaedfe', + 'cffaedfe', + 'cafebabe', + 'bebafeca', + 'cafebabf', + 'bfbafeca', +]); const MAC_CONTROL_HELPER_RELATIVE_PATHS = [ path.join('Contents', 'Resources', 'Bridge', 'bin', 'peekaboo'), path.join('Contents', 'Resources', 'Bridge', 'bin', 'evaos-connector-helper'), @@ -159,10 +169,9 @@ function getAppTrustProcessTimeoutMs(env = process.env) { return getPositiveProcessTimeoutMs(env, 'EVAOS_APP_TRUST_PROCESS_TIMEOUT_MS', DEFAULT_APP_TRUST_PROCESS_TIMEOUT_MS); } -function isMachOExecutable(filePath) { +function isMachOFile(filePath) { if (!fs.existsSync(filePath)) return false; try { - fs.accessSync(filePath, fs.constants.X_OK); const header = fs.readFileSync(filePath, { encoding: null, flag: 'r' }).subarray(0, 4).toString('hex'); return MACHO_MAGICS.has(header); } catch { @@ -170,6 +179,15 @@ function isMachOExecutable(filePath) { } } +function isMachOExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return isMachOFile(filePath); + } catch { + return false; + } +} + function expectedMacControlHelperTeamId(env = process.env) { return ( getEnvValue(env, { @@ -196,11 +214,16 @@ function codeSignatureDetails(filePath, runProcess = spawnSync) { return output; } -function assertMacControlHelperSignature(filePath, env = process.env, runProcess = spawnSync) { +function assertMacControlHelperSignature( + filePath, + env = process.env, + runProcess = spawnSync, + requireExecutable = true +) { if (!fs.existsSync(filePath)) { throw new Error(`Strict evaOS beta release is missing bundled Mac-control helper: ${filePath}`); } - if (!isMachOExecutable(filePath)) { + if (!(requireExecutable ? isMachOExecutable(filePath) : isMachOFile(filePath))) { throw new Error( `Strict evaOS beta release requires bundled Mac-control helper to be a native Mach-O executable: ${filePath}` ); @@ -223,6 +246,32 @@ function assertMacControlHelperSignatures(appPath, env = process.env, runProcess for (const relativePath of MAC_CONTROL_HELPER_RELATIVE_PATHS) { assertMacControlHelperSignature(path.join(appPath, relativePath), env, runProcess); } + + const pythonRuntimeDir = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'python'); + if (!fs.existsSync(pythonRuntimeDir)) { + throw new Error(`Strict evaOS beta release is missing bundled Python runtime: ${pythonRuntimeDir}`); + } + const runtimeMachOPaths = []; + const pending = [pythonRuntimeDir]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile() && isMachOFile(entryPath)) { + runtimeMachOPaths.push(entryPath); + } + } + } + if (runtimeMachOPaths.length === 0) { + throw new Error( + `Strict evaOS beta release bundled Python runtime has no native Mach-O closure: ${pythonRuntimeDir}` + ); + } + for (const runtimePath of runtimeMachOPaths) { + assertMacControlHelperSignature(runtimePath, env, runProcess, false); + } } function isProcessTimeoutError(error) { @@ -470,12 +519,8 @@ function notarizeAndStapleApp(appPath, notarizationOptions, env = process.env, r } } -async function afterSign(context) { - const { electronPlatformName, appOutDir } = context; - - if (electronPlatformName !== 'darwin') { - return; - } +async function performMacAfterSign(context) { + const { appOutDir } = context; const appName = context.packager.appInfo.productFilename; const appBundleId = context.packager.appInfo.id; const appPath = `${appOutDir}/${appName}.app`; @@ -496,6 +541,7 @@ async function afterSign(context) { console.log(`Ad-hoc signature applied successfully to ${appName}`); } catch (adHocError) { console.error('Ad-hoc signing failed:', adHocError.message); + throw adHocError; } return; } @@ -536,6 +582,16 @@ async function afterSign(context) { } } +async function afterSign(context) { + const { electronPlatformName, appOutDir } = context; + + if (electronPlatformName !== 'darwin') { + return; + } + + return withAfterSignCompletion(appOutDir, () => performMacAfterSign(context)); +} + module.exports = afterSign; module.exports.default = afterSign; module.exports.assertMacControlHelperSignature = assertMacControlHelperSignature; diff --git a/scripts/build-with-builder.js b/scripts/build-with-builder.js index c5784ea8e7..e5a85300b2 100644 --- a/scripts/build-with-builder.js +++ b/scripts/build-with-builder.js @@ -10,10 +10,12 @@ * - Packaging only: use --pack-only to skip electron-builder distributable creation */ -const { execSync, spawnSync } = require('child_process'); +const { execFileSync, execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const os = require('os'); const crypto = require('crypto'); +const { clearDmgRetryCompletionMarkersInDirectory, isDmgRetryEligible } = require('./dmgRetryEligibility'); // DMG retry logic for macOS: detects DMG creation failures by checking artifacts // (.app exists but .dmg missing) and retries only the DMG step using @@ -298,6 +300,8 @@ function cleanupGeneratedPackageOutputs({ preserveViteOutputs = false } = {}) { 'resources/bundled-aioncore', 'resources/hub', 'resources/Bridge', + 'resources/Bridge-arm64', + 'resources/Bridge-x64', ]) { removeTarget(relativePath); } @@ -332,9 +336,79 @@ function createDmgWithPrepackaged(appDir, targetArch) { ); } -function buildWithDmgRetry(cmd, targetArch) { +function installPreparedRuntimeEnvironment(envFile) { + const allowedKeys = new Set([ + 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_VERSION', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_SHA256', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_SOURCE_URL', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_ARCH', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_LICENSE_SHA256', + 'EVAOS_REQUIRED_PYTHON_RUNTIME_PACKAGES_JSON', + ]); + const preparedValues = new Map(); + const lines = fs.readFileSync(envFile, 'utf8').split(/\r?\n/); + for (const line of lines) { + if (!line) continue; + const separator = line.indexOf('='); + if (separator <= 0) throw new Error('Bundled Python runtime preparation emitted invalid environment metadata.'); + const key = line.slice(0, separator); + if (!allowedKeys.has(key)) { + throw new Error(`Bundled Python runtime preparation emitted unexpected environment metadata key: ${key}`); + } + if (preparedValues.has(key)) { + throw new Error(`Bundled Python runtime preparation emitted duplicate environment metadata key: ${key}`); + } + preparedValues.set(key, line.slice(separator + 1)); + } + + const previousValues = new Map(); + for (const [key, value] of preparedValues.entries()) { + previousValues.set(key, { + exists: Object.prototype.hasOwnProperty.call(process.env, key), + value: process.env[key], + }); + process.env[key] = value; + } + return () => { + for (const [key, previous] of previousValues.entries()) { + if (previous.exists && typeof previous.value === 'string') process.env[key] = previous.value; + else delete process.env[key]; + } + }; +} + +function withDesktopBridgePythonRuntime(targetArch, operation, { allowExistingRuntime = true } = {}) { + // A caller-supplied runtime is caller-owned: use it for the copy operation, + // but never delete or otherwise assume lifecycle ownership of that path. + if ((allowExistingRuntime && process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR) || process.platform !== 'darwin') { + return operation(); + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-python-runtime-')); + const envFile = path.join(tempRoot, 'runtime.env'); + let restoreEnvironment = () => {}; + try { + execFileSync(path.join(__dirname, 'prepareEvaosDesktopBridgePythonRuntime.sh'), [targetArch, envFile], { + stdio: 'inherit', + env: { ...process.env, RUNNER_TEMP: tempRoot }, + }); + restoreEnvironment = installPreparedRuntimeEnvironment(envFile); + return operation(); + } finally { + restoreEnvironment(); + try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } catch (cleanupError) { + console.warn(`Failed to clean temporary desktop bridge Python runtime ${tempRoot}: ${cleanupError.message}`); + } + } +} + +function buildWithDmgRetry(cmd, targetArch, { multiArch = false } = {}) { const isMac = process.platform === 'darwin'; const outDir = path.resolve(__dirname, '../out'); + clearDmgRetryCompletionMarkersInDirectory(outDir); try { execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32' }); @@ -344,7 +418,13 @@ function buildWithDmgRetry(cmd, targetArch) { const appDir = isMac ? findAppDir(outDir) : null; if (!appDir || dmgExists(outDir)) throw error; - // .app exists but no .dmg → DMG creation failed + // A partial .app can exist when afterPack or another packaging hook failed. + // Retrying that bundle as --prepackaged would turn a real validation failure + // into a false-green DMG, so require successful completion of both the + // afterPack resource gate and the afterSign trust gate. + if (!isDmgRetryEligible(appDir, { multiArch })) throw error; + + // Verified .app exists but no .dmg → DMG creation failed console.log('\n🔄 Build failed during DMG creation (.app exists, .dmg missing)'); console.log(' Retrying DMG creation with --prepackaged...'); @@ -566,22 +646,42 @@ try { const { prepareAioncore, readManagedResourcesBundle } = require('../packages/shared-scripts/src/prepare-aioncore.js'); const { resolveAioncoreVersion } = require('./resolveAioncoreVersion.js'); const projectRoot = path.resolve(__dirname, '..'); - prepareAioncore({ - projectRoot, - platform: process.platform, - arch: targetArch, - version: resolveAioncoreVersion(projectRoot), - env: process.env, - managedResourcesBundle: readManagedResourcesBundle({ env: process.env }), - }); + const isMacBuild = builderArgs.includes('--mac') || builderArgs.includes('--all'); + const resourceArches = isMacBuild && multiArch ? archArgs : [targetArch]; + for (const resourceArch of resourceArches) { + prepareAioncore({ + projectRoot, + platform: process.platform, + arch: resourceArch, + version: resolveAioncoreVersion(projectRoot), + env: process.env, + managedResourcesBundle: readManagedResourcesBundle({ env: process.env }), + }); + } // 6. Prepare hub resources (index.json + extension zips for offline fallback) execSync('node scripts/prepareHubResources.js', { stdio: 'inherit', env: process.env }); // 7. Prepare the bundled evaOS desktop bridge for macOS Workbench pairing/control parity. // The packaged app must resolve Contents/Resources/Bridge/evaos-desktop-bridge before Homebrew. - if (builderArgs.includes('--mac') || builderArgs.includes('--all')) { - execSync('node scripts/prepareEvaosDesktopBridgeResource.js', { stdio: 'inherit', env: process.env }); + if (isMacBuild) { + for (const resourceArch of resourceArches) { + const previousResourceDir = process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR; + process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR = path.join(projectRoot, 'resources', `Bridge-${resourceArch}`); + try { + withDesktopBridgePythonRuntime( + resourceArch, + () => execSync('node scripts/prepareEvaosDesktopBridgeResource.js', { stdio: 'inherit', env: process.env }), + { allowExistingRuntime: !multiArch } + ); + } finally { + if (typeof previousResourceDir === 'string') { + process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR = previousResourceDir; + } else { + delete process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR; + } + } + } } // 8. 运行 electron-builder 生成分发包(DMG/ZIP/EXE等) @@ -668,7 +768,7 @@ try { const builderCommand = `bunx electron-builder --config packages/desktop/electron-builder.yml ${builderArgs} ${archFlag} ${nsisInclude} ${publishArg}`; try { - buildWithDmgRetry(builderCommand, targetArch); + buildWithDmgRetry(builderCommand, targetArch, { multiArch }); if (process.platform === 'darwin' && (builderArgs.includes('--mac') || builderArgs.includes('--all'))) { require('./evaosFinalizeMacDmg').finalizeMacDmgs({ outDir, env: process.env }); } @@ -699,7 +799,7 @@ try { cleanupWindowsPackOutput(); try { - buildWithDmgRetry(`${builderCommand} --config.win.signAndEditExecutable=false`, targetArch); + buildWithDmgRetry(`${builderCommand} --config.win.signAndEditExecutable=false`, targetArch, { multiArch }); } catch (retryError) { const retryFailure = formatExecError(retryError); throw new Error( diff --git a/scripts/create-mock-release-artifacts.sh b/scripts/create-mock-release-artifacts.sh index 0dbbf3e9bf..475bbd3e70 100644 --- a/scripts/create-mock-release-artifacts.sh +++ b/scripts/create-mock-release-artifacts.sh @@ -42,20 +42,97 @@ create_mock_macos_zip() { mkdir -p "$tmp_dir/${PRODUCT_NAME}.app/Contents/Resources/Bridge/licenses" printf '#!/usr/bin/env bash\nprintf "{}\\n"\n' > "$tmp_dir/${PRODUCT_NAME}.app/Contents/Resources/Bridge/evaos-desktop-bridge" chmod +x "$tmp_dir/${PRODUCT_NAME}.app/Contents/Resources/Bridge/evaos-desktop-bridge" - python3 - "$tmp_dir/${PRODUCT_NAME}.app/Contents/Resources/Bridge" <<'PY' + python3 - "$tmp_dir/${PRODUCT_NAME}.app/Contents/Resources/Bridge" "$output_path" "tests/fixtures/licenses/CPython-3.12.13-LICENSE.txt" <<'PY' import hashlib import json import pathlib +import stat import sys bridge = pathlib.Path(sys.argv[1]) +output_path = pathlib.Path(sys.argv[2]) +python_license_path = pathlib.Path(sys.argv[3]) +architecture = "arm64" if "arm64" in output_path.name else "x64" +runtime_sha256 = ( + "5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17" + if architecture == "arm64" + else "cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894" +) +runtime_arch = "aarch64" if architecture == "arm64" else "x86_64" +python_header = bytes.fromhex("cffaedfe0c000001" if architecture == "arm64" else "cffaedfe07000001") macho = bytes.fromhex("cafebabe00000000") license_bytes = b"MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n" +python_license_bytes = python_license_path.read_bytes() (bridge / "bin" / "peekaboo").write_bytes(macho) (bridge / "bin" / "evaos-connector-helper").write_bytes(macho) (bridge / "bin" / "peekaboo").chmod(0o755) (bridge / "bin" / "evaos-connector-helper").chmod(0o755) (bridge / "licenses" / "Peekaboo-LICENSE.txt").write_bytes(license_bytes) +(bridge / "licenses" / "CPython-LICENSE.txt").write_bytes(python_license_bytes) +python_bin = bridge / "python" / "bin" +site_packages = bridge / "python" / "lib" / "python3.12" / "site-packages" +python_bin.mkdir(parents=True) +(python_bin / "python3.12").write_bytes(python_header) +(python_bin / "python3.12").chmod(0o755) +(python_bin / "python3").symlink_to("python3.12") +for package in ("ApplicationServices", "Cocoa", "CoreText", "Quartz", "objc"): + package_dir = site_packages / package + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "__init__.py").write_text("", encoding="utf-8") +(bridge / "python" / "lib" / "python3.12" / "LICENSE.txt").write_bytes(python_license_bytes) +encodings_dir = bridge / "python" / "lib" / "python3.12" / "encodings" +encodings_dir.mkdir(parents=True, exist_ok=True) +(encodings_dir / "__init__.py").write_text("# encodings fixture\n", encoding="utf-8") +native_paths = [ + site_packages / "objc" / "_objc.cpython-312-darwin.so", + site_packages / "Foundation" / "_Foundation.cpython-312-darwin.so", + site_packages / "Quartz" / "CoreGraphics" / "_coregraphics.cpython-312-darwin.so", + site_packages / "HIServices" / "_HIServices.cpython-312-darwin.so", + site_packages / "CoreText" / "_manual.cpython-312-darwin.so", +] +for native_path in native_paths: + native_path.parent.mkdir(parents=True, exist_ok=True) + native_path.write_bytes(python_header) + native_path.chmod(0o755) +(site_packages / "runtime-only.py").write_text("runtime closure\n", encoding="utf-8") +runtime_root = bridge / "python" +inventory_entries = [] +for runtime_path in sorted(runtime_root.rglob("*")): + relative_path = runtime_path.relative_to(runtime_root).as_posix() + metadata = runtime_path.lstat() + if runtime_path.is_symlink(): + inventory_entries.append({ + "path": relative_path, + "type": "symlink", + "mode": 0o777, + "target": runtime_path.readlink().as_posix(), + }) + elif runtime_path.is_dir(): + inventory_entries.append({ + "path": relative_path, + "type": "directory", + "mode": stat.S_IMODE(metadata.st_mode), + }) + elif runtime_path.is_file(): + contents = runtime_path.read_bytes() + inventory_entries.append({ + "path": relative_path, + "type": "file", + "mode": stat.S_IMODE(metadata.st_mode), + "size": len(contents), + "sha256": hashlib.sha256(contents).hexdigest(), + **({"signedMachO": True} if contents[:4].hex() in {"feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "cafebabf", "bebafeca", "bfbafeca"} else {}), + }) +inventory = {"schema": "evaos-python-runtime-inventory/v1", "entries": inventory_entries} +inventory_bytes = (json.dumps(inventory, indent=2) + "\n").encode() +(bridge / "python-runtime-inventory.json").write_bytes(inventory_bytes) +python_packages = [ + {"name":"pyobjc-core","version":"12.2.1","sha256":"a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b"}, + {"name":"pyobjc-framework-Cocoa","version":"12.2.1","sha256":"28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080"}, + {"name":"pyobjc-framework-Quartz","version":"12.2.1","sha256":"de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9"}, + {"name":"pyobjc-framework-ApplicationServices","version":"12.2.1","sha256":"f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b"}, + {"name":"pyobjc-framework-CoreText","version":"12.2.1","sha256":"ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6"}, +] manifest = { "placeholder": False, "source": "mock-release-asset", @@ -66,13 +143,27 @@ manifest = { "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": hashlib.sha256(license_bytes).hexdigest(), - } + }, + "python": { + "version": "3.12.13", + "architecture": architecture, + "sourceSha256": runtime_sha256, + "sourceUrl": f"https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-{runtime_arch}-apple-darwin-install_only.tar.gz", + "packages": python_packages, + "license": "Python-2.0", + "licensePath": "licenses/CPython-LICENSE.txt", + "licenseSha256": hashlib.sha256(python_license_bytes).hexdigest(), + "inventoryPath": "python-runtime-inventory.json", + "inventorySha256": hashlib.sha256(inventory_bytes).hexdigest(), + "inventoryEntryCount": len(inventory_entries), + }, }, } (bridge / "manifest.json").write_text(json.dumps(manifest) + "\n", encoding="utf-8") PY python3 - "$tmp_dir" "$output_path" <<'PY' import pathlib +import stat import sys import zipfile @@ -80,7 +171,15 @@ root = pathlib.Path(sys.argv[1]) output = pathlib.Path(sys.argv[2]) with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(root.rglob("*")): - archive.write(path, path.relative_to(root)) + relative = path.relative_to(root) + if path.is_symlink(): + info = zipfile.ZipInfo(str(relative)) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, path.readlink().as_posix()) + else: + archive.write(path, relative) PY rm -rf "$tmp_dir" } diff --git a/scripts/dmgRetryEligibility.js b/scripts/dmgRetryEligibility.js new file mode 100644 index 0000000000..adefc31e39 --- /dev/null +++ b/scripts/dmgRetryEligibility.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const path = require('path'); + +const AFTER_PACK_MARKER = '.evaos-after-pack-complete'; +const AFTER_SIGN_MARKER = '.evaos-after-sign-complete'; + +function markerPath(appOutDir, marker) { + return path.join(appOutDir, marker); +} + +function clearCompletedAfterPack(appOutDir) { + fs.rmSync(markerPath(appOutDir, AFTER_PACK_MARKER), { force: true }); +} + +function markCompletedAfterPack(appOutDir) { + fs.writeFileSync(markerPath(appOutDir, AFTER_PACK_MARKER), 'verified\n', { encoding: 'utf8', mode: 0o600 }); +} + +function hasCompletedAfterPack(appOutDir) { + return fs.existsSync(markerPath(appOutDir, AFTER_PACK_MARKER)); +} + +function clearCompletedAfterSign(appOutDir) { + fs.rmSync(markerPath(appOutDir, AFTER_SIGN_MARKER), { force: true }); +} + +function markCompletedAfterSign(appOutDir) { + fs.writeFileSync(markerPath(appOutDir, AFTER_SIGN_MARKER), 'verified\n', { encoding: 'utf8', mode: 0o600 }); +} + +function hasCompletedAfterSign(appOutDir) { + return fs.existsSync(markerPath(appOutDir, AFTER_SIGN_MARKER)); +} + +function clearDmgRetryCompletionMarkers(appOutDir) { + clearCompletedAfterPack(appOutDir); + clearCompletedAfterSign(appOutDir); +} + +function clearDmgRetryCompletionMarkersInDirectory(outDir) { + if (!fs.existsSync(outDir)) return; + + for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) { + if (entry.isDirectory() && /^mac(?:-|$)/.test(entry.name)) { + clearDmgRetryCompletionMarkers(path.join(outDir, entry.name)); + } + } +} + +function isDmgRetryEligible(appOutDir, { multiArch = false } = {}) { + if (multiArch) return false; + return hasCompletedAfterPack(appOutDir) && hasCompletedAfterSign(appOutDir); +} + +async function withAfterSignCompletion(appOutDir, operation) { + clearCompletedAfterSign(appOutDir); + const result = await operation(); + markCompletedAfterSign(appOutDir); + return result; +} + +module.exports = { + AFTER_PACK_MARKER, + AFTER_SIGN_MARKER, + clearDmgRetryCompletionMarkers, + clearDmgRetryCompletionMarkersInDirectory, + clearCompletedAfterPack, + clearCompletedAfterSign, + hasCompletedAfterPack, + hasCompletedAfterSign, + isDmgRetryEligible, + markCompletedAfterPack, + markCompletedAfterSign, + withAfterSignCompletion, +}; diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 57c54ea730..0f0dc789d1 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -90,6 +90,29 @@ const LOCAL_SIGNED_DMG_FALLBACK_ACK = 'evaos-local-signed-dmg'; const PEEKABOO_PACKAGE_VERSION = '3.8.0'; const PEEKABOO_SOURCE_SHA256 = '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383'; const PEEKABOO_LICENSE_PATH = 'licenses/Peekaboo-LICENSE.txt'; +const PYTHON_RUNTIME_VERSION = '3.12.13'; +const PYTHON_RUNTIME_SOURCE_SHA256 = { + arm64: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17', + x64: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894', +}; +const PYTHON_RUNTIME_SOURCE_URL = { + arm64: + 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-aarch64-apple-darwin-install_only.tar.gz', + x64: 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-x86_64-apple-darwin-install_only.tar.gz', +}; +const PYTHON_RUNTIME_LICENSE_PATH = 'licenses/CPython-LICENSE.txt'; +const PYTHON_RUNTIME_LICENSE_SHA256 = '3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf'; +const PYTHON_RUNTIME_PACKAGES = [ + ['pyobjc-core', '12.2.1', 'a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b'], + ['pyobjc-framework-Cocoa', '12.2.1', '28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080'], + ['pyobjc-framework-Quartz', '12.2.1', 'de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9'], + [ + 'pyobjc-framework-ApplicationServices', + '12.2.1', + 'f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b', + ], + ['pyobjc-framework-CoreText', '12.2.1', 'ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6'], +].map(([name, version, sha256]) => ({ name, version, sha256 })); const REQUIRED_RC_PROOF_CHECKS = [ { id: 'macos-arm64-dmg-codesign', @@ -437,10 +460,14 @@ function getWorkflowJobRunner(workflow, jobName) { function collectFunctionalSmokeConfigIssues(workflow) { const jobs = getTopLevelYamlSection(workflow, 'jobs'); - if (getWorkflowJobRunner(jobs, 'macos-arm64-app') === 'macos-15') { - return []; + const issues = []; + if (getWorkflowJobRunner(jobs, 'macos-arm64-app') !== 'macos-15') { + issues.push('.github/workflows/workbench-functional-smoke.yml: macos-arm64-app must run on macos-15'); + } + if (!String(workflow || '').includes('[[ ! "$WORKBENCH_SMOKE_BRIDGE_REF" =~ ^[0-9a-fA-F]{40}$ ]]')) { + issues.push('.github/workflows/workbench-functional-smoke.yml: bridge ref must be a full immutable commit SHA'); } - return ['.github/workflows/workbench-functional-smoke.yml: macos-arm64-app must run on macos-15']; + return issues; } function collectReleaseConfigIssues(rootDir = process.cwd()) { @@ -458,6 +485,7 @@ function collectReleaseConfigIssues(rootDir = process.cwd()) { const localSignedDmgManifest = readText(rootDir, '.github/workflows/evaos-beta-local-signed-dmg-manifest.yml'); const reusableBuild = readText(rootDir, '.github/workflows/_build-reusable.yml'); const functionalSmoke = readText(rootDir, '.github/workflows/workbench-functional-smoke.yml'); + const pythonRuntimePrep = readText(rootDir, 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh'); const afterSign = readText(rootDir, 'scripts/afterSign.js'); const dmgFinalizer = readText(rootDir, 'scripts/evaosFinalizeMacDmg.js'); const prepareAssets = readText(rootDir, 'scripts/prepare-release-assets.sh'); @@ -1016,6 +1044,83 @@ function collectReleaseConfigIssues(rootDir = process.cwd()) { issues, 'Peekaboo source digest exported for packaging verification' ); + requireText( + reusableBuild, + "PYTHON_RUNTIME_VERSION: '3.12.13'", + '.github/workflows/_build-reusable.yml', + issues, + 'self-contained desktop bridge Python runtime version pin' + ); + requireText( + pythonRuntimePrep, + 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=$runtime_dir', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'verified desktop bridge Python runtime exported for packaging' + ); + requireText( + pythonRuntimePrep, + 'EVAOS_REQUIRED_PYTHON_RUNTIME_SHA256=$runtime_sha256', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'desktop bridge Python runtime source digest exported for manifest provenance' + ); + requireText( + pythonRuntimePrep, + 'PYTHON_RUNTIME_LICENSE_SHA256:=3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'downloaded CPython license must match the pinned release bytes before packaging' + ); + requireText( + pythonRuntimePrep, + 'import ApplicationServices, Cocoa, CoreText, Quartz', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'bundled runtime must prove direct Accessibility dependencies without host packages' + ); + requireText( + pythonRuntimePrep, + '--no-index', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'bundled PyObjC install must be offline from verified wheels' + ); + requireText( + pythonRuntimePrep, + '-I -m pip check', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'bundled PyObjC dependencies must form a complete installed closure' + ); + requireText( + pythonRuntimePrep, + 'distributions(path=[sys.argv[1]])', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'bundled PyObjC closure must be scoped to the packaged site-packages directory' + ); + requireText( + pythonRuntimePrep, + 'EVAOS_REQUIRED_PYTHON_RUNTIME_PACKAGES_JSON=$packages_json', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'bundled PyObjC package hashes must be recorded in the bridge manifest' + ); + requireText( + pythonRuntimePrep, + 'a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b', + 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh', + issues, + 'pinned PyObjC core wheel digest' + ); + requireText( + functionalSmoke, + 'prepareEvaosDesktopBridgePythonRuntime.sh arm64', + '.github/workflows/workbench-functional-smoke.yml', + issues, + 'functional smoke must build the same pinned self-contained Python runtime' + ); requireText( functionalSmoke, 'BUNDLED_PEEKABOO_LICENSE_SHA256', @@ -1527,31 +1632,118 @@ function inspectMacosZipBridgePayload(zipPath) { 'import hashlib', 'import json', 'import pathlib', + 'import posixpath', + 'import stat', 'import sys', 'import zipfile', 'path = pathlib.Path(sys.argv[1])', + 'expected_python_arch = "arm64" if "arm64" in path.name else "x64"', `expected_source_sha256 = "${PEEKABOO_SOURCE_SHA256}"`, `expected_version = "${PEEKABOO_PACKAGE_VERSION}"`, `expected_license_path = "${PEEKABOO_LICENSE_PATH}"`, - 'macho_magics = {"feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "cafebabf"}', - 'def bridge_entry_suffix(name):', + `expected_python_version = "${PYTHON_RUNTIME_VERSION}"`, + `expected_python_source_sha256 = ${JSON.stringify(PYTHON_RUNTIME_SOURCE_SHA256)}`, + `expected_python_source_url = ${JSON.stringify(PYTHON_RUNTIME_SOURCE_URL)}`, + `expected_python_license_path = "${PYTHON_RUNTIME_LICENSE_PATH}"`, + `expected_python_license_sha256 = "${PYTHON_RUNTIME_LICENSE_SHA256}"`, + `expected_python_packages = ${JSON.stringify(PYTHON_RUNTIME_PACKAGES)}`, + 'macho_magics = {"feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "cafebabf", "bebafeca", "bfbafeca"}', + 'expected_cpu_types = {"arm64": 0x0100000c, "x64": 0x01000007}', + 'def thin_macho_cpu(data):', + ' if len(data) < 8:', + ' return None', + ' magic = data[:4].hex()', + ' if magic in {"cffaedfe", "cefaedfe"}:', + ' return int.from_bytes(data[4:8], "little")', + ' if magic in {"feedfacf", "feedface"}:', + ' return int.from_bytes(data[4:8], "big")', + ' return None', + 'def macho_has_arch(data, expected_arch):', + ' expected_cpu = expected_cpu_types[expected_arch]', + ' thin_cpu = thin_macho_cpu(data)', + ' if thin_cpu is not None:', + ' return thin_cpu == expected_cpu', + ' if len(data) < 8:', + ' return False', + ' magic = data[:4].hex()', + ' fat_shapes = {"cafebabe": ("big", 20, False), "bebafeca": ("little", 20, False), "cafebabf": ("big", 32, True), "bfbafeca": ("little", 32, True)}', + ' if magic not in fat_shapes:', + ' return False', + ' byteorder, record_size, is_fat64 = fat_shapes[magic]', + ' count = int.from_bytes(data[4:8], byteorder)', + ' if count <= 0 or count > 64 or 8 + count * record_size > len(data):', + ' return False', + ' for index in range(count):', + ' record = 8 + index * record_size', + ' cpu_type = int.from_bytes(data[record:record + 4], byteorder)', + ' if is_fat64:', + ' offset = int.from_bytes(data[record + 8:record + 16], byteorder)', + ' size = int.from_bytes(data[record + 16:record + 24], byteorder)', + ' else:', + ' offset = int.from_bytes(data[record + 8:record + 12], byteorder)', + ' size = int.from_bytes(data[record + 12:record + 16], byteorder)', + ' if cpu_type == expected_cpu and size >= 8 and offset + size <= len(data):', + ' if thin_macho_cpu(data[offset:offset + size]) == expected_cpu:', + ' return True', + ' return False', + 'def safe_zip_name(name):', + ' normalized = name[:-1] if name.endswith("/") else name', + ' parts = normalized.split("/")', + ' return bool(normalized) and not normalized.startswith("/") and "\\\\" not in normalized and all(part not in {"", ".", ".."} for part in parts)', + 'def app_root_for_name(name):', ' parts = name.split("/")', - ' for index, part in enumerate(parts):', - ' if part.endswith(".app") and parts[index + 1:index + 4] == ["Contents", "Resources", "Bridge"]:', - ' return "/".join(parts[index + 4:])', + ' for index, part in enumerate(parts[:-1]):', + ' if part.endswith(".app") and parts[index + 1] == "Contents":', + ' return "/".join(parts[:index + 1])', ' return None', - 'result = {"hasBridgeExecutable": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', - 'entries = {}', + 'def zip_mode(info):', + ' return ((info.external_attr >> 16) & 0xffff) if info.create_system == 3 else 0', + 'def regular_executable(info):', + ' mode = zip_mode(info)', + ' return stat.S_ISREG(mode) and bool(stat.S_IMODE(mode) & 0o111)', + 'def sha256_info(archive, info):', + ' digest = hashlib.sha256()', + ' with archive.open(info) as stream:', + ' while True:', + ' chunk = stream.read(1024 * 1024)', + ' if not chunk:', + ' break', + ' digest.update(chunk)', + ' return digest.hexdigest()', + 'def safe_symlink_target(relative_path, target):', + ' if not target or posixpath.isabs(target) or "\\\\" in target:', + ' return False', + ' resolved = posixpath.normpath(posixpath.join(posixpath.dirname(relative_path), target))', + ' return resolved != ".." and not resolved.startswith("../")', + 'result = {"zipLayoutValid": False, "singleAppRoot": False, "hasBridgeExecutable": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "executableModesValid": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False, "hasPythonRuntime": False, "hasPythonLauncher": False, "pythonLauncherValid": False, "pythonRuntimeMachO": False, "pythonRuntimeArchValid": False, "hasPythonLicense": False, "pythonManifestValid": False, "pythonLicenseDigestValid": False, "hasPythonControlModules": False, "pythonObjcArchValid": False, "pythonInventoryValid": False, "hasPythonStdlibSentinel": False, "hasPythonNativeSentinels": False, "pythonNativeSentinelsExecutable": False}', 'with zipfile.ZipFile(path) as archive:', - ' for name in archive.namelist():', - ' suffix = bridge_entry_suffix(name)', - ' if suffix in {"evaos-desktop-bridge", "manifest.json", "bin/peekaboo", "bin/evaos-connector-helper", expected_license_path}:', - ' entries[suffix] = name', + ' infos = archive.infolist()', + ' names = [info.filename for info in infos]', + ' safe_layout = len(infos) <= 200000 and sum(info.file_size for info in infos) <= 4 * 1024 * 1024 * 1024 and len(names) == len(set(names)) and all(safe_zip_name(name.rstrip("/")) for name in names)', + ' app_roots = {root for name in names if (root := app_root_for_name(name))}', + ' result["zipLayoutValid"] = safe_layout', + ' result["singleAppRoot"] = safe_layout and len(app_roots) == 1', + ' entries = {}', + ' if result["singleAppRoot"]:', + ' app_root = next(iter(app_roots))', + ' bridge_prefix = f"{app_root}/Contents/Resources/Bridge/"', + ' for info in infos:', + ' if info.filename.startswith(bridge_prefix) and not info.is_dir():', + ' suffix = info.filename[len(bridge_prefix):]', + ' if suffix:', + ' entries[suffix] = info', ' result["hasBridgeExecutable"] = "evaos-desktop-bridge" in entries', ' result["hasBridgeManifest"] = "manifest.json" in entries', ' result["hasPeekaboo"] = "bin/peekaboo" in entries', ' result["hasConnectorHelper"] = "bin/evaos-connector-helper" in entries', ' result["hasPeekabooLicense"] = expected_license_path in entries', + ' result["hasPythonRuntime"] = "python/bin/python3.12" in entries', + ' result["hasPythonLauncher"] = "python/bin/python3" in entries', + ' result["hasPythonLicense"] = expected_python_license_path in entries', + ' executable_paths = ["evaos-desktop-bridge", "bin/peekaboo", "bin/evaos-connector-helper", "python/bin/python3.12"]', + ' result["executableModesValid"] = all(name in entries and regular_executable(entries[name]) for name in executable_paths)', + ' control_module_paths = {"python/lib/python3.12/site-packages/ApplicationServices/__init__.py", "python/lib/python3.12/site-packages/Cocoa/__init__.py", "python/lib/python3.12/site-packages/CoreText/__init__.py", "python/lib/python3.12/site-packages/Quartz/__init__.py", "python/lib/python3.12/site-packages/objc/__init__.py"}', + ' result["hasPythonControlModules"] = control_module_paths.issubset(entries)', ' manifest = {}', ' if result["hasBridgeManifest"]:', ' try:', @@ -1559,21 +1751,115 @@ function inspectMacosZipBridgePayload(zipPath) { ' except (json.JSONDecodeError, UnicodeDecodeError):', ' manifest = {}', ' peekaboo = manifest.get("bundledTools", {}).get("peekaboo", {}) if isinstance(manifest, dict) else {}', + ' python_runtime = manifest.get("bundledTools", {}).get("python", {}) if isinstance(manifest, dict) else {}', ' result["manifestPlaceholderFalse"] = manifest.get("placeholder") is False if isinstance(manifest, dict) else False', ' result["manifestSourceDigestValid"] = peekaboo.get("version") == expected_version and peekaboo.get("sourceSha256") == expected_source_sha256', ' result["manifestLicenseMetadataValid"] = peekaboo.get("license") == "MIT" and peekaboo.get("licensePath") == expected_license_path', + ' result["pythonManifestValid"] = python_runtime.get("version") == expected_python_version and python_runtime.get("architecture") == expected_python_arch and python_runtime.get("sourceSha256") == expected_python_source_sha256[expected_python_arch] and python_runtime.get("sourceUrl") == expected_python_source_url[expected_python_arch] and python_runtime.get("license") == "Python-2.0" and python_runtime.get("licensePath") == expected_python_license_path and python_runtime.get("licenseSha256") == expected_python_license_sha256 and python_runtime.get("packages") == expected_python_packages', ' if result["hasPeekaboo"]:', - ' result["peekabooMachO"] = archive.read(entries["bin/peekaboo"], pwd=None)[:4].hex() in macho_magics', + ' result["peekabooMachO"] = archive.read(entries["bin/peekaboo"])[:4].hex() in macho_magics', ' if result["hasConnectorHelper"]:', - ' result["connectorHelperMachO"] = archive.read(entries["bin/evaos-connector-helper"], pwd=None)[:4].hex() in macho_magics', + ' result["connectorHelperMachO"] = archive.read(entries["bin/evaos-connector-helper"])[:4].hex() in macho_magics', + ' if result["hasPythonRuntime"]:', + ' python_bytes = archive.read(entries["python/bin/python3.12"])', + ' result["pythonRuntimeMachO"] = python_bytes[:4].hex() in macho_magics', + ' result["pythonRuntimeArchValid"] = macho_has_arch(python_bytes, expected_python_arch)', + ' if result["hasPythonLauncher"]:', + ' launcher_info = entries["python/bin/python3"]', + ' launcher_bytes = archive.read(launcher_info)', + ' launcher_mode = zip_mode(launcher_info)', + ' result["pythonLauncherValid"] = stat.S_ISLNK(launcher_mode) and launcher_bytes == b"python3.12"', + ' native_paths = {"python/lib/python3.12/site-packages/objc/_objc.cpython-312-darwin.so", "python/lib/python3.12/site-packages/Foundation/_Foundation.cpython-312-darwin.so", "python/lib/python3.12/site-packages/Quartz/CoreGraphics/_coregraphics.cpython-312-darwin.so", "python/lib/python3.12/site-packages/HIServices/_HIServices.cpython-312-darwin.so", "python/lib/python3.12/site-packages/CoreText/_manual.cpython-312-darwin.so"}', + ' result["hasPythonNativeSentinels"] = native_paths.issubset(entries)', + ' if result["hasPythonNativeSentinels"]:', + ' result["pythonNativeSentinelsExecutable"] = all(regular_executable(entries[name]) for name in native_paths)', + ' result["pythonObjcArchValid"] = all(macho_has_arch(archive.read(entries[name]), expected_python_arch) for name in native_paths)', + ' result["hasPythonStdlibSentinel"] = "python/lib/python3.12/encodings/__init__.py" in entries', + ' inventory_info = entries.get("python-runtime-inventory.json")', + ' if inventory_info is not None and isinstance(python_runtime, dict):', + ' inventory_bytes = archive.read(inventory_info)', + ' try:', + ' inventory = json.loads(inventory_bytes)', + ' except (json.JSONDecodeError, UnicodeDecodeError):', + ' inventory = {}', + ' declared_entries = inventory.get("entries", []) if isinstance(inventory, dict) else []', + ' metadata_valid = inventory.get("schema") == "evaos-python-runtime-inventory/v1" and python_runtime.get("inventoryPath") == "python-runtime-inventory.json" and python_runtime.get("inventorySha256") == hashlib.sha256(inventory_bytes).hexdigest() and python_runtime.get("inventoryEntryCount") == len(declared_entries)', + ' declared_paths = [entry.get("path") for entry in declared_entries if isinstance(entry, dict)]', + ' actual_python = {}', + ' python_prefix = f"{bridge_prefix}python/"', + ' python_root_valid = False', + ' normalized_python_collision = False', + ' for info in infos:', + ' if info.filename == python_prefix:', + ' root_mode = zip_mode(info)', + ' python_root_valid = info.is_dir() and stat.S_ISDIR(root_mode) and (stat.S_IMODE(root_mode) & 0o500) == 0o500', + ' continue', + ' if info.filename == python_prefix.rstrip("/"):', + ' normalized_python_collision = True', + ' continue', + ' if not info.filename.startswith(python_prefix):', + ' continue', + ' relative_path = info.filename[len(python_prefix):].rstrip("/") if info.is_dir() else info.filename[len(python_prefix):]', + ' if relative_path:', + ' if relative_path in actual_python:', + ' normalized_python_collision = True', + ' actual_python[relative_path] = info', + ' inventory_valid = metadata_valid and python_root_valid and not normalized_python_collision and len(declared_paths) == len(declared_entries) and declared_paths == sorted(declared_paths) and len(declared_paths) == len(set(declared_paths)) and set(declared_paths) == set(actual_python)', + ' if inventory_valid:', + ' for declared in declared_entries:', + ' relative_path = declared["path"]', + ' if not safe_zip_name(relative_path):', + ' inventory_valid = False', + ' break', + ' info = actual_python[relative_path]', + ' mode = zip_mode(info)', + ' declared_type = declared.get("type")', + ' if declared.get("mode") != stat.S_IMODE(mode):', + ' inventory_valid = False', + ' break', + ' if declared_type == "directory":', + ' if not info.is_dir() or not stat.S_ISDIR(mode) or (stat.S_IMODE(mode) & 0o500) != 0o500:', + ' inventory_valid = False', + ' break', + ' elif declared_type == "file":', + ' declared_sha256 = declared.get("sha256")', + ' declared_size = declared.get("size")', + ' if not stat.S_ISREG(mode) or not isinstance(declared_size, int) or declared_size < 0 or not isinstance(declared_sha256, str) or len(declared_sha256) != 64:', + ' inventory_valid = False', + ' break', + ' # Developer ID signing changes Mach-O bytes after the exact pre-sign afterPack inventory check.', + ' # At distribution time, bind these entries by path/type/mode plus the expected architecture slice.', + ' if declared.get("signedMachO") is True:', + ' signed_bytes = archive.read(info)', + ' if signed_bytes[:4].hex() not in macho_magics or not macho_has_arch(signed_bytes, expected_python_arch):', + ' inventory_valid = False', + ' break', + ' elif declared.get("signedMachO") is not None or declared_size != info.file_size or declared_sha256 != sha256_info(archive, info):', + ' inventory_valid = False', + ' break', + ' elif declared_type == "symlink":', + ' try:', + ' target = archive.read(info).decode("utf-8")', + ' except UnicodeDecodeError:', + ' target = ""', + ' if not stat.S_ISLNK(mode) or declared.get("target") != target or not safe_symlink_target(relative_path, target):', + ' inventory_valid = False', + ' break', + ' else:', + ' inventory_valid = False', + ' break', + ' result["pythonInventoryValid"] = inventory_valid', ' if result["hasPeekabooLicense"]:', - ' license_bytes = archive.read(entries[expected_license_path], pwd=None)', + ' license_bytes = archive.read(entries[expected_license_path])', ' result["licenseDigestValid"] = hashlib.sha256(license_bytes).hexdigest() == peekaboo.get("licenseSha256")', ' try:', ' license_text = license_bytes.decode("utf-8")', ' except UnicodeDecodeError:', ' license_text = ""', ' result["licenseNoticeValid"] = license_text.startswith("MIT License") and "Permission is hereby granted" in license_text', + ' if result["hasPythonLicense"]:', + ' python_license_bytes = archive.read(entries[expected_python_license_path])', + ' result["pythonLicenseDigestValid"] = hashlib.sha256(python_license_bytes).hexdigest() == expected_python_license_sha256', 'print(json.dumps(result))', ].join('\n'); try { @@ -1607,11 +1893,14 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { for (const zipName of zipNames) { const probe = inspectMacosZipBridgePayload(path.join(outputDir, zipName)); + assertZipBridgeProbe(probe, 'zipLayoutValid', zipName, 'safe ZIP layout'); + assertZipBridgeProbe(probe, 'singleAppRoot', zipName, 'exactly one .app root'); assertZipBridgeProbe(probe, 'hasBridgeExecutable', zipName, 'executable'); assertZipBridgeProbe(probe, 'hasBridgeManifest', zipName, 'manifest'); assertZipBridgeProbe(probe, 'hasPeekaboo', zipName, 'Peekaboo binary'); assertZipBridgeProbe(probe, 'peekabooMachO', zipName, 'Peekaboo binary Mach-O shape'); assertZipBridgeProbe(probe, 'hasConnectorHelper', zipName, 'connector helper'); + assertZipBridgeProbe(probe, 'executableModesValid', zipName, 'executable ZIP mode'); assertZipBridgeProbe(probe, 'connectorHelperMachO', zipName, 'connector helper Mach-O shape'); assertZipBridgeProbe(probe, 'hasPeekabooLicense', zipName, 'Peekaboo license'); assertZipBridgeProbe(probe, 'manifestPlaceholderFalse', zipName, 'non-placeholder manifest'); @@ -1619,6 +1908,20 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { assertZipBridgeProbe(probe, 'manifestLicenseMetadataValid', zipName, 'Peekaboo license metadata'); assertZipBridgeProbe(probe, 'licenseDigestValid', zipName, 'Peekaboo license digest'); assertZipBridgeProbe(probe, 'licenseNoticeValid', zipName, 'Peekaboo license notice'); + assertZipBridgeProbe(probe, 'hasPythonRuntime', zipName, 'bundled Python runtime'); + assertZipBridgeProbe(probe, 'hasPythonLauncher', zipName, 'bundled Python launcher'); + assertZipBridgeProbe(probe, 'pythonLauncherValid', zipName, 'relocatable bundled Python launcher'); + assertZipBridgeProbe(probe, 'pythonRuntimeMachO', zipName, 'bundled Python runtime Mach-O shape'); + assertZipBridgeProbe(probe, 'pythonRuntimeArchValid', zipName, 'bundled Python runtime architecture'); + assertZipBridgeProbe(probe, 'hasPythonLicense', zipName, 'CPython license'); + assertZipBridgeProbe(probe, 'pythonManifestValid', zipName, 'bundled Python runtime provenance'); + assertZipBridgeProbe(probe, 'pythonLicenseDigestValid', zipName, 'CPython license digest'); + assertZipBridgeProbe(probe, 'hasPythonControlModules', zipName, 'bundled PyObjC control modules'); + assertZipBridgeProbe(probe, 'hasPythonStdlibSentinel', zipName, 'Python stdlib sentinel'); + assertZipBridgeProbe(probe, 'hasPythonNativeSentinels', zipName, 'PyObjC native sentinel'); + assertZipBridgeProbe(probe, 'pythonNativeSentinelsExecutable', zipName, 'PyObjC native sentinel executable mode'); + assertZipBridgeProbe(probe, 'pythonObjcArchValid', zipName, 'bundled PyObjC native runtime architecture'); + assertZipBridgeProbe(probe, 'pythonInventoryValid', zipName, 'Python runtime inventory'); } } diff --git a/scripts/prepareEvaosDesktopBridgePythonRuntime.sh b/scripts/prepareEvaosDesktopBridgePythonRuntime.sh new file mode 100755 index 0000000000..541dc23d8d --- /dev/null +++ b/scripts/prepareEvaosDesktopBridgePythonRuntime.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_arch="${1:?target architecture is required (arm64 or x64)}" +env_output="${2:-${GITHUB_ENV:-}}" +if [ -z "$env_output" ]; then + echo "No environment output file was provided." >&2 + exit 2 +fi + +: "${PYTHON_RUNTIME_VERSION:=3.12.13}" +: "${PYTHON_RUNTIME_RELEASE:=20260510}" +: "${PYTHON_RUNTIME_ARM64_SHA256:=5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17}" +: "${PYTHON_RUNTIME_X64_SHA256:=cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894}" +: "${PYTHON_RUNTIME_LICENSE_SHA256:=3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf}" +: "${RUNNER_TEMP:=${TMPDIR:-/tmp}}" + +case "$target_arch" in + arm64) + runtime_arch="aarch64" + expected_lipo_arch="arm64" + runtime_sha256="$PYTHON_RUNTIME_ARM64_SHA256" + ;; + x64) + runtime_arch="x86_64" + expected_lipo_arch="x86_64" + runtime_sha256="$PYTHON_RUNTIME_X64_SHA256" + ;; + *) + echo "No pinned desktop bridge Python runtime for architecture $target_arch." >&2 + exit 1 + ;; +esac + +runtime_asset="cpython-${PYTHON_RUNTIME_VERSION}+${PYTHON_RUNTIME_RELEASE}-${runtime_arch}-apple-darwin-install_only.tar.gz" +runtime_url="https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RUNTIME_RELEASE}/${runtime_asset}" +runtime_archive="${RUNNER_TEMP:?RUNNER_TEMP is required}/${runtime_asset}" +runtime_root="${RUNNER_TEMP}/evaos-python-${PYTHON_RUNTIME_VERSION}-${runtime_arch}" +wheelhouse="${RUNNER_TEMP}/evaos-python-wheelhouse-${PYTHON_RUNTIME_VERSION}" +python_minor="${PYTHON_RUNTIME_VERSION%.*}" + +curl --fail --location --retry 3 --output "$runtime_archive" "$runtime_url" +printf '%s %s\n' "$runtime_sha256" "$runtime_archive" | shasum -a 256 -c - +rm -rf "$runtime_root" "$wheelhouse" +mkdir -p "$runtime_root" "$wheelhouse" +tar -xzf "$runtime_archive" -C "$runtime_root" +runtime_dir="$runtime_root/python" +python_license_path="$runtime_dir/lib/python${python_minor}/LICENSE.txt" +test -x "$runtime_dir/bin/python3" +test -f "$python_license_path" +printf '%s %s\n' "$PYTHON_RUNTIME_LICENSE_SHA256" "$python_license_path" | shasum -a 256 -c - +test "$("$runtime_dir/bin/python3" --version)" = "Python ${PYTHON_RUNTIME_VERSION}" +test "$(lipo -archs "$runtime_dir/bin/python${python_minor}")" = "$expected_lipo_arch" + +pyobjc_wheels=( + 'pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl|a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b|https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl' + 'pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl|28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080|https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl' + 'pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl|de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9|https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl' + 'pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl|f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b|https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl' + 'pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl|ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6|https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl' +) + +for wheel_spec in "${pyobjc_wheels[@]}"; do + IFS='|' read -r wheel_name wheel_sha256 wheel_url <<< "$wheel_spec" + wheel_path="$wheelhouse/$wheel_name" + curl --fail --location --retry 3 --output "$wheel_path" "$wheel_url" + printf '%s %s\n' "$wheel_sha256" "$wheel_path" | shasum -a 256 -c - +done + +# This hash-pinned wheel set must remain the complete transitive PyObjC +# dependency closure. pip check, exact distribution equality, and the import +# smoke below fail closed if a future version adds another requirement. +"$runtime_dir/bin/python3" -m pip install \ + --disable-pip-version-check \ + --no-compile \ + --no-deps \ + --no-index \ + --target "$runtime_dir/lib/python${python_minor}/site-packages" \ + "$wheelhouse"/*.whl +"$runtime_dir/bin/python3" -I -m pip check +"$runtime_dir/bin/python3" -I - "$runtime_dir/lib/python${python_minor}/site-packages" <<'PY' +import re +import sys +from importlib.metadata import distributions + +expected_pyobjc = { + "pyobjc-core": "12.2.1", + "pyobjc-framework-applicationservices": "12.2.1", + "pyobjc-framework-cocoa": "12.2.1", + "pyobjc-framework-coretext": "12.2.1", + "pyobjc-framework-quartz": "12.2.1", +} +installed_pyobjc = { + re.sub(r"[-_.]+", "-", distribution.metadata["Name"]).lower(): distribution.version + for distribution in distributions(path=[sys.argv[1]]) + if re.sub(r"[-_.]+", "-", distribution.metadata["Name"]).lower().startswith("pyobjc-") +} +if installed_pyobjc != expected_pyobjc: + print( + f"Bundled PyObjC distribution closure mismatch: expected {expected_pyobjc}, got {installed_pyobjc}", + file=sys.stderr, + ) + raise SystemExit(1) +print("bundled-pyobjc-closure-ok") +PY +"$runtime_dir/bin/python3" -I -c 'import ApplicationServices, Cocoa, CoreText, Quartz; print("bundled-pyobjc-ok")' + +packages_json='[{"name":"pyobjc-core","version":"12.2.1","sha256":"a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b"},{"name":"pyobjc-framework-Cocoa","version":"12.2.1","sha256":"28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080"},{"name":"pyobjc-framework-Quartz","version":"12.2.1","sha256":"de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9"},{"name":"pyobjc-framework-ApplicationServices","version":"12.2.1","sha256":"f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b"},{"name":"pyobjc-framework-CoreText","version":"12.2.1","sha256":"ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6"}]' +{ + echo "EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=$runtime_dir" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_VERSION=$PYTHON_RUNTIME_VERSION" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_SHA256=$runtime_sha256" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_SOURCE_URL=$runtime_url" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_ARCH=$target_arch" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_LICENSE_SHA256=$PYTHON_RUNTIME_LICENSE_SHA256" + echo "EVAOS_REQUIRED_PYTHON_RUNTIME_PACKAGES_JSON=$packages_json" +} >> "$env_output" diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index 00437bf973..6820441334 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -6,15 +6,29 @@ const path = require('path'); const { execFileSync } = require('child_process'); const projectRoot = path.resolve(__dirname, '..'); -const bridgeResourceDir = path.join(projectRoot, 'resources', 'Bridge'); +const bridgeResourceDir = process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR + ? path.resolve(process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR) + : path.join(projectRoot, 'resources', 'Bridge'); const bridgeSourceCacheDir = path.join(projectRoot, '.cache', 'evaos-desktop-bridge-source'); const defaultBridgeSourceRepo = 'https://github.com/electricsheephq/evaos-desktop-bridge.git'; const defaultBridgeSourceRef = 'main'; const PLACEHOLDER_SOURCE = 'diagnostic-placeholder'; const PEEKABOO_LICENSE_RELATIVE_PATH = 'licenses/Peekaboo-LICENSE.txt'; +const PYTHON_LICENSE_RELATIVE_PATH = 'licenses/CPython-LICENSE.txt'; +const PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH = 'python-runtime-inventory.json'; +const PYTHON_RUNTIME_INVENTORY_SCHEMA = 'evaos-python-runtime-inventory/v1'; const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on', 'evaos-beta']); -const MACHO_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'cafebabf']); +const MACHO_MAGICS = new Set([ + 'feedface', + 'feedfacf', + 'cefaedfe', + 'cffaedfe', + 'cafebabe', + 'bebafeca', + 'cafebabf', + 'bfbafeca', +]); function truthy(value) { return TRUE_VALUES.has( @@ -172,10 +186,142 @@ function copyDirectory(source, target) { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.cpSync(source, target, { recursive: true, + verbatimSymlinks: true, filter: (sourcePath) => !sourcePath.includes(`${path.sep}__pycache__${path.sep}`), }); } +function pythonRuntimeInventoryEntries(runtimeDir) { + const resolvedRuntimeDir = path.resolve(runtimeDir); + const runtimeMetadata = fs.lstatSync(resolvedRuntimeDir); + const runtimeMode = runtimeMetadata.mode & 0o777; + if (!runtimeMetadata.isDirectory() || (runtimeMode & 0o500) !== 0o500) { + throw new Error('Bundled Python runtime directory must be owner-readable and owner-executable: .'); + } + const entries = []; + const pending = [resolvedRuntimeDir]; + + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const relativePath = path.relative(resolvedRuntimeDir, entryPath).split(path.sep).join('/'); + if (!relativePath || relativePath.startsWith('../') || path.posix.isAbsolute(relativePath)) { + throw new Error(`Bundled Python runtime inventory contains an unsafe path: ${entryPath}`); + } + + if (entry.isDirectory()) { + const metadata = fs.lstatSync(entryPath); + const mode = metadata.mode & 0o777; + if ((mode & 0o500) !== 0o500) { + throw new Error( + `Bundled Python runtime directory must be owner-readable and owner-executable: ${relativePath}` + ); + } + entries.push({ + path: relativePath, + type: 'directory', + mode, + }); + pending.push(entryPath); + continue; + } + + const metadata = fs.lstatSync(entryPath); + if (metadata.isSymbolicLink()) { + const target = fs.readlinkSync(entryPath); + const resolvedTarget = path.resolve(path.dirname(entryPath), target); + if ( + path.isAbsolute(target) || + (resolvedTarget !== resolvedRuntimeDir && !resolvedTarget.startsWith(`${resolvedRuntimeDir}${path.sep}`)) + ) { + throw new Error(`Bundled Python runtime inventory contains an unsafe symlink: ${relativePath} -> ${target}`); + } + entries.push({ path: relativePath, type: 'symlink', mode: 0o777, target }); + } else if (metadata.isFile()) { + const contents = fs.readFileSync(entryPath); + const inventoryEntry = { + path: relativePath, + type: 'file', + mode: metadata.mode & 0o777, + size: metadata.size, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }; + if (MACHO_MAGICS.has(contents.subarray(0, 4).toString('hex'))) { + // Developer ID signing mutates Mach-O signature bytes after afterPack. + // The pre-sign verifier still binds the exact digest; distribution + // verification binds the same path/type/mode and signed architecture. + inventoryEntry.signedMachO = true; + } + entries.push(inventoryEntry); + } else { + throw new Error(`Bundled Python runtime inventory contains an unsupported entry: ${relativePath}`); + } + } + } + + return entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); +} + +function writePythonRuntimeInventory(resourceDir = bridgeResourceDir) { + const runtimeDir = path.join(resourceDir, 'python'); + if (!fs.existsSync(runtimeDir)) { + throw new Error(`Bundled Python runtime inventory source is missing: ${runtimeDir}`); + } + const inventory = { + schema: PYTHON_RUNTIME_INVENTORY_SCHEMA, + entries: pythonRuntimeInventoryEntries(runtimeDir), + }; + const inventoryPath = path.join(resourceDir, PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH); + const inventoryBytes = `${JSON.stringify(inventory, null, 2)}\n`; + fs.writeFileSync(inventoryPath, inventoryBytes); + return { + inventoryPath: PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH, + inventorySha256: crypto.createHash('sha256').update(inventoryBytes).digest('hex'), + inventoryEntryCount: inventory.entries.length, + }; +} + +function verifyPythonRuntimeInventory(resourceDir, metadata) { + if ( + metadata?.inventoryPath !== PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH || + !/^[0-9a-f]{64}$/i.test(String(metadata?.inventorySha256 || '')) || + !Number.isSafeInteger(metadata?.inventoryEntryCount) || + metadata.inventoryEntryCount < 1 + ) { + throw new Error('Bundled Python runtime inventory metadata is missing or invalid.'); + } + + const inventoryPath = path.join(resourceDir, PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH); + if (!fs.existsSync(inventoryPath)) { + throw new Error(`Bundled Python runtime inventory is missing: ${inventoryPath}`); + } + const inventoryBytes = fs.readFileSync(inventoryPath); + if (crypto.createHash('sha256').update(inventoryBytes).digest('hex') !== metadata.inventorySha256) { + throw new Error('Bundled Python runtime inventory digest mismatch.'); + } + + let inventory; + try { + inventory = JSON.parse(inventoryBytes.toString('utf8')); + } catch (error) { + throw new Error(`Bundled Python runtime inventory is not valid JSON: ${error.message}`); + } + if ( + inventory?.schema !== PYTHON_RUNTIME_INVENTORY_SCHEMA || + !Array.isArray(inventory.entries) || + inventory.entries.length !== metadata.inventoryEntryCount + ) { + throw new Error('Bundled Python runtime inventory metadata mismatch.'); + } + + const actualEntries = pythonRuntimeInventoryEntries(path.join(resourceDir, 'python')); + if (JSON.stringify(inventory.entries) !== JSON.stringify(actualEntries)) { + throw new Error('Bundled Python runtime inventory content mismatch.'); + } + return true; +} + function copyOptionalBinary(name, targetDir) { const explicit = process.env[`EVAOS_${name.toUpperCase()}_BIN`]; const candidates = [explicit, findOnPath(name)].filter(Boolean); @@ -268,21 +414,10 @@ function bridgeWrapperScript() { set -eu BRIDGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +PYTHON_BIN="$BRIDGE_DIR/python/bin/python3" -if [ -n "\${EVAOS_DESKTOP_BRIDGE_PYTHON:-}" ] && [ -x "\${EVAOS_DESKTOP_BRIDGE_PYTHON:-}" ]; then - PYTHON_BIN="$EVAOS_DESKTOP_BRIDGE_PYTHON" -else - PYTHON_BIN="" - for candidate in /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/bin/python3; do - if [ -x "$candidate" ]; then - PYTHON_BIN="$candidate" - break - fi - done -fi - -if [ -z "$PYTHON_BIN" ]; then - echo "evaos-desktop-bridge: python3 was not found. Install Python 3 or contact Electric Sheep support." >&2 +if [ ! -x "$PYTHON_BIN" ]; then + echo "evaos-desktop-bridge: bundled Python runtime is missing. Reinstall evaOS Workbench or contact Electric Sheep support." >&2 exit 127 fi @@ -304,10 +439,97 @@ fi mkdir -p "$CACHE_ROOT/pycache" 2>/dev/null || true export PYTHONPYCACHEPREFIX="$CACHE_ROOT/pycache" -exec "$PYTHON_BIN" -S -m evaos_desktop_bridge.cli "$@" +exec "$PYTHON_BIN" -P -m evaos_desktop_bridge.cli "$@" `; } +function installPythonRuntime( + sourcePath = process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR, + resourceDir = bridgeResourceDir +) { + if (!sourcePath) { + if (shouldRequireRealBridge()) { + throw new Error('Release builds require a bundled Python runtime via EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR.'); + } + return undefined; + } + + const sourceDir = path.resolve(sourcePath); + const sourceExecutable = path.join(sourceDir, 'bin', 'python3'); + if (!fs.existsSync(sourceExecutable)) { + throw new Error(`Bundled Python runtime is missing bin/python3: ${sourceDir}`); + } + fs.accessSync(sourceExecutable, fs.constants.X_OK); + + const versionOutput = execFileSync(sourceExecutable, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const versionMatch = versionOutput.match(/^Python\s+(\d+\.\d+\.\d+)$/); + if (!versionMatch) { + throw new Error(`Bundled Python runtime reported an unexpected version: ${versionOutput}`); + } + const version = versionMatch[1]; + const requiredVersion = String(process.env.EVAOS_REQUIRED_PYTHON_RUNTIME_VERSION || '').trim(); + if (requiredVersion && version !== requiredVersion) { + throw new Error(`Bundled Python runtime ${version} does not match required version ${requiredVersion}.`); + } + + const licenseSource = path.join(sourceDir, 'lib', `python${version.split('.').slice(0, 2).join('.')}`, 'LICENSE.txt'); + if (!fs.existsSync(licenseSource)) { + throw new Error(`Bundled Python runtime is missing its CPython license: ${licenseSource}`); + } + + const targetDir = path.join(resourceDir, 'python'); + copyDirectory(sourceDir, targetDir); + const licenseTarget = path.join(resourceDir, ...PYTHON_LICENSE_RELATIVE_PATH.split('/')); + fs.mkdirSync(path.dirname(licenseTarget), { recursive: true }); + fs.copyFileSync(licenseSource, licenseTarget); + const inventoryMetadata = writePythonRuntimeInventory(resourceDir); + + const sourceSha256 = String(process.env.EVAOS_REQUIRED_PYTHON_RUNTIME_SHA256 || '').trim(); + const sourceUrl = String(process.env.EVAOS_REQUIRED_PYTHON_RUNTIME_SOURCE_URL || '').trim(); + const architecture = String(process.env.EVAOS_REQUIRED_PYTHON_RUNTIME_ARCH || '').trim(); + let packages = []; + const packagesJson = String(process.env.EVAOS_REQUIRED_PYTHON_RUNTIME_PACKAGES_JSON || '').trim(); + if (packagesJson) { + try { + packages = JSON.parse(packagesJson); + } catch (error) { + throw new Error(`Bundled Python runtime package provenance is not valid JSON: ${error.message}`); + } + } + if (shouldRequireRealBridge()) { + if (!/^[0-9a-f]{64}$/i.test(sourceSha256) || !sourceUrl || !['arm64', 'x64'].includes(architecture)) { + throw new Error('Release builds require pinned bundled Python runtime source and architecture provenance.'); + } + if ( + !Array.isArray(packages) || + packages.length < 5 || + packages.some( + (entry) => + !entry || + typeof entry.name !== 'string' || + typeof entry.version !== 'string' || + !/^[0-9a-f]{64}$/i.test(String(entry.sha256 || '')) + ) + ) { + throw new Error('Release builds require pinned bundled PyObjC package provenance.'); + } + } + return { + version, + sourceSha256, + sourceUrl, + architecture, + packages, + license: 'Python-2.0', + licensePath: PYTHON_LICENSE_RELATIVE_PATH, + licenseSha256: sha256File(licenseTarget), + ...inventoryMetadata, + }; +} + function writeWrapper() { const wrapperPath = path.join(bridgeResourceDir, 'evaos-desktop-bridge'); fs.writeFileSync(wrapperPath, bridgeWrapperScript()); @@ -487,6 +709,7 @@ function main() { fs.mkdirSync(bridgeBinDir, { recursive: true }); copyDirectory(bridgePackageSource, bridgePackageTarget); removePycache(bridgeResourceDir); + const pythonRuntime = installPythonRuntime(); const bridgeExecutable = writeBridgeExecutable(); const peekabooBinary = copyOptionalBinary('peekaboo', bridgeBinDir); const helperPath = path.join(bridgeBinDir, 'evaos-connector-helper'); @@ -507,7 +730,8 @@ function main() { requireMachOReleaseBinary(path.join(bridgeBinDir, 'peekaboo'), 'bundled Peekaboo helper'); requireMachOReleaseBinary(helperPath, 'bundled evaOS connector helper'); } - const bundledTools = peekabooBundleMetadata(peekabooBinary); + const bundledTools = peekabooBundleMetadata(peekabooBinary) || {}; + if (pythonRuntime) bundledTools.python = pythonRuntime; const manifest = bridgeManifest({ sourcePath: bridgeSourceDir, @@ -529,6 +753,7 @@ if (require.main === module) { module.exports = { bridgeManifest, bridgeWrapperScript, + installPythonRuntime, installPeekabooLicense, isMachOExecutable, peekabooBundleMetadata, @@ -536,4 +761,6 @@ module.exports = { resolveBridgeSourceDir, shouldCloneBridgeRefAsBranch, sourceCandidates, + verifyPythonRuntimeInventory, + writePythonRuntimeInventory, }; diff --git a/scripts/verify-release-assets.sh b/scripts/verify-release-assets.sh index c426f84721..f374de66e0 100644 --- a/scripts/verify-release-assets.sh +++ b/scripts/verify-release-assets.sh @@ -227,6 +227,29 @@ assert_macos_zip_bridge_payload() { else echo "PASS: $(basename "$zip_path") contains bundled evaOS desktop bridge manifest" fi + + if ! zip_contains_entry_matching "$zip_path" '(^|/)[^/]+\.app/Contents/Resources/Bridge/python/bin/python3\.12$'; then + echo "FAIL: $(basename "$zip_path") is missing the self-contained desktop bridge Python runtime" + ERRORS=$((ERRORS + 1)) + else + echo "PASS: $(basename "$zip_path") contains the self-contained desktop bridge Python runtime" + fi + + if ! zip_contains_entry_matching "$zip_path" '(^|/)[^/]+\.app/Contents/Resources/Bridge/python/bin/python3$'; then + echo "FAIL: $(basename "$zip_path") is missing the self-contained desktop bridge Python launcher" + ERRORS=$((ERRORS + 1)) + else + # This is intentionally a coarse archive-entry sanity check. The authoritative + # symlink-or-Mach-O launcher validation runs in evaosBetaReleaseGate.js. + echo "PASS: $(basename "$zip_path") contains the desktop bridge Python launcher entry; authoritative shape validation is performed by the release gate" + fi + + if ! zip_contains_entry_matching "$zip_path" '(^|/)[^/]+\.app/Contents/Resources/Bridge/licenses/CPython-LICENSE\.txt$'; then + echo "FAIL: $(basename "$zip_path") is missing the CPython license notice" + ERRORS=$((ERRORS + 1)) + else + echo "PASS: $(basename "$zip_path") contains the CPython license notice" + fi done } diff --git a/tests/fixtures/licenses/CPython-3.12.13-LICENSE.txt b/tests/fixtures/licenses/CPython-3.12.13-LICENSE.txt new file mode 100644 index 0000000000..f26bcf4d2d --- /dev/null +++ b/tests/fixtures/licenses/CPython-3.12.13-LICENSE.txt @@ -0,0 +1,279 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 3db7f88a72..6cca08701b 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -1,14 +1,22 @@ import { createRequire } from 'node:module'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; const require = createRequire(import.meta.url); +const cpythonLicense = readFileSync(join(__dirname, '../../fixtures/licenses/CPython-3.12.13-LICENSE.txt')); const afterPack = require('../../../scripts/afterPack.js') as { isMachOExecutable: (filePath: string) => boolean; verifyBundledResources: (resourcesDir: string, electronPlatformName: string, targetArch: string) => void; - verifyEvaosDesktopBridgeResource: (resourcesDir: string, electronPlatformName: string) => void; + verifyEvaosDesktopBridgeResource: (resourcesDir: string, electronPlatformName: string, targetArch?: string) => void; +}; +const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { + writePythonRuntimeInventory: (resourceDir: string) => { + inventoryPath: string; + inventorySha256: string; + inventoryEntryCount: number; + }; }; const tempDirs: string[] = []; @@ -38,13 +46,15 @@ function writeExecutableScript(path: string): void { } function writeMachOFixture(path: string): void { - writeFileSync(path, Buffer.from('cffaedfe00000000', 'hex')); + writeFileSync(path, Buffer.from('cffaedfe0c000001', 'hex')); chmodSync(path, 0o755); } function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; nativeHelpers?: boolean } = {}): void { const bridgeDir = join(resourcesDir, 'Bridge'); mkdirSync(join(bridgeDir, 'bin'), { recursive: true }); + mkdirSync(join(bridgeDir, 'python', 'bin'), { recursive: true }); + mkdirSync(join(bridgeDir, 'licenses'), { recursive: true }); const bridgePath = join(bridgeDir, 'evaos-desktop-bridge'); const peekabooPath = join(bridgeDir, 'bin', 'peekaboo'); writeExecutableScript(bridgePath); @@ -53,7 +63,62 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n } else { writeExecutableScript(peekabooPath); } - writeFileSync(join(bridgeDir, 'manifest.json'), '{"placeholder":false}\n'); + if (options.nativeHelpers) { + writeMachOFixture(join(bridgeDir, 'python', 'bin', 'python3.12')); + } else { + writeExecutableScript(join(bridgeDir, 'python', 'bin', 'python3.12')); + } + symlinkSync('python3.12', join(bridgeDir, 'python', 'bin', 'python3')); + mkdirSync(join(bridgeDir, 'python', 'lib', 'python3.12', 'encodings'), { recursive: true }); + writeFileSync(join(bridgeDir, 'python', 'lib', 'python3.12', 'encodings', '__init__.py'), '# fixture\n'); + writeFileSync(join(bridgeDir, 'licenses', 'CPython-LICENSE.txt'), cpythonLicense); + const inventoryMetadata = bridgeResource.writePythonRuntimeInventory(bridgeDir); + writeFileSync( + join(bridgeDir, 'manifest.json'), + JSON.stringify({ + placeholder: false, + bundledTools: { + python: { + version: '3.12.13', + sourceSha256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17', + sourceUrl: + 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-aarch64-apple-darwin-install_only.tar.gz', + architecture: 'arm64', + packages: [ + { + name: 'pyobjc-core', + version: '12.2.1', + sha256: 'a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b', + }, + { + name: 'pyobjc-framework-Cocoa', + version: '12.2.1', + sha256: '28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080', + }, + { + name: 'pyobjc-framework-Quartz', + version: '12.2.1', + sha256: 'de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9', + }, + { + name: 'pyobjc-framework-ApplicationServices', + version: '12.2.1', + sha256: 'f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b', + }, + { + name: 'pyobjc-framework-CoreText', + version: '12.2.1', + sha256: 'ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6', + }, + ], + license: 'Python-2.0', + licensePath: 'licenses/CPython-LICENSE.txt', + licenseSha256: '3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf', + ...inventoryMetadata, + }, + }, + }) + '\n' + ); if (options.helper) { const helperPath = join(bridgeDir, 'bin', 'evaos-connector-helper'); if (options.nativeHelpers) { @@ -168,6 +233,16 @@ describe('afterPack bundled resource verification', () => { expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).not.toThrow(); }); + it('requires the self-contained Python runtime in macOS bridge resources', () => { + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true }); + rmSync(join(resourcesDir, 'Bridge', 'python'), { force: true, recursive: true }); + + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( + /Bridge\/python\/bin\/python3/ + ); + }); + it('rejects script control helper resources for release-mode macOS builds', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); @@ -176,7 +251,7 @@ describe('afterPack bundled resource verification', () => { try { process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( /native Mach-O executable/ ); } finally { @@ -195,7 +270,139 @@ describe('afterPack bundled resource verification', () => { expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'))).toBe(false); expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'peekaboo'))).toBe(true); expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'evaos-connector-helper'))).toBe(true); - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).not.toThrow(); + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).not.toThrow(); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a strict packaged runtime that changed after its inventory was written', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + writeFileSync( + join(resourcesDir, 'Bridge', 'python', 'lib', 'python3.12', 'encodings', '__init__.py'), + '# tampered\n' + ); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /inventory.*mismatch/i + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('requires a target architecture for release-mode macOS bridge validation', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => + afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', undefined as unknown as string) + ).toThrow(/target architecture is required/); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects bundled Python native extensions without the target architecture', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + const nativeExtension = join( + resourcesDir, + 'Bridge', + 'python', + 'lib', + 'python3.12', + 'site-packages', + 'objc', + '_objc.cpython-312-darwin.so' + ); + mkdirSync(join(nativeExtension, '..'), { recursive: true }); + writeFileSync(nativeExtension, Buffer.from('cffaedfe07000001', 'hex')); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /native runtime does not contain target architecture arm64/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a bundled Python source URL that does not name the pinned architecture asset', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + const manifestPath = join(resourcesDir, 'Bridge', 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.bundledTools.python.sourceUrl = + 'https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython.tar.gz'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /pinned bundled Python runtime provenance/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a bundled Python runtime that does not match the target architecture', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'x64')).toThrow( + /target architecture x64/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a non-relocatable bundled Python launcher symlink', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + const pythonPath = join(resourcesDir, 'Bridge', 'python', 'bin', 'python3'); + rmSync(pythonPath); + symlinkSync(join(resourcesDir, 'Bridge', 'python', 'bin', 'python3.12'), pythonPath); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /launcher symlink is not relocatable/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a regular-file bundled Python launcher in a strict release', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + const pythonPath = join(resourcesDir, 'Bridge', 'python', 'bin', 'python3'); + rmSync(pythonPath); + writeMachOFixture(pythonPath); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /launcher symlink is not relocatable/ + ); } finally { restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); } diff --git a/tests/unit/bootstrap/buildWithBuilder.test.ts b/tests/unit/bootstrap/buildWithBuilder.test.ts index 27d07b1851..7e82f71535 100644 --- a/tests/unit/bootstrap/buildWithBuilder.test.ts +++ b/tests/unit/bootstrap/buildWithBuilder.test.ts @@ -5,14 +5,216 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const repoRoot = resolve(__dirname, '../../..'); describe('build-with-builder', () => { + it.each([ + { + name: 'unexpected key', + extraLine: 'NODE_OPTIONS=--require=/tmp/untrusted.cjs', + expected: /unexpected environment metadata key/i, + }, + { + name: 'duplicate key', + extraLine: 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=/tmp/duplicate-runtime', + expected: /duplicate environment metadata key/i, + }, + ])('rejects $name from prepared Python runtime environment metadata', ({ extraLine, expected }) => { + const tempDir = mkdtempSync(join(tmpdir(), 'aionui-runtime-env-test-')); + const hookPath = join(tempDir, 'hook.cjs'); + + writeFileSync( + hookPath, + ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const originalLoad = Module._load; +Object.defineProperty(process, 'platform', { value: 'darwin' }); + +Module._load = function patchedLoad(request, parent, isMain) { + if (request.endsWith('packages/shared-scripts/src/prepare-aioncore.js')) { + return { prepareAioncore: () => ({ prepared: true, dir: 'mock-bundled-aioncore', sourceType: 'mock' }), readManagedResourcesBundle: () => 'no-acp' }; + } + if (request.endsWith('/resolveAioncoreVersion.js') || request === './resolveAioncoreVersion.js') { + return { resolveAioncoreVersion: () => 'v-test' }; + } + return originalLoad.call(this, request, parent, isMain); +}; + +function ensurePlaceholder(relativePath) { + const target = path.join(process.cwd(), relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (!fs.existsSync(target)) fs.writeFileSync(target, ''); +} + +childProcess.execFileSync = function mockedExecFileSync(command, args, options) { + if (!String(command).endsWith('prepareEvaosDesktopBridgePythonRuntime.sh')) return Buffer.from(''); + const runtimeDir = path.join(options.env.RUNNER_TEMP, 'prepared-arm64'); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(args[1], 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=' + runtimeDir + '\\n' + process.env.EVAOS_TEST_RUNTIME_ENV_EXTRA + '\\n'); + return Buffer.from(''); +}; + +childProcess.execSync = function mockedExecSync(command) { + const commandText = String(command); + if (commandText.includes('electron-vite build')) { + ensurePlaceholder('out/main/index.js'); + ensurePlaceholder('out/renderer/index.html'); + } + return Buffer.from(''); +}; +`, + 'utf8' + ); + + try { + const result = spawnSync(process.execPath, ['scripts/build-with-builder.js', 'arm64', '--mac', '--arm64'], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + EVAOS_APP_COMMIT: 'test-candidate-sha', + EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR: '', + EVAOS_SKIP_BUILD_CLEANUP: '1', + EVAOS_TEST_RUNTIME_ENV_EXTRA: extraLine, + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${hookPath}`].filter(Boolean).join(' '), + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toMatch(expected); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('prepares architecture-specific Mac resources for every requested target', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'aionui-multi-arch-build-test-')); + const hookPath = join(tempDir, 'hook.cjs'); + const lifecyclePath = join(tempDir, 'multi-arch-lifecycle.json'); + + writeFileSync( + hookPath, + ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const originalLoad = Module._load; +Object.defineProperty(process, 'platform', { value: 'darwin' }); + +function readLifecycle() { + const file = process.env.EVAOS_RUNTIME_LIFECYCLE_FILE; + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : { aioncore: [], bridge: [] }; +} + +function writeLifecycle(value) { + fs.writeFileSync(process.env.EVAOS_RUNTIME_LIFECYCLE_FILE, JSON.stringify(value)); +} + +function recordAioncore(options) { + const lifecycle = readLifecycle(); + lifecycle.aioncore.push(options.arch); + writeLifecycle(lifecycle); + return { prepared: true, dir: 'mock-bundled-aioncore', sourceType: 'mock' }; +} + +Module._load = function patchedLoad(request, parent, isMain) { + if (request.endsWith('packages/shared-scripts/src/prepare-aioncore.js')) { + return { prepareAioncore: recordAioncore, readManagedResourcesBundle: () => 'no-acp' }; + } + if (request.endsWith('/resolveAioncoreVersion.js') || request === './resolveAioncoreVersion.js') { + return { resolveAioncoreVersion: () => 'v-test' }; + } + return originalLoad.call(this, request, parent, isMain); +}; + +function ensurePlaceholder(relativePath) { + const target = path.join(process.cwd(), relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (!fs.existsSync(target)) fs.writeFileSync(target, ''); +} + +childProcess.execFileSync = function mockedExecFileSync(command, args, options) { + if (!String(command).endsWith('prepareEvaosDesktopBridgePythonRuntime.sh')) return Buffer.from(''); + const arch = args[0]; + const runtimeDir = path.join(options.env.RUNNER_TEMP, 'prepared-' + arch); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(args[1], 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=' + runtimeDir + '\\n'); + return Buffer.from(''); +}; + +childProcess.execSync = function mockedExecSync(command) { + const commandText = String(command); + if (commandText.includes('electron-vite build')) { + ensurePlaceholder('out/main/index.js'); + ensurePlaceholder('out/renderer/index.html'); + } + if (commandText.includes('prepareEvaosDesktopBridgeResource.js')) { + const lifecycle = readLifecycle(); + lifecycle.bridge.push({ + resourceDir: process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR, + runtimeDir: process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR, + }); + writeLifecycle(lifecycle); + } + return Buffer.from(''); +}; +`, + 'utf8' + ); + + try { + const result = spawnSync( + process.execPath, + ['scripts/build-with-builder.js', 'auto', '--mac', '--arm64', '--x64'], + { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + EVAOS_APP_COMMIT: 'test-candidate-sha', + EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR: '', + EVAOS_RUNTIME_LIFECYCLE_FILE: lifecyclePath, + EVAOS_SKIP_BUILD_CLEANUP: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${hookPath}`].filter(Boolean).join(' '), + }, + } + ); + + expect(result.status, result.stderr || result.stdout).toBe(0); + const lifecycle = JSON.parse(readFileSync(lifecyclePath, 'utf8')) as { + aioncore: string[]; + bridge: Array<{ resourceDir?: string; runtimeDir: string }>; + }; + expect(lifecycle.aioncore).toEqual(['arm64', 'x64']); + expect(lifecycle.bridge.map(({ resourceDir }) => (resourceDir ? basename(resourceDir) : undefined))).toEqual([ + 'Bridge-arm64', + 'Bridge-x64', + ]); + expect(lifecycle.bridge.map(({ runtimeDir }) => basename(runtimeDir))).toEqual([ + 'prepared-arm64', + 'prepared-x64', + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('selects the architecture-specific bridge resource in electron-builder', () => { + const builderConfig = readFileSync(join(repoRoot, 'packages/desktop/electron-builder.yml'), 'utf8'); + const gitignore = readFileSync(join(repoRoot, '.gitignore'), 'utf8'); + expect(builderConfig).toContain('from: resources/Bridge-${arch}'); + expect(gitignore).toMatch(/^resources\/Bridge-\*$/m); + }); + it.each([ { args: ['arm64', '--win', '--arm64'], @@ -96,12 +298,14 @@ childProcess.execSync = function mockedExecSync(command) { ...process.env, AIONUI_PREPARE_CALLS_FILE: callsPath, EVAOS_APP_COMMIT: 'test-candidate-sha', + EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR: tempDir, EVAOS_SKIP_BUILD_CLEANUP: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${hookPath}`].filter(Boolean).join(' '), }, }); expect(result.status, result.stderr || result.stdout).toBe(0); + expect(existsSync(tempDir)).toBe(true); const calls = JSON.parse(readFileSync(callsPath, 'utf8')) as Array<{ arch?: string; @@ -120,4 +324,102 @@ childProcess.execSync = function mockedExecSync(command) { rmSync(tempDir, { recursive: true, force: true }); } }); + + it.skipIf(process.platform !== 'darwin').each([ + { prepareFails: false, expectedStatus: 0 }, + { prepareFails: true, expectedStatus: 1 }, + ])( + 'removes its owned Python runtime after desktop bridge preparation (failure=$prepareFails)', + ({ prepareFails, expectedStatus }) => { + const tempDir = mkdtempSync(join(tmpdir(), 'aionui-runtime-cleanup-test-')); + const hookPath = join(tempDir, 'hook.cjs'); + const lifecyclePath = join(tempDir, 'runtime-lifecycle.json'); + + writeFileSync( + hookPath, + ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const originalLoad = Module._load; + +function recordPrepareCall() { + return { prepared: true, dir: 'mock-bundled-aioncore', sourceType: 'mock' }; +} + +Module._load = function patchedLoad(request, parent, isMain) { + if (request.endsWith('packages/shared-scripts/src/prepare-aioncore.js')) { + return { prepareAioncore: recordPrepareCall, readManagedResourcesBundle: () => 'no-acp' }; + } + if (request.endsWith('/resolveAioncoreVersion.js') || request === './resolveAioncoreVersion.js') { + return { resolveAioncoreVersion: () => 'v-test' }; + } + return originalLoad.call(this, request, parent, isMain); +}; + +function writeLifecycle(update) { + const file = process.env.EVAOS_RUNTIME_LIFECYCLE_FILE; + const current = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + fs.writeFileSync(file, JSON.stringify({ ...current, ...update })); +} + +function ensurePlaceholder(relativePath) { + const target = path.join(process.cwd(), relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (!fs.existsSync(target)) fs.writeFileSync(target, ''); +} + +childProcess.execFileSync = function mockedExecFileSync(command, args, options) { + if (!String(command).endsWith('prepareEvaosDesktopBridgePythonRuntime.sh')) return Buffer.from(''); + const tempRoot = options.env.RUNNER_TEMP; + const runtimeDir = path.join(tempRoot, 'prepared-python'); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(args[1], 'EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=' + runtimeDir + '\\n'); + writeLifecycle({ tempRoot, runtimeDir }); + return Buffer.from(''); +}; + +childProcess.execSync = function mockedExecSync(command) { + const commandText = String(command); + if (commandText.includes('electron-vite build')) { + ensurePlaceholder('out/main/index.js'); + ensurePlaceholder('out/renderer/index.html'); + } + if (commandText.includes('prepareEvaosDesktopBridgeResource.js')) { + writeLifecycle({ runtimeExistsDuringPrepare: fs.existsSync(process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR) }); + if (process.env.EVAOS_FORCE_BRIDGE_PREPARE_FAILURE === '1') throw new Error('forced bridge prepare failure'); + } + return Buffer.from(''); +}; +`, + 'utf8' + ); + + try { + const result = spawnSync(process.execPath, ['scripts/build-with-builder.js', 'auto', '--mac', '--arm64'], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + EVAOS_APP_COMMIT: 'test-candidate-sha', + EVAOS_FORCE_BRIDGE_PREPARE_FAILURE: prepareFails ? '1' : '0', + EVAOS_RUNTIME_LIFECYCLE_FILE: lifecyclePath, + EVAOS_SKIP_BUILD_CLEANUP: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${hookPath}`].filter(Boolean).join(' '), + }, + }); + + expect(result.status, result.stderr || result.stdout).toBe(expectedStatus); + const lifecycle = JSON.parse(readFileSync(lifecyclePath, 'utf8')) as { + tempRoot: string; + runtimeExistsDuringPrepare: boolean; + }; + expect(lifecycle.runtimeExistsDuringPrepare).toBe(true); + expect(existsSync(lifecycle.tempRoot)).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + } + ); }); diff --git a/tests/unit/bootstrap/dmgRetryEligibility.test.ts b/tests/unit/bootstrap/dmgRetryEligibility.test.ts new file mode 100644 index 0000000000..b805358eb0 --- /dev/null +++ b/tests/unit/bootstrap/dmgRetryEligibility.test.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const retryEligibility = require('../../../scripts/dmgRetryEligibility.js') as { + clearDmgRetryCompletionMarkers: (appOutDir: string) => void; + clearDmgRetryCompletionMarkersInDirectory?: (outDir: string) => void; + clearCompletedAfterPack: (appOutDir: string) => void; + hasCompletedAfterSign: (appOutDir: string) => boolean; + isDmgRetryEligible: (appOutDir: string, options?: { multiArch?: boolean }) => boolean; + markCompletedAfterPack: (appOutDir: string) => void; + markCompletedAfterSign: (appOutDir: string) => void; + withAfterSignCompletion: (appOutDir: string, operation: () => Promise) => Promise; +}; + +describe('DMG retry eligibility', () => { + it('requires both packaging hooks before retrying a partial app', () => { + const appOutDir = mkdtempSync(join(tmpdir(), 'evaos-after-pack-')); + mkdirSync(join(appOutDir, 'evaOS Workbench.app')); + + try { + expect(retryEligibility.isDmgRetryEligible(appOutDir)).toBe(false); + retryEligibility.markCompletedAfterPack(appOutDir); + expect(retryEligibility.isDmgRetryEligible(appOutDir)).toBe(false); + retryEligibility.markCompletedAfterSign(appOutDir); + expect(retryEligibility.isDmgRetryEligible(appOutDir)).toBe(true); + } finally { + rmSync(appOutDir, { recursive: true, force: true }); + } + }); + + it('clears stale completion from both packaging hooks', () => { + const appOutDir = mkdtempSync(join(tmpdir(), 'evaos-packaging-hooks-')); + + try { + retryEligibility.markCompletedAfterPack(appOutDir); + retryEligibility.markCompletedAfterSign(appOutDir); + retryEligibility.clearDmgRetryCompletionMarkers(appOutDir); + expect(retryEligibility.hasCompletedAfterSign(appOutDir)).toBe(false); + expect(retryEligibility.isDmgRetryEligible(appOutDir)).toBe(false); + } finally { + rmSync(appOutDir, { recursive: true, force: true }); + } + }); + + it('clears stale retry markers from every prior macOS app output', () => { + const outDir = mkdtempSync(join(tmpdir(), 'evaos-stale-packaging-')); + const appOutDirs = [join(outDir, 'mac-arm64'), join(outDir, 'mac-x64')]; + + try { + expect(retryEligibility.clearDmgRetryCompletionMarkersInDirectory).toBeTypeOf('function'); + if (!retryEligibility.clearDmgRetryCompletionMarkersInDirectory) return; + + for (const appOutDir of appOutDirs) { + mkdirSync(appOutDir, { recursive: true }); + retryEligibility.markCompletedAfterPack(appOutDir); + retryEligibility.markCompletedAfterSign(appOutDir); + } + + retryEligibility.clearDmgRetryCompletionMarkersInDirectory(outDir); + expect(appOutDirs.every((appOutDir) => !retryEligibility.isDmgRetryEligible(appOutDir))).toBe(true); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); + + it('disables single-app DMG retry for multi-architecture builds', () => { + const appOutDir = mkdtempSync(join(tmpdir(), 'evaos-multi-arch-packaging-')); + + try { + retryEligibility.markCompletedAfterPack(appOutDir); + retryEligibility.markCompletedAfterSign(appOutDir); + expect(retryEligibility.isDmgRetryEligible(appOutDir)).toBe(true); + expect(retryEligibility.isDmgRetryEligible(appOutDir, { multiArch: true })).toBe(false); + } finally { + rmSync(appOutDir, { recursive: true, force: true }); + } + }); + + it('records afterSign completion only after a successful operation', async () => { + const appOutDir = mkdtempSync(join(tmpdir(), 'evaos-after-sign-success-')); + + try { + await expect(retryEligibility.withAfterSignCompletion(appOutDir, async () => 'verified')).resolves.toBe( + 'verified' + ); + expect(retryEligibility.hasCompletedAfterSign(appOutDir)).toBe(true); + } finally { + rmSync(appOutDir, { recursive: true, force: true }); + } + }); + + it('leaves afterSign incomplete when the operation fails', async () => { + const appOutDir = mkdtempSync(join(tmpdir(), 'evaos-after-sign-failure-')); + + try { + await expect( + retryEligibility.withAfterSignCompletion(appOutDir, async () => { + throw new Error('notarization failed'); + }) + ).rejects.toThrow('notarization failed'); + expect(retryEligibility.hasCompletedAfterSign(appOutDir)).toBe(false); + } finally { + rmSync(appOutDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index aa01e85ad8..0b4b0e897f 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; const require = createRequire(import.meta.url); const releaseGate = require('../../../scripts/evaosBetaReleaseGate.js') as { @@ -129,12 +129,31 @@ function writeMacosBridgeZip( omitLicense?: boolean; sourceSha256?: string; manifestLicenseSha256?: string; + tamperPythonLicense?: boolean; + universalPythonRuntime?: boolean; + omitCocoa?: boolean; + omitCoreText?: boolean; + wrongObjcArchitecture?: boolean; + wrongPythonLauncherArchitecture?: boolean; + regularPythonLauncher?: boolean; + nonTraversablePythonDirectory?: boolean; + nonTraversablePythonRoot?: boolean; + normalizedPythonEntryCollision?: boolean; + wrongPythonSourceUrl?: boolean; + nonExecutablePayload?: 'bridge' | 'peekaboo' | 'helper' | 'python'; + omitFoundationNative?: boolean; + omitInventoriedRuntimeFile?: boolean; + omitStdlibSentinel?: boolean; + signedPythonMutation?: boolean; + secondAppRoot?: boolean; } = {} ) { const script = [ 'import hashlib', 'import json', 'import pathlib', + 'import stat', + 'import struct', 'import sys', 'import zipfile', 'zip_path = pathlib.Path(sys.argv[1])', @@ -143,20 +162,136 @@ function writeMacosBridgeZip( 'omit_license = sys.argv[4] == "1"', 'source_sha256 = sys.argv[5]', 'manifest_license_sha256 = sys.argv[6]', + 'python_license_path = pathlib.Path(sys.argv[7])', + 'tamper_python_license = sys.argv[8] == "1"', + 'wrong_objc_architecture = sys.argv[9] == "1"', + 'wrong_python_source_url = sys.argv[10] == "1"', + 'universal_python_runtime = sys.argv[11] == "1"', + 'omit_cocoa = sys.argv[12] == "1"', + 'omit_core_text = sys.argv[13] == "1"', + 'wrong_python_launcher_architecture = sys.argv[14] == "1"', + 'non_executable_payload = sys.argv[15]', + 'omit_foundation_native = sys.argv[16] == "1"', + 'omit_inventoried_runtime_file = sys.argv[17] == "1"', + 'second_app_root = sys.argv[18] == "1"', + 'omit_stdlib_sentinel = sys.argv[19] == "1"', + 'signed_python_mutation = sys.argv[20] == "1"', + 'regular_python_launcher = sys.argv[21] == "1"', + 'non_traversable_python_directory = sys.argv[22] == "1"', + 'non_traversable_python_root = sys.argv[23] == "1"', + 'normalized_python_entry_collision = sys.argv[24] == "1"', 'app_root = zip_path.stem.replace("-mac-arm64", "").replace("-mac-x64", "") + ".app"', + 'python_arch = "arm64" if "arm64" in zip_path.name else "x64"', + 'python_source_sha256 = "5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17" if python_arch == "arm64" else "cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894"', + 'def fat_macho():', + ' slices = [(0x0100000c, bytes.fromhex("cffaedfe0c000001")), (0x01000007, bytes.fromhex("cffaedfe07000001"))]', + ' offset = 8 + len(slices) * 20', + ' records = []', + ' payload = []', + ' for cpu_type, thin_header in slices:', + ' records.append(struct.pack(">IIIII", cpu_type, 0, offset, len(thin_header), 0))', + ' payload.append(thin_header)', + ' offset += len(thin_header)', + ' return struct.pack(">II", 0xcafebabe, len(slices)) + b"".join(records) + b"".join(payload)', + 'python_header = fat_macho() if universal_python_runtime else bytes.fromhex("cffaedfe0c000001" if python_arch == "arm64" else "cffaedfe07000001")', + 'wrong_python_launcher_header = bytes.fromhex("cffaedfe07000001" if python_arch == "arm64" else "cffaedfe0c000001")', 'license_bytes = b"MIT License\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\n"', 'license_sha256 = manifest_license_sha256 or hashlib.sha256(license_bytes).hexdigest()', - 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}}}', + 'python_license_bytes = python_license_path.read_bytes() + (b"tampered\\n" if tamper_python_license else b"")', + 'python_license_sha256 = hashlib.sha256(python_license_bytes).hexdigest()', + 'objc_header = bytes.fromhex(("cffaedfe07000001" if python_arch == "arm64" else "cffaedfe0c000001") if wrong_objc_architecture else ("cffaedfe0c000001" if python_arch == "arm64" else "cffaedfe07000001"))', + 'python_packages = [{"name":"pyobjc-core","version":"12.2.1","sha256":"a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b"},{"name":"pyobjc-framework-Cocoa","version":"12.2.1","sha256":"28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080"},{"name":"pyobjc-framework-Quartz","version":"12.2.1","sha256":"de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9"},{"name":"pyobjc-framework-ApplicationServices","version":"12.2.1","sha256":"f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b"},{"name":"pyobjc-framework-CoreText","version":"12.2.1","sha256":"ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6"}]', + 'python_asset_arch = "aarch64" if python_arch == "arm64" else "x86_64"', + 'python_source_url = f"https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.12.13+20260510-{python_asset_arch}-apple-darwin-install_only.tar.gz"', + 'if wrong_python_source_url:', + ' python_source_url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython.tar.gz"', + 'runtime_entries = []', + 'def add_runtime_directory(relative_path, mode=0o755):', + ' runtime_entries.append({"path": relative_path, "type": "directory", "mode": mode, "include": True})', + 'def add_runtime_file(relative_path, data, mode=0o644, include_in_zip=True, archive_data=None):', + ' entry = {"path": relative_path, "type": "file", "mode": mode, "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "data": data, "include": include_in_zip, "archiveData": archive_data}', + ' if data[:4].hex() in {"feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "cafebabf", "bebafeca", "bfbafeca"}:', + ' entry["signedMachO"] = True', + ' runtime_entries.append(entry)', + 'def add_runtime_symlink(relative_path, target):', + ' runtime_entries.append({"path": relative_path, "type": "symlink", "mode": 0o777, "target": target, "data": target.encode(), "include": True})', + 'for runtime_directory in ["bin", "lib", "lib/python3.12", "lib/python3.12/encodings", "lib/python3.12/site-packages", "lib/python3.12/site-packages/ApplicationServices", "lib/python3.12/site-packages/Cocoa", "lib/python3.12/site-packages/CoreText", "lib/python3.12/site-packages/Foundation", "lib/python3.12/site-packages/HIServices", "lib/python3.12/site-packages/Quartz", "lib/python3.12/site-packages/Quartz/CoreGraphics", "lib/python3.12/site-packages/objc"]:', + ' add_runtime_directory(runtime_directory, 0o600 if non_traversable_python_directory and runtime_directory == "bin" else 0o755)', + 'if wrong_python_launcher_architecture:', + ' add_runtime_file("bin/python3", wrong_python_launcher_header, 0o755)', + 'elif regular_python_launcher:', + ' add_runtime_file("bin/python3", python_header, 0o755)', + 'else:', + ' add_runtime_symlink("bin/python3", "python3.12")', + 'add_runtime_file("bin/python3.12", python_header, 0o644 if non_executable_payload == "python" else 0o755, True, python_header + b"signed" if signed_python_mutation else None)', + 'add_runtime_file("lib/python3.12/LICENSE.txt", python_license_bytes)', + 'if not omit_stdlib_sentinel:', + ' add_runtime_file("lib/python3.12/encodings/__init__.py", b"# encodings fixture\\n")', + 'add_runtime_file("lib/python3.12/site-packages/ApplicationServices/__init__.py", b"")', + 'if not omit_cocoa:', + ' add_runtime_file("lib/python3.12/site-packages/Cocoa/__init__.py", b"")', + 'if not omit_core_text:', + ' add_runtime_file("lib/python3.12/site-packages/CoreText/__init__.py", b"")', + 'add_runtime_file("lib/python3.12/site-packages/Quartz/__init__.py", b"")', + 'add_runtime_file("lib/python3.12/site-packages/objc/__init__.py", b"")', + 'add_runtime_file("lib/python3.12/site-packages/objc/_objc.cpython-312-darwin.so", objc_header, 0o755)', + 'if not omit_foundation_native:', + ' add_runtime_file("lib/python3.12/site-packages/Foundation/_Foundation.cpython-312-darwin.so", python_header, 0o755)', + 'add_runtime_file("lib/python3.12/site-packages/Quartz/CoreGraphics/_coregraphics.cpython-312-darwin.so", python_header, 0o755)', + 'add_runtime_file("lib/python3.12/site-packages/HIServices/_HIServices.cpython-312-darwin.so", python_header, 0o755)', + 'add_runtime_file("lib/python3.12/site-packages/CoreText/_manual.cpython-312-darwin.so", python_header, 0o755)', + 'add_runtime_file("lib/python3.12/site-packages/runtime-only.py", b"runtime closure\\n", 0o644, not omit_inventoried_runtime_file)', + 'inventory_entries = [{key: value for key, value in entry.items() if key not in {"data", "include", "archiveData"}} for entry in sorted(runtime_entries, key=lambda item: item["path"])]', + 'inventory = {"schema": "evaos-python-runtime-inventory/v1", "entries": inventory_entries}', + 'inventory_bytes = (json.dumps(inventory, indent=2) + "\\n").encode()', + 'python_metadata = {"version": "3.12.13", "architecture": python_arch, "sourceSha256": python_source_sha256, "sourceUrl": python_source_url, "packages": python_packages, "license": "Python-2.0", "licensePath": "licenses/CPython-LICENSE.txt", "licenseSha256": python_license_sha256, "inventoryPath": "python-runtime-inventory.json", "inventorySha256": hashlib.sha256(inventory_bytes).hexdigest(), "inventoryEntryCount": len(inventory_entries)}', + 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}, "python": python_metadata}}', + 'def write_regular(archive, name, data, mode=0o644):', + ' info = zipfile.ZipInfo(name)', + ' info.create_system = 3', + ' info.external_attr = (stat.S_IFREG | mode) << 16', + ' info.compress_type = zipfile.ZIP_DEFLATED', + ' archive.writestr(info, data)', + 'def write_symlink(archive, name, target):', + ' info = zipfile.ZipInfo(name)', + ' info.create_system = 3', + ' info.external_attr = (stat.S_IFLNK | 0o777) << 16', + ' info.compress_type = zipfile.ZIP_DEFLATED', + ' archive.writestr(info, target.encode())', + 'def write_directory(archive, name, mode=0o755):', + ' info = zipfile.ZipInfo(name.rstrip("/") + "/")', + ' info.create_system = 3', + ' info.external_attr = (stat.S_IFDIR | mode) << 16', + ' info.compress_type = zipfile.ZIP_STORED', + ' archive.writestr(info, b"")', 'with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/evaos-desktop-bridge", "#!/usr/bin/env bash\\n")', + ' bridge_prefix = f"{app_root}/Contents/Resources/Bridge"', + ' write_regular(archive, f"{bridge_prefix}/evaos-desktop-bridge", b"#!/usr/bin/env bash\\n", 0o644 if non_executable_payload == "bridge" else 0o755)', ' if not omit_peekaboo:', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/peekaboo", bytes.fromhex("cafebabe00000000"))', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"))', + ' write_regular(archive, f"{bridge_prefix}/bin/peekaboo", bytes.fromhex("cafebabe00000000"), 0o644 if non_executable_payload == "peekaboo" else 0o755)', + ' write_regular(archive, f"{bridge_prefix}/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"), 0o644 if non_executable_payload == "helper" else 0o755)', + ' write_directory(archive, f"{bridge_prefix}/python", 0o600 if non_traversable_python_root else 0o755)', + ' if normalized_python_entry_collision:', + ' write_regular(archive, f"{bridge_prefix}/python/bin", b"shadowed runtime entry")', + ' for entry in runtime_entries:', + ' if not entry["include"]:', + ' continue', + ' entry_name = f"{bridge_prefix}/python/{entry[\'path\']}"', + ' if entry["type"] == "directory":', + ' write_directory(archive, entry_name, entry["mode"])', + ' elif entry["type"] == "symlink":', + ' write_symlink(archive, entry_name, entry["target"])', + ' else:', + ' write_regular(archive, entry_name, entry["archiveData"] if entry["archiveData"] is not None else entry["data"], entry["mode"])', + ' write_regular(archive, f"{bridge_prefix}/python-runtime-inventory.json", inventory_bytes)', + ' write_regular(archive, f"{bridge_prefix}/licenses/CPython-LICENSE.txt", python_license_bytes)', ' if not omit_license:', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/licenses/Peekaboo-LICENSE.txt", license_bytes)', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/manifest.json", json.dumps(manifest) + "\\n")', + ' write_regular(archive, f"{bridge_prefix}/licenses/Peekaboo-LICENSE.txt", license_bytes)', + ' write_regular(archive, f"{bridge_prefix}/manifest.json", (json.dumps(manifest) + "\\n").encode())', + ' if second_app_root:', + ' write_regular(archive, "Stale Workbench.app/Contents/Info.plist", b"stale")', ' for index in range(extra_entry_count):', - ' archive.writestr(f"{app_root}/Contents/Resources/noise/entry-{index:05d}.txt", "x\\n")', + ' write_regular(archive, f"{app_root}/Contents/Resources/noise/entry-{index:05d}.txt", b"x\\n")', ].join('\n'); execFileSync('python3', [ '-c', @@ -167,6 +302,24 @@ function writeMacosBridgeZip( options.omitLicense ? '1' : '0', options.sourceSha256 || '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383', options.manifestLicenseSha256 || '', + path.join(repoRoot, 'tests/fixtures/licenses/CPython-3.12.13-LICENSE.txt'), + options.tamperPythonLicense ? '1' : '0', + options.wrongObjcArchitecture ? '1' : '0', + options.wrongPythonSourceUrl ? '1' : '0', + options.universalPythonRuntime ? '1' : '0', + options.omitCocoa ? '1' : '0', + options.omitCoreText ? '1' : '0', + options.wrongPythonLauncherArchitecture ? '1' : '0', + options.nonExecutablePayload || '', + options.omitFoundationNative ? '1' : '0', + options.omitInventoriedRuntimeFile ? '1' : '0', + options.secondAppRoot ? '1' : '0', + options.omitStdlibSentinel ? '1' : '0', + options.signedPythonMutation ? '1' : '0', + options.regularPythonLauncher ? '1' : '0', + options.nonTraversablePythonDirectory ? '1' : '0', + options.nonTraversablePythonRoot ? '1' : '0', + options.normalizedPythonEntryCollision ? '1' : '0', ]); } @@ -412,10 +565,32 @@ function writeProofReleaseAssetsReference( } describe('evaOS beta release gate', () => { + it('recognizes little-endian fat Mach-O helpers during signing closure validation', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-after-sign-fat-mach-o-')); + try { + for (const [index, magic] of ['bebafeca', 'bfbafeca'].entries()) { + const helperPath = path.join(dir, `helper-${index}`); + fs.writeFileSync(helperPath, Buffer.from(`${magic}00000000`, 'hex')); + fs.chmodSync(helperPath, 0o755); + expect(afterSign.isMachOExecutable(helperPath)).toBe(true); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('pins the stable Peekaboo fallback asset and published digest', () => { const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/_build-reusable.yml'), 'utf8'); expect(workflow).toContain("PEEKABOO_VERSION: '3.8.0'"); + expect(workflow).toContain("PYTHON_RUNTIME_VERSION: '3.12.13'"); + expect(workflow).toContain("PYTHON_RUNTIME_RELEASE: '20260510'"); + expect(workflow).toContain( + "PYTHON_RUNTIME_ARM64_SHA256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17'" + ); + expect(workflow).toContain( + "PYTHON_RUNTIME_X64_SHA256: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894'" + ); expect(workflow).toContain("PEEKABOO_SHA256: '5be06117ed861ac7a87ea1d1e552122db4231bf2cd618ec516d77c66acd39620'"); expect(workflow).toContain( "PEEKABOO_BINARY_SHA256: '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383'" @@ -428,11 +603,28 @@ describe('evaOS beta release gate', () => { it('verifies the pinned Peekaboo digest before exporting the packaging path', () => { const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/_build-reusable.yml'), 'utf8'); + const runtimePrep = fs.readFileSync( + path.join(repoRoot, 'scripts/prepareEvaosDesktopBridgePythonRuntime.sh'), + 'utf8' + ); expect(workflow).toContain('shasum -a 256 -c'); expect(workflow).toContain('EVAOS_PEEKABOO_BIN=$PEEKABOO_BIN'); expect(workflow).toContain('EVAOS_REQUIRED_PEEKABOO_SOURCE_SHA256=$PEEKABOO_BINARY_SHA256'); expect(workflow).toContain('EVAOS_PEEKABOO_LICENSE=$PEEKABOO_LICENSE'); + expect(workflow).toContain('TARGET_ARCH: ${{ matrix.arch }}'); + expect(workflow).toContain('scripts/prepareEvaosDesktopBridgePythonRuntime.sh "$TARGET_ARCH"'); + expect(runtimePrep).toContain('EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR=$runtime_dir'); + expect(runtimePrep).toContain('EVAOS_REQUIRED_PYTHON_RUNTIME_SHA256=$runtime_sha256'); + expect(runtimePrep).toContain( + 'PYTHON_RUNTIME_LICENSE_SHA256:=3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf' + ); + expect(runtimePrep).toContain('"$PYTHON_RUNTIME_LICENSE_SHA256" "$python_license_path"'); + expect(runtimePrep).toContain('-I -m pip check'); + expect(runtimePrep).toContain('distributions(path=[sys.argv[1]])'); + expect(runtimePrep).toContain('installed_pyobjc'); + expect(runtimePrep).toContain('expected_pyobjc'); + expect(runtimePrep).toContain('import ApplicationServices, Cocoa, CoreText, Quartz'); }); it('requires functional smoke to verify the packaged Peekaboo version', () => { @@ -441,6 +633,7 @@ describe('evaOS beta release gate', () => { expect(workflow).toContain('BUNDLED_PEEKABOO_SOURCE_SHA256'); expect(workflow).toContain('BUNDLED_PEEKABOO_LICENSE_SHA256'); expect(workflow).toContain('3.8.0'); + expect(workflow).toContain('import ApplicationServices, Cocoa, CoreText, Quartz'); }); it('requires the functional-smoke app job itself to run on Sequoia', () => { @@ -455,6 +648,14 @@ describe('evaOS beta release gate', () => { expect(releaseGate.collectFunctionalSmokeConfigIssues(decoyWorkflow)).toEqual([ '.github/workflows/workbench-functional-smoke.yml: macos-arm64-app must run on macos-15', ]); + + const mutableBridgeRefWorkflow = workflow.replace( + '[[ ! "$WORKBENCH_SMOKE_BRIDGE_REF" =~ ^[0-9a-fA-F]{40}$ ]]', + 'true' + ); + expect(releaseGate.collectFunctionalSmokeConfigIssues(mutableBridgeRefWorkflow)).toContain( + '.github/workflows/workbench-functional-smoke.yml: bridge ref must be a full immutable commit SHA' + ); }); it('detects strict public beta release mode', () => { @@ -477,6 +678,16 @@ describe('evaOS beta release gate', () => { const helperDir = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'bin'); const peekabooPath = path.join(helperDir, 'peekaboo'); const connectorHelperPath = path.join(helperDir, 'evaos-connector-helper'); + const pythonPath = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'python', 'bin', 'python3.12'); + const pythonDylibPath = path.join( + appPath, + 'Contents', + 'Resources', + 'Bridge', + 'python', + 'lib', + 'libpython3.12.dylib' + ); const signedByExpectedTeam = { status: 0, stdout: '', @@ -493,6 +704,12 @@ describe('evaOS beta release gate', () => { try { writeMachOFixture(peekabooPath); writeMachOFixture(connectorHelperPath); + writeMachOFixture(pythonPath); + fs.mkdirSync(path.dirname(pythonDylibPath), { recursive: true }); + fs.writeFileSync(pythonDylibPath, Buffer.from('cffaedfe0c000001', 'hex')); + fs.chmodSync(pythonDylibPath, 0o644); + + const signedRuntimeClosure = vi.fn(() => signedByExpectedTeam); expect(() => afterSign.assertMacControlHelperSignatures( @@ -501,9 +718,14 @@ describe('evaOS beta release gate', () => { EVAOS_MAC_CONTROL_HELPER_TEAM_ID: 'TC6MS3T6NN', EVAOS_MAC_CONTROL_HELPER_AUTHORITY: 'Developer ID Application: Andrew Ryan (TC6MS3T6NN)', }, - () => signedByExpectedTeam + signedRuntimeClosure ) ).not.toThrow(); + expect(signedRuntimeClosure).toHaveBeenCalledWith( + 'codesign', + ['-dv', '--verbose=4', pythonDylibPath], + expect.any(Object) + ); expect(() => afterSign.assertMacControlHelperSignatures( @@ -1437,7 +1659,7 @@ describe('evaOS beta release gate', () => { } }); - it('rejects macOS release ZIPs without exact Peekaboo package proof', () => { + it('rejects macOS release ZIPs without exact Mac-control package proof', () => { const cases = [ { name: 'missing binary', @@ -1459,6 +1681,81 @@ describe('evaOS beta release gate', () => { options: { sourceSha256: '0'.repeat(64) }, expected: /source digest/, }, + { + name: 'self-consistent altered CPython license', + options: { tamperPythonLicense: true }, + expected: /Python runtime provenance|CPython license digest/, + }, + { + name: 'wrong PyObjC architecture', + options: { wrongObjcArchitecture: true }, + expected: /PyObjC native runtime architecture/, + }, + { + name: 'wrong Python source URL', + options: { wrongPythonSourceUrl: true }, + expected: /Python runtime provenance/, + }, + { + name: 'missing Cocoa payload', + options: { omitCocoa: true }, + expected: /bundled PyObjC control modules/, + }, + { + name: 'missing CoreText payload', + options: { omitCoreText: true }, + expected: /bundled PyObjC control modules/, + }, + { + name: 'wrong Python launcher architecture', + options: { wrongPythonLauncherArchitecture: true }, + expected: /relocatable bundled Python launcher/, + }, + { + name: 'regular-file Python launcher', + options: { regularPythonLauncher: true }, + expected: /relocatable bundled Python launcher/, + }, + { + name: 'non-traversable Python directory', + options: { nonTraversablePythonDirectory: true }, + expected: /Python runtime inventory/, + }, + { + name: 'non-traversable Python runtime root', + options: { nonTraversablePythonRoot: true }, + expected: /Python runtime inventory/, + }, + { + name: 'normalized Python entry collision', + options: { normalizedPythonEntryCollision: true }, + expected: /Python runtime inventory/, + }, + { + name: 'multiple app roots', + options: { secondAppRoot: true }, + expected: /exactly one \.app root/, + }, + ...(['bridge', 'peekaboo', 'helper', 'python'] as const).map((payload) => ({ + name: `non-executable ${payload}`, + options: { nonExecutablePayload: payload }, + expected: /executable ZIP mode/, + })), + { + name: 'missing inventoried runtime file', + options: { omitInventoriedRuntimeFile: true }, + expected: /Python runtime inventory/, + }, + { + name: 'self-consistent missing stdlib sentinel', + options: { omitStdlibSentinel: true }, + expected: /Python stdlib sentinel/, + }, + { + name: 'self-consistent missing Foundation native sentinel', + options: { omitFoundationNative: true }, + expected: /PyObjC native sentinel/, + }, ]; for (const testCase of cases) { @@ -1477,6 +1774,40 @@ describe('evaOS beta release gate', () => { fs.rmSync(dir, { recursive: true, force: true }); } } + }, 20_000); + + it('accepts a universal Mach-O Python runtime containing the target slice', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-beta-universal-python-')); + try { + const tag = writeMacosArm64ReleaseFixture(dir, { universalPythonRuntime: true }); + expect( + releaseGate.verifyReleaseManifest(dir, tag, { + GITHUB_REPOSITORY: '100yenadmin/evaOS-GUI', + EXPECTED_RELEASE_COMMIT: 'abc123', + EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1', + EVAOS_RELEASE_TARGET_PLATFORMS: 'macos-arm64', + }) + ).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('accepts a code-signed Mach-O runtime whose signature bytes changed after the pre-sign inventory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-beta-signed-python-')); + try { + const tag = writeMacosArm64ReleaseFixture(dir, { signedPythonMutation: true }); + expect( + releaseGate.verifyReleaseManifest(dir, tag, { + GITHUB_REPOSITORY: '100yenadmin/evaOS-GUI', + EXPECTED_RELEASE_COMMIT: 'abc123', + EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1', + EVAOS_RELEASE_TARGET_PLATFORMS: 'macos-arm64', + }) + ).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); it('verifies release manifests without buffering the full macOS ZIP entry list', () => { diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index 195ae7902b..789e9ddd17 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -1,4 +1,13 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,6 +15,20 @@ import { describe, expect, it, vi } from 'vitest'; type PeekabooVersionRunner = (filePath: string, args: string[], options: Record) => string; +type PythonRuntimeMetadata = { + version: string; + sourceSha256: string; + sourceUrl: string; + architecture: string; + packages: Array<{ name: string; version: string; sha256: string }>; + license: string; + licensePath: string; + licenseSha256: string; + inventoryPath: string; + inventorySha256: string; + inventoryEntryCount: number; +}; + const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { bridgeManifest: (input: { sourcePath: string; @@ -24,6 +47,13 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour }; }) => Record; bridgeWrapperScript: () => string; + installPythonRuntime: (sourcePath?: string, resourceDir?: string) => PythonRuntimeMetadata | undefined; + writePythonRuntimeInventory: (resourceDir: string) => { + inventoryPath: string; + inventorySha256: string; + inventoryEntryCount: number; + }; + verifyPythonRuntimeInventory: (resourceDir: string, metadata: PythonRuntimeMetadata) => boolean; isMachOExecutable: (filePath: string) => boolean; installPeekabooLicense: ( sourcePath?: string, @@ -48,28 +78,188 @@ describe('prepareEvaosDesktopBridgeResource', () => { it('isolates the packaged desktop bridge wrapper from ambient Python paths', () => { const wrapper = bridgeResource.bridgeWrapperScript(); + expect(wrapper).toContain('PYTHON_BIN="$BRIDGE_DIR/python/bin/python3"'); + expect(wrapper).toContain('bundled Python runtime is missing'); expect(wrapper).toContain('unset PYTHONHOME'); expect(wrapper).toContain('unset PYTHONUSERBASE'); expect(wrapper).toContain('export PYTHONNOUSERSITE=1'); expect(wrapper).toContain('export PYTHONPATH="$BRIDGE_DIR/src"'); expect(wrapper).toContain('CACHE_ROOT="$HOME/Library/Caches/evaos-desktop-bridge"'); expect(wrapper).toContain('export PYTHONPYCACHEPREFIX="$CACHE_ROOT/pycache"'); - expect(wrapper).toContain('exec "$PYTHON_BIN" -S -m evaos_desktop_bridge.cli "$@"'); + expect(wrapper).toContain('exec "$PYTHON_BIN" -P -m evaos_desktop_bridge.cli "$@"'); expect(wrapper).not.toContain('${PYTHONPATH:+:$PYTHONPATH}'); expect(wrapper).not.toContain('site-packages'); + expect(wrapper).not.toContain('/opt/homebrew/bin/python3'); + expect(wrapper).not.toContain('/usr/local/bin/python3'); + expect(wrapper).not.toContain('/usr/bin/python3'); + expect(wrapper).not.toContain('Install Python 3'); + }); + + it('requires a bundled Python runtime for strict release packaging', () => { + const previousRequireReal = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const previousRuntimeDir = process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR; + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + delete process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR; + + expect(() => bridgeResource.installPythonRuntime()).toThrow(/bundled Python runtime/); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previousRequireReal); + restoreEnv('EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR', previousRuntimeDir); + } + }); + + it('preserves relative Python runtime symlinks for relocation into the installed app', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-python-runtime-')); + const sourceDir = join(dir, 'source'); + const resourceDir = join(dir, 'Bridge'); + const versionedExecutable = join(sourceDir, 'bin', 'python3.12'); + try { + mkdirSync(join(sourceDir, 'bin'), { recursive: true }); + mkdirSync(join(sourceDir, 'include', 'python3.12'), { recursive: true }); + mkdirSync(join(sourceDir, 'lib', 'python3.12'), { recursive: true }); + writeFileSync(versionedExecutable, '#!/bin/sh\necho "Python 3.12.13"\n'); + chmodSync(versionedExecutable, 0o755); + symlinkSync('python3.12', join(sourceDir, 'bin', 'python3')); + writeFileSync(join(sourceDir, 'include', 'python3.12', 'Python.h'), '# Python\n'); + writeFileSync(join(sourceDir, 'include', 'python3.12', 'abstract.h'), '# abstract\n'); + writeFileSync(join(sourceDir, 'lib', 'python3.12', 'LICENSE.txt'), 'Python Software Foundation License\n'); + + const metadata = bridgeResource.installPythonRuntime(sourceDir, resourceDir); + expect(metadata).toMatchObject({ + version: '3.12.13', + inventoryPath: 'python-runtime-inventory.json', + inventoryEntryCount: 10, + inventorySha256: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(readlinkSync(join(resourceDir, 'python', 'bin', 'python3'))).toBe('python3.12'); + + const inventory = JSON.parse(readFileSync(join(resourceDir, 'python-runtime-inventory.json'), 'utf8')) as { + schema: string; + entries: Array<{ path: string; type: string; mode: number; target?: string; sha256?: string }>; + }; + expect(inventory.schema).toBe('evaos-python-runtime-inventory/v1'); + expect(inventory.entries.map(({ path }) => path)).toEqual(inventory.entries.map(({ path }) => path).toSorted()); + expect(inventory.entries).toEqual( + expect.arrayContaining([ + { + path: 'bin', + type: 'directory', + mode: 0o755, + }, + { + path: 'bin/python3', + type: 'symlink', + mode: 0o777, + target: 'python3.12', + }, + expect.objectContaining({ + path: 'bin/python3.12', + type: 'file', + mode: 0o755, + sha256: expect.stringMatching(/^[0-9a-f]{64}$/), + }), + ]) + ); + expect(bridgeResource.verifyPythonRuntimeInventory(resourceDir, metadata!)).toBe(true); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a bundled Python runtime whose directory mode changes after inventory', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-python-runtime-directory-tamper-')); + const sourceDir = join(dir, 'source'); + const resourceDir = join(dir, 'Bridge'); + try { + mkdirSync(join(sourceDir, 'bin'), { recursive: true }); + mkdirSync(join(sourceDir, 'lib', 'python3.12'), { recursive: true }); + writeFileSync(join(sourceDir, 'bin', 'python3.12'), '#!/bin/sh\necho "Python 3.12.13"\n'); + chmodSync(join(sourceDir, 'bin', 'python3.12'), 0o755); + symlinkSync('python3.12', join(sourceDir, 'bin', 'python3')); + writeFileSync(join(sourceDir, 'lib', 'python3.12', 'LICENSE.txt'), 'Python Software Foundation License\n'); + + const metadata = bridgeResource.installPythonRuntime(sourceDir, resourceDir); + expect(metadata).toBeDefined(); + chmodSync(join(resourceDir, 'python', 'lib'), 0o700); + + expect(() => bridgeResource.verifyPythonRuntimeInventory(resourceDir, metadata!)).toThrow(/inventory.*mismatch/i); + } finally { + chmodSync(join(resourceDir, 'python', 'lib'), 0o755); + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a non-traversable bundled Python runtime directory before inventory', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-python-runtime-directory-mode-')); + const resourceDir = join(dir, 'Bridge'); + const blockedDir = join(resourceDir, 'python', 'blocked'); + try { + mkdirSync(blockedDir, { recursive: true }); + chmodSync(blockedDir, 0o600); + + expect(() => bridgeResource.writePythonRuntimeInventory(resourceDir)).toThrow( + /directory.*owner-readable and owner-executable/i + ); + } finally { + chmodSync(blockedDir, 0o755); + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a non-traversable bundled Python runtime root before inventory', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-python-runtime-root-mode-')); + const resourceDir = join(dir, 'Bridge'); + const runtimeDir = join(resourceDir, 'python'); + try { + mkdirSync(runtimeDir, { recursive: true }); + chmodSync(runtimeDir, 0o600); + + expect(() => bridgeResource.writePythonRuntimeInventory(resourceDir)).toThrow( + /directory.*owner-readable and owner-executable/i + ); + } finally { + chmodSync(runtimeDir, 0o755); + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a bundled Python runtime that changes after its inventory is written', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-python-runtime-tamper-')); + const sourceDir = join(dir, 'source'); + const resourceDir = join(dir, 'Bridge'); + const versionedExecutable = join(sourceDir, 'bin', 'python3.12'); + try { + mkdirSync(join(sourceDir, 'bin'), { recursive: true }); + mkdirSync(join(sourceDir, 'lib', 'python3.12'), { recursive: true }); + writeFileSync(versionedExecutable, '#!/bin/sh\necho "Python 3.12.13"\n'); + chmodSync(versionedExecutable, 0o755); + symlinkSync('python3.12', join(sourceDir, 'bin', 'python3')); + writeFileSync(join(sourceDir, 'lib', 'python3.12', 'LICENSE.txt'), 'Python Software Foundation License\n'); + + const metadata = bridgeResource.installPythonRuntime(sourceDir, resourceDir); + expect(metadata).toBeDefined(); + writeFileSync(join(resourceDir, 'python', 'lib', 'python3.12', 'LICENSE.txt'), 'tampered\n'); + + expect(() => bridgeResource.verifyPythonRuntimeInventory(resourceDir, metadata!)).toThrow(/inventory.*mismatch/i); + } finally { + rmSync(dir, { force: true, recursive: true }); + } }); it('detects native Mach-O executables before release packaging trusts a control helper', () => { const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-mach-o-')); - const machO = join(dir, 'peekaboo'); const script = join(dir, 'peekaboo.sh'); try { - writeFileSync(machO, Buffer.from('cffaedfe00000000', 'hex')); - chmodSync(machO, 0o755); + for (const [index, magic] of ['cffaedfe', 'cafebabe', 'bebafeca', 'bfbafeca'].entries()) { + const machO = join(dir, `peekaboo-${index}`); + writeFileSync(machO, Buffer.from(`${magic}00000000`, 'hex')); + chmodSync(machO, 0o755); + expect(bridgeResource.isMachOExecutable(machO)).toBe(true); + } writeFileSync(script, '#!/bin/sh\nexit 0\n'); chmodSync(script, 0o755); - expect(bridgeResource.isMachOExecutable(machO)).toBe(true); expect(bridgeResource.isMachOExecutable(script)).toBe(false); } finally { rmSync(dir, { force: true, recursive: true });