mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-17 12:21:10 +00:00
* feat(installer): add standalone archive installation * fix(installer): harden standalone archive installs * fix(installer): address standalone review findings * chore(installer): clarify review followups * fix(installer): stabilize standalone script checks * chore(installer): remove internal planning docs * chore(installer): simplify standalone release review fixes * test(installer): add Windows batch install smoke * test(installer): fix Windows batch smoke quoting * test(installer): preserve Windows cmd quotes * fix(installer): use robust Windows checksum hashing * ci: narrow installer debug matrix * fix(installer): address standalone review hardening * fix(installer): avoid Windows validation parse errors * fix(installer): simplify Windows option validation * fix(installer): harden standalone review fixes
1309 lines
42 KiB
JavaScript
1309 lines
42 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const {
|
|
appendFileSync,
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} = await vi.importActual('node:fs');
|
|
const { execFileSync } = await vi.importActual('node:child_process');
|
|
const crypto = await vi.importActual('node:crypto');
|
|
const { tmpdir } = await vi.importActual('node:os');
|
|
const path = await vi.importActual('node:path');
|
|
const { pathToFileURL } = await vi.importActual('node:url');
|
|
const readScript = (path) => readFileSync(path, 'utf8');
|
|
const standaloneReleaseScriptUrl = pathToFileURL(
|
|
path.resolve('scripts/build-standalone-release.js'),
|
|
).href;
|
|
// These E2E cases execute the Unix shell installer and POSIX symlink behavior.
|
|
// Windows batch behavior has separate Windows-only E2E coverage below.
|
|
const itOnUnix = process.platform === 'win32' ? it.skip : it;
|
|
const itOnWindows = process.platform === 'win32' ? it : it.skip;
|
|
|
|
describe('installation scripts', () => {
|
|
it('keeps the Linux/macOS installer lightweight', () => {
|
|
const script = readScript(
|
|
'scripts/installation/install-qwen-with-source.sh',
|
|
);
|
|
|
|
expect(script).not.toContain('install_nvm');
|
|
expect(script).not.toContain('install_nvm.sh');
|
|
expect(script).not.toContain('nvm install');
|
|
expect(script).not.toContain('NVM_NODEJS_ORG_MIRROR');
|
|
expect(script).not.toContain('npm config set prefix');
|
|
expect(script).not.toContain('clean_npmrc_conflict');
|
|
expect(script).not.toContain('.npmrc');
|
|
expect(script).not.toContain('.npm-global');
|
|
expect(script).not.toMatch(/^\s*exec\s+qwen\s*$/m);
|
|
expect(script).not.toContain('--print-env');
|
|
expect(script).not.toContain('brew install node@20');
|
|
expect(script).toContain('brew install node');
|
|
expect(script).toContain(
|
|
'--source may only contain letters, numbers, dot, underscore, or dash',
|
|
);
|
|
expect(script).toContain('Node.js 20 or newer is required');
|
|
expect(script).toContain(
|
|
'npm install -g @qwen-code/qwen-code@latest --registry',
|
|
);
|
|
expect(script).toContain('You can now run: qwen');
|
|
});
|
|
|
|
it('supports code-server-style standalone install on Linux/macOS', () => {
|
|
const script = readScript(
|
|
'scripts/installation/install-qwen-with-source.sh',
|
|
);
|
|
|
|
expect(script).toContain('--method METHOD');
|
|
expect(script).toContain('--mirror MIRROR');
|
|
expect(script).toContain('--base-url URL');
|
|
expect(script).toContain('--archive PATH');
|
|
expect(script).toContain('install_standalone()');
|
|
expect(script).toContain('install_npm()');
|
|
expect(script).toContain('detect_target()');
|
|
expect(script).toContain('verify_checksum()');
|
|
expect(script).toContain('SHA256SUMS not found; cannot verify archive');
|
|
expect(script).toContain('awk -v archive_name');
|
|
expect(script).not.toContain(
|
|
'grep -E "(^|[[:space:]])[*]?${archive_name}$"',
|
|
);
|
|
expect(script).toContain('validate_archive_contents()');
|
|
expect(script).toContain('Archive contains unsafe path');
|
|
expect(script).toContain('qwen-code-${target}');
|
|
expect(script).toContain('*.tar.xz)');
|
|
expect(script).toContain('METHOD="${METHOD:-detect}"');
|
|
expect(script).toContain('must start with https://');
|
|
expect(script).toContain('Falling back to npm installation');
|
|
expect(script).toContain('standalone_status=$?');
|
|
expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]');
|
|
expect(script).toContain(
|
|
'Standalone install failed. Retry with --method npm',
|
|
);
|
|
expect(script).not.toContain('ln -sf "${INSTALL_LIB_DIR}/bin/qwen"');
|
|
expect(script).toContain('shell_quote()');
|
|
expect(script).toContain('exec ${quoted_qwen_bin} "\\$@"');
|
|
expect(script).toContain('validate_version()');
|
|
expect(script).toContain('validate_install_path');
|
|
expect(script).toContain('validate_https_url "${NPM_REGISTRY}"');
|
|
expect(script).toContain('qwen-code/node/bin/node');
|
|
expect(script).toContain('Archive contains symlinks; refusing to install');
|
|
expect(script).toContain('not a Qwen Code standalone install');
|
|
expect(script).toContain(
|
|
'Return 2 only when a standalone archive is unavailable',
|
|
);
|
|
expect(script).toContain('npm fallback also failed');
|
|
expect(script).toContain(
|
|
'unzip -q "${archive_path}" -d "${destination}" || return 1',
|
|
);
|
|
expect(script).toContain(
|
|
'tar -xzf "${archive_path}" -C "${destination}" || return 1',
|
|
);
|
|
expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"');
|
|
expect(script).toContain('TEMP_DIRS+=');
|
|
expect(script).not.toContain('-print -quit');
|
|
});
|
|
|
|
it('keeps the Windows installer lightweight', () => {
|
|
const script = readScript(
|
|
'scripts/installation/install-qwen-with-source.bat',
|
|
);
|
|
|
|
expect(script).not.toContain('InstallNodeJSDirectly');
|
|
expect(script).not.toContain('node-v!NODE_VERSION!');
|
|
expect(script).not.toContain('msiexec');
|
|
expect(script).not.toContain('Invoke-WebRequest');
|
|
expect(script).not.toContain('PowerShell (Administrator)');
|
|
expect(script).not.toContain('echo INFO: Installation source: %SOURCE%');
|
|
expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m);
|
|
expect(script).toContain(':ValidateSource');
|
|
expect(script).toContain(':PrintUsage');
|
|
expect(script).toContain('findstr /R');
|
|
expect(script).toContain(
|
|
'--source may only contain letters, numbers, dot, underscore, or dash',
|
|
);
|
|
expect(script).toContain('Node.js 20 or newer is required');
|
|
expect(script).toContain('Please install Node.js');
|
|
expect(script).toContain(
|
|
'npm install -g @qwen-code/qwen-code@latest --registry',
|
|
);
|
|
expect(script).toContain('You can now run: qwen');
|
|
});
|
|
|
|
it('supports code-server-style standalone install on Windows', () => {
|
|
const script = readScript(
|
|
'scripts/installation/install-qwen-with-source.bat',
|
|
);
|
|
|
|
expect(script).toContain('--method METHOD');
|
|
expect(script).toContain('--mirror MIRROR');
|
|
expect(script).toContain('--base-url URL');
|
|
expect(script).toContain('--archive PATH');
|
|
expect(script).toContain(':InstallStandalone');
|
|
expect(script).toContain(':InstallNpm');
|
|
expect(script).toContain(':VerifyChecksum');
|
|
expect(script).toContain('SHA256SUMS not found; cannot verify archive');
|
|
expect(script).toContain('Get-FileHash -Algorithm SHA256');
|
|
expect(script).toContain('tokens=1,2');
|
|
expect(script).toContain('CHECKSUM_NAME');
|
|
expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"');
|
|
expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"');
|
|
expect(script).not.toContain('certutil -hashfile');
|
|
expect(script).toContain('qwen-code-win-x64.zip');
|
|
expect(script).toContain('Expand-Archive');
|
|
expect(script).toContain('$env:QWEN_DOWNLOAD_URL');
|
|
expect(script).toContain('$env:QWEN_ARCHIVE_FILE');
|
|
expect(script).toContain(
|
|
'if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!"',
|
|
);
|
|
expect(script).not.toContain('%QWEN_INSTALL_ROOT%');
|
|
expect(script).toContain('set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!"');
|
|
expect(script).toContain(
|
|
'installer options contain unsafe command characters',
|
|
);
|
|
expect(script).toContain('[char[]](10,13,33,34');
|
|
expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"');
|
|
expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"');
|
|
expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"');
|
|
expect(script).toContain(':ValidateVersion');
|
|
expect(script).toContain(
|
|
'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"',
|
|
);
|
|
expect(script).toContain("$ErrorActionPreference = 'Stop'; try");
|
|
expect(script).toContain(
|
|
'[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)',
|
|
);
|
|
expect(script).toContain('must start with https://');
|
|
expect(script).toContain('Falling back to npm installation');
|
|
expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"');
|
|
expect(script).toContain('if !STANDALONE_STATUS! EQU 2');
|
|
expect(script).toContain(
|
|
'Standalone install failed. Retry with --method npm',
|
|
);
|
|
expect(script).toContain('qwen-code\\node\\node.exe');
|
|
expect(script).toContain('Archive contains symlinks or reparse points');
|
|
expect(script).toContain('QWEN_INSTALL_ROOT');
|
|
expect(script).toContain('npm fallback also failed');
|
|
});
|
|
});
|
|
|
|
describe('standalone release packaging', () => {
|
|
it('defines a standalone packaging script', () => {
|
|
const packageJson = JSON.parse(readScript('package.json'));
|
|
|
|
expect(packageJson.scripts['package:standalone']).toBe(
|
|
'node scripts/create-standalone-package.js',
|
|
);
|
|
expect(packageJson.scripts['package:standalone:release']).toBe(
|
|
'node scripts/build-standalone-release.js',
|
|
);
|
|
expect(existsSync('scripts/create-standalone-package.js')).toBe(true);
|
|
expect(existsSync('scripts/build-standalone-release.js')).toBe(true);
|
|
|
|
const packageScript = readScript('scripts/create-standalone-package.js');
|
|
expect(packageScript).toContain('Copyright 2025 Qwen Team');
|
|
expect(packageScript).toContain("'bundled/qc-helper/docs'");
|
|
expect(packageScript).toContain('DIST_ALLOWED_ENTRIES');
|
|
expect(packageScript).toContain('Unexpected dist asset');
|
|
expect(packageScript).toContain('topLevelDistEntryForPath(outDir)');
|
|
expect(packageScript).toContain("path.join(packageRoot, 'package.json')");
|
|
expect(packageScript).toContain('validateNodeRuntime');
|
|
expect(packageScript).toContain('copyNodeRuntimeEntry');
|
|
expect(packageScript).toContain('symlink cycle');
|
|
expect(packageScript).toContain('refusing to write empty SHA256SUMS');
|
|
expect(packageScript).toContain('--skip-checksums');
|
|
expect(packageScript).toContain('dereference: true');
|
|
expect(packageScript).toContain('fs.createReadStream');
|
|
expect(packageScript).toContain('Expand-Archive');
|
|
expect(packageScript).toContain('Compress-Archive');
|
|
|
|
const releaseScript = readScript('scripts/build-standalone-release.js');
|
|
expect(releaseScript).toContain('Copyright 2025 Qwen Team');
|
|
expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}');
|
|
expect(releaseScript).toContain('SHASUMS256.txt');
|
|
expect(releaseScript).toContain('verifyNodeArchive');
|
|
expect(releaseScript).toContain(
|
|
'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length',
|
|
);
|
|
expect(releaseScript).toContain('nodeArchiveExtension');
|
|
expect(releaseScript).toContain('fs.createReadStream');
|
|
expect(releaseScript).toContain('expectedArchiveNames');
|
|
expect(releaseScript).toContain('qwen-code-${qwenTarget}');
|
|
expect(releaseScript).toContain('scripts/create-standalone-package.js');
|
|
expect(releaseScript).toContain('--skip-checksums');
|
|
expect(releaseScript).toContain('writeSha256Sums(outDir)');
|
|
});
|
|
|
|
it('loads the standalone release packaging helper', () => {
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
['scripts/build-standalone-release.js', '--help'],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
|
|
expect(output).toContain('package:standalone:release');
|
|
expect(output).toContain('--node-version VERSION');
|
|
});
|
|
|
|
it('parses Node.js SHASUMS entries', async () => {
|
|
const { parseChecksums } = await import(standaloneReleaseScriptUrl);
|
|
|
|
const checksums = parseChecksums(
|
|
[
|
|
'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz',
|
|
'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe(
|
|
'a'.repeat(64),
|
|
);
|
|
expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64));
|
|
});
|
|
|
|
it('validates standalone release checksum output', async () => {
|
|
const { assertStandaloneOutput, RELEASE_TARGETS } = await import(
|
|
standaloneReleaseScriptUrl
|
|
);
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-'));
|
|
|
|
try {
|
|
const lines = RELEASE_TARGETS.map(({ qwenTarget }) => {
|
|
const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz';
|
|
return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`;
|
|
});
|
|
writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
|
|
|
|
expect(() => assertStandaloneOutput(tmpDir)).not.toThrow();
|
|
|
|
writeFileSync(
|
|
path.join(tmpDir, 'SHA256SUMS'),
|
|
`${lines.join('\n')}\n${'b'.repeat(64)} qwen-code-extra.tar.gz\n`,
|
|
);
|
|
expect(() => assertStandaloneOutput(tmpDir)).toThrow(/Extra/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects a runtime archive without a Node executable', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64';
|
|
const fakeRuntimeArchive =
|
|
process.platform === 'win32'
|
|
? createBadWindowsNodeArchive(tmpDir)
|
|
: createBadUnixNodeArchive(tmpDir);
|
|
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
target,
|
|
'--node-archive',
|
|
fakeRuntimeArchive,
|
|
'--out-dir',
|
|
path.join(tmpDir, 'out'),
|
|
'--version',
|
|
'0.0.0-test',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
),
|
|
).toThrow(/Node\.js runtime for .* must contain/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('packages a win-x64 standalone archive', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
const outDir = path.join(tmpDir, 'out');
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
'win-x64',
|
|
'--node-archive',
|
|
createFakeWindowsNodeArchive(tmpDir),
|
|
'--out-dir',
|
|
outDir,
|
|
'--version',
|
|
'0.0.0-test',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
);
|
|
|
|
const archive = path.join(outDir, 'qwen-code-win-x64.zip');
|
|
const extractDir = path.join(tmpDir, 'extract');
|
|
mkdirSync(extractDir, { recursive: true });
|
|
extractZipForTest(archive, extractDir);
|
|
|
|
expect(existsSync(path.join(extractDir, 'qwen-code'))).toBe(true);
|
|
expect(
|
|
existsSync(path.join(extractDir, 'qwen-code', 'bin', 'qwen.cmd')),
|
|
).toBe(true);
|
|
expect(
|
|
existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')),
|
|
).toBe(true);
|
|
expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain(
|
|
'qwen-code-win-x64.zip',
|
|
);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
}, 30_000);
|
|
|
|
itOnUnix('dereferences safe Node.js runtime symlinks', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir, {
|
|
withSafeNodeSymlink: true,
|
|
});
|
|
const installRoot = path.join(tmpDir, 'install');
|
|
runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home'));
|
|
|
|
const npmShim = path.join(
|
|
installRoot,
|
|
'lib',
|
|
'qwen-code',
|
|
'node',
|
|
'bin',
|
|
'npm',
|
|
);
|
|
expect(existsSync(npmShim)).toBe(true);
|
|
expect(lstatSync(npmShim).isSymbolicLink()).toBe(false);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
'linux-x64',
|
|
'--node-archive',
|
|
createFakeNodeArchive(tmpDir, {
|
|
withEscapingNodeSymlink: true,
|
|
}),
|
|
'--out-dir',
|
|
path.join(tmpDir, 'out'),
|
|
'--version',
|
|
'0.0.0-test',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
),
|
|
).toThrow(/symlink escapes the archive/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('rejects Node.js runtime symlink cycles', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
'linux-x64',
|
|
'--node-archive',
|
|
createFakeNodeArchive(tmpDir, {
|
|
withNodeSymlinkCycle: true,
|
|
}),
|
|
'--out-dir',
|
|
path.join(tmpDir, 'out'),
|
|
'--version',
|
|
'0.0.0-test',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
),
|
|
).toThrow(/symlink cycle/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('rejects unexpected dist assets', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
|
|
|
try {
|
|
writeFileSync('dist/debug-cache.tmp', 'debug\n');
|
|
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
'win-x64',
|
|
'--node-archive',
|
|
createFakeWindowsNodeArchive(tmpDir),
|
|
'--out-dir',
|
|
path.join(tmpDir, 'out'),
|
|
'--version',
|
|
'0.0.0-test',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
),
|
|
).toThrow(/Unexpected dist asset/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
} else {
|
|
rmSync('dist/debug-cache.tmp', { force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('uploads standalone archives during release', () => {
|
|
const workflow = readScript('.github/workflows/release.yml');
|
|
|
|
expect(workflow).toContain('npm run package:standalone:release --');
|
|
expect(workflow).not.toContain('verify_node_checksum()');
|
|
expect(workflow).not.toContain('download_node()');
|
|
expect(workflow).toContain('dist/standalone/qwen-code-*');
|
|
expect(workflow).toContain('dist/standalone/SHA256SUMS');
|
|
});
|
|
|
|
it('does not whitelist internal planning documents in gitignore', () => {
|
|
const gitignore = readScript('.gitignore');
|
|
|
|
expect(gitignore).not.toContain('!.qwen/design/');
|
|
expect(gitignore).not.toContain('!.qwen/e2e-tests/');
|
|
});
|
|
|
|
it('documents optional native module parity for standalone installs', () => {
|
|
const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md');
|
|
|
|
expect(guide).toContain('Optional Native Modules');
|
|
expect(guide).toContain('node-pty');
|
|
expect(guide).toContain('clipboard');
|
|
});
|
|
});
|
|
|
|
describe('Linux/macOS installer end-to-end', () => {
|
|
itOnUnix(
|
|
'installs a local standalone archive with checksum verification',
|
|
() => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
const installRoot = path.join(tmpDir, 'install');
|
|
const home = path.join(tmpDir, 'home');
|
|
runUnixInstaller(archive, installRoot, home);
|
|
|
|
expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true);
|
|
expect(
|
|
existsSync(
|
|
path.join(installRoot, 'lib', 'qwen-code', 'node', 'bin', 'node'),
|
|
),
|
|
).toBe(true);
|
|
expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain(
|
|
'"source": "smoke"',
|
|
);
|
|
|
|
const version = execFileSync(path.join(installRoot, 'bin', 'qwen'), [
|
|
'--version',
|
|
])
|
|
.toString()
|
|
.trim();
|
|
expect(version).toBe('0.0.0-smoke');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
itOnUnix('shell-quotes custom install paths in the generated wrapper', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
const installRoot = path.join(tmpDir, 'install');
|
|
const home = path.join(tmpDir, 'home');
|
|
const installLibDir = path.join(
|
|
installRoot,
|
|
'lib',
|
|
'qwen-code$(touch qwen-pwned)',
|
|
);
|
|
|
|
runUnixInstaller(archive, installRoot, home, 'standalone', {
|
|
QWEN_INSTALL_LIB_DIR: installLibDir,
|
|
});
|
|
|
|
const version = execFileSync(
|
|
path.join(installRoot, 'bin', 'qwen'),
|
|
['--version'],
|
|
{
|
|
cwd: tmpDir,
|
|
},
|
|
)
|
|
.toString()
|
|
.trim();
|
|
expect(version).toBe('0.0.0-smoke');
|
|
expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('rejects a tampered local archive', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
appendFileSync(archive, 'tamper');
|
|
|
|
expect(() =>
|
|
runUnixInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
),
|
|
).toThrow(/Checksum verification failed/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('rejects a local archive when SHA256SUMS is missing', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true });
|
|
|
|
expect(() =>
|
|
runUnixInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
),
|
|
).toThrow(/SHA256SUMS not found/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('rejects standalone archives containing symlinks', () => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = createSymlinkStandaloneArchive(tmpDir);
|
|
|
|
expect(() =>
|
|
runUnixInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
),
|
|
).toThrow(/Archive contains symlinks/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
itOnUnix(
|
|
'rejects standalone archives containing path traversal entries',
|
|
() => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = createTraversalStandaloneArchive(tmpDir);
|
|
|
|
expect(() =>
|
|
runUnixInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
),
|
|
).toThrow(/Archive contains unsafe path/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
itOnUnix('refuses to overwrite a non-managed install directory', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
const installRoot = path.join(tmpDir, 'install');
|
|
const installDir = path.join(installRoot, 'lib', 'qwen-code');
|
|
mkdirSync(installDir, { recursive: true });
|
|
writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n');
|
|
|
|
expect(() =>
|
|
runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')),
|
|
).toThrow(/not a Qwen Code standalone install/);
|
|
expect(readScript(path.join(installDir, 'important.txt'))).toBe(
|
|
'keep me\n',
|
|
);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix('does not fall back to npm when detect finds a bad archive', () => {
|
|
const createdDist = ensureMinimalDist();
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = packageFakeStandalone(tmpDir);
|
|
appendFileSync(archive, 'tamper');
|
|
|
|
let failureMessage = '';
|
|
try {
|
|
runUnixInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
'detect',
|
|
);
|
|
} catch (error) {
|
|
failureMessage = error.message;
|
|
}
|
|
|
|
expect(failureMessage).toContain('Checksum verification failed');
|
|
expect(failureMessage).toContain('Standalone install failed');
|
|
expect(failureMessage).not.toContain('Falling back to npm installation');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
if (createdDist) {
|
|
rmSync('dist', { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
itOnUnix(
|
|
'falls back to npm in detect mode when archive is unavailable',
|
|
() => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const fakeBin = path.join(tmpDir, 'bin');
|
|
const home = path.join(tmpDir, 'home');
|
|
const npmLog = path.join(tmpDir, 'npm-args.txt');
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
|
|
writeFileSync(
|
|
path.join(fakeBin, 'curl'),
|
|
'#!/usr/bin/env sh\nexit 22\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'node'),
|
|
[
|
|
'#!/usr/bin/env sh',
|
|
'if [ "$1" = "-p" ]; then',
|
|
' case "$2" in',
|
|
' *split*) echo 20 ;;',
|
|
' *) echo 20.19.0 ;;',
|
|
' esac',
|
|
' exit 0',
|
|
'fi',
|
|
'exit 0',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'npm'),
|
|
[
|
|
'#!/usr/bin/env sh',
|
|
'case "$1" in',
|
|
' -v) echo 10.0.0 ;;',
|
|
' prefix) echo "$QWEN_FAKE_NPM_PREFIX" ;;',
|
|
' install) printf "%s\\n" "$*" > "$QWEN_FAKE_NPM_LOG" ;;',
|
|
'esac',
|
|
'exit 0',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'qwen'),
|
|
'#!/usr/bin/env sh\necho 0.0.0-npm\n',
|
|
);
|
|
for (const command of ['curl', 'node', 'npm', 'qwen']) {
|
|
chmodSync(path.join(fakeBin, command), 0o755);
|
|
}
|
|
|
|
const output = execFileSync(
|
|
'bash',
|
|
[
|
|
'scripts/installation/install-qwen-with-source.sh',
|
|
'--method',
|
|
'detect',
|
|
'--base-url',
|
|
'https://example.invalid/qwen-code',
|
|
'--source',
|
|
'smoke',
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
PATH: `${fakeBin}:${process.env.PATH}`,
|
|
QWEN_FAKE_NPM_LOG: npmLog,
|
|
QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'),
|
|
},
|
|
stdio: 'pipe',
|
|
},
|
|
).toString();
|
|
|
|
expect(output).toContain('Falling back to npm installation');
|
|
expect(readScript(npmLog)).toContain(
|
|
'install -g @qwen-code/qwen-code@latest --registry',
|
|
);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
itOnUnix('preserves context when npm fallback also fails', () => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const fakeBin = path.join(tmpDir, 'bin');
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n');
|
|
chmodSync(path.join(fakeBin, 'curl'), 0o755);
|
|
|
|
let failureMessage = '';
|
|
try {
|
|
execFileSync(
|
|
'bash',
|
|
[
|
|
'scripts/installation/install-qwen-with-source.sh',
|
|
'--method',
|
|
'detect',
|
|
'--base-url',
|
|
'https://example.invalid/qwen-code',
|
|
'--source',
|
|
'smoke',
|
|
],
|
|
{
|
|
env: {
|
|
HOME: path.join(tmpDir, 'home'),
|
|
PATH: `${fakeBin}:/usr/bin:/bin`,
|
|
},
|
|
stdio: 'pipe',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
failureMessage = [
|
|
error.message,
|
|
error.stdout?.toString() || '',
|
|
error.stderr?.toString() || '',
|
|
].join('\n');
|
|
}
|
|
|
|
expect(failureMessage).toContain('Falling back to npm installation');
|
|
expect(failureMessage).toMatch(
|
|
/Node\.js was not found|Unable to determine Node\.js version/,
|
|
);
|
|
expect(failureMessage).toContain('npm fallback also failed');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Windows installer end-to-end', () => {
|
|
itOnWindows(
|
|
'installs a local standalone archive with checksum verification',
|
|
() => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = createFakeWindowsStandaloneArchive(tmpDir);
|
|
const installRoot = path.join(tmpDir, 'install');
|
|
const home = path.join(tmpDir, 'home');
|
|
runWindowsInstaller(archive, installRoot, home);
|
|
|
|
expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
existsSync(path.join(installRoot, 'qwen-code', 'node', 'node.exe')),
|
|
).toBe(true);
|
|
expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain(
|
|
'"source": "smoke"',
|
|
);
|
|
|
|
const version = runWindowsCommand(
|
|
`call "${path.join(installRoot, 'bin', 'qwen.cmd')}" --version`,
|
|
{ USERPROFILE: home },
|
|
)
|
|
.toString()
|
|
.trim();
|
|
expect(version).toBe('0.0.0-smoke');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
itOnWindows('rejects a tampered local archive', () => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = createFakeWindowsStandaloneArchive(tmpDir);
|
|
appendFileSync(archive, 'tamper');
|
|
|
|
expect(() =>
|
|
runWindowsInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
),
|
|
).toThrow(/Checksum verification failed/);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
itOnWindows('rejects unsafe environment-derived install paths', () => {
|
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
|
|
|
|
try {
|
|
const archive = createFakeWindowsStandaloneArchive(tmpDir);
|
|
const marker = path.join(tmpDir, 'pwned.txt');
|
|
|
|
expect(() =>
|
|
runWindowsInstaller(
|
|
archive,
|
|
path.join(tmpDir, 'install'),
|
|
path.join(tmpDir, 'home'),
|
|
'standalone',
|
|
{
|
|
QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`,
|
|
},
|
|
),
|
|
).toThrow(/unsafe command characters/);
|
|
expect(existsSync(marker)).toBe(false);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
function ensureMinimalDist() {
|
|
if (existsSync('dist')) {
|
|
return false;
|
|
}
|
|
|
|
mkdirSync('dist/vendor', { recursive: true });
|
|
mkdirSync('dist/bundled/qc-helper/docs', { recursive: true });
|
|
writeFileSync('dist/cli.js', 'console.log("qwen");\n');
|
|
writeFileSync(
|
|
'dist/package.json',
|
|
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function createFakeNodeArchive(tmpDir, options = {}) {
|
|
const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64');
|
|
mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true });
|
|
writeFileSync(
|
|
path.join(fakeNodeDir, 'bin', 'node'),
|
|
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
|
|
);
|
|
chmodSync(path.join(fakeNodeDir, 'bin', 'node'), 0o755);
|
|
|
|
if (options.withSafeNodeSymlink) {
|
|
mkdirSync(path.join(fakeNodeDir, 'lib'), { recursive: true });
|
|
writeFileSync(path.join(fakeNodeDir, 'lib', 'npm-cli.js'), 'npm cli\n');
|
|
symlinkSync('../lib/npm-cli.js', path.join(fakeNodeDir, 'bin', 'npm'));
|
|
}
|
|
|
|
if (options.withEscapingNodeSymlink) {
|
|
const outsideTarget = path.join(tmpDir, 'outside-node-helper.js');
|
|
writeFileSync(outsideTarget, 'outside\n');
|
|
symlinkSync(outsideTarget, path.join(fakeNodeDir, 'bin', 'npm'));
|
|
}
|
|
|
|
if (options.withNodeSymlinkCycle) {
|
|
symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle'));
|
|
}
|
|
|
|
const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz');
|
|
execFileSync(
|
|
'tar',
|
|
['-czf', archive, '-C', tmpDir, path.basename(fakeNodeDir)],
|
|
{
|
|
env: { ...process.env, LC_ALL: 'C' },
|
|
stdio: 'ignore',
|
|
},
|
|
);
|
|
return archive;
|
|
}
|
|
|
|
function createBadUnixNodeArchive(tmpDir) {
|
|
const fakeRuntimeDir = path.join(tmpDir, 'not-node');
|
|
mkdirSync(fakeRuntimeDir, { recursive: true });
|
|
writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n');
|
|
|
|
const archive = path.join(tmpDir, 'bad-runtime.tar.gz');
|
|
execFileSync('tar', ['-czf', archive, '-C', tmpDir, 'not-node'], {
|
|
env: { ...process.env, LC_ALL: 'C' },
|
|
stdio: 'ignore',
|
|
});
|
|
return archive;
|
|
}
|
|
|
|
function createBadWindowsNodeArchive(tmpDir) {
|
|
const fakeRuntimeDir = path.join(tmpDir, 'not-node');
|
|
mkdirSync(fakeRuntimeDir, { recursive: true });
|
|
writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n');
|
|
|
|
const archive = path.join(tmpDir, 'bad-runtime.zip');
|
|
createZipForTest(archive, tmpDir, path.basename(fakeRuntimeDir));
|
|
return archive;
|
|
}
|
|
|
|
function createFakeWindowsNodeArchive(tmpDir) {
|
|
const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64');
|
|
mkdirSync(fakeNodeDir, { recursive: true });
|
|
writeFileSync(path.join(fakeNodeDir, 'node.exe'), 'fake node.exe\n');
|
|
|
|
const archive = path.join(tmpDir, 'node-v20.0.0-win-x64.zip');
|
|
createZipForTest(archive, tmpDir, path.basename(fakeNodeDir));
|
|
return archive;
|
|
}
|
|
|
|
function createFakeWindowsStandaloneArchive(tmpDir) {
|
|
const packageRoot = path.join(tmpDir, 'qwen-code');
|
|
const outDir = path.join(tmpDir, 'out');
|
|
mkdirSync(path.join(packageRoot, 'bin'), { recursive: true });
|
|
mkdirSync(path.join(packageRoot, 'node'), { recursive: true });
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
path.join(packageRoot, 'bin', 'qwen.cmd'),
|
|
['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'),
|
|
);
|
|
writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n');
|
|
writeFileSync(
|
|
path.join(packageRoot, 'manifest.json'),
|
|
JSON.stringify({ name: '@qwen-code/qwen-code' }),
|
|
);
|
|
|
|
const archive = path.join(outDir, 'qwen-code-win-x64.zip');
|
|
createZipForTest(archive, tmpDir, path.basename(packageRoot));
|
|
writeChecksumFile(outDir, path.basename(archive));
|
|
return archive;
|
|
}
|
|
|
|
function createZipForTest(archive, cwd, entry) {
|
|
if (process.platform === 'win32') {
|
|
execFileSync(
|
|
'powershell',
|
|
[
|
|
'-NoProfile',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force',
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
QWEN_TEST_ZIP_ENTRY: path.join(cwd, entry),
|
|
QWEN_TEST_ZIP_ARCHIVE: archive,
|
|
},
|
|
stdio: 'ignore',
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
execFileSync('zip', ['-qr', archive, entry], {
|
|
cwd,
|
|
stdio: 'ignore',
|
|
});
|
|
}
|
|
|
|
function extractZipForTest(archive, destination) {
|
|
if (process.platform === 'win32') {
|
|
execFileSync(
|
|
'powershell',
|
|
[
|
|
'-NoProfile',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
'Expand-Archive -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -DestinationPath $env:QWEN_TEST_ZIP_DESTINATION -Force',
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
QWEN_TEST_ZIP_ARCHIVE: archive,
|
|
QWEN_TEST_ZIP_DESTINATION: destination,
|
|
},
|
|
stdio: 'ignore',
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
execFileSync('unzip', ['-q', archive, '-d', destination], {
|
|
stdio: 'ignore',
|
|
});
|
|
}
|
|
|
|
function packageFakeStandalone(tmpDir, nodeArchiveOptions = {}) {
|
|
const outDir = path.join(tmpDir, 'out');
|
|
mkdirSync(outDir, { recursive: true });
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
'scripts/create-standalone-package.js',
|
|
'--target',
|
|
'linux-x64',
|
|
'--node-archive',
|
|
createFakeNodeArchive(tmpDir, nodeArchiveOptions),
|
|
'--out-dir',
|
|
outDir,
|
|
'--version',
|
|
'0.0.0-smoke',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
);
|
|
return path.join(outDir, 'qwen-code-linux-x64.tar.gz');
|
|
}
|
|
|
|
function runUnixInstaller(
|
|
archive,
|
|
installRoot,
|
|
home,
|
|
method = 'standalone',
|
|
extraEnv = {},
|
|
) {
|
|
mkdirSync(home, { recursive: true });
|
|
try {
|
|
return execFileSync(
|
|
'bash',
|
|
[
|
|
'scripts/installation/install-qwen-with-source.sh',
|
|
'--method',
|
|
method,
|
|
'--archive',
|
|
archive,
|
|
'--source',
|
|
'smoke',
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
QWEN_INSTALL_ROOT: installRoot,
|
|
...extraEnv,
|
|
},
|
|
stdio: 'pipe',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const processError = error;
|
|
throw new Error(
|
|
[
|
|
processError.message,
|
|
processError.stdout?.toString() || '',
|
|
processError.stderr?.toString() || '',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
}
|
|
|
|
function runWindowsInstaller(
|
|
archive,
|
|
installRoot,
|
|
home,
|
|
method = 'standalone',
|
|
extraEnv = {},
|
|
) {
|
|
mkdirSync(home, { recursive: true });
|
|
try {
|
|
return runWindowsCommand(
|
|
[
|
|
`call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`,
|
|
'--method',
|
|
method,
|
|
'--archive',
|
|
`"${archive}"`,
|
|
'--source',
|
|
'smoke',
|
|
].join(' '),
|
|
{
|
|
USERPROFILE: home,
|
|
QWEN_INSTALL_ROOT: installRoot,
|
|
...extraEnv,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const processError = error;
|
|
throw new Error(
|
|
[
|
|
processError.message,
|
|
processError.stdout?.toString() || '',
|
|
processError.stderr?.toString() || '',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
}
|
|
|
|
function runWindowsCommand(command, env = {}) {
|
|
return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], {
|
|
env: {
|
|
...process.env,
|
|
...env,
|
|
},
|
|
stdio: 'pipe',
|
|
// cmd.exe parses the command string itself; preserve quoted paths.
|
|
windowsVerbatimArguments: true,
|
|
});
|
|
}
|
|
|
|
function createSymlinkStandaloneArchive(tmpDir) {
|
|
const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code');
|
|
mkdirSync(path.join(packageRoot, 'bin'), { recursive: true });
|
|
mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true });
|
|
symlinkSync('/usr/bin/env', path.join(packageRoot, 'bin', 'qwen'));
|
|
writeFileSync(
|
|
path.join(packageRoot, 'node', 'bin', 'node'),
|
|
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
|
|
);
|
|
chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755);
|
|
writeFileSync(
|
|
path.join(packageRoot, 'manifest.json'),
|
|
JSON.stringify({ name: '@qwen-code/qwen-code' }),
|
|
);
|
|
|
|
const outDir = path.join(tmpDir, 'out');
|
|
mkdirSync(outDir, { recursive: true });
|
|
const archive = path.join(outDir, 'qwen-code-linux-x64.tar.gz');
|
|
execFileSync(
|
|
'tar',
|
|
['-czf', archive, '-C', path.dirname(packageRoot), 'qwen-code'],
|
|
{
|
|
env: { ...process.env, LC_ALL: 'C' },
|
|
stdio: 'ignore',
|
|
},
|
|
);
|
|
writeChecksumFile(outDir, path.basename(archive));
|
|
return archive;
|
|
}
|
|
|
|
function createTraversalStandaloneArchive(tmpDir) {
|
|
const maliciousRoot = path.join(tmpDir, 'malicious');
|
|
const packageRoot = path.join(maliciousRoot, 'qwen-code');
|
|
mkdirSync(path.join(packageRoot, 'bin'), { recursive: true });
|
|
mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true });
|
|
writeFileSync(
|
|
path.join(packageRoot, 'bin', 'qwen'),
|
|
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
|
|
);
|
|
chmodSync(path.join(packageRoot, 'bin', 'qwen'), 0o755);
|
|
writeFileSync(
|
|
path.join(packageRoot, 'node', 'bin', 'node'),
|
|
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
|
|
);
|
|
chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755);
|
|
writeFileSync(
|
|
path.join(packageRoot, 'manifest.json'),
|
|
JSON.stringify({ name: '@qwen-code/qwen-code' }),
|
|
);
|
|
writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n');
|
|
|
|
const outDir = path.join(tmpDir, 'out');
|
|
mkdirSync(outDir, { recursive: true });
|
|
const archive = path.join(outDir, 'qwen-code-linux-x64.zip');
|
|
execFileSync('zip', ['-qr', archive, 'qwen-code', '../qwen-slip'], {
|
|
cwd: maliciousRoot,
|
|
stdio: 'ignore',
|
|
});
|
|
writeChecksumFile(outDir, path.basename(archive));
|
|
return archive;
|
|
}
|
|
|
|
function writeChecksumFile(outDir, archiveName) {
|
|
const archive = path.join(outDir, archiveName);
|
|
const hash = crypto
|
|
.createHash('sha256')
|
|
.update(readFileSync(archive))
|
|
.digest('hex');
|
|
writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`);
|
|
}
|