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,122 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export interface QwenRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
export function sameQwenPath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
export function sameQwenAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function buildQwenCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Qwen Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
}
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||
if (!command.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(command, 2);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(command, executable.next + 1);
|
||||
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
export function qwenRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (!parsed
|
||||
|| !path.isAbsolute(parsed[0])
|
||||
|| !isNodeExecutable(parsed[0])
|
||||
|| !path.isAbsolute(parsed[1])
|
||||
|| path.basename(parsed[1]) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(parsed[1]);
|
||||
if (!sameQwenPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, scriptPath: parsed[1] };
|
||||
}
|
||||
+101
-85
@@ -8,70 +8,76 @@ import {
|
||||
type JSONPath,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser";
|
||||
} from 'jsonc-parser';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
TOOL_EVENTS,
|
||||
MANAGED_EVENTS,
|
||||
managedQwenRemovals,
|
||||
readQwenHooks,
|
||||
type JsonObject,
|
||||
type ManagedQwenEvent,
|
||||
type QwenGroup,
|
||||
type QwenHookSettings,
|
||||
type ToolEvent,
|
||||
type QwenHooks,
|
||||
isObject,
|
||||
managedRemovals,
|
||||
readHooks,
|
||||
} from "./qwen-contract.js";
|
||||
} from './qwen-contract.js';
|
||||
|
||||
export const OWNED_FILE_MARKER = "// Managed by Elydora";
|
||||
export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||
|
||||
export type QwenDocumentKind = 'system-defaults' | 'user' | 'workspace' | 'system';
|
||||
|
||||
export interface QwenDocument {
|
||||
readonly kind: QwenDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: QwenHookSettings;
|
||||
readonly hooks: QwenHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
readonly hooksDisabled: boolean;
|
||||
readonly disableAllHooks?: boolean;
|
||||
readonly folderTrustEnabled?: boolean;
|
||||
readonly ownedFile: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
export interface RenderedQwenDocument {
|
||||
readonly document: QwenDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: QwenDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
function rejectDuplicateKeys(
|
||||
node: Node | undefined,
|
||||
label: string,
|
||||
path: string[] = [],
|
||||
location: string[] = [],
|
||||
): void {
|
||||
if (!node) return;
|
||||
if (node.type === "object") {
|
||||
if (node.type === 'object') {
|
||||
const keys = new Set<string>();
|
||||
for (const property of node.children ?? []) {
|
||||
const key = String(property.children?.[0]?.value);
|
||||
const location = [...path, key];
|
||||
if (keys.has(key))
|
||||
throw new Error(
|
||||
`${label} contains duplicate field "${location.join(".")}"`,
|
||||
);
|
||||
const childLocation = [...location, key];
|
||||
if (keys.has(key)) {
|
||||
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
|
||||
}
|
||||
keys.add(key);
|
||||
rejectDuplicateKeys(property.children?.[1], label, location);
|
||||
rejectDuplicateKeys(property.children?.[1], label, childLocation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === "array") {
|
||||
for (const child of node.children ?? [])
|
||||
rejectDuplicateKeys(child, label, path);
|
||||
if (node.type === 'array') {
|
||||
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonWithComments(raw: string, label: string): JsonObject {
|
||||
export function parseQwenJsoncObject(raw: string, label: string): JsonObject {
|
||||
const errors: ParseError[] = [];
|
||||
const value: unknown = parse(raw, errors, {
|
||||
allowTrailingComma: false,
|
||||
@@ -79,60 +85,76 @@ function parseJsonWithComments(raw: string, label: string): JsonObject {
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
const details = errors
|
||||
.map(
|
||||
(error) =>
|
||||
`${printParseErrorCode(error.error)} at offset ${error.offset}`,
|
||||
)
|
||||
.join(", ");
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join(', ');
|
||||
throw new Error(`Failed to parse ${label}: ${details}`);
|
||||
}
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
rejectDuplicateKeys(
|
||||
parseTree(raw, [], {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: false,
|
||||
}),
|
||||
label,
|
||||
);
|
||||
rejectDuplicateKeys(parseTree(raw, [], {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: false,
|
||||
}), label);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseDocument(options: DocumentOptions): QwenDocument {
|
||||
const label = `Qwen Code settings at ${options.filePath}`;
|
||||
const root = parseJsonWithComments(options.raw, label);
|
||||
if (
|
||||
root.disableAllHooks !== undefined &&
|
||||
typeof root.disableAllHooks !== "boolean"
|
||||
) {
|
||||
export function qwenSourceLabel(kind: QwenDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'system-defaults': return 'Qwen Code system defaults';
|
||||
case 'workspace': return 'Qwen Code workspace settings';
|
||||
case 'system': return 'Qwen Code system override settings';
|
||||
default: return 'Qwen Code user settings';
|
||||
}
|
||||
}
|
||||
|
||||
function readFolderTrustEnabled(root: JsonObject, label: string): boolean | undefined {
|
||||
if (root.security === undefined) return undefined;
|
||||
if (!isObject(root.security)) throw new Error(`${label} field "security" must be an object`);
|
||||
if (root.security.folderTrust === undefined) return undefined;
|
||||
if (!isObject(root.security.folderTrust)) {
|
||||
throw new Error(`${label} field "security.folderTrust" must be an object`);
|
||||
}
|
||||
const enabled = root.security.folderTrust.enabled;
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new Error(`${label} field "security.folderTrust.enabled" must be a boolean`);
|
||||
}
|
||||
return enabled as boolean | undefined;
|
||||
}
|
||||
|
||||
export function parseQwenDocument(options: DocumentOptions): QwenDocument {
|
||||
const label = `${qwenSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseQwenJsoncObject(options.raw, label);
|
||||
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||
throw new Error(`${label} field "disableAllHooks" must be a boolean`);
|
||||
}
|
||||
const hasHooksContainer = Object.hasOwn(root, "hooks");
|
||||
const hooks = hasHooksContainer
|
||||
? readHooks(root.hooks, `${label} field "hooks"`)
|
||||
: {};
|
||||
const hasHooksContainer = Object.hasOwn(root, 'hooks');
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks,
|
||||
hooks: readQwenHooks(root.hooks),
|
||||
hasHooksContainer,
|
||||
hooksDisabled: root.disableAllHooks === true,
|
||||
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
|
||||
disableAllHooks: root.disableAllHooks as boolean | undefined,
|
||||
folderTrustEnabled: readFolderTrustEnabled(root, label),
|
||||
ownedFile: options.kind === 'user' && options.raw.startsWith(QWEN_OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOwnedDocument(filePath: string): QwenDocument {
|
||||
return parseDocument({
|
||||
export function createQwenDocument(
|
||||
kind: QwenDocumentKind,
|
||||
filePath: string,
|
||||
): QwenDocument {
|
||||
return parseQwenDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: `${OWNED_FILE_MARKER}\n{}\n`,
|
||||
raw: kind === 'user' ? `${QWEN_OWNED_FILE_MARKER}\n{}\n` : '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes("\t");
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes("\r\n") ? "\r\n" : "\n",
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
@@ -144,20 +166,19 @@ function change(
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(
|
||||
raw,
|
||||
modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}),
|
||||
);
|
||||
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
|
||||
function currentDocument(document: QwenDocument, raw: string): QwenDocument {
|
||||
return parseDocument({
|
||||
return parseQwenDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,13 +187,13 @@ function removeManagedEntries(
|
||||
raw: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const removals = managedRemovals(document.hooks, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
const removals = managedQwenRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ["hooks", event, removal.groupIndex];
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = change(raw, groupPath, undefined);
|
||||
continue;
|
||||
@@ -180,19 +201,19 @@ function removeManagedEntries(
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = change(raw, [...groupPath, "hooks", handlerIndex], undefined);
|
||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if ((current.hooks[event] ?? []).length === 0) {
|
||||
raw = change(raw, ["hooks", event], undefined);
|
||||
raw = change(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = change(raw, ["hooks"], undefined);
|
||||
raw = change(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -200,38 +221,33 @@ function removeManagedEntries(
|
||||
function appendGroup(
|
||||
document: QwenDocument,
|
||||
raw: string,
|
||||
event: ToolEvent,
|
||||
event: ManagedQwenEvent,
|
||||
group: QwenGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hooks[event]) {
|
||||
return change(
|
||||
raw,
|
||||
["hooks", event, current.hooks[event]!.length],
|
||||
group,
|
||||
true,
|
||||
);
|
||||
return change(raw, ['hooks', event, current.hooks[event].length], group, true);
|
||||
}
|
||||
return change(raw, ["hooks", event], [group]);
|
||||
return change(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
export function renderQwenDocument(
|
||||
document: QwenDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ToolEvent, QwenGroup>,
|
||||
): RenderedDocument {
|
||||
additions: ReadonlyMap<ManagedQwenEvent, QwenGroup>,
|
||||
): RenderedQwenDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (
|
||||
additions.size === 0 &&
|
||||
document.ownedFile &&
|
||||
Object.keys(current.root).length === 0
|
||||
) {
|
||||
if (additions.size === 0 && document.ownedFile && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function qwenDocumentLabel(document: QwenDocument): string {
|
||||
return qwenSourceLabel(document.kind);
|
||||
}
|
||||
+249
-294
@@ -1,352 +1,289 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildQwenCommand,
|
||||
qwenRuntimeReference,
|
||||
sameQwenAgentId,
|
||||
sameQwenPath,
|
||||
type QwenRuntimeReference,
|
||||
} from './qwen-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = "qwen";
|
||||
export const GUARD_SCRIPT = "guard.js";
|
||||
export const AUDIT_SCRIPT = "hook.js";
|
||||
export const AGENT_KEY = 'qwen';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_HOOK_NAME = 'elydora-guard';
|
||||
export const AUDIT_HOOK_NAME = 'elydora-audit';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const TOOL_EVENTS = ["PreToolUse", "PostToolUse"] as const;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const EVENT_NAMES = new Set([
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PostToolBatch",
|
||||
"Notification",
|
||||
"UserPromptSubmit",
|
||||
"UserPromptExpansion",
|
||||
"SessionStart",
|
||||
"Stop",
|
||||
"MessageDisplay",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionEnd",
|
||||
"PermissionRequest",
|
||||
"PermissionDenied",
|
||||
"StopFailure",
|
||||
"TodoCreated",
|
||||
"TodoCompleted",
|
||||
"InstructionsLoaded",
|
||||
const KNOWN_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PostToolBatch',
|
||||
'Notification',
|
||||
'UserPromptSubmit',
|
||||
'UserPromptExpansion',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'MessageDisplay',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
'SessionEnd',
|
||||
'PermissionRequest',
|
||||
'PermissionDenied',
|
||||
'StopFailure',
|
||||
'TodoCreated',
|
||||
'TodoCompleted',
|
||||
'InstructionsLoaded',
|
||||
]);
|
||||
const CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]);
|
||||
const HANDLER_KEYS = ["command", "shell", "timeout", "type"];
|
||||
const GROUP_KEYS = ["hooks", "matcher"];
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type ToolEvent = (typeof TOOL_EVENTS)[number];
|
||||
const REGEX_MATCHER_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
'PermissionDenied',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'StopFailure',
|
||||
'Notification',
|
||||
'InstructionsLoaded',
|
||||
'UserPromptExpansion',
|
||||
]);
|
||||
|
||||
export interface QwenCommandHandler extends JsonObject {
|
||||
readonly type: "command";
|
||||
readonly command: string;
|
||||
export type ManagedQwenEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface QwenHandler extends JsonObject {
|
||||
readonly type: 'command' | 'http' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly name?: string;
|
||||
readonly shell?: 'bash' | 'powershell';
|
||||
readonly timeout?: number;
|
||||
readonly shell?: "bash" | "powershell";
|
||||
}
|
||||
|
||||
export interface QwenGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly sequential?: boolean;
|
||||
readonly hooks: JsonObject[];
|
||||
readonly hooks: QwenHandler[];
|
||||
}
|
||||
|
||||
export interface QwenHookSettings extends JsonObject {
|
||||
readonly PreToolUse?: QwenGroup[];
|
||||
readonly PostToolUse?: QwenGroup[];
|
||||
}
|
||||
export type QwenHooks = Record<string, QwenGroup[]>;
|
||||
|
||||
export interface RuntimeContract {
|
||||
export interface QwenRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRemoval {
|
||||
readonly event: ToolEvent;
|
||||
readonly event: ManagedQwenEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: number[];
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function buildCommand(scriptPath: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
}
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
export function buildGroup(scriptPath: string): QwenGroup {
|
||||
return {
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: buildCommand(scriptPath),
|
||||
shell: process.platform === "win32" ? "powershell" : "bash",
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function validateRegex(value: string, label: string): void {
|
||||
if (value.trim() === "" || value.trim() === "*") return;
|
||||
try {
|
||||
new RegExp(value);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${label} must be a valid regular expression: ${message}`);
|
||||
function optionalString(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'string') {
|
||||
throw new Error(`${label} field "${field}" must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalString(value: JsonObject, key: string, label: string): void {
|
||||
if (value[key] !== undefined && typeof value[key] !== "string") {
|
||||
throw new Error(`${label} field "${key}" must be a string`);
|
||||
function optionalBoolean(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
||||
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalBoolean(value: JsonObject, key: string, label: string): void {
|
||||
if (value[key] !== undefined && typeof value[key] !== "boolean") {
|
||||
throw new Error(`${label} field "${key}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalStringMap(
|
||||
value: JsonObject,
|
||||
key: string,
|
||||
label: string,
|
||||
): void {
|
||||
const item = value[key];
|
||||
function optionalStringMap(value: JsonObject, field: string, label: string): void {
|
||||
const item = value[field];
|
||||
if (item === undefined) return;
|
||||
if (
|
||||
!isObject(item) ||
|
||||
Object.values(item).some((entry) => typeof entry !== "string")
|
||||
) {
|
||||
throw new Error(`${label} field "${key}" must contain string values`);
|
||||
if (!isObject(item) || Object.values(item).some((entry) => typeof entry !== 'string')) {
|
||||
throw new Error(`${label} field "${field}" must map names to strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): JsonObject {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (
|
||||
value.type !== "command" &&
|
||||
value.type !== "http" &&
|
||||
value.type !== "prompt"
|
||||
) {
|
||||
throw new Error(`${label} type must be "command", "http", or "prompt"`);
|
||||
}
|
||||
if (
|
||||
value.timeout !== undefined &&
|
||||
(typeof value.timeout !== "number" ||
|
||||
!Number.isFinite(value.timeout) ||
|
||||
value.timeout < 0)
|
||||
) {
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
for (const key of ["name", "description", "statusMessage", "source"]) {
|
||||
optionalString(value, key, label);
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): QwenHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (!['command', 'http', 'prompt'].includes(String(value.type))) {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
if (value.type === "command") {
|
||||
if (typeof value.command !== "string" || value.command.length === 0) {
|
||||
throw new Error(`${label} command must be a non-empty string`);
|
||||
validateTimeout(value.timeout, label);
|
||||
for (const field of ['name', 'description', 'statusMessage', 'source']) {
|
||||
optionalString(value, field, label);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
optionalStringMap(value, "env", label);
|
||||
optionalBoolean(value, "async", label);
|
||||
if (
|
||||
value.shell !== undefined &&
|
||||
value.shell !== "bash" &&
|
||||
value.shell !== "powershell"
|
||||
) {
|
||||
optionalStringMap(value, 'env', label);
|
||||
optionalBoolean(value, 'async', label);
|
||||
if (value.shell !== undefined && !['bash', 'powershell'].includes(String(value.shell))) {
|
||||
throw new Error(`${label} shell must be "bash" or "powershell"`);
|
||||
}
|
||||
}
|
||||
if (value.type === "http") {
|
||||
if (typeof value.url !== "string" || value.url.length === 0) {
|
||||
throw new Error(`${label} url must be a non-empty string`);
|
||||
if (value.type === 'http') {
|
||||
if (typeof value.url !== 'string' || value.url.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty url`);
|
||||
}
|
||||
optionalStringMap(value, "headers", label);
|
||||
optionalBoolean(value, "once", label);
|
||||
optionalString(value, "if", label);
|
||||
if (
|
||||
value.allowedEnvVars !== undefined &&
|
||||
(!Array.isArray(value.allowedEnvVars) ||
|
||||
value.allowedEnvVars.some((item) => typeof item !== "string"))
|
||||
) {
|
||||
optionalStringMap(value, 'headers', label);
|
||||
optionalBoolean(value, 'once', label);
|
||||
optionalString(value, 'if', label);
|
||||
if (value.allowedEnvVars !== undefined && (
|
||||
!Array.isArray(value.allowedEnvVars)
|
||||
|| value.allowedEnvVars.some((item) => typeof item !== 'string')
|
||||
)) {
|
||||
throw new Error(`${label} allowedEnvVars must be an array of strings`);
|
||||
}
|
||||
}
|
||||
if (value.type === "prompt") {
|
||||
if (typeof value.prompt !== "string" || value.prompt.length === 0) {
|
||||
throw new Error(`${label} prompt must be a non-empty string`);
|
||||
if (value.type === 'prompt') {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
optionalString(value, "model", label);
|
||||
optionalString(value, 'model', label);
|
||||
}
|
||||
return value;
|
||||
return value as QwenHandler;
|
||||
}
|
||||
|
||||
function validateGroup(
|
||||
value: unknown,
|
||||
label: string,
|
||||
index: number,
|
||||
): QwenGroup {
|
||||
const location = `${label}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${location} must be an object`);
|
||||
function validateMatcher(matcher: string, event: string, label: string): void {
|
||||
if (!REGEX_MATCHER_EVENTS.has(event) || matcher.trim() === '' || matcher.trim() === '*') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new RegExp(matcher);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} matcher must be a valid regular expression`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, index: number): QwenGroup {
|
||||
const label = `Qwen Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.matcher !== undefined) {
|
||||
if (typeof value.matcher !== "string")
|
||||
throw new Error(`${location} matcher must be a string`);
|
||||
validateRegex(value.matcher, `${location} matcher`);
|
||||
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
|
||||
validateMatcher(value.matcher, event, label);
|
||||
}
|
||||
if (value.sequential !== undefined && typeof value.sequential !== "boolean") {
|
||||
throw new Error(`${location} sequential must be a boolean`);
|
||||
if (value.sequential !== undefined && typeof value.sequential !== 'boolean') {
|
||||
throw new Error(`${label} sequential must be a boolean`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks))
|
||||
throw new Error(`${location} must contain a hooks array`);
|
||||
const hooks = value.hooks.map((handler, handlerIndex) =>
|
||||
validateHandler(handler, `${location}.hooks[${handlerIndex}]`),
|
||||
);
|
||||
return { ...value, hooks } as QwenGroup;
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as QwenGroup;
|
||||
}
|
||||
|
||||
export function readHooks(value: unknown, label: string): QwenHookSettings {
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
const settings: QwenHookSettings = { ...value };
|
||||
for (const [event, definitions] of Object.entries(value)) {
|
||||
if (CONFIG_FIELDS.has(event)) continue;
|
||||
if (!EVENT_NAMES.has(event))
|
||||
throw new Error(`${label} contains unsupported field "${event}"`);
|
||||
if (!Array.isArray(definitions))
|
||||
throw new Error(`${label} field "${event}" must be an array`);
|
||||
settings[event] = definitions.map((definition, index) =>
|
||||
validateGroup(definition, `${label} field "${event}"`, index),
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function readQuotedArgument(
|
||||
command: string,
|
||||
start: number,
|
||||
): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = "";
|
||||
for (let index = start + 1; index < command.length; ) {
|
||||
if (process.platform === "win32" && command.startsWith("''", index)) {
|
||||
value += "'";
|
||||
index += 2;
|
||||
export function readQwenHooks(value: unknown): QwenHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Qwen Code settings field "hooks" must be an object');
|
||||
const hooks: QwenHooks = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups as QwenGroup[];
|
||||
continue;
|
||||
}
|
||||
if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) {
|
||||
value += "'";
|
||||
index += 5;
|
||||
continue;
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Qwen Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return undefined;
|
||||
return hooks;
|
||||
}
|
||||
|
||||
function parseGeneratedCommand(
|
||||
command: string,
|
||||
): readonly [string, string] | undefined {
|
||||
if (process.platform === "win32" && !command.startsWith("& "))
|
||||
return undefined;
|
||||
const start = process.platform === "win32" ? 2 : 0;
|
||||
const executable = readQuotedArgument(command, start);
|
||||
if (!executable || command[executable.next] !== " ") return undefined;
|
||||
const script = readQuotedArgument(command, executable.next + 1);
|
||||
const expectedSuffix =
|
||||
process.platform === "win32" ? "; exit $LASTEXITCODE" : "";
|
||||
if (
|
||||
!script ||
|
||||
command.slice(script.next) !== expectedSuffix ||
|
||||
!executable.value ||
|
||||
!script.value
|
||||
)
|
||||
return undefined;
|
||||
return [executable.value, script.value];
|
||||
export function buildQwenGroup(scriptPath: string, name: string): QwenGroup {
|
||||
return {
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
name,
|
||||
command: buildQwenCommand(scriptPath),
|
||||
shell: process.platform === 'win32' ? 'powershell' : 'bash',
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === "win32"
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
function exactCurrentGroup(group: QwenGroup): boolean {
|
||||
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||
}
|
||||
|
||||
function sameAgentId(left: string, right: string): boolean {
|
||||
return process.platform === "win32"
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
function exactLegacyGroup(group: QwenGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
|
||||
}
|
||||
|
||||
export function managedAgentId(
|
||||
handler: JsonObject,
|
||||
function currentReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
): string | undefined {
|
||||
if (
|
||||
Object.keys(handler).sort().join("\0") !== HANDLER_KEYS.join("\0") ||
|
||||
handler.type !== "command" ||
|
||||
handler.timeout !== HOOK_TIMEOUT_MS ||
|
||||
handler.shell !== (process.platform === "win32" ? "powershell" : "bash") ||
|
||||
typeof handler.command !== "string"
|
||||
)
|
||||
return undefined;
|
||||
const parsed = parseGeneratedCommand(handler.command);
|
||||
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
|
||||
const scriptPath = parsed[1];
|
||||
if (path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (
|
||||
!samePath(path.dirname(agentDirectory), path.join(os.homedir(), ".elydora"))
|
||||
)
|
||||
return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== "." && agentId !== ".." ? agentId : undefined;
|
||||
hookName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|name|shell|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.name === hookName
|
||||
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? qwenRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function exactOwnedGroup(group: QwenGroup, managedIndexes: number[]): boolean {
|
||||
return (
|
||||
Object.keys(group).sort().join("\0") === GROUP_KEYS.join("\0") &&
|
||||
group.matcher === "*" &&
|
||||
managedIndexes.length > 0 &&
|
||||
managedIndexes.length === group.hooks.length
|
||||
);
|
||||
function legacyReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|shell|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? qwenRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function managedRemovals(
|
||||
settings: QwenHookSettings,
|
||||
function managedReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
includeLegacy: boolean,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return currentReference(handler, scriptName, hookName)
|
||||
?? (includeLegacy ? legacyReference(handler, scriptName) : undefined);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT, GUARD_HOOK_NAME],
|
||||
['PostToolUse', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
|
||||
] as const;
|
||||
|
||||
export function managedQwenRemovals(
|
||||
hooks: QwenHooks,
|
||||
agentId?: string,
|
||||
): ManagedRemoval[] {
|
||||
const removals: ManagedRemoval[] = [];
|
||||
for (const [event, scriptName] of [
|
||||
["PreToolUse", GUARD_SCRIPT],
|
||||
["PostToolUse", AUDIT_SCRIPT],
|
||||
] as const) {
|
||||
(settings[event] ?? []).forEach((group, groupIndex) => {
|
||||
for (const [event, scriptName, hookName] of EVENT_CONTRACTS) {
|
||||
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const managedId = managedAgentId(handler, scriptName);
|
||||
return managedId &&
|
||||
(agentId === undefined || sameAgentId(managedId, agentId))
|
||||
const reference = managedReference(handler, scriptName, hookName, true);
|
||||
return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
@@ -355,7 +292,8 @@ export function managedRemovals(
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactOwnedGroup(group, handlerIndexes),
|
||||
removeGroup: (exactCurrentGroup(group) || exactLegacyGroup(group))
|
||||
&& handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -363,30 +301,47 @@ export function managedRemovals(
|
||||
return removals;
|
||||
}
|
||||
|
||||
function managedIds(groups: QwenGroup[], scriptName: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
function referencesForEvent(
|
||||
groups: readonly QwenGroup[],
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
): Map<string, QwenRuntimeReference[]> {
|
||||
const references = new Map<string, QwenRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactCurrentGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const agentId = managedAgentId(handler, scriptName);
|
||||
if (agentId) ids.add(agentId);
|
||||
const reference = currentReference(handler, scriptName, hookName);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
return references;
|
||||
}
|
||||
|
||||
export function runtimeContracts(
|
||||
settings: QwenHookSettings,
|
||||
): RuntimeContract[] {
|
||||
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
|
||||
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
|
||||
const root = path.join(os.homedir(), ".elydora");
|
||||
return [...guards]
|
||||
.filter((agentId) =>
|
||||
[...audits].some((auditId) => sameAgentId(auditId, agentId)),
|
||||
)
|
||||
.map((agentId) => ({
|
||||
agentId,
|
||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||
}));
|
||||
export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
|
||||
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME);
|
||||
const posts = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME);
|
||||
const failures = referencesForEvent(
|
||||
hooks.PostToolUseFailure ?? [],
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_HOOK_NAME,
|
||||
);
|
||||
const contracts: QwenRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!sameQwenPath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
|
||||
export { isObject, type JsonObject };
|
||||
@@ -0,0 +1,133 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
} from './qwen-contract.js';
|
||||
import { sameQwenPath } from './qwen-command.js';
|
||||
import {
|
||||
qwenDocumentLabel,
|
||||
type RenderedQwenDocument,
|
||||
} from './qwen-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireQwenHooksEnabled,
|
||||
type QwenSources,
|
||||
} from './qwen-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Qwen Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory';
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export type QwenRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedQwenInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightQwenInstallation(
|
||||
config: InstallConfig,
|
||||
sources: QwenSources,
|
||||
): Promise<QwenRuntimePaths> {
|
||||
requireQwenHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.user.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: QwenSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !sameQwenPath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareQwenInstallation(
|
||||
config: InstallConfig,
|
||||
sources: QwenSources,
|
||||
rendered: RenderedQwenDocument,
|
||||
): Promise<PreparedQwenInstallation> {
|
||||
await preflightQwenInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: qwenDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitQwenInstallation(
|
||||
prepared: PreparedQwenInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareQwenUninstall(
|
||||
sources: QwenSources,
|
||||
rendered: RenderedQwenDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: qwenDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitQwenUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
+87
-332
@@ -1,344 +1,99 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fsp from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse as parseDotenv } from "dotenv";
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { generateGuardScript } from './guard-template.js';
|
||||
import { generateHookScript } from './hook-template.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
type JsonObject,
|
||||
type RuntimeContract,
|
||||
isObject,
|
||||
} from "./qwen-contract.js";
|
||||
import {
|
||||
type QwenDocument,
|
||||
type RenderedDocument,
|
||||
createOwnedDocument,
|
||||
parseDocument,
|
||||
} from "./qwen-config.js";
|
||||
type QwenRuntimeContract,
|
||||
} from './qwen-contract.js';
|
||||
import { sameQwenAgentId, sameQwenPath } from './qwen-command.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
interface FileChange {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly original?: string;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface StagedChange {
|
||||
readonly change: FileChange;
|
||||
readonly tempPath?: string;
|
||||
readonly rollbackPath?: string;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return isObject(error) && error.code === code;
|
||||
}
|
||||
|
||||
async function readOptional(
|
||||
filePath: string,
|
||||
label: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await fsp.readFile(filePath, "utf-8");
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return undefined;
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
function requireNonEmptyString(value: unknown, field: string, configPath: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultQwenHome(): string {
|
||||
const homeDir = os.homedir();
|
||||
return homeDir
|
||||
? path.join(homeDir, ".qwen")
|
||||
: path.join(os.tmpdir(), ".qwen");
|
||||
}
|
||||
|
||||
function resolveStoragePath(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);
|
||||
}
|
||||
|
||||
async function qwenHomeFromEnvFile(
|
||||
filePath: string,
|
||||
): Promise<string | undefined> {
|
||||
const raw = await readOptional(filePath, "Qwen home environment");
|
||||
if (raw === undefined) return undefined;
|
||||
const value = parseDotenv(raw).QWEN_HOME;
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
export async function resolveQwenHome(): Promise<string> {
|
||||
const initialValue = process.env.QWEN_HOME;
|
||||
const initialHome = initialValue
|
||||
? resolveStoragePath(initialValue)
|
||||
: defaultQwenHome();
|
||||
if (Object.hasOwn(process.env, "QWEN_HOME")) return initialHome;
|
||||
const candidates = [
|
||||
path.join(initialHome, ".env"),
|
||||
path.join(path.dirname(initialHome), ".env"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const discovered = await qwenHomeFromEnvFile(candidate);
|
||||
if (discovered) return resolveStoragePath(discovered);
|
||||
}
|
||||
return initialHome;
|
||||
}
|
||||
|
||||
export async function readDocument(): Promise<QwenDocument> {
|
||||
const configPath = path.join(await resolveQwenHome(), "settings.json");
|
||||
const raw = await readOptional(configPath, "Qwen Code settings");
|
||||
return raw === undefined
|
||||
? createOwnedDocument(configPath)
|
||||
: parseDocument({ exists: true, filePath: configPath, raw });
|
||||
}
|
||||
|
||||
function fileChange(rendered: RenderedDocument): FileChange | undefined {
|
||||
if (!rendered.changed) return undefined;
|
||||
return {
|
||||
filePath: rendered.document.filePath,
|
||||
label: "Qwen Code settings",
|
||||
original: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
next: rendered.next,
|
||||
};
|
||||
}
|
||||
|
||||
async function unlinkOptional(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(
|
||||
filePath: string,
|
||||
contents: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fsp.open(filePath, "wx", 0o600);
|
||||
await handle.writeFile(contents, "utf-8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (closeError) {
|
||||
failures.push(asError(closeError));
|
||||
}
|
||||
}
|
||||
try {
|
||||
await unlinkOptional(filePath);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
if (failures.length > 1)
|
||||
throw new AggregateError(failures, `Stage ${label}`);
|
||||
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(change: FileChange): Promise<void> {
|
||||
const current = await readOptional(change.filePath, change.label);
|
||||
if (current !== change.original) {
|
||||
throw new Error(
|
||||
`${change.label} changed during installation: ${change.filePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stage(change: FileChange): Promise<StagedChange> {
|
||||
await assertUnchanged(change);
|
||||
const directory = path.dirname(change.filePath);
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const token = randomUUID();
|
||||
const tempPath =
|
||||
change.next === undefined
|
||||
? undefined
|
||||
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
|
||||
const rollbackPath =
|
||||
change.original === undefined
|
||||
? undefined
|
||||
: path.join(
|
||||
directory,
|
||||
`.${path.basename(change.filePath)}.${token}.rollback`,
|
||||
);
|
||||
try {
|
||||
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
|
||||
if (rollbackPath && change.next !== undefined) {
|
||||
await writeExclusive(
|
||||
rollbackPath,
|
||||
change.original!,
|
||||
`${change.label} rollback`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
for (const stagedPath of [tempPath, rollbackPath]) {
|
||||
if (!stagedPath) continue;
|
||||
try {
|
||||
await unlinkOptional(stagedPath);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
}
|
||||
if (failures.length > 1)
|
||||
throw new AggregateError(failures, `Stage ${change.label}`);
|
||||
throw error;
|
||||
}
|
||||
return { change, tempPath, rollbackPath, committed: false };
|
||||
}
|
||||
|
||||
async function commit(staged: StagedChange): Promise<void> {
|
||||
await assertUnchanged(staged.change);
|
||||
if (staged.change.next === undefined) {
|
||||
if (!staged.rollbackPath)
|
||||
throw new Error(`Missing rollback path for ${staged.change.label}`);
|
||||
await fsp.rename(staged.change.filePath, staged.rollbackPath);
|
||||
} else {
|
||||
if (!staged.tempPath)
|
||||
throw new Error(`Missing staged file for ${staged.change.label}`);
|
||||
await fsp.rename(staged.tempPath, staged.change.filePath);
|
||||
}
|
||||
staged.committed = true;
|
||||
}
|
||||
|
||||
async function rollback(staged: StagedChange): Promise<void> {
|
||||
if (!staged.committed) return;
|
||||
if (staged.change.original === undefined) {
|
||||
await unlinkOptional(staged.change.filePath);
|
||||
return;
|
||||
}
|
||||
if (!staged.rollbackPath)
|
||||
throw new Error(`Missing rollback data for ${staged.change.label}`);
|
||||
await fsp.rename(staged.rollbackPath, staged.change.filePath);
|
||||
}
|
||||
|
||||
async function cleanup(staged: StagedChange): Promise<void> {
|
||||
const stagedPaths = [staged.tempPath, staged.rollbackPath].filter(
|
||||
(filePath): filePath is string => filePath !== undefined,
|
||||
);
|
||||
await Promise.all(stagedPaths.map(unlinkOptional));
|
||||
}
|
||||
|
||||
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
||||
const change = fileChange(rendered);
|
||||
if (!change) return;
|
||||
const staged = await stage(change);
|
||||
try {
|
||||
await commit(staged);
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
try {
|
||||
await rollback(staged);
|
||||
} catch (rollbackError) {
|
||||
failures.push(asError(rollbackError));
|
||||
}
|
||||
try {
|
||||
await cleanup(staged);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
if (failures.length > 1)
|
||||
throw new AggregateError(failures, "Write Qwen Code settings");
|
||||
throw new Error(`Write Qwen Code settings: ${errorMessage(error)}`, {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
await cleanup(staged);
|
||||
}
|
||||
|
||||
export async function regularFileExists(
|
||||
filePath: string,
|
||||
label: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"))
|
||||
return false;
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireRuntime(
|
||||
filePath: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
if (!filePath) throw new Error(`${label} path is required`);
|
||||
if (!(await regularFileExists(filePath, label)))
|
||||
throw new Error(`${label} is missing: ${filePath}`);
|
||||
}
|
||||
|
||||
async function readRuntimeConfig(
|
||||
filePath: string,
|
||||
): Promise<JsonObject | undefined> {
|
||||
const raw = await readOptional(filePath, "Elydora runtime config");
|
||||
if (raw === undefined) return undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`,
|
||||
{
|
||||
cause: asError(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isObject(value))
|
||||
throw new Error(
|
||||
`Elydora runtime config at ${filePath} must contain a JSON object`,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(
|
||||
contracts: RuntimeContract[],
|
||||
function validateRuntimeConfig(
|
||||
config: JsonObject,
|
||||
contract: QwenRuntimeContract,
|
||||
configPath: string,
|
||||
): void {
|
||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||
if (extra) {
|
||||
throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||
}
|
||||
requireNonEmptyString(config.org_id, 'org_id', configPath);
|
||||
requireNonEmptyString(config.kid, 'kid', configPath);
|
||||
const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath);
|
||||
if (!sameQwenAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
|
||||
throw new Error(`Elydora runtime identity does not match Qwen Code hooks: ${configPath}`);
|
||||
}
|
||||
if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath);
|
||||
const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath);
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(rawBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||
|| !baseUrl.hostname
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||
const bytes = Buffer.from(contents, 'base64url');
|
||||
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runtimeContractExists(contract: QwenRuntimeContract): Promise<boolean> {
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
if (!sameQwenPath(path.dirname(agentDirectory), runtimeRoot)
|
||||
|| !sameQwenPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false;
|
||||
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) {
|
||||
return false;
|
||||
}
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const keyPath = path.join(agentDirectory, 'private.key');
|
||||
const [config, key, guard, audit] = await Promise.all([
|
||||
readPhysicalFile(configPath, 'Elydora runtime config'),
|
||||
readPhysicalFile(keyPath, 'Elydora private key'),
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (!config || !key || !guard || !audit) return false;
|
||||
const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`);
|
||||
validateRuntimeConfig(parsed, contract, configPath);
|
||||
validatePrivateKey(key.contents, keyPath);
|
||||
return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
|
||||
&& audit.contents === generateHookScript(
|
||||
AGENT_KEY,
|
||||
contract.agentId,
|
||||
{ nativePayload: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function qwenRuntimeFilesExist(
|
||||
contracts: readonly QwenRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
const runtimeConfig = await readRuntimeConfig(
|
||||
path.join(agentDirectory, "config.json"),
|
||||
);
|
||||
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
|
||||
const files = await Promise.all([
|
||||
regularFileExists(contract.guardPath, "Elydora guard runtime"),
|
||||
regularFileExists(contract.auditPath, "Elydora audit runtime"),
|
||||
]);
|
||||
if (files.every(Boolean)) return true;
|
||||
if (await runtimeContractExists(contract)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
+56
-31
@@ -1,49 +1,74 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from "./base.js";
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildGroup,
|
||||
runtimeContracts,
|
||||
AUDIT_HOOK_NAME,
|
||||
GUARD_HOOK_NAME,
|
||||
buildQwenGroup,
|
||||
qwenRuntimeContracts,
|
||||
type ManagedQwenEvent,
|
||||
type QwenGroup,
|
||||
type ToolEvent,
|
||||
} from "./qwen-contract.js";
|
||||
import { renderDocument } from "./qwen-config.js";
|
||||
} from './qwen-contract.js';
|
||||
import { renderQwenDocument } from './qwen-config.js';
|
||||
import {
|
||||
readDocument,
|
||||
requireRuntime,
|
||||
runtimeFilesExist,
|
||||
writeDocument,
|
||||
} from "./qwen-io.js";
|
||||
import { SUPPORTED_AGENTS } from "./registry.js";
|
||||
commitQwenInstallation,
|
||||
commitQwenUninstall,
|
||||
preflightQwenInstallation,
|
||||
prepareQwenInstallation,
|
||||
prepareQwenUninstall,
|
||||
} from './qwen-installation.js';
|
||||
import { qwenRuntimeFilesExist } from './qwen-io.js';
|
||||
import { readQwenSources } from './qwen-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedQwenEvent,
|
||||
QwenGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildQwenGroup(guardPath, GUARD_HOOK_NAME)],
|
||||
['PostToolUse', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||
['PostToolUseFailure', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||
]);
|
||||
}
|
||||
|
||||
export const qwenPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readQwenSources();
|
||||
await preflightQwenInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
if (!config.agentId) throw new Error("agentId is required");
|
||||
const document = await readDocument();
|
||||
await requireRuntime(config.guardScriptPath, "Elydora guard runtime");
|
||||
await requireRuntime(config.hookScriptPath, "Elydora audit runtime");
|
||||
const additions = new Map<ToolEvent, QwenGroup>([
|
||||
["PreToolUse", buildGroup(config.guardScriptPath)],
|
||||
["PostToolUse", buildGroup(config.hookScriptPath)],
|
||||
]);
|
||||
await writeDocument(renderDocument(document, undefined, additions));
|
||||
console.log(` Qwen Code: user hooks installed at ${document.filePath}`);
|
||||
console.log(" Qwen Code: run /hooks to review the Elydora hook changes.");
|
||||
const sources = await readQwenSources();
|
||||
const paths = await preflightQwenInstallation(config, sources);
|
||||
const rendered = renderQwenDocument(
|
||||
sources.user,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitQwenInstallation(
|
||||
await prepareQwenInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Qwen Code hooks: ${sources.user.filePath}`);
|
||||
console.log(' Qwen Code verification: run /hooks.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const document = await readDocument();
|
||||
if (!document.exists) return;
|
||||
await writeDocument(renderDocument(document, agentId, new Map()));
|
||||
const sources = await readQwenSources();
|
||||
const rendered = renderQwenDocument(sources.user, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitQwenUninstall(await prepareQwenUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const document = await readDocument();
|
||||
const contracts = runtimeContracts(document.hooks);
|
||||
const hookConfigured = !document.hooksDisabled && contracts.length > 0;
|
||||
const sources = await readQwenSources();
|
||||
const contracts = qwenRuntimeContracts(sources.user.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
? await runtimeFilesExist(contracts)
|
||||
? await qwenRuntimeFilesExist(contracts)
|
||||
: false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
@@ -51,7 +76,7 @@ export const qwenPlugin: AgentPlugin = {
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: document.filePath,
|
||||
configPath: sources.user.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user