feat: add native Qwen Code hook integration
This commit is contained in:
1 parent
3999ed0f44
commit
d59b0101b1
12 files changed
+1637
No files matched your search
@@ -26,6 +26,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
|||||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
||||||
- 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 and require `/hooks` review after external changes.
|
- 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 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.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ npx elydora install \
|
|||||||
| Factory Droid | `droid` |
|
| Factory Droid | `droid` |
|
||||||
| Kimi Code | `kimi` |
|
| Kimi Code | `kimi` |
|
||||||
| Grok Build | `grok` |
|
| Grok Build | `grok` |
|
||||||
|
| Qwen Code | `qwen` |
|
||||||
| Cursor | `cursor` |
|
| Cursor | `cursor` |
|
||||||
| Gemini CLI | `gemini` |
|
| Gemini CLI | `gemini` |
|
||||||
| Kiro CLI | `kirocli` |
|
| Kiro CLI | `kirocli` |
|
||||||
@@ -90,6 +91,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.
|
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.
|
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`.
|
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`.
|
||||||
|
|||||||
Generated
+13
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@decimalturn/toml-patch": "^2.0.0",
|
"@decimalturn/toml-patch": "^2.0.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"jsonc-parser": "^3.3.1"
|
"jsonc-parser": "^3.3.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -42,6 +43,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/jsonc-parser": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@decimalturn/toml-patch": "^2.0.0",
|
"@decimalturn/toml-patch": "^2.0.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"jsonc-parser": "^3.3.1"
|
"jsonc-parser": "^3.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import { clinePlugin } from './plugins/cline.js';
|
|||||||
import { droidPlugin } from './plugins/droid.js';
|
import { droidPlugin } from './plugins/droid.js';
|
||||||
import { kimiPlugin } from './plugins/kimi.js';
|
import { kimiPlugin } from './plugins/kimi.js';
|
||||||
import { grokPlugin } from './plugins/grok.js';
|
import { grokPlugin } from './plugins/grok.js';
|
||||||
|
import { qwenPlugin } from './plugins/qwen.js';
|
||||||
|
|
||||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
|||||||
['droid', droidPlugin],
|
['droid', droidPlugin],
|
||||||
['kimi', kimiPlugin],
|
['kimi', kimiPlugin],
|
||||||
['grok', grokPlugin],
|
['grok', grokPlugin],
|
||||||
|
['qwen', qwenPlugin],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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<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(".")}"`,
|
||||||
|
);
|
||||||
|
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<ToolEvent, QwenGroup>,
|
||||||
|
): 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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<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 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[],
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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<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.");
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(agentId?: string): Promise<void> {
|
||||||
|
const document = await readDocument();
|
||||||
|
if (!document.exists) return;
|
||||||
|
await writeDocument(renderDocument(document, agentId, new Map()));
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -19,4 +19,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
|||||||
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
|
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
|
||||||
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
||||||
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
|
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
|
||||||
|
['qwen', { name: 'Qwen Code', configDir: '~/.qwen', configFile: 'settings.json' }],
|
||||||
]);
|
]);
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user