From 865248a76736f119653d23007749cfb89475a722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 10:56:47 -0400 Subject: [PATCH] feat: sync Node Factory Droid hook integration --- AGENTS.md | 2 +- integrations/catalog.json | 4 +- sdks/node/README.md | 3 + sdks/node/package-lock.json | 9 +- sdks/node/package.json | 3 +- sdks/node/src/cli.ts | 2 + sdks/node/src/plugins/droid-config.ts | 283 ++++++++++++++ sdks/node/src/plugins/droid-contract.ts | 296 +++++++++++++++ sdks/node/src/plugins/droid-io.ts | 310 ++++++++++++++++ sdks/node/src/plugins/droid.ts | 95 +++++ sdks/node/src/plugins/registry.ts | 1 + sdks/node/test/droid-plugin.test.mjs | 475 ++++++++++++++++++++++++ 12 files changed, 1478 insertions(+), 5 deletions(-) create mode 100644 sdks/node/src/plugins/droid-config.ts create mode 100644 sdks/node/src/plugins/droid-contract.ts create mode 100644 sdks/node/src/plugins/droid-io.ts create mode 100644 sdks/node/src/plugins/droid.ts create mode 100644 sdks/node/test/droid-plugin.test.mjs diff --git a/AGENTS.md b/AGENTS.md index fc4f56e..b396458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This repository publishes the open-source server, console, integration catalog, - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. -- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Edit only effective user sources; keep project and organization sources read-only. +- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index 1369988..ff1b0e9 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -186,8 +186,8 @@ "mechanism": "exit_code_2", "failure_mode": "fail_open" }, - "adapters": { "node": false, "python": false, "go": false }, - "delivery_state": "planned", + "adapters": { "node": true, "python": false, "go": false }, + "delivery_state": "partial", "source_url": "https://docs.factory.ai/reference/hooks-reference", "contract_variants": [ { diff --git a/sdks/node/README.md b/sdks/node/README.md index 563bcc6..50a6cf8 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -56,6 +56,8 @@ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex aft Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control. +Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change. + Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them. Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. @@ -81,6 +83,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Claude Code | `claudecode` | | OpenAI Codex | `codex` | | Cline | `cline` | +| Factory Droid | `droid` | | Kimi Code | `kimi` | | Grok Build | `grok` | | Cursor | `cursor` | diff --git a/sdks/node/package-lock.json b/sdks/node/package-lock.json index 9ce424a..b9fd6f0 100644 --- a/sdks/node/package-lock.json +++ b/sdks/node/package-lock.json @@ -9,7 +9,8 @@ "version": "1.1.0", "license": "MIT", "dependencies": { - "@decimalturn/toml-patch": "^2.0.0" + "@decimalturn/toml-patch": "^2.0.0", + "jsonc-parser": "^3.3.1" }, "bin": { "elydora": "dist/cli.js" @@ -41,6 +42,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/sdks/node/package.json b/sdks/node/package.json index 15849b6..298786d 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -25,6 +25,7 @@ "typescript": "^5.7.0" }, "dependencies": { - "@decimalturn/toml-patch": "^2.0.0" + "@decimalturn/toml-patch": "^2.0.0", + "jsonc-parser": "^3.3.1" } } diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index 8b50d91..d099a3e 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -17,6 +17,7 @@ import { copilotPlugin } from './plugins/copilot.js'; import { lettaPlugin } from './plugins/letta.js'; import { codexPlugin } from './plugins/codex.js'; import { clinePlugin } from './plugins/cline.js'; +import { droidPlugin } from './plugins/droid.js'; import { kimiPlugin } from './plugins/kimi.js'; import { grokPlugin } from './plugins/grok.js'; @@ -34,6 +35,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['letta', lettaPlugin], ['codex', codexPlugin], ['cline', clinePlugin], + ['droid', droidPlugin], ['kimi', kimiPlugin], ['grok', grokPlugin], ]); diff --git a/sdks/node/src/plugins/droid-config.ts b/sdks/node/src/plugins/droid-config.ts new file mode 100644 index 0000000..1a68ae0 --- /dev/null +++ b/sdks/node/src/plugins/droid-config.ts @@ -0,0 +1,283 @@ +import { + applyEdits, + modify, + parse, + parseTree, + printParseErrorCode, + type FormattingOptions, + type JSONPath, + type Node, + type ParseError, +} from 'jsonc-parser'; +import { + TOOL_EVENTS, + type DroidGroup, + type DroidHookSettings, + type JsonObject, + type ToolEvent, + hasOwn, + isObject, + managedRemovals, + readHookSettings, +} from './droid-contract.js'; + +export const OWNED_FILE_MARKER = '// Managed by Elydora'; + +export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings'; + +export interface DroidDocument { + readonly kind: DroidDocumentKind; + readonly filePath: string; + readonly exists: boolean; + readonly raw: string; + readonly root: JsonObject; + readonly hooks: DroidHookSettings; + readonly basePath: JSONPath; + readonly hasHooksContainer: boolean; + readonly ownedFile: boolean; +} + +export interface DroidSources { + readonly rootPath: string; + readonly primary?: DroidDocument; + readonly settings: DroidDocument; +} + +export interface InstallationTargets { + readonly targets: ReadonlyMap; + readonly createdRoot?: DroidDocument; +} + +export interface RenderedDocument { + readonly document: DroidDocument; + readonly changed: boolean; + readonly next?: string; +} + +interface DocumentOptions { + readonly exists: boolean; + readonly filePath: string; + readonly kind: DroidDocumentKind; + readonly raw: string; +} + +function rejectDuplicateKeys(node: Node | undefined, label: string, path: string[] = []): void { + if (!node) return; + if (node.type === 'object') { + const keys = new Set(); + for (const property of node.children ?? []) { + const key = String(property.children?.[0]?.value); + const location = [...path, key]; + if (keys.has(key)) throw new Error(`${label} contains duplicate field "${location.join('.')}"`); + keys.add(key); + rejectDuplicateKeys(property.children?.[1], label, location); + } + return; + } + if (node.type === 'array') { + for (const child of node.children ?? []) rejectDuplicateKeys(child, label, path); + } +} + +function parseJsoncObject(raw: string, label: string): JsonObject { + const errors: ParseError[] = []; + const value: unknown = parse(raw, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + const details = errors + .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) + .join(', '); + throw new Error(`Failed to parse ${label}: ${details}`); + } + if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); + rejectDuplicateKeys(parseTree(raw, [], { + allowTrailingComma: true, + disallowComments: false, + }), label); + return value; +} + +function labelFor(kind: DroidDocumentKind, filePath: string): string { + if (kind === 'settings') return `Factory Droid settings at ${filePath}`; + if (kind === 'legacy') return `Factory Droid legacy hooks at ${filePath}`; + return `Factory Droid hooks at ${filePath}`; +} + +export function parseDocument(options: DocumentOptions): DroidDocument { + const label = labelFor(options.kind, options.filePath); + const root = parseJsoncObject(options.raw, label); + if (options.kind === 'settings') { + const hasHooksContainer = hasOwn(root, 'hooks'); + const hooks = hasHooksContainer + ? readHookSettings(root.hooks, `${label} field "hooks"`) + : {}; + return { + ...options, + root, + hooks, + basePath: ['hooks'], + hasHooksContainer, + ownedFile: false, + }; + } + return { + ...options, + root, + hooks: readHookSettings(root, label), + basePath: [], + hasHooksContainer: true, + ownedFile: options.raw.startsWith(OWNED_FILE_MARKER), + }; +} + +export function createSettingsDocument(filePath: string): DroidDocument { + return parseDocument({ exists: false, filePath, kind: 'settings', raw: '{}\n' }); +} + +export function createOwnedHookDocument(filePath: string): DroidDocument { + return parseDocument({ + exists: false, + filePath, + kind: 'hooks', + raw: `${OWNED_FILE_MARKER}\n{}\n`, + }); +} + +function eventPath(document: DroidDocument, event: string): JSONPath { + return [...document.basePath, event]; +} + +function formatting(raw: string): FormattingOptions { + const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; + const insertSpaces = !indentation?.includes('\t'); + return { + eol: raw.includes('\r\n') ? '\r\n' : '\n', + insertSpaces, + tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, + }; +} + +function change( + raw: string, + path: JSONPath, + value: unknown, + isArrayInsertion = false, +): string { + return applyEdits(raw, modify(raw, path, value, { + formattingOptions: formatting(raw), + isArrayInsertion, + })); +} + +function currentDocument(document: DroidDocument, raw: string): DroidDocument { + return parseDocument({ + exists: document.exists, + filePath: document.filePath, + kind: document.kind, + raw, + }); +} + +function removeManagedEntries( + document: DroidDocument, + raw: string, + agentId?: string, +): string { + const removals = managedRemovals(document.hooks, agentId); + for (const event of TOOL_EVENTS) { + const eventRemovals = removals + .filter((removal) => removal.event === event) + .sort((left, right) => right.groupIndex - left.groupIndex); + if (eventRemovals.length === 0) continue; + for (const removal of eventRemovals) { + const groupPath = [...eventPath(document, event), removal.groupIndex]; + if (removal.removeGroup) { + raw = change(raw, groupPath, undefined); + continue; + } + for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) { + raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + } + } + const current = currentDocument(document, raw); + if ((current.hooks[event] ?? []).length === 0) { + raw = change(raw, eventPath(document, event), undefined); + } + } + return raw; +} + +function appendGroup( + document: DroidDocument, + raw: string, + event: ToolEvent, + group: DroidGroup, +): string { + const current = currentDocument(document, raw); + if (hasOwn(current.hooks, event)) { + const groups = current.hooks[event] ?? []; + return change(raw, [...eventPath(document, event), groups.length], group, true); + } + return change(raw, eventPath(document, event), [group]); +} + +function hookFileIsEmpty(document: DroidDocument, raw: string): boolean { + if (document.kind === 'settings') return false; + return Object.keys(currentDocument(document, raw).hooks).length === 0; +} + +export function renderDocument( + document: DroidDocument, + agentId: string | undefined, + additions: ReadonlyMap, +): RenderedDocument { + let raw = removeManagedEntries(document, document.raw, agentId); + for (const event of TOOL_EVENTS) { + const group = additions.get(event); + if (group) raw = appendGroup(document, raw, event, group); + } + currentDocument(document, raw); + if (additions.size === 0 && document.ownedFile && hookFileIsEmpty(document, raw)) { + return { document, changed: true, next: undefined }; + } + return { document, changed: raw !== document.raw, next: raw }; +} + +function eventTarget( + event: ToolEvent, + sources: DroidSources, + createdRoot: () => DroidDocument, +): DroidDocument { + if (sources.primary && hasOwn(sources.primary.hooks, event)) return sources.primary; + if (sources.settings.hasHooksContainer && hasOwn(sources.settings.hooks, event)) { + return sources.settings; + } + if (sources.primary) return sources.primary; + if (sources.settings.hasHooksContainer) return sources.settings; + return createdRoot(); +} + +export function installationTargets(sources: DroidSources): InstallationTargets { + let root: DroidDocument | undefined; + const createdRoot = (): DroidDocument => { + root ??= createOwnedHookDocument(sources.rootPath); + return root; + }; + const targets = new Map(); + for (const event of TOOL_EVENTS) targets.set(event, eventTarget(event, sources, createdRoot)); + return { targets, createdRoot: root }; +} + +export function additionsFor( + document: DroidDocument, + targets: ReadonlyMap, + groups: ReadonlyMap, +): ReadonlyMap { + const additions = new Map(); + for (const event of TOOL_EVENTS) { + if (targets.get(event) === document) additions.set(event, groups.get(event)!); + } + return additions; +} diff --git a/sdks/node/src/plugins/droid-contract.ts b/sdks/node/src/plugins/droid-contract.ts new file mode 100644 index 0000000..78b51dd --- /dev/null +++ b/sdks/node/src/plugins/droid-contract.ts @@ -0,0 +1,296 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const AGENT_KEY = 'droid'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; +export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const; + +const EVENT_NAMES = new Set([ + 'PreToolUse', + 'PostToolUse', + 'Notification', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreCompact', + 'SessionStart', + 'SessionEnd', +]); +const FLAG_NAMES = new Set(['hooksDisabled', 'showHookOutput']); +const HANDLER_KEYS = ['command', 'timeout', 'type']; +const GROUP_KEYS = ['hooks', 'matcher']; + +export type JsonObject = Record; +export type ToolEvent = typeof TOOL_EVENTS[number]; + +export interface DroidHandler extends JsonObject { + readonly type: 'command'; + readonly command: string; + readonly timeout?: number; +} + +export interface DroidGroup extends JsonObject { + readonly matcher?: string; + readonly commandRegex?: string; + readonly hooks: DroidHandler[]; +} + +export interface DroidHookSettings extends JsonObject { + readonly PreToolUse?: DroidGroup[]; + readonly PostToolUse?: DroidGroup[]; + readonly hooksDisabled?: boolean; + readonly showHookOutput?: boolean; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +export interface ManagedRemoval { + readonly event: string; + readonly groupIndex: number; + readonly handlerIndexes: number[]; + readonly removeGroup: boolean; +} + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function hasOwn(value: JsonObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} + +export function buildCommand(scriptPath: string): string { + const quote = process.platform === 'win32' ? quoteWindows : quotePosix; + return `${quote(process.execPath)} ${quote(scriptPath)}`; +} + +export function buildGroup(scriptPath: string): DroidGroup { + return { + matcher: '*', + hooks: [{ + type: 'command', + command: buildCommand(scriptPath), + timeout: HOOK_TIMEOUT_SECONDS, + }], + }; +} + +function validateRegex(value: string, label: string, allowWildcard: boolean): void { + if (allowWildcard && (value === '*' || value === '')) 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 validateHandler( + value: unknown, + label: string, + groupIndex: number, + handlerIndex: number, +): DroidHandler { + const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`; + if (!isObject(value)) throw new Error(`${location} must be an object`); + if (value.type !== 'command') throw new Error(`${location} type must be "command"`); + if (typeof value.command !== 'string') throw new Error(`${location} command must be a string`); + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' + || !Number.isFinite(value.timeout))) { + throw new Error(`${location} timeout must be a finite number`); + } + return value as DroidHandler; +} + +function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup { + const location = `${label}[${groupIndex}]`; + if (!isObject(value)) throw new Error(`${location} must be an object`); + if (value.matcher !== undefined) { + if (typeof value.matcher !== 'string') throw new Error(`${location} matcher must be a string`); + validateRegex(value.matcher, `${location} matcher`, true); + } + if (value.commandRegex !== undefined) { + if (typeof value.commandRegex !== 'string') { + throw new Error(`${location} commandRegex must be a string`); + } + validateRegex(value.commandRegex, `${location} commandRegex`, false); + } + if (!Array.isArray(value.hooks)) throw new Error(`${location} must contain a hooks array`); + const handlers = value.hooks.map( + (handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex), + ); + return { ...value, hooks: handlers } as DroidGroup; +} + +export function readHookSettings(value: unknown, label: string): DroidHookSettings { + if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); + const settings: DroidHookSettings = { ...value }; + for (const [key, item] of Object.entries(value)) { + if (FLAG_NAMES.has(key)) { + if (typeof item !== 'boolean') throw new Error(`${label} field "${key}" must be a boolean`); + continue; + } + if (!EVENT_NAMES.has(key)) throw new Error(`${label} contains unsupported field "${key}"`); + if (!Array.isArray(item)) throw new Error(`${label} field "${key}" must be an array`); + settings[key] = item.map( + (group, groupIndex) => validateGroup(group, `${label} field "${key}"`, groupIndex), + ); + } + return settings; +} + +function readWindowsArgument(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] === '\\' && command[index + 1] === '"') { + value += '"'; + index += 1; + continue; + } + if (command[index] === '"') return { value, next: index + 1 }; + value += command[index]; + } + return undefined; +} + +const POSIX_APOSTROPHE = `'"'"'`; + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(POSIX_APOSTROPHE, index)) { + value += "'"; + index += POSIX_APOSTROPHE.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parseGeneratedCommand(command: string): readonly [string, string] | undefined { + const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument; + const executable = readArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readArgument(command, executable.next + 1); + if (!script || script.next !== command.length || !executable.value || !script.value) return undefined; + return [executable.value, script.value]; +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +export function managedAgentId(handler: DroidHandler, scriptName: string): string | undefined { + if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0') + || handler.type !== 'command' + || handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined; + const parsed = parseGeneratedCommand(handler.command); + if (!parsed || !samePath(parsed[0], process.execPath)) return undefined; + const scriptPath = parsed[1]; + if (path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean { + return Object.keys(group).sort().join('\0') === GROUP_KEYS.join('\0') + && group.matcher === '*' + && managedIndexes.length > 0 + && managedIndexes.length === group.hooks.length; +} + +export function managedRemovals( + settings: DroidHookSettings, + agentId?: string, +): ManagedRemoval[] { + const removals: ManagedRemoval[] = []; + for (const [event, scriptName] of [ + ['PreToolUse', GUARD_SCRIPT], + ['PostToolUse', AUDIT_SCRIPT], + ] as const) { + const groups = settings[event] ?? []; + groups.forEach((group, groupIndex) => { + const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { + const managedId = managedAgentId(handler, scriptName); + return managedId && (agentId === undefined || sameAgentId(managedId, agentId)) + ? [handlerIndex] + : []; + }); + if (handlerIndexes.length > 0) { + removals.push({ + event, + groupIndex, + handlerIndexes, + removeGroup: exactOwnedGroup(group, handlerIndexes), + }); + } + }); + } + return removals; +} + +function managedIds(groups: DroidGroup[], scriptName: string): Set { + const ids = new Set(); + for (const group of groups) { + for (const handler of group.hooks) { + const agentId = managedAgentId(handler, scriptName); + if (agentId) ids.add(agentId); + } + } + return ids; +} + +export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[] { + const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT); + const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId))) + .map((agentId) => ({ + agentId, + guardPath: path.join(root, agentId, GUARD_SCRIPT), + auditPath: path.join(root, agentId, AUDIT_SCRIPT), + })); +} + +export function mergeHookSettings( + primary: DroidHookSettings | undefined, + fallback: DroidHookSettings | undefined, +): DroidHookSettings { + return { ...(fallback ?? {}), ...(primary ?? {}) }; +} diff --git a/sdks/node/src/plugins/droid-io.ts b/sdks/node/src/plugins/droid-io.ts new file mode 100644 index 0000000..b4840a7 --- /dev/null +++ b/sdks/node/src/plugins/droid-io.ts @@ -0,0 +1,310 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + type JsonObject, + type RuntimeContract, + isObject, +} from './droid-contract.js'; +import { + type DroidSources, + type RenderedDocument, + createSettingsDocument, + parseDocument, +} from './droid-config.js'; + +interface FileChange { + readonly filePath: string; + readonly label: string; + readonly original?: string; + readonly next?: string; +} + +interface StagedChange { + readonly change: FileChange; + readonly tempPath?: string; + readonly rollbackPath?: string; + committed: boolean; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isObject(error) && error.code === code; +} + +function factoryPaths(): { + readonly root: string; + readonly legacy: string; + readonly settings: string; +} { + const directory = path.join(os.homedir(), '.factory'); + return { + root: path.join(directory, 'hooks.json'), + legacy: path.join(directory, 'hooks', 'hooks.json'), + settings: path.join(directory, 'settings.json'), + }; +} + +async function readOptional(filePath: string, label: string): Promise { + try { + return await fsp.readFile(filePath, 'utf-8'); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return undefined; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export async function readSources(): Promise { + const paths = factoryPaths(); + const [rootRaw, settingsRaw] = await Promise.all([ + readOptional(paths.root, 'Factory Droid hooks'), + readOptional(paths.settings, 'Factory Droid settings'), + ]); + let primary; + if (rootRaw !== undefined) { + primary = parseDocument({ + exists: true, + filePath: paths.root, + kind: 'hooks', + raw: rootRaw, + }); + } else { + const legacyRaw = await readOptional(paths.legacy, 'Factory Droid legacy hooks'); + if (legacyRaw !== undefined) { + primary = parseDocument({ + exists: true, + filePath: paths.legacy, + kind: 'legacy', + raw: legacyRaw, + }); + } + } + const settings = settingsRaw === undefined + ? createSettingsDocument(paths.settings) + : parseDocument({ + exists: true, + filePath: paths.settings, + kind: 'settings', + raw: settingsRaw, + }); + return { rootPath: paths.root, primary, settings }; +} + +function labelFor(rendered: RenderedDocument): string { + if (rendered.document.kind === 'settings') return 'Factory Droid settings'; + if (rendered.document.kind === 'legacy') return 'Factory Droid legacy hooks'; + return 'Factory Droid hooks'; +} + +function fileChange(rendered: RenderedDocument): FileChange | undefined { + if (!rendered.changed) return undefined; + return { + filePath: rendered.document.filePath, + label: labelFor(rendered), + original: rendered.document.exists ? rendered.document.raw : undefined, + next: rendered.next, + }; +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) throw error; + } +} + +async function writeExclusive(filePath: string, contents: string, label: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', 0o600); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + } catch (error) { + const failures = [asError(error)]; + if (handle) { + try { + await handle.close(); + } catch (closeError) { + failures.push(asError(closeError)); + } + } + try { + await unlinkOptional(filePath); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) { + throw new AggregateError(failures, `Stage ${label} at ${filePath}`); + } + throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: failures[0], + }); + } +} + +async function assertUnchanged(change: FileChange): Promise { + const current = await readOptional(change.filePath, change.label); + if (current !== change.original) { + throw new Error(`${change.label} changed during installation: ${change.filePath}`); + } +} + +async function stage(change: FileChange): Promise { + await assertUnchanged(change); + const directory = path.dirname(change.filePath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + const token = randomUUID(); + const tempPath = change.next === undefined + ? undefined + : path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`); + const rollbackPath = change.original === undefined + ? undefined + : path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`); + try { + if (tempPath) await writeExclusive(tempPath, change.next!, change.label); + if (rollbackPath && change.next !== undefined) { + await writeExclusive(rollbackPath, change.original!, `${change.label} rollback`); + } + } catch (error) { + const failures = [asError(error)]; + for (const stagedPath of [tempPath, rollbackPath]) { + if (!stagedPath) continue; + try { + await unlinkOptional(stagedPath); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`); + throw error; + } + return { change, tempPath, rollbackPath, committed: false }; +} + +async function commit(staged: StagedChange): Promise { + await assertUnchanged(staged.change); + if (staged.change.next === undefined) { + if (!staged.rollbackPath) throw new Error(`Missing rollback path for ${staged.change.label}`); + await fsp.rename(staged.change.filePath, staged.rollbackPath); + } else { + if (!staged.tempPath) throw new Error(`Missing staged file for ${staged.change.label}`); + await fsp.rename(staged.tempPath, staged.change.filePath); + } + staged.committed = true; +} + +async function rollback(staged: StagedChange): Promise { + if (!staged.committed) return; + if (staged.change.original === undefined) { + await unlinkOptional(staged.change.filePath); + return; + } + if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); + await fsp.rename(staged.rollbackPath, staged.change.filePath); +} + +async function cleanup(staged: StagedChange): Promise { + const paths = [staged.tempPath, staged.rollbackPath].filter( + (filePath): filePath is string => filePath !== undefined, + ); + await Promise.all(paths.map(unlinkOptional)); +} + +export async function writeDocuments(rendered: RenderedDocument[]): Promise { + const changes = rendered.flatMap((item) => { + const next = fileChange(item); + return next ? [next] : []; + }); + if (changes.length === 0) return; + const staged: StagedChange[] = []; + try { + for (const change of changes) staged.push(await stage(change)); + for (const item of staged) await commit(item); + } catch (error) { + const failures = [asError(error)]; + for (const item of [...staged].reverse()) { + try { + await rollback(item); + } catch (rollbackError) { + failures.push(asError(rollbackError)); + } + } + for (const item of staged) { + try { + await cleanup(item); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + if (failures.length > 1) throw new AggregateError(failures, 'Write Factory Droid hook sources'); + throw new Error(`Write Factory Droid hook sources: ${errorMessage(error)}`, { + cause: failures[0], + }); + } + await Promise.all(staged.map(cleanup)); +} + +export async function regularFileExists(filePath: string, label: string): Promise { + try { + return (await fsp.stat(filePath)).isFile(); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export async function requireRuntime(filePath: string, label: string): Promise { + if (!filePath) throw new Error(`${label} path is required`); + if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +} + +async function readRuntimeConfig(filePath: string): Promise { + const raw = await readOptional(filePath, 'Elydora runtime config'); + if (raw === undefined) return undefined; + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(value)) throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`); + return value; +} + +export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + for (const contract of contracts) { + const agentDirectory = path.dirname(contract.guardPath); + const runtimeConfig = await readRuntimeConfig(path.join(agentDirectory, 'config.json')); + if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; + const files = await Promise.all([ + regularFileExists(contract.guardPath, 'Elydora guard runtime'), + regularFileExists(contract.auditPath, 'Elydora audit runtime'), + ]); + if (files.every(Boolean)) return true; + } + return false; +} + +export function displayConfigPath(sources: DroidSources): string { + if (sources.primary) return sources.primary.filePath; + if (sources.settings.exists && sources.settings.hasHooksContainer) return sources.settings.filePath; + return sources.rootPath; +} diff --git a/sdks/node/src/plugins/droid.ts b/sdks/node/src/plugins/droid.ts new file mode 100644 index 0000000..1f46c42 --- /dev/null +++ b/sdks/node/src/plugins/droid.ts @@ -0,0 +1,95 @@ +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + TOOL_EVENTS, + buildGroup, + mergeHookSettings, + runtimeContracts, + type DroidGroup, + type ToolEvent, +} from './droid-contract.js'; +import { + additionsFor, + installationTargets, + renderDocument, + type DroidDocument, +} from './droid-config.js'; +import { + displayConfigPath, + readSources, + requireRuntime, + runtimeFilesExist, + writeDocuments, +} from './droid-io.js'; +import { SUPPORTED_AGENTS } from './registry.js'; + +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +function uniqueDocuments(documents: Array): DroidDocument[] { + const unique = new Map(); + for (const document of documents) { + if (document) unique.set(document.filePath, document); + } + return [...unique.values()]; +} + +export const droidPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + if (!config.agentId) throw new Error('agentId is required'); + const sources = await readSources(); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + + const selected = installationTargets(sources); + const groups = new Map([ + ['PreToolUse', buildGroup(config.guardScriptPath)], + ['PostToolUse', buildGroup(config.hookScriptPath)], + ]); + const documents = uniqueDocuments([ + sources.primary, + sources.settings.hasHooksContainer ? sources.settings : undefined, + ...TOOL_EVENTS.map((event) => selected.targets.get(event)), + ]); + const rendered = documents.map((document) => renderDocument( + document, + undefined, + additionsFor(document, selected.targets, groups), + )); + await writeDocuments(rendered); + const locations = TOOL_EVENTS + .map((event) => `${event}: ${selected.targets.get(event)!.filePath}`) + .join(', '); + console.log(` Factory Droid: ${locations}`); + console.log(' Factory Droid: run /hooks to review the Elydora hook changes.'); + }, + + async uninstall(agentId?: string): Promise { + const sources = await readSources(); + const documents = uniqueDocuments([ + sources.primary, + sources.settings.hasHooksContainer ? sources.settings : undefined, + ]); + const rendered = documents.map((document) => renderDocument( + document, + agentId, + new Map(), + )); + await writeDocuments(rendered); + }, + + async status(): Promise { + const sources = await readSources(); + const effective = mergeHookSettings(sources.primary?.hooks, sources.settings.hooks); + const contracts = runtimeContracts(effective); + const hookConfigured = effective.hooksDisabled !== true && contracts.length > 0; + const hookScriptExists = hookConfigured ? await runtimeFilesExist(contracts) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: displayConfigPath(sources), + }; + }, +}; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index f55d7d0..03428ea 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -16,6 +16,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], ['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }], + ['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }], ['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }], ['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }], ]); diff --git a/sdks/node/test/droid-plugin.test.mjs b/sdks/node/test/droid-plugin.test.mjs new file mode 100644 index 0000000..650556b --- /dev/null +++ b/sdks/node/test/droid-plugin.test.mjs @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'jsonc-parser'; + +const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +const cliPath = path.resolve('dist/cli.js'); + +function runNode(args, env, input = '', cwd) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +function runCommand(command, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(command, { shell: true, 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); + }); +} + +function parseJsonc(raw) { + const errors = []; + const value = parse(raw, errors, { allowTrailingComma: true }); + assert.deepEqual(errors, []); + return value; +} + +function serialize(value) { + return typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; +} + +async function writeConfig(filePath, value) { + if (value === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, serialize(value)); +} + +async function runPlugin(fixture, method, argument) { + const script = ` + import { droidPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await droidPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', script], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + '', + fixture.workspaceDir, + ); +} + +async function createFixture({ hooks, legacyHooks, settings, guardSource, hookSource } = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote"); + const workspaceDir = path.join(homeDir, 'workspace'); + const factoryDir = path.join(homeDir, '.factory'); + const configPath = path.join(factoryDir, 'hooks.json'); + const legacyConfigPath = path.join(factoryDir, 'hooks', 'hooks.json'); + const settingsPath = path.join(factoryDir, 'settings.json'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + guardScriptPath, + guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", + ); + await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n'); + await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ + agent_id: 'agent-1', + agent_name: 'droid', + })); + await writeConfig(configPath, hooks); + await writeConfig(legacyConfigPath, legacyHooks); + await writeConfig(settingsPath, settings); + const fixture = { + agentDir, + configPath, + factoryDir, + guardScriptPath, + homeDir, + hookScriptPath, + legacyConfigPath, + settingsPath, + workspaceDir, + async close() { await rm(rootDir, { recursive: true, force: true }); }, + }; + fixture.installResult = await runPlugin(fixture, 'install', { + agentName: 'droid', + agentId: 'agent-1', + guardScriptPath, + hookScriptPath, + }); + return fixture; +} + +function managedHandler(groups, scriptPath) { + for (const group of groups ?? []) { + const handler = group.hooks.find( + (candidate) => candidate.command?.includes(scriptPath), + ); + if (handler) return handler; + } + return undefined; +} + +test('Factory Droid is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('droid'), { + name: 'Factory Droid', + configDir: '~/.factory', + configFile: 'hooks.json', + }); + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-cli-')); + const workspaceDir = path.join(homeDir, 'workspace'); + await mkdir(workspaceDir); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: homeDir, + USERPROFILE: homeDir, + }, '', workspaceDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Factory Droid \(droid\)/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Droid install preserves JSONC and follows per-event source precedence', async () => { + const hooks = `{ + // root hook source + "PreToolUse": [ + // keep root group comment + { "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] } + ], + "Notification": [] +}\n`; + const settings = `{ + // general setting + "theme": "dark", + "hooks": { + // settings fallback event + "PostToolUse": [ + // keep settings group comment + { "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] } + ], + "showHookOutput": true, + }, +}\n`; + const fixture = await createFixture({ hooks, settings }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const second = await runPlugin(fixture, 'install', { + agentName: 'droid', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(second.code, 0, second.stderr); + const hooksRaw = await readFile(fixture.configPath, 'utf-8'); + const settingsRaw = await readFile(fixture.settingsPath, 'utf-8'); + const root = parseJsonc(hooksRaw); + const userSettings = parseJsonc(settingsRaw); + assert.match(hooksRaw, /root hook source/); + assert.match(hooksRaw, /keep root group comment/); + assert.match(settingsRaw, /general setting/); + assert.match(settingsRaw, /settings fallback event/); + assert.match(settingsRaw, /keep settings group comment/); + assert.equal(root.PreToolUse.length, 2); + assert.equal(root.PreToolUse[0].hooks[0].command, 'root-user'); + assert.equal(root.PostToolUse, undefined); + assert.equal(userSettings.theme, 'dark'); + assert.equal(userSettings.hooks.PostToolUse.length, 2); + assert.equal(userSettings.hooks.PostToolUse[0].hooks[0].command, 'settings-user'); + assert.equal(userSettings.hooks.PreToolUse, undefined); + assert.equal(userSettings.hooks.showHookOutput, true); + for (const [groups, scriptPath] of [ + [root.PreToolUse, fixture.guardScriptPath], + [userSettings.hooks.PostToolUse, fixture.hookScriptPath], + ]) { + const handler = managedHandler(groups, scriptPath); + assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']); + assert.equal(handler.timeout, 10); + assert.equal(handler.type, 'command'); + } + await assert.rejects( + readFile(path.join(fixture.workspaceDir, '.factory', 'hooks.json')), + { code: 'ENOENT' }, + ); + } finally { + await fixture.close(); + } +}); + +test('Droid keeps an active legacy source until Factory migrates it', async () => { + const legacyHooks = { + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'legacy-user' }] }], + }; + const settings = { + hooks: { + PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'settings-user' }] }], + }, + }; + const fixture = await createFixture({ legacyHooks, settings }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + const legacy = parseJsonc(await readFile(fixture.legacyConfigPath, 'utf-8')); + const currentSettings = parseJsonc(await readFile(fixture.settingsPath, 'utf-8')); + assert(managedHandler(legacy.PreToolUse, fixture.guardScriptPath)); + assert(managedHandler(currentSettings.hooks.PostToolUse, fixture.hookScriptPath)); + } finally { + await fixture.close(); + } +}); + +test('Droid reuses an existing settings hook container', async () => { + const settingsSource = '{\r\n\t"owner": "user",\r\n\t"hooks": {}\r\n}\r\n'; + const fixture = await createFixture({ settings: settingsSource }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + const settingsRaw = await readFile(fixture.settingsPath, 'utf-8'); + const settings = parseJsonc(settingsRaw); + assert.match(settingsRaw, /\r\n\t\t"PreToolUse"/); + assert.match(settingsRaw, /\r\n\t\t"PostToolUse"/); + assert.equal(settings.owner, 'user'); + assert(managedHandler(settings.hooks.PreToolUse, fixture.guardScriptPath)); + assert(managedHandler(settings.hooks.PostToolUse, fixture.hookScriptPath)); + } finally { + await fixture.close(); + } +}); + +test('Droid commands block freezes and forward official input byte-for-byte', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-droid-event-${process.pid}-${Date.now()}.json`); + const hookSource = ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); + `; + const fixture = await createFixture({ hookSource }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const root = parseJsonc(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(root.PreToolUse, fixture.guardScriptPath); + const audit = managedHandler(root.PostToolUse, fixture.hookScriptPath); + const prePayload = JSON.stringify({ + session_id: 'session-1', + transcript_path: path.join(fixture.homeDir, 'transcript.jsonl'), + cwd: fixture.workspaceDir, + permission_mode: 'auto-high', + hook_event_name: 'PreToolUse', + tool_name: 'Execute', + tool_input: { command: 'echo test' }, + }); + const guardResult = await runCommand(guard.command, prePayload); + assert.equal(guardResult.code, 2); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + const postPayload = JSON.stringify({ + ...JSON.parse(prePayload), + hook_event_name: 'PostToolUse', + tool_response: { output: 'test', success: true }, + }); + const auditResult = await runCommand(audit.command, postPayload); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Droid status requires an effective pair and both runtime files', async () => { + const fixture = await createFixture({ + hooks: { PreToolUse: [] }, + settings: { hooks: { PostToolUse: [] } }, + }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + let status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + await rm(fixture.hookScriptPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + const root = parseJsonc(await readFile(fixture.configPath, 'utf-8')); + root.hooksDisabled = true; + await writeFile(fixture.configPath, `${JSON.stringify(root, null, 2)}\n`); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).hookConfigured, false); + } finally { + await fixture.close(); + } +}); + +test('Droid uninstall removes exact ownership and preserves user sources', async () => { + const hooks = `{ + // keep root comment + "PreToolUse": [{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }] +}\n`; + const settings = `{ + "theme": "dark", + "hooks": { + // keep settings comment + "PostToolUse": [{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }] + } +}\n`; + const fixture = await createFixture({ hooks, settings }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; + const result = await runPlugin(fixture, 'uninstall', uninstallId); + assert.equal(result.code, 0, result.stderr); + const hooksRaw = await readFile(fixture.configPath, 'utf-8'); + const settingsRaw = await readFile(fixture.settingsPath, 'utf-8'); + const root = parseJsonc(hooksRaw); + const currentSettings = parseJsonc(settingsRaw); + assert.match(hooksRaw, /keep root comment/); + assert.match(settingsRaw, /keep settings comment/); + assert.deepEqual(root.PreToolUse, [{ + matcher: 'Read', + hooks: [{ type: 'command', command: 'root-user' }], + }]); + assert.deepEqual(currentSettings.hooks.PostToolUse, [{ + matcher: 'Edit', + hooks: [{ type: 'command', command: 'settings-user' }], + }]); + assert.equal(currentSettings.theme, 'dark'); + } finally { + await fixture.close(); + } +}); + +test('Droid uninstall deletes only a hooks file marked as Elydora-owned', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + 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('Droid uninstall preserves mixed groups and lookalike commands', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const root = parseJsonc(await readFile(fixture.configPath, 'utf-8')); + const managedGroup = root.PreToolUse.find( + (group) => managedHandler([group], fixture.guardScriptPath), + ); + const command = managedGroup.hooks[0].command; + managedGroup.hooks.push({ type: 'command', command: 'user-command' }); + root.PreToolUse.push({ + matcher: '*', + hooks: [{ type: 'command', command: command.replace('guard.js', 'guard.js.backup'), timeout: 10 }], + }); + root.PreToolUse.push({ + matcher: '*', + hooks: [{ type: 'command', command: command.replace('agent-1', 'agent-10'), timeout: 10 }], + }); + await writeFile(fixture.configPath, `${JSON.stringify(root, null, 2)}\n`); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + const remaining = parseJsonc(await readFile(fixture.configPath, 'utf-8')); + assert(remaining.PreToolUse.some( + (group) => group.hooks.some((handler) => handler.command === 'user-command'), + )); + assert.match(JSON.stringify(remaining), /guard\.js\.backup/); + assert.match(JSON.stringify(remaining), /agent-10/); + assert.equal(remaining.PostToolUse, undefined); + } finally { + await fixture.close(); + } +}); + +test('Droid preserves every malformed source before the first write', async () => { + const cases = [ + { hooks: '{ malformed', target: 'configPath' }, + { hooks: '[]', target: 'configPath' }, + { hooks: '{ "PreToolUse": [], "PreToolUse": [] }', target: 'configPath' }, + { hooks: { PreToolUse: null }, target: 'configPath' }, + { hooks: { PreToolUse: [null] }, target: 'configPath' }, + { hooks: { PreToolUse: [{ matcher: '[', hooks: [] }] }, target: 'configPath' }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 1 }] }] }, target: 'configPath' }, + { legacyHooks: '{ malformed', target: 'legacyConfigPath' }, + { hooks: { PreToolUse: [] }, settings: '{ malformed', target: 'settingsPath' }, + { settings: { hooks: null }, target: 'settingsPath' }, + ]; + for (const input of cases) { + const fixture = await createFixture(input); + try { + assert.equal(fixture.installResult.code, 1, fixture.installResult.stderr); + assert.equal( + await readFile(fixture[input.target], 'utf-8'), + serialize(input[input.target === 'legacyConfigPath' ? 'legacyHooks' + : input.target === 'settingsPath' ? 'settings' : 'hooks']), + ); + } finally { + await fixture.close(); + } + } +}); + +test('Droid rejects missing runtimes before creating a hook source', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-missing-')); + const workspaceDir = path.join(homeDir, 'workspace'); + const fixture = { homeDir, workspaceDir }; + await mkdir(workspaceDir); + try { + const result = await runPlugin(fixture, 'install', { + agentName: 'droid', + agentId: 'agent-1', + guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'), + hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects( + readFile(path.join(homeDir, '.factory', 'hooks.json')), + { code: 'ENOENT' }, + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Droid atomic transactions leave no staging files', async () => { + const fixture = await createFixture({ + hooks: { PreToolUse: [] }, + settings: { hooks: { PostToolUse: [] } }, + }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const names = await readdir(fixture.factoryDir, { recursive: true }); + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); + } finally { + await fixture.close(); + } +});