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
This commit is contained in:
易良 2026-06-04 22:53:12 +08:00 committed by GitHub
parent 6d083fad5d
commit 9e4bea606a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1734 additions and 19 deletions

1
package-lock.json generated
View file

@ -17597,6 +17597,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",

View file

@ -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",

View file

@ -1902,6 +1902,17 @@ export default {
'Show current process memory diagnostics',
'Record a CPU profile for Chrome DevTools analysis':
'Record a CPU profile for Chrome DevTools analysis',
'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:': 'Rollback failed:',
'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':

View file

@ -1489,6 +1489,16 @@ export default {
'Show current process memory diagnostics': '顯示目前程序的內存診斷。',
'Record a CPU profile for Chrome DevTools analysis':
'錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析',
'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:': '回滾失敗:',
'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':
'在不影響主對話的情況下快速提問旁支問題',

View file

@ -1724,6 +1724,16 @@ export default {
'Show current process memory diagnostics': '显示当前进程的内存诊断。',
'Record a CPU profile for Chrome DevTools analysis':
'录制 CPU 性能分析文件,用于 Chrome DevTools 分析',
'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:': '回滚失败:',
'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.': '显示按项目划分的上下文使用详情。',

View file

@ -178,6 +178,7 @@ describe('doctorCommand', () => {
await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([
'memory',
'cpu-profile',
'rollback',
]);
await expect(
doctorCommand.completion!(mockContext, 'mem'),
@ -185,6 +186,9 @@ describe('doctorCommand', () => {
await expect(
doctorCommand.completion!(mockContext, 'cpu'),
).resolves.toEqual(['cpu-profile']);
await expect(
doctorCommand.completion!(mockContext, 'roll'),
).resolves.toEqual(['rollback']);
await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual(
[],
);
@ -1054,7 +1058,7 @@ describe('doctorCommand', () => {
it('should advertise the memory subcommand on the parent doctor argumentHint', () => {
expect(doctorCommand.argumentHint).toBe(
'[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
'[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
);
});
});

View file

@ -21,6 +21,8 @@ import {
startCpuProfile,
stopCpuProfile,
} from '../../utils/cpuProfiler.js';
import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js';
import { getInstallationInfo } from '../../utils/installationInfo.js';
import { t } from '../../i18n/index.js';
import {
collectMemoryDiagnostics,
@ -30,7 +32,8 @@ import { formatMemoryUsage } from '../utils/formatters.js';
const MEMORY_SUBCOMMAND = 'memory';
const CPU_PROFILE_SUBCOMMAND = 'cpu-profile';
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND] as const;
const ROLLBACK_SUBCOMMAND = 'rollback';
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_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.',
@ -55,7 +58,7 @@ export const doctorCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
argumentHint: '[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
argumentHint: '[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
examples: [
'/doctor',
'/doctor memory',
@ -63,6 +66,7 @@ export const doctorCommand: SlashCommand = {
'/doctor memory --snapshot',
'/doctor cpu-profile',
'/doctor cpu-profile --duration 10',
'/doctor rollback',
],
completion: async (_context, partialArg) => {
const trimmed = partialArg.trimStart();
@ -79,6 +83,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;
@ -255,6 +270,15 @@ export const doctorCommand: SlashCommand = {
argumentHint: '[--duration <seconds>]',
action: cpuProfileDoctorAction,
},
{
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,
},
],
};
@ -580,3 +604,57 @@ async function cpuProfileDoctorAction(
}
return { type: 'message', messageType: 'info', content: successMsg };
}
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,
};
}

View file

@ -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<typeof vi.spyOn>;
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),
);

View file

@ -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.',
});
};

View file

