From 52863399d5e0b5157beaa4b66d3c5ab826930e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 12:20:39 -0400 Subject: [PATCH] feat: sync Node Qwen Code hook integration --- AGENTS.md | 1 + integrations/catalog.json | 5 +- sdks/node/README.md | 3 + sdks/node/package-lock.json | 13 + sdks/node/package.json | 1 + sdks/node/src/cli.ts | 2 + sdks/node/src/plugins/qwen-config.ts | 237 +++++++++++++++ sdks/node/src/plugins/qwen-contract.ts | 392 ++++++++++++++++++++++++ sdks/node/src/plugins/qwen-io.ts | 344 +++++++++++++++++++++ sdks/node/src/plugins/qwen.ts | 57 ++++ sdks/node/src/plugins/registry.ts | 1 + sdks/node/test-support/qwen.mjs | 188 ++++++++++++ sdks/node/test/qwen-plugin.test.mjs | 398 +++++++++++++++++++++++++ test/integration-catalog.test.mjs | 19 ++ 14 files changed, 1659 insertions(+), 2 deletions(-) create mode 100644 sdks/node/src/plugins/qwen-config.ts create mode 100644 sdks/node/src/plugins/qwen-contract.ts create mode 100644 sdks/node/src/plugins/qwen-io.ts create mode 100644 sdks/node/src/plugins/qwen.ts create mode 100644 sdks/node/test-support/qwen.mjs create mode 100644 sdks/node/test/qwen-plugin.test.mjs diff --git a/AGENTS.md b/AGENTS.md index fb10212..a087ca3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ This repository publishes the open-source server, console, integration catalog, - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes. +- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index 4769704..4adc4ce 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -462,6 +462,7 @@ "integration_mode": "command_hooks", "config_format": "json", "config_paths": [ + "$QWEN_HOME/settings.json", "~/.qwen/settings.json", ".qwen/settings.json" ], @@ -478,8 +479,8 @@ "mechanism": "exit_code_2", "failure_mode": "fail_open" }, - "adapters": { "node": false, "python": false, "go": false }, - "delivery_state": "planned", + "adapters": { "node": true, "python": false, "go": false }, + "delivery_state": "partial", "source_url": "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/" } ], diff --git a/sdks/node/README.md b/sdks/node/README.md index 50a6cf8..2d5d4b5 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -62,6 +62,8 @@ Kimi installation writes the strict hook contract to each detected runtime: Kimi Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. +Qwen Code installation writes user hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). User-level `.qwen/.env` takes precedence over `~/.env` when it defines `QWEN_HOME`; explicit process environment values take highest precedence. Workspace settings remain unchanged. Run `/hooks` to review the definitions. `disableAllHooks` and `--safe-mode` suspend hook execution. + Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory. Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. @@ -86,6 +88,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Factory Droid | `droid` | | Kimi Code | `kimi` | | Grok Build | `grok` | +| Qwen Code | `qwen` | | Cursor | `cursor` | | Gemini CLI | `gemini` | | Kiro CLI | `kirocli` | diff --git a/sdks/node/package-lock.json b/sdks/node/package-lock.json index b9fd6f0..36a2002 100644 --- a/sdks/node/package-lock.json +++ b/sdks/node/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@decimalturn/toml-patch": "^2.0.0", + "dotenv": "^17.4.2", "jsonc-parser": "^3.3.1" }, "bin": { @@ -42,6 +43,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", diff --git a/sdks/node/package.json b/sdks/node/package.json index 298786d..144d608 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@decimalturn/toml-patch": "^2.0.0", + "dotenv": "^17.4.2", "jsonc-parser": "^3.3.1" } } diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index d099a3e..c8a52f6 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -20,6 +20,7 @@ import { clinePlugin } from './plugins/cline.js'; import { droidPlugin } from './plugins/droid.js'; import { kimiPlugin } from './plugins/kimi.js'; import { grokPlugin } from './plugins/grok.js'; +import { qwenPlugin } from './plugins/qwen.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); @@ -38,6 +39,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['droid', droidPlugin], ['kimi', kimiPlugin], ['grok', grokPlugin], + ['qwen', qwenPlugin], ]); // --------------------------------------------------------------------------- diff --git a/sdks/node/src/plugins/qwen-config.ts b/sdks/node/src/plugins/qwen-config.ts new file mode 100644 index 0000000..5b8c4af --- /dev/null +++ b/sdks/node/src/plugins/qwen-config.ts @@ -0,0 +1,237 @@ +import { + applyEdits, + modify, + parse, + parseTree, + printParseErrorCode, + type FormattingOptions, + type JSONPath, + type Node, + type ParseError, +} from "jsonc-parser"; +import { + TOOL_EVENTS, + type JsonObject, + type QwenGroup, + type QwenHookSettings, + type ToolEvent, + isObject, + managedRemovals, + readHooks, +} from "./qwen-contract.js"; + +export const OWNED_FILE_MARKER = "// Managed by Elydora"; + +export interface QwenDocument { + readonly filePath: string; + readonly exists: boolean; + readonly raw: string; + readonly root: JsonObject; + readonly hooks: QwenHookSettings; + readonly hasHooksContainer: boolean; + readonly hooksDisabled: boolean; + readonly ownedFile: boolean; +} + +export interface RenderedDocument { + readonly document: QwenDocument; + readonly changed: boolean; + readonly next?: string; +} + +interface DocumentOptions { + readonly exists: boolean; + readonly filePath: string; + readonly raw: string; +} + +function rejectDuplicateKeys( + node: Node | undefined, + label: string, + path: string[] = [], +): void { + if (!node) return; + if (node.type === "object") { + const keys = new Set(); + 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(".")}"`, + ); + keys.add(key); + rejectDuplicateKeys(property.children?.[1], label, location); + } + return; + } + if (node.type === "array") { + for (const child of node.children ?? []) + rejectDuplicateKeys(child, label, path); + } +} + +function parseJsonWithComments(raw: string, label: string): JsonObject { + const errors: ParseError[] = []; + const value: unknown = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: false, + }); + if (errors.length > 0) { + const details = errors + .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, + ); + 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" + ) { + 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"`) + : {}; + return { + ...options, + root, + hooks, + hasHooksContainer, + hooksDisabled: root.disableAllHooks === true, + ownedFile: options.raw.startsWith(OWNED_FILE_MARKER), + }; +} + +export function createOwnedDocument(filePath: string): QwenDocument { + return parseDocument({ + exists: false, + filePath, + raw: `${OWNED_FILE_MARKER}\n{}\n`, + }); +} + +function formatting(raw: string): FormattingOptions { + const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; + const insertSpaces = !indentation?.includes("\t"); + return { + eol: raw.includes("\r\n") ? "\r\n" : "\n", + insertSpaces, + tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, + }; +} + +function change( + raw: string, + jsonPath: JSONPath, + value: unknown, + isArrayInsertion = false, +): string { + return applyEdits( + raw, + modify(raw, jsonPath, value, { + formattingOptions: formatting(raw), + isArrayInsertion, + }), + ); +} + +function currentDocument(document: QwenDocument, raw: string): QwenDocument { + return parseDocument({ + exists: document.exists, + filePath: document.filePath, + raw, + }); +} + +function removeManagedEntries( + document: QwenDocument, + raw: string, + agentId?: string, +): string { + const removals = managedRemovals(document.hooks, agentId); + for (const event of TOOL_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]; + if (removal.removeGroup) { + raw = change(raw, groupPath, undefined); + continue; + } + for (const handlerIndex of [...removal.handlerIndexes].sort( + (left, right) => right - left, + )) { + 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); + } + } + } + const current = currentDocument(document, raw); + if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { + raw = change(raw, ["hooks"], undefined); + } + return raw; +} + +function appendGroup( + document: QwenDocument, + raw: string, + event: ToolEvent, + 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], [group]); +} + +export function renderDocument( + document: QwenDocument, + agentId: string | undefined, + additions: ReadonlyMap, +): RenderedDocument { + let raw = removeManagedEntries(document, document.raw, agentId); + for (const event of TOOL_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 + ) { + return { document, changed: true, next: undefined }; + } + return { document, changed: raw !== document.raw, next: raw }; +} diff --git a/sdks/node/src/plugins/qwen-contract.ts b/sdks/node/src/plugins/qwen-contract.ts new file mode 100644 index 0000000..5a5463e --- /dev/null +++ b/sdks/node/src/plugins/qwen-contract.ts @@ -0,0 +1,392 @@ +import os from "node:os"; +import path from "node:path"; + +export const AGENT_KEY = "qwen"; +export const GUARD_SCRIPT = "guard.js"; +export const AUDIT_SCRIPT = "hook.js"; +export const HOOK_TIMEOUT_MS = 10_000; +export const TOOL_EVENTS = ["PreToolUse", "PostToolUse"] 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 CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]); +const HANDLER_KEYS = ["command", "shell", "timeout", "type"]; +const GROUP_KEYS = ["hooks", "matcher"]; + +export type JsonObject = Record; +export type ToolEvent = (typeof TOOL_EVENTS)[number]; + +export interface QwenCommandHandler extends JsonObject { + readonly type: "command"; + readonly command: string; + readonly timeout?: number; + readonly shell?: "bash" | "powershell"; +} + +export interface QwenGroup extends JsonObject { + readonly matcher?: string; + readonly sequential?: boolean; + readonly hooks: JsonObject[]; +} + +export interface QwenHookSettings extends JsonObject { + readonly PreToolUse?: QwenGroup[]; + readonly PostToolUse?: QwenGroup[]; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +export interface ManagedRemoval { + readonly event: ToolEvent; + readonly groupIndex: number; + readonly handlerIndexes: 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, 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, 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]; + 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`); + } +} + +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) + ) { + throw new Error(`${label} timeout must be a non-negative finite number`); + } + for (const key of ["name", "description", "statusMessage", "source"]) { + optionalString(value, key, label); + } + if (value.type === "command") { + if (typeof value.command !== "string" || value.command.length === 0) { + throw new Error(`${label} command must be a non-empty string`); + } + optionalStringMap(value, "env", label); + optionalBoolean(value, "async", label); + if ( + value.shell !== undefined && + value.shell !== "bash" && + value.shell !== "powershell" + ) { + 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`); + } + 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`); + } + optionalString(value, "model", label); + } + return value; +} + +function validateGroup( + value: unknown, + label: string, + index: number, +): QwenGroup { + const location = `${label}[${index}]`; + if (!isObject(value)) throw new Error(`${location} 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 (value.sequential !== undefined && typeof value.sequential !== "boolean") { + throw new Error(`${location} 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; +} + +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; + continue; + } + if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) { + value += "'"; + index += 5; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +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]; +} + +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 sameAgentId(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +export function managedAgentId( + handler: JsonObject, + 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; +} + +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 + ); +} + +export function managedRemovals( + settings: QwenHookSettings, + agentId?: string, +): ManagedRemoval[] { + const removals: ManagedRemoval[] = []; + for (const [event, scriptName] of [ + ["PreToolUse", GUARD_SCRIPT], + ["PostToolUse", AUDIT_SCRIPT], + ] as const) { + (settings[event] ?? []).forEach((group, groupIndex) => { + const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { + const managedId = managedAgentId(handler, scriptName); + return managedId && + (agentId === undefined || sameAgentId(managedId, agentId)) + ? [handlerIndex] + : []; + }); + if (handlerIndexes.length > 0) { + removals.push({ + event, + groupIndex, + handlerIndexes, + removeGroup: exactOwnedGroup(group, handlerIndexes), + }); + } + }); + } + return removals; +} + +function managedIds(groups: QwenGroup[], scriptName: string): Set { + const ids = new Set(); + for (const group of groups) { + for (const handler of group.hooks) { + const agentId = managedAgentId(handler, scriptName); + if (agentId) ids.add(agentId); + } + } + return ids; +} + +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), + })); +} diff --git a/sdks/node/src/plugins/qwen-io.ts b/sdks/node/src/plugins/qwen-io.ts new file mode 100644 index 0000000..2dc6223 --- /dev/null +++ b/sdks/node/src/plugins/qwen-io.ts @@ -0,0 +1,344 @@ +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 { + AGENT_KEY, + type JsonObject, + type RuntimeContract, + isObject, +} from "./qwen-contract.js"; +import { + type QwenDocument, + type RenderedDocument, + createOwnedDocument, + parseDocument, +} from "./qwen-config.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 { + 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 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 { + 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 { + 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 { + 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 { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +async function writeExclusive( + filePath: string, + contents: string, + label: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[], +): Promise { + 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; + } + return false; +} diff --git a/sdks/node/src/plugins/qwen.ts b/sdks/node/src/plugins/qwen.ts new file mode 100644 index 0000000..3726251 --- /dev/null +++ b/sdks/node/src/plugins/qwen.ts @@ -0,0 +1,57 @@ +import type { AgentPlugin, InstallConfig, PluginStatus } from "./base.js"; +import { + AGENT_KEY, + buildGroup, + runtimeContracts, + type QwenGroup, + type ToolEvent, +} from "./qwen-contract.js"; +import { renderDocument } from "./qwen-config.js"; +import { + readDocument, + requireRuntime, + runtimeFilesExist, + writeDocument, +} from "./qwen-io.js"; +import { SUPPORTED_AGENTS } from "./registry.js"; + +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +export const qwenPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + 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([ + ["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."); + }, + + async uninstall(agentId?: string): Promise { + const document = await readDocument(); + if (!document.exists) return; + await writeDocument(renderDocument(document, agentId, new Map())); + }, + + async status(): Promise { + const document = await readDocument(); + const contracts = runtimeContracts(document.hooks); + const hookConfigured = !document.hooksDisabled && contracts.length > 0; + const hookScriptExists = hookConfigured + ? await runtimeFilesExist(contracts) + : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: document.filePath, + }; + }, +}; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index 03428ea..7a4c323 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -19,4 +19,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }], ['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }], ['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }], + ['qwen', { name: 'Qwen Code', configDir: '~/.qwen', configFile: 'settings.json' }], ]); diff --git a/sdks/node/test-support/qwen.mjs b/sdks/node/test-support/qwen.mjs new file mode 100644 index 0000000..3440f53 --- /dev/null +++ b/sdks/node/test-support/qwen.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse } from "jsonc-parser"; + +const pluginModuleUrl = pathToFileURL( + path.resolve("dist/plugins/qwen.js"), +).href; + +export const registryModuleUrl = pathToFileURL( + path.resolve("dist/plugins/registry.js"), +).href; +export const cliPath = path.resolve("dist/cli.js"); +export const expectedShell = + process.platform === "win32" ? "powershell" : "bash"; + +export function runNode(args, env, input = "", cwd) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function childEnv(fixture, overrides = {}) { + const env = { + ...process.env, + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ...overrides, + }; + if (!Object.hasOwn(overrides, "QWEN_HOME")) delete env.QWEN_HOME; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete env[key]; + } + return env; +} + +export function runPlugin(fixture, method, argument, overrides = {}) { + const script = ` + import { qwenPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await qwenPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ["--input-type=module", "--eval", script], + childEnv(fixture, { + ...overrides, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }), + "", + fixture.workspaceDir, + ); +} + +function quoteShell(value) { + return process.platform === "win32" + ? `'${value.replaceAll("'", "''")}'` + : `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function generatedCommand(scriptPath) { + const invocation = `${quoteShell(process.execPath)} ${quoteShell(scriptPath)}`; + return process.platform === "win32" + ? `& ${invocation}; exit $LASTEXITCODE` + : invocation; +} + +export function runHook(handler, input) { + const command = process.platform === "win32" ? "powershell" : "bash"; + const args = + process.platform === "win32" + ? ["-NoProfile", "-NonInteractive", "-Command", handler.command] + : ["-c", handler.command]; + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function parseSettings(raw) { + const errors = []; + const value = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: false, + }); + assert.deepEqual(errors, []); + return value; +} + +export function managedHandler(settings, event, scriptPath) { + const command = generatedCommand(scriptPath); + for (const group of settings.hooks?.[event] ?? []) { + const handler = group.hooks.find( + (candidate) => candidate.command === command, + ); + if (handler) return handler; + } + return undefined; +} + +export async function createFixture({ + existingSettings, + guardSource, + hookSource, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "elydora-qwen-")); + const homeDir = path.join(rootDir, "home with spaces and 'quote"); + const workspaceDir = path.join(homeDir, "workspace"); + const agentDir = path.join(homeDir, ".elydora", "agent-1"); + const configPath = path.join(homeDir, ".qwen", "settings.json"); + const guardScriptPath = path.join(agentDir, "guard.js"); + const hookScriptPath = path.join(agentDir, "hook.js"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + guardScriptPath, + guardSource ?? + "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", + ); + await writeFile(hookScriptPath, hookSource ?? "process.exit(0);\n"); + await writeFile( + path.join(agentDir, "config.json"), + JSON.stringify({ agent_id: "agent-1", agent_name: "qwen" }), + ); + if (existingSettings !== undefined) { + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile( + configPath, + typeof existingSettings === "string" + ? existingSettings + : `${JSON.stringify(existingSettings, null, 2)}\n`, + ); + } + return { + agentDir, + configPath, + guardScriptPath, + homeDir, + hookScriptPath, + rootDir, + workspaceDir, + async install(overrides = {}) { + return runPlugin( + this, + "install", + { + agentName: "qwen", + agentId: "agent-1", + guardScriptPath, + hookScriptPath, + }, + overrides, + ); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} diff --git a/sdks/node/test/qwen-plugin.test.mjs b/sdks/node/test/qwen-plugin.test.mjs new file mode 100644 index 0000000..91280fe --- /dev/null +++ b/sdks/node/test/qwen-plugin.test.mjs @@ -0,0 +1,398 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + childEnv, + cliPath, + createFixture, + expectedShell, + generatedCommand, + managedHandler, + parseSettings, + registryModuleUrl, + runHook, + runNode, + runPlugin, +} from "../test-support/qwen.mjs"; + +test("Qwen Code is registered in the SDK and CLI", async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get("qwen"), { + name: "Qwen Code", + configDir: "~/.qwen", + configFile: "settings.json", + }); + const fixture = await createFixture(); + try { + const result = await runNode( + ["--no-warnings", cliPath, "status"], + childEnv(fixture), + "", + fixture.workspaceDir, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Qwen Code \(qwen\)/); + } finally { + await fixture.close(); + } +}); + +test("Qwen install preserves JSONC settings and is idempotent", async () => { + const existing = [ + "{", + " // Keep this user preference.", + ' "theme": "GitHub",', + ' "hooks": {', + ' "SessionStart": [{ "hooks": [{ "type": "command", "command": "session-hook" }] }],', + ' "PreToolUse": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]', + " }", + "}", + "", + ].join("\r\n"); + const fixture = await createFixture({ existingSettings: existing }); + const workspaceSettings = path.join( + fixture.workspaceDir, + ".qwen", + "settings.json", + ); + await mkdir(path.dirname(workspaceSettings), { recursive: true }); + await writeFile(workspaceSettings, '{ "owner": "workspace" }\n'); + try { + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /run \/hooks/i); + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + const raw = await readFile(fixture.configPath, "utf-8"); + const settings = parseSettings(raw); + assert.match(raw, /Keep this user preference/); + assert.match(raw, /\r\n/); + assert.equal(settings.theme, "GitHub"); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + "session-hook", + ); + assert.equal(settings.hooks.PreToolUse.length, 2); + assert.equal(settings.hooks.PostToolUse.length, 1); + for (const [event, scriptPath] of [ + ["PreToolUse", fixture.guardScriptPath], + ["PostToolUse", fixture.hookScriptPath], + ]) { + const handler = managedHandler(settings, event, scriptPath); + assert.deepEqual(Object.keys(handler).sort(), [ + "command", + "shell", + "timeout", + "type", + ]); + assert.equal(handler.type, "command"); + assert.equal(handler.shell, expectedShell); + assert.equal(handler.timeout, 10_000); + } + assert.equal( + await readFile(workspaceSettings, "utf-8"), + '{ "owner": "workspace" }\n', + ); + } finally { + await fixture.close(); + } +}); + +test("Qwen resolves QWEN_HOME with official user env precedence", async () => { + const fixture = await createFixture(); + const firstHome = path.join(fixture.rootDir, "first qwen home"); + const secondHome = path.join(fixture.rootDir, "second qwen home"); + await mkdir(path.join(fixture.homeDir, ".qwen"), { recursive: true }); + await writeFile( + path.join(fixture.homeDir, ".qwen", ".env"), + `QWEN_HOME=${firstHome}\n`, + ); + await writeFile( + path.join(fixture.homeDir, ".env"), + `QWEN_HOME=${secondHome}\n`, + ); + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const selected = path.join(firstHome, "settings.json"); + assert.ok( + managedHandler( + parseSettings(await readFile(selected, "utf-8")), + "PreToolUse", + fixture.guardScriptPath, + ), + ); + await assert.rejects(readFile(path.join(secondHome, "settings.json")), { + code: "ENOENT", + }); + await assert.rejects(readFile(fixture.configPath), { code: "ENOENT" }); + const status = await runPlugin(fixture, "status", null); + assert.equal(JSON.parse(status.stdout).configPath, selected); + } finally { + await fixture.close(); + } +}); + +test("Qwen resolves explicit relative, tilde, and empty QWEN_HOME values", async () => { + for (const [value, expectedPath] of [ + [ + "relative-qwen", + (fixture) => + path.join(fixture.workspaceDir, "relative-qwen", "settings.json"), + ], + [ + "~/custom-qwen", + (fixture) => path.join(fixture.homeDir, "custom-qwen", "settings.json"), + ], + ["", (fixture) => fixture.configPath], + ]) { + const fixture = await createFixture(); + await mkdir(path.join(fixture.homeDir, ".qwen"), { recursive: true }); + await writeFile( + path.join(fixture.homeDir, ".qwen", ".env"), + `QWEN_HOME=${path.join(fixture.rootDir, "ignored-qwen-home")}\n`, + ); + try { + const result = await fixture.install({ QWEN_HOME: value }); + assert.equal(result.code, 0, result.stderr); + assert.ok( + parseSettings(await readFile(expectedPath(fixture), "utf-8")).hooks, + ); + } finally { + await fixture.close(); + } + } +}); + +test("Qwen hooks block freezes and forward official input", async () => { + const capturePath = path.join( + os.tmpdir(), + `elydora-qwen-event-${process.pid}-${Date.now()}.json`, + ); + const hookSource = ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); + `; + const fixture = await createFixture({ hookSource }); + try { + const installed = await fixture.install(); + assert.equal(installed.code, 0, installed.stderr); + const settings = parseSettings(await readFile(fixture.configPath, "utf-8")); + const guard = managedHandler( + settings, + "PreToolUse", + fixture.guardScriptPath, + ); + const audit = managedHandler( + settings, + "PostToolUse", + fixture.hookScriptPath, + ); + const preInput = JSON.stringify({ + session_id: "session-1", + transcript_path: "transcript.jsonl", + cwd: fixture.workspaceDir, + hook_event_name: "PreToolUse", + timestamp: "2026-07-19T00:00:00.000Z", + tool_name: "run_shell_command", + tool_input: { command: "echo test" }, + }); + const guardResult = await runHook(guard, preInput); + assert.equal(guardResult.code, 2, guardResult.stderr); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + const postInput = preInput.replace("PreToolUse", "PostToolUse"); + const auditResult = await runHook(audit, postInput); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.equal(await readFile(capturePath, "utf-8"), postInput); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test("Qwen status requires enabled hooks and complete runtimes", async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + let status = JSON.parse((await runPlugin(fixture, "status", null)).stdout); + assert.equal(status.installed, true); + const settings = parseSettings(await readFile(fixture.configPath, "utf-8")); + await writeFile( + fixture.configPath, + `${JSON.stringify({ ...settings, disableAllHooks: true }, null, 2)}\n`, + ); + status = JSON.parse((await runPlugin(fixture, "status", null)).stdout); + assert.equal(status.installed, false); + assert.equal(status.hookConfigured, false); + await writeFile( + fixture.configPath, + `${JSON.stringify({ ...settings, disableAllHooks: false }, null, 2)}\n`, + ); + await rm(fixture.guardScriptPath); + status = JSON.parse((await runPlugin(fixture, "status", null)).stdout); + assert.equal(status.installed, false); + assert.equal(status.hookConfigured, true); + assert.equal(status.hookScriptExists, false); + } finally { + await fixture.close(); + } +}); + +test("Qwen uninstall removes exact ownership and preserves user hooks", async () => { + const fixture = await createFixture({ + existingSettings: { $version: 4, owner: "user" }, + }); + try { + assert.equal((await fixture.install()).code, 0); + const settings = parseSettings(await readFile(fixture.configPath, "utf-8")); + const managed = settings.hooks.PreToolUse.at(-1); + managed.hooks.push({ type: "command", command: "user-command" }); + settings.hooks.PreToolUse.push({ + matcher: "*", + hooks: [ + { + type: "command", + command: generatedCommand( + path.join(fixture.homeDir, ".elydora", "agent-10", "guard.js"), + ), + shell: expectedShell, + timeout: 10_000, + }, + ], + }); + await writeFile( + fixture.configPath, + `${JSON.stringify(settings, null, 2)}\n`, + ); + const before = await readFile(fixture.configPath, "utf-8"); + assert.equal( + (await runPlugin(fixture, "uninstall", "other-agent")).code, + 0, + ); + assert.equal(await readFile(fixture.configPath, "utf-8"), before); + const uninstallId = process.platform === "win32" ? "AGENT-1" : "agent-1"; + const result = await runPlugin(fixture, "uninstall", uninstallId); + assert.equal(result.code, 0, result.stderr); + const remaining = parseSettings( + await readFile(fixture.configPath, "utf-8"), + ); + assert.equal(remaining.$version, 4); + assert.equal(remaining.owner, "user"); + assert.equal(remaining.hooks.PreToolUse.length, 2); + assert.equal( + remaining.hooks.PreToolUse[0].hooks[0].command, + "user-command", + ); + assert.match(JSON.stringify(remaining), /agent-10/); + assert.equal(remaining.hooks.PostToolUse, undefined); + } finally { + await fixture.close(); + } +}); + +test("Qwen removes an Elydora-owned settings file on uninstall", async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + assert.match( + await readFile(fixture.configPath, "utf-8"), + /^\/\/ Managed by Elydora/, + ); + const result = await runPlugin(fixture, "uninstall", "agent-1"); + assert.equal(result.code, 0, result.stderr); + await assert.rejects(readFile(fixture.configPath), { code: "ENOENT" }); + } finally { + await fixture.close(); + } +}); + +test("Qwen rejects malformed settings before writes", async () => { + const cases = [ + "{ malformed", + "[]", + '{ "owner": true, }', + '{ "hooks": {}, "hooks": {} }', + '{ "disableAllHooks": "yes" }', + '{ "hooks": [] }', + '{ "hooks": { "UnknownEvent": [] } }', + '{ "hooks": { "PreToolUse": null } }', + '{ "hooks": { "PreToolUse": [null] } }', + '{ "hooks": { "PreToolUse": [{ "matcher": "[", "hooks": [] }] } }', + '{ "hooks": { "PreToolUse": [{ "sequential": "yes", "hooks": [] }] } }', + '{ "hooks": { "PreToolUse": [{ "hooks": null }] } }', + '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }', + '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }', + '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function", "command": "x" }] }] } }', + '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": "ten" }] }] } }', + ]; + for (const existingSettings of cases) { + const fixture = await createFixture({ existingSettings }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1, `${existingSettings}\n${result.stderr}`); + assert.match(result.stderr, /Qwen (Code settings|hooks)/i); + assert.equal( + await readFile(fixture.configPath, "utf-8"), + existingSettings, + ); + const names = await readdir(path.dirname(fixture.configPath)); + assert.equal( + names.some((name) => name.endsWith(".tmp")), + false, + ); + } finally { + await fixture.close(); + } + } +}); + +test("Qwen fails on unreadable home env routing and missing runtimes", async () => { + const envFixture = await createFixture(); + await mkdir(path.join(envFixture.homeDir, ".qwen", ".env"), { + recursive: true, + }); + try { + const result = await envFixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /Qwen home environment/i); + await assert.rejects(readFile(envFixture.configPath), { code: "ENOENT" }); + } finally { + await envFixture.close(); + } + + const runtimeFixture = await createFixture(); + await rm(runtimeFixture.guardScriptPath); + try { + const result = await runtimeFixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects(readFile(runtimeFixture.configPath), { + code: "ENOENT", + }); + } finally { + await runtimeFixture.close(); + } +}); + +test("Qwen status surfaces malformed runtime metadata and leaves no staging files", async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + assert.equal( + (await readdir(path.dirname(fixture.configPath))).some((name) => + name.endsWith(".tmp"), + ), + false, + ); + await writeFile(path.join(fixture.agentDir, "config.json"), "{ malformed"); + const status = await runPlugin(fixture, "status", null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); diff --git a/test/integration-catalog.test.mjs b/test/integration-catalog.test.mjs index 92400af..6b01b1e 100644 --- a/test/integration-catalog.test.mjs +++ b/test/integration-catalog.test.mjs @@ -368,4 +368,23 @@ test('high-drift providers retain their verified hook contracts', async () => { mechanism: 'exit_code_2', failure_mode: 'fail_open', }); + assert.equal(providers.get('qwen').config_format, 'json'); + assert.deepEqual(providers.get('qwen').config_paths, [ + '$QWEN_HOME/settings.json', + '~/.qwen/settings.json', + '.qwen/settings.json', + ]); + assert.deepEqual(providers.get('qwen').events, { + before_tool: 'PreToolUse', + after_tool: 'PostToolUse', + }); + assert.deepEqual(providers.get('qwen').event_fields, { + name: 'tool_name', + input: 'tool_input', + session: 'session_id', + }); + assert.deepEqual(providers.get('qwen').blocking, { + mechanism: 'exit_code_2', + failure_mode: 'fail_open', + }); });