qwen-code/packages/cli/src/utils/handleAutoUpdate.ts
易良 9e4bea606a
feat(cli): add standalone auto-update support (#4629)
* feat(cli): add standalone auto-update support

Standalone installs (via install-qwen-standalone.sh) previously fell
through to npm global detection, causing auto-update to run
`npm install -g` which doesn't update the standalone binary.

Now the CLI detects standalone installs by looking for manifest.json
in ancestor directories, then performs a self-update: download archive
from OSS/GitHub, verify SHA256, extract, and atomically replace the
installation directory.

Includes: input validation (semver + target allowlist), lockfile for
concurrency, Windows deferred replacement via helper script, and tar
path traversal protection.

Closes #4627

* feat(cli): harden standalone update — smoke test, rollback, signature

- Smoke test: spawn new binary with --version after extraction,
  before replacing the installation. Rejects broken/wrong-arch packages.
- Rollback: keep .old directory after updates. Export
  rollbackStandaloneUpdate() for future /doctor rollback integration.
- Signature: Ed25519 verification of SHA256SUMS via node:crypto.
  Embeds public key; CI signs with scripts/sign-release.sh.
  Graceful degradation when .sig is not yet published (transition period).
  Set QWEN_REQUIRE_SIGNATURE=1 to enforce strict mode.

* fix(cli): address CI + review findings on standalone update

- Fix TS4111: use bracket notation for process.env index signature
- Fix CodeQL: validate paths for cmd.exe metacharacters before bat script
- Fix Windows lock race: keep lock alive during deferred swap, release
  in bat script after move completes

* fix(cli): use event payload in update UI + add rollback tests

- handleAutoUpdate: handleUpdateFailed/handleUpdateSuccess now read
  the message from the event payload instead of hardcoding text.
  Windows deferred users now see the correct "applied after exit" message.
- Add standalone-update.test.ts with 4 tests covering rollback scenarios.

* fix(cli): address review findings — 5 bugs in standalone-update

1. SHA256SUMS: handle GNU coreutils binary-mode `*` prefix in filename
2. acquireLock: treat NaN (empty/corrupt lock file) as stale, not held
3. tryFetch: consume response body on non-OK status to release sockets
4. Windows bat: add 30s timeout to PID wait loop preventing infinite hang
5. Finally block: clean orphaned .new directory on Windows error path

* fix(cli): update handleAutoUpdate tests for payload-driven messages

Tests previously asserted hardcoded handler text. After making handlers
read messages from event payload, the assertions need to reflect the
emitted payload values instead.

* feat(cli): auto-migrate npm users to standalone when prefix is not writable

When a global npm install requires sudo (prefix not writable), the
auto-update now falls back to the standalone path instead of spawning
a doomed `npm install -g` that always EACCES.

- installationInfo.ts: detect writable prefix with fs.accessSync;
  return isStandalone=true with fallback dir when not writable
- standalone-update.ts: support first-time migration (no existing
  manifest), detect platform target, create bin wrapper, refuse to
  overwrite unmanaged directories

Closes #4643

* fix(cli): remove unused _mockedAccessSync (TS6133)

* fix(cli): address wenshao review — Windows deferred, PATH, rollback, headers

- Fix Windows deferred update: move pendingDir cleanup to catch block
  so it only runs on error, not on successful deferred path
- Add ensurePathInShellRc: auto-append ~/.local/bin to shell rc on
  npm→standalone migration so the wrapper is actually discoverable
- Wire rollbackStandaloneUpdate into /doctor rollback subcommand
- Add license headers to all 4 new source files
- Fix spawnAndCapture double-settle with settled flag
- Add tests for ensureBinWrapper and ensurePathInShellRc
- Clarify signature verification comments (test key, not production)

* fix(cli): add i18n translations for /doctor rollback command

The mustTranslateKeys test requires zh-CN and zh-TW translations for
all built-in command descriptions. Add translations for the new
rollback subcommand and its user-facing messages.

* fix(cli): skip Unix-only wrapper tests on Windows

The ensureBinWrapper Unix-wrapper test asserts on POSIX file mode bits
(mode & 0o111) which Windows NTFS does not implement. Skip Unix-specific
tests when running on Windows runners.

* fix(cli): address CodeQL + test coverage on standalone-update

- Add assertPathWithin() and resolve install paths before spawning the
  smoke-test process. Closes the CodeQL "shell command built from
  environment values" finding by guaranteeing nodeBin/cliBin stay
  inside the freshly extracted install directory.
- Add performStandaloneUpdate test coverage for the four pre-flight
  failure cases (bad version, missing manifest, unknown target,
  concurrent lock) so the core update path is no longer untested.

* fix(cli): address wenshao review round 2 — bat error handling, rollback safety, shell injection, lock race, test coverage

Five issues raised in PR #4629 review (2026-06-01):

1. Windows bat: add errorlevel checks after each move, rollback on
   second-move failure, timestamped log to qwen-update.log
2. Unix rollback: inner try/catch on recovery rename; compound error
   with manual mv instructions if both renames fail
3. Shell injection: assertSafeForShellEmbed() validates paths before
   embedding in /bin/sh wrappers and shell rc (platform-aware: allows
   backslash on Windows)
4. Lock-file race: bat writes .swap sentinel before rename; acquireLock
   refuses to reclaim stale-PID lock while sentinel exists
5. Test coverage: 4 new handleAutoUpdate standalone-path tests (done,
   deferred, error, npm-not-entered) + 3 shell-safety rejection tests

* fix(cli): prevent symlink attack on staging directory via mkdtempSync

Replace fixed-name .qwen-code-update-staging with mkdtempSync random
suffix — an attacker who can write to parentDir can no longer pre-create
a symlink to hijack recursive deletion.

Also adds lstat guard before cleanup rmSync (defense-in-depth) and fixes
a potential tempDir leak if mkdtempSync for extractDir fails.

* fix(cli): harden standalone-update — download size guard, shell-meta coverage, cleanup logging

- Add MAX_DOWNLOAD_BYTES (512 MB) streaming size guard in downloadToFile
  to prevent resource exhaustion from oversized responses
- Extend UNSAFE_SHELL_META_UNIX to also reject semicolon and single-quote
- Add debugLogger.warn when extractDir cleanup is skipped (unexpected type)
- Add Windows backslash acceptance test for assertSafeForShellEmbed

* fix(cli): harden standalone-update — tar preservePaths, timeout signal, shell-rc validation

Defense-in-depth improvements from independent code review:
- Explicitly set preservePaths:false on tar extract to prevent symlink path traversal
- Check err.signal in spawnAndCapture for cross-platform timeout detection
- Validate binDir in ensurePathInShellRc independently of calling context

* fix(cli): address review round 3 — archive timeout, stderr capture, rollback result type

1. Separate ARCHIVE_TIMEOUT_MS (5 min) from FETCH_TIMEOUT_MS (30s) so
   large archive downloads on slow networks don't time out prematurely
2. Capture stderr from smoke-test execFile and include it in the error
   message so users see the actual crash reason (missing lib, etc.)
3. Change rollbackStandaloneUpdate return from boolean to discriminated
   result type (ok/reason/detail) so doctorCommand shows accurate
   messages for each failure mode instead of a generic "not found"

* fix(test): drop pre-mkdir in shell-safety double-quote test

The 'rejects standaloneDir with embedded double-quote' test called
fs.mkdirSync on a path containing `"`, which ENOENTs on Windows since
NTFS forbids `"` in path components. The validator
assertSafeForShellEmbed runs synchronously before any FS access in
ensureBinWrapper, so the pre-mkdir was vestigial — removing it makes
the test pass on Windows without weakening the assertion.

* refactor(standalone-update): remove over-engineered defenses

Remove theoretical security mechanisms that solve near-impossible
attack scenarios but introduce real complexity and new bug surface:

- sentinel mechanism (solves a <2s race window requiring 3 independent
  low-probability events to align)
- assertSafeForShellEmbed + UNSAFE_SHELL_META regexes (default paths
  are ~/.local/lib/qwen-code, never contain metacharacters)
- assertPathWithin (execFile doesn't invoke shell; CodeQL false positive)
- lstatSync symlink guard (mkdtempSync random naming already prevents
  predictable symlink pre-creation)

Retains unsafeCmdChars in atomicReplace (bat script interpolation is a
real context), bat error handling, archive timeout, and stderr capture.

* fix(cli): harden standalone-update error paths and lock release

* fix(cli): address multi-model review findings on standalone-update

Security:
- Preserve per-mirror error details in downloadWithFallback diagnostics
- Deduplicate SHA256SUMS.sig download via downloadWithFallback
- Upgrade signature-skip log from info to warn with enforcement hint
- Add post-extraction path traversal validation for Windows ZIP
- Add symlink target validation in tar extraction filter
- Add shell metacharacter validation (assertSafeForShellEmbed) for
  binDir in ensurePathInShellRc and standaloneDir in ensureBinWrapper
- Validate cmd.exe metacharacters BEFORE filesystem mutation in
  atomicReplace Windows path

Correctness:
- Fix spawnAndCapture treating string error codes as exit 0
- Release lock when mkdtempSync for extractDir fails
- Add concurrent lock protection to rollbackStandaloneUpdate
- Fix misleading manual-recovery message after auto-recovery succeeds
- Fix t() dynamic i18n key — split into translated prefix + raw detail
- Reject unsupported architectures in detectTarget instead of silent
  fallback to linux-x64

Performance:
- Compute SHA256 hash during download stream (single-pass), eliminating
  the second full read of the 50-150 MB archive

Quality:
- Fix macOS bash shell rc priority (.bash_profile over .bashrc)
- Rename npmPrefixDir to npmPackageDir for accuracy
- Extract magic number 3 to MAX_MANIFEST_SEARCH_DEPTH constant

Tests:
- Add rollback concurrent lock protection tests (live + dead PID)
- Add fish shell ensurePathInShellRc test
- Add shell metacharacter rejection test for ensurePathInShellRc

* fix(cli): move shell validation after platform branch in ensureBinWrapper

assertSafeForShellEmbed rejects backslashes, which are normal in Windows
paths. Move the call into the Unix-only else branch so Windows wrapper
creation uses only the cmd.exe metacharacter check.

* fix(cli): add post-extraction path validation for Unix tar

Matches the Windows zip path's validateExtractedPaths call.
The tar filter's string-level path.resolve check cannot detect chained
symlink attacks (symlink A → ".", then A/payload → "../../etc").
The post-extraction realpath walk catches these.

* fix(cli): fix standalone auto-update reachability and rebase conflicts

- Add missing isStandalone/standaloneDir fields to getStandaloneInstallInfo
  so handleAutoUpdate and /doctor rollback can detect standalone installs
- Remove redundant findStandaloneDir (conflicted with stricter detection)
- Fix race condition: move mkdirSync after lock acquisition
- Ensure parent directory exists before lock file creation
- Clean up empty .old directory after first-time npm→standalone migration
- Align bash RC file selection with install script (Linux → .bashrc)
- Use ?? instead of || for manifest.target fallback
- Upgrade ensureBinWrapper failure logging from debug to warn
- Add debug logging for SHA256SUMS.sig download failures
2026-06-04 22:53:12 +08:00

187 lines
5.7 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import { getInstallationInfo } from './installationInfo.js';
import { updateEventEmitter } from './updateEventEmitter.js';
import type { HistoryItemWithoutId } from '../ui/types.js';
import { MessageType } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import { performStandaloneUpdate } from './standalone-update.js';
import type { spawn } from 'node:child_process';
import os from 'node:os';
export function handleAutoUpdate(
info: UpdateObject | null,
settings: LoadedSettings,
projectRoot: string,
spawnFn: typeof spawn = spawnWrapper,
) {
if (!info) {
return;
}
// enableAutoUpdate is checked in gemini.tsx before calling this function,
// so if we get here, auto-update is enabled (or undefined, which defaults to enabled).
const isAutoUpdateEnabled =
settings.merged.general?.enableAutoUpdate !== false;
const installationInfo = getInstallationInfo(
projectRoot,
isAutoUpdateEnabled,
);
let combinedMessage = info.message;
if (installationInfo.updateMessage) {
combinedMessage += `\n${installationInfo.updateMessage}`;
}
updateEventEmitter.emit('update-received', {
message: combinedMessage,
});
if (
installationInfo.isStandalone &&
installationInfo.standaloneDir &&
isAutoUpdateEnabled
) {
performStandaloneUpdate(installationInfo.standaloneDir, info.update.latest)
.then((result) => {
const message =
result === 'deferred'
? 'Update downloaded. It will be applied after you exit this session.'
: 'Update successful! The new version will be used on your next run.';
updateEventEmitter.emit('update-success', { message });
})
.catch((err: Error) => {
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed: ${err.message}. Re-run the installer to update manually.`,
});
});
return;
}
// Don't automatically run the update if auto-update is disabled or no update command
if (!installationInfo.updateCommand || !isAutoUpdateEnabled) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
'@latest',
isNightly ? '@nightly' : `@${info.update.latest}`,
);
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand];
const updateProcess = spawnFn(shell, shellArgs, { stdio: 'pipe' });
let errorOutput = '';
updateProcess.stderr.on('data', (data) => {
errorOutput += data.toString();
});
updateProcess.on('close', (code) => {
if (code === 0) {
updateEventEmitter.emit('update-success', {
message:
'Update successful! The new version will be used on your next run.',
});
} else {
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually. (command: ${updateCommand}, stderr: ${errorOutput.trim()})`,
});
}
});
updateProcess.on('error', (err) => {
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
});
});
return updateProcess;
}
export function setUpdateHandler(
addItem: (item: HistoryItemWithoutId, timestamp: number) => void,
setUpdateInfo: (info: UpdateObject | null) => void,
isIdleRef: { current: boolean } = { current: true },
) {
let successfullyInstalled = false;
const pendingNotifications: HistoryItemWithoutId[] = [];
const addItemOrDefer = (item: HistoryItemWithoutId) => {
if (isIdleRef.current) {
addItem(item, Date.now());
} else {
pendingNotifications.push(item);
}
};
const handleUpdateRecieved = (info: UpdateObject) => {
setUpdateInfo(info);
const savedMessage = info.message;
setTimeout(() => {
if (!successfullyInstalled) {
addItemOrDefer({
type: MessageType.INFO,
text: savedMessage,
});
}
setUpdateInfo(null);
}, 60000);
};
const handleUpdateFailed = (data: { message?: string }) => {
setUpdateInfo(null);
addItemOrDefer({
type: MessageType.ERROR,
text:
data?.message ||
'Automatic update failed. Please try updating manually',
});
};
const handleUpdateSuccess = (data: { message?: string }) => {
successfullyInstalled = true;
setUpdateInfo(null);
addItemOrDefer({
type: MessageType.INFO,
text:
data?.message ||
'Update successful! The new version will be used on your next run.',
});
};
const handleUpdateInfo = (data: { message: string }) => {
addItemOrDefer({
type: MessageType.INFO,
text: data.message,
});
};
updateEventEmitter.on('update-received', handleUpdateRecieved);
updateEventEmitter.on('update-failed', handleUpdateFailed);
updateEventEmitter.on('update-success', handleUpdateSuccess);
updateEventEmitter.on('update-info', handleUpdateInfo);
const cleanup = () => {
updateEventEmitter.off('update-received', handleUpdateRecieved);
updateEventEmitter.off('update-failed', handleUpdateFailed);
updateEventEmitter.off('update-success', handleUpdateSuccess);
updateEventEmitter.off('update-info', handleUpdateInfo);
pendingNotifications.length = 0;
};
const flush = () => {
while (pendingNotifications.length > 0) {
const item = pendingNotifications.shift()!;
addItem(item, Date.now());
}
};
return { cleanup, flush };
}