diff --git a/scripts/check-packaged-runtime.js b/scripts/check-packaged-runtime.js index 76ea57d..68b441f 100644 --- a/scripts/check-packaged-runtime.js +++ b/scripts/check-packaged-runtime.js @@ -73,6 +73,7 @@ function run(command, args, options = {}) { env: options.env || process.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + ...(options.maxBuffer ? { maxBuffer: options.maxBuffer } : {}), }); if (result.error || result.status !== 0) { fail(`${command} ${args.join(' ')} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim()}`); @@ -231,7 +232,10 @@ function inspectPackage(appPath) { fail(`Python version ${versionOutput} does not match manifest ${normalized.python.version}`); } run(pythonPath, ['-c', [ - 'import fastapi, uvicorn, pydantic, requests, torch, whisper', + 'import fastapi, uvicorn, pydantic, requests, numpy as np, torch, whisper', + 'a = np.ones((32, 32)); b = a @ a; assert float(b[0, 0]) == 32.0', + 'from numba import njit; f = njit(lambda x: x + 1); assert f(2) == 3', + 'x = torch.tensor([1.0, 2.0]); y = x * 2; assert y.tolist() == [2.0, 4.0]', 'from importlib.metadata import version, distributions; assert version("openai-whisper") == "20250625"', 'import main, sys; forbidden = ("whisperx", "nemo", "pyannote.audio", "df", "mediapipe", "cv2", "openai", "anthropic", "moviepy"); assert all(name not in sys.modules for name in forbidden)', 'installed = {(d.metadata.get("Name") or "").lower() for d in distributions()}; assert not installed.intersection({"whisperx", "nemo-toolkit", "pyannote.audio", "deepfilternet", "mediapipe", "opencv-python", "openai", "anthropic", "moviepy"})', @@ -241,6 +245,20 @@ function inspectPackage(appPath) { if (!/arm64/i.test(architecture)) fail(`bundled Python architecture is not arm64: ${architecture.trim()}`); const forbidden = findForbiddenResourceEntry(resourcesPath); if (forbidden) fail(`packaged Resources contains forbidden developer/cache data: ${forbidden}`); + for (const relativePath of [ + 'llvmlite/binding/libllvmlite.dylib', + 'numpy/.dylibs/libopenblas64_.0.dylib', + 'torch/lib/libtorch_cpu.dylib', + 'torch/lib/libtorch_python.dylib', + ]) { + const nativePath = path.join(corePackRoot, ...relativePath.split('/')); + if (!fs.existsSync(nativePath)) fail(`packaged native runtime target is missing: ${relativePath}`); + run('/usr/bin/codesign', ['--verify', '--strict', nativePath]); + const symbols = run('/usr/bin/nm', ['-m', nativePath], { maxBuffer: 16 * 1024 * 1024 }).stdout || ''; + if (/\bnon-external\b/i.test(symbols)) { + fail(`packaged native runtime target still contains local symbols after strip -x: ${relativePath}`); + } + } for (const relativePath of [ 'include', 'lib/python3.11/site-packages', @@ -275,7 +293,7 @@ function inspectPackage(appPath) { console.log(`Core constraints: ${constraints.source}, SHA-256 ${constraints.sha256}`); console.log(`Core dependency lock: ${dependencyLock.source}, SHA-256 ${dependencyLock.sha256}, requireHashes=true, buildIsolation=false`); console.log(`Model manifest: ${modelManifest.id}, ${modelManifest.revision}, ${modelManifest.expectedBytes} bytes`); - console.log('Core imports: fastapi, uvicorn, pydantic, requests, torch, whisper==20250625'); + console.log('Core execution probe: NumPy/OpenBLAS, Numba/llvmlite, Torch, Whisper==20250625'); console.log('Optional packages bundled: No'); console.log('Backend import: passed'); console.log('Model weight in Resources: No'); diff --git a/scripts/prepare-python-runtime.js b/scripts/prepare-python-runtime.js index f005a54..3497009 100644 --- a/scripts/prepare-python-runtime.js +++ b/scripts/prepare-python-runtime.js @@ -59,6 +59,12 @@ const PORTABLE_RUNTIME_BUILD_ONLY_PATHS = [ 'bin/python3-config', 'bin/python3.11-config', ]; +const NATIVE_RUNTIME_STRIP_TARGETS = [ + 'llvmlite/binding/libllvmlite.dylib', + 'numpy/.dylibs/libopenblas64_.0.dylib', + 'torch/lib/libtorch_cpu.dylib', + 'torch/lib/libtorch_python.dylib', +]; function getOption(name) { const index = process.argv.indexOf(name); @@ -272,7 +278,9 @@ function verifyCore(interpreterPath, relativeInterpreter) { SCRIPTCUT_RUNTIME_MANIFEST_SCHEMA: 'scriptcut.runtime.v1', }); const probe = [ - 'import fastapi, uvicorn, pydantic, requests, torch, whisper', + 'import fastapi, uvicorn, pydantic, requests, numpy as np, torch, whisper', + 'a = np.ones((32, 32)); b = a @ a; assert float(b[0, 0]) == 32.0', + 'from numba import njit; f = njit(lambda x: x + 1); assert f(2) == 3', 'x = torch.tensor([1.0, 2.0]); y = x * 2; assert y.tolist() == [2.0, 4.0]', 'from importlib.metadata import version; assert version("openai-whisper") == "20250625"', 'import importlib.util, sys; import main; forbidden = ("whisperx", "nemo", "pyannote.audio", "df", "mediapipe", "cv2", "openai", "anthropic", "moviepy"); assert all(name not in sys.modules for name in forbidden)', @@ -445,6 +453,28 @@ function prunePortableRuntimeBuildTooling() { return removed; } +function stripNativeRuntimeSymbols() { + const optimized = { files: 0, bytesBefore: 0, bytesAfter: 0 }; + for (const relativePath of NATIVE_RUNTIME_STRIP_TARGETS) { + const filePath = path.join(corePackRoot, ...relativePath.split('/')); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + fail(`expected native strip target is missing: ${relativePath}`); + } + const before = fs.statSync(filePath).size; + run('/usr/bin/strip', ['-x', filePath]); + // strip invalidates the wheel's Mach-O signature. Restore an ad-hoc staging + // signature so local probes can load it; electron-builder signs it again. + run('/usr/bin/codesign', ['--force', '--sign', '-', filePath]); + run('/usr/bin/codesign', ['--verify', '--strict', filePath]); + const after = fs.statSync(filePath).size; + if (after >= before) fail(`native symbol stripping did not reduce ${relativePath}: ${before} -> ${after}`); + optimized.files += 1; + optimized.bytesBefore += before; + optimized.bytesAfter += after; + } + return optimized; +} + async function main() { verifyTarget(); fs.mkdirSync(buildRoot, { recursive: true }); @@ -460,10 +490,12 @@ async function main() { const headersRemoved = pruneNonRuntimeCoreArtifacts(); const testsRemoved = prunePackageTestArtifacts(corePackRoot); const portableBuildToolingRemoved = prunePortableRuntimeBuildTooling(); + const nativeSymbols = stripNativeRuntimeSymbols(); const coreAfter = directoryStats(corePackRoot); console.log(`Torch headers removed: ${headersRemoved.files}`); console.log(`Package test artifacts removed: ${testsRemoved.directories} directories, ${testsRemoved.files} files, ${testsRemoved.bytes} bytes`); console.log(`Portable Python build-only tooling removed: ${portableBuildToolingRemoved.paths} paths, ${portableBuildToolingRemoved.files} files, ${portableBuildToolingRemoved.bytes} bytes`); + console.log(`Native runtime symbols stripped: ${nativeSymbols.files} files, ${nativeSymbols.bytesBefore - nativeSymbols.bytesAfter} bytes removed`); console.log(`Core pack files after pruning: ${coreAfter.files}`); console.log(`Core pack bytes after pruning: ${coreAfter.bytes}`); verifyCore(interpreterPath, relativeInterpreter); diff --git a/scripts/smoke-runtime-artifacts.js b/scripts/smoke-runtime-artifacts.js index ace151b..44f9a12 100644 --- a/scripts/smoke-runtime-artifacts.js +++ b/scripts/smoke-runtime-artifacts.js @@ -33,10 +33,12 @@ for (const constraint of constraintPins) { assert.ok(lockLines.some((line) => line.startsWith(`${normalized} --hash=sha256:`)), `core lock is missing artifact hash for: ${constraint}`); } const runtimePreparation = fs.readFileSync(path.join(__dirname, 'prepare-python-runtime.js'), 'utf8'); +const packagedRuntimeCheck = fs.readFileSync(path.join(__dirname, 'check-packaged-runtime.js'), 'utf8'); assert.match(runtimePreparation, /function\s+pruneNonRuntimeCoreArtifacts\s*\(/); assert.match(runtimePreparation, /function\s+prunePythonBytecode\s*\(/); assert.match(runtimePreparation, /function\s+prunePackageTestArtifacts\s*\(/); assert.match(runtimePreparation, /function\s+prunePortableRuntimeBuildTooling\s*\(/); +assert.match(runtimePreparation, /function\s+stripNativeRuntimeSymbols\s*\(/); assert.match(runtimePreparation, /profile:\s*['"]execution-only['"]/); assert.match(runtimePreparation, /buildToolingBundled:\s*false/); assert.match(runtimePreparation, /guiToolkitBundled:\s*false/); @@ -51,6 +53,24 @@ for (const expectedBuildOnlyPath of [ ]) { assert.ok(runtimePreparation.includes(`'${expectedBuildOnlyPath}'`), `portable runtime prune is missing ${expectedBuildOnlyPath}`); } +for (const expectedNativeStripTarget of [ + 'llvmlite/binding/libllvmlite.dylib', + 'numpy/.dylibs/libopenblas64_.0.dylib', + 'torch/lib/libtorch_cpu.dylib', + 'torch/lib/libtorch_python.dylib', +]) { + assert.ok(runtimePreparation.includes(`'${expectedNativeStripTarget}'`), `native strip target is missing ${expectedNativeStripTarget}`); + assert.ok(packagedRuntimeCheck.includes(`'${expectedNativeStripTarget}'`), `packaged runtime verification is missing ${expectedNativeStripTarget}`); +} +assert.match(runtimePreparation, /['"]\/usr\/bin\/strip['"],\s*\[['"]-x['"]/); +assert.match(runtimePreparation, /['"]\/usr\/bin\/codesign['"],\s*\[['"]--force['"],\s*['"]--sign['"],\s*['"]-['"]/); +assert.match(runtimePreparation, /from numba import njit; f = njit/); +assert.match(packagedRuntimeCheck, /from numba import njit; f = njit/); +assert.match(packagedRuntimeCheck, /np\.ones\(\(32, 32\)\)/); +assert.match(packagedRuntimeCheck, /torch\.tensor\(\[1\.0, 2\.0\]\)/); +assert.match(packagedRuntimeCheck, /['"]\/usr\/bin\/codesign['"],\s*\[['"]--verify['"],\s*['"]--strict['"]/); +assert.match(packagedRuntimeCheck, /['"]\/usr\/bin\/nm['"],\s*\[['"]-m['"]/); +assert.match(packagedRuntimeCheck, /\\bnon-external\\b/); assert.match(runtimePreparation, /['"]--no-compile['"]/); assert.match(runtimePreparation, /['"]--no-build-isolation['"]/); assert.match(runtimePreparation, /['"]--require-hashes['"]/);