fix(desktop): stop built-in updates from tripping the installer legacy-data guard #1160

The NSIS installer spawned by quitAndInstall() inherits the app process
environment, including the app-managed CLAUDE_CONFIG_DIR /
CC_HAHA_APP_PORTABLE_DIR pair that applyStartupPortableMode() derives
from app-mode.json. The installer's recovery helper then re-validated
that snapshot against the persisted mode it reads via its own APPDATA
and blocked the whole upgrade on any disagreement (mode switched without
a restart, APPDATA differing from the app's known-folder view), even
though an active data directory outside every install directory cannot
be touched by removing the old version. Manually launched setups never
saw the variables, which is why they kept working.

- clear the app-managed portable env before handing off to the spawned
  installer (shared with the existing app.relaunch() cleanup), so
  built-in updates present the same clean environment as a manual setup
- downgrade the recovery helper's managed-mode consistency check from
  fail-closed to treating the directory as externally managed; the
  install-contained legacy data guard below it still refuses unsafe
  removals, and matching persisted modes behave exactly as before
This commit is contained in:
程序员阿江(Relakkes)
2026-08-01 01:35:50 +08:00
parent 867745c5ae
commit 44137ccb91
5 changed files with 134 additions and 14 deletions
+64 -8
View File
@@ -396,15 +396,28 @@ function Get-UnsafeLegacySource {
}
if ($activeOutsideInstall -and $ActiveConfigManaged -eq '1') {
# The app-managed active directory lives outside every install directory,
# so removing the old version cannot touch it. A persisted mode that is
# missing, stale, or unresolvable here (the mode was switched without a
# restart, or the installer's APPDATA differs from the app's known-folder
# view) must not block the upgrade: fall back to treating the directory as
# externally managed. Install-contained legacy data is still guarded by
# the external-active check below.
$systemMode = Read-AppMode -ConfigDir $UserDataDir
if ($null -eq $systemMode -or
$systemMode.Mode -ne 'portable' -or
[string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) {
throw 'App-managed CLAUDE_CONFIG_DIR has no matching persisted custom mode. Restart the old app before upgrading so its active and saved data directories agree.'
}
$persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json'
if (-not (Test-SamePath -Left $active -Right $persistedActive)) {
throw "App-managed CLAUDE_CONFIG_DIR does not match persisted custom mode. Active: $active; persisted: $persistedActive"
if ($null -ne $systemMode) {
if ($systemMode.Mode -ne 'portable' -or
[string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) {
$systemMode = $null
} else {
try {
$persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json'
if (-not (Test-SamePath -Left $active -Right $persistedActive)) {
$systemMode = $null
}
} catch {
$systemMode = $null
}
}
}
} elseif ($activeOutsideInstall) {
$systemMode = $null
@@ -1008,6 +1021,49 @@ function Run-SelfTest {
}
Assert-SelfTest -Condition $managedExternalInvalidFailed -Message 'invalid metadata bypassed app-managed external mode validation'
$staleModeInstall = Join-Path $testRoot 'stale mode install'
$staleModeActive = Join-Path $testRoot 'stale mode active data'
$staleModeUserData = Join-Path $testRoot 'stale mode app data'
New-Item -ItemType Directory -Path $staleModeInstall -Force | Out-Null
New-Item -ItemType Directory -Path $staleModeActive -Force | Out-Null
Write-TestMode -Dir $staleModeUserData -Value @{ mode = 'default'; portable_dir = $null }
$staleModeResult = Invoke-LegacyRecovery `
-InstallDirs @($staleModeInstall) -UserDataDir $staleModeUserData `
-RecoveryRoot (Join-Path $testRoot 'stale mode recovery') -ProcessName $ProcessName `
-ActiveConfigDir $staleModeActive -ActiveConfigManaged '1' -SkipProcessCheck
Assert-SelfTest -Condition ($null -eq $staleModeResult) -Message 'stale persisted mode blocked an upgrade whose active data directory is outside every install directory'
$mismatchInstall = Join-Path $testRoot 'mismatch mode install'
$mismatchActive = Join-Path $testRoot 'mismatch active data'
$mismatchPersisted = Join-Path $testRoot 'mismatch persisted data'
$mismatchUserData = Join-Path $testRoot 'mismatch app data'
New-Item -ItemType Directory -Path $mismatchInstall -Force | Out-Null
New-Item -ItemType Directory -Path $mismatchActive -Force | Out-Null
New-Item -ItemType Directory -Path $mismatchPersisted -Force | Out-Null
Write-TestMode -Dir $mismatchUserData -Value @{ mode = 'portable'; portable_dir = $mismatchPersisted }
$mismatchResult = Invoke-LegacyRecovery `
-InstallDirs @($mismatchInstall) -UserDataDir $mismatchUserData `
-RecoveryRoot (Join-Path $testRoot 'mismatch recovery') -ProcessName $ProcessName `
-ActiveConfigDir $mismatchActive -ActiveConfigManaged '1' -SkipProcessCheck
Assert-SelfTest -Condition ($null -eq $mismatchResult) -Message 'mismatched persisted custom mode blocked an upgrade whose active data directory is outside every install directory'
$missingModeInstall = Join-Path $testRoot 'missing mode install'
$missingModeLegacy = Join-Path $missingModeInstall 'CLAUDE_CONFIG_DIR'
$missingModeActive = Join-Path $testRoot 'missing mode active data'
New-Item -ItemType Directory -Path $missingModeLegacy -Force | Out-Null
New-Item -ItemType Directory -Path $missingModeActive -Force | Out-Null
Set-Content -LiteralPath (Join-Path $missingModeLegacy 'settings.json') -Value 'missing-mode-data' -NoNewline
$missingModeFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($missingModeInstall) -UserDataDir (Join-Path $testRoot 'missing mode app data') `
-RecoveryRoot (Join-Path $testRoot 'missing mode recovery') -ProcessName $ProcessName `
-ActiveConfigDir $missingModeActive -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null
} catch {
$missingModeFailed = $_.Exception.Message.Contains('install-contained legacy data still exists')
}
Assert-SelfTest -Condition $missingModeFailed -Message 'missing persisted mode skipped the install-contained legacy data guard'
$externalInstall = Join-Path $testRoot 'external install'
$externalLegacy = Join-Path $externalInstall 'CLAUDE_CONFIG_DIR'
$externalUserData = Join-Path $testRoot 'external app data'
+17
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
applyStartupPortableMode,
clearAppManagedPortableEnv,
determineStartupPortableDir,
getAppMode,
setAppMode,
@@ -120,6 +121,22 @@ describe('Electron app mode service', () => {
})).toThrow('outside the application install directory')
})
it('clears only an app-managed portable selection from a handed-off environment', () => {
const customDir = path.join(tempDir(), 'custom-data')
const managedEnv: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: customDir,
CC_HAHA_APP_PORTABLE_DIR: '1',
WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'),
APPDATA: 'C:\\Users\\someone\\AppData\\Roaming',
}
clearAppManagedPortableEnv(managedEnv)
expect(managedEnv).toEqual({ APPDATA: 'C:\\Users\\someone\\AppData\\Roaming' })
const externalEnv: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: customDir }
clearAppManagedPortableEnv(externalEnv)
expect(externalEnv).toEqual({ CLAUDE_CONFIG_DIR: customDir })
})
it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => {
const fakeApp = app()
writeMode(fakeApp, { mode: 'default', portable_dir: null })
+13 -5
View File
@@ -93,6 +93,18 @@ function externallyControlled(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.CLAUDE_CONFIG_DIR && env.CC_HAHA_APP_PORTABLE_DIR !== '1')
}
// The app-managed portable selection is process-local derived state; the
// persisted app-mode.json stays the source of truth. Strip it before this
// environment reaches another process (app.relaunch(), the NSIS installer
// spawned by quitAndInstall()), otherwise the child would trust a snapshot
// that may no longer match the persisted mode (#1160).
export function clearAppManagedPortableEnv(env: NodeJS.ProcessEnv = process.env): void {
if (env.CC_HAHA_APP_PORTABLE_DIR !== '1') return
delete env.CLAUDE_CONFIG_DIR
delete env.CC_HAHA_APP_PORTABLE_DIR
delete env.WEBVIEW2_USER_DATA_FOLDER
}
export function determineStartupPortableDir(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
@@ -115,11 +127,7 @@ export function applyStartupPortableMode(
): string | null {
// app.relaunch() inherits process.env. Discard the previous app-managed
// selection so the persisted two-mode record remains authoritative.
if (env.CC_HAHA_APP_PORTABLE_DIR === '1') {
delete env.CLAUDE_CONFIG_DIR
delete env.CC_HAHA_APP_PORTABLE_DIR
delete env.WEBVIEW2_USER_DATA_FOLDER
}
clearAppManagedPortableEnv(env)
if (env.CLAUDE_CONFIG_DIR) {
env.CLAUDE_CONFIG_DIR = normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR)
return null
+33
View File
@@ -182,4 +182,37 @@ describe('Electron updater service', () => {
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
})
it('hands the spawned installer an environment without the app-managed portable selection', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
updater.downloadUpdate.mockResolvedValue(undefined)
await service.checkForUpdates()
await service.downloadUpdate(() => {})
const env: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data',
CC_HAHA_APP_PORTABLE_DIR: '1',
WEBVIEW2_USER_DATA_FOLDER: 'E:\\cc-haha-data\\EBWebView',
APPDATA: 'C:\\Users\\someone\\AppData\\Roaming',
}
service.quitAndInstallDownloadedUpdate(env)
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
expect(env).toEqual({ APPDATA: 'C:\\Users\\someone\\AppData\\Roaming' })
})
it('leaves an externally supplied CLAUDE_CONFIG_DIR untouched when installing', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
updater.downloadUpdate.mockResolvedValue(undefined)
await service.checkForUpdates()
await service.downloadUpdate(() => {})
const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' }
service.quitAndInstallDownloadedUpdate(env)
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
expect(env).toEqual({ CLAUDE_CONFIG_DIR: 'E:\\external-data' })
})
})
+7 -1
View File
@@ -1,5 +1,6 @@
import type { DesktopUpdateDownloadEvent } from '../../src/lib/desktopHost/types'
import { existsSync } from 'node:fs'
import { clearAppManagedPortableEnv } from './appMode'
export type ElectronUpdateInfo = {
version: string
@@ -193,8 +194,13 @@ export class ElectronUpdaterService {
return !!this.pendingUpdate && this.downloaded
}
quitAndInstallDownloadedUpdate() {
quitAndInstallDownloadedUpdate(env: NodeJS.ProcessEnv = process.env) {
this.stageDownloadedUpdate()
// The NSIS installer spawned here inherits this process's environment.
// Hand it the same clean environment a manually launched setup gets, so
// its legacy-data checks read the persisted app-mode.json instead of a
// process-local snapshot that may disagree with it (#1160).
clearAppManagedPortableEnv(env)
this.updater.quitAndInstall(false, true)
}
}