@ -28,6 +28,7 @@ vi.mock('fs', async (importOriginal) => {
existsSync: vi.fn(),
lstatSync: vi.fn(),
readFileSync: vi.fn(),
accessSync: vi.fn(),
};
});
@ -198,9 +199,10 @@ describe('getInstallationInfo', () => {
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.isGlobal).toBe(true);
expect(info.isStandalone).toBe(true);
expect(info.standaloneDir).toBe(installDir);
expect(info.updateCommand).toBeUndefined();
expect(info.updateMessage).toContain('Standalone install detected');
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
expect(info.updateMessage).not.toContain('npm install');
});
@ -242,8 +244,10 @@ describe('getInstallationInfo', () => {
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.isStandalone).toBe(true);
expect(info.standaloneDir).toBe(installDir);
expect(info.updateCommand).toBeUndefined();
expect(info.updateMessage).toContain('install-qwen-standalone.ps1');
expect(info.updateMessage).toContain('Standalone install detected');
expect(info.updateMessage).not.toContain('npm install');
});
@ -285,8 +289,9 @@ describe('getInstallationInfo', () => {
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.isGlobal).toBe(true);
expect(info.isStandalone).toBe(true);
expect(info.standaloneDir).toBe(installDir);
expect(info.updateMessage).toContain('Standalone install detected');
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
});
it('should fall back to npm when manifest.json is malformed', () => {

View file

@ -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';
@ -31,6 +32,8 @@ const STANDALONE_WINDOWS_INSTALLER =
export interface InstallationInfo {
packageManager: PackageManager;
isGlobal: boolean;
isStandalone?: boolean;
standaloneDir?: string;
updateCommand?: string;
updateMessage?: string;
}
@ -174,7 +177,45 @@ export function getInstallationInfo(
};
}
// Assume global npm
// Check if the package directory is writable to determine whether npm update requires sudo
const npmPackageDir = path.dirname(path.dirname(realPath));
let npmPrefixWritable = false;
try {
fs.accessSync(npmPackageDir, 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,
@ -203,14 +244,15 @@ function getStandaloneInstallInfo(
process.platform === 'win32'
? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"`
: `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`;
const updatePrefix = isAutoUpdateEnabled
? 'Standalone install detected. Automatic in-place updates are not supported yet.'
: 'Standalone install detected.';
return {
packageManager: PackageManager.STANDALONE,
isGlobal: true,
updateMessage: `${updatePrefix} Please rerun the standalone installer to update: ${updateCommand}`,
isStandalone: true,
standaloneDir: installDir,
updateMessage: isAutoUpdateEnabled
? 'Standalone install detected. Attempting to automatically update now...'
: `Standalone install detected. Please rerun the standalone installer to update: ${updateCommand}`,
};
}

View file

@ -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');
});
});
});

View file

@ -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',
);
}
}

View file

