mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
Two gaps in the ReadProcessMemory check, both about what it does not see. It only ever looked at node_modules/@vscode/windows-process-tree. A relay host has no node_modules of ours: it loads ./windows-process-tree.node staged beside the bundle. The relay build asserts the symbol on the artifact it produces, but a bundle and the addon beside it redeploy independently, so a host that has not taken a new bundle keeps whatever binary is already there -- and the published prebuilt is node-addon-api, so it binds cleanly and then walks every process's address space. loadWindowsProcessTree now checks that file too and refuses it, falling back to the CIM scan: slower, but not the thing an EDR quarantines a host for. The predicate is duplicated rather than imported, because the config-script copy is install-time tooling that drags in node-gyp and child_process, and this module is bundled into the app and the relay. And it returned false for a binary that is not there. All three callers happened to be safe, but the name read as a safety predicate, so a future caller would take a missing binary as verified. inspectWindowsProcessTreeAddon() now answers clean/unpatched/missing over an explicit binary path -- which is also what lets the relay's staged addon be checked at all -- and each caller states which state it acts on. Both are covered by cases that fail against the old code: without the load-time check the unpatched staged addon is bound and the CIM fallback never runs, and with 'missing' folded back into 'clean' the absence case fails outright.
472 lines
15 KiB
JavaScript
472 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { createRequire } from 'node:module'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { release } from 'node:os'
|
|
import { basename, dirname, resolve } from 'node:path'
|
|
import {
|
|
ensureWindowsProcessTreeCommandLinePatch,
|
|
inspectWindowsProcessTreeAddon,
|
|
stageWindowsProcessTreeNodeAddonApiHeaders,
|
|
windowsProcessTreeAddonPath
|
|
} from './windows-process-tree-gyp-rebuild.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
|
const scriptPath = import.meta.filename
|
|
const projectDir = resolve(import.meta.dirname, '../..')
|
|
const runtime = readRuntimeArg()
|
|
|
|
const NATIVE_MODULES = [
|
|
'node-pty',
|
|
...(process.platform === 'win32'
|
|
? ['windows-native-registry', '@vscode/windows-process-tree']
|
|
: [])
|
|
]
|
|
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
|
const CHILD_CHECK_FLAG = '--check-only'
|
|
|
|
if (process.argv.includes(CHILD_CHECK_FLAG)) {
|
|
const failures = collectNativeModuleFailures()
|
|
if (failures.length > 0) {
|
|
for (const failure of failures) {
|
|
console.error(`${failure.moduleName}: ${failure.message}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runtime === 'node') {
|
|
ensureNodeRuntime()
|
|
} else if (runtime === 'electron') {
|
|
ensureElectronRuntime()
|
|
} else {
|
|
console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron')
|
|
process.exit(2)
|
|
}
|
|
|
|
function readRuntimeArg() {
|
|
const inline = process.argv.find((arg) => arg.startsWith('--runtime='))
|
|
if (inline) {
|
|
return inline.slice('--runtime='.length)
|
|
}
|
|
|
|
const runtimeIndex = process.argv.indexOf('--runtime')
|
|
if (runtimeIndex !== -1) {
|
|
return process.argv[runtimeIndex + 1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function ensureNodeRuntime() {
|
|
const initial = runNodeCheck()
|
|
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
|
if (initial.ok && !patchedNodePtyRebuildReason) {
|
|
return
|
|
}
|
|
|
|
if (patchedNodePtyRebuildReason) {
|
|
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
|
|
if (!initial.ok) {
|
|
printCheckError(initial)
|
|
}
|
|
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
|
const rebuildModules = [
|
|
'node-pty',
|
|
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
|
|
]
|
|
rebuildNodeRuntimeModules(rebuildModules)
|
|
verifyNodeRuntimeAfterRebuild()
|
|
return
|
|
}
|
|
|
|
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
|
console.warn(
|
|
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
|
)
|
|
printCheckError(initial)
|
|
rebuildNodeRuntimeModules(failedModules)
|
|
verifyNodeRuntimeAfterRebuild()
|
|
}
|
|
|
|
function verifyNodeRuntimeAfterRebuild() {
|
|
const final = runNodeCheck()
|
|
if (!final.ok) {
|
|
console.error(
|
|
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
|
|
)
|
|
printCheckError(final)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function ensureElectronRuntime() {
|
|
const initial = runElectronCheck()
|
|
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
|
if (initial.ok && !patchedNodePtyRebuildReason) {
|
|
return
|
|
}
|
|
|
|
if (patchedNodePtyRebuildReason) {
|
|
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
|
|
if (!initial.ok) {
|
|
printCheckError(initial)
|
|
}
|
|
} else {
|
|
console.warn(
|
|
`[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.`
|
|
)
|
|
printCheckError(initial)
|
|
}
|
|
runNodeScript(['config/scripts/rebuild-native-deps.mjs'])
|
|
|
|
const final = runElectronCheck()
|
|
if (!final.ok) {
|
|
console.error(
|
|
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.`
|
|
)
|
|
printCheckError(final)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function runNodeCheck() {
|
|
// Why: a failed native addon load can poison the current process, so the
|
|
// post-rebuild verification must happen in a fresh Node process.
|
|
const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], {
|
|
cwd: projectDir,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
|
|
return parseChildCheckResult(result)
|
|
}
|
|
|
|
function runElectronCheck() {
|
|
const electronExecutable = resolveInstalledElectronExecutable()
|
|
if (!electronExecutable.ok) {
|
|
return { ok: false, error: electronExecutable.error }
|
|
}
|
|
|
|
const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], {
|
|
cwd: projectDir,
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1'
|
|
},
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
|
|
return parseChildCheckResult(result)
|
|
}
|
|
|
|
function resolveInstalledElectronExecutable() {
|
|
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
|
try {
|
|
const electronVersion = JSON.parse(
|
|
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
|
|
).version
|
|
const platformPath = getElectronPlatformPath()
|
|
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
|
|
.trim()
|
|
.replace(/^v/, '')
|
|
if (installedVersion !== electronVersion) {
|
|
return {
|
|
ok: false,
|
|
error: new Error(
|
|
`Electron package binary version ${installedVersion} does not match ${electronVersion}.`
|
|
)
|
|
}
|
|
}
|
|
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
|
|
if (installedPlatformPath !== platformPath) {
|
|
return {
|
|
ok: false,
|
|
error: new Error(
|
|
`Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.`
|
|
)
|
|
}
|
|
}
|
|
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
|
|
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
|
|
: resolve(electronPackageDir, 'dist', platformPath)
|
|
if (!existsSync(electronPath)) {
|
|
return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) }
|
|
}
|
|
return { ok: true, path: electronPath }
|
|
} catch (error) {
|
|
return { ok: false, error }
|
|
}
|
|
}
|
|
|
|
function getElectronPlatformPath() {
|
|
const targetPlatform =
|
|
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform
|
|
switch (targetPlatform) {
|
|
case 'mas':
|
|
case 'darwin':
|
|
return 'Electron.app/Contents/MacOS/Electron'
|
|
case 'freebsd':
|
|
case 'openbsd':
|
|
case 'linux':
|
|
return 'electron'
|
|
case 'win32':
|
|
return 'electron.exe'
|
|
default:
|
|
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
|
}
|
|
}
|
|
|
|
function parseChildCheckResult(result) {
|
|
const failures = parseCheckFailures(result.stderr)
|
|
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
error: result.error,
|
|
failures
|
|
}
|
|
}
|
|
|
|
function parseCheckFailures(stderr) {
|
|
const failures = []
|
|
for (const line of (stderr ?? '').split(/\r?\n/)) {
|
|
const match = /^([^:]+):\s*(.*)$/.exec(line)
|
|
if (match && NATIVE_MODULES.includes(match[1])) {
|
|
failures.push({ moduleName: match[1], message: match[2] })
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
function collectNativeModuleFailures() {
|
|
const failures = []
|
|
for (const moduleName of NATIVE_MODULES) {
|
|
try {
|
|
loadNativeModule(moduleName)
|
|
} catch (cause) {
|
|
failures.push({ moduleName, message: formatError(cause), cause })
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
function loadNativeModule(moduleName) {
|
|
if (moduleName === '@vscode/windows-process-tree') {
|
|
// A bare require loads the .node addon on win32, so it catches an ABI
|
|
// mismatch on its own. What it cannot catch is *which* addon loaded: the
|
|
// published tarball ships a prebuilt built from unpatched source that is
|
|
// node-addon-api, so it requires cleanly and then reads every process's
|
|
// command line out of its address space. Check the binary, not the load.
|
|
require(moduleName)
|
|
if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') {
|
|
throw new Error(
|
|
'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' +
|
|
'source. Rebuild it (pnpm run rebuild:electron) rather than using the published prebuild.'
|
|
)
|
|
}
|
|
return
|
|
}
|
|
if (moduleName === 'windows-native-registry') {
|
|
const registry = require(moduleName)
|
|
// Why: the package defers loading its .node addon until the first registry call.
|
|
registry.getRegistryKey(registry.HK.CU, 'Environment')
|
|
return
|
|
}
|
|
if (moduleName === 'node-pty') {
|
|
loadNodePtyNativeModule()
|
|
return
|
|
}
|
|
|
|
require(moduleName)
|
|
}
|
|
|
|
function loadNodePtyNativeModule() {
|
|
require('node-pty')
|
|
|
|
const { loadNativeModule } = require('node-pty/lib/utils')
|
|
const nativeName = getNodePtyNativeModuleName()
|
|
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
|
|
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
|
const native = loadNativeModule(nativeName)
|
|
assertNodePtyWindowsConptyRuntime(native?.dir)
|
|
assertNodePtyJobOwnership({ nativeName, native })
|
|
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
|
throw new Error(
|
|
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertNodePtyWindowsConptyRuntime(nativeDir) {
|
|
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
|
|
return
|
|
}
|
|
const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
|
|
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
|
|
(filename) => !existsSync(resolve(runtimeDir, filename))
|
|
)
|
|
if (missingFile) {
|
|
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
|
|
}
|
|
}
|
|
|
|
function getNodePtyNativeModuleName() {
|
|
if (process.platform !== 'win32') {
|
|
return 'pty'
|
|
}
|
|
|
|
return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty'
|
|
}
|
|
|
|
function getPatchedNodePtyRebuildReason() {
|
|
if (!requiresPatchedNodePtySourceBuild()) {
|
|
return null
|
|
}
|
|
|
|
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
|
|
// Windows patches only land in the source-built build/Release artifacts.
|
|
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
|
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
|
|
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
|
|
|
if (!missingArtifact) {
|
|
return null
|
|
}
|
|
|
|
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
|
}
|
|
|
|
function patchedNodePtyArtifactPaths(nodePtyDir) {
|
|
if (process.platform === 'win32') {
|
|
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
|
|
return [
|
|
resolve(releaseDir, 'conpty.node'),
|
|
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
|
|
]
|
|
}
|
|
|
|
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
|
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
|
if (process.platform === 'darwin') {
|
|
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
|
}
|
|
return artifactPaths
|
|
}
|
|
|
|
function requiresPatchedNodePtySourceBuild() {
|
|
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
|
if (!existsSync(nodePtyPatchPath)) {
|
|
return false
|
|
}
|
|
|
|
return existsSync(resolve(projectDir, 'node_modules', 'node-pty'))
|
|
}
|
|
|
|
function isNodePtyReleaseBuildDir(nativeDir) {
|
|
return typeof nativeDir === 'string' && nativeDir.replace(/\\/g, '/').includes('build/Release/')
|
|
}
|
|
|
|
function getWindowsBuildNumber() {
|
|
const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release())
|
|
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
|
|
}
|
|
|
|
function rebuildNodeRuntimeModules(moduleNames) {
|
|
for (const moduleName of moduleNames) {
|
|
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
|
if (moduleName === '@vscode/windows-process-tree') {
|
|
// Why before node-gyp: this module is rebuilt precisely because the
|
|
// binary was the unpatched one, and pnpm materializes it unpatched often
|
|
// enough that compiling the source as-is would just rebuild the same
|
|
// reader and fail the verify pass.
|
|
ensureWindowsProcessTreeCommandLinePatch(moduleDir)
|
|
stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir)
|
|
}
|
|
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
|
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
|
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
|
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
|
}
|
|
}
|
|
}
|
|
|
|
function runPnpm(args, { cwd = projectDir } = {}) {
|
|
// cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe.
|
|
const command = 'pnpm'
|
|
const env =
|
|
process.platform === 'linux' && args.includes('node-gyp')
|
|
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
|
: process.env
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
stdio: 'inherit',
|
|
shell: process.platform === 'win32',
|
|
env
|
|
})
|
|
|
|
if (result.error || result.status !== 0) {
|
|
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
|
if (result.error) {
|
|
console.error(formatError(result.error))
|
|
}
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|
|
|
|
function runNodeScript(args) {
|
|
const result = spawnSync(process.execPath, args, {
|
|
cwd: projectDir,
|
|
stdio: 'inherit'
|
|
})
|
|
|
|
if (result.error || result.status !== 0) {
|
|
console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`)
|
|
if (result.error) {
|
|
console.error(formatError(result.error))
|
|
}
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|
|
|
|
function printCheckError(result) {
|
|
for (const failure of result.failures ?? []) {
|
|
console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`)
|
|
}
|
|
if (result.error) {
|
|
console.warn(`[native-runtime] ${formatError(result.error)}`)
|
|
}
|
|
if (result.stderr?.trim()) {
|
|
console.warn(result.stderr.trim())
|
|
}
|
|
if (result.stdout?.trim()) {
|
|
console.warn(result.stdout.trim())
|
|
}
|
|
if (
|
|
result.status != null &&
|
|
!result.error &&
|
|
!result.stderr?.trim() &&
|
|
!result.stdout?.trim() &&
|
|
result.status !== 0
|
|
) {
|
|
console.warn(`[native-runtime] Native check exited with status ${result.status}.`)
|
|
}
|
|
}
|
|
|
|
function formatError(error) {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function formatRuntimeLabel(value) {
|
|
if (value === 'electron') {
|
|
return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim()
|
|
}
|
|
return `Node ${process.versions.node}`
|
|
}
|