mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(windows): sign the NSIS uninstaller via SignPath
`Uninstall Orca.exe` ships NotSigned, and MDE's whole update cluster is
that one file: electron-builder copies it to `old-uninstaller.exe` and
runs it silently during every update.
The cause is narrower than "NSIS generates the uninstaller at install
time". app-builder-lib already builds the uninstaller in its own makensis
pass and calls `packager.signIf(uninstallerPath)` on it before embedding
it (NsisTarget.computeScriptAndSignUninstaller). Orca signs nothing during
electron-builder — SignPath signs afterwards, behind a human approval — so
that hook is a no-op and the file is deleted before CI can reach it.
Use the hook as a relay instead of a signer: the first Windows build
exports the uninstaller, it rides the existing inner-binaries SignPath
request (no third approval wait), and the rebuild-from-signed-tree pass
swaps the signed bytes back in before makensis embeds them.
Every added step is fail-open. A missing export, a SignPath artifact
configuration that does not cover `uninstaller/`, or a relay error costs
only the uninstaller signature — the inner-binary chain and the shipped
installer are unchanged.
* fix(windows): keep the uninstaller relay out of the packed checkout
Review fixes on the uninstaller signing chain.
The export path lived at `${{ github.workspace }}\uninstaller-signing\`.
`files` in the electron-builder config is all-negation, so app-builder
prepends `**/*` and packs whatever is left in the checkout root, and the
build step retries up to three times — attempt 1 wrote the file after
packing, attempts 2 and 3 would have packed an unsigned `.exe` into
app.asar. All seven relay sites move to `runner.temp`, and a contract test
now fails if any of them points back into the checkout.
The uninstaller staging block guarded with `Test-Path` but left `New-Item`
and `Copy-Item` able to throw. That step's outcome gates the upload of
every inner binary, so a locked file there would have cost all of them
their signatures — worse than before the chain existed. It is wrapped in
try/catch, asserted.
Also: test `signWindowsUninstallerViaSignPath` itself (it runs in a step
with no continue-on-error, so its no-throw property is load-bearing) and
the sha1+sha256 double invocation; make the rehearsal verify the
uninstaller the installer actually writes to disk rather than only the
relay receipt, whose digest comparison is equal by construction; correct
the staged-name comment, which asserted a collision that does not
reproduce; count what was reported rather than what was extracted; and
note two traps — a custom sign hook replaces signtool outright, and the
single-env-var relay would race if a second NSIS target or arch is added.
* fix(windows): stop the signing rehearsal failing on its own artefact
The rehearsal is the merge gate for this chain, so it must not be able to
fail on something that is not the thing under test.
It trusted whatever 7-Zip's NSIS handler emitted. That handler produces
partial or garbled output on some NSIS builds, and a truncated extract
would score NotSigned and be reported as "the shipped uninstaller is
unsigned" when nothing was wrong. It now has to reproduce the digest the
sign hook recorded before its output is trusted; otherwise it falls
through to the silent-install route, which is ground truth. A name miss
falls through the same way.
The install route only checked the signature. Comparing the on-disk file
against the receipt is what actually proves the shipped installer embedded
the SignPath-signed bytes — the release job's own comparison is equal by
construction, so this is the only place the claim is really tested.
Also: bound the silent install (a bare `-Wait` on an installer that ever
prompts hangs to the 360-minute job cap) and poll before stopping Orca,
since the oneClick installer launches the app as it finishes and the
process can appear after the installer has already exited.
Two smaller ones: `-ErrorAction Stop` on the staging New-Item/Copy-Item so
the catch above them does not depend on GitHub's $ErrorActionPreference
default; and the relay-path test now counts every occurrence rather than
the first, so a step carrying two paths cannot root one in RUNNER_TEMP and
leave the other bare-relative — the exact shape of the bug it guards.
* test(windows): stop a pre-existing elevate.exe defect masking the gate
The first real rehearsal (run 33484703381) proved the uninstaller relay
works end to end — the 7-Zip route read the embedded uninstaller, the
digest guard did not trip, SignPath accepted the new uninstaller/ zip
entry, and the shipped `Uninstall Orca.exe` came back signed.
It also failed, on `resources\elevate.exe`, for a reason that predates
this PR. app-builder-lib re-copies the pristine cached elevate.exe over
`resources\elevate.exe` on every nsis pack — `AppPackageHelper.packArch`
calls `elevateHelper.copy()` before `buildAppPackage`, and
`CopyElevateHelper.copy` does `copyFile(elevatePath, outFile, false)` then
`signIf(outFile)`, which signs nothing because this build configures no
certificate. The signed copy restored into win-unpacked is clobbered by
the rebuild.
That is not the sign hook displacing a signtool call: with no `sign` hook,
`signFile` already returned false at "no signing info identified", so
nothing was signing elevate.exe before either. release-cut.yml mitigates
it separately by pre-seeding the electron-builder cache; this workflow has
no such step, which is why the clobber is visible here and not there.
Downgrade elevate.exe alone to advisory so it cannot mask the uninstaller
result, and record it in the evidence artifact so downgrading stays
distinguishable from deleting the check. Both uninstaller verdicts stay
fatal, pinned by a contract test that also holds the escape hatch to
exactly one file. The underlying defect gets its own PR — it is a UAC
elevation helper and deserves more scrutiny than a footnote here.
* docs(windows): warn against relaxing the elevate.exe cache guard
The tempting edit, for anyone who finds the rehearsal red on
resources\elevate.exe, is to relax release-cut's `Valid` +
`CN=SignPath Foundation` guard so the cache swap runs under test-signing
and the rehearsal goes green.
That guard is the only thing stopping a test certificate from being seeded
into a cache a real release restores from — both workflows share the key
`electron-builder-win-<lockfile hash>`. Shipping users a binary signed by
"Test certificate for 'Orca agent ide [OSS]'" is worse than shipping it
unsigned, so say so at the place someone would make that edit.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
188 lines
6.7 KiB
JavaScript
188 lines
6.7 KiB
JavaScript
import { createRequire } from 'node:module'
|
|
import { resolve } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { collectDevChannelPackagingProblems } from './verify-dev-channel-packaging.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const CONFIG_PATH = resolve(import.meta.dirname, '../electron-builder.config.cjs')
|
|
|
|
/** The config reads process.env at require time, so each identity needs a fresh load. */
|
|
function loadConfigWithEnv(env) {
|
|
const saved = { ...process.env }
|
|
for (const key of Object.keys(process.env)) {
|
|
if (key.startsWith('ORCA_')) {
|
|
delete process.env[key]
|
|
}
|
|
}
|
|
Object.assign(process.env, env)
|
|
try {
|
|
delete require.cache[require.resolve(CONFIG_PATH)]
|
|
return require(CONFIG_PATH)
|
|
} finally {
|
|
process.env = saved
|
|
delete require.cache[require.resolve(CONFIG_PATH)]
|
|
}
|
|
}
|
|
|
|
const WIN_ADHOC_ENV = {
|
|
ORCA_WIN_ADHOC: '1',
|
|
ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203'
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete require.cache[require.resolve(CONFIG_PATH)]
|
|
})
|
|
|
|
describe('electron-builder dev-channel identity', () => {
|
|
it('keeps the SignPath publisherName on stable Windows builds', () => {
|
|
const config = loadConfigWithEnv({})
|
|
|
|
expect(config.win.signtoolOptions.publisherName).toBe('SignPath Foundation')
|
|
expect(config.win.verifyUpdateCodeSignature).toBeUndefined()
|
|
expect(config.publish.repo).toBe('orca')
|
|
expect(config.publish.releaseType).toBe('release')
|
|
})
|
|
|
|
// The whole point of the change: an unsigned build that advertised a
|
|
// publisherName would Authenticode-verify — and reject — every installer it
|
|
// ever downloaded, including its own way back to stable.
|
|
it('drops the publisherName and disables update signature checks on Windows dev builds', () => {
|
|
const config = loadConfigWithEnv(WIN_ADHOC_ENV)
|
|
|
|
expect(config.win.signtoolOptions?.publisherName).toBeUndefined()
|
|
expect(config.win.verifyUpdateCodeSignature).toBe(false)
|
|
})
|
|
|
|
// Why on every channel: the hook is the only handle electron-builder gives on
|
|
// the NSIS uninstaller, and it signs nothing — it relays the file to and from
|
|
// the CI SignPath request. Carrying it must not drag a publisherName onto a
|
|
// dev build, which is the failure the split above exists to prevent.
|
|
it('carries the uninstaller sign hook without changing publisherName semantics', () => {
|
|
for (const env of [{}, WIN_ADHOC_ENV]) {
|
|
const config = loadConfigWithEnv(env)
|
|
expect(typeof config.win.signtoolOptions.sign).toBe('function')
|
|
}
|
|
expect(loadConfigWithEnv({}).win.signtoolOptions.publisherName).toBe('SignPath Foundation')
|
|
expect(loadConfigWithEnv(WIN_ADHOC_ENV).win.signtoolOptions.publisherName).toBeUndefined()
|
|
})
|
|
|
|
it.each([
|
|
['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'],
|
|
['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'],
|
|
['adhoc', { ORCA_WIN_ADHOC: '1' }, 'orca-adhoc']
|
|
])('publishes %s Windows builds to its own repo as a prerelease', (_channel, env, repo) => {
|
|
const config = loadConfigWithEnv(env)
|
|
|
|
expect(config.publish.repo).toBe(repo)
|
|
expect(config.publish.releaseType).toBe('prerelease')
|
|
})
|
|
|
|
// Why: ORCA_MAC_* gates hardened runtime, notarization, and root-level
|
|
// forceCodeSigning. If the Windows variables leaked into that, the Windows job
|
|
// would fail packaging for want of a cert it deliberately does not use.
|
|
it('leaves mac release signing off for Windows dev builds', () => {
|
|
const config = loadConfigWithEnv(WIN_ADHOC_ENV)
|
|
|
|
expect(config.forceCodeSigning).toBe(false)
|
|
expect(config.mac.notarize).toBe(false)
|
|
expect(config.mac.hardenedRuntime).toBe(false)
|
|
})
|
|
|
|
it('still notarizes mac dev builds', () => {
|
|
const config = loadConfigWithEnv({
|
|
ORCA_MAC_ADHOC: '1',
|
|
ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203'
|
|
})
|
|
|
|
expect(config.mac.notarize).toBe(true)
|
|
expect(config.publish.repo).toBe('orca-adhoc')
|
|
})
|
|
})
|
|
|
|
describe('collectDevChannelPackagingProblems', () => {
|
|
const goodWinConfig = {
|
|
publish: { repo: 'orca-adhoc', releaseType: 'prerelease' },
|
|
extraMetadata: { version: '1.4.178-adhoc.20260819010203' },
|
|
win: { verifyUpdateCodeSignature: false }
|
|
}
|
|
const env = { ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203' }
|
|
|
|
it('accepts a correctly configured Windows dev build', () => {
|
|
expect(
|
|
collectDevChannelPackagingProblems({
|
|
channel: 'adhoc',
|
|
platform: 'win32',
|
|
config: goodWinConfig,
|
|
env
|
|
})
|
|
).toEqual([])
|
|
})
|
|
|
|
// The failure this script exists for: a branch predating Windows dev builds
|
|
// resolves publish.repo to the main repo.
|
|
it('rejects a config that resolved the main repo', () => {
|
|
const problems = collectDevChannelPackagingProblems({
|
|
channel: 'adhoc',
|
|
platform: 'win32',
|
|
config: { ...goodWinConfig, publish: { repo: 'orca', releaseType: 'release' } },
|
|
env
|
|
})
|
|
|
|
expect(problems.join('\n')).toContain('must publish to "orca-adhoc"')
|
|
expect(problems.join('\n')).toContain('rebase it onto a main that does')
|
|
})
|
|
|
|
it('rejects a Windows dev build that still advertises a publisherName', () => {
|
|
const problems = collectDevChannelPackagingProblems({
|
|
channel: 'adhoc',
|
|
platform: 'win32',
|
|
config: {
|
|
...goodWinConfig,
|
|
win: { signtoolOptions: { publisherName: 'SignPath Foundation' } }
|
|
},
|
|
env
|
|
})
|
|
|
|
expect(problems.join('\n')).toContain('verifyUpdateCodeSignature must be false')
|
|
expect(problems.join('\n')).toContain('publisherName is set to "SignPath Foundation"')
|
|
})
|
|
|
|
it('rejects a build packaging a version other than the tag the workflow created', () => {
|
|
const problems = collectDevChannelPackagingProblems({
|
|
channel: 'adhoc',
|
|
platform: 'win32',
|
|
config: { ...goodWinConfig, extraMetadata: { version: '1.4.178' } },
|
|
env
|
|
})
|
|
|
|
expect(problems.join('\n')).toContain('but the workflow computed')
|
|
})
|
|
|
|
// macOS builds are signed, so the Windows-only assertions must not fire there.
|
|
it('does not apply Windows signature rules to a mac dev build', () => {
|
|
expect(
|
|
collectDevChannelPackagingProblems({
|
|
channel: 'adhoc',
|
|
platform: 'darwin',
|
|
config: {
|
|
publish: { repo: 'orca-adhoc', releaseType: 'prerelease' },
|
|
extraMetadata: { version: '1.4.178-adhoc.20260819010203' },
|
|
win: { signtoolOptions: { publisherName: 'SignPath Foundation' } }
|
|
},
|
|
env
|
|
})
|
|
).toEqual([])
|
|
})
|
|
|
|
it('rejects an unknown channel', () => {
|
|
expect(
|
|
collectDevChannelPackagingProblems({
|
|
channel: 'nightly',
|
|
platform: 'win32',
|
|
config: goodWinConfig,
|
|
env
|
|
})
|
|
).toEqual(['Unknown dev channel "nightly"; expected one of hourly, daily, adhoc.'])
|
|
})
|
|
})
|