fix(qwen): align hooks with Qwen Code 0.20
This commit is contained in:
1 parent
590de9dbdb
commit
3cfd0a5187
12 files changed
+1876
-1207
No files matched your search
@@ -0,0 +1,319 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import {
|
||||
createQwenDocument,
|
||||
parseQwenDocument,
|
||||
parseQwenJsoncObject,
|
||||
qwenDocumentLabel,
|
||||
qwenSourceLabel,
|
||||
type QwenDocument,
|
||||
type QwenDocumentKind,
|
||||
} from './qwen-config.js';
|
||||
import { readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const;
|
||||
|
||||
export interface QwenSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface QwenDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: QwenDocument;
|
||||
}
|
||||
|
||||
export interface QwenSources {
|
||||
readonly qwenHome: string;
|
||||
readonly systemDefaults: QwenDocument;
|
||||
readonly user: QwenDocument;
|
||||
readonly workspace: QwenDocument;
|
||||
readonly system: QwenDocument;
|
||||
readonly workspaceActive: boolean;
|
||||
readonly workspaceTrusted: boolean;
|
||||
readonly disableControl: QwenDisableControl;
|
||||
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||
}
|
||||
|
||||
interface RoutingResult {
|
||||
readonly qwenHome: string;
|
||||
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||
}
|
||||
|
||||
function defaultQwenHome(): string {
|
||||
const home = os.homedir();
|
||||
return home ? path.join(home, '.qwen') : path.join(os.tmpdir(), '.qwen');
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string): string {
|
||||
let resolved = value;
|
||||
if (resolved === '~' || resolved.startsWith('~/') || resolved.startsWith('~\\')) {
|
||||
const segments = resolved === '~'
|
||||
? []
|
||||
: resolved.slice(2).split(/[/\\]+/).filter(Boolean);
|
||||
resolved = path.join(os.homedir(), ...segments);
|
||||
}
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(resolved);
|
||||
}
|
||||
|
||||
function environmentValue(key: string): { own: boolean; value?: string } {
|
||||
return {
|
||||
own: Object.hasOwn(process.env, key),
|
||||
value: process.env[key],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveQwenRouting(): Promise<RoutingResult> {
|
||||
const values = new Map<string, string | undefined>();
|
||||
const owned = new Set<string>();
|
||||
for (const key of HOME_ENV_KEYS) {
|
||||
const current = environmentValue(key);
|
||||
values.set(key, current.value);
|
||||
if (current.own) owned.add(key);
|
||||
}
|
||||
if (HOME_ENV_KEYS.every((key) => values.get(key))) {
|
||||
return {
|
||||
qwenHome: resolveConfigPath(values.get('QWEN_HOME')!),
|
||||
preconditions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const initialQwenHome = values.get('QWEN_HOME');
|
||||
const initialDirectory = initialQwenHome
|
||||
? resolveConfigPath(initialQwenHome)
|
||||
: defaultQwenHome();
|
||||
const candidates = [path.join(initialDirectory, '.env')];
|
||||
if (!initialQwenHome) candidates.push(path.join(path.dirname(initialDirectory), '.env'));
|
||||
const preconditions: QwenSourcePrecondition[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const readCandidate = async (filePath: string): Promise<void> => {
|
||||
const resolved = path.resolve(filePath);
|
||||
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
if (visited.has(key)) return;
|
||||
visited.add(key);
|
||||
const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment');
|
||||
preconditions.push({
|
||||
filePath: resolved,
|
||||
label: 'Qwen Code home environment',
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: snapshot,
|
||||
});
|
||||
if (!snapshot) return;
|
||||
const parsed = parseDotenv(snapshot.contents);
|
||||
for (const envKey of HOME_ENV_KEYS) {
|
||||
const value = parsed[envKey];
|
||||
if (value && !owned.has(envKey)) {
|
||||
values.set(envKey, value);
|
||||
owned.add(envKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const candidate of candidates) await readCandidate(candidate);
|
||||
const discoveredHome = values.get('QWEN_HOME');
|
||||
if (discoveredHome && discoveredHome !== initialQwenHome) {
|
||||
const discoveredDirectory = resolveConfigPath(discoveredHome);
|
||||
if (discoveredDirectory !== initialDirectory) {
|
||||
await readCandidate(path.join(discoveredDirectory, '.env'));
|
||||
}
|
||||
}
|
||||
return {
|
||||
qwenHome: values.get('QWEN_HOME')
|
||||
? resolveConfigPath(values.get('QWEN_HOME')!)
|
||||
: defaultQwenHome(),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
function systemSettingsPath(): string {
|
||||
const configured = process.env.QWEN_CODE_SYSTEM_SETTINGS_PATH;
|
||||
if (configured) return path.resolve(configured);
|
||||
if (process.platform === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
}
|
||||
if (process.platform === 'win32') return 'C:\\ProgramData\\qwen-code\\settings.json';
|
||||
return '/etc/qwen-code/settings.json';
|
||||
}
|
||||
|
||||
function systemDefaultsPath(systemPath: string): string {
|
||||
const configured = process.env.QWEN_CODE_SYSTEM_DEFAULTS_PATH;
|
||||
return configured
|
||||
? path.resolve(configured)
|
||||
: path.join(path.dirname(systemPath), 'system-defaults.json');
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: QwenDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<QwenDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, qwenSourceLabel(kind));
|
||||
return snapshot
|
||||
? parseQwenDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createQwenDocument(kind, filePath);
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function comparisonPath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function isWithin(child: string, parent: string): boolean {
|
||||
const relative = path.relative(comparisonPath(parent), comparisonPath(child));
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function workspaceTrust(
|
||||
system: QwenDocument,
|
||||
user: QwenDocument,
|
||||
qwenHome: string,
|
||||
workspacePath: string,
|
||||
): Promise<{ trusted: boolean; precondition?: QwenSourcePrecondition }> {
|
||||
const enabled = user.folderTrustEnabled ?? system.folderTrustEnabled ?? false;
|
||||
if (!enabled) return { trusted: true };
|
||||
const configured = process.env.QWEN_CODE_TRUSTED_FOLDERS_PATH;
|
||||
const filePath = configured
|
||||
? path.resolve(configured)
|
||||
: path.join(qwenHome, 'trustedFolders.json');
|
||||
const snapshot = await readPhysicalFile(filePath, 'Qwen Code trusted folders');
|
||||
const precondition = {
|
||||
filePath,
|
||||
label: 'Qwen Code trusted folders',
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: snapshot,
|
||||
};
|
||||
if (!snapshot) return { trusted: true, precondition };
|
||||
const rules = parseQwenJsoncObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`);
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) {
|
||||
throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`);
|
||||
}
|
||||
}
|
||||
const workspace = await canonicalPath(workspacePath);
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
const canonicalRule = await canonicalPath(String(rulePath));
|
||||
const trustRoot = level === 'TRUST_PARENT' ? path.dirname(canonicalRule) : canonicalRule;
|
||||
if ((level === 'TRUST_FOLDER' || level === 'TRUST_PARENT')
|
||||
&& isWithin(workspace, trustRoot)) return { trusted: true, precondition };
|
||||
}
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
if (level === 'DO_NOT_TRUST'
|
||||
&& comparisonPath(workspace) === comparisonPath(await canonicalPath(rulePath))) {
|
||||
return { trusted: false, precondition };
|
||||
}
|
||||
}
|
||||
return { trusted: true, precondition };
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
systemDefaults: QwenDocument,
|
||||
user: QwenDocument,
|
||||
workspace: QwenDocument,
|
||||
system: QwenDocument,
|
||||
useWorkspace: boolean,
|
||||
): QwenDisableControl {
|
||||
let disabled = false;
|
||||
let source: QwenDocument | undefined;
|
||||
const documents = [systemDefaults, user, ...(useWorkspace ? [workspace] : []), system];
|
||||
for (const document of documents) {
|
||||
if (document.disableAllHooks === undefined) continue;
|
||||
disabled = document.disableAllHooks;
|
||||
source = document;
|
||||
}
|
||||
return { disabled, source };
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: QwenDocument): QwenSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: qwenDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly QwenSourcePrecondition[],
|
||||
): QwenSourcePrecondition[] {
|
||||
const result = new Map<string, QwenSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = comparisonPath(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export async function readQwenSources(): Promise<QwenSources> {
|
||||
const routing = await resolveQwenRouting();
|
||||
const systemPath = systemSettingsPath();
|
||||
const workspacePath = path.join(process.cwd(), '.qwen', 'settings.json');
|
||||
const system = await readDocument('system', systemPath);
|
||||
const systemDefaults = await readDocument(
|
||||
'system-defaults',
|
||||
systemDefaultsPath(systemPath),
|
||||
);
|
||||
const user = await readDocument('user', path.join(routing.qwenHome, 'settings.json'));
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(process.cwd()),
|
||||
canonicalPath(os.homedir()),
|
||||
]);
|
||||
const workspaceActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
||||
const workspace = workspaceActive
|
||||
? await readDocument('workspace', workspacePath)
|
||||
: createQwenDocument('workspace', workspacePath);
|
||||
const trust = workspaceActive
|
||||
? await workspaceTrust(system, user, routing.qwenHome, canonicalWorkspace)
|
||||
: { trusted: false as const };
|
||||
const useWorkspace = workspaceActive && trust.trusted;
|
||||
const preconditions = deduplicatePreconditions([
|
||||
...routing.preconditions,
|
||||
sourcePrecondition(systemDefaults),
|
||||
sourcePrecondition(user),
|
||||
...(workspaceActive ? [sourcePrecondition(workspace)] : []),
|
||||
sourcePrecondition(system),
|
||||
...(trust.precondition ? [trust.precondition] : []),
|
||||
]);
|
||||
return {
|
||||
qwenHome: routing.qwenHome,
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
system,
|
||||
workspaceActive,
|
||||
workspaceTrusted: trust.trusted,
|
||||
disableControl: effectiveDisable(
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
system,
|
||||
useWorkspace,
|
||||
),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireQwenHooksEnabled(sources: QwenSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source ? `${qwenDocumentLabel(source)} at ${source.filePath}` : 'effective settings';
|
||||
throw new Error(`Qwen Code hooks are disabled by disableAllHooks in ${location}`);
|
||||
}
|
||||
Reference in new issue
Block a user