fix(droid): align hooks with Factory 0.175

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 13:42:26 -04:00
1 parent 2f988e72ba
commit 590de9dbdb
12 files changed
+1855 -932

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
- Resolve Cline 3 file hooks through `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents, `.clinerules/hooks`, and workspace `.cline/hooks` sources read-only. Install exact `PreToolUse.mjs` and `PostToolUse.mjs` files for the CLI `tool_call` and `tool_result` events, preserve the complete hybrid native payload byte-for-byte, and translate guard exit code `2` into a one-line JSON stdout cancellation control. Commit both hook files, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact wrapper and runtime sources, canonical private keys, physical files, and matching runtime identity during status checks, then verify discovery with the current official `@cline/core` loader.
- Resolve GitHub Copilot CLI user hooks through `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default, and treat an empty override as the default. Keep policy, repository, inline settings, cross-tool Claude, and plugin hook sources read-only. Evaluate `disableAllHooks` through legacy user config, user settings, Claude repository and local settings, then GitHub repository and local settings; the managed hook file flag remains independently authoritative. Register exact `preToolUse`, `postToolUse`, and `postToolUseFailure` command handlers with ten-second timeouts, preserve the complete native camelCase payload, and propagate frozen or revoked state through exit code `2`. Commit the user hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical source paths, strict hook schemas, exact runtime sources, canonical private keys, and matching runtime identity during status checks, then verify discovery with the current official Copilot native hook loader and restart active CLI sessions.
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell.
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
- Resolve 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, and require `/hooks` review for the session snapshot and remote organization policy.
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
## Code Quality
+1 -1
View File
@@ -94,7 +94,7 @@ Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` com
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Documents, `.clinerules/hooks`, and workspace `.cline/hooks` remain unchanged and continue loading additively. Both hook files, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. The wrappers preserve Cline 3's complete native payload, and the guard emits its documented one-line JSON 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.
Factory Droid 0.175.0 installation resolves one active user hook source in this order: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the `hooks` field from user `settings.local.json` and `settings.json`. Current hook files use the documented top-level `hooks` container; legacy direct event maps remain readable until Factory migrates them. Elydora writes exact `PreToolUse` and `PostToolUse` groups to that source, removes only exact Elydora ownership from inactive user sources, and keeps project, folder, plugin, and organization hook definitions read-only. System, project, folder, and user `hooksDisabled` values follow Factory's extension-only precedence; system-managed `allowManagedHooksOnly` blocks a user-hook installation. Hook sources, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and use Factory's native PowerShell call operator with explicit `$LASTEXITCODE` propagation on Windows. Run `/hooks` after installation to refresh Droid's session snapshot and verify remote organization policy.
Kimi installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules to every detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the legacy Python CLI's `~/.kimi/config.toml`. A fresh installation targets stable Kimi Code; an existing legacy home activates the migration contract. Empty `KIMI_CODE_HOME` values use the default. The selected TOML files, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Kimi's native snake_case event JSON, propagate freeze and revocation through exit code `2`, and use encoded PowerShell on Windows. Run `/hooks` to inspect the global rules.
+157 -109
View File
@@ -1,51 +1,50 @@
import {
applyEdits,
modify,
parse,
parseTree,
printParseErrorCode,
type FormattingOptions,
type JSONPath,
type Node,
type ParseError,
} from 'jsonc-parser';
import type { FileSnapshot } from './managed-files.js';
import type { DroidPolicyState } from './droid-policy.js';
import {
TOOL_EVENTS,
type DroidGroup,
type DroidHookSettings,
type JsonObject,
type DroidHookMap,
type ToolEvent,
hasOwn,
isObject,
managedRemovals,
readHookSettings,
readHookMap,
} from './droid-contract.js';
import {
isObject,
parseStrictJsoncObject,
type JsonObject,
} from './strict-json.js';
export const OWNED_FILE_MARKER = '// Managed by Elydora';
export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings';
export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings' | 'local-settings';
export interface DroidDocument {
readonly kind: DroidDocumentKind;
readonly filePath: string;
readonly exists: boolean;
readonly raw: string;
readonly snapshot?: FileSnapshot;
readonly root: JsonObject;
readonly hooks: DroidHookSettings;
readonly hooks: DroidHookMap;
readonly basePath: JSONPath;
readonly hasHooksContainer: boolean;
readonly hooksDisabled?: boolean;
readonly showHookOutput?: boolean;
readonly ownedFile: boolean;
}
export interface DroidSources {
readonly rootPath: string;
readonly primary?: DroidDocument;
readonly root: DroidDocument;
readonly legacy: DroidDocument;
readonly settings: DroidDocument;
}
export interface InstallationTargets {
readonly targets: ReadonlyMap<ToolEvent, DroidDocument>;
readonly createdRoot?: DroidDocument;
readonly localSettings: DroidDocument;
readonly policy: DroidPolicyState;
}
export interface RenderedDocument {
@@ -59,59 +58,48 @@ interface DocumentOptions {
readonly filePath: string;
readonly kind: DroidDocumentKind;
readonly raw: string;
readonly snapshot?: FileSnapshot;
}
function rejectDuplicateKeys(node: Node | undefined, label: string, path: string[] = []): void {
if (!node) return;
if (node.type === 'object') {
const keys = new Set<string>();
for (const property of node.children ?? []) {
const key = String(property.children?.[0]?.value);
const location = [...path, key];
if (keys.has(key)) throw new Error(`${label} contains duplicate field "${location.join('.')}"`);
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, location);
}
return;
}
if (node.type === 'array') {
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, path);
}
}
function 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 hasOwn(value: JsonObject, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function labelFor(kind: DroidDocumentKind, filePath: string): string {
if (kind === 'settings') return `Factory Droid settings at ${filePath}`;
if (kind === 'local-settings') return `Factory Droid local settings at ${filePath}`;
if (kind === 'legacy') return `Factory Droid legacy hooks at ${filePath}`;
return `Factory Droid hooks at ${filePath}`;
}
function optionalBoolean(root: JsonObject, field: string, label: string): boolean | undefined {
const value = root[field];
if (value !== undefined && typeof value !== 'boolean') {
throw new Error(`${label} field "${field}" must be a boolean`);
}
return value as boolean | undefined;
}
function legacyDirectHooks(root: JsonObject, label: string): {
readonly hooks: DroidHookMap;
readonly hooksDisabled?: boolean;
readonly showHookOutput?: boolean;
} {
const hooksDisabled = optionalBoolean(root, 'hooksDisabled', label);
const showHookOutput = optionalBoolean(root, 'showHookOutput', label);
const hookEntries = Object.fromEntries(
Object.entries(root).filter(([key]) => key !== 'hooksDisabled' && key !== 'showHookOutput'),
);
return { hooks: readHookMap(hookEntries, label), hooksDisabled, showHookOutput };
}
export function parseDocument(options: DocumentOptions): DroidDocument {
const label = labelFor(options.kind, options.filePath);
const root = parseJsoncObject(options.raw, label);
if (options.kind === 'settings') {
const root = parseStrictJsoncObject(options.raw, label);
if (options.kind === 'settings' || options.kind === 'local-settings') {
const hasHooksContainer = hasOwn(root, 'hooks');
const hooks = hasHooksContainer
? readHookSettings(root.hooks, `${label} field "hooks"`)
? readHookMap(root.hooks, `${label} field "hooks"`)
: {};
return {
...options,
@@ -119,21 +107,42 @@ export function parseDocument(options: DocumentOptions): DroidDocument {
hooks,
basePath: ['hooks'],
hasHooksContainer,
hooksDisabled: optionalBoolean(root, 'hooksDisabled', label),
showHookOutput: optionalBoolean(root, 'showHookOutput', label),
ownedFile: false,
};
}
if (hasOwn(root, 'hooks')) {
if (!isObject(root.hooks)) throw new Error(`${label} field "hooks" must be an object`);
return {
...options,
root,
hooks: readHookMap(root.hooks, `${label} field "hooks"`),
basePath: ['hooks'],
hasHooksContainer: true,
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
};
}
const legacy = legacyDirectHooks(root, label);
return {
...options,
root,
hooks: readHookSettings(root, label),
...legacy,
basePath: [],
hasHooksContainer: true,
hasHooksContainer: false,
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
};
}
export function createSettingsDocument(filePath: string): DroidDocument {
return parseDocument({ exists: false, filePath, kind: 'settings', raw: '{}\n' });
export function createSettingsDocument(
filePath: string,
kind: 'settings' | 'local-settings' = 'settings',
): DroidDocument {
return parseDocument({ exists: false, filePath, kind, raw: '{}\n' });
}
export function createLegacyHookDocument(filePath: string): DroidDocument {
return parseDocument({ exists: false, filePath, kind: 'legacy', raw: '{}\n' });
}
export function createOwnedHookDocument(filePath: string): DroidDocument {
@@ -141,7 +150,7 @@ export function createOwnedHookDocument(filePath: string): DroidDocument {
exists: false,
filePath,
kind: 'hooks',
raw: `${OWNED_FILE_MARKER}\n{}\n`,
raw: `${OWNED_FILE_MARKER}\n{\n "hooks": {}\n}\n`,
});
}
@@ -159,12 +168,7 @@ function formatting(raw: string): FormattingOptions {
};
}
function change(
raw: string,
path: JSONPath,
value: unknown,
isArrayInsertion = false,
): string {
function change(raw: string, path: JSONPath, value: unknown, isArrayInsertion = false): string {
return applyEdits(raw, modify(raw, path, value, {
formattingOptions: formatting(raw),
isArrayInsertion,
@@ -177,20 +181,16 @@ function currentDocument(document: DroidDocument, raw: string): DroidDocument {
filePath: document.filePath,
kind: document.kind,
raw,
snapshot: document.snapshot,
});
}
function removeManagedEntries(
document: DroidDocument,
raw: string,
agentId?: string,
): string {
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) {
@@ -201,9 +201,11 @@ function removeManagedEntries(
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);
if (eventRemovals.length > 0) {
const current = currentDocument(document, raw);
if ((current.hooks[event] ?? []).length === 0) {
raw = change(raw, eventPath(document, event), undefined);
}
}
}
return raw;
@@ -216,7 +218,7 @@ function appendGroup(
group: DroidGroup,
): string {
const current = currentDocument(document, raw);
if (hasOwn(current.hooks, event)) {
if (Object.prototype.hasOwnProperty.call(current.hooks, event)) {
const groups = current.hooks[event] ?? [];
return change(raw, [...eventPath(document, event), groups.length], group, true);
}
@@ -224,8 +226,10 @@ function appendGroup(
}
function hookFileIsEmpty(document: DroidDocument, raw: string): boolean {
if (document.kind === 'settings') return false;
return Object.keys(currentDocument(document, raw).hooks).length === 0;
if (document.kind === 'settings' || document.kind === 'local-settings') return false;
const current = currentDocument(document, raw);
const remainingRootFields = Object.keys(current.root).filter((key) => key !== 'hooks');
return Object.keys(current.hooks).length === 0 && remainingRootFields.length === 0;
}
export function renderDocument(
@@ -239,45 +243,89 @@ export function renderDocument(
if (group) raw = appendGroup(document, raw, event, group);
}
currentDocument(document, raw);
if (additions.size === 0 && document.ownedFile && hookFileIsEmpty(document, raw)) {
if (additions.size === 0
&& document.exists
&& 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;
export function activeDocument(sources: DroidSources): DroidDocument {
if (sources.root.exists) return sources.root;
if (sources.legacy.exists) return sources.legacy;
if (sources.localSettings.hasHooksContainer) return sources.localSettings;
if (sources.settings.hasHooksContainer) return sources.settings;
return createdRoot();
return sources.root;
}
export function installationTargets(sources: DroidSources): InstallationTargets {
let root: DroidDocument | undefined;
const createdRoot = (): DroidDocument => {
root ??= createOwnedHookDocument(sources.rootPath);
return root;
};
const targets = new Map<ToolEvent, DroidDocument>();
for (const event of TOOL_EVENTS) targets.set(event, eventTarget(event, sources, createdRoot));
return { targets, createdRoot: root };
export function effectiveHooks(sources: DroidSources): DroidHookMap {
return activeDocument(sources).hooks;
}
export function additionsFor(
export interface DroidHookBlock {
readonly field: 'allowManagedHooksOnly' | 'hooksDisabled';
readonly filePath: string;
readonly label: string;
}
export function hookBlock(sources: DroidSources): DroidHookBlock | undefined {
if (sources.policy.allowManagedHooksOnlyBy) {
return {
field: 'allowManagedHooksOnly',
...sources.policy.allowManagedHooksOnlyBy,
};
}
if (sources.policy.hooksDisabled !== undefined) {
return sources.policy.hooksDisabled ? {
field: 'hooksDisabled',
...sources.policy.hooksDisabledBy!,
} : undefined;
}
const selected = sources.localSettings.hooksDisabled !== undefined
? sources.localSettings
: sources.settings;
if (selected.hooksDisabled === true) {
return {
field: 'hooksDisabled',
filePath: selected.filePath,
label: labelFor(selected.kind, selected.filePath),
};
}
const active = activeDocument(sources);
return active.hooksDisabled === true
? {
field: 'hooksDisabled',
filePath: active.filePath,
label: labelFor(active.kind, active.filePath),
}
: undefined;
}
export function sourceDocuments(sources: DroidSources): DroidDocument[] {
return [sources.root, sources.legacy, sources.settings, sources.localSettings];
}
export function installationDocuments(sources: DroidSources): DroidDocument[] {
const target = activeDocument(sources);
const documents = [
sources.root.exists || target === sources.root ? sources.root : undefined,
sources.legacy.exists ? sources.legacy : undefined,
sources.settings.hasHooksContainer ? sources.settings : undefined,
sources.localSettings.hasHooksContainer ? sources.localSettings : undefined,
];
const unique = new Map<string, DroidDocument>();
for (const document of documents) {
if (document) unique.set(document.filePath, document);
}
return [...unique.values()];
}
export function additionsForTarget(
document: DroidDocument,
targets: ReadonlyMap<ToolEvent, DroidDocument>,
target: DroidDocument,
groups: ReadonlyMap<ToolEvent, DroidGroup>,
): ReadonlyMap<ToolEvent, DroidGroup> {
const additions = new Map<ToolEvent, DroidGroup>();
for (const event of TOOL_EVENTS) {
if (targets.get(event) === document) additions.set(event, groups.get(event)!);
}
return additions;
return document === target ? groups : new Map();
}
+114 -96
View File
@@ -1,5 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import type { JsonObject } from './strict-json.js';
export const AGENT_KEY = 'droid';
export const GUARD_SCRIPT = 'guard.js';
@@ -7,22 +8,10 @@ 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'];
const WINDOWS_EXIT_SUFFIX = '; exit $LASTEXITCODE';
export type JsonObject = Record<string, unknown>;
export type ToolEvent = typeof TOOL_EVENTS[number];
export interface DroidHandler extends JsonObject {
@@ -37,11 +26,9 @@ export interface DroidGroup extends JsonObject {
readonly hooks: DroidHandler[];
}
export interface DroidHookSettings extends JsonObject {
export interface DroidHookMap extends JsonObject {
readonly PreToolUse?: DroidGroup[];
readonly PostToolUse?: DroidGroup[];
readonly hooksDisabled?: boolean;
readonly showHookOutput?: boolean;
}
export interface RuntimeContract {
@@ -51,7 +38,7 @@ export interface RuntimeContract {
}
export interface ManagedRemoval {
readonly event: string;
readonly event: ToolEvent;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly removeGroup: boolean;
@@ -62,25 +49,21 @@ interface ParsedArgument {
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('"', '\\"')}"`;
function quotePowerShell(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)}`;
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Factory Droid hook commands require absolute executable and script paths');
}
return process.platform === 'win32'
? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${WINDOWS_EXIT_SUFFIX}`
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
export function buildGroup(scriptPath: string): DroidGroup {
@@ -111,65 +94,76 @@ function validateHandler(
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`);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
return value as DroidHandler;
const handler = value as JsonObject;
if (handler.type !== 'command') throw new Error(`${location} type must be "command"`);
if (typeof handler.command !== 'string' || handler.command.trim().length === 0) {
throw new Error(`${location} command must be a non-empty string`);
}
if (handler.timeout !== undefined
&& (typeof handler.timeout !== 'number'
|| !Number.isFinite(handler.timeout)
|| handler.timeout <= 0)) {
throw new Error(`${location} timeout must be a positive finite number`);
}
return handler 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 (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
if (value.commandRegex !== undefined) {
if (typeof value.commandRegex !== 'string') {
const group = value as JsonObject;
if (group.matcher !== undefined) {
if (typeof group.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
validateRegex(group.matcher, `${location} matcher`, true);
}
if (group.commandRegex !== undefined) {
if (typeof group.commandRegex !== 'string') {
throw new Error(`${location} commandRegex must be a string`);
}
validateRegex(value.commandRegex, `${location} commandRegex`, false);
validateRegex(group.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;
if (!Array.isArray(group.hooks)) throw new Error(`${location} must contain a hooks array`);
return {
...group,
hooks: group.hooks.map(
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
),
} 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),
export function readHookMap(value: unknown, label: string): DroidHookMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must contain a JSON object`);
}
const hooks: DroidHookMap = {};
for (const [event, groups] of Object.entries(value)) {
if (!Array.isArray(groups)) throw new Error(`${label} field "${event}" must be an array`);
hooks[event] = groups.map(
(group, groupIndex) => validateGroup(group, `${label} field "${event}"`, groupIndex),
);
}
return settings;
return hooks;
}
function readWindowsArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== '"') return undefined;
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] === '\\' && command[index + 1] === '"') {
value += '"';
if (command[index] !== "'") {
value += command[index];
continue;
}
if (command[index + 1] === "'") {
value += "'";
index += 1;
continue;
}
if (command[index] === '"') return { value, next: index + 1 };
value += command[index];
return { value, next: index + 1 };
}
return undefined;
}
@@ -192,16 +186,45 @@ function readPosixArgument(command: string, start: number): ParsedArgument | und
return undefined;
}
function parseGeneratedCommand(command: string): readonly [string, string] | undefined {
const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument;
const executable = readArgument(command, 0);
function parseTwoArguments(
command: string,
start: number,
readArgument: (command: string, start: number) => ParsedArgument | undefined,
): readonly [string, string] | undefined {
const executable = readArgument(command, start);
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 {
function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined {
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
return match ? [match[1], match[2]] : 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) !== WINDOWS_EXIT_SUFFIX
|| !executable.value
|| !script.value) return undefined;
return [executable.value, script.value];
}
function parseGeneratedCommand(
command: string,
includeLegacy: boolean,
): readonly [string, string] | undefined {
if (process.platform !== 'win32') return parseTwoArguments(command, 0, readPosixArgument);
const current = parsePowerShellCommand(command);
return current ?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined);
}
export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
@@ -209,15 +232,19 @@ function samePath(left: string, right: string): boolean {
: normalizedLeft === normalizedRight;
}
function sameAgentId(left: string, right: string): boolean {
export 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 {
export function managedAgentId(
handler: DroidHandler,
scriptName: string,
includeLegacy = false,
): 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);
const parsed = parseGeneratedCommand(handler.command, includeLegacy);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (path.basename(scriptPath) !== scriptName) return undefined;
@@ -234,19 +261,16 @@ function exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
&& managedIndexes.length === group.hooks.length;
}
export function managedRemovals(
settings: DroidHookSettings,
agentId?: string,
): ManagedRemoval[] {
export function managedRemovals(hooks: DroidHookMap, agentId?: string): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
['PreToolUse', GUARD_SCRIPT],
['PostToolUse', AUDIT_SCRIPT],
] as const) {
const groups = settings[event] ?? [];
const groups = hooks[event] ?? [];
groups.forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName);
const managedId = managedAgentId(handler, scriptName, true);
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
? [handlerIndex]
: [];
@@ -264,20 +288,21 @@ export function managedRemovals(
return removals;
}
function managedIds(groups: DroidGroup[], scriptName: string): Set<string> {
function configuredIds(groups: DroidGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
for (const group of groups) {
for (const handler of group.hooks) {
const agentId = managedAgentId(handler, scriptName);
if (agentId) ids.add(agentId);
}
if (Object.keys(group).sort().join('\0') !== GROUP_KEYS.join('\0')
|| group.matcher !== '*'
|| group.hooks.length !== 1) continue;
const agentId = managedAgentId(group.hooks[0], 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);
export function runtimeContracts(hooks: DroidHookMap): RuntimeContract[] {
const guards = configuredIds(hooks.PreToolUse ?? [], GUARD_SCRIPT);
const audits = configuredIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
@@ -287,10 +312,3 @@ export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[]
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
export function mergeHookSettings(
primary: DroidHookSettings | undefined,
fallback: DroidHookSettings | undefined,
): DroidHookSettings {
return { ...(fallback ?? {}), ...(primary ?? {}) };
}
+172
View File
@@ -0,0 +1,172 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
samePath,
} from './droid-contract.js';
import {
activeDocument,
installationDocuments,
sourceDocuments,
type DroidDocument,
type DroidSources,
type RenderedDocument,
} from './droid-config.js';
import { requireHooksEnabled } from './droid-io.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'Factory Droid';
const DIRECTORY_LABEL = 'Factory Droid user configuration directory';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export type DroidRuntimePaths = ManagedRuntimePaths;
export type PreparedDroidInstallation = PreparedManagedInstallation;
export type { RenameFile };
function sourceLabel(document: DroidDocument): string {
if (document.kind === 'settings') return 'Factory Droid user settings';
if (document.kind === 'local-settings') return 'Factory Droid local settings';
if (document.kind === 'legacy') return 'Factory Droid legacy hooks';
return 'Factory Droid user hooks';
}
function hookLocations(sources: DroidSources) {
return installationDocuments(sources).map((document) => ({
directoryLabel: document.kind === 'legacy'
? 'Factory Droid legacy hooks directory'
: DIRECTORY_LABEL,
filePath: document.filePath,
}));
}
export async function preflightDroidInstallation(
config: InstallConfig,
sources: DroidSources,
): Promise<DroidRuntimePaths> {
requireHooksEnabled(sources);
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: hookLocations(sources),
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
function installationChanges(
sources: DroidSources,
rendered: readonly RenderedDocument[],
): readonly RenderedDocument[] {
const target = activeDocument(sources);
return rendered.filter((item) => item.changed || item.document === target);
}
function sourcePreconditions(
sources: DroidSources,
changed: readonly RenderedDocument[],
) {
const changedPaths = changed.map((item) => item.document.filePath);
return sourceDocuments(sources)
.filter((document) => !changedPaths.some((filePath) => samePath(filePath, document.filePath)))
.map((document) => ({
filePath: document.filePath,
label: sourceLabel(document),
maximumBytes: MAX_SOURCE_BYTES,
original: document.snapshot,
}));
}
export async function prepareDroidInstallation(
config: InstallConfig,
sources: DroidSources,
rendered: readonly RenderedDocument[],
): Promise<PreparedDroidInstallation> {
await preflightDroidInstallation(config, sources);
const changed = installationChanges(sources, rendered);
const prepared = await prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources: changed.map((item) => ({
directoryLabel: item.document.kind === 'legacy'
? 'Factory Droid legacy hooks directory'
: DIRECTORY_LABEL,
label: sourceLabel(item.document),
filePath: item.document.filePath,
expectedSource: item.document.exists ? item.document.raw : undefined,
expectedSnapshot: item.document.snapshot,
source: item.changed ? item.next : item.document.raw,
})),
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
return {
...prepared,
transaction: {
...prepared.transaction,
preconditions: [
...sourcePreconditions(sources, changed),
...sources.policy.preconditions,
],
},
};
}
export async function commitDroidInstallation(
prepared: PreparedDroidInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
export async function prepareDroidUninstall(
rendered: readonly RenderedDocument[],
): Promise<PreparedManagedTransaction> {
const changed = rendered.filter((item) => item.changed);
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
filePath: item.document.filePath,
label: sourceLabel(item.document),
next: item.next,
mode: 0o600,
expectedSource: item.document.raw,
expectedSnapshot: item.document.snapshot,
verifyExpectedSource: true,
})));
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: changed.map((item) => ({
path: path.dirname(item.document.filePath),
label: item.document.kind === 'legacy'
? 'Factory Droid legacy hooks directory'
: DIRECTORY_LABEL,
})),
changes: changes.filter((change) => change !== undefined),
preconditions: rendered
.filter((item) => !item.changed)
.map((item) => ({
filePath: item.document.filePath,
label: sourceLabel(item.document),
maximumBytes: MAX_SOURCE_BYTES,
original: item.document.snapshot,
})),
};
}
export async function commitDroidUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+137 -262
View File
@@ -1,310 +1,185 @@
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,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type RuntimeContract,
isObject,
sameAgentId,
samePath,
} from './droid-contract.js';
import {
type DroidSources,
type RenderedDocument,
activeDocument,
createLegacyHookDocument,
createOwnedHookDocument,
createSettingsDocument,
hookBlock,
parseDocument,
type DroidDocument,
type DroidDocumentKind,
type DroidSources,
} from './droid-config.js';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import { readDroidPolicy } from './droid-policy.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly original?: string;
readonly next?: string;
}
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
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(): {
interface FactoryPaths {
readonly directory: string;
readonly root: string;
readonly legacyDirectory: string;
readonly legacy: string;
readonly settings: string;
} {
readonly localSettings: string;
}
function factoryPaths(): FactoryPaths {
const directory = path.join(os.homedir(), '.factory');
const legacyDirectory = path.join(directory, 'hooks');
return {
directory,
root: path.join(directory, 'hooks.json'),
legacy: path.join(directory, 'hooks', 'hooks.json'),
legacyDirectory,
legacy: path.join(legacyDirectory, 'hooks.json'),
settings: path.join(directory, 'settings.json'),
localSettings: path.join(directory, 'settings.local.json'),
};
}
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),
});
}
async function readDocument(
filePath: string,
kind: DroidDocumentKind,
label: string,
): Promise<DroidDocument | undefined> {
const snapshot = await readPhysicalFile(filePath, label, MAX_SOURCE_BYTES);
return snapshot ? parseDocument({
exists: true,
filePath,
kind,
raw: snapshot.contents,
snapshot,
}) : undefined;
}
export async function readSources(): Promise<DroidSources> {
const paths = factoryPaths();
const [rootRaw, settingsRaw] = await Promise.all([
readOptional(paths.root, 'Factory Droid hooks'),
readOptional(paths.settings, 'Factory Droid settings'),
await inspectPhysicalDirectory(paths.directory, 'Factory Droid user configuration directory');
await inspectPhysicalDirectory(paths.legacyDirectory, 'Factory Droid legacy hooks directory');
const [root, legacy, settings, localSettings, policy] = await Promise.all([
readDocument(paths.root, 'hooks', 'Factory Droid user hooks'),
readDocument(paths.legacy, 'legacy', 'Factory Droid legacy hooks'),
readDocument(paths.settings, 'settings', 'Factory Droid user settings'),
readDocument(paths.localSettings, 'local-settings', 'Factory Droid local settings'),
readDroidPolicy(),
]);
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,
root: root ?? createOwnedHookDocument(paths.root),
legacy: legacy ?? createLegacyHookDocument(paths.legacy),
settings: settings ?? createSettingsDocument(paths.settings),
localSettings: localSettings
?? createSettingsDocument(paths.localSettings, 'local-settings'),
policy,
};
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) throw error;
export function requireHooksEnabled(sources: DroidSources): void {
const blocked = hookBlock(sources);
if (blocked) {
throw new Error(
`Factory Droid user hooks are disabled by ${blocked.field} in ${blocked.label} at ${blocked.filePath}`,
);
}
}
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} at ${filePath}`);
}
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: failures[0],
});
function requireString(value: unknown, field: string, configPath: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
}
}
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 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<void> {
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<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;
}
function validateRuntimeConfig(
config: JsonObject,
contract: RuntimeContract,
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}`);
requireString(config.org_id, 'org_id', configPath);
requireString(config.kid, 'kid', configPath);
const agentId = requireString(config.agent_id, 'agent_id', configPath);
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
throw new Error(`Elydora runtime identity does not match Factory Droid hooks: ${configPath}`);
}
if (config.token !== undefined) requireString(config.token, 'token', configPath);
const rawBaseUrl = requireString(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}`);
}
}
function validContractPaths(contract: RuntimeContract): boolean {
const agentDirectory = path.dirname(contract.guardPath);
return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
for (const contract of contracts) {
if (!validContractPaths(contract)) continue;
const runtimeRoot = path.join(os.homedir(), '.elydora');
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 (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) continue;
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) continue;
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', MAX_CONFIG_BYTES),
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
]);
if (files.every(Boolean)) return true;
if (!config || !key || !guard || !audit) continue;
const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`);
validateRuntimeConfig(parsed, contract, configPath);
validatePrivateKey(key.contents, keyPath);
if (guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
&& audit.contents === generateHookScript(
AGENT_KEY,
contract.agentId,
{ nativePayload: true },
)) 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;
return activeDocument(sources).filePath;
}
+157
View File
@@ -0,0 +1,157 @@
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
import { parseStrictJsoncObject, type JsonObject } from './strict-json.js';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
interface PolicyLocation {
readonly filePath: string;
readonly label: string;
}
interface PolicyLayer extends PolicyLocation {
readonly snapshot?: FileSnapshot;
readonly hooksDisabled?: boolean;
readonly allowManagedHooksOnly?: boolean;
readonly showHookOutput?: boolean;
}
export interface DroidPolicyPrecondition {
readonly filePath: string;
readonly label: string;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
export interface DroidPolicyOrigin {
readonly filePath: string;
readonly label: string;
}
export interface DroidPolicyState {
readonly allowManagedHooksOnlyBy?: DroidPolicyOrigin;
readonly hooksDisabled?: boolean;
readonly hooksDisabledBy?: DroidPolicyOrigin;
readonly preconditions: readonly DroidPolicyPrecondition[];
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
function optionalBoolean(root: JsonObject, field: string, label: string): boolean | undefined {
const value = root[field];
if (value !== undefined && typeof value !== 'boolean') {
throw new Error(`${label} field "${field}" must be a boolean`);
}
return value as boolean | undefined;
}
function managedSettingsPath(): string {
if (process.platform === 'darwin') {
return '/Library/Application Support/Factory/settings.json';
}
if (process.platform === 'win32') {
return path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Factory', 'settings.json');
}
return '/etc/factory/settings.json';
}
async function readLayer(location: PolicyLocation): Promise<PolicyLayer> {
await inspectPhysicalDirectory(path.dirname(location.filePath), `${location.label} directory`);
const snapshot = await readPhysicalFile(location.filePath, location.label, MAX_SOURCE_BYTES);
if (!snapshot) return location;
const root = parseStrictJsoncObject(snapshot.contents, `${location.label} at ${location.filePath}`);
return {
...location,
snapshot,
hooksDisabled: optionalBoolean(root, 'hooksDisabled', location.label),
allowManagedHooksOnly: optionalBoolean(root, 'allowManagedHooksOnly', location.label),
showHookOutput: optionalBoolean(root, 'showHookOutput', location.label),
};
}
async function gitRoot(start: string): Promise<string | undefined> {
let current = path.resolve(start);
for (;;) {
const marker = path.join(current, '.git');
try {
const metadata = await fsp.lstat(marker);
if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {
throw new Error(`Factory Droid project marker is not physical: ${marker}`);
}
return current;
} catch (error) {
if (!hasCode(error, 'ENOENT') && !hasCode(error, 'ENOTDIR')) throw error;
}
const parent = path.dirname(current);
if (parent === current) return undefined;
current = parent;
}
}
function projectDirectories(root: string, current: string): string[] {
const directories = [root];
const relative = path.relative(root, current);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return directories;
let directory = root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
directory = path.join(directory, segment);
directories.push(directory);
}
return directories;
}
async function projectLocations(): Promise<PolicyLocation[][]> {
const current = path.resolve(process.cwd());
const root = await gitRoot(current) ?? current;
return projectDirectories(root, current).map((directory, index) => {
const factory = path.join(directory, '.factory');
const scope = index === 0 ? 'project' : `folder ${directory}`;
return [{
filePath: path.join(factory, 'settings.json'),
label: `Factory Droid ${scope} settings`,
}, {
filePath: path.join(factory, 'settings.local.json'),
label: `Factory Droid ${scope} local settings`,
}];
});
}
function scopeValue(layers: readonly PolicyLayer[], field: 'hooksDisabled'): PolicyLayer | undefined {
const [settings, local] = layers;
return local[field] !== undefined ? local : settings[field] !== undefined ? settings : undefined;
}
export async function readDroidPolicy(): Promise<DroidPolicyState> {
const managedLocation: PolicyLocation = {
filePath: managedSettingsPath(),
label: 'Factory Droid system-managed settings',
};
const scopes = await projectLocations();
const [managed, ...projectLayers] = await Promise.all([
readLayer(managedLocation),
...scopes.map(async (scope) => Promise.all(scope.map(readLayer))),
]);
const flattened = [managed, ...projectLayers.flat()];
const allowManagedHooksOnlyBy = managed.allowManagedHooksOnly === true
? { filePath: managed.filePath, label: managed.label }
: undefined;
const selected = [managed, ...projectLayers.map((scope) => scopeValue(scope, 'hooksDisabled'))]
.find((layer) => layer?.hooksDisabled !== undefined);
return {
allowManagedHooksOnlyBy,
hooksDisabled: selected?.hooksDisabled,
hooksDisabledBy: selected
? { filePath: selected.filePath, label: selected.label }
: undefined,
preconditions: flattened.map((layer) => ({
filePath: layer.filePath,
label: layer.label,
maximumBytes: MAX_SOURCE_BYTES,
original: layer.snapshot,
})),
};
}
+54 -56
View File
@@ -1,88 +1,86 @@
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,
activeDocument,
additionsForTarget,
effectiveHooks,
hookBlock,
installationDocuments,
renderDocument,
type DroidDocument,
sourceDocuments,
type DroidSources,
type RenderedDocument,
} from './droid-config.js';
import {
displayConfigPath,
readSources,
requireRuntime,
runtimeFilesExist,
writeDocuments,
} from './droid-io.js';
commitDroidInstallation,
commitDroidUninstall,
preflightDroidInstallation,
prepareDroidInstallation,
prepareDroidUninstall,
} from './droid-installation.js';
import { displayConfigPath, readSources, runtimeFilesExist } from './droid-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function uniqueDocuments(documents: Array<DroidDocument | undefined>): DroidDocument[] {
const unique = new Map<string, DroidDocument>();
for (const document of documents) {
if (document) unique.set(document.filePath, document);
}
return [...unique.values()];
function renderInstallation(
sources: DroidSources,
guardPath: string,
auditPath: string,
): RenderedDocument[] {
const target = activeDocument(sources);
const groups = new Map<ToolEvent, DroidGroup>([
['PreToolUse', buildGroup(guardPath)],
['PostToolUse', buildGroup(auditPath)],
]);
return installationDocuments(sources).map((document) => renderDocument(
document,
undefined,
additionsForTarget(document, target, groups),
));
}
function renderUninstall(sources: DroidSources, agentId?: string): RenderedDocument[] {
return sourceDocuments(sources).map(
(document) => renderDocument(document, agentId, new Map()),
);
}
export const droidPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
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');
managesRuntime: true,
const selected = installationTargets(sources);
const groups = new Map<ToolEvent, DroidGroup>([
['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}`);
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readSources();
await preflightDroidInstallation(config, sources);
},
async install(config: InstallConfig): Promise<void> {
const sources = await readSources();
const paths = await preflightDroidInstallation(config, sources);
const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath);
const prepared = await prepareDroidInstallation(config, sources, rendered);
await commitDroidInstallation(prepared);
console.log(` Factory Droid hooks: ${activeDocument(sources).filePath}`);
console.log(' Factory Droid: run /hooks to review the Elydora hook changes.');
},
async uninstall(agentId?: string): Promise<void> {
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);
const prepared = await prepareDroidUninstall(renderUninstall(sources, agentId));
await commitDroidUninstall(prepared);
},
async status(): Promise<PluginStatus> {
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;
const contracts = runtimeContracts(effectiveHooks(sources));
const hookConfigured = hookBlock(sources) === undefined && contracts.length > 0;
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
+238
View File
@@ -0,0 +1,238 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parse } from 'jsonc-parser';
export const VALID_PRIVATE_KEY = Buffer.alloc(32, 13).toString('base64url');
export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid.js')).href;
export const configModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-config.js')).href;
export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-contract.js')).href;
export const installationModuleUrl = pathToFileURL(
path.resolve('dist/plugins/droid-installation.js'),
).href;
export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-io.js')).href;
export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
export const cliPath = path.resolve('dist/cli.js');
export function runProcess(command, args, env, cwd, input = '', shell = false) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
shell,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
export function runNode(args, env, cwd, input = '') {
return runProcess(process.execPath, args, env, cwd, input);
}
export function environment(fixture) {
return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir };
}
export function installConfig(fixture, overrides = {}) {
return {
agentName: 'droid',
orgId: 'org-1',
agentId: fixture.agentId,
privateKey: VALID_PRIVATE_KEY,
kid: 'kid-1',
token: 'token-1',
baseUrl: fixture.baseUrl,
guardScriptPath: fixture.guardScriptPath,
hookScriptPath: fixture.hookScriptPath,
...overrides,
};
}
export async function runPlugin(fixture, method, argument) {
const source = `
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', source],
{
...environment(fixture),
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
fixture.workspaceDir,
);
}
export function runHook(command, fixture, input) {
if (process.platform === 'win32') {
return runProcess(
'powershell.exe',
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command],
environment(fixture),
fixture.workspaceDir,
input,
);
}
return runProcess('/bin/sh', ['-c', command], environment(fixture), fixture.workspaceDir, input);
}
export async function writeConfig(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
await writeFile(filePath, source, { mode: 0o600 });
}
async function writeOptional(filePath, value) {
if (value !== undefined) await writeConfig(filePath, value);
}
export async function createFixture({
agentId = 'agent-1',
baseUrl = 'http://127.0.0.1:9',
rootConfig,
legacyConfig,
settings,
localSettings,
projectSettings,
projectLocalSettings,
} = {}) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-'));
const homeDir = path.join(rootDir, "home with spaces and 'quote %DROID%");
const workspaceDir = path.join(rootDir, 'workspace with spaces');
const factoryDir = path.join(homeDir, '.factory');
const rootPath = path.join(factoryDir, 'hooks.json');
const legacyPath = path.join(factoryDir, 'hooks', 'hooks.json');
const settingsPath = path.join(factoryDir, 'settings.json');
const localSettingsPath = path.join(factoryDir, 'settings.local.json');
const agentDir = path.join(homeDir, '.elydora', agentId);
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
const projectFactoryDir = path.join(workspaceDir, '.factory');
const projectSettingsPath = path.join(projectFactoryDir, 'settings.json');
const projectLocalSettingsPath = path.join(projectFactoryDir, 'settings.local.json');
await mkdir(path.join(workspaceDir, '.git'), { recursive: true });
await Promise.all([
writeOptional(rootPath, rootConfig),
writeOptional(legacyPath, legacyConfig),
writeOptional(settingsPath, settings),
writeOptional(localSettingsPath, localSettings),
writeOptional(projectSettingsPath, projectSettings),
writeOptional(projectLocalSettingsPath, projectLocalSettings),
]);
return {
agentDir,
agentId,
baseUrl,
factoryDir,
guardScriptPath,
homeDir,
hookScriptPath,
legacyPath,
localSettingsPath,
projectLocalSettingsPath,
projectSettingsPath,
rootDir,
rootPath,
settingsPath,
workspaceDir,
install(overrides = {}) {
return runPlugin(this, 'install', installConfig(this, overrides));
},
async close() {
await rm(rootDir, { recursive: true, force: true });
},
};
}
export function readJsoncSource(source) {
const errors = [];
const value = parse(source, errors, { allowTrailingComma: true });
if (errors.length > 0) throw new Error(`Unexpected JSONC parse errors: ${JSON.stringify(errors)}`);
return value;
}
export async function readJsonc(filePath) {
return readJsoncSource(await readFile(filePath, 'utf-8'));
}
export function managedGroup(hooks, event, scriptName) {
return hooks?.[event]?.find((group) => group.hooks?.some(
(handler) => handler.command?.includes(scriptName),
));
}
export function managedHandler(hooks, event, scriptName) {
return managedGroup(hooks, event, scriptName)?.hooks.find(
(handler) => handler.command?.includes(scriptName),
);
}
export function assertNativeGroup(group) {
if (!group) throw new Error('Managed group is missing');
if (JSON.stringify(Object.keys(group).sort()) !== JSON.stringify(['hooks', 'matcher'])) {
throw new Error(`Unexpected group fields: ${Object.keys(group).join(', ')}`);
}
if (group.matcher !== '*' || group.hooks.length !== 1) {
throw new Error(`Unexpected group contract: ${JSON.stringify(group)}`);
}
const handler = group.hooks[0];
if (JSON.stringify(Object.keys(handler).sort()) !== JSON.stringify(['command', 'timeout', 'type'])) {
throw new Error(`Unexpected handler fields: ${Object.keys(handler).join(', ')}`);
}
if (handler.type !== 'command' || handler.timeout !== 10) {
throw new Error(`Unexpected handler contract: ${JSON.stringify(handler)}`);
}
}
export async function assertMissing(filePath) {
await assert.rejects(readFile(filePath), { code: 'ENOENT' });
}
export async function assertNoTransactionFiles(fixture) {
const names = await readdir(fixture.rootDir, { recursive: true });
const leaked = names.filter((name) => /\.(tmp|rollback)$/.test(name));
if (leaked.length > 0) throw new Error(`Leaked transaction files: ${leaked.join(', ')}`);
}
export async function startApiServer() {
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({ method: request.method, url: request.url, raw });
response.writeHead(request.method === 'POST' ? 201 : 200, {
'Content-Type': 'application/json',
});
response.end(request.method === 'POST'
? '{"operation":{"accepted":true}}'
: '{"agent":{"status":"active"}}');
});
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());
}),
};
}
+404 -407
View File
@@ -1,137 +1,28 @@
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 { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import { parse } from 'jsonc-parser';
import {
VALID_PRIVATE_KEY,
assertMissing,
assertNativeGroup,
assertNoTransactionFiles,
cliPath,
createFixture,
environment,
managedGroup,
managedHandler,
readJsonc,
registryModuleUrl,
runHook,
runNode,
runPlugin,
startApiServer,
writeConfig,
} from '../test-support/droid-test-helpers.mjs';
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;
function currentHooks(root) {
return root.hooks;
}
test('Factory Droid is registered in the SDK and CLI', async () => {
@@ -141,142 +32,190 @@ test('Factory Droid is registered in the SDK and CLI', async () => {
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);
const fixture = await createFixture();
try {
const result = await runNode(['--no-warnings', cliPath, 'status'], {
HOME: homeDir,
USERPROFILE: homeDir,
}, '', workspaceDir);
const result = await runNode(
['--no-warnings', cliPath, 'agents'],
environment(fixture),
fixture.workspaceDir,
);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /Factory Droid \(droid\)/);
assert.match(result.stdout, /droid\s+Factory Droid/);
} finally {
await rm(homeDir, { recursive: true, force: true });
await fixture.close();
}
});
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": []
test('Droid installs the official hook container and complete managed runtime', async () => {
const fixture = await createFixture();
try {
const first = await fixture.install();
assert.equal(first.code, 0, first.stderr);
assert.match(first.stdout, /run \/hooks/);
const rootSource = await readFile(fixture.rootPath, 'utf-8');
assert.match(rootSource, /^\/\/ Managed by Elydora\r?\n/);
const hooks = currentHooks(await readJsonc(fixture.rootPath));
assertNativeGroup(managedGroup(hooks, 'PreToolUse', 'guard.js'));
assertNativeGroup(managedGroup(hooks, 'PostToolUse', 'hook.js'));
if (process.platform === 'win32') {
assert.match(managedHandler(hooks, 'PreToolUse', 'guard.js').command, /^& '/);
}
assert.deepEqual(JSON.parse(await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8')), {
org_id: 'org-1',
agent_id: fixture.agentId,
kid: 'kid-1',
base_url: fixture.baseUrl,
token: 'token-1',
agent_name: 'droid',
});
assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY);
assert.match(await readFile(fixture.hookScriptPath, 'utf-8'), /const NATIVE_PAYLOAD = true;/);
const managedPaths = [
fixture.rootPath,
fixture.guardScriptPath,
fixture.hookScriptPath,
path.join(fixture.agentDir, 'config.json'),
path.join(fixture.agentDir, 'private.key'),
];
const before = new Map(await Promise.all(managedPaths.map(async (filePath) => [
filePath,
await readFile(filePath, 'utf-8'),
])));
const second = await fixture.install();
assert.equal(second.code, 0, second.stderr);
for (const [filePath, source] of before) {
assert.equal(await readFile(filePath, 'utf-8'), source, filePath);
}
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid migrates the legacy Windows command form to its native PowerShell contract', async (t) => {
if (process.platform !== 'win32') {
t.skip('Legacy double-quoted commands are Windows-specific');
return;
}
const fixture = await createFixture({ rootConfig: { hooks: {} } });
try {
const legacyGroup = (scriptPath) => ({
matcher: '*',
hooks: [{
type: 'command',
command: `"${process.execPath}" "${scriptPath}"`,
timeout: 10,
}],
});
await writeConfig(fixture.rootPath, {
hooks: {
PreToolUse: [legacyGroup(fixture.guardScriptPath)],
PostToolUse: [legacyGroup(fixture.hookScriptPath)],
},
});
assert.equal((await fixture.install()).code, 0);
const hooks = currentHooks(await readJsonc(fixture.rootPath));
assert.equal(hooks.PreToolUse.length, 1);
assert.equal(hooks.PostToolUse.length, 1);
assert.match(hooks.PreToolUse[0].hooks[0].command, /^& '/);
} finally {
await fixture.close();
}
});
test('Droid applies whole-source precedence and preserves inactive user sources', async (t) => {
await t.test('root hooks.json wins over settings hooks', async () => {
const rootConfig = `{
// active root source
"hooks": {
"PreToolUse": [{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }]
}
}\n`;
const settings = `{
// general setting
const settings = `{
// inactive settings source
"theme": "dark",
"hooks": {
// settings fallback event
"PostToolUse": [
// keep settings group comment
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
],
"showHookOutput": true,
},
"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 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');
const fixture = await createFixture({ rootConfig, settings });
try {
assert.equal((await fixture.install()).code, 0);
const rootSource = await readFile(fixture.rootPath, 'utf-8');
const settingsSource = await readFile(fixture.settingsPath, 'utf-8');
const hooks = currentHooks(await readJsonc(fixture.rootPath));
assert.match(rootSource, /active root source/);
assert.equal(hooks.PreToolUse[0].hooks[0].command, 'root-user');
assertNativeGroup(managedGroup(hooks, 'PreToolUse', 'guard.js'));
assertNativeGroup(managedGroup(hooks, 'PostToolUse', 'hook.js'));
assert.equal(settingsSource, settings);
} finally {
await fixture.close();
}
await assert.rejects(
readFile(path.join(fixture.workspaceDir, '.factory', 'hooks.json')),
{ code: 'ENOENT' },
);
} finally {
await fixture.close();
}
});
await t.test('settings hooks are the fallback when hook files are absent', async () => {
const settings = '{\r\n\t"theme": "dark",\r\n\t"hooks": {}\r\n}\r\n';
const fixture = await createFixture({ settings });
try {
assert.equal((await fixture.install()).code, 0);
await assertMissing(fixture.rootPath);
const source = await readFile(fixture.settingsPath, 'utf-8');
const hooks = (await readJsonc(fixture.settingsPath)).hooks;
assert.match(source, /\r\n\t\t"PreToolUse"/);
assertNativeGroup(managedGroup(hooks, 'PreToolUse', 'guard.js'));
assertNativeGroup(managedGroup(hooks, 'PostToolUse', 'hook.js'));
} finally {
await fixture.close();
}
});
await t.test('local settings hooks override base settings hooks', async () => {
const settings = { hooks: { Notification: [] } };
const localSettings = { hooks: { SessionStart: [] } };
const fixture = await createFixture({ settings, localSettings });
try {
assert.equal((await fixture.install()).code, 0);
await assertMissing(fixture.rootPath);
const base = await readJsonc(fixture.settingsPath);
const local = await readJsonc(fixture.localSettingsPath);
assert.equal(base.hooks.PreToolUse, undefined);
assertNativeGroup(managedGroup(local.hooks, 'PreToolUse', 'guard.js'));
assertNativeGroup(managedGroup(local.hooks, 'PostToolUse', 'hook.js'));
} finally {
await fixture.close();
}
});
await t.test('legacy hook source stays active until Factory migrates it', async () => {
const legacyConfig = {
PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'legacy-user' }] }],
};
const settings = { hooks: { PostToolUse: [] } };
const fixture = await createFixture({ legacyConfig, settings });
try {
assert.equal((await fixture.install()).code, 0);
await assertMissing(fixture.rootPath);
const legacy = await readJsonc(fixture.legacyPath);
assertNativeGroup(managedGroup(legacy, 'PreToolUse', 'guard.js'));
assertNativeGroup(managedGroup(legacy, 'PostToolUse', 'hook.js'));
assert.equal((await readJsonc(fixture.settingsPath)).hooks.PreToolUse, undefined);
} 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 });
test('Droid guard blocks frozen agents and audit preserves the native event payload', async () => {
const api = await startApiServer();
const fixture = await createFixture({ baseUrl: api.baseUrl });
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({
assert.equal((await fixture.install()).code, 0);
const hooks = currentHooks(await readJsonc(fixture.rootPath));
const guard = managedHandler(hooks, 'PreToolUse', 'guard.js');
const audit = managedHandler(hooks, 'PostToolUse', 'hook.js');
const prePayload = {
session_id: 'session-1',
transcript_path: path.join(fixture.homeDir, 'transcript.jsonl'),
cwd: fixture.workspaceDir,
@@ -284,192 +223,250 @@ test('Droid commands block freezes and forward official input byte-for-byte', as
hook_event_name: 'PreToolUse',
tool_name: 'Execute',
tool_input: { command: 'echo test' },
};
await writeConfig(path.join(fixture.agentDir, 'status-cache.json'), {
status: 'frozen',
cached_at: Date.now(),
});
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),
const guardResult = await runHook(guard.command, fixture, JSON.stringify(prePayload));
assert.equal(guardResult.code, 2, guardResult.stderr);
assert.match(guardResult.stderr, /Agent "droid" is frozen/);
const postPayload = {
...prePayload,
hook_event_name: 'PostToolUse',
tool_response: { output: 'test', success: true },
});
const auditResult = await runCommand(audit.command, postPayload);
};
const auditResult = await runHook(audit.command, fixture, JSON.stringify(postPayload));
assert.equal(auditResult.code, 0, auditResult.stderr);
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
const operation = JSON.parse(api.requests.find((request) => request.method === 'POST').raw);
assert.deepEqual(operation.payload, postPayload);
assert.deepEqual(operation.subject, { session_id: 'session-1' });
assert.deepEqual(operation.action, { tool: 'Execute' });
} finally {
await api.close();
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: [] } },
test('Droid status requires the exact hook and runtime contract', async (t) => {
const cases = [
['missing audit group', async (fixture) => {
const root = await readJsonc(fixture.rootPath);
delete root.hooks.PostToolUse;
await writeConfig(fixture.rootPath, root);
}, /"hookConfigured":false/],
['tampered guard', (fixture) => writeFile(fixture.guardScriptPath, 'tampered\n'), /"installed":false/],
['tampered audit', (fixture) => writeFile(fixture.hookScriptPath, 'tampered\n'), /"installed":false/],
['malformed runtime config', (fixture) => writeFile(
path.join(fixture.agentDir, 'config.json'),
'{ malformed',
), /parse Elydora runtime config/i],
['invalid private key', (fixture) => writeFile(
path.join(fixture.agentDir, 'private.key'),
'invalid',
), /private key is invalid/i],
];
for (const [label, mutate, expected] of cases) {
await t.test(label, async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const healthy = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
assert.deepEqual(healthy, {
installed: true,
agentName: 'droid',
displayName: 'Factory Droid',
hookConfigured: true,
hookScriptExists: true,
configPath: fixture.rootPath,
});
await mutate(fixture);
const status = await runPlugin(fixture, 'status', null);
assert.match(`${status.stdout}\n${status.stderr}`, expected);
} finally {
await fixture.close();
}
});
}
});
test('Droid accepts current extension fields and rejects malformed hook contracts', async () => {
const valid = await createFixture({
rootConfig: {
hooks: {
FutureEvent: [{
commandRegex: '^git (status|diff)$',
hooks: [{ type: 'command', command: 'future-user', timeout: 2 }],
}],
},
},
});
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);
assert.equal((await valid.install()).code, 0);
assert.equal((await readJsonc(valid.rootPath)).hooks.FutureEvent[0].hooks[0].command, 'future-user');
} finally {
await fixture.close();
await valid.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' },
{ rootConfig: '{ malformed', target: 'rootPath' },
{ rootConfig: '{"hooks":{},"hooks":{}}', target: 'rootPath' },
{ rootConfig: { hooks: [] }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: null } }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: [null] } }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: [{ matcher: '[', hooks: [] }] } }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: [{ commandRegex: '[', hooks: [] }] } }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }, target: 'rootPath' },
{ rootConfig: { hooks: { PreToolUse: [{ hooks: [{ type: 'http', command: 'x' }] }] } }, target: 'rootPath' },
{ settings: { hooks: { showHookOutput: true } }, target: 'settingsPath' },
{ localSettings: '{ malformed', target: 'localSettingsPath' },
];
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']),
);
const target = fixture[input.target];
const before = await readFile(target, 'utf-8');
const result = await fixture.install();
assert.equal(result.code, 1, `${JSON.stringify(input)}\n${result.stderr}`);
assert.equal(await readFile(target, 'utf-8'), before);
await assertMissing(path.join(fixture.agentDir, 'config.json'));
} 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'),
test('Droid uninstall removes exact ownership and preserves user hooks', async (t) => {
await t.test('pre-existing JSONC keeps comments and user groups', async () => {
const rootConfig = `{
// user source
"hooks": {
"Notification": [{ "hooks": [{ "type": "command", "command": "keep" }] }]
}
}\n`;
const fixture = await createFixture({ rootConfig });
try {
assert.equal((await fixture.install()).code, 0);
assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0);
const source = await readFile(fixture.rootPath, 'utf-8');
const root = await readJsonc(fixture.rootPath);
assert.match(source, /user source/);
assert.equal(root.hooks.Notification[0].hooks[0].command, 'keep');
assert.equal(root.hooks.PreToolUse, undefined);
assert.equal(root.hooks.PostToolUse, undefined);
} finally {
await fixture.close();
}
});
await t.test('mixed groups and lookalike paths stay user-owned', async () => {
const fixture = await createFixture({ rootConfig: { hooks: {} } });
try {
assert.equal((await fixture.install()).code, 0);
const root = await readJsonc(fixture.rootPath);
const group = managedGroup(root.hooks, 'PreToolUse', 'guard.js');
const command = group.hooks[0].command;
group.hooks.push({ type: 'command', command: 'user-command' });
root.hooks.PreToolUse.push({
matcher: '*',
hooks: [{ type: 'command', command: command.replace('guard.js', 'guard.js.backup'), timeout: 10 }],
});
root.hooks.PreToolUse.push({
matcher: '*',
hooks: [{ type: 'command', command: command.replace('agent-1', 'agent-10'), timeout: 10 }],
});
await writeConfig(fixture.rootPath, root);
assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0);
const remaining = await readJsonc(fixture.rootPath);
assert.match(JSON.stringify(remaining), /user-command/);
assert.match(JSON.stringify(remaining), /guard\.js\.backup/);
assert.match(JSON.stringify(remaining), /agent-10/);
assert.equal(remaining.hooks.PostToolUse, undefined);
} finally {
await fixture.close();
}
});
await t.test('an empty Elydora-owned hook file is removed', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0);
await assertMissing(fixture.rootPath);
} finally {
await fixture.close();
}
});
await t.test('an empty installation stays empty', async () => {
const fixture = await createFixture();
try {
assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0);
await assertMissing(fixture.rootPath);
} finally {
await fixture.close();
}
});
});
test('Droid rejects linked configuration and runtime paths before writes', async (t) => {
for (const kind of ['factory', 'hook', 'runtime']) {
await t.test(kind, async () => {
const fixture = await createFixture();
try {
const target = path.join(fixture.rootDir, `${kind}-target`);
await mkdir(target, { recursive: true });
if (kind === 'factory') {
await mkdir(fixture.homeDir, { recursive: true });
await symlink(target, fixture.factoryDir, 'junction');
} else if (kind === 'hook') {
await mkdir(fixture.factoryDir, { recursive: true });
const targetFile = path.join(target, 'hooks.json');
await writeConfig(targetFile, { hooks: {} });
await symlink(targetFile, fixture.rootPath, 'file');
} else {
await mkdir(fixture.homeDir, { recursive: true });
await symlink(target, path.join(fixture.homeDir, '.elydora'), 'junction');
}
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical (directory|file)/i);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`Symbolic links unavailable: ${error.message}`);
return;
}
throw error;
} finally {
await fixture.close();
}
});
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: [] } },
});
test('Droid CLI preflight blocks disabled hooks before creating runtime files', async () => {
const fixture = await createFixture({ settings: { hooksDisabled: true } });
const privateKeyPath = path.join(fixture.rootDir, 'private.key');
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);
await writeFile(privateKeyPath, VALID_PRIVATE_KEY, { mode: 0o600 });
const result = await runNode([
'--no-warnings',
cliPath,
'install',
'--agent', 'droid',
'--org_id', 'org-1',
'--agent_id', fixture.agentId,
'--kid', 'kid-1',
'--private_key_file', privateKeyPath,
'--base_url', fixture.baseUrl,
], environment(fixture), fixture.workspaceDir);
assert.equal(result.code, 1);
assert.match(result.stderr, /hooks are disabled/i);
await assertMissing(path.join(fixture.agentDir, 'config.json'));
await assertMissing(fixture.rootPath);
} finally {
await rm(privateKeyPath, { force: true });
await fixture.close();
}
});
+123
View File
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import {
assertMissing,
configModuleUrl,
createFixture,
} from '../test-support/droid-test-helpers.mjs';
test('Droid resolves hooksDisabled through official settings precedence', async (t) => {
await t.test('user settings disable installation before runtime creation', async () => {
const fixture = await createFixture({ settings: { hooksDisabled: true } });
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /hooksDisabled/);
await assertMissing(path.join(fixture.agentDir, 'config.json'));
await assertMissing(fixture.rootPath);
} finally {
await fixture.close();
}
});
await t.test('user local settings override their base file', async () => {
const fixture = await createFixture({
settings: { hooksDisabled: true },
localSettings: { hooksDisabled: false },
});
try {
assert.equal((await fixture.install()).code, 0);
} finally {
await fixture.close();
}
});
await t.test('legacy direct flags remain safety-compatible', async () => {
const fixture = await createFixture({
legacyConfig: { hooksDisabled: true, PreToolUse: [] },
settings: { hooksDisabled: false },
});
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, new RegExp(fixture.legacyPath.replaceAll('\\', '\\\\'), 'i'));
} finally {
await fixture.close();
}
});
await t.test('project policy takes precedence over user settings', async () => {
const fixture = await createFixture({
settings: { hooksDisabled: false },
projectSettings: { hooksDisabled: true },
});
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /project settings/);
await assertMissing(path.join(fixture.agentDir, 'config.json'));
} finally {
await fixture.close();
}
});
await t.test('project false locks out a lower user disable flag', async () => {
const fixture = await createFixture({
settings: { hooksDisabled: true },
projectSettings: { hooksDisabled: false },
});
try {
assert.equal((await fixture.install()).code, 0);
} finally {
await fixture.close();
}
});
await t.test('project local settings override their base file', async () => {
const fixture = await createFixture({
projectSettings: { hooksDisabled: false },
projectLocalSettings: { hooksDisabled: true },
});
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /project local settings/);
} finally {
await fixture.close();
}
});
});
test('Droid treats allowManagedHooksOnly as an authoritative user-hook block', async () => {
const { hookBlock } = await import(configModuleUrl);
assert.deepEqual(hookBlock({
policy: {
allowManagedHooksOnlyBy: {
filePath: '/managed/settings.json',
label: 'Factory Droid system-managed settings',
},
preconditions: [],
},
}), {
field: 'allowManagedHooksOnly',
filePath: '/managed/settings.json',
label: 'Factory Droid system-managed settings',
});
});
test('Droid surfaces malformed read-only project policy before any write', async () => {
const fixture = await createFixture({ projectSettings: '{ malformed' });
try {
const before = await readFile(fixture.projectSettingsPath, 'utf-8');
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /project settings/);
assert.equal(await readFile(fixture.projectSettingsPath, 'utf-8'), before);
await assertMissing(path.join(fixture.agentDir, 'config.json'));
await assertMissing(fixture.rootPath);
} finally {
await fixture.close();
}
});
+297
View File
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import {
assertMissing,
assertNoTransactionFiles,
configModuleUrl,
contractModuleUrl,
createFixture,
environment,
installConfig,
installationModuleUrl,
ioModuleUrl,
runNode,
writeConfig,
} from '../test-support/droid-test-helpers.mjs';
const managedFiles = ['guard.js', 'config.json', 'private.key', 'hook.js'];
async function snapshotInstallation(fixture, extraPaths = []) {
const paths = [
...managedFiles.map((name) => path.join(fixture.agentDir, name)),
fixture.rootPath,
...extraPaths,
];
return new Map(await Promise.all(paths.map(async (filePath) => [
filePath,
await readFile(filePath, 'utf-8'),
])));
}
async function assertSnapshot(snapshot) {
for (const [filePath, source] of snapshot) {
assert.equal(await readFile(filePath, 'utf-8'), source, filePath);
}
}
function transactionEnvironment(fixture, overrides = {}) {
return {
...environment(fixture),
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)),
};
}
const readAndRenderSource = `
import { buildGroup } from ${JSON.stringify(contractModuleUrl)};
import {
activeDocument,
additionsForTarget,
installationDocuments,
renderDocument,
} from ${JSON.stringify(configModuleUrl)};
import { readSources } from ${JSON.stringify(ioModuleUrl)};
import {
preflightDroidInstallation,
prepareDroidInstallation,
} from ${JSON.stringify(installationModuleUrl)};
const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG);
const sources = await readSources();
`;
const prepareSource = `${readAndRenderSource}
const paths = await preflightDroidInstallation(config, sources);
const target = activeDocument(sources);
const groups = new Map([
['PreToolUse', buildGroup(paths.guardPath)],
['PostToolUse', buildGroup(paths.auditPath)],
]);
const rendered = installationDocuments(sources).map((document) => renderDocument(
document,
undefined,
additionsForTarget(document, target, groups),
));
const prepared = await prepareDroidInstallation(config, sources, rendered);
`;
test('Droid rolls back runtime and hooks after a late commit failure', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const source = `${prepareSource}
import { rename } from 'node:fs/promises';
import { commitDroidInstallation } from ${JSON.stringify(installationModuleUrl)};
let calls = 0;
await commitDroidInstallation(prepared, async (from, to) => {
calls += 1;
if (calls === 5) throw new Error('injected Droid rename failure');
await rename(from, to);
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
transactionEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }),
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /injected Droid rename failure/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid detects concurrent active hook replacement before runtime commit', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"hooks":{"PreToolUse":[]},"owner":"concurrent"}\n';
const source = `${prepareSource}
import { writeFile } from 'node:fs/promises';
import { commitDroidInstallation } from ${JSON.stringify(installationModuleUrl)};
await writeFile(process.env.ELYDORA_CONCURRENT_PATH, process.env.ELYDORA_CONCURRENT);
await commitDroidInstallation(prepared);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
...transactionEnvironment(fixture, { orgId: 'org-updated' }),
ELYDORA_CONCURRENT_PATH: fixture.rootPath,
ELYDORA_CONCURRENT: concurrent,
},
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /hooks changed during Factory Droid installation/i);
for (const [filePath, original] of before) {
if (filePath !== fixture.rootPath) assert.equal(await readFile(filePath, 'utf-8'), original);
}
assert.equal(await readFile(fixture.rootPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid tracks concurrent creation of an inactive local settings source', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"hooksDisabled":true}\n';
const source = `${prepareSource}
import { writeFile } from 'node:fs/promises';
import { commitDroidInstallation } from ${JSON.stringify(installationModuleUrl)};
await writeFile(process.env.ELYDORA_LOCAL_PATH, process.env.ELYDORA_CONCURRENT);
await commitDroidInstallation(prepared);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
...transactionEnvironment(fixture, { orgId: 'org-updated' }),
ELYDORA_LOCAL_PATH: fixture.localSettingsPath,
ELYDORA_CONCURRENT: concurrent,
},
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /local settings changed during Factory Droid installation/i);
await assertSnapshot(before);
assert.equal(await readFile(fixture.localSettingsPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid tracks concurrent changes to read-only project policy', async () => {
const fixture = await createFixture({ projectSettings: { hooksDisabled: false } });
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"hooksDisabled":true}\n';
const source = `${prepareSource}
import { writeFile } from 'node:fs/promises';
import { commitDroidInstallation } from ${JSON.stringify(installationModuleUrl)};
await writeFile(process.env.ELYDORA_POLICY_PATH, process.env.ELYDORA_CONCURRENT);
await commitDroidInstallation(prepared);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
...transactionEnvironment(fixture, { orgId: 'org-updated' }),
ELYDORA_POLICY_PATH: fixture.projectSettingsPath,
ELYDORA_CONCURRENT: concurrent,
},
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /project settings changed during Factory Droid installation/i);
await assertSnapshot(before);
assert.equal(await readFile(fixture.projectSettingsPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid rejects same-content stale hook snapshots by physical identity', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const source = `${readAndRenderSource}
import { readFile, rm, writeFile } from 'node:fs/promises';
const original = await readFile(sources.root.filePath, 'utf-8');
await rm(sources.root.filePath);
await writeFile(sources.root.filePath, original);
const paths = await preflightDroidInstallation(config, sources);
const target = activeDocument(sources);
const groups = new Map([
['PreToolUse', buildGroup(paths.guardPath)],
['PostToolUse', buildGroup(paths.auditPath)],
]);
const rendered = installationDocuments(sources).map((document) => renderDocument(
document,
undefined,
additionsForTarget(document, target, groups),
));
await prepareDroidInstallation(config, sources, rendered);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
transactionEnvironment(fixture),
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /hooks changed before installation/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Droid preserves orphaned runtime artifacts without a verifiable identity', async () => {
const fixture = await createFixture();
try {
await mkdir(fixture.agentDir, { recursive: true });
await writeFile(fixture.guardScriptPath, 'orphaned guard\n');
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /identity cannot be verified/i);
assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n');
await assertMissing(fixture.rootPath);
} finally {
await fixture.close();
}
});
test('Droid uninstall restores all hook documents after a later removal fails', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const installed = JSON.parse(
(await readFile(fixture.rootPath, 'utf-8')).replace(/^\/\/[^\n]*\n/, ''),
);
await writeConfig(fixture.settingsPath, { hooks: installed.hooks, owner: 'user' });
const before = await snapshotInstallation(fixture, [fixture.settingsPath]);
const source = `
import { renderDocument, sourceDocuments } from ${JSON.stringify(configModuleUrl)};
import { readSources } from ${JSON.stringify(ioModuleUrl)};
import {
commitDroidUninstall,
prepareDroidUninstall,
} from ${JSON.stringify(installationModuleUrl)};
import { rename } from 'node:fs/promises';
const sources = await readSources();
const rendered = sourceDocuments(sources).map((document) => renderDocument(
document,
'agent-1',
new Map(),
));
const prepared = await prepareDroidUninstall(rendered);
let calls = 0;
await commitDroidUninstall(prepared, async (from, to) => {
calls += 1;
if (calls === 2) throw new Error('injected Droid uninstall failure');
await rename(from, to);
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
environment(fixture),
fixture.workspaceDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /injected Droid uninstall failure/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});