feat(cli): add standalone auto-update support - #4629
Conversation
📋 Review SummaryThis PR adds standalone auto-update support for Qwen Code installations that were installed via the standalone installer ( 🔍 General FeedbackPositive aspects:
Architectural decisions:
Potential concerns:
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
There was a problem hiding this comment.
Pull request overview
Adds standalone-install auto-update support so Qwen Code can update user-writable standalone installations without falling back to npm.
Changes:
- Detects standalone installs via
manifest.jsonand surfaces standalone update messaging. - Adds a standalone updater that downloads release archives, verifies SHA256, extracts, and replaces the install directory.
- Wires standalone auto-update into the existing update event flow and adds the
tardependency.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages/cli/src/utils/standalone-update.ts |
Implements standalone archive download, checksum verification, extraction, locking, and install replacement. |
packages/cli/src/utils/installationInfo.ts |
Adds standalone install detection and metadata to installation info. |
packages/cli/src/utils/handleAutoUpdate.ts |
Routes standalone installs through the new updater instead of package-manager commands. |
packages/cli/package.json |
Adds tar as a CLI dependency for archive extraction. |
package-lock.json |
Updates dependency metadata for the new CLI dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
wenshao
left a comment
There was a problem hiding this comment.
Review — standalone auto-update
Solid, security-conscious shape overall: atomic replace, stale-lock reclamation via PID liveness, checksum + (optional) Ed25519 signature, a smoke test before the swap, and OSS→GitHub fallback are all the right building blocks. A few issues I'd treat as blocking before this ships enabled-by-default, plus convention/coverage gaps.
🔴 High
1. The Windows deferred update is deleted by its own finally block — the Windows path can never succeed.
In atomicReplace (win32) the new install is staged at ${standaloneDir}.new (pendingDir) and a detached .bat is spawned that waits for this process to exit and then moves pendingDir → standaloneDir. But performStandaloneUpdate's finally runs synchronously, long before the process exits:
// standalone-update.ts:514-518
// Clean orphaned pendingDir if it exists (from a failed Windows path validation)
const pendingDir = `${standaloneDir}.new`;
if (fs.existsSync(pendingDir)) {
fs.rmSync(pendingDir, { recursive: true, force: true }); // ← deletes the staged update
}On the successful deferred path pendingDir is the live staged update, not a validation orphan, so this removes it while the .bat is still in its tasklist wait loop. When the .bat finally runs move /Y "<pendingDir>" "<standaloneDir>" the source is gone and the move silently fails (the script has no error handling). The user sees “Update downloaded, will apply after exit” but nothing is ever applied — consistent with the PR's pendingDir on the throw path, or skip this finally cleanup when result === 'deferred').
2. No effective supply-chain protection by default; the embedded signing key is a placeholder.
This runs automatically (enableAutoUpdate defaults to true) and downloads, executes (smoke test), and installs a binary. The only mandatory check is the SHA256 from SHA256SUMS, fetched from the same origin as the archive — so it guards against corruption, not a compromised mirror/release. The Ed25519 path is the real defense, but as shipped it's a no-op:
- The embedded key is explicitly a placeholder —
standalone-update-verify.ts:18(“Replace this with the production key generated by the release team”). - Verification only runs if
SHA256SUMS.sighappens to exist, and is only required whenQWEN_REQUIRE_SIGNATURE=1(undocumented, default off) —verifyChecksum,standalone-update.ts:94-113.
So by default a compromised release asset is downloaded and executed during the smoke test before any authenticity check that matters. Recommend either (a) gate standalone auto-update behind explicit opt-in until release CI signs with the real key and the signature is required by default, or (b) land the real key + CI signing in this same change and require the signature. A placeholder key gives the appearance of signature verification without the substance.
🟡 Medium
3. Silent npm → standalone migration can create a shadow install that isn't on PATH — installationInfo.ts:206-215.
When the npm prefix looks non-writable the code silently switches a global-npm user to a standalone install under ~/.local/lib/qwen-code and writes a ~/.local/bin/qwen wrapper. But nothing adds ~/.local/bin to PATH, and the existing npm qwen (e.g. /usr/local/bin) usually still wins resolution — so the user keeps running the old binary and the “update” appears to do nothing. Also npmPrefixDir = path.dirname(path.dirname(realPath)) is not the npm prefix; it's the package directory (~2 levels under wherever the bin resolves), and the depth is layout-dependent. Consider making the migration explicit (notice/prompt) rather than automatic.
4. Missing license headers — CI lint will fail.
The repo enforces eslint-plugin-license-header; every source file begins with the @license / Copyright 2025 Google LLC / SPDX-License-Identifier: Apache-2.0 block. All four new files (standalone-update.ts, standalone-update-verify.ts, and both *.test.ts) are missing it.
5. Bulk of the new code is untested; rollbackStandaloneUpdate is unreachable dead code.
standalone-update.test.ts covers only rollbackStandaloneUpdate. The risky core — performStandaloneUpdate, downloadWithFallback, verifyChecksum (incl. the *filename parsing), extractArchive, atomicReplace (especially the Windows branch with the bug above), acquireLock stale-reclaim, smokeTest — has no tests. Separately, rollbackStandaloneUpdate and the .qwen-rollback-info.json metadata reference a “/doctor rollback” that doesn't exist in this PR, and rollbackStandaloneUpdate has no callers outside its own test. Either wire it up or drop it from this PR.
🟢 Nits / minor
SEMVER_RE(standalone-update.ts:28) allows..in the prerelease segment (-[\w.]+);versionPathflows into a URL so it's not a real traversal and the version comes from the trusted update check, but tightening it is cheap.- Nightly handling diverges from the npm path:
handleAutoUpdatemaps@latest→@nightly(handleAutoUpdate.ts:50) but the standalone branch passesinfo.update.latestliterally. - Windows extraction uses
Expand-Archive, which doesn't get the zip-slipfilterthe tar path applies (standalone-update.ts:182) — checksum-gated, but asymmetric hardening. spawnAndCapturecan settle twice ifexecFilefires both the callback (ENOENT) and theerrorevent; today it's only safe becausesmokeTestexistsSync-checks the binary first.downloadToFilestreams with no max-size guard.- On a hard exit mid-update the
finallydoesn't run, leaking.qwen-code-update-stagingand theos.tmpdir()dir (the lock self-heals via the PID check; the dirs don't). detectTargetreturnslinux-x64for any unrecognized platform.
Happy to dig deeper on any of these.
Addressed in a2ebbabThanks for the thorough review @wenshao! Here's the status: 🔴 High — Fixed#1 Windows finally deletes pendingDir — Moved cleanup to #2 Signature placeholder — Acknowledged explicitly in code comments. The signature path is wired up and functional (verified with a test key end-to-end), but enforcement ( 🟡 Medium — Fixed#3 PATH not updated — Added #4 License headers — Added to all 4 new files. #5 rollback dead code — Wired into 🟢 Nits — Fixed
Not addressed (requires follow-up)
|
E2E Verification Report — Standalone Installer PATH PriorityDate: 2026-06-01 20:01 CST Each scenario uses an isolated Results
Terminal output (abridged)Scenario 1: Fresh environmentScenario 2: npm(0.17.0) → standalone installScenario 3: standalone(0.17.0) → user runs npm install -g (0.17.1)Scenario 4: nvm user (npm 0.16.0 in nvm path) → standaloneScenario 5: pnpm global (0.15.0) → standaloneMechanismThe installer appends a PATH block at the very end of # Qwen Code PATH block begin
export PATH='~/.local/bin':$PATH
# Qwen Code PATH block endSince zsh processes the file top-to-bottom, this Known UX gap (non-blocking)Scenario 3 shows that if a user already has standalone and then runs |
📄 Test script used (run-tests.sh)#!/bin/bash
set -euo pipefail
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALLER="$1"
REPORT="$TEST_DIR/report.txt"
> "$REPORT"
log() { echo "$@" | tee -a "$REPORT"; }
separator() { log ""; log "━━━ $1 ━━━"; log ""; }
# Scenario 1: Fresh environment
separator "场景 1: 全新环境安装 standalone"
FAKE_HOME="$TEST_DIR/home-scenario1"
mkdir -p "$FAKE_HOME"
echo '# fresh zshrc' > "$FAKE_HOME/.zshrc"
HOME="$FAKE_HOME" SHELL=/bin/zsh PATH="/usr/local/bin:/usr/bin:/bin" \
bash "$INSTALLER" 2>&1 | grep -E "(SUCCESS|WARNING|version|PATH)" || true
RESULT=$(HOME="$FAKE_HOME" SHELL=/bin/zsh zsh -c 'source ~/.zshrc 2>/dev/null; echo "which=$(which qwen) version=$(qwen --version 2>/dev/null)"')
log "$RESULT"
echo "$RESULT" | grep -q "/.local/bin/qwen" && log "✅ PASS" || log "❌ FAIL"
# Scenario 2: npm exists → standalone install
separator "场景 2: 已有 npm 安装 → 再装 standalone"
FAKE_HOME="$TEST_DIR/home-scenario2"
mkdir -p "$FAKE_HOME/.npm-global/bin"
printf '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "0.17.0"; fi\n' > "$FAKE_HOME/.npm-global/bin/qwen"
chmod +x "$FAKE_HOME/.npm-global/bin/qwen"
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' > "$FAKE_HOME/.zshrc"
HOME="$FAKE_HOME" SHELL=/bin/zsh PATH="$FAKE_HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin" \
bash "$INSTALLER" 2>&1 | grep -E "(SUCCESS|WARNING|version|PATH|Other)" || true
RESULT=$(HOME="$FAKE_HOME" SHELL=/bin/zsh zsh -c 'source ~/.zshrc 2>/dev/null; which -a qwen 2>/dev/null | while read p; do echo "$p → $($p --version 2>/dev/null)"; done')
log "$RESULT"
# Scenario 3: standalone exists → npm install later
separator "场景 3: 已有 standalone → 后 npm install -g"
FAKE_HOME="$TEST_DIR/home-scenario3"
cp -a "$TEST_DIR/home-scenario2" "$FAKE_HOME"
printf '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "0.17.1"; fi\n' > "$FAKE_HOME/.npm-global/bin/qwen"
chmod +x "$FAKE_HOME/.npm-global/bin/qwen"
RESULT=$(HOME="$FAKE_HOME" SHELL=/bin/zsh zsh -c 'source ~/.zshrc 2>/dev/null; echo "$(which qwen) → $(qwen --version 2>/dev/null)"')
log "$RESULT"
# Scenario 4: nvm user
separator "场景 4: nvm 用户"
FAKE_HOME="$TEST_DIR/home-scenario4"
mkdir -p "$FAKE_HOME/.nvm/versions/node/v22.22.0/bin"
printf '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "0.16.0"; fi\n' > "$FAKE_HOME/.nvm/versions/node/v22.22.0/bin/qwen"
chmod +x "$FAKE_HOME/.nvm/versions/node/v22.22.0/bin/qwen"
cat > "$FAKE_HOME/.zshrc" << 'RC'
export NVM_DIR="$HOME/.nvm"
export PATH="$NVM_DIR/versions/node/v22.22.0/bin:$PATH"
RC
HOME="$FAKE_HOME" SHELL=/bin/zsh PATH="$FAKE_HOME/.nvm/versions/node/v22.22.0/bin:/usr/local/bin:/usr/bin:/bin" \
bash "$INSTALLER" 2>&1 | grep -E "(SUCCESS|WARNING|Other)" || true
RESULT=$(HOME="$FAKE_HOME" SHELL=/bin/zsh zsh -c 'source ~/.zshrc 2>/dev/null; which -a qwen 2>/dev/null | while read p; do echo "$p → $($p --version 2>/dev/null)"; done')
log "$RESULT"
# Scenario 5: pnpm user
separator "场景 5: pnpm 全局安装用户"
FAKE_HOME="$TEST_DIR/home-scenario5"
mkdir -p "$FAKE_HOME/Library/pnpm"
printf '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "0.15.0"; fi\n' > "$FAKE_HOME/Library/pnpm/qwen"
chmod +x "$FAKE_HOME/Library/pnpm/qwen"
cat > "$FAKE_HOME/.zshrc" << 'RC'
export PNPM_HOME="$HOME/Library/pnpm"
export PATH="$PNPM_HOME:$PATH"
RC
HOME="$FAKE_HOME" SHELL=/bin/zsh PATH="$FAKE_HOME/Library/pnpm:/usr/local/bin:/usr/bin:/bin" \
bash "$INSTALLER" 2>&1 | grep -E "(SUCCESS|WARNING|Other)" || true
RESULT=$(HOME="$FAKE_HOME" SHELL=/bin/zsh zsh -c 'source ~/.zshrc 2>/dev/null; which -a qwen 2>/dev/null | while read p; do echo "$p → $($p --version 2>/dev/null)"; done')
log "$RESULT"
separator "测试完成: $(date)"How to reproduce: mkdir /tmp/qwen-e2e && cd /tmp/qwen-e2e
# Save the script above as run-tests.sh
chmod +x run-tests.sh
./run-tests.sh /path/to/install-qwen-standalone.sh |
E2E Verification Report — PR #4629 Standalone Auto-UpdateDate: 2026-06-01 20:55 CST Auto-Update Flow Test — 3/3 PASS
中文说明测试方法通过 场景说明场景 1: 自动更新
场景 2: 回滚
场景 3: 并发保护
与真实 TUI 的关系真实用户启动
本测试跳过了 update check API(版本比较),直接验证核心更新逻辑。 Full outputWhat was verified
|
wenshao
left a comment
There was a problem hiding this comment.
Two pre-existing type errors in handleAutoUpdate.test.ts (TS2322 at line 52, TS2540 at line 219) were detected by tsc --noEmit but fall outside the PR diff — they should be fixed separately.
…ck 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
Verification ReportBranch: Test Results
Test Coverage Summary (97 tests total)
Code Review Notes
Verdict✅ Ready to merge — all 97 tests pass across 5 test suites, typecheck is clean, both packages build successfully. The implementation covers the full update lifecycle with proper atomicity, rollback, and concurrent protection. Verified by wenshao |
| ); | ||
| messageType = 'info'; | ||
| } else { | ||
| msg = t(`Rollback failed: ${result.detail}`); |
There was a problem hiding this comment.
[Suggestion] t(\Rollback failed: ${result.detail}`)creates a dynamic i18n key containing filesystem paths (e.g.,"/home/user/.local/lib/qwen-code.old does not exist"). This will never match the registered locale key 'Rollback failed: no previous version found (.old directory missing).', so the error message always renders in English regardless of locale. The registered key in en.js, zh.js, and zh-TW.js` is dead code.
| msg = t(`Rollback failed: ${result.detail}`); | |
| msg = `${t('Rollback failed:')} ${result.detail}`; |
(Also update the locale files to register 'Rollback failed:' as the key, and remove the dead full-sentence key.)
— claude-opus-4-6 via Qwen Code /review
| ); | ||
| } | ||
| const version = stdout.trim(); | ||
| if (!SEMVER_RE.test(version)) { |
There was a problem hiding this comment.
[Suggestion] smokeTest validates that --version output matches a semver regex but never compares it to the requested newVersion. A CDN misconfiguration (or, without signature enforcement, a targeted attack) could serve an older archive at the URL for a newer version. The checksum passes (attacker controls SHA256SUMS), the smoke test passes (valid semver), and the installation is silently downgraded to a version with known vulnerabilities.
Pass newVersion into smokeTest and assert the output matches:
const expected = newVersion.replace(/^v/, '');
if (version !== expected) {
throw new Error(
`Smoke test failed: expected version ${expected}, got ${version}`,
);
}— claude-opus-4-6 via Qwen Code /review
| } | ||
|
|
||
| if (!npmPrefixWritable && isAutoUpdateEnabled) { | ||
| // npm prefix requires sudo — fall back to standalone update path |
There was a problem hiding this comment.
[Suggestion] When the npm prefix is not writable and auto-update is enabled, this code silently sets isStandalone: true with a fallback standaloneDir. This triggers performStandaloneUpdate in handleAutoUpdate.ts, which downloads to ~/.local/lib/qwen-code/, creates ~/.local/bin/qwen, and modifies .zshrc/.bashrc to prepend ~/.local/bin to PATH — all without user confirmation.
Users who installed via sudo npm install -g never opted into the standalone distribution channel. The silent shell rc modification is particularly concerning for enterprise environments with strict dotfile management.
Consider requiring an interactive confirmation before the first npm→standalone migration, or at minimum gating behind an explicit opt-in setting.
— claude-opus-4-6 via Qwen Code /review
|
|
||
| function findStandaloneDir(realPath: string): string | null { | ||
| let dir = path.dirname(realPath); | ||
| for (let i = 0; i < 3; i++) { |
There was a problem hiding this comment.
[Suggestion] findStandaloneDir() (upward manifest walk) and the npm-to-standalone migration fallback (lines 207-243: accessSync writability check, HOME='/' guard, fallback standaloneDir construction) have zero test coverage despite being the entry point for the entire standalone update feature.
The existing test file adds accessSync: vi.fn() to the mock setup but introduces no new test cases exercising these ~50 new lines. At least 5 distinct branches are uncovered: manifest found, manifest missing/malformed, npm prefix writable, npm prefix not writable with valid HOME, npm prefix not writable with HOME='/'.
— claude-opus-4-6 via Qwen Code /review
pomelo-nwu
left a comment
There was a problem hiding this comment.
Thanks for the PR, @yiliang114!
Template looks good — all required sections are in place. ✓
On direction: this lines up well with what the ecosystem is doing. Claude Code has been actively working on auto-updater reliability, native installer support (winget, native binary distribution), and CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE for non-npm installations. Standalone self-update is a natural fit for where Qwen Code is heading.
Before diving into the code, the question I always ask: is this the simplest approach? In this case, the answer is yes — downloading + verifying + atomically swapping the installation directory is the straightforward path. The Windows deferred-update bat script adds complexity, but that's a platform necessity, not an engineering choice.
Moving on to code review. 🔍
中文说明
感谢贡献,@yiliang114!
模板完整,必填项都有 ✓
方向上,这与行业趋势吻合。Claude Code 一直在推进 auto-updater 可靠性、原生安装器支持(winget、native binary distribution)以及 CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE 等非 npm 安装路径。Standalone 自更新是 Qwen Code 自然的下一步。
看代码之前我也在想:这是否是最简洁的方式?答案是肯定的——下载、校验、原子替换安装目录就是最直接的路径。Windows 延迟更新 bat 脚本确实增加了复杂度,但这是平台限制,不是工程选择。
接下来进入代码审查。🔍
— Qwen Code · qwen3.7-max
- 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.
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
Tests previously asserted hardcoded handler text. After making handlers read messages from event payload, the assertions need to reflect the emitted payload values instead.
…itable 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
…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)
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.
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.
- 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.
…ck 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
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.
…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
…l, 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
…ollback 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"
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.
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.
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
…pper 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.
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.
|
@qwen-code /triage |
- 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
da255a5 to
f408ce7
Compare
… to standalone (#5207) When the npm global prefix is not writable, getInstallationInfo (added in #4629) silently migrated the install to the standalone installer, which bundles an official Node 22 build requiring glibc >= 2.28. On older hosts (e.g. CentOS 7 / glibc 2.17) the bundled node can't load and the update fails. Keep npm installs on npm and ask the user to update with sudo.
What this PR does
Adds self-update capability for standalone installations. When Qwen Code detects it was installed via the standalone installer (not npm/pnpm/yarn), it now downloads the new release archive from OSS/GitHub, verifies its SHA256 checksum, extracts it, and atomically replaces the installation directory — all without requiring sudo or any package manager.
Key implementation details:
manifest.jsonpresence under~/.local/lib/qwen-code/.new/.oldstaging with rollback on failure.batscript (applied after process exit)ensurePathInShellRc()prepends~/.local/binto the user's shell rc fileWhy it's needed
Standalone installs (
~/.local/lib/qwen-code/) were not recognized byinstallationInfo.ts. They fell through to the npm global fallback, causing auto-update to runnpm install -g— which either fails with EACCES (if npm prefix requires root) or installs to a different location without updating the standalone binary.Reviewer Test Plan
How to verify
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bashwhich qwenpoints to~/.local/bin/qwenand~/.local/lib/qwen-code/manifest.jsonexistsmanifest.jsonto set"version": "0.0.1"(to trigger update detection)qwen— observe the update notification and auto-download behaviorEvidence (Before & After)
Auto-Update Flow E2E (direct invocation of
performStandaloneUpdate(), real OSS download):What was verified in the update flow:
qwen-code-darwin-arm64.tar.gzfrom Aliyun OSS (v0.17.0)cli.js --version→ "0.17.0" ✅.old, new dir moved into placerollbackStandaloneUpdate()swaps.oldback → version restored to 0.16.0PATH Priority E2E (real npm installs, 4 scenarios):
which qwen~/.local/bin/qwen(0.17.0)npm install -g @0.16.2→ standalone~/.npm-global/bin/qwen(0.16.2)~/.local/bin/qwen(0.17.0)npm install -g @latest~/.local/bin/qwen(0.17.0)~/.nvm/.../bin/qwen(0.16.2)~/.local/bin/qwen(0.17.0)PATH mechanism — installer appends at end of
.zshrc:Tested on
Environment (optional)
macOS Darwin 24.1.0 arm64, zsh. Real OSS download (v0.17.0). Auto-update E2E via direct
tsxinvocation ofperformStandaloneUpdate(). PATH E2E scenarios use realnpm install -g(not mocked), each in isolated$HOME.Risk & Scope
finallyblock now only cleanspendingDiron the error path (not the deferred-success path).QWEN_REQUIRE_SIGNATURE=1is opt-in.performStandaloneUpdate— better as a dedicated test PR.Linked Issues
Closes #4627
中文说明
这个 PR 做了什么
为 standalone 安装添加自更新能力。当 Qwen Code 检测到自身通过 standalone 安装器安装(非 npm/pnpm/yarn)时,会从 OSS/GitHub 下载新版本 release 归档,验证 SHA256 校验和,解压并原子替换安装目录——无需 sudo 或任何包管理器。
关键实现:
manifest.json检测 standalone 安装.new/.old暂存目录原子替换,失败时回滚.bat脚本延迟更新(进程退出后执行)ensurePathInShellRc()在用户 shell rc 文件中 prepend~/.local/bin为什么需要
Standalone 安装(
~/.local/lib/qwen-code/)未被installationInfo.ts识别,会错误回退到 npm global——要么 EACCES 失败(npm prefix 需要 root),要么安装到不同位置而不更新 standalone 二进制。评审测试计划
如何验证
which qwen指向~/.local/bin/qwenmanifest.jsonversion 为0.0.1触发更新检测qwen,观察更新下载行为证据(修复前后对比)
自动更新流程 E2E(直接调用
performStandaloneUpdate(),真实 OSS 下载):验证了完整链路:下载 → SHA256 校验 → Ed25519 签名验证(graceful skip)→ 解压 → smoke test → 原子替换 → 回滚 → 并发锁。
PATH 优先级 E2E(真实 npm,4 个场景): 全新环境、npm→standalone、standalone→npm、nvm 用户,standalone 始终优先。
测试平台
风险与范围
finally块现在仅在错误路径清理pendingDir(不影响延迟成功路径)。performStandaloneUpdate的完整 mock 集成测试——留作专门 PR。关联 Issue
Closes #4627