diff --git a/src/plugins/codex.ts b/src/plugins/codex.ts index 04348c3..49d33af 100644 --- a/src/plugins/codex.ts +++ b/src/plugins/codex.ts @@ -1,12 +1,17 @@ +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 = 'codex'; +const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement'; const GUARD_STATUS = 'Checking Elydora agent state'; const AUDIT_STATUS = 'Recording Elydora tool use'; +const GUARD_SCRIPT = 'guard.js'; +const AUDIT_SCRIPT = 'hook.js'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; type JsonObject = Record; @@ -15,6 +20,18 @@ function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isObject(error) && error.code === code; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + function resolveConfigPath(): string { const configDir = entry.configDir.replace(/^~/, os.homedir()); return path.join(configDir, entry.configFile); @@ -47,11 +64,22 @@ function eventGroups(hooks: JsonObject, event: string): JsonObject[] { return value; } +function isManagedCommand(command: unknown, scriptName: string, agentId?: string): boolean { + if (typeof command !== 'string') return false; + const normalized = command.toLowerCase(); + if (!normalized.includes('.elydora') || !normalized.includes(scriptName)) return false; + return !agentId || command.includes(agentId); +} + function isElydoraHandler(handler: JsonObject, agentId?: string): boolean { - if (handler.statusMessage !== GUARD_STATUS && handler.statusMessage !== AUDIT_STATUS) return false; - if (!agentId) return true; + const scriptName = handler.statusMessage === GUARD_STATUS + ? GUARD_SCRIPT + : handler.statusMessage === AUDIT_STATUS + ? AUDIT_SCRIPT + : undefined; + if (!scriptName) return false; return [handler.command, handler.commandWindows].some( - (command) => typeof command === 'string' && command.includes(agentId), + (command) => isManagedCommand(command, scriptName, agentId), ); } @@ -69,9 +97,12 @@ function withoutElydora(groups: JsonObject[], agentId?: string): JsonObject[] { function findHandler(groups: JsonObject[], statusMessage: string): JsonObject | undefined { for (const group of groups) { - if (!Array.isArray(group.hooks)) continue; + if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) { + throw new Error('Codex hook matcher group must contain a hooks array'); + } const handler = group.hooks.find( - (candidate) => isObject(candidate) && candidate.statusMessage === statusMessage, + (candidate) => candidate.statusMessage === statusMessage + && isElydoraHandler(candidate), ); if (isObject(handler)) return handler; } @@ -84,37 +115,82 @@ function commandReferences(handler: JsonObject, scriptPath: string): boolean { ); } -async function readSettings(configPath: string): Promise { +async function readJsonObject(filePath: string, label: string): Promise { let raw: string; try { - raw = await fsp.readFile(configPath, 'utf-8'); + raw = await fsp.readFile(filePath, 'utf-8'); } catch (error) { - if (isObject(error) && error.code === 'ENOENT') return undefined; - throw new Error(`Read Codex hooks config: ${error instanceof Error ? error.message : String(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 Codex hooks config: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); } - if (!isObject(value)) throw new Error('Codex hooks config must contain a JSON object'); + if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); return value; } -async function writeSettings(configPath: string, settings: JsonObject): Promise { - await fsp.mkdir(path.dirname(configPath), { recursive: true }); - const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; +async function readSettings(configPath: string): Promise { + return readJsonObject(configPath, 'Codex hooks config'); +} + +async function failWrite( + handle: FileHandle | undefined, + tempPath: string, + label: string, + cause: unknown, +): Promise { + const errors = [asError(cause)]; + if (handle) { + try { + await handle.close(); + } catch (error) { + errors.push(asError(error)); + } + } try { - await fsp.writeFile(tempPath, JSON.stringify(settings, null, 2) + '\n', { - encoding: 'utf-8', - mode: 0o600, - }); + await fsp.unlink(tempPath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); + } + const message = `Write ${label}: ${errorMessage(cause)}`; + if (errors.length > 1) throw new AggregateError(errors, message); + throw new Error(message, { cause: errors[0] }); +} + +async function writeSettings(configPath: string, settings: 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(settings, null, 2) + '\n', 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; await fsp.rename(tempPath, configPath); } catch (error) { - await fsp.unlink(tempPath).catch(() => undefined); - throw new Error(`Write Codex hooks config: ${error instanceof Error ? error.message : String(error)}`); + await failWrite(handle, tempPath, `Codex hooks config at ${configPath}`, error); + } +} + +async function removeSettings(configPath: string): Promise { + try { + await fsp.unlink(configPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return; + throw new Error(`Remove Codex hooks config at ${configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); } } @@ -124,37 +200,73 @@ function getHooks(settings: JsonObject): JsonObject { return { ...settings.hooks }; } +function isOwnedSettings(settings: JsonObject, hooks: JsonObject): boolean { + const settingsKeys = new Set(['description', 'hooks']); + const hookKeys = new Set(['PreToolUse', 'PostToolUse']); + return Object.keys(settings).every((key) => settingsKeys.has(key)) + && settings.description === OWNED_DESCRIPTION + && Object.keys(hooks).every((key) => hookKeys.has(key)) + && Object.values(hooks).every((value) => Array.isArray(value) && value.length === 0); +} + +async function regularFileExists(filePath: string, label: string): Promise { + try { + const metadata = await fsp.stat(filePath); + return metadata.isFile(); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) 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 runtimeScriptsExist(guard: JsonObject, audit: JsonObject): Promise { const root = path.join(os.homedir(), '.elydora'); - let entries; + let entries: Array<{ isDirectory(): boolean; name: string }>; try { entries = await fsp.readdir(root, { withFileTypes: true }); - } catch { - return false; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { + cause: asError(error), + }); } for (const directory of entries) { if (!directory.isDirectory()) continue; const agentDir = path.join(root, directory.name); - try { - const config = JSON.parse(await fsp.readFile(path.join(agentDir, 'config.json'), 'utf-8')); - if (config.agent_name !== AGENT_KEY) continue; - const guardPath = path.join(agentDir, 'guard.js'); - const hookPath = path.join(agentDir, 'hook.js'); - if (!commandReferences(guard, guardPath) || !commandReferences(audit, hookPath)) continue; - await Promise.all([fsp.access(guardPath), fsp.access(hookPath)]); - return true; - } catch { - continue; - } + const guardPath = path.join(agentDir, GUARD_SCRIPT); + const hookPath = path.join(agentDir, AUDIT_SCRIPT); + if (!commandReferences(guard, guardPath) || !commandReferences(audit, hookPath)) continue; + + const configPath = path.join(agentDir, 'config.json'); + const config = await readJsonObject(configPath, 'Elydora runtime config'); + if (!config || config.agent_name !== AGENT_KEY) continue; + const [guardExists, hookExists] = await Promise.all([ + regularFileExists(guardPath, 'Elydora guard runtime'), + regularFileExists(hookPath, 'Elydora audit runtime'), + ]); + return guardExists && hookExists; } return false; } export const codexPlugin: AgentPlugin = { async install(config: InstallConfig): Promise { + if (!config.agentId) throw new Error('agentId is required'); const configPath = resolveConfigPath(); - const settings = (await readSettings(configPath)) ?? {}; + const existingSettings = await readSettings(configPath); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + const settings = existingSettings ?? { description: OWNED_DESCRIPTION }; const hooks = getHooks(settings); hooks.PreToolUse = [ @@ -177,7 +289,11 @@ export const codexPlugin: AgentPlugin = { const hooks = getHooks(settings); hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId); hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId); - await writeSettings(configPath, { ...settings, hooks }); + if (isOwnedSettings(settings, hooks)) { + await removeSettings(configPath); + } else { + await writeSettings(configPath, { ...settings, hooks }); + } }, async status(): Promise { diff --git a/test/codex-plugin.test.mjs b/test/codex-plugin.test.mjs index f932af8..60f3d84 100644 --- a/test/codex-plugin.test.mjs +++ b/test/codex-plugin.test.mjs @@ -224,6 +224,114 @@ test('Codex status requires both runtime scripts and uninstall preserves other h } }); +test('Codex install preserves user handlers that reuse Elydora status text', async () => { + const userGuard = { + type: 'command', + command: 'user-guard', + statusMessage: 'Checking Elydora agent state', + }; + const userAudit = { + type: 'command', + command: 'user-audit', + statusMessage: 'Recording Elydora tool use', + }; + const fixture = await createFixture({ existingSettings: { + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [userGuard] }], + PostToolUse: [{ matcher: 'Bash', hooks: [userAudit] }], + }, + } }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.deepEqual(settings.hooks.PreToolUse[0].hooks[0], userGuard); + assert.deepEqual(settings.hooks.PostToolUse[0].hooks[0], userAudit); + assert.equal(settings.hooks.PreToolUse.length, 2); + assert.equal(settings.hooks.PostToolUse.length, 2); + } finally { + await fixture.close(); + } +}); + +test('Codex install rejects missing runtimes before writing hooks config', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-missing-runtime-')); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const configPath = path.join(homeDir, '.codex', 'hooks.json'); + try { + const install = await runPlugin(homeDir, 'install', { + agentName: 'codex', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath: path.join(agentDir, 'guard.js'), + hookScriptPath: path.join(agentDir, 'hook.js'), + }); + assert.equal(install.code, 1); + assert.match(install.stderr, /runtime is missing/i); + await assert.rejects(readFile(configPath), { code: 'ENOENT' }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Codex 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.homeDir, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Codex status surfaces malformed hook matcher groups', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + settings.hooks.PreToolUse[0].hooks = null; + await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); + const status = await runPlugin(fixture.homeDir, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /matcher group must contain a hooks array/i); + } finally { + await fixture.close(); + } +}); + +test('Codex uninstall removes a hooks config owned entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const uninstall = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Codex install preserves invalid contract shapes before writing', async () => { + for (const existingSettings of [ + { hooks: null }, + { hooks: { PreToolUse: null } }, + { hooks: { PreToolUse: [{ hooks: null }] } }, + ]) { + const fixture = await createFixture({ existingSettings }); + try { + assert.equal(fixture.installResult.code, 1); + assert.deepEqual( + JSON.parse(await readFile(fixture.configPath, 'utf-8')), + existingSettings, + ); + } finally { + await fixture.close(); + } + } +}); + test('Codex install preserves malformed config for recovery', async () => { const fixture = await createFixture({ existingSettings: '{ malformed' }); try {