fix(windows): separate updater from orchestration migration (#11405)

* fix(windows): separate updater from orchestration migration

* fix(terminal): attest adopted reveal identity

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin 2026-07-29 15:23:06 -07:00 committed by GitHub
parent 857f28554b
commit dde72f85de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 44 additions and 255 deletions

View File

@ -128,7 +128,6 @@ jobs:
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--require-distinct-artifacts `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE

View File

@ -13,26 +13,6 @@
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!include "getProcessInfo.nsh"
Var pid
!macro customCheckAppRunning
${if} ${isUpdated}
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
Pop $0
${if} $0 != 0
DetailPrint "Orca update release proof failed with status $0."
SetErrorLevel 2
Quit
${endIf}
${else}
!insertmacro IS_POWERSHELL_AVAILABLE
!insertmacro _CHECK_APP_RUNNING
${endIf}
!macroend
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'

View File

@ -5266,15 +5266,15 @@
"daemon",
"ssh"
],
"coverageNotes": "Deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, federated non-reveal, and fail-closed distinct Windows package proof. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"coverageNotes": "Deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"
],
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, SSH fallback parity, and distinct packaged installer paths, versions, and hashes.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
@ -5291,8 +5291,7 @@
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"src/main/ssh/ssh-remote-orca-cli.test.ts",
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
"tools/win-update-e2e/installer-pair-proof.test.mjs"
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts"
],
"assertionRefs": [
{
@ -5373,13 +5372,6 @@
"sleeping-session, resume-claim, and pending-startup state are cleared after adoption",
"no second spawn, resume argv, input, signal, interruption, or duplicate tab occurs after workspace re-entry"
]
},
{
"file": "tools/win-update-e2e/installer-pair-proof.test.mjs",
"assertions": [
"distinct package proof records different paths, versions, and SHA-256 hashes",
"identical paths, bytes, versions, or unreadable versions fail closed"
]
}
],
"evidenceRuns": [
@ -5396,10 +5388,10 @@
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"result": "passed",
"durationSeconds": 2.72,
"summary": "Three focused files passed with 39 tests, covering authority-aware legacy affordances, federated non-reveal, and fail-closed distinct packaged installer proof."
"summary": "Two focused files passed with 34 tests, covering authority-aware legacy affordances and federated non-reveal."
},
{
"date": "2026-07-28",
@ -5440,13 +5432,12 @@
"Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.",
"Run distinct installed A/B artifacts through separate headed and headless paired-runtime cutover journeys, plus a git-independent folder workspace parameter.",
"Add Docker SSH restart/reconnect proof and physical Windows WSL correct-distro/wrong-distro proof.",
"Gate a packaged Windows real-updater journey with a blocked packaged CLI ask, launcher status 75, exact native/WSL resume commands, and one question/reply.",
"Attach saved intentional-break artifacts for hidden local presentation and dropped stable-pane delivery."
],
"knownGaps": [
"No distinct installed A/B headed or headless paired-runtime cutover is attached; the restart journey relaunches the same build while preserving the daemon and agent.",
"No Docker SSH restart/reconnect, physical Windows WSL distro-authority, or git-independent folder-workspace cutover journey is attached.",
"The Windows workflow now rejects non-distinct package inputs, but it does not yet drive the packaged updater with a blocked packaged CLI ask or prove status 75 and native/WSL resume.",
"Packaged Windows updater and uninstaller continuity is owned by separate updater reliability work and is not claimed by this gate.",
"The Electron journey uses a deterministic fake Codex CLI rather than a real account.",
"The local presentation journey tolerates its existing terminal-handle remint and proves continuity by PTY/incarnation/tab/leaf; byte-stable handle proof across a real A/B cutover awaits the runtime-authority/RPC implementation.",
"The local Electron restart proof covers retained visible output from the same-build daemon checkpoint, not transcript recovery after a transport cut or distinct A/B runtime replacement."

View File

@ -417,28 +417,10 @@ status. The command name comes only from the validated inherited
than rendered. The launcher propagates status 75. No long wait starts on this
path.
The candidate installer owns the backward edge: after the desktop has shut
down, it may terminate only remaining processes whose resolved executable path
is the exact installed Orca executable before replacement. This releases an
already-blocked A CLI that cannot know the new marker. Its accepted question
remains durable; retrying the exact old ask under B reconstructs the same
pending question and prints the resume command. If launcher restart
supervision can be proved, it may transparently reconnect instead, but
seamless install cannot depend on that optimization.
After terminating the exact installed child, the installer waits boundedly for
its exact descendant native launcher to exit and for the installed executable
lock to clear. Replacement fails explicitly if either release cannot be
proved; it never proceeds from a broad process-name match or an assumed delay.
A non-skipped packaged Windows A-to-B test uses the real launcher and updater,
cuts a blocked ask, verifies post-commit output flush and status 75 propagation
on native and packaged WSL marker-aware paths, including an executable
`orca-ide` WSL resume command. It separately proves the candidate installer
terminates an exact-path marker-unaware A child and observes the exact launcher
exit plus lock release. The test fails if either release is unknown. It then
installs B, resumes the same question, and records one question/reply. This
fallback is product behavior, not test-only guidance.
This compatibility path makes the accepted question recoverable without
claiming Windows updater or uninstaller process ownership. Generic executable
drain, replacement, and uninstall behavior remains separate updater
reliability work and requires its own Windows artifact proof.
## Rendering and authoritative guidance
@ -510,7 +492,7 @@ Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
coordinator focus; and remains inert after switching away and back. This
candidate/candidate fixture covers the renderer regression deterministically
but does not replace the distinct installed-A/candidate-B packaged cutover
journey below.
journey below. It does not claim packaged Windows updater behavior.
Use distinct installed A and B artifacts plus an external append-only fake
agent spawn ledger:
@ -558,11 +540,9 @@ Run separate remote journeys:
- a git-independent folder workspace parameter through the same adoption
journey.
The Windows test is a non-skipped PR/release gate. It installs distinct A and
candidate-B packages, drives the real packaged updater while a packaged
`orca.exe` ask is blocked, proves the launcher and child release, verifies A
and B hashes/versions differ, resumes the same question on B, and records
exactly one question and reply.
Packaged Windows updater continuity is outside this migration gate. A separate
updater change must prove candidate-built A/B install, executable release,
resume, and normal uninstall behavior on Windows before making that guarantee.
## Implementation slices

View File

@ -25,27 +25,4 @@ describe('packaged Windows CLI launcher asset', () => {
expect(source).toContain('child.WaitForExit();')
expect(source).toContain('return child.ExitCode;')
})
it('requires exact updater process and executable release proof', () => {
const includePath = join(process.cwd(), 'config', 'nsis', 'daemon-host-uninstall.nsh')
const source = readFileSync(includePath, 'utf8')
expect(source).toContain('!macro customCheckAppRunning')
expect(source).toContain('!include "getProcessInfo.nsh"')
expect(source).toContain('Var pid')
expect(source).toContain('[String]::Equals')
expect(source).toContain('"$INSTDIR\\Orca.exe" "$INSTDIR\\resources\\bin\\orca.exe"')
expect(source).toContain('CreationDate=[string]$$parent.CreationDate')
expect(source).toContain("'ProcessId=' + $$proof.ProcessId")
expect(source).toContain('[IO.FileShare]::None')
expect(source).toContain('$$released=$$false')
expect(source).toContain('$$released=$$true')
expect(source).not.toMatch(/(?<!\$)(?:\$\$)*\$(?:false|true)\b/)
expect(source).toContain(
' ${else}\n !insertmacro IS_POWERSHELL_AVAILABLE\n !insertmacro _CHECK_APP_RUNNING\n ${endIf}'
)
expect(source).toContain('SetErrorLevel 2')
expect(source).toContain('Quit')
expect(source).not.toContain('taskkill /IM Orca.exe')
})
})

View File

@ -30,7 +30,7 @@ export type HarnessStoreState = {
export function createHarnessStoreState(
overrides: Partial<HarnessStoreState> & Pick<HarnessStoreState, 'tabsByWorktree'>
): HarnessStoreState {
return {
const state: HarnessStoreState = {
createTab: vi.fn(() => ({ id: 'tab-minted' })),
setActiveView: vi.fn(),
setActiveWorktree: vi.fn(),
@ -87,6 +87,14 @@ export function createHarnessStoreState(
},
...overrides
}
if (overrides.setTabLayout === undefined) {
state.setTabLayout = vi.fn(
(tabId: string, layout: HarnessStoreState['terminalLayoutsByTabId'][string]) => {
state.terminalLayoutsByTabId[tabId] = layout
}
)
}
return state
}
/** Subscription no-ops for every listener useIpcEvents attaches beyond the ones under test. */

View File

@ -7,6 +7,10 @@ import {
const WORKTREE_ID = 'wt-1'
function revealIdentity(tabId: string) {
return { worktreeId: WORKTREE_ID, tabId, leafId: 'leaf-b', ptyId: 'pty-b' }
}
function revealSplitPaneFromMobile(harness: {
createTerminal: (request: {
requestId?: string
@ -49,7 +53,8 @@ describe('mobile terminal reveal tab adoption', () => {
expect(harness.replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'mobile-reveal',
tabId: 'tab-split',
title: 'codex'
title: 'codex',
identity: revealIdentity('tab-split')
})
})
@ -70,7 +75,8 @@ describe('mobile terminal reveal tab adoption', () => {
expect(harness.replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'mobile-reveal',
tabId: 'tab-split',
title: 'codex'
title: 'codex',
identity: revealIdentity('tab-split')
})
})
@ -99,7 +105,8 @@ describe('mobile terminal reveal tab adoption', () => {
expect(harness.replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'mobile-reveal',
tabId: 'tab-detached',
title: 'codex'
title: 'codex',
identity: revealIdentity('tab-detached')
})
})
@ -199,7 +206,8 @@ describe('mobile terminal reveal tab adoption', () => {
expect(harness.replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'mobile-reveal',
tabId: 'tab-detached',
title: 'codex'
title: 'codex',
identity: revealIdentity('tab-detached')
})
})

View File

@ -1582,11 +1582,12 @@ export function useIpcEvents(): void {
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
}
if (requestId) {
// Why: attest the actual binding; recovery callers compare it with their expected identity.
const identity =
ptyId && tabId && leafId
? verifyTerminalRevealIdentity(useAppStore.getState(), {
worktreeId,
tabId,
tabId: tab.id,
leafId,
ptyId
})

View File

@ -54,7 +54,6 @@ Or directly: `node tools/win-update-e2e/run.mjs --from ... --to ... --expect ...
| `--install-dir <path>` | Isolated-install mode (see below) — install into `<path>` |
| `--asset-pattern <glob>` | gh asset glob (default `*windows-setup.exe`) |
| `--soak-seconds <n>` | Post-relaunch window-watch soak (default `180`) |
| `--require-distinct-artifacts` | Require distinct N/B paths, versions, and SHA-256 hashes |
| `--keep-install` | Skip teardown/uninstall for debugging (ignored in isolated) |
### Profiles
@ -211,14 +210,6 @@ powershell -File tools/win-update-e2e/window-enum.ps1
## Known limitations
- **This is installer/session scaffolding, not the orchestration cutover gate.**
`--require-distinct-artifacts` makes the existing Windows update journey fail
closed unless it received two provably different packages. A CI follow-up
must still drive the packaged updater with a blocked packaged `orca.exe` ask,
prove launcher status `75` and the exact native/WSL resume command, then
record one question and reply on B. That proof requires distinct installed
A/B artifacts and a physical Windows host with WSL; this harness does not
simulate either contract.
- **Scrollback fidelity is best-effort.** A production build renders the
terminal with WebGL, so xterm text is not reliably in the DOM and the e2e
`SerializeAddon` is not exposed. When text cannot be read the check reports

View File

@ -52,9 +52,6 @@ Options:
--keep-install Skip teardown/uninstall (leaves the app installed)
--asset-pattern <glob> gh release asset glob (default: *windows-setup.exe)
--soak-seconds <n> Post-relaunch window watch duration (default: 180)
--require-distinct-artifacts
Fail unless N and N+1 have distinct paths, versions,
and SHA-256 hashes. Intended for compatibility gates.
-h, --help Show this help
`
@ -74,7 +71,6 @@ export function parseArgs(argv) {
installDir: takeValue(argv, '--install-dir'),
keepInstall: argv.includes('--keep-install'),
allowExistingInstall: argv.includes('--allow-existing-install'),
requireDistinctArtifacts: argv.includes('--require-distinct-artifacts'),
usage: USAGE
}

View File

@ -1,35 +0,0 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import path from 'node:path'
async function sha256(filePath) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk)
}
return hash.digest('hex')
}
export async function proveDistinctInstallerPair({ fromPath, toPath, fromVersion, toVersion }) {
const [fromHash, toHash] = await Promise.all([sha256(fromPath), sha256(toPath)])
const proof = {
from: { path: path.resolve(fromPath), version: fromVersion, sha256: fromHash },
to: { path: path.resolve(toPath), version: toVersion, sha256: toHash }
}
const errors = []
if (proof.from.path.toLowerCase() === proof.to.path.toLowerCase()) {
errors.push('base and candidate installer paths are identical')
}
if (fromHash === toHash) {
errors.push('base and candidate installer SHA-256 hashes are identical')
}
if (!fromVersion || !toVersion) {
errors.push('both installer versions must be readable')
} else if (fromVersion === toVersion) {
errors.push(`base and candidate installer versions are identical (${fromVersion})`)
}
if (errors.length > 0) {
throw new Error(`Distinct installer proof failed: ${errors.join('; ')}`)
}
return proof
}

View File

@ -1,88 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseArgs } from './cli-args.mjs'
import { proveDistinctInstallerPair } from './installer-pair-proof.mjs'
const scratchDirs = []
async function artifactPair(fromBody = 'artifact-a', toBody = 'artifact-b') {
const directory = await mkdtemp(path.join(os.tmpdir(), 'orca-installer-pair-proof-'))
scratchDirs.push(directory)
const fromPath = path.join(directory, 'orca-a.exe')
const toPath = path.join(directory, 'orca-b.exe')
await Promise.all([writeFile(fromPath, fromBody), writeFile(toPath, toBody)])
return { fromPath, toPath }
}
afterEach(async () => {
await Promise.all(scratchDirs.splice(0).map((directory) => rm(directory, { recursive: true })))
})
describe('packaged installer pair proof', () => {
it('requires an explicit compatibility-gate flag', () => {
const opts = parseArgs([
'--from',
'a.exe',
'--to',
'b.exe',
'--expect',
'cold-restore',
'--require-distinct-artifacts'
])
expect(opts.requireDistinctArtifacts).toBe(true)
})
it('records distinct paths, versions, and hashes', async () => {
const pair = await artifactPair()
const proof = await proveDistinctInstallerPair({
...pair,
fromVersion: '1.4.130',
toVersion: '1.4.131'
})
expect(proof.from.path).not.toBe(proof.to.path)
expect(proof.from.version).not.toBe(proof.to.version)
expect(proof.from.sha256).not.toBe(proof.to.sha256)
})
it('fails closed for byte-identical artifacts at different paths', async () => {
const pair = await artifactPair('same-artifact', 'same-artifact')
await expect(
proveDistinctInstallerPair({
...pair,
fromVersion: '1.4.130',
toVersion: '1.4.131'
})
).rejects.toThrow('SHA-256 hashes are identical')
})
it('fails closed for an identical path or version', async () => {
const pair = await artifactPair()
await expect(
proveDistinctInstallerPair({
fromPath: pair.fromPath,
toPath: pair.fromPath,
fromVersion: '1.4.130',
toVersion: '1.4.130'
})
).rejects.toThrow(/paths are identical.*hashes are identical.*versions are identical/)
})
it('fails closed when either package version is unavailable', async () => {
const pair = await artifactPair()
await expect(
proveDistinctInstallerPair({
...pair,
fromVersion: null,
toVersion: '1.4.131'
})
).rejects.toThrow('both installer versions must be readable')
})
})

View File

@ -12,13 +12,7 @@ import { execFileSync } from 'node:child_process'
import { assertWin32 } from './platform-guard.mjs'
import { parseArgs } from './cli-args.mjs'
import { preflight } from './preflight.mjs'
import {
getExeVersion,
resolveInstaller,
silentInstall,
silentUninstall
} from './installer-steps.mjs'
import { proveDistinctInstallerPair } from './installer-pair-proof.mjs'
import { resolveInstaller, silentInstall, silentUninstall } from './installer-steps.mjs'
import { backupInstallState, restoreInstallState } from './registry-shortcut-backup.mjs'
import {
launchInstalledApp,
@ -183,24 +177,6 @@ async function runProof(ctx, args) {
releaseTag: opts.fromRelease,
assetPattern: opts.assetPattern
})
const toInstaller = resolveInstaller({
localPath: opts.to,
releaseTag: opts.toRelease,
assetPattern: opts.assetPattern
})
if (opts.requireDistinctArtifacts) {
const artifactProof = await proveDistinctInstallerPair({
fromPath: fromInstaller,
toPath: toInstaller,
fromVersion: getExeVersion(fromInstaller),
toVersion: getExeVersion(toInstaller)
})
log(
'artifact-proof',
`A ${artifactProof.from.version} ${artifactProof.from.sha256}; ` +
`B ${artifactProof.to.version} ${artifactProof.to.sha256}`
)
}
log('install-base', `installing ${fromInstaller}`)
const base = silentInstall(fromInstaller, { installDir })
// Track the install now (not only after the update at L238) so a failure
@ -254,6 +230,11 @@ async function runProof(ctx, args) {
log('watch', `started (duration ${watchDuration}s) -> ${watchOut}`)
// --- Update: install N+1 ---
const toInstaller = resolveInstaller({
localPath: opts.to,
releaseTag: opts.toRelease,
assetPattern: opts.assetPattern
})
log('update', `installing ${toInstaller}`)
const updated = silentInstall(toInstaller, { installDir })
// Record the exact dir the harness installed into so non-isolated teardown