mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(update): support a configurable release download base URL (#11150)
Co-authored-by: 西岐 <[email protected]> Co-authored-by: 易良 <[email protected]>
This commit is contained in:
7 files changed
+464
-4
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# Configurable standalone update downloads
|
||||
|
||||
Issue: #11149
|
||||
|
||||
## Problem and current behavior
|
||||
|
||||
The standalone updater uses fixed release download sources. The installer has its own base URL option, but it selects a final version directory; reusing that variable in the updater would introduce different path semantics. All standalone update entrypoints share performStandaloneUpdate, including qwen update, /update, background updates, and update-and-relaunch.
|
||||
|
||||
## Proposed behavior
|
||||
|
||||
Introduce QWEN_UPDATE_BASE_URL as an optional absolute HTTPS release root. The updater appends the normalized v-prefixed version and platform archive name. The same root supplies SHA256SUMS and its optional signature. For example, https://downloads.example.com/qwen-code resolves version 0.23.0 beneath /qwen-code/v0.23.0/.
|
||||
|
||||
Resolve and validate the value once per update before creating directories or a lock. Treat an empty or whitespace-only value as unset and normalize trailing slashes. Reject non-HTTPS URLs, credentials, query strings, and fragments without echoing the rejected value.
|
||||
|
||||
With no override, preserve existing source order and fallback behavior. With an override, use only the configured source and report download failure without trying the built-in sources. Keep TLS verification, checksum checking, signature policy, extraction checks, smoke testing, rollback, and cleanup unchanged.
|
||||
|
||||
## Configuration trust
|
||||
|
||||
The update source controls executable downloads. Add the variable to the existing project-environment hardcoded exclusions. The launching shell and initial user-level dotenv loading may supply it; project dotenv files and the top-level settings.env section at every scope must not set or override it. User-level dotenv changes require a restart. Reuse the shared filter so initial loading, reload, and explicit runtime environments agree.
|
||||
|
||||
## Affected areas
|
||||
|
||||
- Shared standalone updater: one-time source resolution and download propagation.
|
||||
- Existing environment exclusion list: protect the new variable from project configuration.
|
||||
- Collocated updater and environment tests: custom/default source behavior, failures, integrity, and trust boundaries.
|
||||
- User configuration documentation: variable syntax, path rules, fallback, trusted scopes, and separate npm registry lookup.
|
||||
|
||||
## Validation
|
||||
|
||||
Use temporary standalone fixtures, controlled HTTPS download responses, and isolated CLI configuration. Check the global CLI baseline without modifying its installation, then exercise the built local CLI and shared downloader. Cover full archive/verification URL selection, absent optional signatures, required-signature/checksum failures, invalid configuration before mutation, no fallback for a custom source, existing defaults, and environment filtering. No model call or live deployment is required.
|
||||
|
||||
## Scope and open decisions
|
||||
|
||||
This does not change installer semantics, npm version discovery, authentication, update scheduling, or verification policy. The configuration name and custom-source fallback choice remain reviewable proposals for maintainers; this PR chooses a distinct updater variable and no implicit fallback when it is set.
|
||||
@@ -791,6 +791,23 @@ When both user-level `.env` files define the same variable, the Qwen-specific
|
||||
file wins: `<QWEN_HOME>/.env` (or `~/.qwen/.env` when `QWEN_HOME` is unset) is
|
||||
loaded before `~/.env`, and existing environment values are not overwritten.
|
||||
|
||||
### Standalone update download source
|
||||
|
||||
Set `QWEN_UPDATE_BASE_URL` to use a custom HTTPS release root for standalone updates:
|
||||
|
||||
```bash
|
||||
export QWEN_UPDATE_BASE_URL="https://downloads.example.com/qwen-code"
|
||||
qwen update
|
||||
```
|
||||
|
||||
For version `0.23.0`, the updater downloads the platform archive, `SHA256SUMS`, and `SHA256SUMS.sig` from `<base-url>/v0.23.0/`. Host these files using the same names and directory layout as an official release. Existing checksum and signature checks still apply, including `QWEN_REQUIRE_SIGNATURE=1` when a signature is required.
|
||||
|
||||
The URL must use HTTPS and cannot contain credentials, a query string, or a fragment. Surrounding whitespace and trailing slashes are removed. An empty or whitespace-only value preserves the built-in download sources and their fallback order. When a custom root is set, a failed download does not fall back to the built-in sources.
|
||||
|
||||
Configure this variable in the launching shell or a user-level `.env` file. It is rejected from project `.env` and `.qwen/.env` files and from the top-level `settings.json` `env` section at every scope. A user-level `.env` value is loaded at startup; restart Qwen Code after changing it.
|
||||
|
||||
This setting applies to `qwen update`, `/update`, and automatic standalone updates. It does not change npm registry version discovery. It is separate from the installer's `QWEN_INSTALL_BASE_URL`, which points directly to a version-specific directory.
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
Arguments passed directly when running the CLI can override other configurations for that specific session.
|
||||
|
||||
@@ -84,6 +84,9 @@ const TRACKED_ENV = [
|
||||
'QWEN_CLI_ENTRY',
|
||||
'qwen_cli_entry',
|
||||
'Qwen_Cli_Entry',
|
||||
'QWEN_UPDATE_BASE_URL',
|
||||
'qwen_update_base_url',
|
||||
'Qwen_Update_Base_Url',
|
||||
'QWEN_HOME',
|
||||
ENV_ACP_REPEATED_TOOL_FAILURE_GUARD,
|
||||
'QWEN_CODE_PENDING_COMPILE_CACHE',
|
||||
@@ -145,6 +148,105 @@ afterEach(() => {
|
||||
tmpDirs = [];
|
||||
});
|
||||
|
||||
describe('update download source environment', () => {
|
||||
const updateSourceKeys = [
|
||||
'QWEN_UPDATE_BASE_URL',
|
||||
'qwen_update_base_url',
|
||||
'Qwen_Update_Base_Url',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetEnvironmentTrackingForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetEnvironmentTrackingForTesting();
|
||||
});
|
||||
|
||||
it.each(['.env', '.qwen/.env', 'settings.env'])(
|
||||
'rejects update sources from %s on load, reload, and runtime snapshots',
|
||||
(source) => {
|
||||
const workspace = makeWorkspace();
|
||||
const values = Object.fromEntries(
|
||||
updateSourceKeys.map((key) => [key, 'https://project.example.com']),
|
||||
);
|
||||
const settings = testSettings({ advanced: { excludedEnvVars: [] } });
|
||||
if (source === 'settings.env') {
|
||||
settings.env = { ...values, RUNTIME_SETTINGS_ONLY: 'allowed' };
|
||||
} else {
|
||||
const envPath = path.join(workspace, source);
|
||||
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
[
|
||||
...Object.entries(values).map(([key, value]) => `${key}=${value}`),
|
||||
'RUNTIME_DOTENV=allowed',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
const allowedKey =
|
||||
source === 'settings.env' ? 'RUNTIME_SETTINGS_ONLY' : 'RUNTIME_DOTENV';
|
||||
|
||||
loadEnvironment(settings, workspace);
|
||||
for (const key of updateSourceKeys) {
|
||||
expect(process.env[key]).toBeUndefined();
|
||||
}
|
||||
expect(process.env[allowedKey]).toBe('allowed');
|
||||
|
||||
reloadEnvironment(settings, workspace);
|
||||
for (const key of updateSourceKeys) {
|
||||
expect(process.env[key]).toBeUndefined();
|
||||
}
|
||||
expect(process.env[allowedKey]).toBe('allowed');
|
||||
|
||||
const snapshot = buildRuntimeEnvironment(settings, workspace, {});
|
||||
for (const key of updateSourceKeys) {
|
||||
expect(snapshot.effectiveEnv[key]).toBeUndefined();
|
||||
}
|
||||
expect(snapshot.effectiveEnv[allowedKey]).toBe('allowed');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['shell', '.env', '.qwen/.env'])(
|
||||
'preserves the update source from %s against project configuration',
|
||||
(source) => {
|
||||
const workspace = makeWorkspace();
|
||||
const trustedUrl = 'https://downloads.example.com/releases';
|
||||
const homeEnvPath = path.join(os.homedir(), source);
|
||||
if (source === 'shell') {
|
||||
process.env['QWEN_UPDATE_BASE_URL'] = trustedUrl;
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(homeEnvPath), { recursive: true });
|
||||
fs.writeFileSync(homeEnvPath, `QWEN_UPDATE_BASE_URL=${trustedUrl}\n`);
|
||||
}
|
||||
fs.mkdirSync(path.join(workspace, '.qwen'));
|
||||
fs.writeFileSync(
|
||||
path.join(workspace, '.qwen', '.env'),
|
||||
'QWEN_UPDATE_BASE_URL=https://project.example.com\n',
|
||||
);
|
||||
const settings = testSettings({
|
||||
env: { QWEN_UPDATE_BASE_URL: 'https://settings.example.com' },
|
||||
});
|
||||
|
||||
loadEnvironment(settings, workspace);
|
||||
expect(process.env['QWEN_UPDATE_BASE_URL']).toBe(trustedUrl);
|
||||
|
||||
if (source !== 'shell') {
|
||||
fs.writeFileSync(
|
||||
homeEnvPath,
|
||||
'QWEN_UPDATE_BASE_URL=https://changed.example.com\n',
|
||||
);
|
||||
}
|
||||
reloadEnvironment(settings, workspace);
|
||||
expect(process.env['QWEN_UPDATE_BASE_URL']).toBe(trustedUrl);
|
||||
const snapshot = buildRuntimeEnvironment(settings, workspace, {
|
||||
QWEN_UPDATE_BASE_URL: trustedUrl,
|
||||
});
|
||||
expect(snapshot.effectiveEnv['QWEN_UPDATE_BASE_URL']).toBe(trustedUrl);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildRuntimeEnvironment', () => {
|
||||
it('computes a runtime overlay without mutating process.env or base env', () => {
|
||||
const workspace = makeWorkspace();
|
||||
|
||||
@@ -28,6 +28,8 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [
|
||||
'QWEN_RUNTIME_DIR',
|
||||
'QWEN_CODE_MCP_APPROVALS_PATH',
|
||||
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
|
||||
// Downloaded updates execute as the user; a project must not select them.
|
||||
'QWEN_UPDATE_BASE_URL',
|
||||
// This points to a host temp file that carries build warnings. A project
|
||||
// `.env` must not redirect it to an arbitrary file to read or delete.
|
||||
'QWEN_CODE_WARNINGS_FILE',
|
||||
|
||||
@@ -1757,6 +1757,60 @@ describe('serve fast path environment bootstrap', () => {
|
||||
expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted');
|
||||
});
|
||||
|
||||
it.each(['.env', '.qwen/.env', 'settings.env'])(
|
||||
'keeps update download sources user-owned when loading %s',
|
||||
(source) => {
|
||||
const qwenHome = useTempQwenHome();
|
||||
tempWorkspace = realpathSync(
|
||||
mkdtempSync(join(os.tmpdir(), 'qws-fast-path-update-source-')),
|
||||
);
|
||||
const keys = [
|
||||
'QWEN_UPDATE_BASE_URL',
|
||||
'qwen_update_base_url',
|
||||
'Qwen_Update_Base_Url',
|
||||
];
|
||||
for (const key of keys) vi.stubEnv(key, undefined);
|
||||
const values = Object.fromEntries(
|
||||
keys.map((key) => [key, 'https://project.example.com']),
|
||||
);
|
||||
const settings: ServeFastPathSettings = {
|
||||
advanced: { excludedEnvVars: [] },
|
||||
};
|
||||
if (source === 'settings.env') {
|
||||
settings.env = values;
|
||||
} else {
|
||||
const envPath = join(tempWorkspace, source);
|
||||
mkdirSync(dirname(envPath), { recursive: true });
|
||||
writeFileSync(
|
||||
envPath,
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
try {
|
||||
loadServeFastPathEnvironment(settings, tempWorkspace);
|
||||
for (const key of keys) expect(process.env[key]).toBeUndefined();
|
||||
|
||||
const trustedUrl = 'https://downloads.example.com/releases';
|
||||
writeFileSync(
|
||||
join(qwenHome, '.env'),
|
||||
`QWEN_UPDATE_BASE_URL=${trustedUrl}\n`,
|
||||
);
|
||||
loadServeFastPathEnvironment(settings, tempWorkspace);
|
||||
expect(process.env['QWEN_UPDATE_BASE_URL']).toBe(trustedUrl);
|
||||
|
||||
process.env['QWEN_UPDATE_BASE_URL'] = 'https://shell.example.com';
|
||||
loadServeFastPathEnvironment(settings, tempWorkspace);
|
||||
expect(process.env['QWEN_UPDATE_BASE_URL']).toBe(
|
||||
'https://shell.example.com',
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Regression for #8653: the fast path runs before runQwenServeImpl freezes
|
||||
// daemonRuntimeBaseEnv, so any loader key it applies is baked into the base
|
||||
// env distributed to every workspace's session subprocesses — the exact
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as tar from 'tar';
|
||||
import {
|
||||
acquireLock,
|
||||
rollbackStandaloneUpdate,
|
||||
@@ -20,15 +22,24 @@ import {
|
||||
isSafeTarLinkTarget,
|
||||
} from './standalone-update.js';
|
||||
|
||||
const mockFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../utils/load-undici.js', () => ({
|
||||
loadUndici: async () => ({ fetch: mockFetch }),
|
||||
}));
|
||||
|
||||
describe('standalone-update', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-'));
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', undefined);
|
||||
vi.stubEnv('QWEN_REQUIRE_SIGNATURE', undefined);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('rollbackStandaloneUpdate', () => {
|
||||
@@ -373,6 +384,194 @@ describe('standalone-update', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('download sources', () => {
|
||||
const baseUrl = 'https://downloads.example.com/qwen-code';
|
||||
const filename = 'qwen-code-linux-x64.tar.gz';
|
||||
let standaloneDir: string;
|
||||
let originalManifest: string;
|
||||
|
||||
beforeEach(() => {
|
||||
standaloneDir = path.join(tempDir, 'installed');
|
||||
originalManifest = JSON.stringify({
|
||||
target: 'linux-x64',
|
||||
version: '0.1.0',
|
||||
});
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
originalManifest,
|
||||
);
|
||||
});
|
||||
|
||||
function expectInstallationPreserved() {
|
||||
expect(
|
||||
fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf8'),
|
||||
).toBe(originalManifest);
|
||||
expect(fs.existsSync(`${standaloneDir}.old`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(tempDir, '.qwen-update.lock'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
fs
|
||||
.readdirSync(tempDir)
|
||||
.some((entry) => entry.startsWith('.qwen-code-update-')),
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
async function serveArchive(options: { badChecksum?: boolean } = {}) {
|
||||
const fixture = path.join(tempDir, 'fixture');
|
||||
fs.mkdirSync(path.join(fixture, 'qwen-code'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'qwen-code', 'manifest.json'), '{}');
|
||||
const archivePath = path.join(tempDir, 'release.tar.gz');
|
||||
await tar.c({ gzip: true, cwd: fixture, file: archivePath }, [
|
||||
'qwen-code',
|
||||
]);
|
||||
const archive = fs.readFileSync(archivePath);
|
||||
const checksum = options.badChecksum
|
||||
? '0'.repeat(64)
|
||||
: createHash('sha256').update(archive).digest('hex');
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith(`/${filename}`)) {
|
||||
return new Response(new Uint8Array(archive));
|
||||
}
|
||||
if (url.endsWith('/SHA256SUMS')) {
|
||||
return new Response(`${checksum} ${filename}\n`);
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
it.each([baseUrl, `${baseUrl}/`, ` ${baseUrl}/// `])(
|
||||
'downloads and verifies every resource from the configured root %s',
|
||||
async (configured) => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', configured);
|
||||
await serveArchive();
|
||||
|
||||
// The verified archive deliberately has no runtime, so it cannot replace
|
||||
// the fixture installation even if the download and checksum succeed.
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.2.3'),
|
||||
).rejects.toThrow('Smoke test failed: node binary not found');
|
||||
|
||||
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
||||
`${baseUrl}/v1.2.3/${filename}`,
|
||||
`${baseUrl}/v1.2.3/SHA256SUMS`,
|
||||
`${baseUrl}/v1.2.3/SHA256SUMS.sig`,
|
||||
]);
|
||||
expectInstallationPreserved();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps one configured source for the whole update', async () => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', baseUrl);
|
||||
await serveArchive();
|
||||
const fetchResource = mockFetch.getMockImplementation()!;
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
vi.stubEnv(
|
||||
'QWEN_UPDATE_BASE_URL',
|
||||
'https://other.example.com/releases',
|
||||
);
|
||||
return fetchResource(url);
|
||||
});
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, 'v1.2.3'),
|
||||
).rejects.toThrow('Smoke test failed: node binary not found');
|
||||
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
||||
`${baseUrl}/v1.2.3/${filename}`,
|
||||
`${baseUrl}/v1.2.3/SHA256SUMS`,
|
||||
`${baseUrl}/v1.2.3/SHA256SUMS.sig`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, '', ' '])(
|
||||
'preserves default fallback when unset (%s)',
|
||||
async (value) => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', value);
|
||||
mockFetch.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.2.3'),
|
||||
).rejects.toThrow('OSS (HTTP 503');
|
||||
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
||||
`https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v1.2.3/${filename}`,
|
||||
`https://github.com/QwenLM/qwen-code/releases/download/v1.2.3/${filename}`,
|
||||
]);
|
||||
expectInstallationPreserved();
|
||||
},
|
||||
);
|
||||
|
||||
it('reports a custom-source failure without trying default sources', async () => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', baseUrl);
|
||||
mockFetch.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.2.3'),
|
||||
).rejects.toThrow(
|
||||
`Failed to download ${filename} from QWEN_UPDATE_BASE_URL: HTTP 503`,
|
||||
);
|
||||
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
||||
`${baseUrl}/v1.2.3/${filename}`,
|
||||
]);
|
||||
expectInstallationPreserved();
|
||||
});
|
||||
|
||||
it('preserves the installation when the custom source serves a bad checksum', async () => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', baseUrl);
|
||||
await serveArchive({ badChecksum: true });
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.2.3'),
|
||||
).rejects.toThrow('Checksum mismatch');
|
||||
expectInstallationPreserved();
|
||||
});
|
||||
|
||||
it('still requires a signature when explicitly enabled', async () => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', baseUrl);
|
||||
vi.stubEnv('QWEN_REQUIRE_SIGNATURE', '1');
|
||||
await serveArchive();
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.2.3'),
|
||||
).rejects.toThrow(
|
||||
'SHA256SUMS.sig not found and QWEN_REQUIRE_SIGNATURE=1 is set',
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expectInstallationPreserved();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'not-a-url',
|
||||
'/releases',
|
||||
'http://downloads.example.com/releases',
|
||||
'file:///releases',
|
||||
'https:downloads.example.com/releases',
|
||||
'https://example:[email protected]/releases',
|
||||
`${baseUrl}?channel=latest`,
|
||||
`${baseUrl}?`,
|
||||
`${baseUrl}#fragment`,
|
||||
`${baseUrl}#`,
|
||||
'https://downloads.example.com/rele\nases',
|
||||
])(
|
||||
'rejects invalid configuration before filesystem or network changes: %s',
|
||||
async (value) => {
|
||||
vi.stubEnv('QWEN_UPDATE_BASE_URL', value);
|
||||
const parent = path.join(tempDir, 'not-created');
|
||||
await expect(
|
||||
performStandaloneUpdate(path.join(parent, 'qwen-code'), '1.2.3'),
|
||||
).rejects.toThrow(
|
||||
'QWEN_UPDATE_BASE_URL must be an absolute HTTPS URL without credentials, query parameters, or a fragment.',
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(parent)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isSafeTarEntryPath', () => {
|
||||
it('allows double dots inside a filename segment', () => {
|
||||
expect(isSafeTarEntryPath('qwen-code/release..notes.md')).toBe(true);
|
||||
|
||||
@@ -63,6 +63,31 @@ function escapePS(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function resolveUpdateBaseUrl(): string | undefined {
|
||||
const value = process.env['QWEN_UPDATE_BASE_URL']?.trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
const invalidUrl = () =>
|
||||
new Error(
|
||||
'QWEN_UPDATE_BASE_URL must be an absolute HTTPS URL without credentials, query parameters, or a fragment.',
|
||||
);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw invalidUrl();
|
||||
}
|
||||
if (
|
||||
!/^https:\/\//i.test(value) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
/[?#\s]/.test(value)
|
||||
) {
|
||||
throw invalidUrl();
|
||||
}
|
||||
return url.href.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function tryFetch(
|
||||
url: string,
|
||||
timeoutMs = FETCH_TIMEOUT_MS,
|
||||
@@ -91,7 +116,19 @@ async function downloadWithFallback(
|
||||
versionPath: string,
|
||||
filename: string,
|
||||
timeoutMs = FETCH_TIMEOUT_MS,
|
||||
baseUrl?: string,
|
||||
): Promise<UndiciResponse> {
|
||||
if (baseUrl) {
|
||||
const result = await tryFetch(
|
||||
`${baseUrl}/${versionPath}/${filename}`,
|
||||
timeoutMs,
|
||||
);
|
||||
if (result.response) return result.response;
|
||||
throw new Error(
|
||||
`Failed to download ${filename} from QWEN_UPDATE_BASE_URL: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ossUrl = `${OSS_BASE}/${versionPath}/${filename}`;
|
||||
const ossResult = await tryFetch(ossUrl, timeoutMs);
|
||||
if (ossResult.response) return ossResult.response;
|
||||
@@ -109,8 +146,14 @@ async function verifyChecksum(
|
||||
actualHash: string,
|
||||
filename: string,
|
||||
versionPath: string,
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
const response = await downloadWithFallback(versionPath, 'SHA256SUMS');
|
||||
const response = await downloadWithFallback(
|
||||
versionPath,
|
||||
'SHA256SUMS',
|
||||
FETCH_TIMEOUT_MS,
|
||||
baseUrl,
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
// Ed25519 signature verification of SHA256SUMS.
|
||||
@@ -120,7 +163,12 @@ async function verifyChecksum(
|
||||
const requireSig = process.env['QWEN_REQUIRE_SIGNATURE'] === '1';
|
||||
let sigResponse: UndiciResponse | undefined;
|
||||
try {
|
||||
sigResponse = await downloadWithFallback(versionPath, 'SHA256SUMS.sig');
|
||||
sigResponse = await downloadWithFallback(
|
||||
versionPath,
|
||||
'SHA256SUMS.sig',
|
||||
FETCH_TIMEOUT_MS,
|
||||
baseUrl,
|
||||
);
|
||||
} catch (err) {
|
||||
debugLogger.debug('SHA256SUMS.sig not available:', err);
|
||||
}
|
||||
@@ -164,11 +212,13 @@ async function downloadToFile(
|
||||
versionPath: string,
|
||||
filename: string,
|
||||
destPath: string,
|
||||
baseUrl?: string,
|
||||
): Promise<string> {
|
||||
const response = await downloadWithFallback(
|
||||
versionPath,
|
||||
filename,
|
||||
ARCHIVE_TIMEOUT_MS,
|
||||
baseUrl,
|
||||
);
|
||||
const body = response.body;
|
||||
if (!body) throw new Error('Empty response body');
|
||||
@@ -950,6 +1000,7 @@ export async function performStandaloneUpdate(
|
||||
newVersion: string,
|
||||
): Promise<'done' | 'deferred'> {
|
||||
const versionPath = normalizeVersion(newVersion);
|
||||
const baseUrl = resolveUpdateBaseUrl();
|
||||
|
||||
let target: string;
|
||||
let isFirstTimeMigration = false;
|
||||
@@ -1022,10 +1073,11 @@ export async function performStandaloneUpdate(
|
||||
versionPath,
|
||||
filename,
|
||||
archivePath,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
debugLogger.info('Verifying checksum...');
|
||||
await verifyChecksum(archiveHash, filename, versionPath);
|
||||
await verifyChecksum(archiveHash, filename, versionPath, baseUrl);
|
||||
|
||||
debugLogger.info('Extracting archive...');
|
||||
await extractArchive(archivePath, extractDir, target);
|
||||
|
||||
Reference in new issue
Block a user