diff --git a/.github/workflows/win-update-e2e.yml b/.github/workflows/win-update-e2e.yml index 58a27b82d..21c788256 100644 --- a/.github/workflows/win-update-e2e.yml +++ b/.github/workflows/win-update-e2e.yml @@ -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 diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh index fc3ebf464..dc3a497ce 100644 --- a/config/nsis/daemon-host-uninstall.nsh +++ b/config/nsis/daemon-host-uninstall.nsh @@ -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' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 7d3bd1717..108299288 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." diff --git a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md b/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md index b648e2601..18ceea5bf 100644 --- a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md +++ b/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md @@ -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 diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index b615e88c8..f491cb077 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -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(/(? & Pick ): 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. */ diff --git a/src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts b/src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts index 30130211c..4f5afa434 100644 --- a/src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts +++ b/src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts @@ -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') }) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 475608f4f..9d9acf188 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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 }) diff --git a/tools/win-update-e2e/README.md b/tools/win-update-e2e/README.md index cc94aefc5..6c771bb11 100644 --- a/tools/win-update-e2e/README.md +++ b/tools/win-update-e2e/README.md @@ -54,7 +54,6 @@ Or directly: `node tools/win-update-e2e/run.mjs --from ... --to ... --expect ... | `--install-dir ` | Isolated-install mode (see below) — install into `` | | `--asset-pattern ` | gh asset glob (default `*windows-setup.exe`) | | `--soak-seconds ` | 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 diff --git a/tools/win-update-e2e/cli-args.mjs b/tools/win-update-e2e/cli-args.mjs index e98ffda89..ed5041231 100644 --- a/tools/win-update-e2e/cli-args.mjs +++ b/tools/win-update-e2e/cli-args.mjs @@ -52,9 +52,6 @@ Options: --keep-install Skip teardown/uninstall (leaves the app installed) --asset-pattern gh release asset glob (default: *windows-setup.exe) --soak-seconds 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 } diff --git a/tools/win-update-e2e/installer-pair-proof.mjs b/tools/win-update-e2e/installer-pair-proof.mjs deleted file mode 100644 index f97101c0a..000000000 --- a/tools/win-update-e2e/installer-pair-proof.mjs +++ /dev/null @@ -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 -} diff --git a/tools/win-update-e2e/installer-pair-proof.test.mjs b/tools/win-update-e2e/installer-pair-proof.test.mjs deleted file mode 100644 index 203170f5e..000000000 --- a/tools/win-update-e2e/installer-pair-proof.test.mjs +++ /dev/null @@ -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') - }) -}) diff --git a/tools/win-update-e2e/run.mjs b/tools/win-update-e2e/run.mjs index d9f8d8b5b..7c8d8cde6 100644 --- a/tools/win-update-e2e/run.mjs +++ b/tools/win-update-e2e/run.mjs @@ -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