diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81c19e6..fe3110a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,42 @@ concurrency: cancel-in-progress: true jobs: + nvidia-runtime: + name: NVIDIA runtime clean startup regression + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Restore the known incomplete official release runtime + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'GoAgent-0.4.21-win-x64-nvidia-portable.7z' + Invoke-WebRequest 'https://github.com/wimi321/GoAgent/releases/download/v0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z' -OutFile $archive + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne '7b52ab7e87e3356c22f28ec028bb7c91f2e825f9e78ddcb894f0d313ab2ac8a8') { throw 'Regression archive SHA-256 mismatch' } + & 7z x $archive '-o.nvidia-regression' 'resources/data/katago/bin/win32-x64/*' -y + if ($LASTEXITCODE -ne 0) { throw 'Regression runtime extraction failed' } + - name: Reject original missing NVRTC even if runner has CUDA installed + shell: pwsh + run: | + node --input-type=module -e "import assert from 'node:assert/strict'; import {checkWindowsRuntimeDependencies} from './scripts/lib/windows_pe_dependencies.mjs'; await assert.rejects(checkWindowsRuntimeDependencies('.nvidia-regression/resources/data/katago/bin/win32-x64', {nvidia:true}), {message:'Missing bundled dependency: katago.exe -> nvrtc64_120_0.dll'}); console.log('Original package rejected: missing bundled nvrtc64_120_0.dll');" | Tee-Object -FilePath .nvidia-regression/before.log + if ($LASTEXITCODE -ne 0) { throw 'Missing NVRTC regression assertion failed' } + - name: Prepare and execute corrected runtime with clean environment + shell: pwsh + run: | + node scripts/prepare_nvidia_nvrtc.mjs --runtime-dir=.nvidia-regression/resources/data/katago/bin/win32-x64 + if ($LASTEXITCODE -ne 0) { throw 'NVRTC preparation failed' } + node scripts/check_windows_katago_runtime.mjs --runtime-dir=.nvidia-regression/resources/data/katago/bin/win32-x64 --nvidia --evidence=.nvidia-regression/after.json + if ($LASTEXITCODE -ne 0) { throw 'Clean executable version probe failed' } + - uses: actions/upload-artifact@v4 + if: always() + with: + name: nvidia-clean-startup-evidence + path: | + .nvidia-regression/before.log + .nvidia-regression/after.json + website: name: Website build and download checks runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e62d6f..9306796 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -599,6 +599,12 @@ jobs: --flavor=nvidia \ --source-label="${GOAGENT_NVIDIA_KATAGO_ASSET_REPO}@${GOAGENT_NVIDIA_KATAGO_ASSET_RELEASE_TAG}:${GOAGENT_NVIDIA_KATAGO_ASSET_PATTERN}" + - name: Bundle verified official CUDA NVRTC + run: node scripts/prepare_nvidia_nvrtc.mjs + + - name: Check NVIDIA runtime with clean DLL search paths + run: node scripts/check_windows_katago_runtime.mjs --runtime-dir=data/katago/bin/win32-x64 --nvidia + - name: Bundle official KataGo Transformer model (NVIDIA) shell: bash run: | @@ -664,6 +670,13 @@ jobs: - name: Verify packaged Codex App Server run: node scripts/smoke_codex_packaged_runtime.mjs --root=release --expect=win32-x64 + - name: Check final NVIDIA portable and NSIS payload with clean DLL search paths + shell: pwsh + run: | + $version = node -p "JSON.parse(require('fs').readFileSync('package.json','utf8')).version" + node scripts/check_windows_katago_runtime.mjs --nvidia "--artifact=release/$version/GoAgent-$version-win-x64-nvidia-portable.7z" "--artifact=release/$version/GoAgent-$version-win-x64-nvidia.exe" "--evidence=release/$version/nvidia-clean-katago.json" + if ($LASTEXITCODE -ne 0) { throw "Final NVIDIA KataGo runtime check failed" } + - name: Smoke NVIDIA packaged Windows app run: node scripts/smoke_windows_packaged_app.mjs --mode=nvidia --require-katago diff --git a/build/afterPack.cjs b/build/afterPack.cjs index f812ccf..b7302f3 100644 --- a/build/afterPack.cjs +++ b/build/afterPack.cjs @@ -1,7 +1,8 @@ const { createHash } = require('node:crypto') const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync } = require('node:fs') const { join } = require('node:path') -const { Arch } = require('builder-util') +const { pathToFileURL } = require('node:url') +const { Arch } = require('electron-builder') module.exports = async function afterPack(context) { const projectDir = context.packager.projectDir @@ -29,4 +30,10 @@ module.exports = async function afterPack(context) { copyFileSync(manifestPath, join(destinationDir, 'manifest.json')) copyFileSync(join(projectDir, 'data', 'codex', 'LICENSE'), join(destinationDir, 'LICENSE')) if (context.electronPlatformName !== 'win32') chmodSync(destination, 0o755) + + const editionPath = join(resources, 'data', 'katago', 'edition.json') + if (context.electronPlatformName === 'win32' && existsSync(editionPath) && JSON.parse(readFileSync(editionPath, 'utf8')).flavor === 'nvidia') { + const { checkRuntime } = await import(pathToFileURL(join(projectDir, 'scripts', 'check_windows_katago_runtime.mjs')).href) + await checkRuntime(join(resources, 'data', 'katago', 'bin', 'win32-x64'), true) + } } diff --git a/docs/WINDOWS_NVIDIA_RUNTIME.md b/docs/WINDOWS_NVIDIA_RUNTIME.md new file mode 100644 index 0000000..49db6b4 --- /dev/null +++ b/docs/WINDOWS_NVIDIA_RUNTIME.md @@ -0,0 +1,79 @@ +# Windows NVIDIA runtime packaging and clean startup gate + +The v0.4.21 NVIDIA portable release includes KataGo 1.17.1 but omits its direct +`nvrtc64_120_0.dll` dependency. An isolated Windows test returned loader status +`0xC0000135`; adding the official NVRTC pair made `katago.exe version` succeed, +and withdrawing the pair reproduced the failure. The source runtime manifest +lists CUDA Runtime, cuBLAS, nvJitLink and cuDNN, but not NVRTC. + +## Preparation + +After restoring the existing NVIDIA source runtime, run: + +```sh +node scripts/prepare_nvidia_nvrtc.mjs +node scripts/check_windows_katago_runtime.mjs --runtime-dir=data/katago/bin/win32-x64 --nvidia +``` + +Preparation supplements the source bundle with official CUDA NVRTC 12.1.105, +matching its CUDA 12.1 runtime and KataGo's compiled CUDA 12.1.66. The archive, +both DLLs and license have pinned SHA-256 checksums in +`scripts/lib/nvidia_nvrtc.mjs`. An offline archive can be provided with +`--archive=`; it receives the same checks. Generated binaries remain +ignored by Git, and a generated `goagent-nvrtc.json` records provenance. + +Official metadata: +https://developer.download.nvidia.com/compute/cuda/redist/redistrib_12.1.1.json + +Official archive: +https://developer.download.nvidia.com/compute/cuda/redist/cuda_nvrtc/windows-x86_64/cuda_nvrtc-windows-x86_64-12.1.105-archive.zip + +Archive SHA-256: +`7fa294726483b10815305fe1c07c9ceb23e048fc43bb459775eb68dee82d1a8f`. + +## License handling + +The archive's `LICENSE` is byte-identical (SHA-256 +`b9ee12782ca117b887588d99ae1b8c24deb59105e05417fe28ffbd7a55896dd1`) to the +CUDA Runtime license in the source bundle. CUDA Supplement Attachment A lists +the Windows NVIDIA Runtime Compilation Library (`nvrtc.dll` and +`nvrtc-builtins.dll`) as distributable components, subject to the agreement's +distribution conditions. The unmodified license is carried alongside the +existing NVIDIA notices as `licenses/nvidia-runtime/cuda_nvrtc-LICENSE`. +There is no license-acceptance flag, installer or system Toolkit dependency. +Changes to this pinned version or license require maintainer review; this +patch does not accept a new license or publish a distribution. + +## Final artifact verification + +Requires Windows, Node 22+, PowerShell 7 (`pwsh.exe`) and full 7-Zip (`7z`, +including its NSIS handler; `7za` / `7zr` alone cannot read NSIS installers). + +```sh +node scripts/check_windows_katago_runtime.mjs --nvidia --artifact=release/0.4.21/GoAgent-0.4.21-win-x64-nvidia-portable.7z --artifact=release/0.4.21/GoAgent-0.4.21-win-x64-nvidia.exe --evidence=release/0.4.21/nvidia-clean-katago.json +``` + +This extracts each final artifact to a private temporary directory. For NSIS, +it extracts `app-64.7z` without executing the installer. It validates the x64 +PE normal and delay import closure, requiring non-system dependencies to exist +beside the engine, then checks the explicit NVRTC pair and license hashes. +CUDA DLLs cannot be satisfied by the developer's PATH or System32. + +The actual `version` subprocess runs in an empty temporary working directory +with a cleared environment, Windows-only PATH, hidden window and process-local +loader dialog suppression. It must exit zero and report executable KataGo +1.17.1 with the CUDA backend within ten seconds. Embedded binary metadata and +GPU-less smoke exemptions cannot satisfy this gate. The existing NVIDIA app +smoke also invokes this preflight before launching Electron. + +CI restores the hash-pinned official v0.4.21 portable release as a regression +fixture, requires the original missing dependency to be rejected, supplements +NVRTC, and requires clean executable startup. The release workflow checks the +prepared runtime and both final NVIDIA artifacts before upload. The afterPack +hook also checks packaged NVIDIA runtimes, including local builds. The packaging +hook imports `Arch` from its declared `electron-builder` dependency, avoiding +an undeclared transitive import under pnpm's isolated layout. + +This is a loader / executable version check, not GPU model initialization, +analysis throughput, complete LoadLibrary discovery or interactive installation +validation. A real NVIDIA GPU analysis smoke is still needed for release QA. diff --git a/package.json b/package.json index ff485f4..fd571d1 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,8 @@ "check:katago-assets:release": "node scripts/check_katago_assets.mjs --mode=release", "check:no-lite-release-assets": "node scripts/check_no_lite_release_assets.mjs", "check:nvidia-release-assets": "node scripts/check_nvidia_release_assets.mjs", + "prepare:nvidia-nvrtc": "node scripts/prepare_nvidia_nvrtc.mjs", + "check:windows-katago-runtime": "node scripts/check_windows_katago_runtime.mjs", "check:release-notes-i18n": "node scripts/check_release_notes_i18n.mjs", "check:release-quality": "pnpm check:teacher-quality && pnpm check:no-lite-release-assets && pnpm check:nvidia-release-assets && pnpm check:release-notes-i18n && pnpm check:tts-assets && pnpm smoke:tts && pnpm smoke:release-artifacts", "check:p0-beta": "node scripts/p0_beta_acceptance.mjs", diff --git a/scripts/check_nvidia_release_assets.mjs b/scripts/check_nvidia_release_assets.mjs index e8b62e9..533aa1f 100644 --- a/scripts/check_nvidia_release_assets.mjs +++ b/scripts/check_nvidia_release_assets.mjs @@ -18,6 +18,9 @@ const requiredWorkflowFragments = [ 'nvidia_katago_asset_release_tag', 'nvidia_katago_asset_pattern', 'package-nvidia-windows', + 'node scripts/prepare_nvidia_nvrtc.mjs', + 'Check final NVIDIA portable and NSIS payload with clean DLL search paths', + 'node scripts/check_windows_katago_runtime.mjs --nvidia', 'wimi321/lizzieyzy-next', '*windows64.nvidia.portable.zip', '--copy-runtime-dir', diff --git a/scripts/check_windows_katago_runtime.mjs b/scripts/check_windows_katago_runtime.mjs new file mode 100644 index 0000000..55e3b75 --- /dev/null +++ b/scripts/check_windows_katago_runtime.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process' +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { checkWindowsRuntimeDependencies } from './lib/windows_pe_dependencies.mjs' +import { parseKataGoVersion } from './lib/katago_asset_metadata.mjs' + +const execFileAsync = promisify(execFile) +const arg = (name, fallback = '') => process.argv.find((item) => item.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback +const helper = join(dirname(fileURLToPath(import.meta.url)), 'probe_windows_katago.ps1') +export async function checkRuntime(runtime, nvidia) { + const dependencies = await checkWindowsRuntimeDependencies(runtime, { nvidia }) + const { stdout } = await execFileAsync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-File', helper, '-Binary', join(runtime, 'katago.exe')], { windowsHide: true, timeout: 30_000, maxBuffer: 1024 * 1024 }) + const probe = JSON.parse(stdout.trim()) + if (probe.exitCode !== 0) throw new Error(`KataGo clean version probe failed: 0x${probe.exitHex}; ${probe.stderr || probe.stdout || 'no output'}`) + if (parseKataGoVersion(probe.stdout) !== '1.17.1') throw new Error(`Expected executable KataGo v1.17.1: ${probe.stdout}`) + if (nvidia && !/Using CUDA backend/i.test(probe.stdout)) throw new Error(`Expected CUDA backend: ${probe.stdout}`) + return { runtime, ...probe, dependencies } +} + +async function walk(root) { + const files = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const full = join(root, entry.name) + if (entry.isDirectory()) files.push(...await walk(full)) + else files.push(full) + } + return files +} +async function checkArtifact(artifact, nvidia, sevenZip) { + const work = await mkdtemp(join(tmpdir(), 'goagent-katago-artifact-')) + try { + const extract = async (archive, destination) => execFileAsync(sevenZip, ['x', resolve(archive), `-o${destination}`, '-y'], { windowsHide: true, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }) + await extract(artifact, join(work, 'outer')) + let files = await walk(join(work, 'outer')) + if (/\.exe$/i.test(artifact)) { + // Inspect NSIS payload without executing the installer or touching an install. + const payloads = files.filter((p) => /(?:^|[\\/])app-64\.7z$/i.test(p)) + if (payloads.length !== 1) throw new Error(`Expected one NSIS app-64.7z payload: ${artifact}`) + await extract(payloads[0], join(work, 'payload')) + files = await walk(join(work, 'payload')) + } + const engines = files.filter((p) => /[\\/]resources[\\/]data[\\/]katago[\\/]bin[\\/]win32-x64[\\/]katago\.exe$/i.test(p)) + if (engines.length !== 1) throw new Error(`Expected one packaged KataGo runtime: ${artifact}`) + return { artifact: resolve(artifact), ...await checkRuntime(dirname(engines[0]), nvidia) } + } finally { + await rm(work, { recursive: true, force: true }) + } +} +async function main() { + if (process.platform !== 'win32') throw new Error('Clean KataGo runtime check requires Windows') + const nvidia = process.argv.includes('--nvidia') + const results = [] + if (arg('runtime-dir')) results.push(await checkRuntime(resolve(arg('runtime-dir')), nvidia)) + for (const item of process.argv.filter((item) => item.startsWith('--artifact='))) results.push(await checkArtifact(item.slice(11), nvidia, arg('7z', '7z'))) + if (!results.length) throw new Error('Provide --runtime-dir=... or --artifact=... (repeatable)') + const report = JSON.stringify(results, null, 2) + '\n' + if (arg('evidence')) await writeFile(resolve(arg('evidence')), report) + console.log(report) +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/lib/nvidia_nvrtc.mjs b/scripts/lib/nvidia_nvrtc.mjs new file mode 100644 index 0000000..6988ec6 --- /dev/null +++ b/scripts/lib/nvidia_nvrtc.mjs @@ -0,0 +1,14 @@ +// CUDA 12.1 matches the existing cuda12.1-cudnn9 runtime and KataGo build. +// Hashes are from NVIDIA's redistrib_12.1.1.json and the verified archive. +export const NVRTC = Object.freeze({ + version: '12.1.105', + url: 'https://developer.download.nvidia.com/compute/cuda/redist/cuda_nvrtc/windows-x86_64/cuda_nvrtc-windows-x86_64-12.1.105-archive.zip', + archiveSha256: '7fa294726483b10815305fe1c07c9ceb23e048fc43bb459775eb68dee82d1a8f', + archiveRoot: 'cuda_nvrtc-windows-x86_64-12.1.105-archive', + // Identical to the CUDA Runtime license already shipped by the source bundle. + licenseSha256: 'b9ee12782ca117b887588d99ae1b8c24deb59105e05417fe28ffbd7a55896dd1', + files: { + 'nvrtc64_120_0.dll': '62b0975b7fe2940bfb367e257d734ef1a7cd512fcf3b53a5660082c742b244bb', + 'nvrtc-builtins64_121.dll': '0baa6563c0edfa4a36744f0d680398619b596d3167eeb0f29fa7d3f2ec5b6f18' + } +}) diff --git a/scripts/lib/windows_pe_dependencies.mjs b/scripts/lib/windows_pe_dependencies.mjs new file mode 100644 index 0000000..661106e --- /dev/null +++ b/scripts/lib/windows_pe_dependencies.mjs @@ -0,0 +1,80 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { NVRTC } from './nvidia_nvrtc.mjs' + +// Parse normal and delay imports; this deliberately does not claim to discover +// DLL names assembled at runtime via LoadLibrary (NVRTC builtins is explicit). +export function peImports(bytes) { + if (bytes.toString('ascii', 0, 2) !== 'MZ') throw new Error('Not a PE binary') + const pe = bytes.readUInt32LE(0x3c) + if (bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0') throw new Error('Invalid PE signature') + const machine = bytes.readUInt16LE(pe + 4) + if (machine !== 0x8664) throw new Error(`Expected x64 PE, machine=0x${machine.toString(16)}`) + const optional = pe + 24 + if (bytes.readUInt16LE(optional) !== 0x20b) throw new Error('Expected PE32+') + const directory = optional + 112 + const sections = [] + for (let i = 0; i < bytes.readUInt16LE(pe + 6); i++) { + const s = optional + bytes.readUInt16LE(pe + 20) + 40 * i + sections.push({ va: bytes.readUInt32LE(s + 12), size: Math.max(bytes.readUInt32LE(s + 8), bytes.readUInt32LE(s + 16)), raw: bytes.readUInt32LE(s + 20) }) + } + const offset = (rva) => { + const s = sections.find((s) => rva >= s.va && rva < s.va + s.size) + if (!s) throw new Error(`Invalid PE RVA: ${rva}`) + return s.raw + rva - s.va + } + const name = (rva) => { + const start = offset(rva), end = bytes.indexOf(0, start) + if (end < 0) throw new Error('Unterminated PE import name') + const value = bytes.toString('ascii', start, end) + if (!/^[\w.-]+\.dll$/i.test(value)) throw new Error(`Invalid import DLL: ${value}`) + return value + } + const imports = [] + for (const [index, stride, nameOffset] of [[1, 20, 12], [13, 32, 4]]) { + const rva = bytes.readUInt32LE(directory + index * 8) + if (!rva) continue + for (let p = offset(rva); bytes.readUInt32LE(p + nameOffset); p += stride) { + // PE32+ delay descriptors must use RVAs, not truncated 32-bit VAs. + if (index === 13 && !(bytes.readUInt32LE(p) & 1)) throw new Error('Unsupported delay-import VA descriptor') + imports.push(name(bytes.readUInt32LE(p + nameOffset))) + } + } + return imports +} + +// OS/driver dependencies only. Never allow a CUDA Toolkit DLL to be supplied +// by System32 or the developer's PATH; every other import must be app-local. +export function isWindowsSystemDependency(name) { + return /^(?:api-ms-|ext-ms-)/i.test(name) || /^(?:kernel32|user32|gdi32|advapi32|shell32|shlwapi|ole32|oleaut32|ws2_32|crypt32|bcrypt|ncrypt|secur32|normaliz|version|winmm|winhttp|wininet|iphlpapi|psapi|setupapi|cfgmgr32|ntdll|rpcrt4|comdlg32|comctl32|dbghelp|msvcrt|ucrtbase|d3d12|dxgi|nvcuda)\.dll$/i.test(name) +} + +export async function checkWindowsRuntimeDependencies(runtime, { nvidia = false } = {}) { + const names = new Map((await readdir(runtime)).map((name) => [name.toLowerCase(), name])) + const queue = ['katago.exe'], seen = new Set(), imports = [] + while (queue.length) { + const next = queue.shift().toLowerCase() + if (seen.has(next)) continue + seen.add(next) + const actual = names.get(next) + if (!actual) throw new Error(`Missing bundled dependency: ${next}`) + const deps = peImports(await readFile(join(runtime, actual))) + for (const dep of deps) { + imports.push({ from: actual, dll: dep }) + if (names.has(dep.toLowerCase())) queue.push(dep) + else if (!isWindowsSystemDependency(dep)) throw new Error(`Missing bundled dependency: ${actual} -> ${dep}`) + } + } + if (nvidia) { + for (const [name, expected] of Object.entries(NVRTC.files)) { + const actual = names.get(name.toLowerCase()) + if (!actual) throw new Error(`Missing bundled NVRTC component: ${name}`) + const hash = createHash('sha256').update(await readFile(join(runtime, actual))).digest('hex') + if (hash !== expected) throw new Error(`Bundled NVRTC hash mismatch: ${name}`) + } + const license = await readFile(join(runtime, 'licenses/nvidia-runtime/cuda_nvrtc-LICENSE')) + if (createHash('sha256').update(license).digest('hex') !== NVRTC.licenseSha256) throw new Error('NVRTC license hash mismatch') + } + return { checkedBinaries: [...seen], imports } +} diff --git a/scripts/prepare_nvidia_nvrtc.mjs b/scripts/prepare_nvidia_nvrtc.mjs new file mode 100644 index 0000000..f8e454c --- /dev/null +++ b/scripts/prepare_nvidia_nvrtc.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { NVRTC } from './lib/nvidia_nvrtc.mjs' + +const execFileAsync = promisify(execFile) +const arg = (name, fallback = '') => process.argv.find((item) => item.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') +async function verified(path, expected) { + const bytes = await readFile(path) + if (digest(bytes) !== expected) throw new Error(`SHA-256 mismatch: ${path}`) + return bytes +} + +async function main() { + if (process.platform !== 'win32') throw new Error('NVRTC preparation requires Windows') + const runtime = resolve(arg('runtime-dir', 'data/katago/bin/win32-x64')) + await readFile(join(runtime, 'katago.exe')) + const work = await mkdtemp(join(tmpdir(), 'goagent-nvrtc-')) + try { + const archive = join(work, 'nvrtc.zip') + const localArchive = arg('archive') + if (localArchive) await copyFile(resolve(localArchive), archive) + else { + const response = await fetch(NVRTC.url, { signal: AbortSignal.timeout(120_000) }) + if (!response.ok) throw new Error(`NVRTC download failed: HTTP ${response.status}`) + await writeFile(archive, Buffer.from(await response.arrayBuffer())) + } + await verified(archive, NVRTC.archiveSha256) + // Paths are supplied as environment data, never interpolated PowerShell code. + const extracted = join(work, 'extracted') + await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + '$ErrorActionPreference="Stop"; Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::ExtractToDirectory($env:GOAGENT_NVRTC_ARCHIVE, $env:GOAGENT_NVRTC_EXTRACT)'], { + windowsHide: true, timeout: 120_000, + env: { ...process.env, GOAGENT_NVRTC_ARCHIVE: archive, GOAGENT_NVRTC_EXTRACT: extracted } + }) + const source = join(extracted, NVRTC.archiveRoot) + const license = await verified(join(source, 'LICENSE'), NVRTC.licenseSha256) + const files = [] + for (const [name, hash] of Object.entries(NVRTC.files)) { + const bytes = await verified(join(source, 'bin', name), hash) + files.push({ name, hash, bytes }) + } + const licensePath = join(runtime, 'licenses', 'nvidia-runtime', 'cuda_nvrtc-LICENSE') + await mkdir(dirname(licensePath), { recursive: true }) + await writeFile(licensePath, license) + for (const { name, bytes } of files) await writeFile(join(runtime, name), bytes) + await writeFile(join(runtime, 'goagent-nvrtc.json'), JSON.stringify({ + version: NVRTC.version, url: NVRTC.url, archiveSha256: NVRTC.archiveSha256, + license: 'licenses/nvidia-runtime/cuda_nvrtc-LICENSE', licenseSha256: NVRTC.licenseSha256, + files: NVRTC.files + }, null, 2) + '\n') + console.log(`[prepare-nvidia-nvrtc] verified NVRTC ${NVRTC.version}, both DLLs and license copied to ${runtime}`) + } finally { + await rm(work, { recursive: true, force: true }) + } +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/probe_windows_katago.ps1 b/scripts/probe_windows_katago.ps1 new file mode 100644 index 0000000..d7bca69 --- /dev/null +++ b/scripts/probe_windows_katago.ps1 @@ -0,0 +1,53 @@ +param([Parameter(Mandatory=$true)][string]$Binary) +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; +public class GoAgentLoaderMode { + [DllImport("kernel32.dll")] public static extern uint SetErrorMode(uint mode); +} +'@ +$binaryPath = (Resolve-Path -LiteralPath $Binary).Path +$work = Join-Path ([IO.Path]::GetTempPath()) ('goagent-clean-version-' + [Guid]::NewGuid()) +[void](New-Item -ItemType Directory -Path $work) +$oldMode = [GoAgentLoaderMode]::SetErrorMode(0x8003) +try { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $binaryPath + $info.Arguments = 'version' + $info.WorkingDirectory = $work + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.EnvironmentVariables.Clear() + $info.EnvironmentVariables['SystemRoot'] = $env:SystemRoot + $info.EnvironmentVariables['WINDIR'] = $env:SystemRoot + $info.EnvironmentVariables['PATH'] = (Join-Path $env:SystemRoot 'System32') + ';' + $env:SystemRoot + $info.EnvironmentVariables['TEMP'] = $work + $info.EnvironmentVariables['TMP'] = $work + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + [void]$process.Start() + # Read asynchronously so an unexpected verbose binary cannot fill a pipe. + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { + $process.Kill() + $process.WaitForExit() + throw 'KataGo clean version probe timed out (10 seconds)' + } + [pscustomobject]@{ + binary = $binaryPath + exitCode = $process.ExitCode + exitHex = ('{0:X8}' -f $process.ExitCode) + stdout = $stdout.Result + stderr = $stderr.Result + cleanEnvironment = $true + } | ConvertTo-Json -Compress +} finally { + [void][GoAgentLoaderMode]::SetErrorMode($oldMode) + $resolvedWork = [IO.Path]::GetFullPath($work) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $resolvedWork.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unexpected probe cleanup directory' } + Remove-Item -LiteralPath $resolvedWork -Recurse -Force +} diff --git a/scripts/smoke_windows_packaged_app.mjs b/scripts/smoke_windows_packaged_app.mjs index ae174dd..733f9e5 100644 --- a/scripts/smoke_windows_packaged_app.mjs +++ b/scripts/smoke_windows_packaged_app.mjs @@ -6,6 +6,7 @@ import { createServer } from 'node:http' import { setTimeout as delay } from 'node:timers/promises' import process from 'node:process' import { isExpectedNvidiaRunnerLimitation } from './lib/windows_packaged_smoke_policy.mjs' +import { checkRuntime } from './check_windows_katago_runtime.mjs' const root = resolve(process.cwd()) const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) @@ -136,6 +137,9 @@ async function main() { const exe = resolve(arg('exe', defaultExePath())) const requireKatago = hasFlag('require-katago') || (mode !== 'lite' && !hasFlag('allow-missing-katago')) if (!existsSync(exe)) fail(`Missing packaged executable: ${exe}`) + // An embedded version string / GPU-less runner exemption must never hide a + // loader failure such as missing NVRTC. This gate executes the actual binary. + if (mode === 'nvidia') await checkRuntime(join(dirname(exe), 'resources/data/katago/bin/win32-x64'), true) const port = Number(arg('port', '')) || await freePort() const smokeHome = resolve(arg('home', join(root, '.tmp', 'windows-packaged-smoke', `${Date.now()}-${mode}`))) diff --git a/tests/windows-katago-dependencies.test.mjs b/tests/windows-katago-dependencies.test.mjs new file mode 100644 index 0000000..a215f33 --- /dev/null +++ b/tests/windows-katago-dependencies.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import test from 'node:test' +import { checkWindowsRuntimeDependencies, isWindowsSystemDependency, peImports } from '../scripts/lib/windows_pe_dependencies.mjs' + +function fixture({ imports = [], delays = [] } = {}) { + const b = Buffer.alloc(4096) + b.write('MZ'); b.writeUInt32LE(0x80, 0x3c); b.write('PE\0\0', 0x80) + b.writeUInt16LE(0x8664, 0x84); b.writeUInt16LE(1, 0x86); b.writeUInt16LE(240, 0x94) + b.writeUInt16LE(0x20b, 0x98) + const section = 0x98 + 240 + b.writeUInt32LE(3072, section + 8); b.writeUInt32LE(0x1000, section + 12) + b.writeUInt32LE(3072, section + 16); b.writeUInt32LE(0x400, section + 20) + let strings = 0x900 + const build = (names, index, raw, stride, nameOffset) => { + if (!names.length) return + b.writeUInt32LE(0x1000 + raw - 0x400, 0x98 + 112 + index * 8) + names.forEach((name, i) => { + if (index === 13) b.writeUInt32LE(1, raw + stride * i) + b.writeUInt32LE(0x1000 + strings - 0x400, raw + stride * i + nameOffset) + b.write(name, strings); strings += name.length + 1 + }) + } + build(imports, 1, 0x400, 20, 12); build(delays, 13, 0x600, 32, 4) + return b +} +async function temp(t) { + const root = await mkdtemp(join(tmpdir(), 'goagent-pe-test-')) + t.after(() => rm(root, { recursive: true, force: true })) + return root +} +test('reads normal and delay imports, rejecting malformed or wrong-architecture PE', () => { + assert.deepEqual(peImports(fixture({ imports: ['KERNEL32.dll'], delays: ['nvrtc64_120_0.dll'] })), ['KERNEL32.dll', 'nvrtc64_120_0.dll']) + const x86 = fixture(); x86.writeUInt16LE(0x14c, 0x84) + assert.throws(() => peImports(x86), /Expected x64/) + assert.throws(() => peImports(Buffer.from('not a PE')), /Not a PE/) +}) +test('rejects a missing transitive CUDA dependency instead of consulting host PATH', async (t) => { + const root = await temp(t) + await writeFile(join(root, 'katago.exe'), fixture({ imports: ['cudnn64_9.dll'] })) + await writeFile(join(root, 'cudnn64_9.dll'), fixture({ delays: ['nvrtc64_120_0.dll'] })) + assert.equal(isWindowsSystemDependency('nvrtc64_120_0.dll'), false) + assert.equal(isWindowsSystemDependency('cublas64_12.dll'), false) + await assert.rejects(checkWindowsRuntimeDependencies(root), /cudnn64_9.dll -> nvrtc64_120_0.dll/) +}) +test('resolves case-insensitive local import cycles and OS/API-set dependencies', async (t) => { + const root = await temp(t) + await writeFile(join(root, 'katago.exe'), fixture({ imports: ['HELPER.dll', 'api-ms-win-crt-runtime-l1-1-0.dll'] })) + await writeFile(join(root, 'helper.DLL'), fixture({ imports: ['katago.exe.dll', 'KERNEL32.dll'] })) + await writeFile(join(root, 'katago.exe.dll'), fixture({ imports: ['helper.dll'] })) + const report = await checkWindowsRuntimeDependencies(root) + assert.equal(report.checkedBinaries.length, 3) +}) +test('requires dynamic NVRTC builtins even when the PE import graph is satisfied', async (t) => { + const root = await temp(t) + await writeFile(join(root, 'katago.exe'), fixture()) + await assert.rejects(checkWindowsRuntimeDependencies(root, { nvidia: true }), /Missing bundled NVRTC component/) + await writeFile(join(root, 'nvrtc64_120_0.dll'), Buffer.from('wrong component')) + await assert.rejects(checkWindowsRuntimeDependencies(root, { nvidia: true }), /Bundled NVRTC hash mismatch/) +}) +test('preparation rejects a tampered offline archive before copying any DLL', { skip: process.platform !== 'win32' }, async (t) => { + const root = await temp(t), runtime = join(root, 'runtime'), archive = join(root, 'bad.zip') + await mkdir(runtime) + await writeFile(join(runtime, 'katago.exe'), fixture()) + await writeFile(archive, 'tampered archive') + await assert.rejects(promisify(execFile)(process.execPath, ['scripts/prepare_nvidia_nvrtc.mjs', `--runtime-dir=${runtime}`, `--archive=${archive}`]), /SHA-256 mismatch/) + await assert.rejects(readFile(join(runtime, 'nvrtc64_120_0.dll')), { code: 'ENOENT' }) +})