diff --git a/AGENTS.md b/AGENTS.md index 83fae6c..defbe97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,8 @@ This repository publishes the open-source server, console, integration catalog, - Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Python Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, and own exact native `preToolUse`, `postToolUse`, and `postToolUseFailure` commands. Forward native success and failure payloads, retain PowerShell exit code `2`, set every handler to `failClosed` with a bounded timeout, and commit the guard, audit script, runtime identity, private key, and hook configuration as one rollback-capable transaction. - Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, require exact generated runtime sources during status checks, and require `/hooks` review for the session snapshot and remote organization policy. -- Qwen Code `0.20.0` resolves 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. -- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. +- Resolve Qwen Code 0.20.0 user settings through explicit `QWEN_HOME`, then the initial Qwen home `.env`, then the OS home `.env`; when bootstrap discovers a redirected home, read that home's `.env` as well. Treat empty overrides as unset while preserving their process-level ownership semantics. Read system defaults, user settings, the active trusted workspace, system overrides, and trusted-folder rules with Qwen's documented precedence; write only the user source. Validate the current twenty-one event names and command, HTTP, and prompt handler schemas while preserving future event payloads. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups named `elydora-guard` and `elydora-audit` with ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate freeze and revocation through exit code `2`, and use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows. +- Commit Qwen Code user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction, with snapshot preconditions for every consulted routing, policy, trust, and settings source. Status requires physical files, exact runtime sources, a canonical private key, matching runtime identity, and an effective enabled state. Verify discovery with the official 0.20.0 loader, review `/hooks`, and restart active Qwen Code sessions. ## Mirror Workflow diff --git a/sdks/node/README.md b/sdks/node/README.md index 273eb03..eb67ce6 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -71,7 +71,7 @@ Kimi installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFail Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. -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. +Qwen Code 0.20.0 installation writes exact, named `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` command hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). Explicit environment values lead Qwen's home bootstrap, followed by `/.env` or `~/.qwen/.env`, then `~/.env`; a discovered home contributes its own `.env`. System defaults, trusted workspace settings, system overrides, and trust rules remain read-only and participate in effective `disableAllHooks` evaluation. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of every consulted source. Managed handlers use ten-second millisecond timeouts, preserve Qwen's complete native snake_case payload for success and failure events, and propagate frozen or revoked state through exit code `2`. Run `/hooks` after installation and restart active Qwen Code sessions. Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. diff --git a/sdks/node/src/plugins/qwen-command.ts b/sdks/node/src/plugins/qwen-command.ts new file mode 100644 index 0000000..15bc29c --- /dev/null +++ b/sdks/node/src/plugins/qwen-command.ts @@ -0,0 +1,122 @@ +import os from 'node:os'; +import path from 'node:path'; + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export interface QwenRuntimeReference { + readonly agentId: string; + readonly scriptPath: string; +} + +export function sameQwenPath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +export function sameQwenAgentId(left: string, right: string): boolean { + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function buildQwenCommand(scriptPath: string): string { + if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { + throw new Error('Qwen Code hook commands require absolute executable and script paths'); + } + if (process.platform === 'win32') { + return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; + } + return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parsePosixCommand(command: string): readonly [string, string] | undefined { + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parsePowerShellCommand(command: string): readonly [string, string] | undefined { + if (!command.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(command, 2); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(command, executable.next + 1); + if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; + return [executable.value, script.value]; +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath).toLowerCase(); + return basename === 'node' || basename === 'node.exe'; +} + +export function qwenRuntimeReference( + command: string, + scriptName: string, +): QwenRuntimeReference | undefined { + const parsed = process.platform === 'win32' + ? parsePowerShellCommand(command) + : parsePosixCommand(command); + if (!parsed + || !path.isAbsolute(parsed[0]) + || !isNodeExecutable(parsed[0]) + || !path.isAbsolute(parsed[1]) + || path.basename(parsed[1]) !== scriptName) return undefined; + const agentDirectory = path.dirname(parsed[1]); + if (!sameQwenPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + if (!agentId || agentId === '.' || agentId === '..') return undefined; + return { agentId, scriptPath: parsed[1] }; +} diff --git a/sdks/node/src/plugins/qwen-config.ts b/sdks/node/src/plugins/qwen-config.ts index 5b8c4af..d02d57f 100644 --- a/sdks/node/src/plugins/qwen-config.ts +++ b/sdks/node/src/plugins/qwen-config.ts @@ -8,70 +8,76 @@ import { type JSONPath, type Node, type ParseError, -} from "jsonc-parser"; +} from 'jsonc-parser'; +import type { FileSnapshot } from './managed-files.js'; import { - TOOL_EVENTS, + MANAGED_EVENTS, + managedQwenRemovals, + readQwenHooks, type JsonObject, + type ManagedQwenEvent, type QwenGroup, - type QwenHookSettings, - type ToolEvent, + type QwenHooks, isObject, - managedRemovals, - readHooks, -} from "./qwen-contract.js"; +} from './qwen-contract.js'; -export const OWNED_FILE_MARKER = "// Managed by Elydora"; +export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora'; + +export type QwenDocumentKind = 'system-defaults' | 'user' | 'workspace' | 'system'; export interface QwenDocument { + readonly kind: QwenDocumentKind; readonly filePath: string; readonly exists: boolean; readonly raw: string; + readonly snapshot?: FileSnapshot; readonly root: JsonObject; - readonly hooks: QwenHookSettings; + readonly hooks: QwenHooks; readonly hasHooksContainer: boolean; - readonly hooksDisabled: boolean; + readonly disableAllHooks?: boolean; + readonly folderTrustEnabled?: boolean; readonly ownedFile: boolean; } -export interface RenderedDocument { +export interface RenderedQwenDocument { readonly document: QwenDocument; readonly changed: boolean; readonly next?: string; } interface DocumentOptions { + readonly kind: QwenDocumentKind; readonly exists: boolean; readonly filePath: string; readonly raw: string; + readonly snapshot?: FileSnapshot; } function rejectDuplicateKeys( node: Node | undefined, label: string, - path: string[] = [], + location: string[] = [], ): void { if (!node) return; - if (node.type === "object") { + if (node.type === 'object') { const keys = new Set(); for (const property of node.children ?? []) { const key = String(property.children?.[0]?.value); - const location = [...path, key]; - if (keys.has(key)) - throw new Error( - `${label} contains duplicate field "${location.join(".")}"`, - ); + const childLocation = [...location, key]; + if (keys.has(key)) { + throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); + } keys.add(key); - rejectDuplicateKeys(property.children?.[1], label, location); + rejectDuplicateKeys(property.children?.[1], label, childLocation); } return; } - if (node.type === "array") { - for (const child of node.children ?? []) - rejectDuplicateKeys(child, label, path); + if (node.type === 'array') { + for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); } } -function parseJsonWithComments(raw: string, label: string): JsonObject { +export function parseQwenJsoncObject(raw: string, label: string): JsonObject { const errors: ParseError[] = []; const value: unknown = parse(raw, errors, { allowTrailingComma: false, @@ -79,60 +85,76 @@ function parseJsonWithComments(raw: string, label: string): JsonObject { }); if (errors.length > 0) { const details = errors - .map( - (error) => - `${printParseErrorCode(error.error)} at offset ${error.offset}`, - ) - .join(", "); + .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) + .join(', '); throw new Error(`Failed to parse ${label}: ${details}`); } if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - rejectDuplicateKeys( - parseTree(raw, [], { - allowTrailingComma: false, - disallowComments: false, - }), - label, - ); + rejectDuplicateKeys(parseTree(raw, [], { + allowTrailingComma: false, + disallowComments: false, + }), label); return value; } -export function parseDocument(options: DocumentOptions): QwenDocument { - const label = `Qwen Code settings at ${options.filePath}`; - const root = parseJsonWithComments(options.raw, label); - if ( - root.disableAllHooks !== undefined && - typeof root.disableAllHooks !== "boolean" - ) { +export function qwenSourceLabel(kind: QwenDocumentKind): string { + switch (kind) { + case 'system-defaults': return 'Qwen Code system defaults'; + case 'workspace': return 'Qwen Code workspace settings'; + case 'system': return 'Qwen Code system override settings'; + default: return 'Qwen Code user settings'; + } +} + +function readFolderTrustEnabled(root: JsonObject, label: string): boolean | undefined { + if (root.security === undefined) return undefined; + if (!isObject(root.security)) throw new Error(`${label} field "security" must be an object`); + if (root.security.folderTrust === undefined) return undefined; + if (!isObject(root.security.folderTrust)) { + throw new Error(`${label} field "security.folderTrust" must be an object`); + } + const enabled = root.security.folderTrust.enabled; + if (enabled !== undefined && typeof enabled !== 'boolean') { + throw new Error(`${label} field "security.folderTrust.enabled" must be a boolean`); + } + return enabled as boolean | undefined; +} + +export function parseQwenDocument(options: DocumentOptions): QwenDocument { + const label = `${qwenSourceLabel(options.kind)} at ${options.filePath}`; + const root = parseQwenJsoncObject(options.raw, label); + if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { throw new Error(`${label} field "disableAllHooks" must be a boolean`); } - const hasHooksContainer = Object.hasOwn(root, "hooks"); - const hooks = hasHooksContainer - ? readHooks(root.hooks, `${label} field "hooks"`) - : {}; + const hasHooksContainer = Object.hasOwn(root, 'hooks'); return { ...options, root, - hooks, + hooks: readQwenHooks(root.hooks), hasHooksContainer, - hooksDisabled: root.disableAllHooks === true, - ownedFile: options.raw.startsWith(OWNED_FILE_MARKER), + disableAllHooks: root.disableAllHooks as boolean | undefined, + folderTrustEnabled: readFolderTrustEnabled(root, label), + ownedFile: options.kind === 'user' && options.raw.startsWith(QWEN_OWNED_FILE_MARKER), }; } -export function createOwnedDocument(filePath: string): QwenDocument { - return parseDocument({ +export function createQwenDocument( + kind: QwenDocumentKind, + filePath: string, +): QwenDocument { + return parseQwenDocument({ + kind, exists: false, filePath, - raw: `${OWNED_FILE_MARKER}\n{}\n`, + raw: kind === 'user' ? `${QWEN_OWNED_FILE_MARKER}\n{}\n` : '{}\n', }); } function formatting(raw: string): FormattingOptions { const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; - const insertSpaces = !indentation?.includes("\t"); + const insertSpaces = !indentation?.includes('\t'); return { - eol: raw.includes("\r\n") ? "\r\n" : "\n", + eol: raw.includes('\r\n') ? '\r\n' : '\n', insertSpaces, tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, }; @@ -144,20 +166,19 @@ function change( value: unknown, isArrayInsertion = false, ): string { - return applyEdits( - raw, - modify(raw, jsonPath, value, { - formattingOptions: formatting(raw), - isArrayInsertion, - }), - ); + return applyEdits(raw, modify(raw, jsonPath, value, { + formattingOptions: formatting(raw), + isArrayInsertion, + })); } function currentDocument(document: QwenDocument, raw: string): QwenDocument { - return parseDocument({ + return parseQwenDocument({ + kind: document.kind, exists: document.exists, filePath: document.filePath, raw, + snapshot: document.snapshot, }); } @@ -166,13 +187,13 @@ function removeManagedEntries( raw: string, agentId?: string, ): string { - const removals = managedRemovals(document.hooks, agentId); - for (const event of TOOL_EVENTS) { + const removals = managedQwenRemovals(document.hooks, agentId); + for (const event of MANAGED_EVENTS) { const eventRemovals = removals .filter((removal) => removal.event === event) .sort((left, right) => right.groupIndex - left.groupIndex); for (const removal of eventRemovals) { - const groupPath: JSONPath = ["hooks", event, removal.groupIndex]; + const groupPath: JSONPath = ['hooks', event, removal.groupIndex]; if (removal.removeGroup) { raw = change(raw, groupPath, undefined); continue; @@ -180,19 +201,19 @@ function removeManagedEntries( for (const handlerIndex of [...removal.handlerIndexes].sort( (left, right) => right - left, )) { - raw = change(raw, [...groupPath, "hooks", handlerIndex], undefined); + raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); } } if (eventRemovals.length > 0) { const current = currentDocument(document, raw); if ((current.hooks[event] ?? []).length === 0) { - raw = change(raw, ["hooks", event], undefined); + raw = change(raw, ['hooks', event], undefined); } } } const current = currentDocument(document, raw); if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { - raw = change(raw, ["hooks"], undefined); + raw = change(raw, ['hooks'], undefined); } return raw; } @@ -200,38 +221,33 @@ function removeManagedEntries( function appendGroup( document: QwenDocument, raw: string, - event: ToolEvent, + event: ManagedQwenEvent, group: QwenGroup, ): string { const current = currentDocument(document, raw); if (current.hooks[event]) { - return change( - raw, - ["hooks", event, current.hooks[event]!.length], - group, - true, - ); + return change(raw, ['hooks', event, current.hooks[event].length], group, true); } - return change(raw, ["hooks", event], [group]); + return change(raw, ['hooks', event], [group]); } -export function renderDocument( +export function renderQwenDocument( document: QwenDocument, agentId: string | undefined, - additions: ReadonlyMap, -): RenderedDocument { + additions: ReadonlyMap, +): RenderedQwenDocument { let raw = removeManagedEntries(document, document.raw, agentId); - for (const event of TOOL_EVENTS) { + for (const event of MANAGED_EVENTS) { const group = additions.get(event); if (group) raw = appendGroup(document, raw, event, group); } const current = currentDocument(document, raw); - if ( - additions.size === 0 && - document.ownedFile && - Object.keys(current.root).length === 0 - ) { + if (additions.size === 0 && document.ownedFile && Object.keys(current.root).length === 0) { return { document, changed: true, next: undefined }; } return { document, changed: raw !== document.raw, next: raw }; } + +export function qwenDocumentLabel(document: QwenDocument): string { + return qwenSourceLabel(document.kind); +} diff --git a/sdks/node/src/plugins/qwen-contract.ts b/sdks/node/src/plugins/qwen-contract.ts index 5a5463e..af10742 100644 --- a/sdks/node/src/plugins/qwen-contract.ts +++ b/sdks/node/src/plugins/qwen-contract.ts @@ -1,352 +1,289 @@ -import os from "node:os"; -import path from "node:path"; +import { + buildQwenCommand, + qwenRuntimeReference, + sameQwenAgentId, + sameQwenPath, + type QwenRuntimeReference, +} from './qwen-command.js'; +import { isObject, type JsonObject } from './strict-json.js'; -export const AGENT_KEY = "qwen"; -export const GUARD_SCRIPT = "guard.js"; -export const AUDIT_SCRIPT = "hook.js"; +export const AGENT_KEY = 'qwen'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const GUARD_HOOK_NAME = 'elydora-guard'; +export const AUDIT_HOOK_NAME = 'elydora-audit'; export const HOOK_TIMEOUT_MS = 10_000; -export const TOOL_EVENTS = ["PreToolUse", "PostToolUse"] as const; +export const MANAGED_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', +] as const; -const EVENT_NAMES = new Set([ - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PostToolBatch", - "Notification", - "UserPromptSubmit", - "UserPromptExpansion", - "SessionStart", - "Stop", - "MessageDisplay", - "SubagentStart", - "SubagentStop", - "PreCompact", - "PostCompact", - "SessionEnd", - "PermissionRequest", - "PermissionDenied", - "StopFailure", - "TodoCreated", - "TodoCompleted", - "InstructionsLoaded", +const KNOWN_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PostToolBatch', + 'Notification', + 'UserPromptSubmit', + 'UserPromptExpansion', + 'SessionStart', + 'Stop', + 'MessageDisplay', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'SessionEnd', + 'PermissionRequest', + 'PermissionDenied', + 'StopFailure', + 'TodoCreated', + 'TodoCompleted', + 'InstructionsLoaded', ]); -const CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]); -const HANDLER_KEYS = ["command", "shell", "timeout", "type"]; -const GROUP_KEYS = ["hooks", "matcher"]; -export type JsonObject = Record; -export type ToolEvent = (typeof TOOL_EVENTS)[number]; +const REGEX_MATCHER_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'PermissionDenied', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'SessionStart', + 'SessionEnd', + 'StopFailure', + 'Notification', + 'InstructionsLoaded', + 'UserPromptExpansion', +]); -export interface QwenCommandHandler extends JsonObject { - readonly type: "command"; - readonly command: string; +export type ManagedQwenEvent = (typeof MANAGED_EVENTS)[number]; + +export interface QwenHandler extends JsonObject { + readonly type: 'command' | 'http' | 'prompt'; + readonly command?: string; + readonly name?: string; + readonly shell?: 'bash' | 'powershell'; readonly timeout?: number; - readonly shell?: "bash" | "powershell"; } export interface QwenGroup extends JsonObject { readonly matcher?: string; readonly sequential?: boolean; - readonly hooks: JsonObject[]; + readonly hooks: QwenHandler[]; } -export interface QwenHookSettings extends JsonObject { - readonly PreToolUse?: QwenGroup[]; - readonly PostToolUse?: QwenGroup[]; -} +export type QwenHooks = Record; -export interface RuntimeContract { +export interface QwenRuntimeContract { readonly agentId: string; readonly guardPath: string; readonly auditPath: string; } export interface ManagedRemoval { - readonly event: ToolEvent; + readonly event: ManagedQwenEvent; readonly groupIndex: number; - readonly handlerIndexes: number[]; + readonly handlerIndexes: readonly number[]; readonly removeGroup: boolean; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export function isObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -export function buildCommand(scriptPath: string): string { - if (process.platform === "win32") { - return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - } - return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; -} - -export function buildGroup(scriptPath: string): QwenGroup { - return { - matcher: "*", - hooks: [ - { - type: "command", - command: buildCommand(scriptPath), - shell: process.platform === "win32" ? "powershell" : "bash", - timeout: HOOK_TIMEOUT_MS, - }, - ], - }; -} - -function validateRegex(value: string, label: string): void { - if (value.trim() === "" || value.trim() === "*") return; - try { - new RegExp(value); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${label} must be a valid regular expression: ${message}`); +function optionalString(value: JsonObject, field: string, label: string): void { + if (value[field] !== undefined && typeof value[field] !== 'string') { + throw new Error(`${label} field "${field}" must be a string`); } } -function optionalString(value: JsonObject, key: string, label: string): void { - if (value[key] !== undefined && typeof value[key] !== "string") { - throw new Error(`${label} field "${key}" must be a string`); +function optionalBoolean(value: JsonObject, field: string, label: string): void { + if (value[field] !== undefined && typeof value[field] !== 'boolean') { + throw new Error(`${label} field "${field}" must be a boolean`); } } -function optionalBoolean(value: JsonObject, key: string, label: string): void { - if (value[key] !== undefined && typeof value[key] !== "boolean") { - throw new Error(`${label} field "${key}" must be a boolean`); - } -} - -function optionalStringMap( - value: JsonObject, - key: string, - label: string, -): void { - const item = value[key]; +function optionalStringMap(value: JsonObject, field: string, label: string): void { + const item = value[field]; if (item === undefined) return; - if ( - !isObject(item) || - Object.values(item).some((entry) => typeof entry !== "string") - ) { - throw new Error(`${label} field "${key}" must contain string values`); + if (!isObject(item) || Object.values(item).some((entry) => typeof entry !== 'string')) { + throw new Error(`${label} field "${field}" must map names to strings`); } } -function validateHandler(value: unknown, label: string): JsonObject { - if (!isObject(value)) throw new Error(`${label} must be an object`); - if ( - value.type !== "command" && - value.type !== "http" && - value.type !== "prompt" - ) { - throw new Error(`${label} type must be "command", "http", or "prompt"`); - } - if ( - value.timeout !== undefined && - (typeof value.timeout !== "number" || - !Number.isFinite(value.timeout) || - value.timeout < 0) - ) { +function validateTimeout(value: unknown, label: string): void { + if (value === undefined) return; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { throw new Error(`${label} timeout must be a non-negative finite number`); } - for (const key of ["name", "description", "statusMessage", "source"]) { - optionalString(value, key, label); +} + +function validateHandler(value: unknown, label: string): QwenHandler { + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (!['command', 'http', 'prompt'].includes(String(value.type))) { + throw new Error(`${label} has unsupported type "${String(value.type)}"`); } - if (value.type === "command") { - if (typeof value.command !== "string" || value.command.length === 0) { - throw new Error(`${label} command must be a non-empty string`); + validateTimeout(value.timeout, label); + for (const field of ['name', 'description', 'statusMessage', 'source']) { + optionalString(value, field, label); + } + if (value.type === 'command') { + if (typeof value.command !== 'string' || value.command.length === 0) { + throw new Error(`${label} requires a non-empty command`); } - optionalStringMap(value, "env", label); - optionalBoolean(value, "async", label); - if ( - value.shell !== undefined && - value.shell !== "bash" && - value.shell !== "powershell" - ) { + optionalStringMap(value, 'env', label); + optionalBoolean(value, 'async', label); + if (value.shell !== undefined && !['bash', 'powershell'].includes(String(value.shell))) { throw new Error(`${label} shell must be "bash" or "powershell"`); } } - if (value.type === "http") { - if (typeof value.url !== "string" || value.url.length === 0) { - throw new Error(`${label} url must be a non-empty string`); + if (value.type === 'http') { + if (typeof value.url !== 'string' || value.url.length === 0) { + throw new Error(`${label} requires a non-empty url`); } - optionalStringMap(value, "headers", label); - optionalBoolean(value, "once", label); - optionalString(value, "if", label); - if ( - value.allowedEnvVars !== undefined && - (!Array.isArray(value.allowedEnvVars) || - value.allowedEnvVars.some((item) => typeof item !== "string")) - ) { + optionalStringMap(value, 'headers', label); + optionalBoolean(value, 'once', label); + optionalString(value, 'if', label); + if (value.allowedEnvVars !== undefined && ( + !Array.isArray(value.allowedEnvVars) + || value.allowedEnvVars.some((item) => typeof item !== 'string') + )) { throw new Error(`${label} allowedEnvVars must be an array of strings`); } } - if (value.type === "prompt") { - if (typeof value.prompt !== "string" || value.prompt.length === 0) { - throw new Error(`${label} prompt must be a non-empty string`); + if (value.type === 'prompt') { + if (typeof value.prompt !== 'string' || value.prompt.length === 0) { + throw new Error(`${label} requires a non-empty prompt`); } - optionalString(value, "model", label); + optionalString(value, 'model', label); } - return value; + return value as QwenHandler; } -function validateGroup( - value: unknown, - label: string, - index: number, -): QwenGroup { - const location = `${label}[${index}]`; - if (!isObject(value)) throw new Error(`${location} must be an object`); +function validateMatcher(matcher: string, event: string, label: string): void { + if (!REGEX_MATCHER_EVENTS.has(event) || matcher.trim() === '' || matcher.trim() === '*') { + return; + } + try { + new RegExp(matcher); + } catch (error) { + throw new Error(`${label} matcher must be a valid regular expression`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} + +function validateGroup(value: unknown, event: string, index: number): QwenGroup { + const label = `Qwen Code settings group hooks.${event}[${index}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); if (value.matcher !== undefined) { - if (typeof value.matcher !== "string") - throw new Error(`${location} matcher must be a string`); - validateRegex(value.matcher, `${location} matcher`); + if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`); + validateMatcher(value.matcher, event, label); } - if (value.sequential !== undefined && typeof value.sequential !== "boolean") { - throw new Error(`${location} sequential must be a boolean`); + if (value.sequential !== undefined && typeof value.sequential !== 'boolean') { + throw new Error(`${label} sequential must be a boolean`); } - if (!Array.isArray(value.hooks)) - throw new Error(`${location} must contain a hooks array`); - const hooks = value.hooks.map((handler, handlerIndex) => - validateHandler(handler, `${location}.hooks[${handlerIndex}]`), - ); - return { ...value, hooks } as QwenGroup; + if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`); + return { + ...value, + hooks: value.hooks.map((handler, handlerIndex) => ( + validateHandler(handler, `${label}.hooks[${handlerIndex}]`) + )), + } as QwenGroup; } -export function readHooks(value: unknown, label: string): QwenHookSettings { - if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - const settings: QwenHookSettings = { ...value }; - for (const [event, definitions] of Object.entries(value)) { - if (CONFIG_FIELDS.has(event)) continue; - if (!EVENT_NAMES.has(event)) - throw new Error(`${label} contains unsupported field "${event}"`); - if (!Array.isArray(definitions)) - throw new Error(`${label} field "${event}" must be an array`); - settings[event] = definitions.map((definition, index) => - validateGroup(definition, `${label} field "${event}"`, index), - ); - } - return settings; -} - -function readQuotedArgument( - command: string, - start: number, -): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ""; - for (let index = start + 1; index < command.length; ) { - if (process.platform === "win32" && command.startsWith("''", index)) { - value += "'"; - index += 2; +export function readQwenHooks(value: unknown): QwenHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error('Qwen Code settings field "hooks" must be an object'); + const hooks: QwenHooks = {}; + for (const [event, groups] of Object.entries(value)) { + if (!KNOWN_EVENTS.has(event)) { + hooks[event] = groups as QwenGroup[]; continue; } - if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) { - value += "'"; - index += 5; - continue; + if (!Array.isArray(groups)) { + throw new Error(`Qwen Code settings field "hooks.${event}" must be an array`); } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; + hooks[event] = groups.map((group, index) => validateGroup(group, event, index)); } - return undefined; + return hooks; } -function parseGeneratedCommand( - command: string, -): readonly [string, string] | undefined { - if (process.platform === "win32" && !command.startsWith("& ")) - return undefined; - const start = process.platform === "win32" ? 2 : 0; - const executable = readQuotedArgument(command, start); - if (!executable || command[executable.next] !== " ") return undefined; - const script = readQuotedArgument(command, executable.next + 1); - const expectedSuffix = - process.platform === "win32" ? "; exit $LASTEXITCODE" : ""; - if ( - !script || - command.slice(script.next) !== expectedSuffix || - !executable.value || - !script.value - ) - return undefined; - return [executable.value, script.value]; +export function buildQwenGroup(scriptPath: string, name: string): QwenGroup { + return { + hooks: [{ + type: 'command', + name, + command: buildQwenCommand(scriptPath), + shell: process.platform === 'win32' ? 'powershell' : 'bash', + timeout: HOOK_TIMEOUT_MS, + }], + }; } -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === "win32" - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; +function exactCurrentGroup(group: QwenGroup): boolean { + return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks'); } -function sameAgentId(left: string, right: string): boolean { - return process.platform === "win32" - ? left.toLowerCase() === right.toLowerCase() - : left === right; +function exactLegacyGroup(group: QwenGroup): boolean { + return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*'; } -export function managedAgentId( - handler: JsonObject, +function currentReference( + handler: QwenHandler, scriptName: string, -): string | undefined { - if ( - Object.keys(handler).sort().join("\0") !== HANDLER_KEYS.join("\0") || - handler.type !== "command" || - handler.timeout !== HOOK_TIMEOUT_MS || - handler.shell !== (process.platform === "win32" ? "powershell" : "bash") || - typeof handler.command !== "string" - ) - return undefined; - const parsed = parseGeneratedCommand(handler.command); - if (!parsed || !samePath(parsed[0], process.execPath)) return undefined; - const scriptPath = parsed[1]; - if (path.basename(scriptPath) !== scriptName) return undefined; - const agentDirectory = path.dirname(scriptPath); - if ( - !samePath(path.dirname(agentDirectory), path.join(os.homedir(), ".elydora")) - ) - return undefined; - const agentId = path.basename(agentDirectory); - return agentId && agentId !== "." && agentId !== ".." ? agentId : undefined; + hookName: string, +): QwenRuntimeReference | undefined { + return Object.keys(handler).sort().join('|') === 'command|name|shell|timeout|type' + && handler.type === 'command' + && handler.name === hookName + && handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash') + && handler.timeout === HOOK_TIMEOUT_MS + && typeof handler.command === 'string' + ? qwenRuntimeReference(handler.command, scriptName) + : undefined; } -function exactOwnedGroup(group: QwenGroup, managedIndexes: number[]): boolean { - return ( - Object.keys(group).sort().join("\0") === GROUP_KEYS.join("\0") && - group.matcher === "*" && - managedIndexes.length > 0 && - managedIndexes.length === group.hooks.length - ); +function legacyReference( + handler: QwenHandler, + scriptName: string, +): QwenRuntimeReference | undefined { + return Object.keys(handler).sort().join('|') === 'command|shell|timeout|type' + && handler.type === 'command' + && handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash') + && handler.timeout === HOOK_TIMEOUT_MS + && typeof handler.command === 'string' + ? qwenRuntimeReference(handler.command, scriptName) + : undefined; } -export function managedRemovals( - settings: QwenHookSettings, +function managedReference( + handler: QwenHandler, + scriptName: string, + hookName: string, + includeLegacy: boolean, +): QwenRuntimeReference | undefined { + return currentReference(handler, scriptName, hookName) + ?? (includeLegacy ? legacyReference(handler, scriptName) : undefined); +} + +const EVENT_CONTRACTS = [ + ['PreToolUse', GUARD_SCRIPT, GUARD_HOOK_NAME], + ['PostToolUse', AUDIT_SCRIPT, AUDIT_HOOK_NAME], + ['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_HOOK_NAME], +] as const; + +export function managedQwenRemovals( + hooks: QwenHooks, agentId?: string, ): ManagedRemoval[] { const removals: ManagedRemoval[] = []; - for (const [event, scriptName] of [ - ["PreToolUse", GUARD_SCRIPT], - ["PostToolUse", AUDIT_SCRIPT], - ] as const) { - (settings[event] ?? []).forEach((group, groupIndex) => { + for (const [event, scriptName, hookName] of EVENT_CONTRACTS) { + (hooks[event] ?? []).forEach((group, groupIndex) => { const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { - const managedId = managedAgentId(handler, scriptName); - return managedId && - (agentId === undefined || sameAgentId(managedId, agentId)) + const reference = managedReference(handler, scriptName, hookName, true); + return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId)) ? [handlerIndex] : []; }); @@ -355,7 +292,8 @@ export function managedRemovals( event, groupIndex, handlerIndexes, - removeGroup: exactOwnedGroup(group, handlerIndexes), + removeGroup: (exactCurrentGroup(group) || exactLegacyGroup(group)) + && handlerIndexes.length === group.hooks.length, }); } }); @@ -363,30 +301,47 @@ export function managedRemovals( return removals; } -function managedIds(groups: QwenGroup[], scriptName: string): Set { - const ids = new Set(); +function referencesForEvent( + groups: readonly QwenGroup[], + scriptName: string, + hookName: string, +): Map { + const references = new Map(); for (const group of groups) { + if (!exactCurrentGroup(group)) continue; for (const handler of group.hooks) { - const agentId = managedAgentId(handler, scriptName); - if (agentId) ids.add(agentId); + const reference = currentReference(handler, scriptName, hookName); + if (!reference) continue; + const key = process.platform === 'win32' + ? reference.agentId.toLowerCase() + : reference.agentId; + references.set(key, [...(references.get(key) ?? []), reference]); } } - return ids; + return references; } -export function runtimeContracts( - settings: QwenHookSettings, -): RuntimeContract[] { - const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT); - const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT); - const root = path.join(os.homedir(), ".elydora"); - return [...guards] - .filter((agentId) => - [...audits].some((auditId) => sameAgentId(auditId, agentId)), - ) - .map((agentId) => ({ - agentId, - guardPath: path.join(root, agentId, GUARD_SCRIPT), - auditPath: path.join(root, agentId, AUDIT_SCRIPT), - })); +export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] { + const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME); + const posts = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME); + const failures = referencesForEvent( + hooks.PostToolUseFailure ?? [], + AUDIT_SCRIPT, + AUDIT_HOOK_NAME, + ); + const contracts: QwenRuntimeContract[] = []; + for (const [key, guard] of guards) { + const post = posts.get(key); + const failure = failures.get(key); + if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue; + if (!sameQwenPath(post[0].scriptPath, failure[0].scriptPath)) continue; + contracts.push({ + agentId: guard[0].agentId, + guardPath: guard[0].scriptPath, + auditPath: post[0].scriptPath, + }); + } + return contracts; } + +export { isObject, type JsonObject }; diff --git a/sdks/node/src/plugins/qwen-installation.ts b/sdks/node/src/plugins/qwen-installation.ts new file mode 100644 index 0000000..b1f2275 --- /dev/null +++ b/sdks/node/src/plugins/qwen-installation.ts @@ -0,0 +1,133 @@ +import path from 'node:path'; +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, +} from './qwen-contract.js'; +import { sameQwenPath } from './qwen-command.js'; +import { + qwenDocumentLabel, + type RenderedQwenDocument, +} from './qwen-config.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, + type PreparedManagedTransaction, +} from './managed-transaction.js'; +import { + requireQwenHooksEnabled, + type QwenSources, +} from './qwen-sources.js'; + +const DISPLAY_NAME = 'Qwen Code'; +const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory'; +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; + +export type QwenRuntimePaths = ManagedRuntimePaths; +export type PreparedQwenInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +export async function preflightQwenInstallation( + config: InstallConfig, + sources: QwenSources, +): Promise { + requireQwenHooksEnabled(sources); + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: [{ + directoryLabel: SETTINGS_DIRECTORY_LABEL, + filePath: sources.user.filePath, + }], + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +function readOnlyPreconditions(sources: QwenSources, changedPath?: string) { + return sources.preconditions.filter((condition) => ( + changedPath === undefined || !sameQwenPath(condition.filePath, changedPath) + )); +} + +export async function prepareQwenInstallation( + config: InstallConfig, + sources: QwenSources, + rendered: RenderedQwenDocument, +): Promise { + await preflightQwenInstallation(config, sources); + const settingsSource = rendered.next ?? rendered.document.raw; + const prepared = await prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: SETTINGS_DIRECTORY_LABEL, + label: qwenDocumentLabel(rendered.document), + filePath: rendered.document.filePath, + expectedSource: rendered.document.exists ? rendered.document.raw : undefined, + expectedSnapshot: rendered.document.snapshot, + source: settingsSource, + }], + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); + return { + ...prepared, + transaction: { + ...prepared.transaction, + preconditions: readOnlyPreconditions(sources, rendered.document.filePath), + }, + }; +} + +export async function commitQwenInstallation( + prepared: PreparedQwenInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareQwenUninstall( + sources: QwenSources, + rendered: RenderedQwenDocument, +): Promise { + const change = rendered.changed + ? await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: qwenDocumentLabel(rendered.document), + next: rendered.next, + mode: 0o600, + maximumBytes: MAX_SOURCE_BYTES, + expectedSource: rendered.document.exists ? rendered.document.raw : undefined, + expectedSnapshot: rendered.document.snapshot, + verifyExpectedSource: true, + }) + : undefined; + return { + displayName: DISPLAY_NAME, + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.filePath), + label: SETTINGS_DIRECTORY_LABEL, + }], + changes: change ? [change] : [], + preconditions: readOnlyPreconditions( + sources, + rendered.changed ? rendered.document.filePath : undefined, + ), + }; +} + +export async function commitQwenUninstall( + prepared: PreparedManagedTransaction, + renameFile?: RenameFile, +): Promise { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/sdks/node/src/plugins/qwen-io.ts b/sdks/node/src/plugins/qwen-io.ts index 2dc6223..86743f4 100644 --- a/sdks/node/src/plugins/qwen-io.ts +++ b/sdks/node/src/plugins/qwen-io.ts @@ -1,344 +1,99 @@ -import { randomUUID } from "node:crypto"; -import fsp from "node:fs/promises"; -import type { FileHandle } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { parse as parseDotenv } from "dotenv"; +import os from 'node:os'; +import path from 'node:path'; +import { generateGuardScript } from './guard-template.js'; +import { generateHookScript } from './hook-template.js'; import { AGENT_KEY, - type JsonObject, - type RuntimeContract, - isObject, -} from "./qwen-contract.js"; -import { - type QwenDocument, - type RenderedDocument, - createOwnedDocument, - parseDocument, -} from "./qwen-config.js"; + type QwenRuntimeContract, +} from './qwen-contract.js'; +import { sameQwenAgentId, sameQwenPath } from './qwen-command.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; -interface FileChange { - readonly filePath: string; - readonly label: string; - readonly original?: string; - readonly next?: string; -} - -interface StagedChange { - readonly change: FileChange; - readonly tempPath?: string; - readonly rollbackPath?: string; - committed: boolean; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return isObject(error) && error.code === code; -} - -async function readOptional( - filePath: string, - label: string, -): Promise { - try { - return await fsp.readFile(filePath, "utf-8"); - } catch (error) { - if (hasErrorCode(error, "ENOENT")) return undefined; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); +function requireNonEmptyString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); } -} - -function defaultQwenHome(): string { - const homeDir = os.homedir(); - return homeDir - ? path.join(homeDir, ".qwen") - : path.join(os.tmpdir(), ".qwen"); -} - -function resolveStoragePath(value: string): string { - let resolved = value; - if ( - resolved === "~" || - resolved.startsWith("~/") || - resolved.startsWith("~\\") - ) { - const segments = - resolved === "~" - ? [] - : resolved - .slice(2) - .split(/[/\\]+/) - .filter(Boolean); - resolved = path.join(os.homedir(), ...segments); - } - return path.isAbsolute(resolved) ? resolved : path.resolve(resolved); -} - -async function qwenHomeFromEnvFile( - filePath: string, -): Promise { - 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[], +function validateRuntimeConfig( + config: JsonObject, + contract: QwenRuntimeContract, + configPath: string, +): void { + const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); + const extra = Object.keys(config).find((key) => !supported.has(key)); + if (extra) { + throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); + } + requireNonEmptyString(config.org_id, 'org_id', configPath); + requireNonEmptyString(config.kid, 'kid', configPath); + const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath); + if (!sameQwenAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Qwen Code hooks: ${configPath}`); + } + if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); + const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath); + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch (error) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + if (!['http:', 'https:'].includes(baseUrl.protocol) + || !baseUrl.hostname + || baseUrl.username + || baseUrl.password + || baseUrl.search + || baseUrl.hash) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); + } +} + +function validatePrivateKey(contents: string, keyPath: string): void { + const bytes = Buffer.from(contents, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { + throw new Error(`Elydora private key is invalid: ${keyPath}`); + } +} + +async function runtimeContractExists(contract: QwenRuntimeContract): Promise { + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!sameQwenPath(path.dirname(agentDirectory), runtimeRoot) + || !sameQwenPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false; + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; + if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) { + return false; + } + const configPath = path.join(agentDirectory, 'config.json'); + const keyPath = path.join(agentDirectory, 'private.key'); + const [config, key, guard, audit] = await Promise.all([ + readPhysicalFile(configPath, 'Elydora runtime config'), + readPhysicalFile(keyPath, 'Elydora private key'), + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + ]); + if (!config || !key || !guard || !audit) return false; + const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); + validateRuntimeConfig(parsed, contract, configPath); + validatePrivateKey(key.contents, keyPath); + return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) + && audit.contents === generateHookScript( + AGENT_KEY, + contract.agentId, + { nativePayload: true }, + ); +} + +export async function qwenRuntimeFilesExist( + contracts: readonly QwenRuntimeContract[], ): Promise { for (const contract of contracts) { - const agentDirectory = path.dirname(contract.guardPath); - const runtimeConfig = await readRuntimeConfig( - path.join(agentDirectory, "config.json"), - ); - if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; - const files = await Promise.all([ - regularFileExists(contract.guardPath, "Elydora guard runtime"), - regularFileExists(contract.auditPath, "Elydora audit runtime"), - ]); - if (files.every(Boolean)) return true; + if (await runtimeContractExists(contract)) return true; } return false; } diff --git a/sdks/node/src/plugins/qwen-sources.ts b/sdks/node/src/plugins/qwen-sources.ts new file mode 100644 index 0000000..e93854a --- /dev/null +++ b/sdks/node/src/plugins/qwen-sources.ts @@ -0,0 +1,319 @@ +import os from 'node:os'; +import path from 'node:path'; +import fsp from 'node:fs/promises'; +import { parse as parseDotenv } from 'dotenv'; +import { + createQwenDocument, + parseQwenDocument, + parseQwenJsoncObject, + qwenDocumentLabel, + qwenSourceLabel, + type QwenDocument, + type QwenDocumentKind, +} from './qwen-config.js'; +import { readPhysicalFile, type FileSnapshot } from './managed-files.js'; + +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const; + +export interface QwenSourcePrecondition { + readonly filePath: string; + readonly label: string; + readonly maximumBytes: number; + readonly original?: FileSnapshot; +} + +export interface QwenDisableControl { + readonly disabled: boolean; + readonly source?: QwenDocument; +} + +export interface QwenSources { + readonly qwenHome: string; + readonly systemDefaults: QwenDocument; + readonly user: QwenDocument; + readonly workspace: QwenDocument; + readonly system: QwenDocument; + readonly workspaceActive: boolean; + readonly workspaceTrusted: boolean; + readonly disableControl: QwenDisableControl; + readonly preconditions: readonly QwenSourcePrecondition[]; +} + +interface RoutingResult { + readonly qwenHome: string; + readonly preconditions: readonly QwenSourcePrecondition[]; +} + +function defaultQwenHome(): string { + const home = os.homedir(); + return home ? path.join(home, '.qwen') : path.join(os.tmpdir(), '.qwen'); +} + +function resolveConfigPath(value: string): string { + let resolved = value; + if (resolved === '~' || resolved.startsWith('~/') || resolved.startsWith('~\\')) { + const segments = resolved === '~' + ? [] + : resolved.slice(2).split(/[/\\]+/).filter(Boolean); + resolved = path.join(os.homedir(), ...segments); + } + return path.isAbsolute(resolved) ? resolved : path.resolve(resolved); +} + +function environmentValue(key: string): { own: boolean; value?: string } { + return { + own: Object.hasOwn(process.env, key), + value: process.env[key], + }; +} + +async function resolveQwenRouting(): Promise { + const values = new Map(); + const owned = new Set(); + for (const key of HOME_ENV_KEYS) { + const current = environmentValue(key); + values.set(key, current.value); + if (current.own) owned.add(key); + } + if (HOME_ENV_KEYS.every((key) => values.get(key))) { + return { + qwenHome: resolveConfigPath(values.get('QWEN_HOME')!), + preconditions: [], + }; + } + + const initialQwenHome = values.get('QWEN_HOME'); + const initialDirectory = initialQwenHome + ? resolveConfigPath(initialQwenHome) + : defaultQwenHome(); + const candidates = [path.join(initialDirectory, '.env')]; + if (!initialQwenHome) candidates.push(path.join(path.dirname(initialDirectory), '.env')); + const preconditions: QwenSourcePrecondition[] = []; + const visited = new Set(); + + const readCandidate = async (filePath: string): Promise => { + const resolved = path.resolve(filePath); + const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved; + if (visited.has(key)) return; + visited.add(key); + const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment'); + preconditions.push({ + filePath: resolved, + label: 'Qwen Code home environment', + maximumBytes: MAX_SOURCE_BYTES, + original: snapshot, + }); + if (!snapshot) return; + const parsed = parseDotenv(snapshot.contents); + for (const envKey of HOME_ENV_KEYS) { + const value = parsed[envKey]; + if (value && !owned.has(envKey)) { + values.set(envKey, value); + owned.add(envKey); + } + } + }; + + for (const candidate of candidates) await readCandidate(candidate); + const discoveredHome = values.get('QWEN_HOME'); + if (discoveredHome && discoveredHome !== initialQwenHome) { + const discoveredDirectory = resolveConfigPath(discoveredHome); + if (discoveredDirectory !== initialDirectory) { + await readCandidate(path.join(discoveredDirectory, '.env')); + } + } + return { + qwenHome: values.get('QWEN_HOME') + ? resolveConfigPath(values.get('QWEN_HOME')!) + : defaultQwenHome(), + preconditions, + }; +} + +function systemSettingsPath(): string { + const configured = process.env.QWEN_CODE_SYSTEM_SETTINGS_PATH; + if (configured) return path.resolve(configured); + if (process.platform === 'darwin') { + return '/Library/Application Support/QwenCode/settings.json'; + } + if (process.platform === 'win32') return 'C:\\ProgramData\\qwen-code\\settings.json'; + return '/etc/qwen-code/settings.json'; +} + +function systemDefaultsPath(systemPath: string): string { + const configured = process.env.QWEN_CODE_SYSTEM_DEFAULTS_PATH; + return configured + ? path.resolve(configured) + : path.join(path.dirname(systemPath), 'system-defaults.json'); +} + +async function readDocument( + kind: QwenDocumentKind, + filePath: string, +): Promise { + const snapshot = await readPhysicalFile(filePath, qwenSourceLabel(kind)); + return snapshot + ? parseQwenDocument({ + kind, + exists: true, + filePath, + raw: snapshot.contents, + snapshot, + }) + : createQwenDocument(kind, filePath); +} + +async function canonicalPath(filePath: string): Promise { + try { + return await fsp.realpath(filePath); + } catch { + return path.resolve(filePath); + } +} + +function comparisonPath(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +function isWithin(child: string, parent: string): boolean { + const relative = path.relative(comparisonPath(parent), comparisonPath(child)); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function workspaceTrust( + system: QwenDocument, + user: QwenDocument, + qwenHome: string, + workspacePath: string, +): Promise<{ trusted: boolean; precondition?: QwenSourcePrecondition }> { + const enabled = user.folderTrustEnabled ?? system.folderTrustEnabled ?? false; + if (!enabled) return { trusted: true }; + const configured = process.env.QWEN_CODE_TRUSTED_FOLDERS_PATH; + const filePath = configured + ? path.resolve(configured) + : path.join(qwenHome, 'trustedFolders.json'); + const snapshot = await readPhysicalFile(filePath, 'Qwen Code trusted folders'); + const precondition = { + filePath, + label: 'Qwen Code trusted folders', + maximumBytes: MAX_SOURCE_BYTES, + original: snapshot, + }; + if (!snapshot) return { trusted: true, precondition }; + const rules = parseQwenJsoncObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`); + for (const [rulePath, level] of Object.entries(rules)) { + if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) { + throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`); + } + } + const workspace = await canonicalPath(workspacePath); + for (const [rulePath, level] of Object.entries(rules)) { + const canonicalRule = await canonicalPath(String(rulePath)); + const trustRoot = level === 'TRUST_PARENT' ? path.dirname(canonicalRule) : canonicalRule; + if ((level === 'TRUST_FOLDER' || level === 'TRUST_PARENT') + && isWithin(workspace, trustRoot)) return { trusted: true, precondition }; + } + for (const [rulePath, level] of Object.entries(rules)) { + if (level === 'DO_NOT_TRUST' + && comparisonPath(workspace) === comparisonPath(await canonicalPath(rulePath))) { + return { trusted: false, precondition }; + } + } + return { trusted: true, precondition }; +} + +function effectiveDisable( + systemDefaults: QwenDocument, + user: QwenDocument, + workspace: QwenDocument, + system: QwenDocument, + useWorkspace: boolean, +): QwenDisableControl { + let disabled = false; + let source: QwenDocument | undefined; + const documents = [systemDefaults, user, ...(useWorkspace ? [workspace] : []), system]; + for (const document of documents) { + if (document.disableAllHooks === undefined) continue; + disabled = document.disableAllHooks; + source = document; + } + return { disabled, source }; +} + +function sourcePrecondition(document: QwenDocument): QwenSourcePrecondition { + return { + filePath: document.filePath, + label: qwenDocumentLabel(document), + maximumBytes: MAX_SOURCE_BYTES, + original: document.snapshot, + }; +} + +function deduplicatePreconditions( + values: readonly QwenSourcePrecondition[], +): QwenSourcePrecondition[] { + const result = new Map(); + for (const value of values) { + const key = comparisonPath(value.filePath); + if (!result.has(key)) result.set(key, value); + } + return [...result.values()]; +} + +export async function readQwenSources(): Promise { + const routing = await resolveQwenRouting(); + const systemPath = systemSettingsPath(); + const workspacePath = path.join(process.cwd(), '.qwen', 'settings.json'); + const system = await readDocument('system', systemPath); + const systemDefaults = await readDocument( + 'system-defaults', + systemDefaultsPath(systemPath), + ); + const user = await readDocument('user', path.join(routing.qwenHome, 'settings.json')); + const [canonicalWorkspace, canonicalHome] = await Promise.all([ + canonicalPath(process.cwd()), + canonicalPath(os.homedir()), + ]); + const workspaceActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome); + const workspace = workspaceActive + ? await readDocument('workspace', workspacePath) + : createQwenDocument('workspace', workspacePath); + const trust = workspaceActive + ? await workspaceTrust(system, user, routing.qwenHome, canonicalWorkspace) + : { trusted: false as const }; + const useWorkspace = workspaceActive && trust.trusted; + const preconditions = deduplicatePreconditions([ + ...routing.preconditions, + sourcePrecondition(systemDefaults), + sourcePrecondition(user), + ...(workspaceActive ? [sourcePrecondition(workspace)] : []), + sourcePrecondition(system), + ...(trust.precondition ? [trust.precondition] : []), + ]); + return { + qwenHome: routing.qwenHome, + systemDefaults, + user, + workspace, + system, + workspaceActive, + workspaceTrusted: trust.trusted, + disableControl: effectiveDisable( + systemDefaults, + user, + workspace, + system, + useWorkspace, + ), + preconditions, + }; +} + +export function requireQwenHooksEnabled(sources: QwenSources): void { + if (!sources.disableControl.disabled) return; + const source = sources.disableControl.source; + const location = source ? `${qwenDocumentLabel(source)} at ${source.filePath}` : 'effective settings'; + throw new Error(`Qwen Code hooks are disabled by disableAllHooks in ${location}`); +} diff --git a/sdks/node/src/plugins/qwen.ts b/sdks/node/src/plugins/qwen.ts index 3726251..1766a47 100644 --- a/sdks/node/src/plugins/qwen.ts +++ b/sdks/node/src/plugins/qwen.ts @@ -1,49 +1,74 @@ -import type { AgentPlugin, InstallConfig, PluginStatus } from "./base.js"; +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import { AGENT_KEY, - buildGroup, - runtimeContracts, + AUDIT_HOOK_NAME, + GUARD_HOOK_NAME, + buildQwenGroup, + qwenRuntimeContracts, + type ManagedQwenEvent, type QwenGroup, - type ToolEvent, -} from "./qwen-contract.js"; -import { renderDocument } from "./qwen-config.js"; +} from './qwen-contract.js'; +import { renderQwenDocument } from './qwen-config.js'; import { - readDocument, - requireRuntime, - runtimeFilesExist, - writeDocument, -} from "./qwen-io.js"; -import { SUPPORTED_AGENTS } from "./registry.js"; + commitQwenInstallation, + commitQwenUninstall, + preflightQwenInstallation, + prepareQwenInstallation, + prepareQwenUninstall, +} from './qwen-installation.js'; +import { qwenRuntimeFilesExist } from './qwen-io.js'; +import { readQwenSources } from './qwen-sources.js'; +import { SUPPORTED_AGENTS } from './registry.js'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; +function installedGroups(guardPath: string, auditPath: string): ReadonlyMap< + ManagedQwenEvent, + QwenGroup +> { + return new Map([ + ['PreToolUse', buildQwenGroup(guardPath, GUARD_HOOK_NAME)], + ['PostToolUse', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)], + ['PostToolUseFailure', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)], + ]); +} + export const qwenPlugin: AgentPlugin = { + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { + const sources = await readQwenSources(); + await preflightQwenInstallation(config, sources); + }, + 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."); + const sources = await readQwenSources(); + const paths = await preflightQwenInstallation(config, sources); + const rendered = renderQwenDocument( + sources.user, + undefined, + installedGroups(paths.guardPath, paths.auditPath), + ); + await commitQwenInstallation( + await prepareQwenInstallation(config, sources, rendered), + ); + console.log(` Qwen Code hooks: ${sources.user.filePath}`); + console.log(' Qwen Code verification: run /hooks.'); }, async uninstall(agentId?: string): Promise { - const document = await readDocument(); - if (!document.exists) return; - await writeDocument(renderDocument(document, agentId, new Map())); + const sources = await readQwenSources(); + const rendered = renderQwenDocument(sources.user, agentId, new Map()); + if (!rendered.changed) return; + await commitQwenUninstall(await prepareQwenUninstall(sources, rendered)); }, async status(): Promise { - const document = await readDocument(); - const contracts = runtimeContracts(document.hooks); - const hookConfigured = !document.hooksDisabled && contracts.length > 0; + const sources = await readQwenSources(); + const contracts = qwenRuntimeContracts(sources.user.hooks); + const hookConfigured = !sources.disableControl.disabled && contracts.length > 0; const hookScriptExists = hookConfigured - ? await runtimeFilesExist(contracts) + ? await qwenRuntimeFilesExist(contracts) : false; return { installed: hookConfigured && hookScriptExists, @@ -51,7 +76,7 @@ export const qwenPlugin: AgentPlugin = { displayName: entry.name, hookConfigured, hookScriptExists, - configPath: document.filePath, + configPath: sources.user.filePath, }; }, }; diff --git a/sdks/node/test-support/qwen.mjs b/sdks/node/test-support/qwen.mjs index 3440f53..35c5b9f 100644 --- a/sdks/node/test-support/qwen.mjs +++ b/sdks/node/test-support/qwen.mjs @@ -1,109 +1,93 @@ -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"; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +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"), +export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/qwen.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const sourcesModuleUrl = pathToFileURL( + path.resolve('dist/plugins/qwen-sources.js'), ).href; - -export const registryModuleUrl = pathToFileURL( - path.resolve("dist/plugins/registry.js"), +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/qwen-installation.js'), ).href; -export const cliPath = path.resolve("dist/cli.js"); -export const expectedShell = - process.platform === "win32" ? "powershell" : "bash"; +export const configModuleUrl = pathToFileURL( + path.resolve('dist/plugins/qwen-config.js'), +).href; +export const contractModuleUrl = pathToFileURL( + path.resolve('dist/plugins/qwen-contract.js'), +).href; +export const cliPath = path.resolve('dist/cli.js'); -export function runNode(args, env, input = "", cwd) { +const QWEN_ENV_KEYS = [ + 'QWEN_HOME', + 'QWEN_RUNTIME_DIR', + 'QWEN_CODE_SYSTEM_SETTINGS_PATH', + 'QWEN_CODE_SYSTEM_DEFAULTS_PATH', + 'QWEN_CODE_TRUSTED_FOLDERS_PATH', +]; + +export function runProcess(command, args, env, cwd, input = '', unset = []) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { + const childEnv = { ...process.env, ...env }; + for (const key of unset) delete childEnv[key]; + const child = spawn(command, args, { cwd, - env, - stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, }); - 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 })); + 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 runNode(args, env, cwd, input = '', unset = []) { + return runProcess(process.execPath, args, env, cwd, input, unset); } -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 windowsPowerShell() { + return path.win32.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', ); } -function quoteShell(value) { - return process.platform === "win32" - ? `'${value.replaceAll("'", "''")}'` - : `'${value.replaceAll("'", `'"'"'`)}'`; +export function runQwenHook(handler, input, fixture, environment = {}) { + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ...environment, + }; + if (process.platform === 'win32') { + return runProcess( + windowsPowerShell(), + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', handler.command], + env, + fixture.projectDir, + input, + ); + } + return runProcess('/bin/bash', ['-c', handler.command], env, fixture.projectDir, input); } -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 async function writeSettings(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); } export function parseSettings(raw) { @@ -116,73 +100,177 @@ export function parseSettings(raw) { 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 readSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: parseSettings(raw) }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'qwen', + orgId: 'org-1', + agentId: 'agent-1', + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +function qwenEnvironment(fixture, environment) { + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + QWEN_CODE_SYSTEM_DEFAULTS_PATH: path.join(fixture.rootDir, 'system-defaults.json'), + QWEN_CODE_SYSTEM_SETTINGS_PATH: path.join(fixture.rootDir, 'system-settings.json'), + ...environment, + }; + const unset = QWEN_ENV_KEYS.filter((key) => !Object.hasOwn(env, key)); + return { env, unset }; +} + +export async function runPlugin(fixture, method, argument, environment = {}) { + const source = ` + 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)); + `; + const configured = qwenEnvironment(fixture, { + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + ...environment, + }); + return runNode( + ['--input-type=module', '--eval', source], + configured.env, + fixture.projectDir, + '', + configured.unset, + ); } export async function createFixture({ - existingSettings, - guardSource, - hookSource, + baseUrl = 'http://127.0.0.1:9', + settings, } = {}) { - 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", + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-qwen-')); + const homeDir = path.join( + rootDir, + "home with spaces and 'quote $QWEN_PROJECT_DIR %QWEN_PROJECT_DIR%", ); - 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 { + const projectDir = path.join(rootDir, 'project with spaces'); + const settingsPath = path.join(homeDir, '.qwen', 'settings.json'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(homeDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + if (settings !== undefined) await writeSettings(settingsPath, settings); + const fixture = { agentDir, - configPath, + baseUrl, guardScriptPath, homeDir, hookScriptPath, + projectDir, rootDir, - workspaceDir, - async install(overrides = {}) { - return runPlugin( - this, - "install", - { - agentName: "qwen", - agentId: "agent-1", - guardScriptPath, - hookScriptPath, - }, - overrides, - ); + settingsPath, + install(overrides = {}, environment = {}) { + return runPlugin(this, 'install', installConfig(this, overrides), environment); }, async close() { await rm(rootDir, { recursive: true, force: true }); }, }; + return fixture; +} + +export function managedHandler(settings, event, name) { + for (const group of settings.hooks?.[event] ?? []) { + if (Object.keys(group).join('|') !== 'hooks' || !Array.isArray(group.hooks)) continue; + const handler = group.hooks.find((candidate) => ( + Object.keys(candidate).sort().join('|') === 'command|name|shell|timeout|type' + && candidate.type === 'command' + && candidate.name === name + && candidate.shell === (process.platform === 'win32' ? 'powershell' : 'bash') + && candidate.timeout === 10_000 + )); + if (handler) return handler; + } + return undefined; +} + +export function assertManagedHandler(handler, name) { + assert(handler); + assert.deepEqual(Object.keys(handler).sort(), [ + 'command', + 'name', + 'shell', + 'timeout', + 'type', + ]); + assert.equal(handler.type, 'command'); + assert.equal(handler.name, name); + assert.equal(handler.shell, process.platform === 'win32' ? 'powershell' : 'bash'); + assert.equal(handler.timeout, 10_000); +} + +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 legacyGroup(scriptPath) { + return { + matcher: '*', + hooks: [{ + type: 'command', + command: generatedCommand(scriptPath), + shell: process.platform === 'win32' ? 'powershell' : 'bash', + timeout: 10_000, + }], + }; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; } diff --git a/sdks/node/test/qwen-plugin.test.mjs b/sdks/node/test/qwen-plugin.test.mjs index 91280fe..0790f3e 100644 --- a/sdks/node/test/qwen-plugin.test.mjs +++ b/sdks/node/test/qwen-plugin.test.mjs @@ -1,36 +1,69 @@ -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 assert from 'node:assert/strict'; +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; import { - childEnv, + VALID_PRIVATE_KEY, + assertManagedHandler, cliPath, + configModuleUrl, + contractModuleUrl, createFixture, - expectedShell, - generatedCommand, + installConfig, + installationModuleUrl, + legacyGroup, managedHandler, parseSettings, + readSettings, registryModuleUrl, - runHook, runNode, runPlugin, -} from "../test-support/qwen.mjs"; + sourcesModuleUrl, + writeSettings, +} from '../test-support/qwen.mjs'; -test("Qwen Code is registered in the SDK and CLI", async () => { +const GUARD_NAME = 'elydora-guard'; +const AUDIT_NAME = 'elydora-audit'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +function assertManagedTriple(settings) { + const guard = managedHandler(settings, 'PreToolUse', GUARD_NAME); + const audit = managedHandler(settings, 'PostToolUse', AUDIT_NAME); + const failure = managedHandler(settings, 'PostToolUseFailure', AUDIT_NAME); + assertManagedHandler(guard, GUARD_NAME); + assertManagedHandler(audit, AUDIT_NAME); + assertManagedHandler(failure, AUDIT_NAME); + assert.equal(audit.command, failure.command); + return { guard, audit, failure }; +} + +function isolatedEnvironment(fixture) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + QWEN_HOME: '', + QWEN_CODE_SYSTEM_DEFAULTS_PATH: path.join(fixture.rootDir, 'system-defaults.json'), + QWEN_CODE_SYSTEM_SETTINGS_PATH: path.join(fixture.rootDir, 'system-settings.json'), + QWEN_CODE_TRUSTED_FOLDERS_PATH: path.join(fixture.rootDir, 'trusted-folders.json'), + }; +} + +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", + 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, + ['--no-warnings', cliPath, 'status'], + isolatedEnvironment(fixture), + fixture.projectDir, ); assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /Qwen Code \(qwen\)/); @@ -39,359 +72,420 @@ test("Qwen Code is registered in the SDK and CLI", async () => { } }); -test("Qwen install preserves JSONC settings and is idempotent", async () => { +test('Qwen installs an exact three-event contract and preserves every source', async () => { const existing = [ - "{", - " // Keep this user preference.", + '{', + ' // Keep this user preference.', ' "theme": "GitHub",', ' "hooks": {', - ' "SessionStart": [{ "hooks": [{ "type": "command", "command": "session-hook" }] }],', + ' "FutureEvent": [null],', ' "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'); + ' }', + '}', + '', + ].join('\r\n'); + const fixture = await createFixture({ settings: existing }); + const workspacePath = path.join(fixture.projectDir, '.qwen', 'settings.json'); + const defaultsPath = path.join(fixture.rootDir, 'system-defaults.json'); + const systemPath = path.join(fixture.rootDir, 'system-settings.json'); + await writeSettings(workspacePath, { owner: 'workspace' }); + await writeSettings(defaultsPath, { owner: 'defaults' }); + await writeSettings(systemPath, { owner: 'system' }); + const environment = { + QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsPath, + QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath, + }; try { - const first = await fixture.install(); + const first = await fixture.install({}, environment); assert.equal(first.code, 0, first.stderr); assert.match(first.stdout, /run \/hooks/i); - const second = await fixture.install(); + const installed = await readSettings(fixture.settingsPath); + assert.match(installed.raw, /Keep this user preference/); + assert.match(installed.raw, /\r\n/); + assert.equal(installed.settings.theme, 'GitHub'); + assert.deepEqual(installed.settings.hooks.FutureEvent, [null]); + assert.equal(installed.settings.hooks.PreToolUse[0].hooks[0].command, 'user-hook'); + assertManagedTriple(installed.settings); + for (const filePath of [ + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + fixture.guardScriptPath, + fixture.hookScriptPath, + ]) assert.equal((await lstat(filePath)).isFile(), true); + + const second = await fixture.install({}, environment); 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', - ); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), installed.raw); + assert.deepEqual(parseSettings(await readFile(workspacePath, 'utf-8')), { owner: 'workspace' }); + assert.deepEqual(parseSettings(await readFile(defaultsPath, 'utf-8')), { owner: 'defaults' }); + assert.deepEqual(parseSettings(await readFile(systemPath, 'utf-8')), { owner: 'system' }); } finally { await fixture.close(); } }); -test("Qwen resolves QWEN_HOME with official user env precedence", async () => { +test('Qwen resolves QWEN_HOME with official bootstrap 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"), + const firstHome = path.join(fixture.rootDir, 'first qwen home'); + const secondHome = path.join(fixture.rootDir, 'second qwen home'); + await writeSettings( + path.join(fixture.homeDir, '.qwen', '.env'), `QWEN_HOME=${firstHome}\n`, ); - await writeFile( - path.join(fixture.homeDir, ".env"), - `QWEN_HOME=${secondHome}\n`, - ); + await writeSettings(path.join(fixture.homeDir, '.env'), `QWEN_HOME=${secondHome}\n`); + await writeSettings(path.join(firstHome, '.env'), 'QWEN_RUNTIME_DIR=runtime\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); + const selected = path.join(firstHome, 'settings.json'); + assertManagedTriple((await readSettings(selected)).settings); + await assertMissing(path.join(secondHome, 'settings.json')); + await assertMissing(fixture.settingsPath); + const status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.configPath, selected); } finally { await fixture.close(); } }); -test("Qwen resolves explicit relative, tilde, and empty QWEN_HOME values", async () => { +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], + ['relative-qwen', (fixture) => path.join(fixture.projectDir, 'relative-qwen', 'settings.json')], + ['~/custom-qwen', (fixture) => path.join(fixture.homeDir, 'custom-qwen', 'settings.json')], + ['', (fixture) => fixture.settingsPath], ]) { 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`, + await writeSettings( + path.join(fixture.homeDir, '.qwen', '.env'), + `QWEN_HOME=${path.join(fixture.rootDir, 'ignored-home')}\n`, ); try { - const result = await fixture.install({ QWEN_HOME: value }); + 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, - ); + assertManagedTriple((await readSettings(expectedPath(fixture))).settings); } 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 }); +test('Qwen validates current hook schemas and preserves future events', async () => { + const fixture = await createFixture({ settings: { + hooks: { + FutureEvent: [null], + MessageDisplay: [{ matcher: '[', hooks: [{ + type: 'http', + url: 'http://127.0.0.1:8080/hook', + headers: { Authorization: 'Bearer ${TOKEN}' }, + allowedEnvVars: ['TOKEN'], + timeout: 10, + once: true, + }] }], + Stop: [{ hooks: [{ + type: 'prompt', + prompt: 'Evaluate $ARGUMENTS', + model: 'fast', + timeout: 30, + }] }], + }, + } }); 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); + const result = await fixture.install(); 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); + const settings = (await readSettings(fixture.settingsPath)).settings; + assert.deepEqual(settings.hooks.FutureEvent, [null]); + assert.equal(settings.hooks.MessageDisplay[0].matcher, '['); + assert.equal(settings.hooks.Stop[0].hooks[0].type, 'prompt'); } 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 () => { +test('Qwen rejects malformed affected settings before runtime writes', async (t) => { 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" }] }] } }', + ['syntax', '{ malformed', /parse Qwen Code user settings/i], + ['root', '[]', /must contain a JSON object/i], + ['trailing comma', '{ "theme": true, }', /parse Qwen Code user settings/i], + ['duplicate', '{ "hooks": {}, "hooks": {} }', /duplicate field "hooks"/i], + ['disable', '{ "disableAllHooks": "yes" }', /disableAllHooks.*boolean/i], + ['hooks shape', '{ "hooks": [] }', /field "hooks" must be an object/i], + ['event shape', '{ "hooks": { "PreToolUse": null } }', /must be an array/i], + ['group shape', '{ "hooks": { "PreToolUse": [null] } }', /group.*must be an object/i], + ['matcher shape', '{ "hooks": { "PreToolUse": [{ "matcher": 1, "hooks": [] }] } }', /matcher must be a string/i], + ['matcher regex', '{ "hooks": { "PreToolUse": [{ "matcher": "[", "hooks": [] }] } }', /valid regular expression/i], + ['notification matcher', '{ "hooks": { "Notification": [{ "matcher": "[", "hooks": [] }] } }', /valid regular expression/i], + ['sequential', '{ "hooks": { "PreToolUse": [{ "sequential": 1, "hooks": [] }] } }', /sequential must be a boolean/i], + ['hooks missing', '{ "hooks": { "PreToolUse": [{}] } }', /must contain a hooks array/i], + ['handler shape', '{ "hooks": { "PreToolUse": [{ "hooks": [null] }] } }', /hooks\[0\].*object/i], + ['handler type', '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function" }] }] } }', /unsupported type/i], + ['command', '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }', /non-empty command/i], + ['http', '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }', /non-empty url/i], + ['prompt', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "prompt" }] }] } }', /non-empty prompt/i], + ['timeout', '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": -1 }] }] } }', /non-negative finite number/i], + ['trust', '{ "security": { "folderTrust": { "enabled": "yes" } } }', /folderTrust.enabled.*boolean/i], ]; - 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", + for (const [name, settings, pattern] of cases) { + await t.test(name, async () => { + const fixture = await createFixture({ settings }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), settings); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } }); - } finally { - await runtimeFixture.close(); } }); -test("Qwen status surfaces malformed runtime metadata and leaves no staging files", async () => { - const fixture = await createFixture(); +test('Qwen surfaces malformed read-only sources and home routing', async () => { + const system = await createFixture(); + const systemPath = path.join(system.rootDir, 'system.json'); + await writeSettings(systemPath, '{ malformed'); try { - assert.equal((await fixture.install()).code, 0); - assert.equal( - (await readdir(path.dirname(fixture.configPath))).some((name) => - name.endsWith(".tmp"), - ), - false, + const result = await system.install({}, { QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath }); + assert.equal(result.code, 1); + assert.match(result.stderr, /parse Qwen Code system override settings/i); + assert.equal(await readFile(systemPath, 'utf-8'), '{ malformed'); + await assertMissing(system.settingsPath); + await assertMissing(system.agentDir); + } finally { + await system.close(); + } + + const routing = await createFixture(); + await mkdir(path.join(routing.homeDir, '.qwen', '.env'), { recursive: true }); + try { + const result = await routing.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /home environment.*physical file/i); + await assertMissing(routing.settingsPath); + await assertMissing(routing.agentDir); + } finally { + await routing.close(); + } +}); + +test('Qwen transaction aborts when a read-only source changes after prepare', async () => { + const fixture = await createFixture(); + const systemPath = path.join(fixture.rootDir, 'system.json'); + const defaultsPath = path.join(fixture.rootDir, 'defaults.json'); + await writeSettings(systemPath, { owner: 'before' }); + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readQwenSources } from ${JSON.stringify(sourcesModuleUrl)}; + import { renderQwenDocument } from ${JSON.stringify(configModuleUrl)}; + import { + AUDIT_HOOK_NAME, + GUARD_HOOK_NAME, + buildQwenGroup, + } from ${JSON.stringify(contractModuleUrl)}; + import { + commitQwenInstallation, + preflightQwenInstallation, + prepareQwenInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_TEST_CONFIG); + const sources = await readQwenSources(); + const paths = await preflightQwenInstallation(config, sources); + const rendered = renderQwenDocument(sources.user, undefined, new Map([ + ['PreToolUse', buildQwenGroup(paths.guardPath, GUARD_HOOK_NAME)], + ['PostToolUse', buildQwenGroup(paths.auditPath, AUDIT_HOOK_NAME)], + ['PostToolUseFailure', buildQwenGroup(paths.auditPath, AUDIT_HOOK_NAME)], + ])); + const prepared = await prepareQwenInstallation(config, sources, rendered); + await writeFile(process.env.ELYDORA_SYSTEM_PATH, '{"owner":"after"}\\n'); + await commitQwenInstallation(prepared); + `; + try { + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + QWEN_HOME: '', + QWEN_RUNTIME_DIR: '', + QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath, + QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsPath, + QWEN_CODE_TRUSTED_FOLDERS_PATH: path.join(fixture.rootDir, 'trusted.json'), + ELYDORA_SYSTEM_PATH: systemPath, + ELYDORA_TEST_CONFIG: JSON.stringify(installConfig(fixture)), + }, + fixture.projectDir, ); - 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); + assert.equal(result.code, 1); + assert.match(result.stderr, /system override settings changed during Qwen Code installation/i); + assert.deepEqual(parseSettings(await readFile(systemPath, 'utf-8')), { owner: 'after' }); + await assertMissing(fixture.settingsPath); + await assertMissing(fixture.guardScriptPath); + await assertMissing(fixture.hookScriptPath); + await assertMissing(path.join(fixture.agentDir, 'config.json')); + await assertMissing(path.join(fixture.agentDir, 'private.key')); + } finally { + await fixture.close(); + } +}); + +test('Qwen applies disableAllHooks precedence and workspace trust', async () => { + const defaults = await createFixture({ settings: { disableAllHooks: false } }); + const defaultsPath = path.join(defaults.rootDir, 'defaults.json'); + await writeSettings(defaultsPath, { disableAllHooks: true }); + try { + assert.equal((await defaults.install({}, { + QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsPath, + })).code, 0); + } finally { + await defaults.close(); + } + + const system = await createFixture(); + const systemPath = path.join(system.rootDir, 'system.json'); + await writeSettings(systemPath, { disableAllHooks: true }); + try { + const result = await system.install({}, { QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath }); + assert.equal(result.code, 1); + assert.match(result.stderr, /disableAllHooks.*system override/i); + await assertMissing(system.agentDir); + } finally { + await system.close(); + } + + const untrusted = await createFixture({ settings: { + security: { folderTrust: { enabled: true } }, + } }); + await writeSettings(path.join(untrusted.projectDir, '.qwen', 'settings.json'), { + disableAllHooks: true, + }); + await writeSettings(path.join(untrusted.homeDir, '.qwen', 'trustedFolders.json'), { + [untrusted.projectDir]: 'DO_NOT_TRUST', + }); + try { + const result = await untrusted.install(); + assert.equal(result.code, 0, result.stderr); + assertManagedTriple((await readSettings(untrusted.settingsPath)).settings); + } finally { + await untrusted.close(); + } +}); + +test('Qwen migrates exact legacy handlers and preserves ownership lookalikes', async () => { + const fixture = await createFixture(); + const lookalike = legacyGroup(fixture.guardScriptPath); + lookalike.hooks[0].timeout = 9_000; + await writeSettings(fixture.settingsPath, { + hooks: { + PreToolUse: [legacyGroup(fixture.guardScriptPath), lookalike], + PostToolUse: [legacyGroup(fixture.hookScriptPath)], + }, + }); + try { + assert.equal((await fixture.install()).code, 0); + let settings = (await readSettings(fixture.settingsPath)).settings; + assertManagedTriple(settings); + assert(settings.hooks.PreToolUse.some((group) => group.hooks[0].timeout === 9_000)); + settings.hooks.PreToolUse.at(-1).userField = 'preserve-group'; + settings.hooks.PreToolUse.at(-1).hooks.push({ type: 'command', command: 'user-command' }); + await writeSettings(fixture.settingsPath, settings); + const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + settings = (await readSettings(fixture.settingsPath)).settings; + assert(settings.hooks.PreToolUse.some((group) => group.hooks[0].timeout === 9_000)); + assert(settings.hooks.PreToolUse.some((group) => ( + group.hooks.some((handler) => handler.command === 'user-command') + ))); + assert.equal(settings.hooks.PostToolUse, undefined); + assert.equal(settings.hooks.PostToolUseFailure, undefined); + } finally { + await fixture.close(); + } +}); + +test('Qwen uninstall removes an owned file and preserves user settings', async () => { + const user = await createFixture({ settings: { theme: 'GitHub', hooks: { Notification: [] } } }); + try { + assert.equal((await user.install()).code, 0); + assert.equal((await runPlugin(user, 'uninstall', 'agent-1')).code, 0); + assert.deepEqual((await readSettings(user.settingsPath)).settings, { + theme: 'GitHub', + hooks: { Notification: [] }, + }); + } finally { + await user.close(); + } + + const owned = await createFixture(); + try { + assert.equal((await owned.install()).code, 0); + assert.match(await readFile(owned.settingsPath, 'utf-8'), /^\/\/ Managed by Elydora/); + assert.equal((await runPlugin(owned, 'uninstall', 'agent-1')).code, 0); + await assertMissing(owned.settingsPath); + } finally { + await owned.close(); + } +}); + +test('Qwen status requires exact hooks and strict runtime identity', 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 = (await readSettings(fixture.settingsPath)).settings; + settings.hooks.PostToolUseFailure.push(settings.hooks.PostToolUseFailure.at(-1)); + await writeSettings(fixture.settingsPath, settings); + status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.installed, false); + assert.equal((await fixture.install()).code, 0); + await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid'); + const invalid = await runPlugin(fixture, 'status', null); + assert.equal(invalid.code, 1); + assert.match(invalid.stderr, /private key is invalid/i); + assert.equal((await fixture.install()).code, 0); + await writeFile(fixture.guardScriptPath, 'tampered'); + status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.installed, false); + } finally { + await fixture.close(); + } +}); + +test('Qwen CLI completes install, status, and uninstall end to end', async () => { + const fixture = await createFixture(); + const privateKeyFile = path.join(fixture.rootDir, 'install-private.key'); + const tokenFile = path.join(fixture.rootDir, 'install-token.txt'); + const environment = isolatedEnvironment(fixture); + try { + await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 }); + await writeFile(tokenFile, 'token-1\n', { mode: 0o600 }); + const install = await runNode([ + '--no-warnings', cliPath, 'install', + '--agent', 'qwen', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', fixture.baseUrl, + ], environment, fixture.projectDir); + assert.equal(install.code, 0, install.stderr); + assertManagedTriple((await readSettings(fixture.settingsPath)).settings); + const status = await runNode( + ['--no-warnings', cliPath, 'status'], + environment, + fixture.projectDir, + ); + assert.equal(status.code, 0, status.stderr); + assert.match(status.stdout, /Qwen Code \(qwen\) \[installed\]/); + const uninstall = await runNode([ + '--no-warnings', cliPath, 'uninstall', + '--agent', 'qwen', + '--agent_id', 'agent-1', + ], environment, fixture.projectDir); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assertMissing(fixture.settingsPath); + await assertMissing(fixture.agentDir); } finally { await fixture.close(); } diff --git a/sdks/node/test/qwen-runtime.test.mjs b/sdks/node/test/qwen-runtime.test.mjs new file mode 100644 index 0000000..8fea4c0 --- /dev/null +++ b/sdks/node/test/qwen-runtime.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + readSettings, + runQwenHook, + startApiServer, +} from '../test-support/qwen.mjs'; + +const GUARD_NAME = 'elydora-guard'; +const AUDIT_NAME = 'elydora-audit'; + +function officialInput(fixture, event) { + return { + session_id: 'session-1', + transcript_path: path.join(fixture.projectDir, 'transcript.jsonl'), + cwd: fixture.projectDir, + hook_event_name: event, + timestamp: '2026-07-19T00:00:00.000Z', + permission_mode: 'default', + tool_name: 'run_shell_command', + tool_input: { command: 'echo test' }, + tool_use_id: 'toolu_1', + tool_call_id: 'call_1', + ...(event === 'PostToolUse' ? { + tool_response: { output: 'test', error: null }, + } : {}), + ...(event === 'PostToolUseFailure' ? { + error: 'command failed', + is_interrupt: false, + } : {}), + }; +} + +async function installedHandlers(fixture) { + const settings = (await readSettings(fixture.settingsPath)).settings; + return { + guard: managedHandler(settings, 'PreToolUse', GUARD_NAME), + audit: managedHandler(settings, 'PostToolUse', AUDIT_NAME), + failure: managedHandler(settings, 'PostToolUseFailure', AUDIT_NAME), + }; +} + +test('Qwen guard accepts active agents and preserves native exit semantics', async () => { + const api = await startApiServer({ status: 'active' }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { guard } = await installedHandlers(fixture); + const result = await runQwenHook( + guard, + JSON.stringify(officialInput(fixture, 'PreToolUse')), + fixture, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.equal(api.requests.filter((request) => request.method === 'GET').length, 1); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Qwen guard propagates frozen and revoked states through exit code 2', async (t) => { + for (const status of ['frozen', 'revoked']) { + await t.test(status, async () => { + const api = await startApiServer({ status }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { guard } = await installedHandlers(fixture); + const result = await runQwenHook( + guard, + JSON.stringify(officialInput(fixture, 'PreToolUse')), + fixture, + ); + assert.equal(result.code, 2, result.stderr); + assert.equal(result.stdout, ''); + assert.match(result.stderr, new RegExp(`is ${status}`, 'i')); + } finally { + await fixture.close(); + await api.close(); + } + }); + } +}); + +test('Qwen audit forwards complete success and failure payloads', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { audit, failure } = await installedHandlers(fixture); + for (const [handler, event] of [ + [audit, 'PostToolUse'], + [failure, 'PostToolUseFailure'], + ]) { + const payload = officialInput(fixture, event); + payload.future_provider_field = { preserved: true }; + const result = await runQwenHook(handler, JSON.stringify(payload), fixture); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, ''); + } + const posts = api.requests.filter((request) => request.method === 'POST'); + assert.equal(posts.length, 2); + assert.deepEqual(JSON.parse(posts[0].raw).payload, { + ...officialInput(fixture, 'PostToolUse'), + future_provider_field: { preserved: true }, + }); + assert.deepEqual(JSON.parse(posts[1].raw).payload, { + ...officialInput(fixture, 'PostToolUseFailure'), + future_provider_field: { preserved: true }, + }); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Qwen audit keeps delivery failures observable and fail-open', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const { audit } = await installedHandlers(fixture); + const result = await runQwenHook( + audit, + JSON.stringify(officialInput(fixture, 'PostToolUse')), + fixture, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.match( + await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), + /Elydora audit|fetch failed|ECONNREFUSED/i, + ); + } finally { + await fixture.close(); + } +}); + +test('Qwen runtime artifacts use private modes on POSIX', { + skip: process.platform === 'win32' ? 'POSIX mode bits are not authoritative on Windows' : false, +}, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + for (const [filePath, expected] of [ + [path.join(fixture.agentDir, 'config.json'), 0o600], + [path.join(fixture.agentDir, 'private.key'), 0o600], + [fixture.guardScriptPath, 0o700], + [fixture.hookScriptPath, 0o700], + [fixture.settingsPath, 0o600], + ]) { + assert.equal((await stat(filePath)).mode & 0o777, expected); + } + } finally { + await fixture.close(); + } +});