Files
orca/config/scripts/electron-builder-config.test.mjs
T
OrcaWinandOrca Worker 4559c250b3 fix(windows): restrict secure files in-process instead of spawning icacls (#28135)
* 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>
2026-10-13 01:56:55 -07:00

662 lines
26 KiB
JavaScript

import { existsSync } from 'node:fs'
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs'
const REPO_ROOT = join(import.meta.dirname, '..', '..')
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const MAIN_BUILD_ENTRY_POINTS_PATH = join(
REPO_ROOT,
'config',
'build-plugins',
'main-build-entry-points.ts'
)
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { copyFiles, FileMatcher } = require('app-builder-lib/out/fileMatcher')
const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
require('../../src/shared/local-build-compatibility-contract.json').appId
)
})
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!notes{,/**/*}',
'!{.claude,.grok,.agents,.codex}{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
it('keeps release build staging out of app.asar while preserving runtime output', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
expect(isPacked(join('/app', '.build'), { isDirectory: () => true })).toBe(false)
for (const stagingPath of [
'.build/release-javascript/release-javascript.tar.gz',
'.build/release-javascript/manifest.json',
'.build/release-javascript-123/out/main/index.js'
]) {
expect(isPacked(join('/app', stagingPath), { isDirectory: () => false })).toBe(false)
}
for (const runtimePath of [
'out/main/index.js',
'out/cli/index.js',
'out/renderer/index.html'
]) {
expect(isPacked(join('/app', runtimePath), { isDirectory: () => false })).toBe(true)
}
})
it('keeps local agent tooling out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const toolingPath of [
'.grok/skills/review-and-submit/review-and-submit/SKILL.md',
'.claude/skills/review-and-submit/review-and-submit/SKILL.md',
'.agents/skills/electron/SKILL.md',
'.codex/sessions/session.json'
]) {
expect(packs(toolingPath)).toBe(false)
}
expect(packs('out/main/index.js')).toBe(true)
})
it.each(['file', 'directory'])('keeps a root notes %s out of app.asar', async (kind) => {
const root = await mkdtemp(join(tmpdir(), 'orca-packaging-notes-'))
const source = join(root, 'app')
const destination = join(root, 'selected')
const runtimePaths = [
'package.json',
'out/main/index.js',
'out/renderer/index.html',
'out/cli/index.js',
'out/shared/index.js',
'out/main/notes/index.js',
'out/renderer/assets/notes/help.md',
'resources/notes/help.md',
'notes.txt'
]
const notesPaths =
kind === 'file'
? ['notes']
: [
'notes/build.log',
'notes/installed-orca-backup/Orca.exe',
'notes/installed-orca-backup/resources/app.asar',
'notes/orca-windows-setup.exe',
'notes/.recovery/state.json'
]
try {
for (const fixturePath of [...runtimePaths, ...notesPaths]) {
const file = join(source, fixturePath)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, 'synthetic fixture\n')
}
if (kind === 'directory') {
await mkdir(join(source, 'notes', 'empty'))
}
const matcher = new FileMatcher(
source,
destination,
(value) => value,
electronBuilderConfig.files
)
// copyFiles adds the default include and prunes excluded directories during traversal.
await copyFiles([matcher])
for (const runtimePath of runtimePaths) {
expect(await readFile(join(destination, runtimePath), 'utf8')).toBe('synthetic fixture\n')
}
const isPacked = matcher.createFilter()
for (const notesPath of new Set(['notes', ...notesPaths])) {
const file = join(source, notesPath)
expect(isPacked(file, await lstat(file)), notesPath).toBe(false)
}
expect(existsSync(join(destination, 'notes'))).toBe(false)
} finally {
await rm(root, { recursive: true, force: true })
}
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
it('keeps plugin authoring examples out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
// copyFiles() prepends this itself once the pattern list is all-negation.
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const authoringOnly of [
'examples/plugins/hostile-panel/panel.html',
'examples/plugins/hostile-panel/orca-plugin.json',
'examples/plugins/hello-orca/main.mjs',
'examples/plugins/hello-orca/orca-plugin.json'
]) {
expect(packs(authoringOnly)).toBe(false)
}
// The negation stays anchored at the app root, so nested `examples` segments still ship.
expect(packs('out/main/examples/index.js')).toBe(true)
})
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
it('keeps cached dev Electron bundles out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const devBundlePath of [
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
]) {
expect(packs(devBundlePath)).toBe(false)
}
// The real build outputs sit beside it under out/ and must still ship.
expect(packs('out/main/index.js')).toBe(true)
expect(packs('out/renderer/index.html')).toBe(true)
})
// Why: an AV verdict on the bundled relay.js used to take app.asar with it as a
// compound object, gutting the install (#20966). resources/relay is the only copy
// a packaged build resolves, so the asar copy was 14MB of pure blast radius.
it('keeps the relay bundles out of app.asar and ships them only through extraResources', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const relayPath of [
'out/relay/linux-x64/relay.js',
'out/relay/win32-x64/relay.js',
'out/relay/darwin-arm64/relay-watcher.js',
'out/relay/wsl/wsl-agent-hook-relay.js'
]) {
expect(packs(relayPath)).toBe(false)
}
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'out/relay',
to: 'relay'
})
}
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
to: 'plugins/launch'
})
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
expect(electronBuilderConfig[platform].extraResources).toEqual(
expect.arrayContaining([bundledPluginResources])
)
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
})
])
)
expect(electronBuilderConfig.linux.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-linux/runtime.py',
to: 'computer-use-linux/runtime.py'
})
])
)
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
}),
expect.objectContaining({
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
})
])
)
})
it('ships one macOS serve-sim package through the runtime closure', () => {
const serveSimResources = electronBuilderConfig.mac.extraResources.filter((resource) =>
[join('node_modules', 'serve-sim'), 'serve-sim'].includes(resource.to)
)
expect(serveSimResources).toEqual([
expect.objectContaining({ to: join('node_modules', 'serve-sim') })
])
})
// Why: serve-sim's addon is a Mach-O, and Windows signing rejects every *.node that is not PE.
it('keeps serve-sim out of the Windows and Linux runtime closures', () => {
const {
PACKAGED_RUNTIME_PACKAGE_ROOTS,
createPackagedRuntimeNodeModuleResources
} = require('../packaged-runtime-node-modules.cjs')
expect(PACKAGED_RUNTIME_PACKAGE_ROOTS).not.toContain('serve-sim')
const serveSimTarget = join('node_modules', 'serve-sim')
expect(createPackagedRuntimeNodeModuleResources('linux').map((r) => r.to)).not.toContain(
serveSimTarget
)
expect(electronBuilderConfig.linux.extraResources.map((r) => r.to)).not.toContain(
serveSimTarget
)
expect(electronBuilderConfig.win.extraResources.map((r) => r.to)).not.toContain(serveSimTarget)
})
// Why: the Windows CLI shim is delivered only via extraResources to
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
// source tree is also packed into app.asar it gets extracted by
// asarUnpack:['resources/**'] to app.asar.unpacked/resources/win32/bin/orca.cmd,
// a duplicate with no adjacent orca.exe that fails to launch (#7351).
it('keeps the Windows CLI shim source tree out of app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining(['!resources/win32{,/**/*}'])
)
// Regression guard: the working shim must still ship via extraResources.
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
})
])
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('ships the mac keyboard-layout helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
to: 'MacOS/orca-keyboard-layout'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-keyboard-layout' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining([
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/claude-accounts/keychain.js'
])
)
})
// Why: without the unpacked entry the watcher client silently falls back to
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
it('unpacks the forked parcel-watcher process entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
)
})
it('unpacks the replaceable WSL transcript filesystem process entry', async () => {
const entryFilename = 'wsl-transcript-fs-process-entry.js'
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
const entryPoints = await readFile(MAIN_BUILD_ENTRY_POINTS_PATH, 'utf8')
expect(entryPoints).toMatch(
new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)
)
})
// Why: the CLI runs this entry under ELECTRON_RUN_AS_NODE, which cannot read
// app.asar, so a packed copy sends every `orca serve` to Electron (#27307).
it('unpacks the orcad serve-selection entry the CLI runs', async () => {
const sharedSource = await readFile(
join(REPO_ROOT, 'src', 'shared', 'orcad-local-serve-selection.ts'),
'utf8'
)
const entryName = sharedSource.match(/ORCAD_LOCAL_SERVE_SELECTION_ENTRY = '([^']+)'/)?.[1]
expect(entryName).toBeDefined()
const matcher = new FileMatcher(
'/app',
'/dest',
(value) => value,
electronBuilderConfig.asarUnpack
)
const isUnpacked = matcher.createFilter()
expect(
isUnpacked(join('/app', 'out', 'main', `${entryName}.js`), { isDirectory: () => false })
).toBe(true)
})
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
// invisible to it and a packed worker entry fails closed — dropping every
// OpenCode session in packaged builds while dev stays green. Three legs must
// agree on the filename, so all three are read rather than hardcoded.
it('unpacks the foreign SQLite reader entry the scanner service runs OpenCode reads on', async () => {
const entryPathSource = await readFile(
join(SRC_MAIN_DIR, 'foreign-sqlite-readers', 'foreign-sqlite-reader-entry-path.ts'),
'utf8'
)
const entryFilename = entryPathSource.match(
/FOREIGN_SQLITE_READER_ENTRY_FILENAME = '([^']+)'/
)?.[1]
expect(entryFilename).toBeDefined()
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
// Why: the emitted path comes from the rollup input key under
// entryFileNames '[name].js', not from the source filename — renaming the
// key alone would leave the other two legs agreeing on a file that no
// longer exists.
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toContain("entryFileNames: '[name].js'")
const entryPoints = await readFile(MAIN_BUILD_ENTRY_POINTS_PATH, 'utf8')
expect(entryPoints).toMatch(
new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)
)
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
it('matches the Linux desktop entry to Electron window class', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
it('uses the release artifact set as local Linux targets without changing existing names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' })
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
expect(electronBuilderConfig.rpm).toMatchObject({
packageName: 'orca-ide',
artifactName: 'orca-ide-${version}.${arch}.${ext}'
})
})
it('retains electron-builder runtime dependencies in deb and rpm packages', () => {
for (const target of ['deb', 'rpm']) {
const dependencies = electronBuilderConfig[target].depends
expect(dependencies).toEqual(
expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target))
)
expect(new Set(dependencies).size).toBe(dependencies.length)
}
})
it('validates each AppImage before electron-builder publishes it', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-'))
try {
const appImage = join(root, 'orca-linux.AppImage')
await writeFile(appImage, 'not an ELF')
await chmod(appImage, 0o755)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 })
).toThrow(/ELF header is outside/)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LINUX_ARM64_RELEASE
try {
delete require.cache[configPath]
process.env.ORCA_LINUX_ARM64_RELEASE = '1'
expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe(
'orca-linux-arm64.${ext}'
)
} finally {
if (original === undefined) {
delete process.env.ORCA_LINUX_ARM64_RELEASE
} else {
process.env.ORCA_LINUX_ARM64_RELEASE = original
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('overrides packaged semver only for local macOS builds', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LOCAL_BUILD_VERSION
const originalMacRelease = process.env.ORCA_MAC_RELEASE
try {
delete require.cache[configPath]
delete process.env.ORCA_MAC_RELEASE
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
version: '1.4.159-rc.0.local.123.abc'
})
} finally {
if (originalMacRelease === undefined) {
delete process.env.ORCA_MAC_RELEASE
} else {
process.env.ORCA_MAC_RELEASE = originalMacRelease
}
if (original === undefined) {
delete process.env.ORCA_LOCAL_BUILD_VERSION
} else {
process.env.ORCA_LOCAL_BUILD_VERSION = original
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('never applies local semver to release packaging', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
const originalMacRelease = process.env.ORCA_MAC_RELEASE
try {
delete require.cache[configPath]
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
process.env.ORCA_MAC_RELEASE = '1'
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
} finally {
if (originalLocalVersion === undefined) {
delete process.env.ORCA_LOCAL_BUILD_VERSION
} else {
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
}
if (originalMacRelease === undefined) {
delete process.env.ORCA_MAC_RELEASE
} else {
process.env.ORCA_MAC_RELEASE = originalMacRelease
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
})
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
// quietly goes back to true, and no unit test notices.
describe('linux root-package update recovery contract', () => {
// FpmTarget writes resources/package-type only for targets it supports auto-update for.
const MARKER_TARGETS = new Set(['deb', 'rpm', 'pacman'])
const RECOVERABLE_TARGETS = new Set(['deb', 'rpm'])
const linuxTargets = electronBuilderConfig.linux.target.map((entry) =>
typeof entry === 'string' ? entry : entry.target
)
it('still ships an AppImage plus at least one root-package target', () => {
expect(linuxTargets).toContain('AppImage')
expect(linuxTargets.some((target) => MARKER_TARGETS.has(target))).toBe(true)
})
it('ships no root-package target the recovery path cannot recover', () => {
const unrecoverable = linuxTargets.filter(
(target) => MARKER_TARGETS.has(target) && !RECOVERABLE_TARGETS.has(target)
)
expect(unrecoverable).toEqual([])
})
it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => {
const source = await readFile(
require.resolve('app-builder-lib/out/targets/FpmTarget'),
'utf8'
)
expect(source).toContain('path.join(resourceDir, "package-type"), target')
for (const target of RECOVERABLE_TARGETS) {
expect(electronBuilderConfig[target]).toBeDefined()
}
})
})
})
describe('arch-aware packaging guard', () => {
// electron-builder Arch enum: ia32=0, x64=1, armv7l=2, arm64=3.
const HOST_ARCH = process.arch === 'arm64' ? 3 : 1
const OTHER_ARCH = process.arch === 'arm64' ? 1 : 3
const OTHER_ARCH_NAME = process.arch === 'arm64' ? 'x64' : 'arm64'
const SHERPA_PLATFORM = process.platform === 'win32' ? 'win' : process.platform
const otherSherpa = `sherpa-onnx-${SHERPA_PLATFORM}-${OTHER_ARCH_NAME}`
// beforePack also hash-verifies the mobile web bundle, which the unit-test job never builds.
// Point it at a real bundle built into a temp dir: these tests are about the native-variant
// guard, and the bundle guard has its own suite.
let scratch
let bundleDir
beforeAll(async () => {
scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-'))
bundleDir = join(scratch, 'mobile-web')
await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
})
afterAll(async () => {
await rm(scratch, { recursive: true, force: true })
})
const packHost = (arch) =>
electronBuilderConfig.beforePack(
{ electronPlatformName: process.platform, arch },
bundleDir,
() => undefined
)
it('allows packaging the host platform and architecture', () => {
expect(() => packHost(HOST_ARCH)).not.toThrow()
})
it('requires the other architecture natives to be installed', () => {
const otherSherpaInstalled = existsSync(
join(REPO_ROOT, 'node_modules', otherSherpa, 'package.json')
)
const otherSherpaExpected = Object.hasOwn(
require('../../package.json').optionalDependencies,
otherSherpa
)
if (otherSherpaExpected && !otherSherpaInstalled) {
expect(() => packHost(OTHER_ARCH)).toThrow(otherSherpa)
expect(() => packHost(OTHER_ARCH)).toThrow('pnpm install:release')
expect(() => packHost(HOST_ARCH)).not.toThrow()
} else if (process.platform !== 'win32') {
expect(() => packHost(OTHER_ARCH)).not.toThrow()
}
})
// The host's own build is the foreign-arch case: a Windows checkout compiles these for its CPU only.
it.runIf(process.platform === 'win32')(
'refuses a Windows workspace addon built for another architecture',
() => {
for (const binary of ['orca_windows_registry.node', 'orca_windows_security.node']) {
expect(() => packHost(OTHER_ARCH)).toThrow(`${binary} for ${OTHER_ARCH_NAME}`)
}
expect(() => packHost(HOST_ARCH)).not.toThrow()
}
)
it('requires installed Windows addons for Windows packaging', () => {
const windowsAddon = electronBuilderConfig.win.extraResources.some(
(resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree')
)
const packWindows = () =>
electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }, bundleDir)
if (process.platform === 'win32' || windowsAddon) {
expect(packWindows).not.toThrow()
} else {
expect(packWindows).toThrow('@vscode/windows-process-tree')
expect(packWindows).toThrow('Windows packaging requires a Windows host')
// A workspace link exists everywhere; only its compiled binary proves the addon will load.
expect(packWindows).toThrow('@orca/windows-security')
}
})
})