qwen-code/scripts/sign-release.sh
易良 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

42 lines
1.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# Signs SHA256SUMS with an Ed25519 private key for release integrity verification.
#
# Usage:
# RELEASE_SIGNING_KEY_PEM=/path/to/key.pem ./scripts/sign-release.sh dist/releases/vX.Y.Z/SHA256SUMS
#
# Output:
# Creates SHA256SUMS.sig alongside the input file (base64-encoded 64-byte signature).
#
# Key generation (one-time):
# openssl genpkey -algorithm Ed25519 -out release-signing-key.pem
# openssl pkey -in release-signing-key.pem -pubout -outform DER | base64
# # Embed the base64 public key in standalone-update-verify.ts
set -euo pipefail
if [[ -z "${RELEASE_SIGNING_KEY_PEM:-}" ]]; then
echo "ERROR: RELEASE_SIGNING_KEY_PEM environment variable must point to the Ed25519 private key." >&2
exit 1
fi
if [[ ! -f "${RELEASE_SIGNING_KEY_PEM}" ]]; then
echo "ERROR: Key file not found: ${RELEASE_SIGNING_KEY_PEM}" >&2
exit 1
fi
SHA256SUMS_FILE="${1:-}"
if [[ -z "${SHA256SUMS_FILE}" || ! -f "${SHA256SUMS_FILE}" ]]; then
echo "Usage: $0 <path/to/SHA256SUMS>" >&2
exit 1
fi
SIG_FILE="${SHA256SUMS_FILE}.sig"
# openssl pkeyutl -rawin signs raw content with Ed25519, outputs 64-byte signature
openssl pkeyutl -sign -rawin \
-inkey "${RELEASE_SIGNING_KEY_PEM}" \
-in "${SHA256SUMS_FILE}" \
-out /dev/stdout 2>/dev/null | base64 > "${SIG_FILE}"
echo "Signed: ${SIG_FILE}"
echo "Signature (base64): $(cat "${SIG_FILE}")"