feat(installer): add standalone hosted install and uninstall flow#3828
Conversation
wenshao
left a comment
There was a problem hiding this comment.
Requesting changes due to a deterministic script test failure.
— gpt-5.5 via Qwen Code /review
|
@copilot pls review for me |
| fi | ||
| ;; | ||
| *.tar.gz|*.tgz|*.tar.xz) | ||
| if ! entries=$(tar -tf "${archive_path}"); then |
There was a problem hiding this comment.
[Critical] tar -tf does not show symlink targets (both GNU tar and macOS bsdtar omit the -> target portion), so validate_archive_entry_path never sees symlink entries and cannot reject them. Then at line 936, tar -xzf follows symlinks during extraction on Linux (GNU tar default). A crafted archive with a symlink entry (e.g., qwen-code/evil -> /etc) followed by a regular file entry traversing that symlink (e.g., qwen-code/evil/cron.d/backdoor) achieves arbitrary file write before the post-extraction find -type l check at line 948 can detect it. This is a textbook tar-symlink vulnerability (same root cause as CVEs in pip, npm tar, etc.). macOS is safe because bsdtar refuses to extract through symlinks.
Fix: use tar -tvf (verbose listing shows -> target) to reject any symlink entries before extraction:
| if ! entries=$(tar -tf "${archive_path}"); then | |
| *.tar.gz|*.tgz|*.tar.xz) | |
| if tar -tvf "${archive_path}" 2>/dev/null | grep -q ' -> '; then | |
| log_error "Archive contains symlinks; refusing to install." | |
| return 1 | |
| fi | |
| if ! entries=$(tar -tf "${archive_path}"); then | |
| log_error "Failed to inspect archive entries: ${archive_path}" | |
| return 1 | |
| fi | |
| ;; |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| { stdio: 'inherit' }, | ||
| ); | ||
|
|
||
| if (result.error) { |
There was a problem hiding this comment.
[Critical] The result.error branch (ossutil missing from PATH, fork failure, etc.) is never exercised in tests. The test shim always spawns a Node process that exits cleanly (status 0 or 1), so spawnSync never populates result.error. If ossutil is absent in CI, the error propagation behavior (throwing without retry) is unverified.
Add a test where ossutilCommand points to a nonexistent binary and assert the thrown error matches ENOENT without retrying.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| config, | ||
| { ossutilCommand, ossutilCommandArgs }, | ||
| ) { | ||
| for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { |
There was a problem hiding this comment.
[Critical] The retry logic only tests two scenarios: success on first attempt, and failure after all 3 attempts. The transient-failure-then-success path (attempt 1 fails, attempt 2 succeeds) — the core value of the retry mechanism — is never tested. A regression in the retry loop (wrong backoff, off-by-one, missing return after success) would go undetected.
Add a test with an ossutil shim that tracks invocation count in a file and exits 1 on the first call, 0 on subsequent calls.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| PRERELEASE_FLAG="--prerelease" | ||
| fi | ||
|
|
||
| mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) |
There was a problem hiding this comment.
[Suggestion] Bash mapfile silently swallows failures from the process substitution <(...), even under set -euo pipefail. If verify-installation-release.js exits non-zero, release_assets is empty and gh release create proceeds with only dist/cli.js — publishing a release with no standalone archives.
Add a guard:
if [[ ${#release_assets[@]} -eq 0 ]]; then
echo "::error::Release asset verification produced no assets"
exit 1
fi— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| ) | ||
|
|
||
| set "EXPECTED_HASH=" | ||
| for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( |
There was a problem hiding this comment.
[Suggestion] The for /f loop iterates all lines in SHA256SUMS and keeps the first match (if "!EXPECTED_HASH!"=="" guard). While "first match wins" is safer than "last match wins", the loop doesn't break early. Batch for /f doesn't support break, but you can goto out of the loop after finding the first match to avoid processing remaining lines.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| function fetchWithTimeout(fetchImpl, url, options = {}) { | ||
| return fetchImpl(url, { | ||
| ...options, | ||
| signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), |
There was a problem hiding this comment.
[Suggestion] AbortSignal.timeout(30_000) covers the entire request lifecycle: DNS, TCP, TLS, headers, and body consumption. In fetchSha256, the body is a 50-150MB archive streamed through pipeline(). If the total download exceeds 30s (slow CDN, rate limiting), the abort fires mid-stream and the error is reported as "unavailable release asset" rather than a timeout.
Consider increasing REMOTE_FETCH_TIMEOUT_MS to 300_000 (matching curl --max-time 300 used elsewhere) or applying a separate timeout for body streaming.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| run: |- | ||
| set -euo pipefail | ||
|
|
||
| hosted_assets=( |
There was a problem hiding this comment.
[Suggestion] This hardcoded hosted_assets=() array must stay in sync with two other locations: (1) HOSTED_INSTALLATION_ASSETS in build-hosted-installation-assets.js and (2) the for asset in ... loop in the "Verify Aliyun OSS Hosted Installation Assets" step. Adding a new hosted installer to the build script without updating this array means the new file silently never reaches the CDN.
Consider deriving the upload list from the build output (e.g., node scripts/build-hosted-installation-assets.js --list-output-names) instead of hardcoding.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| set "QWEN_DOWNLOAD_DEST=%~2" | ||
| rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it); | ||
| rem fall back to Invoke-WebRequest (which shows its own progress bar). | ||
| powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" |
There was a problem hiding this comment.
[Suggestion] The .bat installer spawns PowerShell as a subprocess ~10-12 times during a typical installation (ValidateRawEnvironmentOptions, ValidateOptions, ValidateVersion, RaceMirrorHead, DownloadFile, VerifyChecksum, CreateTempDir, ValidateArchiveContents, MaybeUpdateUserPath, etc.). Each PowerShell process spawn costs 1-3 seconds, adding an estimated 15-30 seconds of overhead. The .sh equivalent uses native bash builtins for most operations.
Consider consolidating validation into a single PowerShell invocation, or writing a temporary .ps1 that performs all validation/download/checksum in one process.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| fi | ||
| ;; | ||
| *.tar.gz|*.tgz|*.tar.xz) | ||
| if ! entries=$(tar -tf "${archive_path}"); then |
There was a problem hiding this comment.
[Critical] tar -tf does not show symlink targets (both GNU tar and macOS bsdtar omit the -> target portion), so validate_archive_entry_path never sees symlink entries and cannot reject them. Then at line 936, tar -xzf follows symlinks during extraction on Linux (GNU tar default). A crafted archive with a symlink entry (e.g., qwen-code/evil -> /etc) followed by a regular file entry traversing that symlink (e.g., qwen-code/evil/cron.d/backdoor) achieves arbitrary file write before the post-extraction find -type l check at line 948 can detect it. This is a textbook tar-symlink vulnerability (same root cause as CVEs in pip, npm tar, etc.). macOS is safe because bsdtar refuses to extract through symlinks.
Fix: use tar -tvf (verbose listing shows -> target) to reject any symlink entries before extraction:
| if ! entries=$(tar -tf "${archive_path}"); then | |
| *.tar.gz|*.tgz|*.tar.xz) | |
| if tar -tvf "${archive_path}" 2>/dev/null | grep -q ' -> '; then | |
| log_error "Archive contains symlinks; refusing to install." | |
| return 1 | |
| fi | |
| if ! entries=$(tar -tf "${archive_path}"); then | |
| log_error "Failed to inspect archive entries: ${archive_path}" | |
| return 1 | |
| fi | |
| ;; |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| --config "${RUNNER_TEMP}/.ossutilconfig" \ | ||
| --prefix "installation/${RELEASE_TAG}" \ | ||
| "${hosted_assets[@]}" | ||
| node scripts/upload-aliyun-oss-assets.js \ |
There was a problem hiding this comment.
[Critical] This ossutil cp call bypasses the retry wrapper (upload-aliyun-oss-assets.js), unlike every other OSS upload in the workflow. If it fails transiently after gh release create has already succeeded, re-running the workflow hits a duplicate-tag error — the workflow cannot make progress without manual intervention (delete the GitHub release or manually upload VERSION).
Route through upload-aliyun-oss-assets.js or replicate the retry loop inline.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| { stdio: 'inherit' }, | ||
| ); | ||
|
|
||
| if (result.error) { |
There was a problem hiding this comment.
[Critical] The result.error branch (ossutil missing from PATH, fork failure, etc.) is never exercised in tests. The test shim always spawns a Node process that exits cleanly (status 0 or 1), so spawnSync never populates result.error. If ossutil is absent in CI, the error propagation behavior (throwing without retry) is unverified.
Add a test where ossutilCommand points to a nonexistent binary and assert the thrown error matches ENOENT without retrying.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| config, | ||
| { ossutilCommand, ossutilCommandArgs }, | ||
| ) { | ||
| for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { |
There was a problem hiding this comment.
[Critical] The retry logic only tests two scenarios: success on first attempt, and failure after all 3 attempts. The transient-failure-then-success path (attempt 1 fails, attempt 2 succeeds) — the core value of the retry mechanism — is never tested. A regression in the retry loop (wrong backoff, off-by-one, missing return after success) would go undetected.
Add a test with an ossutil shim that tracks invocation count in a file and exits 1 on the first call, 0 on subsequent calls.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| set -euo pipefail | ||
|
|
||
| printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" | ||
| ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read |
There was a problem hiding this comment.
[Critical] If this second upload (global installation/ prefix) fails partway through, users get a mix of old archives and new SHA256SUMS (or vice versa), causing checksum verification failures for anyone running irm .../installation/install-qwen-standalone.ps1 | iex. The versioned prefix is safe, but the global alias is not atomic.
Fix: reorder the hosted_assets array so SHA256SUMS is first (not last), ensuring checksums are uploaded before archives. A partial failure then leaves old-but-consistent state (new SHA256SUMS referencing new archives that weren't uploaded yet — old archives still match old SHA256SUMS).
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| PRERELEASE_FLAG="--prerelease" | ||
| fi | ||
|
|
||
| mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) |
There was a problem hiding this comment.
[Critical] This ossutil cp call bypasses the retry wrapper (upload-aliyun-oss-assets.js), unlike every other OSS upload in the workflow. If it fails transiently after gh release create has already succeeded, re-running the workflow hits a duplicate-tag error — the workflow cannot make progress without manual intervention (delete the GitHub release or manually upload VERSION).
Route through upload-aliyun-oss-assets.js or replicate the retry loop inline.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| ) | ||
|
|
||
| set "EXPECTED_HASH=" | ||
| for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( |
There was a problem hiding this comment.
[Suggestion] The for /f loop iterates all lines in SHA256SUMS and keeps the first match (if "!EXPECTED_HASH!"=="" guard). While "first match wins" is safer than "last match wins", the loop doesn't break early. Batch for /f doesn't support break, but you can goto out of the loop after finding the first match to avoid processing remaining lines.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| function fetchWithTimeout(fetchImpl, url, options = {}) { | ||
| return fetchImpl(url, { | ||
| ...options, | ||
| signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), |
There was a problem hiding this comment.
[Suggestion] AbortSignal.timeout(30_000) covers the entire request lifecycle: DNS, TCP, TLS, headers, and body consumption. In fetchSha256, the body is a 50-150MB archive streamed through pipeline(). If the total download exceeds 30s (slow CDN, rate limiting), the abort fires mid-stream and the error is reported as "unavailable release asset" rather than a timeout.
Consider increasing REMOTE_FETCH_TIMEOUT_MS to 300_000 (matching curl --max-time 300 used elsewhere) or applying a separate timeout for body streaming.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| run: |- | ||
| set -euo pipefail | ||
|
|
||
| hosted_assets=( |
There was a problem hiding this comment.
[Critical] If this second upload (global installation/ prefix) fails partway through, users get a mix of old archives and new SHA256SUMS (or vice versa), causing checksum verification failures for anyone running irm .../installation/install-qwen-standalone.ps1 | iex. The versioned prefix is safe, but the global alias is not atomic.
Fix: reorder the hosted_assets array so SHA256SUMS is first (not last), ensuring checksums are uploaded before archives. A partial failure then leaves old-but-consistent state (new SHA256SUMS referencing new archives that weren't uploaded yet — old archives still match old SHA256SUMS).
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| return 0 | ||
| fi | ||
|
|
||
| if [[ "${SOURCE}" =~ ^[A-Za-z0-9._-]+$ ]]; then |
There was a problem hiding this comment.
[Critical] tar -tf does not show symlink targets (both GNU tar and macOS bsdtar omit the -> target portion), so validate_archive_entry_path never sees symlink entries and cannot reject them. Then at line 936, tar -xzf follows symlinks during extraction on Linux (GNU tar default). A crafted archive with a symlink entry (e.g., qwen-code/evil -> /etc) followed by a regular file entry traversing that symlink (e.g., qwen-code/evil/cron.d/backdoor) achieves arbitrary file write before the post-extraction find -type l check at line 948 can detect it. This is a textbook tar-symlink vulnerability (same root cause as CVEs in pip, npm tar, etc.). macOS is safe because bsdtar refuses to extract through symlinks.
Fix: use tar -tvf (verbose listing shows -> target) to reject any symlink entries before extraction:
| if [[ "${SOURCE}" =~ ^[A-Za-z0-9._-]+$ ]]; then | |
| *.tar.gz|*.tgz|*.tar.xz) | |
| if tar -tvf "${archive_path}" 2>/dev/null | grep -q ' -> '; then | |
| log_error "Archive contains symlinks; refusing to install." | |
| return 1 | |
| fi | |
| if ! entries=$(tar -tf "${archive_path}"); then | |
| log_error "Failed to inspect archive entries: ${archive_path}" | |
| return 1 | |
| fi | |
| ;; |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| # Manifest format is produced by writeManifest in create-standalone-package.js. | ||
| # Keep these grep checks in sync if that JSON layout changes. | ||
| grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 | ||
| grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 |
There was a problem hiding this comment.
[Suggestion] Cross-platform inconsistency: this regex (^[A-Za-z0-9._-]+$) allows digits, underscores, or dashes as the first character, but the .bat equivalent at line 460 requires ^[A-Za-z][A-Za-z0-9._-]*$ (letter first). A source value like 123build or _internal passes on Linux/macOS but fails on Windows.
Align the .bat regex to match, or tighten the .sh to require a letter first.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| set "QWEN_DOWNLOAD_DEST=%~2" | ||
| rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it); | ||
| rem fall back to Invoke-WebRequest (which shows its own progress bar). | ||
| powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" |
There was a problem hiding this comment.
[Suggestion] The .bat installer spawns PowerShell as a subprocess ~10-12 times during a typical installation (ValidateRawEnvironmentOptions, ValidateOptions, ValidateVersion, RaceMirrorHead, DownloadFile, VerifyChecksum, CreateTempDir, ValidateArchiveContents, MaybeUpdateUserPath, etc.). Each PowerShell process spawn costs 1-3 seconds, adding an estimated 15-30 seconds of overhead. The .sh equivalent uses native bash builtins for most operations.
Consider consolidating validation into a single PowerShell invocation, or writing a temporary .ps1 that performs all validation/download/checksum in one process.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| return 0 | ||
| fi | ||
|
|
||
| if [[ "${SOURCE}" =~ ^[A-Za-z0-9._-]+$ ]]; then |
There was a problem hiding this comment.
[Suggestion] Cross-platform inconsistency: this regex (^[A-Za-z0-9._-]+$) allows digits, underscores, or dashes as the first character, but the .bat equivalent at line 460 requires ^[A-Za-z][A-Za-z0-9._-]*$ (letter first). A source value like 123build or _internal passes on Linux/macOS but fails on Windows.
Align the .bat regex to match, or tighten the .sh to require a letter first.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| # Manifest format is produced by writeManifest in create-standalone-package.js. | ||
| # Keep these grep checks in sync if that JSON layout changes. | ||
| grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 | ||
| grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 |
There was a problem hiding this comment.
[Suggestion] The manifest target regex (darwin|linux)-(arm64|x64) is duplicated across 4 files: install-qwen-standalone.sh, uninstall-qwen-standalone.sh, install-qwen-standalone.bat, and uninstall-qwen-standalone.ps1. When RELEASE_TARGETS in build-standalone-release.js gains a new platform (e.g., linux-riscv64), all four regexes must be updated independently with no build-time check linking them.
Add a comment near RELEASE_TARGETS listing the coupled locations, or add a test asserting the regex patterns match RELEASE_TARGETS.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| --config "${RUNNER_TEMP}/.ossutilconfig" \ | ||
| --prefix "installation/${RELEASE_TAG}" \ | ||
| "${hosted_assets[@]}" | ||
| node scripts/upload-aliyun-oss-assets.js \ |
There was a problem hiding this comment.
[Critical] If this second upload (global installation/ prefix) fails partway through, users get a mix of old archives and new SHA256SUMS (or vice versa), causing checksum verification failures for anyone running irm .../installation/install-qwen-standalone.ps1 | iex. The versioned prefix is safe, but the global alias is not atomic.
Fix: reorder the hosted_assets array so SHA256SUMS is first (not last), ensuring checksums are uploaded before archives. A partial failure then leaves old-but-consistent state (new SHA256SUMS referencing new archives that weren't uploaded yet — old archives still match old SHA256SUMS).
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| set -euo pipefail | ||
|
|
||
| printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" | ||
| ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read |
There was a problem hiding this comment.
[Critical] This ossutil cp call bypasses the retry wrapper (upload-aliyun-oss-assets.js), unlike every other OSS upload in the workflow. If it fails transiently after gh release create has already succeeded, re-running the workflow fails with a duplicate-tag error — the workflow cannot make progress without manual intervention (delete the GitHub release or manually upload VERSION).
Route through upload-aliyun-oss-assets.js or replicate the retry loop inline.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
…ection [skip ci] 1. The hosted installation asset build now accepts --version and stamps it into the copied .sh/.bat installers so they default to the tagged release version instead of 'latest'. The release workflow passes the version. 2. install-qwen-with-source.bat now validates archive entries before calling Expand-Archive, rejecting paths with '..', leading '/', drive-rooted paths, empty names, or control characters — matching the protection already present in install-qwen-standalone.bat and the .sh installer.
…[skip ci] The SOURCE variable is user-provided and used in path operations but was not included in the :ValidateOptions unsafe-character check. Add it alongside the other validated variables.
| ) { | ||
| Add-PathCandidate -Candidates $candidates -Directory $entry | ||
| } | ||
| } |
There was a problem hiding this comment.
[Critical] The PowerShell shim's checksum verification (download SHA256SUMS, parse hash, Get-FileHash comparison, abort-on-mismatch) is the security boundary for irm ... | iex — the primary Windows hosted install entrypoint. However, it is only verified by toContain string assertions in scripts/tests/install-script.test.js (lines ~794-812). No E2E test executes the ps1 against a real or mock HTTP server serving a .bat + SHA256SUMS pair.
The .bat installer's checksum verification IS tested E2E (tampered archive test), but the equivalent ps1 path is not. A regression in the checksum parsing regex ('^([0-9a-fA-F]{64})\\s+\\*?(.+)$'), hash comparison (.ToLowerInvariant()), or cleanup-on-failure logic would silently bypass the security boundary.
Consider adding an itOnWindows E2E test that starts a local HTTPS server, serves a .bat + SHA256SUMS, and runs the ps1 shim — testing both the happy path (checksum match) and failure path (tampered .bat, shim exits non-zero without executing).
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| $preferredDirectories += Join-Path $env:USERPROFILE '.bun\bin' | ||
| } | ||
|
|
||
| foreach ($preferredDirectory in $preferredDirectories) { |
There was a problem hiding this comment.
[Critical] The HTTPS-only guard ($env:QWEN_INSTALLER_BAT_URL -notmatch '^https://') is the injection-prevention boundary for the hosted entrypoint. It is verified only by toContain('must start with https://') string assertion — no test passes an http:// URL and verifies the shim actually rejects it with exit 1.
If the regex or exit logic regresses, a misconfigured or malicious QWEN_INSTALLER_BAT_URL=http://attacker.com/... would silently download an unencrypted .bat, enabling MITM attacks on the primary Windows install path.
Consider adding an itOnWindows test that sets QWEN_INSTALLER_BAT_URL=http://localhost/... and asserts the ps1 exits with code 1.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
|
|
||
| if ($removed) { | ||
| [Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') | ||
| Write-Success "Removed $BinDir from user PATH." |
There was a problem hiding this comment.
[Critical] Remove-UserPathEntry reads the Windows User PATH from the registry, removes the qwen bin directory, and writes back via [Environment]::SetEnvironmentVariable('Path', ..., 'User'). This is a destructive registry modification that is only tested by static toContain assertions — no E2E test sets a real User PATH, runs the uninstaller, and verifies the registry value afterward.
The Unix equivalent IS tested E2E (shell rc PATH removal tests with .zshrc/.bashrc). A regression in Get-NormalizedPath or Test-PathMatches could corrupt the PATH by removing wrong entries — a registry write that is hard for users to diagnose.
Consider adding an itOnWindows test that: (1) sets User PATH to include the qwen bin dir plus other entries, (2) runs the uninstaller, (3) reads back [Environment]::GetEnvironmentVariable('Path', 'User') and asserts only the qwen entry was removed.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
|
|
||
| if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then | ||
| rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}" | ||
| if [[ -e "${old_install_dir}" ]]; then |
There was a problem hiding this comment.
[Critical] When wrapper creation fails (mv -f "${wrapper_tmp}" at line 1224), the rollback unconditionally destroys the newly-installed directory (rm -rf "${INSTALL_LIB_DIR}") BEFORE attempting to restore the old install from ${old_install_dir}. If the subsequent mv also fails (permissions, disk error, cross-device rename), the user is left with no installation at all. The error message "Failed to create qwen wrapper" does not mention that the old install may still exist at ${old_install_dir}.
This is a data-loss scenario in a dual-failure path. Consider a safer ordering: rename INSTALL_LIB_DIR to a trash name first, attempt the rollback, and only delete the trash if rollback succeeded. If rollback fails, restore the new install so the user isn't empty-handed:
local trash="${INSTALL_LIB_DIR}.trash.$$"
mv "${INSTALL_LIB_DIR}" "${trash}" 2>/dev/null || true
if [[ -e "${old_install_dir}" ]]; then
if mv "${old_install_dir}" "${INSTALL_LIB_DIR}"; then
rm -rf "${trash}"
else
[[ ! -e "${INSTALL_LIB_DIR}" ]] && mv "${trash}" "${INSTALL_LIB_DIR}" 2>/dev/null
rm -rf "${trash}"
log_error "Failed to restore previous install from ${old_install_dir}."
fi
fi— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| ) { | ||
| Add-PathCandidate -Candidates $candidates -Directory $entry | ||
| } | ||
| } |
There was a problem hiding this comment.
[Critical] The PowerShell shim's checksum verification (download SHA256SUMS, parse hash, Get-FileHash comparison, abort-on-mismatch) is the security boundary for irm ... | iex — the primary Windows hosted install entrypoint. However, it is only verified by toContain string assertions in scripts/tests/install-script.test.js (lines ~794-812). No E2E test executes the ps1 against a real or mock HTTP server serving a .bat + SHA256SUMS pair.
The .bat installer's checksum verification IS tested E2E (tampered archive test), but the equivalent ps1 path is not. A regression in the checksum parsing regex ('^([0-9a-fA-F]{64})\\s+\\*?(.+)$'), hash comparison (.ToLowerInvariant()), or cleanup-on-failure logic would silently bypass the security boundary.
Consider adding an itOnWindows E2E test that starts a local HTTPS server, serves a .bat + SHA256SUMS, and runs the ps1 shim — testing both the happy path (checksum match) and failure path (tampered .bat, shim exits non-zero without executing).
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| $preferredDirectories += Join-Path $env:USERPROFILE '.bun\bin' | ||
| } | ||
|
|
||
| foreach ($preferredDirectory in $preferredDirectories) { |
There was a problem hiding this comment.
[Critical] The HTTPS-only guard ($env:QWEN_INSTALLER_BAT_URL -notmatch '^https://') is the injection-prevention boundary for the hosted entrypoint. It is verified only by toContain('must start with https://') string assertion — no test passes an http:// URL and verifies the shim actually rejects it with exit 1.
If the regex or exit logic regresses, a misconfigured or malicious QWEN_INSTALLER_BAT_URL=http://attacker.com/... would silently download an unencrypted .bat, enabling MITM attacks on the primary Windows install path.
Consider adding an itOnWindows test that sets QWEN_INSTALLER_BAT_URL=http://localhost/... and asserts the ps1 exits with code 1.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
|
|
||
| if ($removed) { | ||
| [Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') | ||
| Write-Success "Removed $BinDir from user PATH." |
There was a problem hiding this comment.
[Critical] Remove-UserPathEntry reads the Windows User PATH from the registry, removes the qwen bin directory, and writes back via [Environment]::SetEnvironmentVariable('Path', ..., 'User'). This is a destructive registry modification that is only tested by static toContain assertions — no E2E test sets a real User PATH, runs the uninstaller, and verifies the registry value afterward.
The Unix equivalent IS tested E2E (shell rc PATH removal tests with .zshrc/.bashrc). A regression in Get-NormalizedPath or Test-PathMatches could corrupt the PATH by removing wrong entries — a registry write that is hard for users to diagnose.
Consider adding an itOnWindows test that: (1) sets User PATH to include the qwen bin dir plus other entries, (2) runs the uninstaller, (3) reads back [Environment]::GetEnvironmentVariable('Path', 'User') and asserts only the qwen entry was removed.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
|
|
||
| if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then | ||
| rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}" | ||
| if [[ -e "${old_install_dir}" ]]; then |
There was a problem hiding this comment.
[Critical] When wrapper creation fails (mv -f "${wrapper_tmp}" at line 1224), the rollback unconditionally destroys the newly-installed directory (rm -rf "${INSTALL_LIB_DIR}") BEFORE attempting to restore the old install from ${old_install_dir}. If the subsequent mv also fails (permissions, disk error, cross-device rename), the user is left with no installation at all. The error message "Failed to create qwen wrapper" does not mention that the old install may still exist at ${old_install_dir}.
This is a data-loss scenario in a dual-failure path. Consider a safer ordering: rename INSTALL_LIB_DIR to a trash name first, attempt the rollback, and only delete the trash if rollback succeeded. If rollback fails, restore the new install so the user isn't empty-handed:
local trash="${INSTALL_LIB_DIR}.trash.$$"
mv "${INSTALL_LIB_DIR}" "${trash}" 2>/dev/null || true
if [[ -e "${old_install_dir}" ]]; then
if mv "${old_install_dir}" "${INSTALL_LIB_DIR}"; then
rm -rf "${trash}"
else
[[ ! -e "${INSTALL_LIB_DIR}" ]] && mv "${trash}" "${INSTALL_LIB_DIR}" 2>/dev/null
rm -rf "${trash}"
log_error "Failed to restore previous install from ${old_install_dir}."
fi
fi— qwen-latest-series-invite-beta-v34 via Qwen Code /review
Bot Review Critical 评论统一回复以下是对本 PR 中 bot(gpt-5.5 via /review)标记的所有 Critical 评论的逐一核实。大部分是误报或风险被过度放大。 误报(代码中已处理,bot 未正确识别)
风险过度放大(理论可能但实际影响极低)
可作为 Follow-up 的合理建议(非 blocker)
以上均不影响现有安装脚本和发布流程的正确性,不阻塞合并。 |
Summary
latest/VERSIONresolution.cmd.execan makeqwenavailable in the same command prompt when a writable directory already on the inherited PATH can host the generated shim.Validation
SHA256SUMSare uploaded toreleases/qwen-code/<tag>/.releases/qwen-code/latest/VERSIONpointer after versioned release assets are uploaded and verified; there is nolatest/<archive>alias.SHA256SUMSare uploaded toinstallation/<tag>/for audit and rollback; the release workflow does not overwrite the globalinstallation/entrypoint objects.latestinstalls readlatest/VERSION, then download the archive andSHA256SUMSfrom the resolved versioned release directory.SHA256SUMS.releases/qwen-code/latest/VERSIONwas verified to resolve to the current stable release pointer during manual OSS sync validation.npm run build && npm run typecheckdid not complete locally because build stops on the existing@lydell/node-ptydeclaration resolution issue under Node v22.22.0.npm run test:scripts.dist/installation/SHA256SUMS.installation/and runsha256sum -c SHA256SUMSagainst them.cmd.exe, then runqwen -vin the same prompt.Scope / Risk
cmd.exePATH; when that is not available, the installer prints an explicit one-line PATH fallback.installation/; release jobs publish versioned hosted snapshots underinstallation/<tag>/, while the moving release selection for Aliyun is the smalllatest/VERSIONpointer, not duplicated latest archives.@lydell/node-ptytyping resolution issue noted above.npm uninstall @qwen-code/qwen-codecannot remove standalone files.Testing Matrix
Testing matrix notes:
cmd.exe.Linked Issues / Bugs
Related #3728