diff --git a/package-lock.json b/package-lock.json index f1346a56c8d..34732962f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17203,6 +17203,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.2", "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index a8f615c0514..aff588fc919 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,6 +77,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.2", "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 9061fa2b0e2..64d842ec013 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1898,6 +1898,18 @@ export default { 'Open the memory manager.': 'Open the memory manager.', 'Show current process memory diagnostics': 'Show current process memory diagnostics', + 'Roll back a standalone update to the previous version': + 'Roll back a standalone update to the previous version', + 'Rollback is not available in ACP mode.': + 'Rollback is not available in ACP mode.', + 'Rollback is only available for standalone installations.': + 'Rollback is only available for standalone installations.', + 'Rollback successful. Restart your terminal to use the previous version.': + 'Rollback successful. Restart your terminal to use the previous version.', + 'Rollback failed: no previous version found (.old directory missing).': + 'Rollback failed: no previous version found (.old directory missing).', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.', 'Save a durable memory to the memory system.': 'Save a durable memory to the memory system.', 'Ask a quick side question without affecting the main conversation': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 44c2116887a..1c76e599177 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1485,6 +1485,17 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': '打開記憶管理器。', 'Show current process memory diagnostics': '顯示目前程序的內存診斷。', + 'Roll back a standalone update to the previous version': + '將獨立安裝回滾到上一個版本', + 'Rollback is not available in ACP mode.': '回滾在 ACP 模式下不可用。', + 'Rollback is only available for standalone installations.': + '回滾僅適用於獨立安裝。', + 'Rollback successful. Restart your terminal to use the previous version.': + '回滾成功。請重啟終端以使用上一個版本。', + 'Rollback failed: no previous version found (.old directory missing).': + '回滾失敗:未找到上一個版本(.old 目錄缺失)。', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + '在 Windows 上回滾需要手動操作。請將安裝目錄中的 qwen-code.old 重新命名為 qwen-code。', 'Save a durable memory to the memory system.': '將持久記憶保存到記憶系統。', 'Ask a quick side question without affecting the main conversation': '在不影響主對話的情況下快速提問旁支問題', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index eac5e259413..952990bde95 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1720,6 +1720,17 @@ export default { 'Loading suggestions...': '正在加载建议...', 'Open the memory manager.': '打开记忆管理器。', 'Show current process memory diagnostics': '显示当前进程的内存诊断。', + 'Roll back a standalone update to the previous version': + '将独立安装回滚到上一个版本', + 'Rollback is not available in ACP mode.': '回滚在 ACP 模式下不可用。', + 'Rollback is only available for standalone installations.': + '回滚仅适用于独立安装。', + 'Rollback successful. Restart your terminal to use the previous version.': + '回滚成功。请重启终端以使用上一个版本。', + 'Rollback failed: no previous version found (.old directory missing).': + '回滚失败:未找到上一个版本(.old 目录缺失)。', + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.': + '在 Windows 上回滚需要手动操作。请将安装目录中的 qwen-code.old 重命名为 qwen-code。', 'Save a durable memory to the memory system.': '将一条持久记忆保存到记忆系统。', 'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。', diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 315ebc8cbcc..413a0296333 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -177,10 +177,14 @@ describe('doctorCommand', () => { it('should complete memory subcommand names', async () => { await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([ 'memory', + 'rollback', ]); await expect( doctorCommand.completion!(mockContext, 'mem'), ).resolves.toEqual(['memory']); + await expect( + doctorCommand.completion!(mockContext, 'roll'), + ).resolves.toEqual(['rollback']); await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual( [], ); @@ -1049,6 +1053,8 @@ describe('doctorCommand', () => { }); it('should advertise the memory subcommand on the parent doctor argumentHint', () => { - expect(doctorCommand.argumentHint).toBe('[memory] [--sample] [--snapshot]'); + expect(doctorCommand.argumentHint).toBe( + '[memory|rollback] [--sample] [--snapshot]', + ); }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index bb90c389974..8d24d3c4818 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -16,6 +16,8 @@ import { isHighHeapPressure, writeMemoryHeapSnapshot, } from '../../utils/memoryDiagnostics.js'; +import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js'; +import { getInstallationInfo } from '../../utils/installationInfo.js'; import { t } from '../../i18n/index.js'; import { collectMemoryDiagnostics, @@ -24,7 +26,8 @@ import { import { formatMemoryUsage } from '../utils/formatters.js'; const MEMORY_SUBCOMMAND = 'memory'; -const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND] as const; +const ROLLBACK_SUBCOMMAND = 'rollback'; +const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, ROLLBACK_SUBCOMMAND] as const; function getHeapSnapshotSensitiveDataWarning(): string { return t( 'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.', @@ -49,12 +52,13 @@ export const doctorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - argumentHint: '[memory] [--sample] [--snapshot]', + argumentHint: '[memory|rollback] [--sample] [--snapshot]', examples: [ '/doctor', '/doctor memory', '/doctor memory --sample', '/doctor memory --snapshot', + '/doctor rollback', ], completion: async (_context, partialArg) => { const trimmed = partialArg.trimStart(); @@ -71,6 +75,17 @@ export const doctorCommand: SlashCommand = { const shouldWriteHeapSnapshot = subCommandArgs.includes('--snapshot'); const shouldSampleMemory = subCommandArgs.includes('--sample'); + if (subCommand === ROLLBACK_SUBCOMMAND) { + if (executionMode === 'acp') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t('Rollback is not available in ACP mode.'), + }; + } + return rollbackDoctorAction(context); + } + if (subCommand === MEMORY_SUBCOMMAND) { if (abortSignal?.aborted) { return; @@ -233,6 +248,15 @@ export const doctorCommand: SlashCommand = { argumentHint: '[--json] [--sample] [--snapshot]', action: memoryDoctorAction, }, + { + name: 'rollback', + get description() { + return t('Roll back a standalone update to the previous version'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive'] as const, + action: rollbackDoctorAction, + }, ], }; @@ -382,3 +406,57 @@ function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string { ); return lines.join('\n'); } + +function rollbackDoctorAction(context: CommandContext) { + const installInfo = getInstallationInfo(process.cwd(), false); + if (!installInfo.isStandalone || !installInfo.standaloneDir) { + const msg = t('Rollback is only available for standalone installations.'); + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: msg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: 'info' as const, + content: msg, + }; + } + + if (process.platform === 'win32') { + const winMsg = t( + 'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.', + ); + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: winMsg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: 'info' as const, + content: winMsg, + }; + } + + const result = rollbackStandaloneUpdate(installInfo.standaloneDir); + let msg: string; + let messageType: 'info' | 'error'; + if (result.ok) { + msg = t( + 'Rollback successful. Restart your terminal to use the previous version.', + ); + messageType = 'info'; + } else { + msg = t(`Rollback failed: ${result.detail}`); + messageType = 'error'; + } + + if (context.executionMode === 'interactive') { + context.ui.addItem({ type: messageType, text: msg }, Date.now()); + return; + } + return { + type: 'message' as const, + messageType: messageType as 'info' | 'error', + content: msg, + }; +} diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 97bbd5f81cb..848d6e2c9ac 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -12,6 +12,7 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import EventEmitter from 'node:events'; import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; +import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; vi.mock('./installationInfo.js', async () => { @@ -22,6 +23,10 @@ vi.mock('./installationInfo.js', async () => { }; }); +vi.mock('./standalone-update.js', () => ({ + performStandaloneUpdate: vi.fn(), +})); + vi.mock('./updateEventEmitter.js', async () => { const { EventEmitter } = await import('node:events'); return { @@ -38,6 +43,7 @@ interface MockChildProcess extends EventEmitter { } const mockGetInstallationInfo = vi.mocked(getInstallationInfo); +const mockPerformStandaloneUpdate = vi.mocked(performStandaloneUpdate); describe('handleAutoUpdate', () => { let mockSpawn: Mock; @@ -263,6 +269,125 @@ describe('handleAutoUpdate', () => { }); }); +describe('handleAutoUpdate — standalone path', () => { + let mockSpawn: Mock; + let mockUpdateInfo: UpdateObject; + let mockSettings: LoadedSettings; + let emitSpy: ReturnType; + + beforeEach(() => { + mockSpawn = vi.fn(); + vi.clearAllMocks(); + emitSpy = vi.spyOn(updateEventEmitter, 'emit'); + mockUpdateInfo = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@qwen-code/qwen-code', + }, + message: 'An update is available!', + }; + mockSettings = { + merged: { general: { enableAutoUpdate: true } }, + } as LoadedSettings; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls performStandaloneUpdate and does NOT spawn npm', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('done'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(mockPerformStandaloneUpdate).toHaveBeenCalledWith( + '/home/user/.local/lib/qwen-code', + '2.0.0', + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('emits deferred message when result is "deferred"', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('deferred'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-success', { + message: + 'Update downloaded. It will be applied after you exit this session.', + }); + }); + + it('emits "done" success message when result is "done"', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockResolvedValue('done'); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + }); + + it('emits update-failed on rejection', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: '', + isGlobal: false, + isStandalone: true, + standaloneDir: '/home/user/.local/lib/qwen-code', + packageManager: PackageManager.NPM, + }); + mockPerformStandaloneUpdate.mockRejectedValue(new Error('Download failed')); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + await vi.waitFor(() => + expect(emitSpy).toHaveBeenCalledWith('update-failed', expect.anything()), + ); + + expect(emitSpy).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed: Download failed. Re-run the installer to update manually.', + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); +}); + describe('setUpdateHandler', () => { let addItem: Mock; let setUpdateInfo: Mock; @@ -288,7 +413,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Update successful!', }, expect.any(Number), ); @@ -342,7 +467,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Update successful!', }, expect.any(Number), ); @@ -369,7 +494,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: 'Automatic update failed. Please try updating manually', + text: 'Update failed', }, expect.any(Number), ); @@ -402,7 +527,7 @@ describe('setUpdateHandler', () => { 2, { type: MessageType.INFO, - text: 'Update successful! The new version will be used on your next run.', + text: 'Success!', }, expect.any(Number), ); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 05dd62c3de9..215d300420f 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -11,6 +11,7 @@ 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'; @@ -43,6 +44,27 @@ export function handleAutoUpdate( 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; @@ -113,20 +135,24 @@ export function setUpdateHandler( }, 60000); }; - const handleUpdateFailed = () => { + const handleUpdateFailed = (data: { message?: string }) => { setUpdateInfo(null); addItemOrDefer({ type: MessageType.ERROR, - text: `Automatic update failed. Please try updating manually`, + text: + data?.message || + 'Automatic update failed. Please try updating manually', }); }; - const handleUpdateSuccess = () => { + const handleUpdateSuccess = (data: { message?: string }) => { successfullyInstalled = true; setUpdateInfo(null); addItemOrDefer({ type: MessageType.INFO, - text: `Update successful! The new version will be used on your next run.`, + text: + data?.message || + 'Update successful! The new version will be used on your next run.', }); }; diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 1da119db719..b169668711b 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -26,6 +26,7 @@ vi.mock('fs', async (importOriginal) => { ...actualFs, realpathSync: vi.fn(), existsSync: vi.fn(), + accessSync: vi.fn(), }; }); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 6eb39b0540b..8e52243a960 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -6,6 +6,7 @@ import { createDebugLogger, isGitRepository } from '@qwen-code/qwen-code-core'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import * as childProcess from 'node:child_process'; @@ -26,10 +27,37 @@ const debugLogger = createDebugLogger('INSTALLATION_INFO'); export interface InstallationInfo { packageManager: PackageManager; isGlobal: boolean; + isStandalone?: boolean; + standaloneDir?: string; updateCommand?: string; updateMessage?: string; } +function findStandaloneDir(realPath: string): string | null { + let dir = path.dirname(realPath); + for (let i = 0; i < 3; i++) { + const manifestPath = path.join(dir, 'manifest.json'); + try { + if (fs.existsSync(manifestPath)) { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw) as { + name?: string; + target?: string; + }; + if (manifest.name === '@qwen-code/qwen-code' && manifest.target) { + return dir; + } + } + } catch { + // ignore parse errors + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + export function getInstallationInfo( projectRoot: string, isAutoUpdateEnabled: boolean, @@ -161,7 +189,59 @@ export function getInstallationInfo( }; } - // Assume global npm + // Check for standalone install (manifest.json with @qwen-code/qwen-code) + const standaloneDir = findStandaloneDir(realPath); + if (standaloneDir) { + return { + packageManager: PackageManager.UNKNOWN, + isGlobal: true, + isStandalone: true, + standaloneDir, + updateMessage: isAutoUpdateEnabled + ? 'Standalone install detected. Attempting to automatically update now...' + : 'Standalone install detected. Re-run the installer to update.', + }; + } + + // Assume global npm — check if prefix is writable before offering npm update + const npmPrefixDir = path.dirname(path.dirname(realPath)); + let npmPrefixWritable = false; + try { + fs.accessSync(npmPrefixDir, fs.constants.W_OK); + npmPrefixWritable = true; + } catch { + // Not writable (e.g., /usr/local/lib/node_modules owned by root) + } + + if (!npmPrefixWritable && isAutoUpdateEnabled) { + // npm prefix requires sudo — fall back to standalone update path + // which installs to ~/.local/lib/qwen-code/ (user-writable) + const installRoot = process.env['HOME'] || os.homedir(); + if (!installRoot || installRoot === '/') { + // Cannot determine a safe user-writable location; skip migration + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateMessage: + 'Update requires sudo. Run: sudo npm install -g @qwen-code/qwen-code@latest', + }; + } + const fallbackStandaloneDir = path.join( + installRoot, + '.local', + 'lib', + 'qwen-code', + ); + return { + packageManager: PackageManager.NPM, + isGlobal: true, + isStandalone: true, + standaloneDir: fallbackStandaloneDir, + updateMessage: + 'npm install requires sudo. Migrating to standalone installer for automatic updates.', + }; + } + const updateCommand = 'npm install -g @qwen-code/qwen-code@latest'; return { packageManager: PackageManager.NPM, diff --git a/packages/cli/src/utils/standalone-update-verify.test.ts b/packages/cli/src/utils/standalone-update-verify.test.ts new file mode 100644 index 00000000000..c72bb7f914a --- /dev/null +++ b/packages/cli/src/utils/standalone-update-verify.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { verifySignature } from './standalone-update-verify.js'; + +describe('standalone-update-verify', () => { + // Since we can't use the embedded key's private counterpart in tests, + // we test the structure and error paths. + + describe('verifySignature', () => { + it('rejects signature with wrong length', () => { + expect(() => verifySignature('test content', 'dG9vc2hvcnQ=')).toThrow( + 'Invalid signature length', + ); + }); + + it('rejects invalid signature (correct length but wrong bytes)', () => { + // 64 bytes of zeros, base64 encoded + const fakeSig = Buffer.alloc(64, 0).toString('base64'); + expect(() => verifySignature('test content', fakeSig)).toThrow( + 'signature verification failed', + ); + }); + + it('rejects tampered content with valid-length signature', () => { + // Even a random 64-byte signature should fail verification + const randomSig = Buffer.from( + Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)), + ).toString('base64'); + expect(() => + verifySignature('some SHA256SUMS content', randomSig), + ).toThrow('signature verification failed'); + }); + }); +}); diff --git a/packages/cli/src/utils/standalone-update-verify.ts b/packages/cli/src/utils/standalone-update-verify.ts new file mode 100644 index 00000000000..adac9244da0 --- /dev/null +++ b/packages/cli/src/utils/standalone-update-verify.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Ed25519 signature verification for standalone update integrity. + * + * The release CI signs SHA256SUMS with an Ed25519 private key, producing + * SHA256SUMS.sig (base64-encoded raw 64-byte signature). This module + * verifies that signature using the embedded public key. + * + * Key generation (one-time): + * openssl genpkey -algorithm Ed25519 -out release-signing-key.pem + * openssl pkey -in release-signing-key.pem -pubout -outform DER | base64 + */ + +import { createPublicKey, verify } from 'node:crypto'; + +// Ed25519 public key in DER/SPKI format, base64-encoded. +// Replace this with the production key generated by the release team. +// The corresponding private key must be stored in CI secrets only. +const RELEASE_PUBLIC_KEY_DER_B64 = + 'MCowBQYDK2VwAyEAr9WRFLDauibZQKCe1oKfuFZn6zRMaEkD5+KVqN6mKM4='; + +/** + * Verifies an Ed25519 signature over the SHA256SUMS content. + * @param sha256sumsContent - The raw text of SHA256SUMS + * @param signatureBase64 - Base64-encoded 64-byte Ed25519 signature + * @throws if signature is invalid or verification fails + */ +export function verifySignature( + sha256sumsContent: string, + signatureBase64: string, +): void { + const key = createPublicKey({ + key: Buffer.from(RELEASE_PUBLIC_KEY_DER_B64, 'base64'), + format: 'der', + type: 'spki', + }); + + const signature = Buffer.from(signatureBase64, 'base64'); + if (signature.length !== 64) { + throw new Error( + `Invalid signature length: expected 64 bytes, got ${signature.length}`, + ); + } + + const valid = verify(null, Buffer.from(sha256sumsContent), key, signature); + if (!valid) { + throw new Error( + 'SHA256SUMS signature verification failed — possible tampering detected', + ); + } +} diff --git a/packages/cli/src/utils/standalone-update.test.ts b/packages/cli/src/utils/standalone-update.test.ts new file mode 100644 index 00000000000..3e6780cb99f --- /dev/null +++ b/packages/cli/src/utils/standalone-update.test.ts @@ -0,0 +1,408 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + rollbackStandaloneUpdate, + ensureBinWrapper, + ensurePathInShellRc, + performStandaloneUpdate, +} from './standalone-update.js'; + +describe('standalone-update', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('rollbackStandaloneUpdate', () => { + it('returns no-old when .old directory does not exist', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('no-old'); + }); + + it('returns no-manifest when .old directory has no manifest.json', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('no-manifest'); + }); + + it('swaps current with .old directory on valid rollback', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + version: '0.17.0', + }), + ); + fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new'); + + fs.writeFileSync( + path.join(oldDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + version: '0.16.2', + }), + ); + fs.writeFileSync(path.join(oldDir, 'marker.txt'), 'old'); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(true); + + const manifest = JSON.parse( + fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'), + ); + expect(manifest.version).toBe('0.16.2'); + expect( + fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'), + ).toBe('old'); + expect(fs.existsSync(oldDir)).toBe(false); + }); + + it('succeeds even with minimal manifest in .old', () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const oldDir = `${standaloneDir}.old`; + fs.mkdirSync(standaloneDir); + fs.mkdirSync(oldDir); + + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }), + ); + fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}'); + + const result = rollbackStandaloneUpdate(standaloneDir); + expect(result.ok).toBe(true); + }); + }); + + describe('ensureBinWrapper', () => { + // Unix wrapper test relies on POSIX file permissions (mode bits) and + // the SHELL env var, neither of which behave consistently on Windows. + it.skipIf(process.platform === 'win32')( + 'creates a Unix shell wrapper script', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + // Isolate HOME so ensurePathInShellRc doesn't touch real shell rc + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + try { + ensureBinWrapper(standaloneDir, 'darwin-arm64'); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + + const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen'); + expect(fs.existsSync(wrapperPath)).toBe(true); + const content = fs.readFileSync(wrapperPath, 'utf-8'); + expect(content).toContain('#!/bin/sh'); + expect(content).toContain(standaloneDir); + const mode = fs.statSync(wrapperPath).mode; + expect(mode & 0o111).toBeGreaterThan(0); + }, + ); + + it('creates a Windows cmd wrapper', () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + ensureBinWrapper(standaloneDir, 'win-x64'); + + const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd'); + expect(fs.existsSync(wrapperPath)).toBe(true); + const content = fs.readFileSync(wrapperPath, 'utf-8'); + expect(content).toContain('@echo off'); + }); + + it.skipIf(process.platform === 'win32')( + 'does not overwrite existing wrapper', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + const binDir = path.join(tempDir, '.local', 'bin'); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + const origHome = process.env['HOME']; + const origShell = process.env['SHELL']; + process.env['HOME'] = tempDir; + process.env['SHELL'] = '/bin/zsh'; + + const wrapperPath = path.join(binDir, 'qwen'); + fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 }); + + try { + ensureBinWrapper(standaloneDir, 'linux-x64'); + expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe( + 'existing-content', + ); + } finally { + process.env['HOME'] = origHome; + process.env['SHELL'] = origShell; + } + }, + ); + }); + + describe('performStandaloneUpdate', () => { + it('rejects invalid version format', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + await expect( + performStandaloneUpdate(standaloneDir, 'not-a-version'), + ).rejects.toThrow('Invalid version format'); + }); + + it('rejects directory without manifest as non-managed install', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + // No manifest.json — could be user data + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('not a Qwen Code standalone install'); + }); + + it('rejects unknown target in manifest', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + fs.mkdirSync(standaloneDir); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'freebsd-mips', + }), + ); + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('Unknown target'); + }); + + it('fails gracefully when another update is in progress', async () => { + const standaloneDir = path.join(tempDir, 'qwen-code'); + const parentDir = path.dirname(standaloneDir); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.writeFileSync( + path.join(standaloneDir, 'manifest.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }), + ); + + // Simulate held lock from a live process (current PID) + const lockPath = path.join(parentDir, '.qwen-update.lock'); + fs.writeFileSync(lockPath, String(process.pid)); + + await expect( + performStandaloneUpdate(standaloneDir, '1.0.0'), + ).rejects.toThrow('Another update is already in progress'); + + // Clean up lock + fs.unlinkSync(lockPath); + }); + }); + + describe('ensureBinWrapper — shell safety', () => { + it('rejects standaloneDir with backtick', () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen`id`'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'unsafe for shell embedding', + ); + }); + + it('rejects standaloneDir with dollar sign', () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen$(rm -rf /)'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'unsafe for shell embedding', + ); + }); + + it('rejects standaloneDir with embedded double-quote', () => { + // No mkdirSync: assertSafeForShellEmbed runs synchronously before any FS + // access, and Windows filesystems forbid `"` in path components — pre-creating + // the directory would ENOENT on Windows runners. + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen"code'); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'unsafe for shell embedding', + ); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects standaloneDir with single-quote on Unix', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, "qwen'code"); + fs.mkdirSync(standaloneDir, { recursive: true }); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'unsafe for shell embedding', + ); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects standaloneDir with semicolon on Unix', + () => { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen;rm -rf /'); + fs.mkdirSync(standaloneDir, { recursive: true }); + + expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow( + 'unsafe for shell embedding', + ); + }, + ); + + it('accepts backslash on Windows (path separator)', () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const libDir = path.join(tempDir, '.local', 'lib'); + const standaloneDir = path.join(libDir, 'qwen-code'); + fs.mkdirSync(standaloneDir, { recursive: true }); + // Windows paths naturally contain backslashes; should not throw + const winPath = `${standaloneDir}\\subdir`; + fs.mkdirSync(winPath, { recursive: true }); + expect(() => ensureBinWrapper(winPath, 'win-x64')).not.toThrow(); + } finally { + if (origPlatform) { + Object.defineProperty(process, 'platform', origPlatform); + } + } + }); + }); + + describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => { + it('appends PATH export to zshrc when SHELL is zsh', () => { + const binDir = path.join(tempDir, 'bin'); + const zshrc = path.join(tempDir, '.zshrc'); + fs.writeFileSync(zshrc, '# existing config\n'); + + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + const content = fs.readFileSync(zshrc, 'utf-8'); + expect(content).toContain('# Added by Qwen Code standalone installer'); + expect(content).toContain(`export PATH="${binDir}:$PATH"`); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('skips if marker already in rc file', () => { + const binDir = path.join(tempDir, 'bin'); + const zshrc = path.join(tempDir, '.zshrc'); + fs.writeFileSync( + zshrc, + `# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\n`, + ); + + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/zsh'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + const content = fs.readFileSync(zshrc, 'utf-8'); + const matches = content.match( + /# Added by Qwen Code standalone installer/g, + ); + expect(matches).toHaveLength(1); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + + it('does nothing for unknown shells', () => { + const binDir = path.join(tempDir, 'bin'); + const origShell = process.env['SHELL']; + const origHome = process.env['HOME']; + process.env['SHELL'] = '/bin/csh'; + process.env['HOME'] = tempDir; + + try { + ensurePathInShellRc(binDir); + // No rc file should be created + expect( + fs.readdirSync(tempDir).filter((f) => f.startsWith('.')), + ).toHaveLength(0); + } finally { + process.env['SHELL'] = origShell; + process.env['HOME'] = origHome; + } + }); + }); +}); diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/utils/standalone-update.ts new file mode 100644 index 00000000000..ce390ac15ba --- /dev/null +++ b/packages/cli/src/utils/standalone-update.ts @@ -0,0 +1,796 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { spawn, execFile } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; +import { fetch } from 'undici'; +import * as tar from 'tar'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { verifySignature } from './standalone-update-verify.js'; + +const debugLogger = createDebugLogger('STANDALONE_UPDATE'); + +const OSS_BASE = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code'; +const GITHUB_BASE = 'https://github.com/QwenLM/qwen-code/releases/download'; +const FETCH_TIMEOUT_MS = 30_000; +const ARCHIVE_TIMEOUT_MS = 300_000; // 5 min — archives are 50–150 MB + +const VALID_TARGETS = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win-x64', +]); + +const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/; + +type UndiciResponse = Awaited>; + +function normalizeVersion(version: string): string { + if (!SEMVER_RE.test(version)) { + throw new Error(`Invalid version format: ${version}`); + } + return version.startsWith('v') ? version : `v${version}`; +} + +function validateTarget(target: string): void { + if (!VALID_TARGETS.has(target)) { + throw new Error(`Unknown target: ${target}`); + } +} + +function archiveFilename(target: string): string { + const ext = target.startsWith('win') ? 'zip' : 'tar.gz'; + return `qwen-code-${target}.${ext}`; +} + +function escapePS(s: string): string { + return s.replace(/'/g, "''"); +} + +async function tryFetch( + url: string, + timeoutMs = FETCH_TIMEOUT_MS, +): Promise { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) return res; + // Consume body to release the socket back to the connection pool + await res.body?.cancel().catch(() => {}); + } catch (err) { + debugLogger.debug(`Fetch failed for ${url}: ${err}`); + } + return null; +} + +async function downloadWithFallback( + versionPath: string, + filename: string, + timeoutMs = FETCH_TIMEOUT_MS, +): Promise { + const ossUrl = `${OSS_BASE}/${versionPath}/${filename}`; + const ossRes = await tryFetch(ossUrl, timeoutMs); + if (ossRes) return ossRes; + + const ghUrl = `${GITHUB_BASE}/${versionPath}/${filename}`; + const ghRes = await tryFetch(ghUrl, timeoutMs); + if (ghRes) return ghRes; + + throw new Error( + `Failed to download ${filename} from both OSS and GitHub mirrors`, + ); +} + +async function verifyChecksum( + filePath: string, + filename: string, + versionPath: string, +): Promise { + const response = await downloadWithFallback(versionPath, 'SHA256SUMS'); + const text = await response.text(); + + // Ed25519 signature verification of SHA256SUMS. + // NOTE: Currently uses a test key. Once release CI signs with the production + // key and publishes SHA256SUMS.sig, set QWEN_REQUIRE_SIGNATURE=1 to enforce. + // Until then, verification is best-effort (passes when .sig exists, warns when not). + const requireSig = process.env['QWEN_REQUIRE_SIGNATURE'] === '1'; + let sigResponse = await tryFetch(`${OSS_BASE}/${versionPath}/SHA256SUMS.sig`); + if (!sigResponse) { + sigResponse = await tryFetch( + `${GITHUB_BASE}/${versionPath}/SHA256SUMS.sig`, + ); + } + if (sigResponse) { + const sigContent = await sigResponse.text(); + verifySignature(text, sigContent.trim()); + debugLogger.info('SHA256SUMS signature verified.'); + } else if (requireSig) { + throw new Error( + 'SHA256SUMS.sig not found and QWEN_REQUIRE_SIGNATURE=1 is set', + ); + } else { + debugLogger.info( + 'SHA256SUMS.sig not available — update integrity relies on SHA256 checksum only.', + ); + } + + const expectedLine = text.split('\n').find((line) => { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) return false; + // Handle GNU coreutils binary-mode prefix: "hash *filename" + const name = parts[parts.length - 1]!.replace(/^\*/, ''); + return name === filename; + }); + if (!expectedLine) { + throw new Error(`No checksum found for ${filename} in SHA256SUMS`); + } + const expectedHash = expectedLine.trim().split(/\s+/)[0]!; + + const hash = createHash('sha256'); + const stream = fs.createReadStream(filePath); + for await (const chunk of stream) { + hash.update(chunk); + } + const actualHash = hash.digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error( + `Checksum mismatch: expected ${expectedHash}, got ${actualHash}`, + ); + } +} + +const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024; // 512 MB + +async function downloadToFile( + versionPath: string, + filename: string, + destPath: string, +): Promise { + const response = await downloadWithFallback( + versionPath, + filename, + ARCHIVE_TIMEOUT_MS, + ); + const body = response.body; + if (!body) throw new Error('Empty response body'); + + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) { + await body.cancel().catch(() => {}); + throw new Error( + `Download too large: ${contentLength} bytes exceeds ${MAX_DOWNLOAD_BYTES} limit`, + ); + } + + let bytesWritten = 0; + const dest = fs.createWriteStream(destPath); + const sizeGuard = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytesWritten += chunk.length; + if (bytesWritten > MAX_DOWNLOAD_BYTES) { + callback( + new Error(`Download exceeded ${MAX_DOWNLOAD_BYTES} byte limit`), + ); + } else { + callback(null, chunk); + } + }, + }); + await pipeline(Readable.fromWeb(body), sizeGuard, dest); +} + +async function extractArchive( + archivePath: string, + destDir: string, + target: string, +): Promise { + fs.mkdirSync(destDir, { recursive: true }); + + if (target.startsWith('win')) { + await new Promise((resolve, reject) => { + const ps = spawn( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Expand-Archive -Path '${escapePS(archivePath)}' -DestinationPath '${escapePS(destDir)}' -Force`, + ], + { stdio: 'ignore' }, + ); + ps.on('close', (code) => + code === 0 + ? resolve() + : reject(new Error(`Expand-Archive exited with code ${code}`)), + ); + ps.on('error', reject); + }); + } else { + await tar.extract({ + file: archivePath, + cwd: destDir, + preservePaths: false, + filter: (p) => !p.startsWith('/') && !p.includes('..'), + }); + } +} + +/** + * Runs a command and captures stdout, stderr, and exit code. + */ +function spawnAndCapture( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + let settled = false; + const child = execFile( + command, + args, + { timeout: timeoutMs }, + (err, out, stderr) => { + if (settled) return; + settled = true; + if ( + err && + (('killed' in err && err.killed) || ('signal' in err && err.signal)) + ) { + reject(new Error('Smoke test timed out')); + return; + } + const exitCode = + err && 'code' in err && typeof err.code === 'number' ? err.code : 0; + resolve({ exitCode, stdout: out || '', stderr: stderr || '' }); + }, + ); + child.on('error', (e) => { + if (settled) return; + settled = true; + reject(e); + }); + }); +} + +/** + * Validates that a resolved path lies within the expected parent directory. + * Guards against path traversal when constructing executable paths. + */ +function assertPathWithin(filePath: string, parentDir: string): void { + const resolved = path.resolve(filePath); + const resolvedParent = path.resolve(parentDir) + path.sep; + if ( + !resolved.startsWith(resolvedParent) && + resolved !== path.resolve(parentDir) + ) { + throw new Error( + `Path traversal detected: ${resolved} is outside ${resolvedParent}`, + ); + } +} + +/** + * Verifies the new installation can actually run by invoking --version. + * Prevents replacing a working install with a broken binary. + */ +async function smokeTest(newInstallDir: string, target: string): Promise { + const resolvedInstallDir = path.resolve(newInstallDir); + const nodeBin = target.startsWith('win') + ? path.join(resolvedInstallDir, 'node', 'node.exe') + : path.join(resolvedInstallDir, 'node', 'bin', 'node'); + const cliBin = path.join(resolvedInstallDir, 'lib', 'cli.js'); + + // Validate paths stay within the installation directory (CodeQL: shell command safety) + assertPathWithin(nodeBin, resolvedInstallDir); + assertPathWithin(cliBin, resolvedInstallDir); + + if (!fs.existsSync(nodeBin)) { + throw new Error(`Smoke test failed: node binary not found at ${nodeBin}`); + } + if (!fs.existsSync(cliBin)) { + throw new Error(`Smoke test failed: cli.js not found at ${cliBin}`); + } + + const { exitCode, stdout, stderr } = await spawnAndCapture( + nodeBin, + [cliBin, '--version'], + 10_000, + ); + if (exitCode !== 0) { + const detail = stderr.trim() ? `: ${stderr.trim()}` : ''; + throw new Error( + `Smoke test failed: new binary exited with code ${exitCode}${detail}`, + ); + } + const version = stdout.trim(); + if (!SEMVER_RE.test(version)) { + throw new Error( + `Smoke test failed: unexpected version output "${version}"`, + ); + } + debugLogger.info(`Smoke test passed: ${version}`); +} + +/** + * Sentinel written by the Windows deferred-update bat script while it is + * mid-rename. acquireLock refuses to reclaim a stale PID lock when this + * file exists, preventing a concurrent update from racing the rename. + */ +function sentinelPath(lockPath: string): string { + return `${lockPath}.swap`; +} + +function acquireLock(lockPath: string): boolean { + try { + fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + try { + const pidStr = fs.readFileSync(lockPath, 'utf-8').trim(); + const pid = parseInt(pidStr, 10); + // NaN (empty/corrupt file) or dead PID → stale lock, reclaim it + // — UNLESS the bat helper is mid-rename (sentinel file present). + if (Number.isNaN(pid) || !isProcessAlive(pid)) { + if (fs.existsSync(sentinelPath(lockPath))) { + // Deferred update still swapping directories; do not reclaim. + return false; + } + fs.unlinkSync(lockPath); + try { + fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + return false; + } + } + } catch { + // lock is held by another live process + } + return false; + } +} + +function releaseLock(lockPath: string): void { + try { + fs.unlinkSync(lockPath); + } catch { + // already gone + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Rejects paths that would break out of a `"..."` quoted shell context. + * Conservative on purpose: `~/.local/lib/qwen-code` is the documented + * install path, so any of these characters indicates a misconfigured + * or hostile environment and we prefer to abort rather than evaluate + * `$(...)`, backticks, or `\` escapes inside a wrapper script. + * + * Backslash is only dangerous in POSIX shells — on Windows cmd/bat it is + * the standard path separator. We validate per-platform in ensureBinWrapper. + */ +const UNSAFE_SHELL_META_UNIX = /[`$"\\;'\n\r]/; +const UNSAFE_SHELL_META_WIN = /[`$"\n\r]/; + +function assertSafeForShellEmbed(label: string, value: string): void { + const pattern = + os.platform() === 'win32' ? UNSAFE_SHELL_META_WIN : UNSAFE_SHELL_META_UNIX; + if (pattern.test(value)) { + throw new Error( + `${label} contains characters unsafe for shell embedding: ${value}`, + ); + } +} + +function atomicReplace( + standaloneDir: string, + newDir: string, + lockPath: string, +): 'done' | 'deferred' { + const oldDir = `${standaloneDir}.old`; + const pendingDir = `${standaloneDir}.new`; + + if (fs.existsSync(oldDir)) { + fs.rmSync(oldDir, { recursive: true, force: true }); + } + + if (os.platform() === 'win32') { + // On Windows, the running node.exe holds file locks. Stage the new dir + // as a sibling, then spawn a helper script that waits for this process + // to exit before completing the swap. + if (fs.existsSync(pendingDir)) { + fs.rmSync(pendingDir, { recursive: true, force: true }); + } + fs.renameSync(newDir, pendingDir); + + // Validate paths don't contain cmd.exe metacharacters that could break the script + const unsafeCmdChars = /[&|<>^%!"`\n\r]/; + if ( + unsafeCmdChars.test(standaloneDir) || + unsafeCmdChars.test(oldDir) || + unsafeCmdChars.test(pendingDir) + ) { + throw new Error( + 'Installation path contains characters unsafe for deferred update script', + ); + } + + const lockFile = lockPath; + const sentinelFile = sentinelPath(lockPath); + const logFile = path.join(path.dirname(standaloneDir), 'qwen-update.log'); + // Bat script runs detached after Node exits. It must: + // 1. Wait for this Node process to release file locks (<= 30s). + // 2. Write a sentinel so a concurrently-launched qwen does not reclaim the + // stale-PID lock and start another update mid-rename. + // 3. Run both moves with errorlevel checks; if move #2 fails, roll back + // move #1 so the user is never left without a working install. + // 4. Log success/failure to qwen-update.log for post-mortem (the bat + // runs with stdio:ignore — the log is the only diagnostic surface). + const script = [ + '@echo off', + 'set /a TRIES=0', + ':wait', + 'set /a TRIES+=1', + 'if %TRIES% GTR 30 goto proceed', + `tasklist /FI "PID eq ${process.pid}" 2>nul | find "${process.pid}" >nul && (timeout /t 1 >nul & goto wait)`, + ':proceed', + `echo swap-in-progress > "${sentinelFile}"`, + `echo [%DATE% %TIME%] starting swap >> "${logFile}"`, + `move /Y "${standaloneDir}" "${oldDir}"`, + 'if errorlevel 1 goto move1_failed', + `move /Y "${pendingDir}" "${standaloneDir}"`, + 'if errorlevel 1 goto move2_failed', + `echo [%DATE% %TIME%] swap completed >> "${logFile}"`, + 'goto cleanup', + ':move1_failed', + `echo [%DATE% %TIME%] ERROR: failed to rename install to .old (errorlevel %errorlevel%) >> "${logFile}"`, + 'goto cleanup', + ':move2_failed', + `echo [%DATE% %TIME%] ERROR: failed to promote .new; rolling back >> "${logFile}"`, + `move /Y "${oldDir}" "${standaloneDir}"`, + 'if errorlevel 1 (', + ` echo [%DATE% %TIME%] CRITICAL: rollback also failed; manual recovery: move "${oldDir}" "${standaloneDir}" >> "${logFile}"`, + ') else (', + ` echo [%DATE% %TIME%] rollback succeeded >> "${logFile}"`, + ')', + ':cleanup', + 'rem Release sentinel and update lock; keep .old (if present) for rollback', + `del /F /Q "${sentinelFile}" 2>nul`, + `del /F /Q "${lockFile}" 2>nul`, + `del "%~f0"`, + ].join('\r\n'); + const scriptPath = path.join( + path.dirname(standaloneDir), + 'qwen-update.bat', + ); + fs.writeFileSync(scriptPath, script); + spawn('cmd.exe', ['/c', scriptPath], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }).unref(); + return 'deferred'; + } else { + // Unix: rename is atomic on same filesystem. newDir is a sibling of + // standaloneDir (same parent), so EXDEV won't happen. + fs.renameSync(standaloneDir, oldDir); + try { + fs.renameSync(newDir, standaloneDir); + } catch (promoteErr) { + // Recovery rename can also fail (e.g. FS hiccup, oldDir grabbed by + // another process). Surface BOTH errors with manual-recovery steps so + // the user is never silently left with a missing install. + try { + fs.renameSync(oldDir, standaloneDir); + } catch (rollbackErr) { + const detail = + `Standalone update failed AND rollback failed.\n` + + `Original error: ${(promoteErr as Error).message}\n` + + `Rollback error: ${(rollbackErr as Error).message}\n` + + `Manual recovery: mv "${oldDir}" "${standaloneDir}"`; + throw new Error(detail); + } + throw promoteErr; + } + // Keep .old for rollback instead of deleting immediately + return 'done'; + } +} + +/** + * Ensures ~/.local/bin/qwen exists and points to the standalone install. + * Required for npm→standalone migration so the new binary is on PATH. + */ +export function ensureBinWrapper(standaloneDir: string, target: string): void { + // Validate before embedding in any shell/cmd context + assertSafeForShellEmbed('standaloneDir', standaloneDir); + const binDir = path.join(path.dirname(standaloneDir), '..', 'bin'); + assertSafeForShellEmbed('binDir', binDir); + + try { + fs.mkdirSync(binDir, { recursive: true }); + if (target.startsWith('win')) { + const wrapperPath = path.join(binDir, 'qwen.cmd'); + if (!fs.existsSync(wrapperPath)) { + const content = `@echo off\r\ncall "${standaloneDir}\\bin\\qwen.cmd" %*\r\n`; + fs.writeFileSync(wrapperPath, content); + } + } else { + const wrapperPath = path.join(binDir, 'qwen'); + if (!fs.existsSync(wrapperPath)) { + const content = `#!/bin/sh\nexec "${standaloneDir}/bin/qwen" "$@"\n`; + fs.writeFileSync(wrapperPath, content, { mode: 0o755 }); + } + ensurePathInShellRc(binDir); + } + } catch (err) { + debugLogger.debug('Failed to create bin wrapper:', err); + } +} + +/** + * Appends binDir to the user's shell rc file if not already present. + * Mirrors the logic in install-qwen-standalone.sh maybe_update_shell_path. + */ +export function ensurePathInShellRc(binDir: string): void { + assertSafeForShellEmbed('binDir', binDir); + const shell = process.env['SHELL'] || ''; + let rcFile: string | null = null; + const home = process.env['HOME'] || os.homedir(); + + if (shell.endsWith('/zsh')) { + rcFile = path.join(home, '.zshrc'); + } else if (shell.endsWith('/bash')) { + // Prefer .bashrc; fall back to .bash_profile on macOS + const bashrc = path.join(home, '.bashrc'); + const profile = path.join(home, '.bash_profile'); + rcFile = fs.existsSync(bashrc) ? bashrc : profile; + } else if (shell.endsWith('/fish')) { + rcFile = path.join(home, '.config', 'fish', 'config.fish'); + } + + if (!rcFile) return; + + try { + const content = fs.existsSync(rcFile) + ? fs.readFileSync(rcFile, 'utf-8') + : ''; + // Use a marker to detect our managed PATH entry precisely, + // avoiding false positives from comments or $PATH-appended entries + const marker = '# Added by Qwen Code standalone installer'; + if (content.includes(marker)) return; + + const exportLine = shell.endsWith('/fish') + ? `\n${marker}\nfish_add_path "${binDir}"\n` + : `\n${marker}\nexport PATH="${binDir}:$PATH"\n`; + fs.appendFileSync(rcFile, exportLine); + debugLogger.info(`Added ${binDir} to ${rcFile}`); + } catch (err) { + debugLogger.debug('Failed to update shell rc:', err); + } +} + +/** + * Detect the current platform target string for standalone archives. + */ +function detectTarget(): string { + const platform = os.platform(); + const arch = os.arch(); + if (platform === 'darwin') + return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'; + if (platform === 'win32') return 'win-x64'; + return arch === 'arm64' ? 'linux-arm64' : 'linux-x64'; +} + +export async function performStandaloneUpdate( + standaloneDir: string, + newVersion: string, +): Promise<'done' | 'deferred'> { + const versionPath = normalizeVersion(newVersion); + + let target: string; + const manifestPath = path.join(standaloneDir, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifestRaw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw) as { target?: string }; + target = manifest.target || detectTarget(); + } else if (fs.existsSync(standaloneDir)) { + // Directory exists but has no manifest — not a managed Qwen install. + // Refuse to overwrite to avoid data loss. + throw new Error( + `${standaloneDir} exists but is not a Qwen Code standalone install. Remove it manually to proceed.`, + ); + } else { + // First-time migration from npm — directory does not exist yet + target = detectTarget(); + fs.mkdirSync(standaloneDir, { recursive: true }); + } + validateTarget(target); + + const filename = archiveFilename(target); + const parentDir = path.dirname(standaloneDir); + + // Use a lockfile to prevent concurrent updates + const lockPath = path.join(parentDir, '.qwen-update.lock'); + if (!acquireLock(lockPath)) { + throw new Error('Another update is already in progress'); + } + + // Download to a temp dir in os.tmpdir(), then extract to a sibling dir + // of standaloneDir to avoid EXDEV (cross-device rename). + // extractDir uses mkdtempSync (random suffix) to prevent symlink + // pre-creation attacks on predictable directory names. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-code-update-')); + let extractDir: string; + try { + extractDir = fs.mkdtempSync(path.join(parentDir, '.qwen-code-update-')); + } catch (err) { + fs.rmSync(tempDir, { recursive: true, force: true }); + throw err; + } + + try { + const archivePath = path.join(tempDir, filename); + debugLogger.info(`Downloading ${filename} (${versionPath})...`); + await downloadToFile(versionPath, filename, archivePath); + + debugLogger.info('Verifying checksum...'); + await verifyChecksum(archivePath, filename, versionPath); + + debugLogger.info('Extracting archive...'); + await extractArchive(archivePath, extractDir, target); + + const newInstallDir = path.join(extractDir, 'qwen-code'); + if (!fs.existsSync(path.join(newInstallDir, 'manifest.json'))) { + throw new Error( + 'Extracted archive does not contain expected qwen-code directory', + ); + } + + debugLogger.info('Running smoke test...'); + await smokeTest(newInstallDir, target); + + debugLogger.info('Replacing installation...'); + const result = atomicReplace(standaloneDir, newInstallDir, lockPath); + + // Write rollback metadata so /doctor rollback knows what version is preserved + const oldDir = `${standaloneDir}.old`; + if (fs.existsSync(oldDir)) { + try { + // Read the old manifest to capture its version + const oldManifestPath = path.join(oldDir, 'manifest.json'); + let oldVersion = 'unknown'; + if (fs.existsSync(oldManifestPath)) { + const oldManifest = JSON.parse( + fs.readFileSync(oldManifestPath, 'utf-8'), + ) as { version?: string }; + oldVersion = oldManifest.version || 'unknown'; + } + const rollbackInfo = { + preservedVersion: oldVersion, + updatedTo: versionPath, + timestamp: new Date().toISOString(), + reason: 'auto-update', + }; + fs.writeFileSync( + path.join(oldDir, '.qwen-rollback-info.json'), + JSON.stringify(rollbackInfo, null, 2), + ); + } catch { + // Non-critical — rollback still works without metadata + } + } + + // Ensure bin wrapper exists (critical for npm→standalone migration) + ensureBinWrapper(standaloneDir, target); + + debugLogger.info('Standalone update complete.'); + return result; + } catch (err) { + // On error: clean orphaned pendingDir (only safe when NOT a successful deferred update) + const pendingDir = `${standaloneDir}.new`; + if (fs.existsSync(pendingDir)) { + fs.rmSync(pendingDir, { recursive: true, force: true }); + } + throw err; + } finally { + // On Windows deferred updates, keep the lock alive until the bat script + // finishes the swap — it will be cleaned up by the next successful update. + // On Unix (immediate), release the lock now. + if (os.platform() !== 'win32') { + releaseLock(lockPath); + } + fs.rmSync(tempDir, { recursive: true, force: true }); + // Only remove extractDir if it is a real directory (not a symlink) + try { + const stat = fs.lstatSync(extractDir); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.rmSync(extractDir, { recursive: true, force: true }); + } else { + debugLogger.warn( + `Skipping cleanup of extractDir (unexpected type): ${extractDir}`, + ); + } + } catch { + // Already removed or never created — nothing to clean + } + } +} + +export type RollbackResult = + | { ok: true } + | { + ok: false; + reason: 'no-old' | 'no-manifest' | 'rename-failed'; + detail: string; + }; + +/** + * Rolls back a standalone installation to the previous version (.old directory). + */ +export function rollbackStandaloneUpdate( + standaloneDir: string, +): RollbackResult { + const oldDir = `${standaloneDir}.old`; + + if (!fs.existsSync(oldDir)) { + return { ok: false, reason: 'no-old', detail: `${oldDir} does not exist` }; + } + + const oldManifest = path.join(oldDir, 'manifest.json'); + if (!fs.existsSync(oldManifest)) { + debugLogger.error('Rollback failed: .old directory has no manifest.json'); + return { + ok: false, + reason: 'no-manifest', + detail: `${oldDir}/manifest.json missing — .old may be corrupt`, + }; + } + + const failedDir = `${standaloneDir}.failed`; + try { + if (fs.existsSync(failedDir)) { + fs.rmSync(failedDir, { recursive: true, force: true }); + } + fs.renameSync(standaloneDir, failedDir); + fs.renameSync(oldDir, standaloneDir); + fs.rmSync(failedDir, { recursive: true, force: true }); + debugLogger.info('Rollback successful.'); + return { ok: true }; + } catch (err) { + debugLogger.error('Rollback failed:', err); + // Attempt to restore current if we moved it + if (!fs.existsSync(standaloneDir) && fs.existsSync(failedDir)) { + try { + fs.renameSync(failedDir, standaloneDir); + } catch { + // Critical failure — both dirs are in bad state + } + } + return { + ok: false, + reason: 'rename-failed', + detail: `Filesystem error: ${(err as Error).message}. Manual recovery: mv "${oldDir}" "${standaloneDir}"`, + }; + } +} diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh new file mode 100755 index 00000000000..0d68f11b8f9 --- /dev/null +++ b/scripts/sign-release.sh @@ -0,0 +1,42 @@ +#!/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 " >&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}")"