mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* 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
187 lines
5.7 KiB
TypeScript
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 };
|
|
}
|