diff --git a/integrations/catalog.json b/integrations/catalog.json index e6fd17b..b78d7d3 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -258,11 +258,11 @@ "failure_mode": "fail_open" }, "adapters": { - "node": false, + "node": true, "python": false, "go": false }, - "delivery_state": "planned", + "delivery_state": "partial", "source_url": "https://docs.x.ai/build/features/hooks" }, { diff --git a/sdks/node/README.md b/sdks/node/README.md index a04ff10..c047b5e 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -56,6 +56,8 @@ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex aft Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them. +Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. + Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. ### Commands @@ -74,6 +76,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Claude Code | `claudecode` | | OpenAI Codex | `codex` | | Kimi Code | `kimi` | +| Grok Build | `grok` | | Cursor | `cursor` | | Gemini CLI | `gemini` | | Kiro CLI | `kirocli` | diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index 502253a..ddb0a10 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -16,6 +16,7 @@ import { copilotPlugin } from './plugins/copilot.js'; import { lettaPlugin } from './plugins/letta.js'; import { codexPlugin } from './plugins/codex.js'; import { kimiPlugin } from './plugins/kimi.js'; +import { grokPlugin } from './plugins/grok.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); @@ -30,6 +31,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['letta', lettaPlugin], ['codex', codexPlugin], ['kimi', kimiPlugin], + ['grok', grokPlugin], ]); // --------------------------------------------------------------------------- diff --git a/sdks/node/src/plugins/grok.ts b/sdks/node/src/plugins/grok.ts new file mode 100644 index 0000000..28e39a1 --- /dev/null +++ b/sdks/node/src/plugins/grok.ts @@ -0,0 +1,453 @@ +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 type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { SUPPORTED_AGENTS } from './registry.js'; + +const AGENT_KEY = 'grok'; +const GUARD_SCRIPT = 'guard.js'; +const AUDIT_SCRIPT = 'hook.js'; +const HOOK_TIMEOUT_SECONDS = 10; +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +type JsonObject = Record; + +interface GrokHandler extends JsonObject { + readonly type: 'command' | 'http'; + readonly command?: string; + readonly url?: string; + readonly timeout?: number; +} + +interface GrokGroup extends JsonObject { + readonly matcher?: string; + readonly hooks: GrokHandler[]; +} + +type GrokHooks = Record; + +interface GrokDocument { + readonly exists: boolean; + readonly configPath: string; + readonly root: JsonObject; + readonly hooks: GrokHooks; +} + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwn(value: JsonObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +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 resolveConfigPath(): string { + const grokHome = process.env.GROK_HOME || path.join(os.homedir(), '.grok'); + return path.join(grokHome, 'hooks', entry.configFile); +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function buildCommand(scriptPath: string): string { + const quote = process.platform === 'win32' ? quoteWindows : quotePosix; + return `${quote(process.execPath)} ${quote(scriptPath)}`; +} + +function buildHandler(scriptPath: string): GrokHandler { + return { + type: 'command', + command: buildCommand(scriptPath), + timeout: HOOK_TIMEOUT_SECONDS, + }; +} + +function readWindowsArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== '"') return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] === '\\' && command[index + 1] === '"') { + value += '"'; + index += 1; + continue; + } + if (command[index] === '"') return { value, next: index + 1 }; + value += command[index]; + } + return undefined; +} + +const POSIX_APOSTROPHE = `'"'"'`; + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(POSIX_APOSTROPHE, index)) { + value += "'"; + index += POSIX_APOSTROPHE.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parseGeneratedCommand(command: string): readonly [string, string] | undefined { + const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument; + const executable = readArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readArgument(command, executable.next + 1); + if (!script || script.next !== command.length || !executable.value || !script.value) return undefined; + return [executable.value, script.value]; +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function managedAgentId(handler: GrokHandler, scriptName: string): string | undefined { + if (handler.type !== 'command' + || handler.timeout !== HOOK_TIMEOUT_SECONDS + || typeof handler.command !== 'string') return undefined; + const parsed = parseGeneratedCommand(handler.command); + if (!parsed) return undefined; + const scriptPath = parsed[1]; + if (path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + const runtimeRoot = path.join(os.homedir(), '.elydora'); + if (!samePath(path.dirname(agentDirectory), runtimeRoot)) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function validateHandler(value: unknown, event: string, groupIndex: number, handlerIndex: number): GrokHandler { + const label = `Grok hooks config handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.type !== 'command' && value.type !== 'http') { + throw new Error(`${label} has unsupported type "${String(value.type)}"`); + } + if (value.type === 'command' && (typeof value.command !== 'string' || value.command.length === 0)) { + throw new Error(`${label} requires a non-empty command`); + } + if (value.type === 'http' && (typeof value.url !== 'string' || value.url.length === 0)) { + throw new Error(`${label} requires a non-empty url`); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + throw new Error(`${label} timeout must be a positive finite number`); + } + return value as GrokHandler; +} + +function validateGroup(value: unknown, event: string, groupIndex: number): GrokGroup { + const label = `Grok hooks config group hooks.${event}[${groupIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.matcher !== undefined && typeof value.matcher !== 'string') { + throw new Error(`${label} matcher must be a string`); + } + if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`); + const handlers = value.hooks.map( + (handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex), + ); + return { ...value, hooks: handlers } as GrokGroup; +} + +function readHooks(root: JsonObject): GrokHooks { + if (root.hooks === undefined) return {}; + if (!isObject(root.hooks)) throw new Error('Grok hooks config field "hooks" must be an object'); + const hooks: GrokHooks = {}; + for (const [event, value] of Object.entries(root.hooks)) { + if (!Array.isArray(value)) { + throw new Error(`Grok hooks config field "hooks.${event}" must be an array`); + } + hooks[event] = value.map((group, index) => validateGroup(group, event, index)); + } + return hooks; +} + +async function readConfig(): Promise { + const configPath = resolveConfigPath(); + let raw: string; + try { + raw = await fsp.readFile(configPath, 'utf-8'); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return { exists: false, configPath, root: {}, hooks: {} }; + throw new Error(`Read Grok hooks config at ${configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + let root: unknown; + try { + root = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse Grok hooks config at ${configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(root)) throw new Error(`Grok hooks config at ${configPath} must contain a JSON object`); + return { exists: true, configPath, root, hooks: readHooks(root) }; +} + +function removeManaged( + groups: GrokGroup[], + scriptName: string, + agentId?: string, +): { readonly groups: GrokGroup[]; readonly changed: boolean } { + let changed = false; + const result: GrokGroup[] = []; + for (const group of groups) { + if (hasOwn(group, 'matcher')) { + result.push(group); + continue; + } + const handlers = group.hooks.filter((handler) => { + const managedId = managedAgentId(handler, scriptName); + const remove = managedId !== undefined + && (agentId === undefined || sameAgentId(managedId, agentId)); + if (remove) changed = true; + return !remove; + }); + if (handlers.length > 0) result.push(handlers.length === group.hooks.length ? group : { ...group, hooks: handlers }); + } + return { groups: result, changed }; +} + +function removeManagedHooks(hooks: GrokHooks, agentId?: string): { + readonly hooks: GrokHooks; + readonly changed: boolean; +} { + const next = { ...hooks }; + let changed = false; + for (const [event, scriptName] of [ + ['PreToolUse', GUARD_SCRIPT], + ['PostToolUse', AUDIT_SCRIPT], + ] as const) { + const result = removeManaged(next[event] ?? [], scriptName, agentId); + if (!result.changed) continue; + changed = true; + if (result.groups.length > 0) next[event] = result.groups; + else delete next[event]; + } + return { hooks: next, changed }; +} + +async function failWrite( + handle: FileHandle | undefined, + tempPath: string, + configPath: string, + cause: unknown, +): Promise { + const errors = [asError(cause)]; + if (handle) { + try { + await handle.close(); + } catch (error) { + errors.push(asError(error)); + } + } + try { + await fsp.unlink(tempPath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); + } + const message = `Write Grok hooks config at ${configPath}: ${errorMessage(cause)}`; + if (errors.length > 1) throw new AggregateError(errors, message); + throw new Error(message, { cause: errors[0] }); +} + +async function writeConfig(configPath: string, root: JsonObject): Promise { + const directory = path.dirname(configPath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`); + let handle: FileHandle | undefined; + try { + handle = await fsp.open(tempPath, 'wx', 0o600); + await handle.writeFile(JSON.stringify(root, null, 2) + '\n', 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await fsp.rename(tempPath, configPath); + } catch (error) { + await failWrite(handle, tempPath, configPath, error); + } +} + +async function removeConfig(configPath: string): Promise { + try { + await fsp.unlink(configPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return; + throw new Error(`Remove Grok hooks config at ${configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +async function regularFileExists(filePath: string, label: string): Promise { + try { + return (await fsp.stat(filePath)).isFile(); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); + } +} + +async function requireRuntime(filePath: string, label: string): Promise { + if (!filePath) throw new Error(`${label} path is required`); + if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +} + +async function readJsonObject(filePath: string, label: string): Promise { + let raw: string; + try { + raw = 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) }); + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); + return value; +} + +function managedIds(groups: GrokGroup[], scriptName: string): Set { + const ids = new Set(); + for (const group of groups) { + if (hasOwn(group, 'matcher')) continue; + for (const handler of group.hooks) { + const agentId = managedAgentId(handler, scriptName); + if (agentId) ids.add(agentId); + } + } + return ids; +} + +function runtimeContracts(hooks: GrokHooks): RuntimeContract[] { + const guards = managedIds(hooks.PreToolUse ?? [], GUARD_SCRIPT); + const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter((agentId) => audits.has(agentId)) + .map((agentId) => ({ + agentId, + guardPath: path.join(root, agentId, GUARD_SCRIPT), + auditPath: path.join(root, agentId, AUDIT_SCRIPT), + })); +} + +async function runtimeScriptsExist(contracts: RuntimeContract[]): Promise { + const root = path.join(os.homedir(), '.elydora'); + let entries: Array<{ isDirectory(): boolean; name: string }>; + try { + entries = await fsp.readdir(root, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + for (const contract of contracts) { + if (!entries.some((item) => item.isDirectory() && sameAgentId(item.name, contract.agentId))) continue; + const runtimeConfigPath = path.join(root, contract.agentId, 'config.json'); + const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config'); + if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; + const [guardExists, auditExists] = await Promise.all([ + regularFileExists(contract.guardPath, 'Elydora guard runtime'), + regularFileExists(contract.auditPath, 'Elydora audit runtime'), + ]); + if (guardExists && auditExists) return true; + } + return false; +} + +export const grokPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + if (!config.agentId) throw new Error('agentId is required'); + const document = await readConfig(); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + const cleaned = removeManagedHooks(document.hooks).hooks; + const hooks: GrokHooks = { + ...cleaned, + PreToolUse: [...(cleaned.PreToolUse ?? []), { hooks: [buildHandler(config.guardScriptPath)] }], + PostToolUse: [...(cleaned.PostToolUse ?? []), { hooks: [buildHandler(config.hookScriptPath)] }], + }; + await writeConfig(document.configPath, { ...document.root, hooks }); + console.log(' Grok Build: global PreToolUse and PostToolUse hooks installed.'); + }, + + async uninstall(agentId?: string): Promise { + const document = await readConfig(); + if (!document.exists) return; + const result = removeManagedHooks(document.hooks, agentId); + if (!result.changed) return; + const root = { ...document.root }; + if (Object.keys(result.hooks).length > 0) root.hooks = result.hooks; + else delete root.hooks; + if (Object.keys(root).length === 0) await removeConfig(document.configPath); + else await writeConfig(document.configPath, root); + }, + + async status(): Promise { + const document = await readConfig(); + const contracts = runtimeContracts(document.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: document.configPath, + }; + }, +}; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index e969362..893995b 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -15,4 +15,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], ['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }], + ['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }], ]); diff --git a/sdks/node/test/grok-plugin.test.mjs b/sdks/node/test/grok-plugin.test.mjs new file mode 100644 index 0000000..3171ccd --- /dev/null +++ b/sdks/node/test/grok-plugin.test.mjs @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +const cliPath = path.resolve('dist/cli.js'); + +function runNode(args, env, input = '', unset = [], cwd) { + return new Promise((resolve, reject) => { + const childEnv = { ...process.env, ...env }; + for (const key of unset) delete childEnv[key]; + const child = spawn(process.execPath, args, { + cwd, + env: childEnv, + 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 quotePosix(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value) { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function buildCommand(scriptPath) { + const quote = process.platform === 'win32' ? quoteWindows : quotePosix; + return `${quote(process.execPath)} ${quote(scriptPath)}`; +} + +async function runPlugin(fixture, method, argument) { + const script = ` + import { grokPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await grokPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }; + if (fixture.explicitGrokHome) env.GROK_HOME = fixture.grokHome; + return runNode( + ['--input-type=module', '--eval', script], + env, + '', + fixture.explicitGrokHome ? [] : ['GROK_HOME'], + fixture.homeDir, + ); +} + +async function createFixture({ + config, + explicitGrokHome = true, + guardSource, + hookSource, +} = {}) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-')); + const grokHome = explicitGrokHome ? path.join(homeDir, 'custom-grok') : path.join(homeDir, '.grok'); + const configPath = path.join(grokHome, 'hooks', 'elydora-audit.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(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: 'grok', + })); + if (config !== undefined) { + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, typeof config === 'string' ? config : JSON.stringify(config, null, 2)); + } + const fixture = { + agentDir, + configPath, + explicitGrokHome, + grokHome, + guardScriptPath, + homeDir, + hookScriptPath, + async close() { + await rm(homeDir, { recursive: true, force: true }); + }, + }; + fixture.installResult = await runPlugin(fixture, 'install', { + agentName: 'grok', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath, + hookScriptPath, + }); + return fixture; +} + +function managedHandler(config, event, scriptName) { + for (const group of config.hooks?.[event] ?? []) { + if (Object.hasOwn(group, 'matcher')) continue; + const handler = group.hooks.find((candidate) => candidate.command?.includes(scriptName)); + if (handler) return handler; + } + return undefined; +} + +function assertStrictHandler(handler) { + assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']); + assert.equal(handler.type, 'command'); + assert.equal(handler.timeout, 10); +} + +test('Grok Build is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('grok'), { + name: 'Grok Build', + configDir: '~/.grok/hooks', + configFile: 'elydora-audit.json', + }); + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-cli-')); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: homeDir, + USERPROFILE: homeDir, + GROK_HOME: path.join(homeDir, 'custom-grok'), + }, '', [], homeDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Grok Build \(grok\)/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Grok install preserves native user config and is idempotent', async () => { + const existing = { + schemaVersion: 1, + hooks: { + SessionStart: [{ + matcher: 'startup', + hooks: [{ type: 'http', url: 'https://example.test/hook', timeout: 5, headers: { x: 'keep' } }], + label: 'keep group metadata', + }], + PreToolUse: [{ + matcher: 'Bash', + hooks: [{ type: 'command', command: 'existing-command', timeout: 5 }], + }], + }, + }; + const fixture = await createFixture({ config: existing }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.match(fixture.installResult.stdout, /global PreToolUse and PostToolUse hooks installed/i); + const second = await runPlugin(fixture, 'install', { + agentName: 'grok', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(second.code, 0, second.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(config.schemaVersion, 1); + assert.deepEqual(config.hooks.SessionStart, existing.hooks.SessionStart); + assert.deepEqual(config.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); + assert.equal(config.hooks.PreToolUse.length, 2); + assert.equal(config.hooks.PostToolUse.length, 1); + assert.deepEqual(Object.keys(config.hooks.PreToolUse[1]), ['hooks']); + assertStrictHandler(managedHandler(config, 'PreToolUse', 'guard.js')); + assertStrictHandler(managedHandler(config, 'PostToolUse', 'hook.js')); + await assert.rejects(readFile(path.join(fixture.homeDir, '.grok', 'hooks', 'elydora-audit.json')), { + code: 'ENOENT', + }); + await assert.rejects(readFile(path.join(fixture.homeDir, '.claude', 'settings.json')), { code: 'ENOENT' }); + await assert.rejects(readFile(path.join(fixture.homeDir, '.cursor', 'hooks.json')), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Grok treats an empty home override as the official default', async () => { + const fixture = await createFixture({ explicitGrokHome: false }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + fixture.explicitGrokHome = true; + fixture.grokHome = ''; + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } +}); + +test('Grok commands block freezes and forward the official payload byte-for-byte', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-grok-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 config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', 'guard.js'); + const audit = managedHandler(config, 'PostToolUse', 'hook.js'); + const prePayload = JSON.stringify({ + hookEventName: 'PreToolUse', + sessionId: 'session-1', + cwd: fixture.homeDir, + workspaceRoot: fixture.homeDir, + toolName: 'Bash', + toolInput: { command: 'echo test' }, + }); + const guardResult = await runCommand(guard.command, prePayload); + assert.equal(guardResult.code, 2); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + const postPayload = JSON.stringify({ + hookEventName: 'PostToolUse', + sessionId: 'session-1', + cwd: fixture.homeDir, + workspaceRoot: fixture.homeDir, + toolName: 'Bash', + toolInput: { command: 'echo test' }, + toolResult: { output: 'test' }, + }); + const auditResult = await runCommand(audit.command, postPayload); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Grok status requires a complete hook pair and both runtime files', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + let status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + delete config.hooks.PostToolUse; + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).hookConfigured, false); + await runPlugin(fixture, 'install', { + agentName: 'grok', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + await rm(fixture.guardScriptPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + } finally { + await fixture.close(); + } +}); + +test('Grok uninstall removes exact ownership and preserves mixed user handlers', async () => { + const fixture = await createFixture({ config: { owner: 'user', hooks: { Notification: [] } } }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + config.hooks.PreToolUse.at(-1).hooks.push({ type: 'command', command: 'user-command', timeout: 10 }); + config.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: buildCommand(path.join(fixture.agentDir, 'guard.js.backup')), + timeout: 10, + }] }); + config.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: buildCommand(path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')), + timeout: 10, + }] }); + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; + const result = await runPlugin(fixture, 'uninstall', uninstallId); + assert.equal(result.code, 0, result.stderr); + const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(remaining.owner, 'user'); + assert.equal(remaining.hooks.PreToolUse.length, 3); + assert.equal(remaining.hooks.PreToolUse[0].hooks[0].command, 'user-command'); + assert(remaining.hooks.PreToolUse.some((group) => group.hooks[0].command.includes('guard.js.backup'))); + assert(remaining.hooks.PreToolUse.some((group) => group.hooks[0].command.includes('agent-10'))); + assert.equal(remaining.hooks.PostToolUse, undefined); + assert.deepEqual(remaining.hooks.Notification, []); + } finally { + await fixture.close(); + } +}); + +test('Grok install replaces stale Elydora handlers for every agent', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + for (const [event, scriptName] of [['PreToolUse', 'guard.js'], ['PostToolUse', 'hook.js']]) { + config.hooks[event].push({ hooks: [{ + type: 'command', + command: buildCommand(path.join(fixture.homeDir, '.elydora', 'agent-old', scriptName)), + timeout: 10, + }] }); + } + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + const result = await runPlugin(fixture, 'install', { + agentName: 'grok', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(result.code, 0, result.stderr); + const current = await readFile(fixture.configPath, 'utf-8'); + assert.doesNotMatch(current, /agent-old/); + assert.equal(JSON.parse(current).hooks.PreToolUse.length, 1); + assert.equal(JSON.parse(current).hooks.PostToolUse.length, 1); + } finally { + await fixture.close(); + } +}); + +test('Grok uninstall preserves an untouched empty native event', async () => { + const fixture = await createFixture({ config: { owner: 'user' } }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + config.hooks.PreToolUse = []; + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.deepEqual(remaining.hooks.PreToolUse, []); + assert.equal(remaining.hooks.PostToolUse, undefined); + } finally { + await fixture.close(); + } +}); + +test('Grok uninstall removes a config owned entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + 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('Grok preserves malformed JSON for recovery', async () => { + const fixture = await createFixture({ config: '{ malformed' }); + try { + assert.equal(fixture.installResult.code, 1); + assert.match(fixture.installResult.stderr, /parse Grok hooks config/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); + } finally { + await fixture.close(); + } +}); + +test('Grok rejects invalid native hook shapes before writing', async () => { + const cases = [ + { hooks: null }, + { hooks: { PreToolUse: null } }, + { hooks: { PreToolUse: [null] } }, + { hooks: { PreToolUse: [{ matcher: 1, hooks: [] }] } }, + { hooks: { PreToolUse: [{ hooks: null }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '' }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'file', command: 'x' }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'http', url: '' }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }, + ]; + for (const existing of cases) { + const fixture = await createFixture({ config: existing }); + try { + assert.equal(fixture.installResult.code, 1); + assert.deepEqual(JSON.parse(await readFile(fixture.configPath, 'utf-8')), existing); + } finally { + await fixture.close(); + } + } +}); + +test('Grok install rejects missing runtimes before creating config', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-missing-runtime-')); + const fixture = { + explicitGrokHome: true, + grokHome: path.join(homeDir, 'custom-grok'), + homeDir, + }; + const configPath = path.join(fixture.grokHome, 'hooks', 'elydora-audit.json'); + try { + const result = await runPlugin(fixture, 'install', { + agentName: 'grok', + agentId: 'agent-1', + guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'), + hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects(readFile(configPath), { code: 'ENOENT' }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Grok status surfaces malformed referenced runtime metadata', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Grok atomic writes leave no temporary files', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.equal((await readdir(path.dirname(fixture.configPath))).some((name) => name.endsWith('.tmp')), false); + } finally { + await fixture.close(); + } +});