mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-01 12:34:55 +00:00
* fix(vscode): keep sign-in reachable from the no-models screen * fix(vscode): stop flagging bundled this.require(...) calls as VSIX runtime dependencies
490 lines
17 KiB
JavaScript
490 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
import { builtinModules } from 'node:module';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { isDeepStrictEqual } from 'node:util';
|
|
import { parse } from 'acorn';
|
|
|
|
import { extractZip } from './zip.mjs';
|
|
import {
|
|
defaultVsixOutputDir,
|
|
extensionRoot,
|
|
isMainModule,
|
|
normalizeVsixTargets,
|
|
vsixFileName,
|
|
} from './vsix-targets.mjs';
|
|
|
|
const REQUIRED_WEBVIEW_FILES = [
|
|
'dist/webview.js',
|
|
'dist/kimi-banner-dark.svg',
|
|
'dist/kimi-banner-light.svg',
|
|
'dist/kimi-logo.png',
|
|
];
|
|
const FORBIDDEN_PATH_SEGMENTS = new Set([
|
|
'.kimi',
|
|
'.kimi-code',
|
|
'.vscode',
|
|
'__tests__',
|
|
'cache',
|
|
'caches',
|
|
'credentials',
|
|
'logs',
|
|
'node_modules',
|
|
'profile',
|
|
'profiles',
|
|
'runtime',
|
|
'scripts',
|
|
'session',
|
|
'sessions',
|
|
'src',
|
|
'state',
|
|
'states',
|
|
'test',
|
|
'tests',
|
|
'tokens',
|
|
'webview-ui',
|
|
]);
|
|
const FORBIDDEN_EXTENSIONS = new Set(['.jsonl', '.log', '.map', '.py', '.pyc', '.ts', '.tsx']);
|
|
const TEXT_EXTENSIONS = new Set([
|
|
'.cjs',
|
|
'.css',
|
|
'.html',
|
|
'.js',
|
|
'.json',
|
|
'.md',
|
|
'.mjs',
|
|
'.svg',
|
|
'.txt',
|
|
'.xml',
|
|
]);
|
|
const BUILTIN_IMPORTS = new Set(
|
|
builtinModules.flatMap((name) => [name, name.startsWith('node:') ? name.slice(5) : `node:${name}`]),
|
|
);
|
|
// `ws` probes these native accelerators inside try/catch and immediately uses
|
|
// its bundled JavaScript fallback when they are absent. They are not required
|
|
// runtime dependencies and must not be shipped as cross-platform native code.
|
|
const OPTIONAL_FALLBACK_IMPORTS = new Set(['bufferutil', 'canvas', 'utf-8-validate']);
|
|
const MANIFEST_FIELDS = [
|
|
'name',
|
|
'publisher',
|
|
'displayName',
|
|
'version',
|
|
'engines',
|
|
'extensionKind',
|
|
'capabilities',
|
|
'activationEvents',
|
|
'main',
|
|
'icon',
|
|
];
|
|
const CONTRIBUTE_FIELDS = [
|
|
'commands',
|
|
'configuration',
|
|
'keybindings',
|
|
'menus',
|
|
'views',
|
|
'viewsContainers',
|
|
];
|
|
|
|
export async function verifyVsix(vsixPath, target, options = {}) {
|
|
const extractionRoot = await mkdtemp(join(tmpdir(), 'kimi-vsix-audit-'));
|
|
try {
|
|
await extractZip(vsixPath, extractionRoot);
|
|
return await auditExtractedVsix(extractionRoot, target, options);
|
|
} finally {
|
|
await rm(extractionRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function auditExtractedVsix(extractionRoot, target, options = {}) {
|
|
const sourceRoot = options.sourceRoot ?? extensionRoot;
|
|
const extensionDir = join(extractionRoot, 'extension');
|
|
const files = await listFiles(extractionRoot);
|
|
const fileSet = new Set(files);
|
|
|
|
requireFile(fileSet, 'extension.vsixmanifest');
|
|
requireFile(fileSet, '[Content_Types].xml');
|
|
requireFile(fileSet, 'extension/package.json');
|
|
const packagedManifest = await readJson(join(extensionDir, 'package.json'), 'package.json');
|
|
const sourceManifest = await readJson(join(sourceRoot, 'package.json'), 'source package.json');
|
|
|
|
await verifyTargetManifest(extractionRoot, target);
|
|
verifyPackageManifest(packagedManifest, sourceManifest);
|
|
verifyRequiredFiles(fileSet, packagedManifest);
|
|
verifyForbiddenFiles(files);
|
|
await verifyNoSensitiveContent(extractionRoot, files, sourceRoot, options.forbiddenText ?? []);
|
|
await verifyRuntimeImports(extensionDir, files);
|
|
await verifyEntryImport(extensionDir, packagedManifest.main);
|
|
|
|
const bytes = await totalSize(extractionRoot, files);
|
|
return { target, files: files.length, bytes };
|
|
}
|
|
|
|
async function verifyTargetManifest(extractionRoot, target) {
|
|
const xml = await readFile(join(extractionRoot, 'extension.vsixmanifest'), 'utf8');
|
|
const actual = xml.match(/\bTargetPlatform="([^"]+)"/)?.[1];
|
|
if (actual !== target) {
|
|
throw new Error(
|
|
`VSIX manifest target is ${actual ?? 'missing'}, expected ${target}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifyPackageManifest(packaged, source) {
|
|
if (typeof packaged.main !== 'string' || !packaged.main.endsWith('.js')) {
|
|
throw new Error(`Packaged extension main must be a .js entry, got ${String(packaged.main)}.`);
|
|
}
|
|
if (packaged.main !== './dist/extension.js') {
|
|
throw new Error(`Packaged extension main is ${packaged.main}, expected ./dist/extension.js.`);
|
|
}
|
|
|
|
for (const field of MANIFEST_FIELDS) {
|
|
if (!isDeepStrictEqual(packaged[field], source[field])) {
|
|
throw new Error(`Packaged package.json field "${field}" does not match the source manifest.`);
|
|
}
|
|
}
|
|
for (const field of CONTRIBUTE_FIELDS) {
|
|
if (!isDeepStrictEqual(packaged.contributes?.[field], source.contributes?.[field])) {
|
|
throw new Error(
|
|
`Packaged package.json contributes.${field} does not match the source manifest.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function verifyRequiredFiles(fileSet, manifest) {
|
|
const required = [
|
|
'extension/LICENSE.txt',
|
|
`extension/${stripLeadingDotSlash(manifest.main)}`,
|
|
...REQUIRED_WEBVIEW_FILES.map((file) => `extension/${file}`),
|
|
];
|
|
requireOneOf(fileSet, ['extension/README.md', 'extension/readme.md'], 'marketplace README');
|
|
if (typeof manifest.icon === 'string') required.push(`extension/${manifest.icon}`);
|
|
|
|
for (const container of Object.values(manifest.contributes?.viewsContainers ?? {})) {
|
|
if (!Array.isArray(container)) continue;
|
|
for (const view of container) {
|
|
if (typeof view?.icon === 'string') required.push(`extension/${view.icon}`);
|
|
}
|
|
}
|
|
for (const file of required) requireFile(fileSet, file);
|
|
}
|
|
|
|
function verifyForbiddenFiles(files) {
|
|
for (const file of files) {
|
|
const normalized = file.replaceAll('\\', '/');
|
|
const lower = normalized.toLowerCase();
|
|
const extensionRelative = lower.startsWith('extension/') ? lower.slice('extension/'.length) : lower;
|
|
const segments = extensionRelative.split('/');
|
|
const forbiddenSegment = segments.find((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment));
|
|
if (forbiddenSegment !== undefined) {
|
|
throw new Error(`Forbidden package path segment "${forbiddenSegment}" in ${normalized}.`);
|
|
}
|
|
if (FORBIDDEN_EXTENSIONS.has(extname(lower))) {
|
|
throw new Error(`Forbidden package file type in ${normalized}.`);
|
|
}
|
|
if (
|
|
lower.includes('kimi-agent-sdk') ||
|
|
lower.includes('download-cli') ||
|
|
lower.includes('/bin/kimi/') ||
|
|
/(^|\/)uv(?:\.exe)?$/.test(lower)
|
|
) {
|
|
throw new Error(`Legacy CLI/runtime artifact found in ${normalized}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function verifyNoSensitiveContent(extractionRoot, files, sourceRoot, extraForbiddenText) {
|
|
const secretValues = [process.env.VSCE_PAT, process.env.OVSX_PAT]
|
|
.filter((value) => typeof value === 'string' && value.length >= 8);
|
|
const forbidden = [sourceRoot, homedir(), ...extraForbiddenText, ...secretValues]
|
|
.filter((value) => typeof value === 'string' && value.length >= 4)
|
|
.flatMap((value) => [value, value.replaceAll('\\', '/'), value.replaceAll('/', '\\')]);
|
|
|
|
for (const file of files) {
|
|
if (!TEXT_EXTENSIONS.has(extname(file).toLowerCase())) continue;
|
|
const content = await readFile(join(extractionRoot, file), 'utf8');
|
|
const match = forbidden.find((value) => content.includes(value));
|
|
if (match === undefined) continue;
|
|
const label = secretValues.includes(match) ? 'a marketplace token' : 'a local filesystem path';
|
|
throw new Error(`Packaged text file ${file} contains ${label}.`);
|
|
}
|
|
}
|
|
|
|
async function verifyRuntimeImports(extensionDir, files) {
|
|
const distFiles = files.filter(
|
|
(file) => file.startsWith('extension/dist/') && ['.cjs', '.js', '.mjs'].includes(extname(file)),
|
|
);
|
|
if (distFiles.length === 0) throw new Error('No JavaScript extension bundle files were packaged.');
|
|
|
|
for (const archivePath of distFiles) {
|
|
const localPath = join(dirname(extensionDir), archivePath);
|
|
const source = await readFile(localPath, 'utf8');
|
|
for (const specifier of collectLiteralImports(source)) {
|
|
if (specifier === 'vscode' || BUILTIN_IMPORTS.has(specifier)) continue;
|
|
if (OPTIONAL_FALLBACK_IMPORTS.has(specifier)) continue;
|
|
if (specifier.startsWith('node:')) continue;
|
|
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
|
if (specifier.startsWith('/')) {
|
|
throw new Error(`Absolute runtime import "${specifier}" in ${archivePath}.`);
|
|
}
|
|
const dependencyPath = resolve(dirname(localPath), stripImportSuffix(specifier));
|
|
if (!runtimeImportExists(dependencyPath)) {
|
|
throw new Error(`Missing relative runtime import "${specifier}" in ${archivePath}.`);
|
|
}
|
|
continue;
|
|
}
|
|
if (specifier.startsWith('data:') || specifier.startsWith('file:')) continue;
|
|
throw new Error(`Bare runtime dependency "${specifier}" remains in ${archivePath}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectLiteralImports(source) {
|
|
const imports = new Set();
|
|
const program = parse(source, {
|
|
allowHashBang: true,
|
|
ecmaVersion: 'latest',
|
|
sourceType: 'module',
|
|
});
|
|
walkSyntax(program, (node) => {
|
|
if (
|
|
node.type === 'ImportDeclaration' ||
|
|
node.type === 'ExportAllDeclaration' ||
|
|
node.type === 'ExportNamedDeclaration'
|
|
) {
|
|
const specifier = literalString(node.source);
|
|
if (specifier !== undefined) imports.add(specifier);
|
|
return;
|
|
}
|
|
if (node.type === 'ImportExpression') {
|
|
const specifier = literalString(node.source);
|
|
if (specifier !== undefined) imports.add(specifier);
|
|
return;
|
|
}
|
|
if (node.type === 'CallExpression' && isRuntimeRequire(node.callee)) {
|
|
const specifier = literalString(node.arguments?.[0]);
|
|
if (specifier !== undefined) imports.add(specifier);
|
|
}
|
|
});
|
|
return imports;
|
|
}
|
|
|
|
function walkSyntax(value, visit) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) walkSyntax(item, visit);
|
|
return;
|
|
}
|
|
if (typeof value !== 'object' || value === null) return;
|
|
if (typeof value.type === 'string') visit(value);
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
|
|
walkSyntax(child, visit);
|
|
}
|
|
}
|
|
|
|
function isRuntimeRequire(callee) {
|
|
if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name);
|
|
if (callee?.type !== 'MemberExpression' || callee.computed === true) return false;
|
|
// `this.require(...)` is an ordinary class method call (e.g. a private field
|
|
// accessor in bundled sources), never a CommonJS require of a bare specifier.
|
|
if (callee.object?.type === 'ThisExpression') return false;
|
|
return callee.property?.type === 'Identifier' && callee.property.name === 'require';
|
|
}
|
|
|
|
function literalString(node) {
|
|
if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
|
|
return node.quasis?.[0]?.value?.cooked;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function verifyEntryImport(extensionDir, main) {
|
|
const mainPath = join(extensionDir, stripLeadingDotSlash(main));
|
|
const stubDir = join(extensionDir, 'node_modules', 'vscode');
|
|
await mkdir(stubDir, { recursive: true });
|
|
await writeFile(
|
|
join(stubDir, 'package.json'),
|
|
`${JSON.stringify({ name: 'vscode', version: '0.0.0-test', type: 'module', exports: './index.js' }, null, 2)}\n`,
|
|
);
|
|
await writeFile(join(stubDir, 'index.js'), 'export {};\n');
|
|
|
|
const script = [
|
|
`const extension = await import(${JSON.stringify(pathToFileURL(mainPath).href)});`,
|
|
'if (typeof extension.activate !== "function") {',
|
|
' throw new Error("extension bundle does not export activate");',
|
|
'}',
|
|
].join('\n');
|
|
const env = { ...process.env };
|
|
delete env.NODE_PATH;
|
|
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
|
|
cwd: dirname(extensionDir),
|
|
env,
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
await rm(join(extensionDir, 'node_modules'), { recursive: true, force: true });
|
|
if (result.error !== undefined) {
|
|
throw new Error(`Unable to import the unpacked extension entry: ${result.error.message}`);
|
|
}
|
|
if (result.status !== 0) {
|
|
const detail = conciseProcessError(result.stderr || result.stdout);
|
|
throw new Error(`Unpacked extension entry import failed: ${detail}`);
|
|
}
|
|
}
|
|
|
|
async function readJson(path, label) {
|
|
try {
|
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
} catch (error) {
|
|
throw new Error(`${label} is not valid JSON: ${describeError(error)}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function listFiles(root) {
|
|
const output = [];
|
|
async function visit(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const localPath = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(localPath);
|
|
} else if (entry.isFile()) {
|
|
output.push(relative(root, localPath).split(sep).join('/'));
|
|
} else {
|
|
throw new Error(`Unsupported non-file entry in unpacked VSIX: ${localPath}`);
|
|
}
|
|
}
|
|
}
|
|
await visit(root);
|
|
return output.sort();
|
|
}
|
|
|
|
async function totalSize(root, files) {
|
|
let bytes = 0;
|
|
for (const file of files) bytes += (await stat(join(root, file))).size;
|
|
return bytes;
|
|
}
|
|
|
|
function requireFile(fileSet, file) {
|
|
if (fileSet.has(file)) return;
|
|
throw new Error(`Required VSIX resource is missing: ${file}`);
|
|
}
|
|
|
|
function requireOneOf(fileSet, files, label) {
|
|
if (files.some((file) => fileSet.has(file))) return;
|
|
throw new Error(`Required VSIX resource is missing: ${label} (${files.join(' or ')}).`);
|
|
}
|
|
|
|
function runtimeImportExists(path) {
|
|
return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, 'index.js')].some(existsSync);
|
|
}
|
|
|
|
function stripImportSuffix(specifier) {
|
|
return specifier.split(/[?#]/, 1)[0];
|
|
}
|
|
|
|
function stripLeadingDotSlash(value) {
|
|
return String(value).replace(/^\.\//, '');
|
|
}
|
|
|
|
function describeError(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function conciseProcessError(output) {
|
|
const lines = String(output).trim().split(/\r?\n/).filter(Boolean);
|
|
return lines.slice(-4).join('\n') || 'process exited without an error message';
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const targets = [];
|
|
let outputDir = defaultVsixOutputDir;
|
|
let file;
|
|
let directory;
|
|
let help = false;
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--') {
|
|
continue;
|
|
} else if (argument === '--help' || argument === '-h') {
|
|
help = true;
|
|
} else if (argument === '--out-dir') {
|
|
outputDir = requireOptionValue(argv, ++index, '--out-dir');
|
|
} else if (argument === '--target') {
|
|
targets.push(requireOptionValue(argv, ++index, '--target'));
|
|
} else if (argument === '--file') {
|
|
file = requireOptionValue(argv, ++index, '--file');
|
|
} else if (argument === '--directory') {
|
|
directory = requireOptionValue(argv, ++index, '--directory');
|
|
} else if (argument.startsWith('-')) {
|
|
throw new Error(`Unknown option: ${argument}`);
|
|
} else {
|
|
targets.push(argument);
|
|
}
|
|
}
|
|
|
|
if (file !== undefined && directory !== undefined) {
|
|
throw new Error('--file and --directory cannot be used together.');
|
|
}
|
|
const normalizedTargets = normalizeVsixTargets(targets);
|
|
if ((file !== undefined || directory !== undefined) && normalizedTargets.length !== 1) {
|
|
throw new Error('--file and --directory require exactly one target.');
|
|
}
|
|
return { targets: normalizedTargets, outputDir: resolve(outputDir), file, directory, help };
|
|
}
|
|
|
|
function requireOptionValue(argv, index, option) {
|
|
const value = argv[index];
|
|
if (value !== undefined && !value.startsWith('-')) return value;
|
|
throw new Error(`${option} requires a value.`);
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage: node scripts/vsix-verify.mjs [targets...] [--out-dir <directory>]',
|
|
' node scripts/vsix-verify.mjs --target <target> --file <file.vsix>',
|
|
' node scripts/vsix-verify.mjs --target <target> --directory <unpacked-vsix>',
|
|
'',
|
|
'The verifier performs a package-content audit and an entry import smoke only.',
|
|
'It does not claim that a target passed a real operating-system E2E run.',
|
|
].join('\n');
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
if (options.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
for (const target of options.targets) {
|
|
const input = options.directory ?? options.file ?? join(options.outputDir, vsixFileName(target));
|
|
const result = options.directory === undefined
|
|
? await verifyVsix(resolve(input), target, { sourceRoot: extensionRoot })
|
|
: await auditExtractedVsix(resolve(input), target, { sourceRoot: extensionRoot });
|
|
console.log(
|
|
`Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; static audit and entry import smoke passed (package-only).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(`VSIX verification failed: ${describeError(error)}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|