244 lines
8.1 KiB
JavaScript
244 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Post-build checks for bare-os seeder/booter standalones (CI + local).
|
||
*
|
||
* Verifies each out/bare-os-{product}-{host}/ binary exists, records size,
|
||
* rejects oversized Booter/Seeder builds (CodeRange OOM risk), and smoke-runs
|
||
* the native-host booter when present.
|
||
*
|
||
* Usage:
|
||
* node scripts/verify-standalone-build.cjs
|
||
* BARE_OS_HOSTS=linux-x64,darwin-arm64 node scripts/verify-standalone-build.cjs
|
||
*/
|
||
'use strict'
|
||
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const { spawnSync } = require('child_process')
|
||
const { ALL_64, parseHostList, hostSupportsQvacNative } = require('./hosts.cjs')
|
||
|
||
const root = path.resolve(__dirname, '..')
|
||
const hosts = parseHostList(process.env.BARE_OS_HOSTS, ALL_64)
|
||
const nativeHost = `${process.platform}-${process.arch}`
|
||
|
||
/**
|
||
* Soft ceilings (bytes). CI fails above these — keeps pear-runtime / dup natives out.
|
||
* Booter hosts with in-process QVAC (`@qvac/llm-llamacpp`) are much larger (~250–300+ MiB);
|
||
* win32-arm64 stubs QVAC and stays on the lean ceiling.
|
||
*/
|
||
const MAX_BYTES = {
|
||
seeder: {
|
||
'darwin-arm64': 110 * 1024 * 1024,
|
||
'darwin-x64': 120 * 1024 * 1024,
|
||
'linux-x64': 145 * 1024 * 1024,
|
||
'linux-arm64': 145 * 1024 * 1024,
|
||
'win32-x64': 150 * 1024 * 1024,
|
||
'win32-arm64': 150 * 1024 * 1024,
|
||
},
|
||
booter: {
|
||
'darwin-arm64': 110 * 1024 * 1024,
|
||
'darwin-x64': 120 * 1024 * 1024,
|
||
'linux-x64': 145 * 1024 * 1024,
|
||
'linux-arm64': 145 * 1024 * 1024,
|
||
'win32-x64': 150 * 1024 * 1024,
|
||
'win32-arm64': 150 * 1024 * 1024,
|
||
},
|
||
}
|
||
|
||
/** Ceiling when QVAC natives are packed into the booter (~294 MiB seen on linux-x64 CI). */
|
||
const MAX_BYTES_BOOTER_WITH_QVAC = 360 * 1024 * 1024
|
||
|
||
/**
|
||
* @param {string} product
|
||
* @param {string} host
|
||
* @param {string} file
|
||
*/
|
||
function maxBytesFor(product, host, file) {
|
||
const lean = MAX_BYTES[product]?.[host] || 160 * 1024 * 1024
|
||
if (product !== 'booter') return lean
|
||
|
||
const infoPath = path.join(path.dirname(file), 'build-info.json')
|
||
let stubbed = null
|
||
try {
|
||
const info = JSON.parse(fs.readFileSync(infoPath, 'utf8'))
|
||
if (info.skipQvac === true || info.qvacStubbed === true) stubbed = true
|
||
else if (info.skipQvac === false || info.qvacStubbed === false) stubbed = false
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
|
||
if (stubbed === true) return lean
|
||
if (stubbed === false || hostSupportsQvacNative(host)) {
|
||
return Math.max(lean, MAX_BYTES_BOOTER_WITH_QVAC)
|
||
}
|
||
return lean
|
||
}
|
||
|
||
function binName(product, host) {
|
||
const name = `bare-os-${product}`
|
||
return host.startsWith('win32') ? `${name}.exe` : name
|
||
}
|
||
|
||
function findBinary(product, host) {
|
||
const dir = path.join(root, 'out', `bare-os-${product}-${host}`)
|
||
const preferred = path.join(dir, binName(product, host))
|
||
if (fs.existsSync(preferred)) return preferred
|
||
if (!fs.existsSync(dir)) return null
|
||
const walk = (d) => {
|
||
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
||
const p = path.join(d, ent.name)
|
||
if (ent.isDirectory()) {
|
||
const f = walk(p)
|
||
if (f) return f
|
||
} else if (ent.name === binName(product, host) || ent.name === `bare-os-${product}`) {
|
||
return p
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
return walk(dir)
|
||
}
|
||
|
||
function assertSize(product, host, file) {
|
||
const st = fs.statSync(file)
|
||
const mb = (st.size / 1024 / 1024).toFixed(1)
|
||
const max = maxBytesFor(product, host, file)
|
||
console.log(`[verify] ${product}/${host}: ${mb} MiB (${file})`)
|
||
if (st.size > max) {
|
||
throw new Error(
|
||
`${product}/${host} binary is ${mb} MiB — exceeds ${(max / 1024 / 1024).toFixed(0)} MiB ceiling (likely packed pear-runtime, duplicate natives, or unexpected QVAC growth)`
|
||
)
|
||
}
|
||
return st.size
|
||
}
|
||
|
||
/** Darwin JIT/library-validation entitlements required under Hardened Runtime. */
|
||
const REQUIRED_ENTITLEMENTS = [
|
||
'com.apple.security.cs.allow-jit',
|
||
'com.apple.security.cs.allow-unsigned-executable-memory',
|
||
'com.apple.security.cs.disable-library-validation',
|
||
]
|
||
|
||
function assertDarwinEntitlements(product, host, file) {
|
||
if (!host.startsWith('darwin') || process.platform !== 'darwin') return
|
||
const r = spawnSync('codesign', ['-d', '--entitlements', '-', file], {
|
||
encoding: 'utf8',
|
||
})
|
||
if (r.status !== 0) {
|
||
throw new Error(
|
||
`[verify] ${product}/${host}: codesign -d --entitlements failed: ${r.stderr || r.stdout}`
|
||
)
|
||
}
|
||
const out = r.stdout || ''
|
||
const missing = REQUIRED_ENTITLEMENTS.filter((key) => !out.includes(key))
|
||
if (missing.length) {
|
||
throw new Error(
|
||
`[verify] ${product}/${host}: missing macOS entitlements: ${missing.join(', ')}`
|
||
)
|
||
}
|
||
console.log(`[verify] ${product}/${host}: darwin entitlements ok`)
|
||
}
|
||
|
||
function assertMarkers(file) {
|
||
// Sample start of file as latin1 for cheap string checks on embedded bundle
|
||
const fd = fs.openSync(file, 'r')
|
||
try {
|
||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 48 * 1024 * 1024))
|
||
fs.readSync(fd, buf, 0, buf.length, Math.max(0, fs.fstatSync(fd).size - buf.length))
|
||
const s = buf.toString('latin1')
|
||
if (!s.includes('standalone.mjs') && !s.includes('bare-os-host-flags') && !s.includes('--datadir')) {
|
||
// binary may compress/strip; require at least one app marker
|
||
if (!s.includes('bare-os-booter') && !s.includes('bare-os-seeder')) {
|
||
throw new Error(`${file}: missing bare-os markers in embed`)
|
||
}
|
||
}
|
||
// pear-runtime stub path or real module both contain the string; prefer stub class marker
|
||
if (s.includes('PearRuntimeStub') || s.includes('pear-runtime not packed')) {
|
||
console.log('[verify] pear-runtime stub present (OTA not embedded)')
|
||
}
|
||
if (file.includes('bare-os-booter') && !s.includes('bare-os-discord-gateway-ws')) {
|
||
throw new Error(
|
||
`${file}: packed booter missing Discord WHATWG gateway WS bootstrap (bare-os-discord-gateway-ws)`
|
||
)
|
||
}
|
||
} finally {
|
||
fs.closeSync(fd)
|
||
}
|
||
}
|
||
|
||
function smokeNativeBooter(file) {
|
||
if (process.platform === 'win32') {
|
||
console.log('[verify] skip smoke on win32')
|
||
return
|
||
}
|
||
const data = path.join(
|
||
require('os').tmpdir(),
|
||
`bare-os-verify-${process.pid}-${Date.now()}`
|
||
)
|
||
fs.mkdirSync(data, { recursive: true })
|
||
const env = {
|
||
...process.env,
|
||
BARE_OS_HOST_DATA: data,
|
||
BARE_OS_BOOT_TIMEOUT_MS: '1500',
|
||
BARE_OS_OTA_DISABLE: '1',
|
||
}
|
||
console.log(`[verify] smoke: ${file} --datadir ${data}`)
|
||
const r = spawnSync(file, ['--datadir', data, '--no-updates'], {
|
||
env,
|
||
encoding: 'utf8',
|
||
timeout: 20_000,
|
||
})
|
||
const out = `${r.stdout || ''}${r.stderr || ''}`
|
||
if (/Failed to reserve virtual memory for CodeRange/i.test(out)) {
|
||
throw new Error('smoke failed: CodeRange OOM')
|
||
}
|
||
if (/MODULE_NOT_FOUND.*bare-os-booter/i.test(out)) {
|
||
throw new Error(`smoke failed: module missing\n${out.slice(0, 800)}`)
|
||
}
|
||
// Expected without a seeder peer
|
||
if (!/No swarm peers|swarm peers within/i.test(out) && r.status === 0 && !out.trim()) {
|
||
console.warn('[verify] WARN: smoke produced little output; status', r.status)
|
||
} else {
|
||
console.log('[verify] smoke ok (exit', r.status, ')')
|
||
}
|
||
// datadir must have been applied
|
||
const store = path.join(data, 'corestore', 'booter')
|
||
if (!fs.existsSync(store)) {
|
||
throw new Error(`smoke: --datadir did not create ${store}`)
|
||
}
|
||
console.log('[verify] --datadir ok →', store)
|
||
}
|
||
|
||
function main() {
|
||
const argProducts = process.argv.slice(2).filter((a) => a === 'seeder' || a === 'booter')
|
||
const products = argProducts.length ? argProducts : ['seeder', 'booter']
|
||
let checked = 0
|
||
for (const product of products) {
|
||
for (const host of hosts) {
|
||
const file = findBinary(product, host)
|
||
if (!file) {
|
||
console.warn(`[verify] WARN: missing ${product}/${host}`)
|
||
continue
|
||
}
|
||
assertSize(product, host, file)
|
||
assertMarkers(file)
|
||
assertDarwinEntitlements(product, host, file)
|
||
checked++
|
||
if (product === 'booter' && host === nativeHost) {
|
||
smokeNativeBooter(file)
|
||
}
|
||
}
|
||
}
|
||
if (checked === 0) {
|
||
throw new Error('no binaries found under out/ — run make first')
|
||
}
|
||
console.log(`[verify] passed (${checked} binaries)`)
|
||
}
|
||
|
||
try {
|
||
main()
|
||
} catch (err) {
|
||
console.error('[verify] FAIL:', err.message || err)
|
||
process.exit(1)
|
||
}
|