@ -0,0 +1,405 @@
/**
* @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);
expect(result).toHaveProperty('reason', '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);
expect(result).toHaveProperty('reason', '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('rollbackStandaloneUpdate — concurrent lock protection', () => {
it('returns error when an active update holds the lock', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(path.join(standaloneDir, 'manifest.json'), '{}');
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
fs.writeFileSync(lockPath, String(process.pid));
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.detail).toContain('auto-update is currently in progress');
}
fs.unlinkSync(lockPath);
});
it('proceeds when lock has dead PID', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
const lockPath = path.join(tempDir, '.qwen-update.lock');
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'),
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.16.0' }),
);
fs.writeFileSync(lockPath, '999999999');
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(true);
});
});
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('appends fish_add_path for fish shell', () => {
const binDir = path.join(tempDir, 'bin');
const fishDir = path.join(tempDir, '.config', 'fish');
const fishConfig = path.join(fishDir, 'config.fish');
fs.mkdirSync(fishDir, { recursive: true });
fs.writeFileSync(fishConfig, '# existing config\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/usr/bin/fish';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(fishConfig, 'utf-8');
expect(content).toContain('fish_add_path');
expect(content).toContain(binDir);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('rejects binDir with shell metacharacters', () => {
const binDir = path.join(tempDir, 'bin$(evil)');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
expect(() => ensurePathInShellRc(binDir)).toThrow(
'unsafe for shell embedding',
);
} 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;
}
});
});
});

View file

@ -0,0 +1,860 @@
/**
* @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 50150 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<ReturnType<typeof fetch>>;
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<
| { response: UndiciResponse; error?: undefined }
| { response?: undefined; error: Error }
> {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
if (res.ok) return { response: res };
await res.body?.cancel().catch(() => {});
return { error: new Error(`HTTP ${res.status} ${res.statusText}`) };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
debugLogger.debug(`Fetch failed for ${url}: ${error.message}`);
return { error };
}
}
async function downloadWithFallback(
versionPath: string,
filename: string,
timeoutMs = FETCH_TIMEOUT_MS,
): Promise<UndiciResponse> {
const ossUrl = `${OSS_BASE}/${versionPath}/${filename}`;
const ossResult = await tryFetch(ossUrl, timeoutMs);
if (ossResult.response) return ossResult.response;
const ghUrl = `${GITHUB_BASE}/${versionPath}/${filename}`;
const ghResult = await tryFetch(ghUrl, timeoutMs);
if (ghResult.response) return ghResult.response;
throw new Error(
`Failed to download ${filename}: OSS (${ossResult.error?.message ?? 'unknown'}), GitHub (${ghResult.error?.message ?? 'unknown'})`,
);
}
async function verifyChecksum(
actualHash: string,
filename: string,
versionPath: string,
): Promise<void> {
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: UndiciResponse | undefined;
try {
sigResponse = await downloadWithFallback(versionPath, 'SHA256SUMS.sig');
} catch (err) {
debugLogger.debug('SHA256SUMS.sig not available:', err);
}
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.warn(
'SHA256SUMS.sig not available — update integrity relies on SHA256 checksum only. ' +
'Set QWEN_REQUIRE_SIGNATURE=1 to enforce signature verification.',
);
}
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]!;
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<string> {
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`,
);
}
const hash = createHash('sha256');
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 {
hash.update(chunk);
callback(null, chunk);
}
},
});
await pipeline(Readable.fromWeb(body), sizeGuard, dest);
return hash.digest('hex');
}
function validateExtractedPaths(resolvedDest: string): void {
const entries = fs.readdirSync(resolvedDest, {
recursive: true,
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = path.join(
String(entry.parentPath || entry.path),
entry.name,
);
const resolved = fs.realpathSync(fullPath);
if (
!resolved.startsWith(resolvedDest + path.sep) &&
resolved !== resolvedDest
) {
fs.rmSync(resolvedDest, { recursive: true, force: true });
throw new Error(
`Path traversal detected in archive: ${entry.name} resolves to ${resolved}`,
);
}
}
}
async function extractArchive(
archivePath: string,
destDir: string,
target: string,
): Promise<void> {
fs.mkdirSync(destDir, { recursive: true });
if (target.startsWith('win')) {
await new Promise<void>((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);
});
const resolvedDest = fs.realpathSync(destDir);
validateExtractedPaths(resolvedDest);
} else {
const resolvedDest = path.resolve(destDir);
await tar.extract({
file: archivePath,
cwd: destDir,
preservePaths: false,
filter: (p, entry) => {
if (p.startsWith('/') || p.includes('..')) return false;
if (
'type' in entry &&
entry.type === 'SymbolicLink' &&
'linkpath' in entry
) {
const linkTarget = path.resolve(
resolvedDest,
path.dirname(p),
String(entry.linkpath),
);
if (
!linkTarget.startsWith(resolvedDest + path.sep) &&
linkTarget !== resolvedDest
) {
return false;
}
}
return true;
},
});
// Post-extraction defense-in-depth: detect chained symlink attacks that
// bypass the string-level filter (e.g. symlink A → ".", then A/payload → "../../etc")
validateExtractedPaths(fs.realpathSync(destDir));
}
}
/**
* 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;
}
if (err) {
const exitCode =
'code' in err && typeof err.code === 'number' ? err.code : 1;
resolve({ exitCode, stdout: out || '', stderr: stderr || '' });
return;
}
resolve({ exitCode: 0, stdout: out || '', stderr: stderr || '' });
},
);
child.on('error', (e) => {
if (settled) return;
settled = true;
reject(e);
});
});
}
/**
* 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<void> {
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');
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}`);
}
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);
if (Number.isNaN(pid) || !isProcessAlive(pid)) {
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
}
}
// Remove an empty standaloneDir left behind by a failed first-time migration
// (no manifest.json means it was just mkdir'd, never populated). Prevents
// permanently blocking future updates with "exists but is not a standalone install".
function cleanupEmptyStandaloneDir(standaloneDir: string): void {
const manifestPath = path.join(standaloneDir, 'manifest.json');
if (fs.existsSync(standaloneDir) && !fs.existsSync(manifestPath)) {
fs.rmSync(standaloneDir, { recursive: true, force: true });
}
}
const UNSAFE_SHELL_CHARS = /["`$\\;\n\r]/;
const UNSAFE_CMD_CHARS = /[&|<>^%!"`\n\r]/;
function assertSafeForShellEmbed(p: string, context: string): void {
if (UNSAFE_SHELL_CHARS.test(p)) {
throw new Error(
`${context} contains characters unsafe for shell embedding: ${p}`,
);
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
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.
// Validate paths BEFORE any filesystem mutations
if (
UNSAFE_CMD_CHARS.test(standaloneDir) ||
UNSAFE_CMD_CHARS.test(oldDir) ||
UNSAFE_CMD_CHARS.test(pendingDir)
) {
throw new Error(
'Installation path contains characters unsafe for deferred update script',
);
}
if (fs.existsSync(pendingDir)) {
fs.rmSync(pendingDir, { recursive: true, force: true });
}
fs.renameSync(newDir, pendingDir);
const lockFile = 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. Run both moves with errorlevel checks; if move #2 fails, roll back
// move #1 so the user is never left without a working install.
// 3. 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 [%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',
`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 npmstandalone migration so the new binary is on PATH.
*/
export function ensureBinWrapper(standaloneDir: string, target: string): void {
const binDir = path.join(path.dirname(standaloneDir), '..', 'bin');
try {
fs.mkdirSync(binDir, { recursive: true });
if (target.startsWith('win')) {
if (UNSAFE_CMD_CHARS.test(standaloneDir)) {
throw new Error(
'standaloneDir contains characters unsafe for cmd.exe wrapper',
);
}
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 {
assertSafeForShellEmbed(standaloneDir, 'standaloneDir');
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.warn('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')) {
const bashrc = path.join(home, '.bashrc');
const profile = path.join(home, '.bash_profile');
// macOS bash reads .bash_profile for login shells; Linux reads .bashrc.
// Match install-qwen-standalone.sh's maybe_update_shell_path logic.
if (os.platform() === 'darwin') {
rcFile = fs.existsSync(profile) ? profile : bashrc;
} else {
rcFile = bashrc;
}
} 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';
if (platform === 'linux') {
if (arch === 'arm64') return 'linux-arm64';
if (arch === 'x64') return 'linux-x64';
}
throw new Error(`Unsupported platform: ${platform}-${arch}`);
}
export async function performStandaloneUpdate(
standaloneDir: string,
newVersion: string,
): Promise<'done' | 'deferred'> {
const versionPath = normalizeVersion(newVersion);
let target: string;
let isFirstTimeMigration = false;
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 will be created after lock
target = detectTarget();
isFirstTimeMigration = true;
}
validateTarget(target);
const filename = archiveFilename(target);
const parentDir = path.dirname(standaloneDir);
// Ensure the parent directory exists so the lock file can be created.
// On first-time migration, standaloneDir (and its parent) may not exist yet.
fs.mkdirSync(parentDir, { recursive: true });
// Use a lockfile to prevent concurrent updates.
// Acquire lock BEFORE creating standaloneDir to prevent a concurrent
// process from seeing the empty directory and throwing a misleading error.
const lockPath = path.join(parentDir, '.qwen-update.lock');
if (!acquireLock(lockPath)) {
throw new Error('Another update is already in progress');
}
if (isFirstTimeMigration) {
fs.mkdirSync(standaloneDir, { recursive: true });
}
// 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;
let updateResult: 'done' | 'deferred' | undefined;
try {
extractDir = fs.mkdtempSync(path.join(parentDir, '.qwen-code-update-'));
} catch (err) {
fs.rmSync(tempDir, { recursive: true, force: true });
releaseLock(lockPath);
throw err;
}
try {
const archivePath = path.join(tempDir, filename);
debugLogger.info(`Downloading ${filename} (${versionPath})...`);
const archiveHash = await downloadToFile(
versionPath,
filename,
archivePath,
);
debugLogger.info('Verifying checksum...');
await verifyChecksum(archiveHash, 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...');
updateResult = atomicReplace(standaloneDir, newInstallDir, lockPath);
// Write rollback metadata so /doctor rollback knows what version is preserved.
// For first-time migrations, the .old dir is the empty seed directory —
// remove it since there is no meaningful version to roll back to.
const oldDir = `${standaloneDir}.old`;
if (fs.existsSync(oldDir)) {
if (isFirstTimeMigration) {
fs.rmSync(oldDir, { recursive: true, force: true });
} else {
try {
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 updateResult;
} catch (err) {
const pendingDir = `${standaloneDir}.new`;
if (fs.existsSync(pendingDir)) {
fs.rmSync(pendingDir, { recursive: true, force: true });
}
cleanupEmptyStandaloneDir(standaloneDir);
throw err;
} finally {
// Only keep the lock alive when the bat script was spawned (deferred).
// On failure or on Unix, release immediately.
if (updateResult !== 'deferred') {
releaseLock(lockPath);
}
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
try {
fs.rmSync(extractDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
}
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 lockPath = path.join(path.dirname(standaloneDir), '.qwen-update.lock');
try {
const pidStr = fs.readFileSync(lockPath, 'utf-8').trim();
const pid = parseInt(pidStr, 10);
if (!Number.isNaN(pid) && isProcessAlive(pid)) {
return {
ok: false,
reason: 'rename-failed',
detail:
'An auto-update is currently in progress. Wait for it to finish before rolling back.',
};
}
} catch {
// No lock file — safe to proceed
}
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);
debugLogger.info('Rollback successful.');
try {
fs.rmSync(failedDir, { recursive: true, force: true });
} catch {
debugLogger.debug(`Leftover .failed dir at ${failedDir}, safe to delete`);
}
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);
return {
ok: false,
reason: 'rename-failed',
detail: `Filesystem error: ${(err as Error).message}. Current installation was restored automatically.`,
};
} 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}"`,
};
}
}

42
scripts/sign-release.sh Executable file
View file

@ -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 <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}")"