mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(windows): restrict secure files in-process instead of spawning icacls Every secure write on Windows spawned icacls.exe five times on the calling thread (the Electron main thread for most stores): verify, /reset, /inheritance:r /grant:r, verify, verify. Process creation under endpoint security costs ~0.6 s each, so each write froze the UI for seconds (#26027). The /reset + /inheritance:r pair is also what behavioural EDR scores as ransomware tradecraft (#25583). Add native/windows-security (@orca/windows-security): a small N-API addon over advapi32 that reads and writes DACLs in-process. It exposes the token user SID, a DACL read as SDDL, a DACL write (sync and on the thread pool), a create-with-DACL file write, and an additive grant. Policy and verification stay in TypeScript. - writeSecureFile creates each temp file already carrying the protected user/SYSTEM/Administrators DACL (CreateFileW + SECURITY_ATTRIBUTES), reads it back once, and renames it. No ACL is modified on the write path and no process starts. Before: 5-7 spawns, 94-138 ms per write. After: 0 spawns, ~0.5 ms. - A secure directory is verified once per process. It is rewritten only when verification fails, on the thread pool, because the write walks every descendant (~2 s on a 29k-entry userData). - The verifier follows OpenSSH's rule: only the user, SYSTEM and Administrators may be granted anything, and the path must be protected (or, for a file, inherit from a secure parent). DACLs written by the icacls-era hardening pass unchanged, so there is no migration. - If the addon cannot load, the write still happens and reports unrestricted, logged once. There is no icacls or PowerShell fallback. - grantDirAcl, the startup userData grant, and the install-dir probe and package repair use the same addon, so whoami.exe and icacls.exe are no longer spawned anywhere at runtime. - orcad's Windows slots ship the addon beside their bundles (built per arch, PE-machine checked). Its data-root privacy check fails closed, so without the addon a Windows orcad would not start. Fixes #26027 Fixes #25583 * refactor(secure-file): harden existing Windows files through the sync lane The read path called restrictWindowsPathSync directly and recorded success itself; the existing sync lane already does both. One call site fewer. * refactor(windows-security): one load, one rule, no fallthrough (review #28135) - The descriptor parser is anchored, so a conditional ACE (its body holds parentheses) or anything else it cannot read fails closed instead of verifying the ACEs before it. - findSecureDaclViolation is one flat rule: protected, allow rules only for the user, SYSTEM and Administrators (OpenSSH's ACE rule), and full control for the user. The inherits-from-a-secure-parent exception is gone: it accepted exactly what loosening the parent reaches. - The addon and the user SID load once, behind one wrapper that adds the extended-length prefix to every path and runs async DACL writes to one path in order, so a startup grant cannot land after a re-lock and restore the broader DACL. - With the addon loaded, a failed born-restricted create throws with the matching Node error code instead of falling back to a plain write. - A grant leaves a NULL DACL untouched instead of turning 'everyone' into 'only this SID'. WriteFile runs once and fails on a short write. - The artifact create-intent store writes through writeSecureFile and drops its own temp writer, directory LRU and synchronous directory re-lock. - Test seams the global fake makes redundant are gone. * fix(packaging): fail a Windows build whose workspace addon binary is missing or foreign-arch The @orca/windows-* workspace links exist on every host, so their presence proved nothing: a missing or wrong-architecture .node only surfaced at runtime, as unrestricted secure files or an orcad that refuses to start. beforePack now reads each addon's PE machine against the target arch. * refactor(windows-security): prune what the native ACL left dead (review #28135) - Sync-spawn ratchet: hardenSecurePath and the sync-flag exemption it alone used are gone, and the advice and baseline header no longer say secure writers run icacls. - The install-dir probe reads ACEs through parseSddlDacl, so an ACE it cannot parse fails the whole reading closed instead of being skipped; AC is a resolved alias there now. - The packaging check reads the addon binary through node_modules, the copy that ships. - No winCode-to-errno table: nothing reads .code off a failed secure write. - The load memo is not mutated; secure-path keeps its own log-once flag. - grantInstallDirPackageAcl is a plain function, and secure-file restricts a Windows directory and a POSIX path directly instead of through one two-platform helper. * fix(windows-security): resolve LA in real-ACL tests; tear a file whose close fails - The GitHub Windows runner is the built-in Administrator (RID 500), whose SID reads back as LA. Production already resolves it through localDomainSidOf; the win32 test helpers parsed without it. One shared readDaclOf resolves aliases for every real-ACL suite, and the verifier's unit test covers an LA-aliased directory and LA as a stranger. - createFileWithDacl treats a failed CloseHandle like a failed write (lost data on a network volume) and deletes the torn file. - Drop a duplicate test reset and a typo. * fix(windows-security): load only from inside the app; read DACLs as structure (review #28135) - Loader: orcad's staged binary first, then the workspace package only if its real path is inside the app (process.resourcesPath, or this checkout). A bare specifier otherwise walks every parent up to C:\node_modules, which any local user may create, and orcad holds credentials. - The addon reads a DACL as { protected, aces: [{ type, flags, mask, sid }] } via GetAce, with a SID only for plain allow/deny ACEs and null for a NULL DACL. findSecureDaclViolation and the install-dir probe check that structure, so conditional, callback and object ACEs fail closed. The SDDL parser, its alias table, LA/localDomainSid resolution and getDaclSddl are gone; SDDL is only the write format. - Install-dir tree pass: files flagless as before, but a directory only when its own DACL is protected (inheritably). Re-granting inheriting directories re-propagated each subtree (3.2 s for 1,093 directories). The root is no longer granted, or counted, twice. The two passes are plain functions behind a { root, tree } seam. - One namespacing (path.win32.toNamespacedPath) at the addon boundary, also the per-path ordering key; the comment states the real sync-grant invariant. - restrictWindowsDirectory(dir): Promise<boolean> replaces the callback lane; the reporter is guarded once, so the 'settle' stage is gone. - Sync-spawn ratchet: the secure writers no longer spawn or block, so they leave the gate (109 -> 61 uses). - Errors name the Win32 call and code only; a failed GetSecurityDescriptorControl fails the write instead of defaulting to unprotected. * test(secure-file): assert the durable POSIX write's outcome, not the old chmod order writeSecureFile now chmods the temp file to 0o600 before fsyncing it, so the fsync also covers the mode change and the file is owner-writable when opened. Assert what matters under umask 0o200: the write succeeds, the temp file is fsynced read-only, and the published file is exactly 0o600. * fix(windows-security): namespace paths with the host's toNamespacedPath path.win32.toNamespacedPath rewrote POSIX temp paths (/tmp/x -> \tmp\x) in the Linux runs of the Windows-logic unit tests, so the fake addon wrote to the wrong place. The addon only runs on Windows, where the host function is the win32 one; elsewhere it is the identity. The long-path assertion runs on Windows only. * test(windows-security): assert the install-dir long-path prefix on Windows only The prefix comes from the host's toNamespacedPath, which is the identity off Windows; the Linux run of this suite expected the win32 escape. * fix(windows-user-data-acl): grant the root even when its children cannot be listed (review #28135) A readdir failure on userData aborted the whole grant, so the root grant that could repair a DACL refusing the listing was never attempted. The listing failure is now recorded, the root is still granted, and the marker stays withheld so the next launch retries. * test(windows-security): wait for relock propagation, accept Entra ID SIDs (review #28135) - The large-relock test waited only for the parent to turn protected, which happens before propagation reaches the children, then read a child. It now waits on the parent and both ends of the child range. - The token-user test rejected S-1-12-1 (Entra ID) accounts, failing the suite on Entra-joined dev machines. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
696 lines
27 KiB
JavaScript
696 lines
27 KiB
JavaScript
const {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync
|
|
} = require('node:fs')
|
|
const { dirname, join, resolve } = require('node:path')
|
|
const { builtinModules, createRequire } = require('node:module')
|
|
const { PE_MACHINE, readPeMachine } = require('./scripts/windows-pe-machine.cjs')
|
|
|
|
const projectDir = resolve(__dirname, '..')
|
|
const requireFromProject = createRequire(join(projectDir, 'package.json'))
|
|
|
|
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
|
'@anthropic-ai/claude-agent-sdk',
|
|
'@electron-toolkit/utils',
|
|
'@linear/sdk',
|
|
'@orca/process-host',
|
|
'@parcel/watcher',
|
|
'electron-updater',
|
|
'i18next',
|
|
'jsonc-parser',
|
|
'node-pty',
|
|
'posthog-node',
|
|
'proper-lockfile',
|
|
'qrcode',
|
|
'ssh2',
|
|
'tweetnacl',
|
|
'ws',
|
|
'yaml',
|
|
'zod'
|
|
]
|
|
// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that
|
|
// Windows signing rejects as a PE file.
|
|
const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim']
|
|
/** The compiled binary each workspace Windows addon loads, relative to its package. */
|
|
const WORKSPACE_WINDOWS_ADDON_BINARIES = [
|
|
['@orca/windows-registry', 'build/Release/orca_windows_registry.node'],
|
|
['@orca/windows-security', 'build/Release/orca_windows_security.node']
|
|
]
|
|
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
|
'@vscode/windows-process-tree',
|
|
'@orca/windows-registry',
|
|
'@orca/windows-security'
|
|
]
|
|
|
|
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
|
darwin: 'darwin-',
|
|
linux: 'linux-',
|
|
win32: 'win32-'
|
|
}
|
|
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
|
const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
|
|
darwin: 'watcher-darwin',
|
|
linux: 'watcher-linux',
|
|
win32: 'watcher-win32'
|
|
}
|
|
const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
|
0: 'ia32',
|
|
1: 'x64',
|
|
2: 'arm',
|
|
3: 'arm64',
|
|
4: 'universal'
|
|
}
|
|
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
|
const PACKAGED_MAIN_REQUIRED_FILES = [
|
|
'out/main/index.js',
|
|
'out/main/agent-hooks/managed-agent-hook-controls.js'
|
|
]
|
|
const PACKAGED_MAIN_SOURCE_RE = /^out\/main\/.+\.js$/
|
|
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
|
const JS_SOURCE_MAP_ARTIFACT_RE = /\.(?:c|m)?js\.map$/
|
|
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
|
|
|
const NODE_BUILTINS = new Set([
|
|
...builtinModules,
|
|
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
|
])
|
|
|
|
function packageNameFromSpecifier(specifier) {
|
|
if (specifier.startsWith('@')) {
|
|
const [scope, name] = specifier.split('/')
|
|
return scope && name ? `${scope}/${name}` : specifier
|
|
}
|
|
return specifier.split('/')[0]
|
|
}
|
|
|
|
function isPackagedExternalSpecifier(specifier) {
|
|
return (
|
|
!specifier.startsWith('.') &&
|
|
!specifier.startsWith('/') &&
|
|
specifier !== 'electron' &&
|
|
!NODE_BUILTINS.has(specifier)
|
|
)
|
|
}
|
|
|
|
function resolvePackageJsonPath(packageName, fromDir = projectDir) {
|
|
const nested = join(fromDir, 'node_modules', packageName, 'package.json')
|
|
if (existsSync(nested)) {
|
|
return nested
|
|
}
|
|
// Why: published serve-sim has no "." export (only ./middleware and ./state), so
|
|
// require.resolve('serve-sim') fails even though the package is present for bridge exec.
|
|
if (packageName === 'serve-sim') {
|
|
const direct = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
|
|
if (existsSync(direct)) {
|
|
return direct
|
|
}
|
|
}
|
|
try {
|
|
return requireFromProject.resolve(`${packageName}/package.json`, { paths: [fromDir] })
|
|
} catch {
|
|
let entryPath
|
|
try {
|
|
entryPath = requireFromProject.resolve(packageName, { paths: [fromDir] })
|
|
} catch {
|
|
throw new Error(`Could not resolve package ${packageName} from ${fromDir}`)
|
|
}
|
|
let dir = dirname(entryPath)
|
|
while (dir !== dirname(dir)) {
|
|
const packageJsonPath = join(dir, 'package.json')
|
|
if (existsSync(packageJsonPath)) {
|
|
return packageJsonPath
|
|
}
|
|
dir = dirname(dir)
|
|
}
|
|
throw new Error(`Could not find package.json for ${packageName}`)
|
|
}
|
|
}
|
|
|
|
function readPackage(packageName, fromDir = projectDir) {
|
|
const packageJsonPath = resolvePackageJsonPath(packageName, fromDir)
|
|
const packageDir = realpathSync(dirname(packageJsonPath))
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
return {
|
|
name: packageJson.name ?? packageName,
|
|
packageDir,
|
|
dependencies: Object.keys(packageJson.dependencies ?? {})
|
|
}
|
|
}
|
|
|
|
function isKnownOmittedServeSimDependency(packageName, fromDir) {
|
|
if (packageName !== 'inspect-webkit') {
|
|
return false
|
|
}
|
|
const serveSimPackageJsonPath = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
|
|
if (!existsSync(serveSimPackageJsonPath)) {
|
|
return false
|
|
}
|
|
try {
|
|
return realpathSync(fromDir) === realpathSync(dirname(serveSimPackageJsonPath))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function collectPackagedRuntimePackages(electronPlatformName = process.platform) {
|
|
const packages = new Map()
|
|
const visit = (packageName, fromDir = projectDir) => {
|
|
if (packageName === 'electron' || packages.has(packageName)) {
|
|
return
|
|
}
|
|
|
|
let packageInfo
|
|
try {
|
|
packageInfo = readPackage(packageName, fromDir)
|
|
} catch (error) {
|
|
// Why: serve-sim declares inspect-webkit, but current installs omit it.
|
|
// Keep that escape hatch narrow so broken packages still fail packaging.
|
|
if (isKnownOmittedServeSimDependency(packageName, fromDir)) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
if (packages.has(packageInfo.name)) {
|
|
return
|
|
}
|
|
packages.set(packageInfo.name, packageInfo.packageDir)
|
|
|
|
for (const dependencyName of packageInfo.dependencies) {
|
|
visit(dependencyName, packageInfo.packageDir)
|
|
}
|
|
}
|
|
|
|
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
|
|
const packageRoots = [
|
|
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
|
...(electronPlatformName === 'darwin' ? DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS : []),
|
|
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
|
|
]
|
|
for (const packageName of packageRoots) {
|
|
visit(packageName)
|
|
}
|
|
|
|
// Why: @parcel/watcher loads its native .node addon from a platform-specific
|
|
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
|
|
// dependencies graph above never reaches. Include the ones installed for the
|
|
// build's supported architectures; afterPack pruning trims non-target
|
|
// platform/architecture variants. Without this the packaged main bundle's import of
|
|
// '@parcel/watcher' resolves at runtime but throws loading its binary.
|
|
const parcelWatcherDir = packages.get('@parcel/watcher')
|
|
if (parcelWatcherDir) {
|
|
const parcelWatcherPackage = JSON.parse(
|
|
readFileSync(join(parcelWatcherDir, 'package.json'), 'utf8')
|
|
)
|
|
for (const optionalName of Object.keys(parcelWatcherPackage.optionalDependencies ?? {})) {
|
|
try {
|
|
visit(optionalName)
|
|
} catch {
|
|
// Optional platform subpackage is not installed for this build; skip it.
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
}
|
|
|
|
function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) {
|
|
return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({
|
|
from: packageDir,
|
|
to: join('node_modules', ...packageName.split('/')),
|
|
...(packageName === '@orca/process-host' ? { filter: ['package.json', 'dist/**/*'] } : {})
|
|
}))
|
|
}
|
|
|
|
function listProcessHostModules(directory, extension, prefix = '') {
|
|
if (!existsSync(directory)) {
|
|
return []
|
|
}
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const relativePath = `${prefix}${entry.name}`
|
|
if (entry.isDirectory()) {
|
|
return entry.name === '__fixtures__'
|
|
? []
|
|
: listProcessHostModules(join(directory, entry.name), extension, `${relativePath}/`)
|
|
}
|
|
return entry.name.endsWith(extension) && !entry.name.endsWith(`.test${extension}`)
|
|
? [relativePath.slice(0, -extension.length)]
|
|
: []
|
|
})
|
|
}
|
|
|
|
// Why: the resource filter copies whatever dist holds, so a missing, partial, or stale build
|
|
// would ship a package main cannot load. Mirrors the package tsconfig include/exclude.
|
|
function assertProcessHostOutputBuilt(packageDir = readPackage('@orca/process-host').packageDir) {
|
|
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
|
|
const publicTargets = [
|
|
manifest.main,
|
|
...Object.values(manifest.exports ?? {}).map((target) =>
|
|
typeof target === 'string' ? target : target?.default
|
|
)
|
|
].filter((target) => typeof target === 'string')
|
|
const sources = listProcessHostModules(join(packageDir, 'src'), '.ts')
|
|
const sourceSet = new Set(sources)
|
|
const missing = [
|
|
...new Set([...publicTargets, ...sources.map((module) => `./dist/${module}.js`)])
|
|
].filter((target) => !existsSync(join(packageDir, target)))
|
|
const stale = listProcessHostModules(join(packageDir, 'dist'), '.js')
|
|
.filter((module) => !sourceSet.has(module))
|
|
.map((module) => `./dist/${module}.js`)
|
|
if (missing.length > 0 || stale.length > 0) {
|
|
throw new Error(
|
|
`@orca/process-host output in ${packageDir} does not match its source ` +
|
|
`(missing: ${missing.join(', ') || 'none'}; stale: ${stale.join(', ') || 'none'}). ` +
|
|
'Run `pnpm build:packages` before packaging.'
|
|
)
|
|
}
|
|
}
|
|
|
|
function normalizeAsarEntryPath(entry) {
|
|
return entry.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
}
|
|
|
|
function findAsarEntry(entries, expectedPath) {
|
|
return entries.find((entry) => normalizeAsarEntryPath(entry) === expectedPath)
|
|
}
|
|
|
|
function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/asar')) {
|
|
const asarPath = join(resourcesDir, 'app.asar')
|
|
if (!existsSync(asarPath)) {
|
|
return
|
|
}
|
|
|
|
const entries = asar.listPackage(asarPath)
|
|
for (const file of PACKAGED_MAIN_REQUIRED_FILES) {
|
|
if (!findAsarEntry(entries, file)) {
|
|
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
|
|
}
|
|
}
|
|
|
|
const missing = new Set()
|
|
// Why every emitted main file rather than the entry points alone: rolldown hoists
|
|
// modules shared by two entries into out/main/chunks, so an entry's own bare imports
|
|
// move out from under a fixed file list and silently stop being checked.
|
|
for (const entry of entries) {
|
|
if (!PACKAGED_MAIN_SOURCE_RE.test(normalizeAsarEntryPath(entry))) {
|
|
continue
|
|
}
|
|
|
|
// Why: @electron/asar lists entries with host separators; Windows returns
|
|
// backslashes, and extractFile expects that same host-style path.
|
|
const internalPath = entry.replace(/^[\\/]+/, '')
|
|
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
|
|
// Why the lookbehind: Orca has its own registry methods named `require`, so a
|
|
// minified `registry.require('some-id')` must not read as a bare specifier.
|
|
// Why it readmits `...`: a dot that ends a spread is not member access, and
|
|
// the two error directions are not symmetric -- a false positive fails the
|
|
// release build loudly, a false negative is this guard going blind.
|
|
// Known limit: a specifier inside an embedded source string counts too, and
|
|
// ssh-relay-deploy's remote probe names node-pty that way. A remote-only
|
|
// dependency added to that script would fail desktop packaging here; telling
|
|
// the two apart needs a parser, not a wider pattern.
|
|
for (const match of source.matchAll(
|
|
/(?:(?<![.\w])|(?<=\.\.\.))(?:require|import)\s*\(\s*(["'`])([^"'`$]+)\1\s*\)/g
|
|
)) {
|
|
const specifier = match[2]
|
|
if (!isPackagedExternalSpecifier(specifier)) {
|
|
continue
|
|
}
|
|
const packageName = packageNameFromSpecifier(specifier)
|
|
if (!existsSync(join(resourcesDir, 'node_modules', ...packageName.split('/')))) {
|
|
missing.add(packageName)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missing.size > 0) {
|
|
throw new Error(
|
|
`Packaged main bundle has bare runtime imports without copied node_modules: ${[
|
|
...missing
|
|
].join(', ')}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function normalizeNodePtyWindowsArch(electronArch) {
|
|
const architecture = normalizeElectronArchitecture(electronArch)
|
|
if (architecture !== 'x64' && architecture !== 'arm64') {
|
|
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
|
|
}
|
|
return architecture
|
|
}
|
|
|
|
function normalizeElectronArchitecture(electronArch) {
|
|
const architecture =
|
|
typeof electronArch === 'number'
|
|
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
|
|
: electronArch === 'armv7l'
|
|
? 'arm'
|
|
: electronArch
|
|
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
|
|
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
|
|
}
|
|
return architecture
|
|
}
|
|
|
|
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
|
|
if (!existsSync(directory)) {
|
|
return
|
|
}
|
|
const architecture = normalizeElectronArchitecture(electronArch)
|
|
const targetPrefix = `${platformPrefix}${architecture}`
|
|
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
|
|
continue
|
|
}
|
|
const matchesTarget =
|
|
entry.name.startsWith(platformPrefix) &&
|
|
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
|
|
if (!matchesTarget) {
|
|
rmSync(join(directory, entry.name), { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
|
|
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty')
|
|
if (!existsSync(conptyRoot)) {
|
|
throw new Error(`Packaged node-pty is missing ${conptyRoot}`)
|
|
}
|
|
for (const entry of readdirSync(conptyRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue
|
|
}
|
|
const sourceDir = join(conptyRoot, entry.name, `win10-${windowsArch}`)
|
|
if (existsSync(sourceDir)) {
|
|
return sourceDir
|
|
}
|
|
}
|
|
throw new Error(`Packaged node-pty has no ConPTY payload for win10-${windowsArch}`)
|
|
}
|
|
|
|
function ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) {
|
|
const releaseDir = join(nodePtyDir, 'build', 'Release')
|
|
if (!existsSync(join(releaseDir, 'conpty.node'))) {
|
|
return
|
|
}
|
|
|
|
const runtimeDir = join(releaseDir, 'conpty')
|
|
const missingRuntimeFiles = NODE_PTY_CONPTY_RUNTIME_FILES.filter(
|
|
(filename) => !existsSync(join(runtimeDir, filename))
|
|
)
|
|
if (missingRuntimeFiles.length === 0) {
|
|
return
|
|
}
|
|
|
|
const windowsArch = normalizeNodePtyWindowsArch(electronArch)
|
|
const sourceDir = findNodePtyConptySourceDir(nodePtyDir, windowsArch)
|
|
mkdirSync(runtimeDir, { recursive: true })
|
|
for (const filename of missingRuntimeFiles) {
|
|
const sourceFile = join(sourceDir, filename)
|
|
if (!existsSync(sourceFile)) {
|
|
throw new Error(`Packaged node-pty is missing ${sourceFile}`)
|
|
}
|
|
// Why: node-pty's Windows addon loads conpty.dll relative to conpty.node,
|
|
// but its install script can run before electron-builder gathers resources.
|
|
copyFileSync(sourceFile, join(runtimeDir, filename))
|
|
}
|
|
}
|
|
|
|
/** Whether node-pty's source build holds a conpty.node the `electronArch` slice could load. */
|
|
function conptyTargetsArch(nodePtyDir, electronArch) {
|
|
const releaseAddon = join(nodePtyDir, 'build', 'Release', 'conpty.node')
|
|
if (!existsSync(releaseAddon)) {
|
|
return false
|
|
}
|
|
// Null (not a PE) counts as unloadable, so a truncated or quarantined build keeps the fallback.
|
|
return readPeMachine(releaseAddon) === PE_MACHINE[normalizeNodePtyWindowsArch(electronArch)]
|
|
}
|
|
|
|
function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) {
|
|
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
|
if (!existsSync(nodePtyDir)) {
|
|
return
|
|
}
|
|
|
|
// Why delete only conpty.node: node-pty's loader tries build/Release, then
|
|
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
|
|
// between. Only the source build carries Orca's job-object exports, so an ABI
|
|
// mismatch or an AV quarantine of build/Release/conpty.node would silently
|
|
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
|
|
// ancestry, with no error anywhere.
|
|
//
|
|
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
|
|
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
|
|
// build emits conpty.node and nothing else. conpty_console_list.node,
|
|
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
|
|
// silently kills console-membership probing (the forked agent throws at
|
|
// require, and its caller resolves null with silent: true), and removes the
|
|
// winpty backend that node-pty still selects below Windows build 18309.
|
|
//
|
|
// Why the arch check: a cross-HOST package can copy a build/Release that is not a Windows
|
|
// binary at all, so its mere presence does not mean the target can load it -- deleting the
|
|
// target-arch prebuild would then remove the only loadable binary. This used to approximate
|
|
// that with `electronArch === process.arch`, which also skipped the arm64 slice cross-built on
|
|
// an x64 Windows host -- a rebuild that DOES emit a correct arm64 addon. That slice kept the
|
|
// unpatched prebuild as a reachable fallback for any later load failure of build/Release.
|
|
// Read the PE header instead of guessing.
|
|
if (electronPlatformName === 'win32' && conptyTargetsArch(nodePtyDir, electronArch)) {
|
|
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
|
|
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
|
|
rmSync(join(prebuildDir, staleFallback), { force: true })
|
|
}
|
|
}
|
|
|
|
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
|
if (allowedPrebuildPrefix) {
|
|
pruneNodePtyNativeDirectories(
|
|
join(nodePtyDir, 'prebuilds'),
|
|
allowedPrebuildPrefix,
|
|
electronArch,
|
|
false
|
|
)
|
|
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
|
|
pruneNodePtyNativeDirectories(
|
|
join(nodePtyDir, 'bin'),
|
|
allowedPrebuildPrefix,
|
|
electronArch,
|
|
true
|
|
)
|
|
}
|
|
|
|
if (electronPlatformName === 'win32') {
|
|
ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch)
|
|
} else {
|
|
// Why: conpty is Windows-only and node-pty resolves runtime binaries from
|
|
// build/Release or prebuilds/<platform>-<arch>, not third_party/conpty.
|
|
rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true })
|
|
rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
|
|
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
|
if (!existsSync(parcelDir)) {
|
|
return
|
|
}
|
|
|
|
// Why: we package every installed @parcel/watcher-<platform> optional
|
|
// subpackage (pnpm install:release fetches every CPU), but each build only needs
|
|
// its own platform/architecture binaries. Keep the core package and matching
|
|
// native variants; drop the rest.
|
|
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
|
|
const architecture = normalizeElectronArchitecture(electronArch)
|
|
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
|
|
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name === 'watcher') {
|
|
continue
|
|
}
|
|
// Why: only ever prune the watcher's own platform subpackages. Guards against
|
|
// nuking an unrelated @parcel/* runtime dep if one is added to the roots later.
|
|
if (!entry.name.startsWith('watcher-')) {
|
|
continue
|
|
}
|
|
if (
|
|
keepPrefix &&
|
|
entry.name.startsWith(keepPrefix) &&
|
|
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
|
|
) {
|
|
continue
|
|
}
|
|
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
// Why type declarations: they are compile-time only; the packaged app never resolves them.
|
|
// Why source maps: they embed the original sources (megabytes for @linear/sdk alone) and
|
|
// nothing in the packaged app turns on Node's source-map support, so they are never read.
|
|
// Orca's own main-process maps live outside node_modules and ship as a separate release artifact.
|
|
function isPrunableTypeOrSourceMapArtifact(filename) {
|
|
return TYPE_DECLARATION_ARTIFACT_RE.test(filename) || JS_SOURCE_MAP_ARTIFACT_RE.test(filename)
|
|
}
|
|
|
|
// Why one walk: pruneMatchingFiles only ever deletes files, so passes over the same tree
|
|
// commute — a second recursive traversal costs seconds for no extra deletions.
|
|
function prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) {
|
|
const nodeModulesDir = join(resourcesDir, 'node_modules')
|
|
if (!existsSync(nodeModulesDir)) {
|
|
return
|
|
}
|
|
pruneMatchingFiles(nodeModulesDir, isPrunableTypeOrSourceMapArtifact)
|
|
}
|
|
|
|
function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) {
|
|
if (electronPlatformName !== 'darwin') {
|
|
return
|
|
}
|
|
const nodeModulesDir = join(resourcesDir, 'node_modules')
|
|
if (!existsSync(nodeModulesDir)) {
|
|
return
|
|
}
|
|
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || !entry.name.startsWith('sherpa-onnx-darwin-')) {
|
|
continue
|
|
}
|
|
const packageDir = join(nodeModulesDir, entry.name)
|
|
const packageEntries = readdirSync(packageDir)
|
|
const hasVersionedOnnxRuntime = packageEntries.some((filename) =>
|
|
VERSIONED_ONNXRUNTIME_DYLIB_RE.test(filename)
|
|
)
|
|
if (hasVersionedOnnxRuntime) {
|
|
// Why: darwin sherpa-onnx binaries link to the versioned ONNX Runtime
|
|
// install name; the unversioned dylib is a duplicate fallback copy.
|
|
rmSync(join(packageDir, 'libonnxruntime.dylib'), { force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
function prunePackagedZodSources(resourcesDir) {
|
|
// Why: Zod's src tree is TypeScript source only selected by the @zod/source
|
|
// condition; packaged runtime import/require paths resolve to built JS.
|
|
rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true })
|
|
}
|
|
|
|
// Why: electron-builder only warns on a missing extraResources source, so a host-only
|
|
// install would silently ship a foreign-arch slice without its native addons.
|
|
function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArch) {
|
|
const architecture = normalizeElectronArchitecture(electronArch)
|
|
const nodeModulesDir = join(projectDir, 'node_modules')
|
|
const isInstalled = (name) => existsSync(join(nodeModulesDir, name, 'package.json'))
|
|
const missing = []
|
|
|
|
const rootOptionalDependencies =
|
|
JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).optionalDependencies ?? {}
|
|
// Why win32 is always x64: winSpeechNativeResource packages sherpa-onnx-win-x64 for every
|
|
// Windows target (there is no sherpa-onnx-win-arm64; it runs under emulation).
|
|
const sherpaName =
|
|
electronPlatformName === 'win32'
|
|
? 'sherpa-onnx-win-x64'
|
|
: `sherpa-onnx-${electronPlatformName}-${architecture}`
|
|
if (sherpaName in rootOptionalDependencies && !isInstalled(sherpaName)) {
|
|
missing.push(sherpaName)
|
|
}
|
|
|
|
// Why prefix, not equality: linux variants carry a libc suffix (watcher-linux-x64-glibc),
|
|
// mirroring what prunePackagedParcelWatcher keeps.
|
|
const watcherPrefix = `watcher-${electronPlatformName}-${architecture}`
|
|
const parcelDir = join(nodeModulesDir, '@parcel')
|
|
if (isInstalled('@parcel/watcher')) {
|
|
const watcherOptionalDependencies = Object.keys(
|
|
JSON.parse(readFileSync(join(parcelDir, 'watcher', 'package.json'), 'utf8'))
|
|
.optionalDependencies ?? {}
|
|
)
|
|
const expectedVariants = watcherOptionalDependencies.filter((name) =>
|
|
name.startsWith(`@parcel/${watcherPrefix}`)
|
|
)
|
|
// Why not withFileTypes: pnpm links the variants, so isDirectory() is false for them.
|
|
const hasVariant = readdirSync(parcelDir).some(
|
|
(name) => name.startsWith(watcherPrefix) && isInstalled(`@parcel/${name}`)
|
|
)
|
|
if (expectedVariants.length > 0 && !hasVariant) {
|
|
missing.push(...expectedVariants)
|
|
}
|
|
}
|
|
|
|
// Why one package: @vscode/windows-process-tree is the only os: win32 npm addon;
|
|
// the @orca/windows-* addons are workspace links present on every host, so their presence proves nothing.
|
|
const missingWindowsAddons = []
|
|
if (electronPlatformName === 'win32' && !isInstalled('@vscode/windows-process-tree')) {
|
|
missingWindowsAddons.push('@vscode/windows-process-tree')
|
|
}
|
|
// Why the binary, not the package: the link exists everywhere, and a missing or foreign-arch
|
|
// .node would otherwise surface only at runtime (secure files unrestricted, orcad refusing to start).
|
|
if (electronPlatformName === 'win32') {
|
|
for (const [name, binary] of WORKSPACE_WINDOWS_ADDON_BINARIES) {
|
|
const addon = join(nodeModulesDir, name, binary)
|
|
if (!existsSync(addon) || readPeMachine(addon) !== PE_MACHINE[architecture]) {
|
|
missingWindowsAddons.push(`${name} (${binary} for ${architecture})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missing.length === 0 && missingWindowsAddons.length === 0) {
|
|
return
|
|
}
|
|
// Why separate remedies: install:release widens only the CPU set, so the os: win32 addon
|
|
// never arrives on a non-Windows host and is compiled only by the Windows-only rebuild.
|
|
const remedies = []
|
|
if (missing.length > 0) {
|
|
remedies.push('Run pnpm install:release to install another architecture.')
|
|
}
|
|
if (missingWindowsAddons.length > 0) {
|
|
remedies.push(
|
|
'Windows packaging requires a Windows host: the Windows addons are installed only where ' +
|
|
'os: win32 matches and compiled only by the Windows-only rebuild.'
|
|
)
|
|
}
|
|
throw new Error(
|
|
`Packaging ${electronPlatformName}/${architecture} requires native variants that are not installed: ` +
|
|
`${[...new Set([...missing, ...missingWindowsAddons])].sort().join(', ')}. ${remedies.join(' ')}`
|
|
)
|
|
}
|
|
|
|
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
|
|
const architecture = normalizeElectronArchitecture(electronArch)
|
|
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
|
|
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
|
|
// Why before the filename walk: zod/src is deleted wholesale, so walking it first is wasted work.
|
|
prunePackagedZodSources(resourcesDir)
|
|
prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
|
|
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
|
}
|
|
|
|
function pruneMatchingFiles(directory, shouldPrune) {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const entryPath = join(directory, entry.name)
|
|
if (entry.isDirectory()) {
|
|
pruneMatchingFiles(entryPath, shouldPrune)
|
|
} else if (entry.isFile() && shouldPrune(entry.name)) {
|
|
rmSync(entryPath, { force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
|
assertPackagedNativeVariantsInstalled,
|
|
assertProcessHostOutputBuilt,
|
|
createPackagedRuntimeNodeModuleResources,
|
|
findAsarEntry,
|
|
isPackagedExternalSpecifier,
|
|
normalizeNodePtyWindowsArch,
|
|
packageNameFromSpecifier,
|
|
prunePackagedNodePty,
|
|
prunePackagedParcelWatcher,
|
|
prunePackagedRuntimeNodeModules,
|
|
prunePackagedRuntimeTypeAndSourceMapArtifacts,
|
|
prunePackagedSherpaOnnx,
|
|
prunePackagedZodSources,
|
|
verifyPackagedMainRuntimeDeps
|
|
}
|