fix(qwen): mirror Qwen Code 0.20 hooks
This commit is contained in:
1 parent
aa4e2c5fd4
commit
3095661c9a
12 files changed
+1864
-1195
No files matched your search
@@ -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 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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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
|
## Mirror Workflow
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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 `<QWEN_HOME>/.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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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] };
|
||||||
|
}
|
||||||
@@ -8,70 +8,76 @@ import {
|
|||||||
type JSONPath,
|
type JSONPath,
|
||||||
type Node,
|
type Node,
|
||||||
type ParseError,
|
type ParseError,
|
||||||
} from "jsonc-parser";
|
} from 'jsonc-parser';
|
||||||
|
import type { FileSnapshot } from './managed-files.js';
|
||||||
import {
|
import {
|
||||||
TOOL_EVENTS,
|
MANAGED_EVENTS,
|
||||||
|
managedQwenRemovals,
|
||||||
|
readQwenHooks,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
|
type ManagedQwenEvent,
|
||||||
type QwenGroup,
|
type QwenGroup,
|
||||||
type QwenHookSettings,
|
type QwenHooks,
|
||||||
type ToolEvent,
|
|
||||||
isObject,
|
isObject,
|
||||||
managedRemovals,
|
} from './qwen-contract.js';
|
||||||
readHooks,
|
|
||||||
} 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 {
|
export interface QwenDocument {
|
||||||
|
readonly kind: QwenDocumentKind;
|
||||||
readonly filePath: string;
|
readonly filePath: string;
|
||||||
readonly exists: boolean;
|
readonly exists: boolean;
|
||||||
readonly raw: string;
|
readonly raw: string;
|
||||||
|
readonly snapshot?: FileSnapshot;
|
||||||
readonly root: JsonObject;
|
readonly root: JsonObject;
|
||||||
readonly hooks: QwenHookSettings;
|
readonly hooks: QwenHooks;
|
||||||
readonly hasHooksContainer: boolean;
|
readonly hasHooksContainer: boolean;
|
||||||
readonly hooksDisabled: boolean;
|
readonly disableAllHooks?: boolean;
|
||||||
|
readonly folderTrustEnabled?: boolean;
|
||||||
readonly ownedFile: boolean;
|
readonly ownedFile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderedDocument {
|
export interface RenderedQwenDocument {
|
||||||
readonly document: QwenDocument;
|
readonly document: QwenDocument;
|
||||||
readonly changed: boolean;
|
readonly changed: boolean;
|
||||||
readonly next?: string;
|
readonly next?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentOptions {
|
interface DocumentOptions {
|
||||||
|
readonly kind: QwenDocumentKind;
|
||||||
readonly exists: boolean;
|
readonly exists: boolean;
|
||||||
readonly filePath: string;
|
readonly filePath: string;
|
||||||
readonly raw: string;
|
readonly raw: string;
|
||||||
|
readonly snapshot?: FileSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectDuplicateKeys(
|
function rejectDuplicateKeys(
|
||||||
node: Node | undefined,
|
node: Node | undefined,
|
||||||
label: string,
|
label: string,
|
||||||
path: string[] = [],
|
location: string[] = [],
|
||||||
): void {
|
): void {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
if (node.type === "object") {
|
if (node.type === 'object') {
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const property of node.children ?? []) {
|
for (const property of node.children ?? []) {
|
||||||
const key = String(property.children?.[0]?.value);
|
const key = String(property.children?.[0]?.value);
|
||||||
const location = [...path, key];
|
const childLocation = [...location, key];
|
||||||
if (keys.has(key))
|
if (keys.has(key)) {
|
||||||
throw new Error(
|
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
|
||||||
`${label} contains duplicate field "${location.join(".")}"`,
|
}
|
||||||
);
|
|
||||||
keys.add(key);
|
keys.add(key);
|
||||||
rejectDuplicateKeys(property.children?.[1], label, location);
|
rejectDuplicateKeys(property.children?.[1], label, childLocation);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === "array") {
|
if (node.type === 'array') {
|
||||||
for (const child of node.children ?? [])
|
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
|
||||||
rejectDuplicateKeys(child, label, path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonWithComments(raw: string, label: string): JsonObject {
|
export function parseQwenJsoncObject(raw: string, label: string): JsonObject {
|
||||||
const errors: ParseError[] = [];
|
const errors: ParseError[] = [];
|
||||||
const value: unknown = parse(raw, errors, {
|
const value: unknown = parse(raw, errors, {
|
||||||
allowTrailingComma: false,
|
allowTrailingComma: false,
|
||||||
@@ -79,60 +85,76 @@ function parseJsonWithComments(raw: string, label: string): JsonObject {
|
|||||||
});
|
});
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const details = errors
|
const details = errors
|
||||||
.map(
|
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||||
(error) =>
|
.join(', ');
|
||||||
`${printParseErrorCode(error.error)} at offset ${error.offset}`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
throw new Error(`Failed to parse ${label}: ${details}`);
|
throw new Error(`Failed to parse ${label}: ${details}`);
|
||||||
}
|
}
|
||||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||||
rejectDuplicateKeys(
|
rejectDuplicateKeys(parseTree(raw, [], {
|
||||||
parseTree(raw, [], {
|
|
||||||
allowTrailingComma: false,
|
allowTrailingComma: false,
|
||||||
disallowComments: false,
|
disallowComments: false,
|
||||||
}),
|
}), label);
|
||||||
label,
|
|
||||||
);
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDocument(options: DocumentOptions): QwenDocument {
|
export function qwenSourceLabel(kind: QwenDocumentKind): string {
|
||||||
const label = `Qwen Code settings at ${options.filePath}`;
|
switch (kind) {
|
||||||
const root = parseJsonWithComments(options.raw, label);
|
case 'system-defaults': return 'Qwen Code system defaults';
|
||||||
if (
|
case 'workspace': return 'Qwen Code workspace settings';
|
||||||
root.disableAllHooks !== undefined &&
|
case 'system': return 'Qwen Code system override settings';
|
||||||
typeof root.disableAllHooks !== "boolean"
|
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`);
|
throw new Error(`${label} field "disableAllHooks" must be a boolean`);
|
||||||
}
|
}
|
||||||
const hasHooksContainer = Object.hasOwn(root, "hooks");
|
const hasHooksContainer = Object.hasOwn(root, 'hooks');
|
||||||
const hooks = hasHooksContainer
|
|
||||||
? readHooks(root.hooks, `${label} field "hooks"`)
|
|
||||||
: {};
|
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
root,
|
root,
|
||||||
hooks,
|
hooks: readQwenHooks(root.hooks),
|
||||||
hasHooksContainer,
|
hasHooksContainer,
|
||||||
hooksDisabled: root.disableAllHooks === true,
|
disableAllHooks: root.disableAllHooks as boolean | undefined,
|
||||||
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
|
folderTrustEnabled: readFolderTrustEnabled(root, label),
|
||||||
|
ownedFile: options.kind === 'user' && options.raw.startsWith(QWEN_OWNED_FILE_MARKER),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOwnedDocument(filePath: string): QwenDocument {
|
export function createQwenDocument(
|
||||||
return parseDocument({
|
kind: QwenDocumentKind,
|
||||||
|
filePath: string,
|
||||||
|
): QwenDocument {
|
||||||
|
return parseQwenDocument({
|
||||||
|
kind,
|
||||||
exists: false,
|
exists: false,
|
||||||
filePath,
|
filePath,
|
||||||
raw: `${OWNED_FILE_MARKER}\n{}\n`,
|
raw: kind === 'user' ? `${QWEN_OWNED_FILE_MARKER}\n{}\n` : '{}\n',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatting(raw: string): FormattingOptions {
|
function formatting(raw: string): FormattingOptions {
|
||||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||||
const insertSpaces = !indentation?.includes("\t");
|
const insertSpaces = !indentation?.includes('\t');
|
||||||
return {
|
return {
|
||||||
eol: raw.includes("\r\n") ? "\r\n" : "\n",
|
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||||
insertSpaces,
|
insertSpaces,
|
||||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||||
};
|
};
|
||||||
@@ -144,20 +166,19 @@ function change(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
isArrayInsertion = false,
|
isArrayInsertion = false,
|
||||||
): string {
|
): string {
|
||||||
return applyEdits(
|
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||||
raw,
|
|
||||||
modify(raw, jsonPath, value, {
|
|
||||||
formattingOptions: formatting(raw),
|
formattingOptions: formatting(raw),
|
||||||
isArrayInsertion,
|
isArrayInsertion,
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentDocument(document: QwenDocument, raw: string): QwenDocument {
|
function currentDocument(document: QwenDocument, raw: string): QwenDocument {
|
||||||
return parseDocument({
|
return parseQwenDocument({
|
||||||
|
kind: document.kind,
|
||||||
exists: document.exists,
|
exists: document.exists,
|
||||||
filePath: document.filePath,
|
filePath: document.filePath,
|
||||||
raw,
|
raw,
|
||||||
|
snapshot: document.snapshot,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,13 +187,13 @@ function removeManagedEntries(
|
|||||||
raw: string,
|
raw: string,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
): string {
|
): string {
|
||||||
const removals = managedRemovals(document.hooks, agentId);
|
const removals = managedQwenRemovals(document.hooks, agentId);
|
||||||
for (const event of TOOL_EVENTS) {
|
for (const event of MANAGED_EVENTS) {
|
||||||
const eventRemovals = removals
|
const eventRemovals = removals
|
||||||
.filter((removal) => removal.event === event)
|
.filter((removal) => removal.event === event)
|
||||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||||
for (const removal of eventRemovals) {
|
for (const removal of eventRemovals) {
|
||||||
const groupPath: JSONPath = ["hooks", event, removal.groupIndex];
|
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||||
if (removal.removeGroup) {
|
if (removal.removeGroup) {
|
||||||
raw = change(raw, groupPath, undefined);
|
raw = change(raw, groupPath, undefined);
|
||||||
continue;
|
continue;
|
||||||
@@ -180,19 +201,19 @@ function removeManagedEntries(
|
|||||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||||
(left, right) => right - left,
|
(left, right) => right - left,
|
||||||
)) {
|
)) {
|
||||||
raw = change(raw, [...groupPath, "hooks", handlerIndex], undefined);
|
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eventRemovals.length > 0) {
|
if (eventRemovals.length > 0) {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if ((current.hooks[event] ?? []).length === 0) {
|
if ((current.hooks[event] ?? []).length === 0) {
|
||||||
raw = change(raw, ["hooks", event], undefined);
|
raw = change(raw, ['hooks', event], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||||
raw = change(raw, ["hooks"], undefined);
|
raw = change(raw, ['hooks'], undefined);
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -200,38 +221,33 @@ function removeManagedEntries(
|
|||||||
function appendGroup(
|
function appendGroup(
|
||||||
document: QwenDocument,
|
document: QwenDocument,
|
||||||
raw: string,
|
raw: string,
|
||||||
event: ToolEvent,
|
event: ManagedQwenEvent,
|
||||||
group: QwenGroup,
|
group: QwenGroup,
|
||||||
): string {
|
): string {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hooks[event]) {
|
if (current.hooks[event]) {
|
||||||
return change(
|
return change(raw, ['hooks', event, current.hooks[event].length], group, true);
|
||||||
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,
|
document: QwenDocument,
|
||||||
agentId: string | undefined,
|
agentId: string | undefined,
|
||||||
additions: ReadonlyMap<ToolEvent, QwenGroup>,
|
additions: ReadonlyMap<ManagedQwenEvent, QwenGroup>,
|
||||||
): RenderedDocument {
|
): RenderedQwenDocument {
|
||||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||||
for (const event of TOOL_EVENTS) {
|
for (const event of MANAGED_EVENTS) {
|
||||||
const group = additions.get(event);
|
const group = additions.get(event);
|
||||||
if (group) raw = appendGroup(document, raw, event, group);
|
if (group) raw = appendGroup(document, raw, event, group);
|
||||||
}
|
}
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (
|
if (additions.size === 0 && document.ownedFile && Object.keys(current.root).length === 0) {
|
||||||
additions.size === 0 &&
|
|
||||||
document.ownedFile &&
|
|
||||||
Object.keys(current.root).length === 0
|
|
||||||
) {
|
|
||||||
return { document, changed: true, next: undefined };
|
return { document, changed: true, next: undefined };
|
||||||
}
|
}
|
||||||
return { document, changed: raw !== document.raw, next: raw };
|
return { document, changed: raw !== document.raw, next: raw };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function qwenDocumentLabel(document: QwenDocument): string {
|
||||||
|
return qwenSourceLabel(document.kind);
|
||||||
|
}
|
||||||
@@ -1,352 +1,289 @@
|
|||||||
import os from "node:os";
|
import {
|
||||||
import path from "node:path";
|
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 AGENT_KEY = 'qwen';
|
||||||
export const GUARD_SCRIPT = "guard.js";
|
export const GUARD_SCRIPT = 'guard.js';
|
||||||
export const AUDIT_SCRIPT = "hook.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 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([
|
const KNOWN_EVENTS = new Set([
|
||||||
"PreToolUse",
|
'PreToolUse',
|
||||||
"PostToolUse",
|
'PostToolUse',
|
||||||
"PostToolUseFailure",
|
'PostToolUseFailure',
|
||||||
"PostToolBatch",
|
'PostToolBatch',
|
||||||
"Notification",
|
'Notification',
|
||||||
"UserPromptSubmit",
|
'UserPromptSubmit',
|
||||||
"UserPromptExpansion",
|
'UserPromptExpansion',
|
||||||
"SessionStart",
|
'SessionStart',
|
||||||
"Stop",
|
'Stop',
|
||||||
"MessageDisplay",
|
'MessageDisplay',
|
||||||
"SubagentStart",
|
'SubagentStart',
|
||||||
"SubagentStop",
|
'SubagentStop',
|
||||||
"PreCompact",
|
'PreCompact',
|
||||||
"PostCompact",
|
'PostCompact',
|
||||||
"SessionEnd",
|
'SessionEnd',
|
||||||
"PermissionRequest",
|
'PermissionRequest',
|
||||||
"PermissionDenied",
|
'PermissionDenied',
|
||||||
"StopFailure",
|
'StopFailure',
|
||||||
"TodoCreated",
|
'TodoCreated',
|
||||||
"TodoCompleted",
|
'TodoCompleted',
|
||||||
"InstructionsLoaded",
|
'InstructionsLoaded',
|
||||||
]);
|
]);
|
||||||
const CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]);
|
|
||||||
const HANDLER_KEYS = ["command", "shell", "timeout", "type"];
|
|
||||||
const GROUP_KEYS = ["hooks", "matcher"];
|
|
||||||
|
|
||||||
export type JsonObject = Record<string, unknown>;
|
const REGEX_MATCHER_EVENTS = new Set([
|
||||||
export type ToolEvent = (typeof TOOL_EVENTS)[number];
|
'PreToolUse',
|
||||||
|
'PostToolUse',
|
||||||
|
'PostToolUseFailure',
|
||||||
|
'PermissionRequest',
|
||||||
|
'PermissionDenied',
|
||||||
|
'SubagentStart',
|
||||||
|
'SubagentStop',
|
||||||
|
'PreCompact',
|
||||||
|
'PostCompact',
|
||||||
|
'SessionStart',
|
||||||
|
'SessionEnd',
|
||||||
|
'StopFailure',
|
||||||
|
'Notification',
|
||||||
|
'InstructionsLoaded',
|
||||||
|
'UserPromptExpansion',
|
||||||
|
]);
|
||||||
|
|
||||||
export interface QwenCommandHandler extends JsonObject {
|
export type ManagedQwenEvent = (typeof MANAGED_EVENTS)[number];
|
||||||
readonly type: "command";
|
|
||||||
readonly command: string;
|
export interface QwenHandler extends JsonObject {
|
||||||
|
readonly type: 'command' | 'http' | 'prompt';
|
||||||
|
readonly command?: string;
|
||||||
|
readonly name?: string;
|
||||||
|
readonly shell?: 'bash' | 'powershell';
|
||||||
readonly timeout?: number;
|
readonly timeout?: number;
|
||||||
readonly shell?: "bash" | "powershell";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QwenGroup extends JsonObject {
|
export interface QwenGroup extends JsonObject {
|
||||||
readonly matcher?: string;
|
readonly matcher?: string;
|
||||||
readonly sequential?: boolean;
|
readonly sequential?: boolean;
|
||||||
readonly hooks: JsonObject[];
|
readonly hooks: QwenHandler[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QwenHookSettings extends JsonObject {
|
export type QwenHooks = Record<string, QwenGroup[]>;
|
||||||
readonly PreToolUse?: QwenGroup[];
|
|
||||||
readonly PostToolUse?: QwenGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeContract {
|
export interface QwenRuntimeContract {
|
||||||
readonly agentId: string;
|
readonly agentId: string;
|
||||||
readonly guardPath: string;
|
readonly guardPath: string;
|
||||||
readonly auditPath: string;
|
readonly auditPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManagedRemoval {
|
export interface ManagedRemoval {
|
||||||
readonly event: ToolEvent;
|
readonly event: ManagedQwenEvent;
|
||||||
readonly groupIndex: number;
|
readonly groupIndex: number;
|
||||||
readonly handlerIndexes: number[];
|
readonly handlerIndexes: readonly number[];
|
||||||
readonly removeGroup: boolean;
|
readonly removeGroup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedArgument {
|
function optionalString(value: JsonObject, field: string, label: string): void {
|
||||||
readonly value: string;
|
if (value[field] !== undefined && typeof value[field] !== 'string') {
|
||||||
readonly next: number;
|
throw new Error(`${label} field "${field}" must be a string`);
|
||||||
}
|
|
||||||
|
|
||||||
export function isObject(value: unknown): value is JsonObject {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotePosix(value: string): string {
|
|
||||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotePowerShell(value: string): string {
|
|
||||||
return `'${value.replaceAll("'", "''")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCommand(scriptPath: string): string {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
|
||||||
}
|
|
||||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildGroup(scriptPath: string): QwenGroup {
|
|
||||||
return {
|
|
||||||
matcher: "*",
|
|
||||||
hooks: [
|
|
||||||
{
|
|
||||||
type: "command",
|
|
||||||
command: buildCommand(scriptPath),
|
|
||||||
shell: process.platform === "win32" ? "powershell" : "bash",
|
|
||||||
timeout: HOOK_TIMEOUT_MS,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateRegex(value: string, label: string): void {
|
|
||||||
if (value.trim() === "" || value.trim() === "*") return;
|
|
||||||
try {
|
|
||||||
new RegExp(value);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`${label} must be a valid regular expression: ${message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalString(value: JsonObject, key: string, label: string): void {
|
function optionalBoolean(value: JsonObject, field: string, label: string): void {
|
||||||
if (value[key] !== undefined && typeof value[key] !== "string") {
|
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
||||||
throw new Error(`${label} field "${key}" must be a string`);
|
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalBoolean(value: JsonObject, key: string, label: string): void {
|
function optionalStringMap(value: JsonObject, field: string, label: string): void {
|
||||||
if (value[key] !== undefined && typeof value[key] !== "boolean") {
|
const item = value[field];
|
||||||
throw new Error(`${label} field "${key}" must be a boolean`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalStringMap(
|
|
||||||
value: JsonObject,
|
|
||||||
key: string,
|
|
||||||
label: string,
|
|
||||||
): void {
|
|
||||||
const item = value[key];
|
|
||||||
if (item === undefined) return;
|
if (item === undefined) return;
|
||||||
if (
|
if (!isObject(item) || Object.values(item).some((entry) => typeof entry !== 'string')) {
|
||||||
!isObject(item) ||
|
throw new Error(`${label} field "${field}" must map names to strings`);
|
||||||
Object.values(item).some((entry) => typeof entry !== "string")
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} field "${key}" must contain string values`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateHandler(value: unknown, label: string): JsonObject {
|
function validateTimeout(value: unknown, label: string): void {
|
||||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
if (value === undefined) return;
|
||||||
if (
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||||
value.type !== "command" &&
|
|
||||||
value.type !== "http" &&
|
|
||||||
value.type !== "prompt"
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} type must be "command", "http", or "prompt"`);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
value.timeout !== undefined &&
|
|
||||||
(typeof value.timeout !== "number" ||
|
|
||||||
!Number.isFinite(value.timeout) ||
|
|
||||||
value.timeout < 0)
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
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") {
|
validateTimeout(value.timeout, label);
|
||||||
if (typeof value.command !== "string" || value.command.length === 0) {
|
for (const field of ['name', 'description', 'statusMessage', 'source']) {
|
||||||
throw new Error(`${label} command must be a non-empty string`);
|
optionalString(value, field, label);
|
||||||
}
|
}
|
||||||
optionalStringMap(value, "env", label);
|
if (value.type === 'command') {
|
||||||
optionalBoolean(value, "async", label);
|
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||||
if (
|
throw new Error(`${label} requires a non-empty command`);
|
||||||
value.shell !== undefined &&
|
}
|
||||||
value.shell !== "bash" &&
|
optionalStringMap(value, 'env', label);
|
||||||
value.shell !== "powershell"
|
optionalBoolean(value, 'async', label);
|
||||||
) {
|
if (value.shell !== undefined && !['bash', 'powershell'].includes(String(value.shell))) {
|
||||||
throw new Error(`${label} shell must be "bash" or "powershell"`);
|
throw new Error(`${label} shell must be "bash" or "powershell"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (value.type === "http") {
|
if (value.type === 'http') {
|
||||||
if (typeof value.url !== "string" || value.url.length === 0) {
|
if (typeof value.url !== 'string' || value.url.length === 0) {
|
||||||
throw new Error(`${label} url must be a non-empty string`);
|
throw new Error(`${label} requires a non-empty url`);
|
||||||
}
|
}
|
||||||
optionalStringMap(value, "headers", label);
|
optionalStringMap(value, 'headers', label);
|
||||||
optionalBoolean(value, "once", label);
|
optionalBoolean(value, 'once', label);
|
||||||
optionalString(value, "if", label);
|
optionalString(value, 'if', label);
|
||||||
if (
|
if (value.allowedEnvVars !== undefined && (
|
||||||
value.allowedEnvVars !== undefined &&
|
!Array.isArray(value.allowedEnvVars)
|
||||||
(!Array.isArray(value.allowedEnvVars) ||
|
|| value.allowedEnvVars.some((item) => typeof item !== 'string')
|
||||||
value.allowedEnvVars.some((item) => typeof item !== "string"))
|
)) {
|
||||||
) {
|
|
||||||
throw new Error(`${label} allowedEnvVars must be an array of strings`);
|
throw new Error(`${label} allowedEnvVars must be an array of strings`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (value.type === "prompt") {
|
if (value.type === 'prompt') {
|
||||||
if (typeof value.prompt !== "string" || value.prompt.length === 0) {
|
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||||
throw new Error(`${label} prompt must be a non-empty string`);
|
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(
|
function validateMatcher(matcher: string, event: string, label: string): void {
|
||||||
value: unknown,
|
if (!REGEX_MATCHER_EVENTS.has(event) || matcher.trim() === '' || matcher.trim() === '*') {
|
||||||
label: string,
|
return;
|
||||||
index: number,
|
}
|
||||||
): QwenGroup {
|
try {
|
||||||
const location = `${label}[${index}]`;
|
new RegExp(matcher);
|
||||||
if (!isObject(value)) throw new Error(`${location} must be an object`);
|
} 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 (value.matcher !== undefined) {
|
||||||
if (typeof value.matcher !== "string")
|
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
|
||||||
throw new Error(`${location} matcher must be a string`);
|
validateMatcher(value.matcher, event, label);
|
||||||
validateRegex(value.matcher, `${location} matcher`);
|
|
||||||
}
|
}
|
||||||
if (value.sequential !== undefined && typeof value.sequential !== "boolean") {
|
if (value.sequential !== undefined && typeof value.sequential !== 'boolean') {
|
||||||
throw new Error(`${location} sequential must be a boolean`);
|
throw new Error(`${label} sequential must be a boolean`);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(value.hooks))
|
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||||
throw new Error(`${location} must contain a hooks array`);
|
return {
|
||||||
const hooks = value.hooks.map((handler, handlerIndex) =>
|
...value,
|
||||||
validateHandler(handler, `${location}.hooks[${handlerIndex}]`),
|
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||||
);
|
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||||
return { ...value, hooks } as QwenGroup;
|
)),
|
||||||
|
} as QwenGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readHooks(value: unknown, label: string): QwenHookSettings {
|
export function readQwenHooks(value: unknown): QwenHooks {
|
||||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
if (value === undefined) return {};
|
||||||
const settings: QwenHookSettings = { ...value };
|
if (!isObject(value)) throw new Error('Qwen Code settings field "hooks" must be an object');
|
||||||
for (const [event, definitions] of Object.entries(value)) {
|
const hooks: QwenHooks = {};
|
||||||
if (CONFIG_FIELDS.has(event)) continue;
|
for (const [event, groups] of Object.entries(value)) {
|
||||||
if (!EVENT_NAMES.has(event))
|
if (!KNOWN_EVENTS.has(event)) {
|
||||||
throw new Error(`${label} contains unsupported field "${event}"`);
|
hooks[event] = groups as QwenGroup[];
|
||||||
if (!Array.isArray(definitions))
|
|
||||||
throw new Error(`${label} field "${event}" must be an array`);
|
|
||||||
settings[event] = definitions.map((definition, index) =>
|
|
||||||
validateGroup(definition, `${label} field "${event}"`, index),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readQuotedArgument(
|
|
||||||
command: string,
|
|
||||||
start: number,
|
|
||||||
): ParsedArgument | undefined {
|
|
||||||
if (command[start] !== "'") return undefined;
|
|
||||||
let value = "";
|
|
||||||
for (let index = start + 1; index < command.length; ) {
|
|
||||||
if (process.platform === "win32" && command.startsWith("''", index)) {
|
|
||||||
value += "'";
|
|
||||||
index += 2;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) {
|
if (!Array.isArray(groups)) {
|
||||||
value += "'";
|
throw new Error(`Qwen Code settings field "hooks.${event}" must be an array`);
|
||||||
index += 5;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (command[index] === "'") return { value, next: index + 1 };
|
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||||
value += command[index];
|
|
||||||
index += 1;
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGeneratedCommand(
|
export function buildQwenGroup(scriptPath: string, name: string): QwenGroup {
|
||||||
command: string,
|
return {
|
||||||
): readonly [string, string] | undefined {
|
hooks: [{
|
||||||
if (process.platform === "win32" && !command.startsWith("& "))
|
type: 'command',
|
||||||
return undefined;
|
name,
|
||||||
const start = process.platform === "win32" ? 2 : 0;
|
command: buildQwenCommand(scriptPath),
|
||||||
const executable = readQuotedArgument(command, start);
|
shell: process.platform === 'win32' ? 'powershell' : 'bash',
|
||||||
if (!executable || command[executable.next] !== " ") return undefined;
|
timeout: HOOK_TIMEOUT_MS,
|
||||||
const script = readQuotedArgument(command, executable.next + 1);
|
}],
|
||||||
const expectedSuffix =
|
};
|
||||||
process.platform === "win32" ? "; exit $LASTEXITCODE" : "";
|
|
||||||
if (
|
|
||||||
!script ||
|
|
||||||
command.slice(script.next) !== expectedSuffix ||
|
|
||||||
!executable.value ||
|
|
||||||
!script.value
|
|
||||||
)
|
|
||||||
return undefined;
|
|
||||||
return [executable.value, script.value];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function samePath(left: string, right: string): boolean {
|
function exactCurrentGroup(group: QwenGroup): boolean {
|
||||||
const normalizedLeft = path.resolve(left);
|
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||||
const normalizedRight = path.resolve(right);
|
|
||||||
return process.platform === "win32"
|
|
||||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
||||||
: normalizedLeft === normalizedRight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameAgentId(left: string, right: string): boolean {
|
function exactLegacyGroup(group: QwenGroup): boolean {
|
||||||
return process.platform === "win32"
|
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
|
||||||
? left.toLowerCase() === right.toLowerCase()
|
|
||||||
: left === right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function managedAgentId(
|
function currentReference(
|
||||||
handler: JsonObject,
|
handler: QwenHandler,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
): string | undefined {
|
hookName: string,
|
||||||
if (
|
): QwenRuntimeReference | undefined {
|
||||||
Object.keys(handler).sort().join("\0") !== HANDLER_KEYS.join("\0") ||
|
return Object.keys(handler).sort().join('|') === 'command|name|shell|timeout|type'
|
||||||
handler.type !== "command" ||
|
&& handler.type === 'command'
|
||||||
handler.timeout !== HOOK_TIMEOUT_MS ||
|
&& handler.name === hookName
|
||||||
handler.shell !== (process.platform === "win32" ? "powershell" : "bash") ||
|
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
|
||||||
typeof handler.command !== "string"
|
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||||
)
|
&& typeof handler.command === 'string'
|
||||||
return undefined;
|
? qwenRuntimeReference(handler.command, scriptName)
|
||||||
const parsed = parseGeneratedCommand(handler.command);
|
: undefined;
|
||||||
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
|
|
||||||
const scriptPath = parsed[1];
|
|
||||||
if (path.basename(scriptPath) !== scriptName) return undefined;
|
|
||||||
const agentDirectory = path.dirname(scriptPath);
|
|
||||||
if (
|
|
||||||
!samePath(path.dirname(agentDirectory), path.join(os.homedir(), ".elydora"))
|
|
||||||
)
|
|
||||||
return undefined;
|
|
||||||
const agentId = path.basename(agentDirectory);
|
|
||||||
return agentId && agentId !== "." && agentId !== ".." ? agentId : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exactOwnedGroup(group: QwenGroup, managedIndexes: number[]): boolean {
|
function legacyReference(
|
||||||
return (
|
handler: QwenHandler,
|
||||||
Object.keys(group).sort().join("\0") === GROUP_KEYS.join("\0") &&
|
scriptName: string,
|
||||||
group.matcher === "*" &&
|
): QwenRuntimeReference | undefined {
|
||||||
managedIndexes.length > 0 &&
|
return Object.keys(handler).sort().join('|') === 'command|shell|timeout|type'
|
||||||
managedIndexes.length === group.hooks.length
|
&& 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(
|
function managedReference(
|
||||||
settings: QwenHookSettings,
|
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,
|
agentId?: string,
|
||||||
): ManagedRemoval[] {
|
): ManagedRemoval[] {
|
||||||
const removals: ManagedRemoval[] = [];
|
const removals: ManagedRemoval[] = [];
|
||||||
for (const [event, scriptName] of [
|
for (const [event, scriptName, hookName] of EVENT_CONTRACTS) {
|
||||||
["PreToolUse", GUARD_SCRIPT],
|
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
||||||
["PostToolUse", AUDIT_SCRIPT],
|
|
||||||
] as const) {
|
|
||||||
(settings[event] ?? []).forEach((group, groupIndex) => {
|
|
||||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||||
const managedId = managedAgentId(handler, scriptName);
|
const reference = managedReference(handler, scriptName, hookName, true);
|
||||||
return managedId &&
|
return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId))
|
||||||
(agentId === undefined || sameAgentId(managedId, agentId))
|
|
||||||
? [handlerIndex]
|
? [handlerIndex]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
@@ -355,7 +292,8 @@ export function managedRemovals(
|
|||||||
event,
|
event,
|
||||||
groupIndex,
|
groupIndex,
|
||||||
handlerIndexes,
|
handlerIndexes,
|
||||||
removeGroup: exactOwnedGroup(group, handlerIndexes),
|
removeGroup: (exactCurrentGroup(group) || exactLegacyGroup(group))
|
||||||
|
&& handlerIndexes.length === group.hooks.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -363,30 +301,47 @@ export function managedRemovals(
|
|||||||
return removals;
|
return removals;
|
||||||
}
|
}
|
||||||
|
|
||||||
function managedIds(groups: QwenGroup[], scriptName: string): Set<string> {
|
function referencesForEvent(
|
||||||
const ids = new Set<string>();
|
groups: readonly QwenGroup[],
|
||||||
|
scriptName: string,
|
||||||
|
hookName: string,
|
||||||
|
): Map<string, QwenRuntimeReference[]> {
|
||||||
|
const references = new Map<string, QwenRuntimeReference[]>();
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
|
if (!exactCurrentGroup(group)) continue;
|
||||||
for (const handler of group.hooks) {
|
for (const handler of group.hooks) {
|
||||||
const agentId = managedAgentId(handler, scriptName);
|
const reference = currentReference(handler, scriptName, hookName);
|
||||||
if (agentId) ids.add(agentId);
|
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(
|
export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
|
||||||
settings: QwenHookSettings,
|
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME);
|
||||||
): RuntimeContract[] {
|
const posts = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME);
|
||||||
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
|
const failures = referencesForEvent(
|
||||||
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
|
hooks.PostToolUseFailure ?? [],
|
||||||
const root = path.join(os.homedir(), ".elydora");
|
AUDIT_SCRIPT,
|
||||||
return [...guards]
|
AUDIT_HOOK_NAME,
|
||||||
.filter((agentId) =>
|
);
|
||||||
[...audits].some((auditId) => sameAgentId(auditId, agentId)),
|
const contracts: QwenRuntimeContract[] = [];
|
||||||
)
|
for (const [key, guard] of guards) {
|
||||||
.map((agentId) => ({
|
const post = posts.get(key);
|
||||||
agentId,
|
const failure = failures.get(key);
|
||||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
if (!sameQwenPath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||||
}));
|
contracts.push({
|
||||||
|
agentId: guard[0].agentId,
|
||||||
|
guardPath: guard[0].scriptPath,
|
||||||
|
auditPath: post[0].scriptPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return contracts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { isObject, type JsonObject };
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import type { InstallConfig } from './base.js';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
AUDIT_SCRIPT,
|
||||||
|
GUARD_SCRIPT,
|
||||||
|
} from './qwen-contract.js';
|
||||||
|
import { sameQwenPath } from './qwen-command.js';
|
||||||
|
import {
|
||||||
|
qwenDocumentLabel,
|
||||||
|
type RenderedQwenDocument,
|
||||||
|
} from './qwen-config.js';
|
||||||
|
import {
|
||||||
|
commitManagedInstallation,
|
||||||
|
preflightManagedInstallation,
|
||||||
|
prepareManagedInstallation,
|
||||||
|
type ManagedRuntimePaths,
|
||||||
|
type PreparedManagedInstallation,
|
||||||
|
type RenameFile,
|
||||||
|
} from './managed-installation.js';
|
||||||
|
import {
|
||||||
|
commitManagedTransaction,
|
||||||
|
prepareManagedFileChange,
|
||||||
|
type PreparedManagedTransaction,
|
||||||
|
} from './managed-transaction.js';
|
||||||
|
import {
|
||||||
|
requireQwenHooksEnabled,
|
||||||
|
type QwenSources,
|
||||||
|
} from './qwen-sources.js';
|
||||||
|
|
||||||
|
const DISPLAY_NAME = 'Qwen Code';
|
||||||
|
const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory';
|
||||||
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type QwenRuntimePaths = ManagedRuntimePaths;
|
||||||
|
export type PreparedQwenInstallation = PreparedManagedInstallation;
|
||||||
|
export type { RenameFile };
|
||||||
|
|
||||||
|
export async function preflightQwenInstallation(
|
||||||
|
config: InstallConfig,
|
||||||
|
sources: QwenSources,
|
||||||
|
): Promise<QwenRuntimePaths> {
|
||||||
|
requireQwenHooksEnabled(sources);
|
||||||
|
return preflightManagedInstallation({
|
||||||
|
agentKey: AGENT_KEY,
|
||||||
|
hookLocations: [{
|
||||||
|
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||||
|
filePath: sources.user.filePath,
|
||||||
|
}],
|
||||||
|
config,
|
||||||
|
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOnlyPreconditions(sources: QwenSources, changedPath?: string) {
|
||||||
|
return sources.preconditions.filter((condition) => (
|
||||||
|
changedPath === undefined || !sameQwenPath(condition.filePath, changedPath)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareQwenInstallation(
|
||||||
|
config: InstallConfig,
|
||||||
|
sources: QwenSources,
|
||||||
|
rendered: RenderedQwenDocument,
|
||||||
|
): Promise<PreparedQwenInstallation> {
|
||||||
|
await preflightQwenInstallation(config, sources);
|
||||||
|
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||||
|
const prepared = await prepareManagedInstallation({
|
||||||
|
agentKey: AGENT_KEY,
|
||||||
|
displayName: DISPLAY_NAME,
|
||||||
|
hookSources: [{
|
||||||
|
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||||
|
label: qwenDocumentLabel(rendered.document),
|
||||||
|
filePath: rendered.document.filePath,
|
||||||
|
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||||
|
expectedSnapshot: rendered.document.snapshot,
|
||||||
|
source: settingsSource,
|
||||||
|
}],
|
||||||
|
config,
|
||||||
|
auditOptions: { nativePayload: true },
|
||||||
|
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||||
|
return {
|
||||||
|
...prepared,
|
||||||
|
transaction: {
|
||||||
|
...prepared.transaction,
|
||||||
|
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitQwenInstallation(
|
||||||
|
prepared: PreparedQwenInstallation,
|
||||||
|
renameFile?: RenameFile,
|
||||||
|
): Promise<void> {
|
||||||
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareQwenUninstall(
|
||||||
|
sources: QwenSources,
|
||||||
|
rendered: RenderedQwenDocument,
|
||||||
|
): Promise<PreparedManagedTransaction> {
|
||||||
|
const change = rendered.changed
|
||||||
|
? await prepareManagedFileChange({
|
||||||
|
filePath: rendered.document.filePath,
|
||||||
|
label: qwenDocumentLabel(rendered.document),
|
||||||
|
next: rendered.next,
|
||||||
|
mode: 0o600,
|
||||||
|
maximumBytes: MAX_SOURCE_BYTES,
|
||||||
|
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||||
|
expectedSnapshot: rendered.document.snapshot,
|
||||||
|
verifyExpectedSource: true,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
displayName: DISPLAY_NAME,
|
||||||
|
operation: 'uninstall',
|
||||||
|
directories: [{
|
||||||
|
path: path.dirname(rendered.document.filePath),
|
||||||
|
label: SETTINGS_DIRECTORY_LABEL,
|
||||||
|
}],
|
||||||
|
changes: change ? [change] : [],
|
||||||
|
preconditions: readOnlyPreconditions(
|
||||||
|
sources,
|
||||||
|
rendered.changed ? rendered.document.filePath : undefined,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitQwenUninstall(
|
||||||
|
prepared: PreparedManagedTransaction,
|
||||||
|
renameFile?: RenameFile,
|
||||||
|
): Promise<void> {
|
||||||
|
await commitManagedTransaction(prepared, renameFile);
|
||||||
|
}
|
||||||
@@ -1,344 +1,99 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import os from 'node:os';
|
||||||
import fsp from "node:fs/promises";
|
import path from 'node:path';
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import { generateGuardScript } from './guard-template.js';
|
||||||
import os from "node:os";
|
import { generateHookScript } from './hook-template.js';
|
||||||
import path from "node:path";
|
|
||||||
import { parse as parseDotenv } from "dotenv";
|
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
type JsonObject,
|
type QwenRuntimeContract,
|
||||||
type RuntimeContract,
|
} from './qwen-contract.js';
|
||||||
isObject,
|
import { sameQwenAgentId, sameQwenPath } from './qwen-command.js';
|
||||||
} from "./qwen-contract.js";
|
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||||
import {
|
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||||
type QwenDocument,
|
|
||||||
type RenderedDocument,
|
|
||||||
createOwnedDocument,
|
|
||||||
parseDocument,
|
|
||||||
} from "./qwen-config.js";
|
|
||||||
|
|
||||||
interface FileChange {
|
function requireNonEmptyString(value: unknown, field: string, configPath: string): string {
|
||||||
readonly filePath: string;
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
readonly label: string;
|
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||||
readonly original?: string;
|
|
||||||
readonly next?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StagedChange {
|
|
||||||
readonly change: FileChange;
|
|
||||||
readonly tempPath?: string;
|
|
||||||
readonly rollbackPath?: string;
|
|
||||||
committed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function asError(error: unknown): Error {
|
|
||||||
return error instanceof Error ? error : new Error(String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasErrorCode(error: unknown, code: string): boolean {
|
|
||||||
return isObject(error) && error.code === code;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readOptional(
|
|
||||||
filePath: string,
|
|
||||||
label: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
try {
|
|
||||||
return await fsp.readFile(filePath, "utf-8");
|
|
||||||
} catch (error) {
|
|
||||||
if (hasErrorCode(error, "ENOENT")) return undefined;
|
|
||||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
|
||||||
cause: asError(error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function defaultQwenHome(): string {
|
|
||||||
const homeDir = os.homedir();
|
|
||||||
return homeDir
|
|
||||||
? path.join(homeDir, ".qwen")
|
|
||||||
: path.join(os.tmpdir(), ".qwen");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveStoragePath(value: string): string {
|
|
||||||
let resolved = value;
|
|
||||||
if (
|
|
||||||
resolved === "~" ||
|
|
||||||
resolved.startsWith("~/") ||
|
|
||||||
resolved.startsWith("~\\")
|
|
||||||
) {
|
|
||||||
const segments =
|
|
||||||
resolved === "~"
|
|
||||||
? []
|
|
||||||
: resolved
|
|
||||||
.slice(2)
|
|
||||||
.split(/[/\\]+/)
|
|
||||||
.filter(Boolean);
|
|
||||||
resolved = path.join(os.homedir(), ...segments);
|
|
||||||
}
|
|
||||||
return path.isAbsolute(resolved) ? resolved : path.resolve(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function qwenHomeFromEnvFile(
|
|
||||||
filePath: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const raw = await readOptional(filePath, "Qwen home environment");
|
|
||||||
if (raw === undefined) return undefined;
|
|
||||||
const value = parseDotenv(raw).QWEN_HOME;
|
|
||||||
return value || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveQwenHome(): Promise<string> {
|
|
||||||
const initialValue = process.env.QWEN_HOME;
|
|
||||||
const initialHome = initialValue
|
|
||||||
? resolveStoragePath(initialValue)
|
|
||||||
: defaultQwenHome();
|
|
||||||
if (Object.hasOwn(process.env, "QWEN_HOME")) return initialHome;
|
|
||||||
const candidates = [
|
|
||||||
path.join(initialHome, ".env"),
|
|
||||||
path.join(path.dirname(initialHome), ".env"),
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const discovered = await qwenHomeFromEnvFile(candidate);
|
|
||||||
if (discovered) return resolveStoragePath(discovered);
|
|
||||||
}
|
|
||||||
return initialHome;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readDocument(): Promise<QwenDocument> {
|
|
||||||
const configPath = path.join(await resolveQwenHome(), "settings.json");
|
|
||||||
const raw = await readOptional(configPath, "Qwen Code settings");
|
|
||||||
return raw === undefined
|
|
||||||
? createOwnedDocument(configPath)
|
|
||||||
: parseDocument({ exists: true, filePath: configPath, raw });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileChange(rendered: RenderedDocument): FileChange | undefined {
|
|
||||||
if (!rendered.changed) return undefined;
|
|
||||||
return {
|
|
||||||
filePath: rendered.document.filePath,
|
|
||||||
label: "Qwen Code settings",
|
|
||||||
original: rendered.document.exists ? rendered.document.raw : undefined,
|
|
||||||
next: rendered.next,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unlinkOptional(filePath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fsp.unlink(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeExclusive(
|
|
||||||
filePath: string,
|
|
||||||
contents: string,
|
|
||||||
label: string,
|
|
||||||
): Promise<void> {
|
|
||||||
let handle: FileHandle | undefined;
|
|
||||||
try {
|
|
||||||
handle = await fsp.open(filePath, "wx", 0o600);
|
|
||||||
await handle.writeFile(contents, "utf-8");
|
|
||||||
await handle.sync();
|
|
||||||
await handle.close();
|
|
||||||
} catch (error) {
|
|
||||||
const failures = [asError(error)];
|
|
||||||
if (handle) {
|
|
||||||
try {
|
|
||||||
await handle.close();
|
|
||||||
} catch (closeError) {
|
|
||||||
failures.push(asError(closeError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await unlinkOptional(filePath);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
failures.push(asError(cleanupError));
|
|
||||||
}
|
|
||||||
if (failures.length > 1)
|
|
||||||
throw new AggregateError(failures, `Stage ${label}`);
|
|
||||||
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
|
|
||||||
cause: failures[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertUnchanged(change: FileChange): Promise<void> {
|
|
||||||
const current = await readOptional(change.filePath, change.label);
|
|
||||||
if (current !== change.original) {
|
|
||||||
throw new Error(
|
|
||||||
`${change.label} changed during installation: ${change.filePath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stage(change: FileChange): Promise<StagedChange> {
|
|
||||||
await assertUnchanged(change);
|
|
||||||
const directory = path.dirname(change.filePath);
|
|
||||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
||||||
const token = randomUUID();
|
|
||||||
const tempPath =
|
|
||||||
change.next === undefined
|
|
||||||
? undefined
|
|
||||||
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
|
|
||||||
const rollbackPath =
|
|
||||||
change.original === undefined
|
|
||||||
? undefined
|
|
||||||
: path.join(
|
|
||||||
directory,
|
|
||||||
`.${path.basename(change.filePath)}.${token}.rollback`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
|
|
||||||
if (rollbackPath && change.next !== undefined) {
|
|
||||||
await writeExclusive(
|
|
||||||
rollbackPath,
|
|
||||||
change.original!,
|
|
||||||
`${change.label} rollback`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const failures = [asError(error)];
|
|
||||||
for (const stagedPath of [tempPath, rollbackPath]) {
|
|
||||||
if (!stagedPath) continue;
|
|
||||||
try {
|
|
||||||
await unlinkOptional(stagedPath);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
failures.push(asError(cleanupError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failures.length > 1)
|
|
||||||
throw new AggregateError(failures, `Stage ${change.label}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return { change, tempPath, rollbackPath, committed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commit(staged: StagedChange): Promise<void> {
|
|
||||||
await assertUnchanged(staged.change);
|
|
||||||
if (staged.change.next === undefined) {
|
|
||||||
if (!staged.rollbackPath)
|
|
||||||
throw new Error(`Missing rollback path for ${staged.change.label}`);
|
|
||||||
await fsp.rename(staged.change.filePath, staged.rollbackPath);
|
|
||||||
} else {
|
|
||||||
if (!staged.tempPath)
|
|
||||||
throw new Error(`Missing staged file for ${staged.change.label}`);
|
|
||||||
await fsp.rename(staged.tempPath, staged.change.filePath);
|
|
||||||
}
|
|
||||||
staged.committed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rollback(staged: StagedChange): Promise<void> {
|
|
||||||
if (!staged.committed) return;
|
|
||||||
if (staged.change.original === undefined) {
|
|
||||||
await unlinkOptional(staged.change.filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!staged.rollbackPath)
|
|
||||||
throw new Error(`Missing rollback data for ${staged.change.label}`);
|
|
||||||
await fsp.rename(staged.rollbackPath, staged.change.filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanup(staged: StagedChange): Promise<void> {
|
|
||||||
const stagedPaths = [staged.tempPath, staged.rollbackPath].filter(
|
|
||||||
(filePath): filePath is string => filePath !== undefined,
|
|
||||||
);
|
|
||||||
await Promise.all(stagedPaths.map(unlinkOptional));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
|
||||||
const change = fileChange(rendered);
|
|
||||||
if (!change) return;
|
|
||||||
const staged = await stage(change);
|
|
||||||
try {
|
|
||||||
await commit(staged);
|
|
||||||
} catch (error) {
|
|
||||||
const failures = [asError(error)];
|
|
||||||
try {
|
|
||||||
await rollback(staged);
|
|
||||||
} catch (rollbackError) {
|
|
||||||
failures.push(asError(rollbackError));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await cleanup(staged);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
failures.push(asError(cleanupError));
|
|
||||||
}
|
|
||||||
if (failures.length > 1)
|
|
||||||
throw new AggregateError(failures, "Write Qwen Code settings");
|
|
||||||
throw new Error(`Write Qwen Code settings: ${errorMessage(error)}`, {
|
|
||||||
cause: failures[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await cleanup(staged);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function regularFileExists(
|
|
||||||
filePath: string,
|
|
||||||
label: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return (await fsp.stat(filePath)).isFile();
|
|
||||||
} catch (error) {
|
|
||||||
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"))
|
|
||||||
return false;
|
|
||||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
|
||||||
cause: asError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireRuntime(
|
|
||||||
filePath: string,
|
|
||||||
label: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!filePath) throw new Error(`${label} path is required`);
|
|
||||||
if (!(await regularFileExists(filePath, label)))
|
|
||||||
throw new Error(`${label} is missing: ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readRuntimeConfig(
|
|
||||||
filePath: string,
|
|
||||||
): Promise<JsonObject | undefined> {
|
|
||||||
const raw = await readOptional(filePath, "Elydora runtime config");
|
|
||||||
if (raw === undefined) return undefined;
|
|
||||||
let value: unknown;
|
|
||||||
try {
|
|
||||||
value = JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`,
|
|
||||||
{
|
|
||||||
cause: asError(error),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!isObject(value))
|
|
||||||
throw new Error(
|
|
||||||
`Elydora runtime config at ${filePath} must contain a JSON object`,
|
|
||||||
);
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runtimeFilesExist(
|
function validateRuntimeConfig(
|
||||||
contracts: RuntimeContract[],
|
config: JsonObject,
|
||||||
|
contract: QwenRuntimeContract,
|
||||||
|
configPath: string,
|
||||||
|
): void {
|
||||||
|
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||||
|
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||||
|
if (extra) {
|
||||||
|
throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||||
|
}
|
||||||
|
requireNonEmptyString(config.org_id, 'org_id', configPath);
|
||||||
|
requireNonEmptyString(config.kid, 'kid', configPath);
|
||||||
|
const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath);
|
||||||
|
if (!sameQwenAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
|
||||||
|
throw new Error(`Elydora runtime identity does not match Qwen Code hooks: ${configPath}`);
|
||||||
|
}
|
||||||
|
if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath);
|
||||||
|
const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath);
|
||||||
|
let baseUrl: URL;
|
||||||
|
try {
|
||||||
|
baseUrl = new URL(rawBaseUrl);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||||
|
cause: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||||
|
|| !baseUrl.hostname
|
||||||
|
|| baseUrl.username
|
||||||
|
|| baseUrl.password
|
||||||
|
|| baseUrl.search
|
||||||
|
|| baseUrl.hash) {
|
||||||
|
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||||
|
const bytes = Buffer.from(contents, 'base64url');
|
||||||
|
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||||
|
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeContractExists(contract: QwenRuntimeContract): Promise<boolean> {
|
||||||
|
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||||
|
const agentDirectory = path.dirname(contract.guardPath);
|
||||||
|
if (!sameQwenPath(path.dirname(agentDirectory), runtimeRoot)
|
||||||
|
|| !sameQwenPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false;
|
||||||
|
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||||
|
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const configPath = path.join(agentDirectory, 'config.json');
|
||||||
|
const keyPath = path.join(agentDirectory, 'private.key');
|
||||||
|
const [config, key, guard, audit] = await Promise.all([
|
||||||
|
readPhysicalFile(configPath, 'Elydora runtime config'),
|
||||||
|
readPhysicalFile(keyPath, 'Elydora private key'),
|
||||||
|
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||||
|
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||||
|
]);
|
||||||
|
if (!config || !key || !guard || !audit) return false;
|
||||||
|
const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`);
|
||||||
|
validateRuntimeConfig(parsed, contract, configPath);
|
||||||
|
validatePrivateKey(key.contents, keyPath);
|
||||||
|
return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
|
||||||
|
&& audit.contents === generateHookScript(
|
||||||
|
AGENT_KEY,
|
||||||
|
contract.agentId,
|
||||||
|
{ nativePayload: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qwenRuntimeFilesExist(
|
||||||
|
contracts: readonly QwenRuntimeContract[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const contract of contracts) {
|
for (const contract of contracts) {
|
||||||
const agentDirectory = path.dirname(contract.guardPath);
|
if (await runtimeContractExists(contract)) return true;
|
||||||
const runtimeConfig = await readRuntimeConfig(
|
|
||||||
path.join(agentDirectory, "config.json"),
|
|
||||||
);
|
|
||||||
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
|
|
||||||
const files = await Promise.all([
|
|
||||||
regularFileExists(contract.guardPath, "Elydora guard runtime"),
|
|
||||||
regularFileExists(contract.auditPath, "Elydora audit runtime"),
|
|
||||||
]);
|
|
||||||
if (files.every(Boolean)) return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { parse as parseDotenv } from 'dotenv';
|
||||||
|
import {
|
||||||
|
createQwenDocument,
|
||||||
|
parseQwenDocument,
|
||||||
|
parseQwenJsoncObject,
|
||||||
|
qwenDocumentLabel,
|
||||||
|
qwenSourceLabel,
|
||||||
|
type QwenDocument,
|
||||||
|
type QwenDocumentKind,
|
||||||
|
} from './qwen-config.js';
|
||||||
|
import { readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||||
|
|
||||||
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||||
|
const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const;
|
||||||
|
|
||||||
|
export interface QwenSourcePrecondition {
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly maximumBytes: number;
|
||||||
|
readonly original?: FileSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QwenDisableControl {
|
||||||
|
readonly disabled: boolean;
|
||||||
|
readonly source?: QwenDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QwenSources {
|
||||||
|
readonly qwenHome: string;
|
||||||
|
readonly systemDefaults: QwenDocument;
|
||||||
|
readonly user: QwenDocument;
|
||||||
|
readonly workspace: QwenDocument;
|
||||||
|
readonly system: QwenDocument;
|
||||||
|
readonly workspaceActive: boolean;
|
||||||
|
readonly workspaceTrusted: boolean;
|
||||||
|
readonly disableControl: QwenDisableControl;
|
||||||
|
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoutingResult {
|
||||||
|
readonly qwenHome: string;
|
||||||
|
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultQwenHome(): string {
|
||||||
|
const home = os.homedir();
|
||||||
|
return home ? path.join(home, '.qwen') : path.join(os.tmpdir(), '.qwen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(value: string): string {
|
||||||
|
let resolved = value;
|
||||||
|
if (resolved === '~' || resolved.startsWith('~/') || resolved.startsWith('~\\')) {
|
||||||
|
const segments = resolved === '~'
|
||||||
|
? []
|
||||||
|
: resolved.slice(2).split(/[/\\]+/).filter(Boolean);
|
||||||
|
resolved = path.join(os.homedir(), ...segments);
|
||||||
|
}
|
||||||
|
return path.isAbsolute(resolved) ? resolved : path.resolve(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentValue(key: string): { own: boolean; value?: string } {
|
||||||
|
return {
|
||||||
|
own: Object.hasOwn(process.env, key),
|
||||||
|
value: process.env[key],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveQwenRouting(): Promise<RoutingResult> {
|
||||||
|
const values = new Map<string, string | undefined>();
|
||||||
|
const owned = new Set<string>();
|
||||||
|
for (const key of HOME_ENV_KEYS) {
|
||||||
|
const current = environmentValue(key);
|
||||||
|
values.set(key, current.value);
|
||||||
|
if (current.own) owned.add(key);
|
||||||
|
}
|
||||||
|
if (HOME_ENV_KEYS.every((key) => values.get(key))) {
|
||||||
|
return {
|
||||||
|
qwenHome: resolveConfigPath(values.get('QWEN_HOME')!),
|
||||||
|
preconditions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialQwenHome = values.get('QWEN_HOME');
|
||||||
|
const initialDirectory = initialQwenHome
|
||||||
|
? resolveConfigPath(initialQwenHome)
|
||||||
|
: defaultQwenHome();
|
||||||
|
const candidates = [path.join(initialDirectory, '.env')];
|
||||||
|
if (!initialQwenHome) candidates.push(path.join(path.dirname(initialDirectory), '.env'));
|
||||||
|
const preconditions: QwenSourcePrecondition[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
const readCandidate = async (filePath: string): Promise<void> => {
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||||
|
if (visited.has(key)) return;
|
||||||
|
visited.add(key);
|
||||||
|
const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment');
|
||||||
|
preconditions.push({
|
||||||
|
filePath: resolved,
|
||||||
|
label: 'Qwen Code home environment',
|
||||||
|
maximumBytes: MAX_SOURCE_BYTES,
|
||||||
|
original: snapshot,
|
||||||
|
});
|
||||||
|
if (!snapshot) return;
|
||||||
|
const parsed = parseDotenv(snapshot.contents);
|
||||||
|
for (const envKey of HOME_ENV_KEYS) {
|
||||||
|
const value = parsed[envKey];
|
||||||
|
if (value && !owned.has(envKey)) {
|
||||||
|
values.set(envKey, value);
|
||||||
|
owned.add(envKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const candidate of candidates) await readCandidate(candidate);
|
||||||
|
const discoveredHome = values.get('QWEN_HOME');
|
||||||
|
if (discoveredHome && discoveredHome !== initialQwenHome) {
|
||||||
|
const discoveredDirectory = resolveConfigPath(discoveredHome);
|
||||||
|
if (discoveredDirectory !== initialDirectory) {
|
||||||
|
await readCandidate(path.join(discoveredDirectory, '.env'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
qwenHome: values.get('QWEN_HOME')
|
||||||
|
? resolveConfigPath(values.get('QWEN_HOME')!)
|
||||||
|
: defaultQwenHome(),
|
||||||
|
preconditions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemSettingsPath(): string {
|
||||||
|
const configured = process.env.QWEN_CODE_SYSTEM_SETTINGS_PATH;
|
||||||
|
if (configured) return path.resolve(configured);
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return '/Library/Application Support/QwenCode/settings.json';
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32') return 'C:\\ProgramData\\qwen-code\\settings.json';
|
||||||
|
return '/etc/qwen-code/settings.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemDefaultsPath(systemPath: string): string {
|
||||||
|
const configured = process.env.QWEN_CODE_SYSTEM_DEFAULTS_PATH;
|
||||||
|
return configured
|
||||||
|
? path.resolve(configured)
|
||||||
|
: path.join(path.dirname(systemPath), 'system-defaults.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDocument(
|
||||||
|
kind: QwenDocumentKind,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<QwenDocument> {
|
||||||
|
const snapshot = await readPhysicalFile(filePath, qwenSourceLabel(kind));
|
||||||
|
return snapshot
|
||||||
|
? parseQwenDocument({
|
||||||
|
kind,
|
||||||
|
exists: true,
|
||||||
|
filePath,
|
||||||
|
raw: snapshot.contents,
|
||||||
|
snapshot,
|
||||||
|
})
|
||||||
|
: createQwenDocument(kind, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canonicalPath(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fsp.realpath(filePath);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparisonPath(filePath: string): string {
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(child: string, parent: string): boolean {
|
||||||
|
const relative = path.relative(comparisonPath(parent), comparisonPath(child));
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceTrust(
|
||||||
|
system: QwenDocument,
|
||||||
|
user: QwenDocument,
|
||||||
|
qwenHome: string,
|
||||||
|
workspacePath: string,
|
||||||
|
): Promise<{ trusted: boolean; precondition?: QwenSourcePrecondition }> {
|
||||||
|
const enabled = user.folderTrustEnabled ?? system.folderTrustEnabled ?? false;
|
||||||
|
if (!enabled) return { trusted: true };
|
||||||
|
const configured = process.env.QWEN_CODE_TRUSTED_FOLDERS_PATH;
|
||||||
|
const filePath = configured
|
||||||
|
? path.resolve(configured)
|
||||||
|
: path.join(qwenHome, 'trustedFolders.json');
|
||||||
|
const snapshot = await readPhysicalFile(filePath, 'Qwen Code trusted folders');
|
||||||
|
const precondition = {
|
||||||
|
filePath,
|
||||||
|
label: 'Qwen Code trusted folders',
|
||||||
|
maximumBytes: MAX_SOURCE_BYTES,
|
||||||
|
original: snapshot,
|
||||||
|
};
|
||||||
|
if (!snapshot) return { trusted: true, precondition };
|
||||||
|
const rules = parseQwenJsoncObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`);
|
||||||
|
for (const [rulePath, level] of Object.entries(rules)) {
|
||||||
|
if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) {
|
||||||
|
throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workspace = await canonicalPath(workspacePath);
|
||||||
|
for (const [rulePath, level] of Object.entries(rules)) {
|
||||||
|
const canonicalRule = await canonicalPath(String(rulePath));
|
||||||
|
const trustRoot = level === 'TRUST_PARENT' ? path.dirname(canonicalRule) : canonicalRule;
|
||||||
|
if ((level === 'TRUST_FOLDER' || level === 'TRUST_PARENT')
|
||||||
|
&& isWithin(workspace, trustRoot)) return { trusted: true, precondition };
|
||||||
|
}
|
||||||
|
for (const [rulePath, level] of Object.entries(rules)) {
|
||||||
|
if (level === 'DO_NOT_TRUST'
|
||||||
|
&& comparisonPath(workspace) === comparisonPath(await canonicalPath(rulePath))) {
|
||||||
|
return { trusted: false, precondition };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { trusted: true, precondition };
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveDisable(
|
||||||
|
systemDefaults: QwenDocument,
|
||||||
|
user: QwenDocument,
|
||||||
|
workspace: QwenDocument,
|
||||||
|
system: QwenDocument,
|
||||||
|
useWorkspace: boolean,
|
||||||
|
): QwenDisableControl {
|
||||||
|
let disabled = false;
|
||||||
|
let source: QwenDocument | undefined;
|
||||||
|
const documents = [systemDefaults, user, ...(useWorkspace ? [workspace] : []), system];
|
||||||
|
for (const document of documents) {
|
||||||
|
if (document.disableAllHooks === undefined) continue;
|
||||||
|
disabled = document.disableAllHooks;
|
||||||
|
source = document;
|
||||||
|
}
|
||||||
|
return { disabled, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcePrecondition(document: QwenDocument): QwenSourcePrecondition {
|
||||||
|
return {
|
||||||
|
filePath: document.filePath,
|
||||||
|
label: qwenDocumentLabel(document),
|
||||||
|
maximumBytes: MAX_SOURCE_BYTES,
|
||||||
|
original: document.snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicatePreconditions(
|
||||||
|
values: readonly QwenSourcePrecondition[],
|
||||||
|
): QwenSourcePrecondition[] {
|
||||||
|
const result = new Map<string, QwenSourcePrecondition>();
|
||||||
|
for (const value of values) {
|
||||||
|
const key = comparisonPath(value.filePath);
|
||||||
|
if (!result.has(key)) result.set(key, value);
|
||||||
|
}
|
||||||
|
return [...result.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readQwenSources(): Promise<QwenSources> {
|
||||||
|
const routing = await resolveQwenRouting();
|
||||||
|
const systemPath = systemSettingsPath();
|
||||||
|
const workspacePath = path.join(process.cwd(), '.qwen', 'settings.json');
|
||||||
|
const system = await readDocument('system', systemPath);
|
||||||
|
const systemDefaults = await readDocument(
|
||||||
|
'system-defaults',
|
||||||
|
systemDefaultsPath(systemPath),
|
||||||
|
);
|
||||||
|
const user = await readDocument('user', path.join(routing.qwenHome, 'settings.json'));
|
||||||
|
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||||
|
canonicalPath(process.cwd()),
|
||||||
|
canonicalPath(os.homedir()),
|
||||||
|
]);
|
||||||
|
const workspaceActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
||||||
|
const workspace = workspaceActive
|
||||||
|
? await readDocument('workspace', workspacePath)
|
||||||
|
: createQwenDocument('workspace', workspacePath);
|
||||||
|
const trust = workspaceActive
|
||||||
|
? await workspaceTrust(system, user, routing.qwenHome, canonicalWorkspace)
|
||||||
|
: { trusted: false as const };
|
||||||
|
const useWorkspace = workspaceActive && trust.trusted;
|
||||||
|
const preconditions = deduplicatePreconditions([
|
||||||
|
...routing.preconditions,
|
||||||
|
sourcePrecondition(systemDefaults),
|
||||||
|
sourcePrecondition(user),
|
||||||
|
...(workspaceActive ? [sourcePrecondition(workspace)] : []),
|
||||||
|
sourcePrecondition(system),
|
||||||
|
...(trust.precondition ? [trust.precondition] : []),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
qwenHome: routing.qwenHome,
|
||||||
|
systemDefaults,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
system,
|
||||||
|
workspaceActive,
|
||||||
|
workspaceTrusted: trust.trusted,
|
||||||
|
disableControl: effectiveDisable(
|
||||||
|
systemDefaults,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
system,
|
||||||
|
useWorkspace,
|
||||||
|
),
|
||||||
|
preconditions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireQwenHooksEnabled(sources: QwenSources): void {
|
||||||
|
if (!sources.disableControl.disabled) return;
|
||||||
|
const source = sources.disableControl.source;
|
||||||
|
const location = source ? `${qwenDocumentLabel(source)} at ${source.filePath}` : 'effective settings';
|
||||||
|
throw new Error(`Qwen Code hooks are disabled by disableAllHooks in ${location}`);
|
||||||
|
}
|
||||||
@@ -1,49 +1,74 @@
|
|||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from "./base.js";
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
buildGroup,
|
AUDIT_HOOK_NAME,
|
||||||
runtimeContracts,
|
GUARD_HOOK_NAME,
|
||||||
|
buildQwenGroup,
|
||||||
|
qwenRuntimeContracts,
|
||||||
|
type ManagedQwenEvent,
|
||||||
type QwenGroup,
|
type QwenGroup,
|
||||||
type ToolEvent,
|
} from './qwen-contract.js';
|
||||||
} from "./qwen-contract.js";
|
import { renderQwenDocument } from './qwen-config.js';
|
||||||
import { renderDocument } from "./qwen-config.js";
|
|
||||||
import {
|
import {
|
||||||
readDocument,
|
commitQwenInstallation,
|
||||||
requireRuntime,
|
commitQwenUninstall,
|
||||||
runtimeFilesExist,
|
preflightQwenInstallation,
|
||||||
writeDocument,
|
prepareQwenInstallation,
|
||||||
} from "./qwen-io.js";
|
prepareQwenUninstall,
|
||||||
import { SUPPORTED_AGENTS } from "./registry.js";
|
} 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)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
export const qwenPlugin: AgentPlugin = {
|
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||||
async install(config: InstallConfig): Promise<void> {
|
ManagedQwenEvent,
|
||||||
if (!config.agentId) throw new Error("agentId is required");
|
QwenGroup
|
||||||
const document = await readDocument();
|
> {
|
||||||
await requireRuntime(config.guardScriptPath, "Elydora guard runtime");
|
return new Map([
|
||||||
await requireRuntime(config.hookScriptPath, "Elydora audit runtime");
|
['PreToolUse', buildQwenGroup(guardPath, GUARD_HOOK_NAME)],
|
||||||
const additions = new Map<ToolEvent, QwenGroup>([
|
['PostToolUse', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||||
["PreToolUse", buildGroup(config.guardScriptPath)],
|
['PostToolUseFailure', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||||
["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.");
|
export const qwenPlugin: AgentPlugin = {
|
||||||
|
managesRuntime: true,
|
||||||
|
|
||||||
|
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||||
|
const sources = await readQwenSources();
|
||||||
|
await preflightQwenInstallation(config, sources);
|
||||||
|
},
|
||||||
|
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const sources = await readQwenSources();
|
||||||
|
const paths = await preflightQwenInstallation(config, sources);
|
||||||
|
const rendered = renderQwenDocument(
|
||||||
|
sources.user,
|
||||||
|
undefined,
|
||||||
|
installedGroups(paths.guardPath, paths.auditPath),
|
||||||
|
);
|
||||||
|
await commitQwenInstallation(
|
||||||
|
await prepareQwenInstallation(config, sources, rendered),
|
||||||
|
);
|
||||||
|
console.log(` Qwen Code hooks: ${sources.user.filePath}`);
|
||||||
|
console.log(' Qwen Code verification: run /hooks.');
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(agentId?: string): Promise<void> {
|
async uninstall(agentId?: string): Promise<void> {
|
||||||
const document = await readDocument();
|
const sources = await readQwenSources();
|
||||||
if (!document.exists) return;
|
const rendered = renderQwenDocument(sources.user, agentId, new Map());
|
||||||
await writeDocument(renderDocument(document, agentId, new Map()));
|
if (!rendered.changed) return;
|
||||||
|
await commitQwenUninstall(await prepareQwenUninstall(sources, rendered));
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(): Promise<PluginStatus> {
|
async status(): Promise<PluginStatus> {
|
||||||
const document = await readDocument();
|
const sources = await readQwenSources();
|
||||||
const contracts = runtimeContracts(document.hooks);
|
const contracts = qwenRuntimeContracts(sources.user.hooks);
|
||||||
const hookConfigured = !document.hooksDisabled && contracts.length > 0;
|
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||||
const hookScriptExists = hookConfigured
|
const hookScriptExists = hookConfigured
|
||||||
? await runtimeFilesExist(contracts)
|
? await qwenRuntimeFilesExist(contracts)
|
||||||
: false;
|
: false;
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
@@ -51,7 +76,7 @@ export const qwenPlugin: AgentPlugin = {
|
|||||||
displayName: entry.name,
|
displayName: entry.name,
|
||||||
hookConfigured,
|
hookConfigured,
|
||||||
hookScriptExists,
|
hookScriptExists,
|
||||||
configPath: document.filePath,
|
configPath: sources.user.filePath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+229
-141
@@ -1,109 +1,93 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from 'node:child_process';
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import os from "node:os";
|
import http from 'node:http';
|
||||||
import path from "node:path";
|
import os from 'node:os';
|
||||||
import { pathToFileURL } from "node:url";
|
import path from 'node:path';
|
||||||
import { parse } from "jsonc-parser";
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { parse } from 'jsonc-parser';
|
||||||
|
|
||||||
const pluginModuleUrl = pathToFileURL(
|
export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url');
|
||||||
path.resolve("dist/plugins/qwen.js"),
|
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;
|
).href;
|
||||||
|
export const installationModuleUrl = pathToFileURL(
|
||||||
export const registryModuleUrl = pathToFileURL(
|
path.resolve('dist/plugins/qwen-installation.js'),
|
||||||
path.resolve("dist/plugins/registry.js"),
|
|
||||||
).href;
|
).href;
|
||||||
export const cliPath = path.resolve("dist/cli.js");
|
export const configModuleUrl = pathToFileURL(
|
||||||
export const expectedShell =
|
path.resolve('dist/plugins/qwen-config.js'),
|
||||||
process.platform === "win32" ? "powershell" : "bash";
|
).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) => {
|
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,
|
cwd,
|
||||||
env,
|
env: childEnv,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = '';
|
||||||
let stderr = "";
|
let stderr = '';
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||||
stdout += chunk;
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||||
});
|
child.once('error', reject);
|
||||||
child.stderr.on("data", (chunk) => {
|
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||||
stderr += chunk;
|
|
||||||
});
|
|
||||||
child.once("error", reject);
|
|
||||||
child.once("close", (code) => resolve({ code, stdout, stderr }));
|
|
||||||
child.stdin.end(input);
|
child.stdin.end(input);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function childEnv(fixture, overrides = {}) {
|
export function runNode(args, env, cwd, input = '', unset = []) {
|
||||||
const env = {
|
return runProcess(process.execPath, args, env, cwd, input, unset);
|
||||||
...process.env,
|
|
||||||
HOME: fixture.homeDir,
|
|
||||||
USERPROFILE: fixture.homeDir,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
if (!Object.hasOwn(overrides, "QWEN_HOME")) delete env.QWEN_HOME;
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
if (value === undefined) delete env[key];
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runPlugin(fixture, method, argument, overrides = {}) {
|
function windowsPowerShell() {
|
||||||
const script = `
|
return path.win32.join(
|
||||||
import { qwenPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
process.env.SystemRoot || 'C:\\Windows',
|
||||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
'System32',
|
||||||
const result = await qwenPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
'WindowsPowerShell',
|
||||||
if (result !== undefined) console.log(JSON.stringify(result));
|
'v1.0',
|
||||||
`;
|
'powershell.exe',
|
||||||
return runNode(
|
|
||||||
["--input-type=module", "--eval", script],
|
|
||||||
childEnv(fixture, {
|
|
||||||
...overrides,
|
|
||||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
|
||||||
ELYDORA_TEST_METHOD: method,
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
fixture.workspaceDir,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteShell(value) {
|
export function runQwenHook(handler, input, fixture, environment = {}) {
|
||||||
return process.platform === "win32"
|
const env = {
|
||||||
? `'${value.replaceAll("'", "''")}'`
|
HOME: fixture.homeDir,
|
||||||
: `'${value.replaceAll("'", `'"'"'`)}'`;
|
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) {
|
export async function writeSettings(filePath, value) {
|
||||||
const invocation = `${quoteShell(process.execPath)} ${quoteShell(scriptPath)}`;
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
return process.platform === "win32"
|
const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
|
||||||
? `& ${invocation}; exit $LASTEXITCODE`
|
await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 });
|
||||||
: invocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runHook(handler, input) {
|
|
||||||
const command = process.platform === "win32" ? "powershell" : "bash";
|
|
||||||
const args =
|
|
||||||
process.platform === "win32"
|
|
||||||
? ["-NoProfile", "-NonInteractive", "-Command", handler.command]
|
|
||||||
: ["-c", handler.command];
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
child.stdout.on("data", (chunk) => {
|
|
||||||
stdout += chunk;
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk;
|
|
||||||
});
|
|
||||||
child.once("error", reject);
|
|
||||||
child.once("close", (code) => resolve({ code, stdout, stderr }));
|
|
||||||
child.stdin.end(input);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSettings(raw) {
|
export function parseSettings(raw) {
|
||||||
@@ -116,73 +100,177 @@ export function parseSettings(raw) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function managedHandler(settings, event, scriptPath) {
|
export async function readSettings(filePath) {
|
||||||
const command = generatedCommand(scriptPath);
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
for (const group of settings.hooks?.[event] ?? []) {
|
return { raw, settings: parseSettings(raw) };
|
||||||
const handler = group.hooks.find(
|
}
|
||||||
(candidate) => candidate.command === command,
|
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
if (handler) return handler;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFixture({
|
export async function createFixture({
|
||||||
existingSettings,
|
baseUrl = 'http://127.0.0.1:9',
|
||||||
guardSource,
|
settings,
|
||||||
hookSource,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "elydora-qwen-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-qwen-'));
|
||||||
const homeDir = path.join(rootDir, "home with spaces and 'quote");
|
const homeDir = path.join(
|
||||||
const workspaceDir = path.join(homeDir, "workspace");
|
rootDir,
|
||||||
const agentDir = path.join(homeDir, ".elydora", "agent-1");
|
"home with spaces and 'quote $QWEN_PROJECT_DIR %QWEN_PROJECT_DIR%",
|
||||||
const configPath = path.join(homeDir, ".qwen", "settings.json");
|
|
||||||
const guardScriptPath = path.join(agentDir, "guard.js");
|
|
||||||
const hookScriptPath = path.join(agentDir, "hook.js");
|
|
||||||
await mkdir(workspaceDir, { recursive: true });
|
|
||||||
await mkdir(agentDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
guardScriptPath,
|
|
||||||
guardSource ??
|
|
||||||
"process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
|
|
||||||
);
|
);
|
||||||
await writeFile(hookScriptPath, hookSource ?? "process.exit(0);\n");
|
const projectDir = path.join(rootDir, 'project with spaces');
|
||||||
await writeFile(
|
const settingsPath = path.join(homeDir, '.qwen', 'settings.json');
|
||||||
path.join(agentDir, "config.json"),
|
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||||
JSON.stringify({ agent_id: "agent-1", agent_name: "qwen" }),
|
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||||
);
|
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||||
if (existingSettings !== undefined) {
|
await mkdir(homeDir, { recursive: true });
|
||||||
await mkdir(path.dirname(configPath), { recursive: true });
|
await mkdir(projectDir, { recursive: true });
|
||||||
await writeFile(
|
if (settings !== undefined) await writeSettings(settingsPath, settings);
|
||||||
configPath,
|
const fixture = {
|
||||||
typeof existingSettings === "string"
|
|
||||||
? existingSettings
|
|
||||||
: `${JSON.stringify(existingSettings, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
agentDir,
|
agentDir,
|
||||||
configPath,
|
baseUrl,
|
||||||
guardScriptPath,
|
guardScriptPath,
|
||||||
homeDir,
|
homeDir,
|
||||||
hookScriptPath,
|
hookScriptPath,
|
||||||
|
projectDir,
|
||||||
rootDir,
|
rootDir,
|
||||||
workspaceDir,
|
settingsPath,
|
||||||
async install(overrides = {}) {
|
install(overrides = {}, environment = {}) {
|
||||||
return runPlugin(
|
return runPlugin(this, 'install', installConfig(this, overrides), environment);
|
||||||
this,
|
|
||||||
"install",
|
|
||||||
{
|
|
||||||
agentName: "qwen",
|
|
||||||
agentId: "agent-1",
|
|
||||||
guardScriptPath,
|
|
||||||
hookScriptPath,
|
|
||||||
},
|
|
||||||
overrides,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
await rm(rootDir, { recursive: true, force: true });
|
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());
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+406
-312
@@ -1,36 +1,69 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import os from "node:os";
|
import path from 'node:path';
|
||||||
import path from "node:path";
|
import test from 'node:test';
|
||||||
import test from "node:test";
|
|
||||||
import {
|
import {
|
||||||
childEnv,
|
VALID_PRIVATE_KEY,
|
||||||
|
assertManagedHandler,
|
||||||
cliPath,
|
cliPath,
|
||||||
|
configModuleUrl,
|
||||||
|
contractModuleUrl,
|
||||||
createFixture,
|
createFixture,
|
||||||
expectedShell,
|
installConfig,
|
||||||
generatedCommand,
|
installationModuleUrl,
|
||||||
|
legacyGroup,
|
||||||
managedHandler,
|
managedHandler,
|
||||||
parseSettings,
|
parseSettings,
|
||||||
|
readSettings,
|
||||||
registryModuleUrl,
|
registryModuleUrl,
|
||||||
runHook,
|
|
||||||
runNode,
|
runNode,
|
||||||
runPlugin,
|
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);
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
assert.deepEqual(SUPPORTED_AGENTS.get("qwen"), {
|
assert.deepEqual(SUPPORTED_AGENTS.get('qwen'), {
|
||||||
name: "Qwen Code",
|
name: 'Qwen Code',
|
||||||
configDir: "~/.qwen",
|
configDir: '~/.qwen',
|
||||||
configFile: "settings.json",
|
configFile: 'settings.json',
|
||||||
});
|
});
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
const result = await runNode(
|
const result = await runNode(
|
||||||
["--no-warnings", cliPath, "status"],
|
['--no-warnings', cliPath, 'status'],
|
||||||
childEnv(fixture),
|
isolatedEnvironment(fixture),
|
||||||
"",
|
fixture.projectDir,
|
||||||
fixture.workspaceDir,
|
|
||||||
);
|
);
|
||||||
assert.equal(result.code, 0, result.stderr);
|
assert.equal(result.code, 0, result.stderr);
|
||||||
assert.match(result.stdout, /Qwen Code \(qwen\)/);
|
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 = [
|
const existing = [
|
||||||
"{",
|
'{',
|
||||||
" // Keep this user preference.",
|
' // Keep this user preference.',
|
||||||
' "theme": "GitHub",',
|
' "theme": "GitHub",',
|
||||||
' "hooks": {',
|
' "hooks": {',
|
||||||
' "SessionStart": [{ "hooks": [{ "type": "command", "command": "session-hook" }] }],',
|
' "FutureEvent": [null],',
|
||||||
' "PreToolUse": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]',
|
' "PreToolUse": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]',
|
||||||
" }",
|
' }',
|
||||||
"}",
|
'}',
|
||||||
"",
|
'',
|
||||||
].join("\r\n");
|
].join('\r\n');
|
||||||
const fixture = await createFixture({ existingSettings: existing });
|
const fixture = await createFixture({ settings: existing });
|
||||||
const workspaceSettings = path.join(
|
const workspacePath = path.join(fixture.projectDir, '.qwen', 'settings.json');
|
||||||
fixture.workspaceDir,
|
const defaultsPath = path.join(fixture.rootDir, 'system-defaults.json');
|
||||||
".qwen",
|
const systemPath = path.join(fixture.rootDir, 'system-settings.json');
|
||||||
"settings.json",
|
await writeSettings(workspacePath, { owner: 'workspace' });
|
||||||
);
|
await writeSettings(defaultsPath, { owner: 'defaults' });
|
||||||
await mkdir(path.dirname(workspaceSettings), { recursive: true });
|
await writeSettings(systemPath, { owner: 'system' });
|
||||||
await writeFile(workspaceSettings, '{ "owner": "workspace" }\n');
|
const environment = {
|
||||||
|
QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsPath,
|
||||||
|
QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const first = await fixture.install();
|
const first = await fixture.install({}, environment);
|
||||||
assert.equal(first.code, 0, first.stderr);
|
assert.equal(first.code, 0, first.stderr);
|
||||||
assert.match(first.stdout, /run \/hooks/i);
|
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);
|
assert.equal(second.code, 0, second.stderr);
|
||||||
const raw = await readFile(fixture.configPath, "utf-8");
|
assert.equal(await readFile(fixture.settingsPath, 'utf-8'), installed.raw);
|
||||||
const settings = parseSettings(raw);
|
assert.deepEqual(parseSettings(await readFile(workspacePath, 'utf-8')), { owner: 'workspace' });
|
||||||
assert.match(raw, /Keep this user preference/);
|
assert.deepEqual(parseSettings(await readFile(defaultsPath, 'utf-8')), { owner: 'defaults' });
|
||||||
assert.match(raw, /\r\n/);
|
assert.deepEqual(parseSettings(await readFile(systemPath, 'utf-8')), { owner: 'system' });
|
||||||
assert.equal(settings.theme, "GitHub");
|
|
||||||
assert.equal(
|
|
||||||
settings.hooks.SessionStart[0].hooks[0].command,
|
|
||||||
"session-hook",
|
|
||||||
);
|
|
||||||
assert.equal(settings.hooks.PreToolUse.length, 2);
|
|
||||||
assert.equal(settings.hooks.PostToolUse.length, 1);
|
|
||||||
for (const [event, scriptPath] of [
|
|
||||||
["PreToolUse", fixture.guardScriptPath],
|
|
||||||
["PostToolUse", fixture.hookScriptPath],
|
|
||||||
]) {
|
|
||||||
const handler = managedHandler(settings, event, scriptPath);
|
|
||||||
assert.deepEqual(Object.keys(handler).sort(), [
|
|
||||||
"command",
|
|
||||||
"shell",
|
|
||||||
"timeout",
|
|
||||||
"type",
|
|
||||||
]);
|
|
||||||
assert.equal(handler.type, "command");
|
|
||||||
assert.equal(handler.shell, expectedShell);
|
|
||||||
assert.equal(handler.timeout, 10_000);
|
|
||||||
}
|
|
||||||
assert.equal(
|
|
||||||
await readFile(workspaceSettings, "utf-8"),
|
|
||||||
'{ "owner": "workspace" }\n',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
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 fixture = await createFixture();
|
||||||
const firstHome = path.join(fixture.rootDir, "first qwen home");
|
const firstHome = path.join(fixture.rootDir, 'first qwen home');
|
||||||
const secondHome = path.join(fixture.rootDir, "second qwen home");
|
const secondHome = path.join(fixture.rootDir, 'second qwen home');
|
||||||
await mkdir(path.join(fixture.homeDir, ".qwen"), { recursive: true });
|
await writeSettings(
|
||||||
await writeFile(
|
path.join(fixture.homeDir, '.qwen', '.env'),
|
||||||
path.join(fixture.homeDir, ".qwen", ".env"),
|
|
||||||
`QWEN_HOME=${firstHome}\n`,
|
`QWEN_HOME=${firstHome}\n`,
|
||||||
);
|
);
|
||||||
await writeFile(
|
await writeSettings(path.join(fixture.homeDir, '.env'), `QWEN_HOME=${secondHome}\n`);
|
||||||
path.join(fixture.homeDir, ".env"),
|
await writeSettings(path.join(firstHome, '.env'), 'QWEN_RUNTIME_DIR=runtime\n');
|
||||||
`QWEN_HOME=${secondHome}\n`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const result = await fixture.install();
|
const result = await fixture.install();
|
||||||
assert.equal(result.code, 0, result.stderr);
|
assert.equal(result.code, 0, result.stderr);
|
||||||
const selected = path.join(firstHome, "settings.json");
|
const selected = path.join(firstHome, 'settings.json');
|
||||||
assert.ok(
|
assertManagedTriple((await readSettings(selected)).settings);
|
||||||
managedHandler(
|
await assertMissing(path.join(secondHome, 'settings.json'));
|
||||||
parseSettings(await readFile(selected, "utf-8")),
|
await assertMissing(fixture.settingsPath);
|
||||||
"PreToolUse",
|
const status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||||
fixture.guardScriptPath,
|
assert.equal(status.configPath, selected);
|
||||||
),
|
|
||||||
);
|
|
||||||
await assert.rejects(readFile(path.join(secondHome, "settings.json")), {
|
|
||||||
code: "ENOENT",
|
|
||||||
});
|
|
||||||
await assert.rejects(readFile(fixture.configPath), { code: "ENOENT" });
|
|
||||||
const status = await runPlugin(fixture, "status", null);
|
|
||||||
assert.equal(JSON.parse(status.stdout).configPath, selected);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
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 [
|
for (const [value, expectedPath] of [
|
||||||
[
|
['relative-qwen', (fixture) => path.join(fixture.projectDir, 'relative-qwen', 'settings.json')],
|
||||||
"relative-qwen",
|
['~/custom-qwen', (fixture) => path.join(fixture.homeDir, 'custom-qwen', 'settings.json')],
|
||||||
(fixture) =>
|
['', (fixture) => fixture.settingsPath],
|
||||||
path.join(fixture.workspaceDir, "relative-qwen", "settings.json"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"~/custom-qwen",
|
|
||||||
(fixture) => path.join(fixture.homeDir, "custom-qwen", "settings.json"),
|
|
||||||
],
|
|
||||||
["", (fixture) => fixture.configPath],
|
|
||||||
]) {
|
]) {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
await mkdir(path.join(fixture.homeDir, ".qwen"), { recursive: true });
|
await writeSettings(
|
||||||
await writeFile(
|
path.join(fixture.homeDir, '.qwen', '.env'),
|
||||||
path.join(fixture.homeDir, ".qwen", ".env"),
|
`QWEN_HOME=${path.join(fixture.rootDir, 'ignored-home')}\n`,
|
||||||
`QWEN_HOME=${path.join(fixture.rootDir, "ignored-qwen-home")}\n`,
|
|
||||||
);
|
);
|
||||||
try {
|
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.equal(result.code, 0, result.stderr);
|
||||||
assert.ok(
|
assertManagedTriple((await readSettings(expectedPath(fixture))).settings);
|
||||||
parseSettings(await readFile(expectedPath(fixture), "utf-8")).hooks,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Qwen hooks block freezes and forward official input", async () => {
|
test('Qwen validates current hook schemas and preserves future events', async () => {
|
||||||
const capturePath = path.join(
|
const fixture = await createFixture({ settings: {
|
||||||
os.tmpdir(),
|
hooks: {
|
||||||
`elydora-qwen-event-${process.pid}-${Date.now()}.json`,
|
FutureEvent: [null],
|
||||||
);
|
MessageDisplay: [{ matcher: '[', hooks: [{
|
||||||
const hookSource = `
|
type: 'http',
|
||||||
const fs = require('node:fs');
|
url: 'http://127.0.0.1:8080/hook',
|
||||||
const chunks = [];
|
headers: { Authorization: 'Bearer ${TOKEN}' },
|
||||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
allowedEnvVars: ['TOKEN'],
|
||||||
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
timeout: 10,
|
||||||
`;
|
once: true,
|
||||||
const fixture = await createFixture({ hookSource });
|
}] }],
|
||||||
try {
|
Stop: [{ hooks: [{
|
||||||
const installed = await fixture.install();
|
type: 'prompt',
|
||||||
assert.equal(installed.code, 0, installed.stderr);
|
prompt: 'Evaluate $ARGUMENTS',
|
||||||
const settings = parseSettings(await readFile(fixture.configPath, "utf-8"));
|
model: 'fast',
|
||||||
const guard = managedHandler(
|
timeout: 30,
|
||||||
settings,
|
}] }],
|
||||||
"PreToolUse",
|
|
||||||
fixture.guardScriptPath,
|
|
||||||
);
|
|
||||||
const audit = managedHandler(
|
|
||||||
settings,
|
|
||||||
"PostToolUse",
|
|
||||||
fixture.hookScriptPath,
|
|
||||||
);
|
|
||||||
const preInput = JSON.stringify({
|
|
||||||
session_id: "session-1",
|
|
||||||
transcript_path: "transcript.jsonl",
|
|
||||||
cwd: fixture.workspaceDir,
|
|
||||||
hook_event_name: "PreToolUse",
|
|
||||||
timestamp: "2026-07-19T00:00:00.000Z",
|
|
||||||
tool_name: "run_shell_command",
|
|
||||||
tool_input: { command: "echo test" },
|
|
||||||
});
|
|
||||||
const guardResult = await runHook(guard, preInput);
|
|
||||||
assert.equal(guardResult.code, 2, guardResult.stderr);
|
|
||||||
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
|
||||||
const postInput = preInput.replace("PreToolUse", "PostToolUse");
|
|
||||||
const auditResult = await runHook(audit, postInput);
|
|
||||||
assert.equal(auditResult.code, 0, auditResult.stderr);
|
|
||||||
assert.equal(await readFile(capturePath, "utf-8"), postInput);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
await rm(capturePath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Qwen status requires enabled hooks and complete runtimes", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
try {
|
|
||||||
assert.equal((await fixture.install()).code, 0);
|
|
||||||
let status = JSON.parse((await runPlugin(fixture, "status", null)).stdout);
|
|
||||||
assert.equal(status.installed, true);
|
|
||||||
const settings = parseSettings(await readFile(fixture.configPath, "utf-8"));
|
|
||||||
await writeFile(
|
|
||||||
fixture.configPath,
|
|
||||||
`${JSON.stringify({ ...settings, disableAllHooks: true }, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
status = JSON.parse((await runPlugin(fixture, "status", null)).stdout);
|
|
||||||
assert.equal(status.installed, false);
|
|
||||||
assert.equal(status.hookConfigured, false);
|
|
||||||
await writeFile(
|
|
||||||
fixture.configPath,
|
|
||||||
`${JSON.stringify({ ...settings, disableAllHooks: false }, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
await rm(fixture.guardScriptPath);
|
|
||||||
status = JSON.parse((await runPlugin(fixture, "status", null)).stdout);
|
|
||||||
assert.equal(status.installed, false);
|
|
||||||
assert.equal(status.hookConfigured, true);
|
|
||||||
assert.equal(status.hookScriptExists, false);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Qwen uninstall removes exact ownership and preserves user hooks", async () => {
|
|
||||||
const fixture = await createFixture({
|
|
||||||
existingSettings: { $version: 4, owner: "user" },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
assert.equal((await fixture.install()).code, 0);
|
|
||||||
const settings = parseSettings(await readFile(fixture.configPath, "utf-8"));
|
|
||||||
const managed = settings.hooks.PreToolUse.at(-1);
|
|
||||||
managed.hooks.push({ type: "command", command: "user-command" });
|
|
||||||
settings.hooks.PreToolUse.push({
|
|
||||||
matcher: "*",
|
|
||||||
hooks: [
|
|
||||||
{
|
|
||||||
type: "command",
|
|
||||||
command: generatedCommand(
|
|
||||||
path.join(fixture.homeDir, ".elydora", "agent-10", "guard.js"),
|
|
||||||
),
|
|
||||||
shell: expectedShell,
|
|
||||||
timeout: 10_000,
|
|
||||||
},
|
},
|
||||||
],
|
} });
|
||||||
});
|
|
||||||
await writeFile(
|
|
||||||
fixture.configPath,
|
|
||||||
`${JSON.stringify(settings, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
const before = await readFile(fixture.configPath, "utf-8");
|
|
||||||
assert.equal(
|
|
||||||
(await runPlugin(fixture, "uninstall", "other-agent")).code,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert.equal(await readFile(fixture.configPath, "utf-8"), before);
|
|
||||||
const uninstallId = process.platform === "win32" ? "AGENT-1" : "agent-1";
|
|
||||||
const result = await runPlugin(fixture, "uninstall", uninstallId);
|
|
||||||
assert.equal(result.code, 0, result.stderr);
|
|
||||||
const remaining = parseSettings(
|
|
||||||
await readFile(fixture.configPath, "utf-8"),
|
|
||||||
);
|
|
||||||
assert.equal(remaining.$version, 4);
|
|
||||||
assert.equal(remaining.owner, "user");
|
|
||||||
assert.equal(remaining.hooks.PreToolUse.length, 2);
|
|
||||||
assert.equal(
|
|
||||||
remaining.hooks.PreToolUse[0].hooks[0].command,
|
|
||||||
"user-command",
|
|
||||||
);
|
|
||||||
assert.match(JSON.stringify(remaining), /agent-10/);
|
|
||||||
assert.equal(remaining.hooks.PostToolUse, undefined);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Qwen removes an Elydora-owned settings file on uninstall", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
try {
|
|
||||||
assert.equal((await fixture.install()).code, 0);
|
|
||||||
assert.match(
|
|
||||||
await readFile(fixture.configPath, "utf-8"),
|
|
||||||
/^\/\/ Managed by Elydora/,
|
|
||||||
);
|
|
||||||
const result = await runPlugin(fixture, "uninstall", "agent-1");
|
|
||||||
assert.equal(result.code, 0, result.stderr);
|
|
||||||
await assert.rejects(readFile(fixture.configPath), { code: "ENOENT" });
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Qwen rejects malformed settings before writes", async () => {
|
|
||||||
const cases = [
|
|
||||||
"{ malformed",
|
|
||||||
"[]",
|
|
||||||
'{ "owner": true, }',
|
|
||||||
'{ "hooks": {}, "hooks": {} }',
|
|
||||||
'{ "disableAllHooks": "yes" }',
|
|
||||||
'{ "hooks": [] }',
|
|
||||||
'{ "hooks": { "UnknownEvent": [] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": null } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [null] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "matcher": "[", "hooks": [] }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "sequential": "yes", "hooks": [] }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "hooks": null }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function", "command": "x" }] }] } }',
|
|
||||||
'{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": "ten" }] }] } }',
|
|
||||||
];
|
|
||||||
for (const existingSettings of cases) {
|
|
||||||
const fixture = await createFixture({ existingSettings });
|
|
||||||
try {
|
try {
|
||||||
const result = await fixture.install();
|
const result = await fixture.install();
|
||||||
assert.equal(result.code, 1, `${existingSettings}\n${result.stderr}`);
|
assert.equal(result.code, 0, result.stderr);
|
||||||
assert.match(result.stderr, /Qwen (Code settings|hooks)/i);
|
const settings = (await readSettings(fixture.settingsPath)).settings;
|
||||||
assert.equal(
|
assert.deepEqual(settings.hooks.FutureEvent, [null]);
|
||||||
await readFile(fixture.configPath, "utf-8"),
|
assert.equal(settings.hooks.MessageDisplay[0].matcher, '[');
|
||||||
existingSettings,
|
assert.equal(settings.hooks.Stop[0].hooks[0].type, 'prompt');
|
||||||
);
|
|
||||||
const names = await readdir(path.dirname(fixture.configPath));
|
|
||||||
assert.equal(
|
|
||||||
names.some((name) => name.endsWith(".tmp")),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Qwen rejects malformed affected settings before runtime writes', async (t) => {
|
||||||
|
const cases = [
|
||||||
|
['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 [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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Qwen fails on unreadable home env routing and missing runtimes", async () => {
|
test('Qwen surfaces malformed read-only sources and home routing', async () => {
|
||||||
const envFixture = await createFixture();
|
const system = await createFixture();
|
||||||
await mkdir(path.join(envFixture.homeDir, ".qwen", ".env"), {
|
const systemPath = path.join(system.rootDir, 'system.json');
|
||||||
recursive: true,
|
await writeSettings(systemPath, '{ malformed');
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const result = await envFixture.install();
|
const result = await system.install({}, { QWEN_CODE_SYSTEM_SETTINGS_PATH: systemPath });
|
||||||
assert.equal(result.code, 1);
|
assert.equal(result.code, 1);
|
||||||
assert.match(result.stderr, /Qwen home environment/i);
|
assert.match(result.stderr, /parse Qwen Code system override settings/i);
|
||||||
await assert.rejects(readFile(envFixture.configPath), { code: "ENOENT" });
|
assert.equal(await readFile(systemPath, 'utf-8'), '{ malformed');
|
||||||
|
await assertMissing(system.settingsPath);
|
||||||
|
await assertMissing(system.agentDir);
|
||||||
} finally {
|
} finally {
|
||||||
await envFixture.close();
|
await system.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeFixture = await createFixture();
|
const routing = await createFixture();
|
||||||
await rm(runtimeFixture.guardScriptPath);
|
await mkdir(path.join(routing.homeDir, '.qwen', '.env'), { recursive: true });
|
||||||
try {
|
try {
|
||||||
const result = await runtimeFixture.install();
|
const result = await routing.install();
|
||||||
assert.equal(result.code, 1);
|
assert.equal(result.code, 1);
|
||||||
assert.match(result.stderr, /runtime is missing/i);
|
assert.match(result.stderr, /home environment.*physical file/i);
|
||||||
await assert.rejects(readFile(runtimeFixture.configPath), {
|
await assertMissing(routing.settingsPath);
|
||||||
code: "ENOENT",
|
await assertMissing(routing.agentDir);
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
await runtimeFixture.close();
|
await routing.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Qwen status surfaces malformed runtime metadata and leaves no staging files", async () => {
|
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,
|
||||||
|
);
|
||||||
|
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();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
assert.equal(
|
let status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||||
(await readdir(path.dirname(fixture.configPath))).some((name) =>
|
assert.equal(status.installed, true);
|
||||||
name.endsWith(".tmp"),
|
const settings = (await readSettings(fixture.settingsPath)).settings;
|
||||||
),
|
settings.hooks.PostToolUseFailure.push(settings.hooks.PostToolUseFailure.at(-1));
|
||||||
false,
|
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,
|
||||||
);
|
);
|
||||||
await writeFile(path.join(fixture.agentDir, "config.json"), "{ malformed");
|
assert.equal(status.code, 0, status.stderr);
|
||||||
const status = await runPlugin(fixture, "status", null);
|
assert.match(status.stdout, /Qwen Code \(qwen\) \[installed\]/);
|
||||||
assert.equal(status.code, 1);
|
const uninstall = await runNode([
|
||||||
assert.match(status.stderr, /parse Elydora runtime config/i);
|
'--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 {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user