diff --git a/sdks/node/README.md b/sdks/node/README.md index 0dd07fc..31408ee 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -52,6 +52,8 @@ npx elydora install \ --kid agent-456-key-v1 ``` +Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. + 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 @@ -68,6 +70,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Agent | Key | |-------|-----| | Claude Code | `claudecode` | +| OpenAI Codex | `codex` | | Cursor | `cursor` | | Gemini CLI | `gemini` | | Kiro CLI | `kirocli` | diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index 25719b1..f8abb58 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -14,6 +14,7 @@ import { kiroidePlugin } from './plugins/kiroide.js'; import { opencodePlugin } from './plugins/opencode.js'; import { copilotPlugin } from './plugins/copilot.js'; import { lettaPlugin } from './plugins/letta.js'; +import { codexPlugin } from './plugins/codex.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); @@ -26,6 +27,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['opencode', opencodePlugin], ['copilot', copilotPlugin], ['letta', lettaPlugin], + ['codex', codexPlugin], ]); // --------------------------------------------------------------------------- diff --git a/sdks/node/src/plugins/codex.ts b/sdks/node/src/plugins/codex.ts new file mode 100644 index 0000000..49d33af --- /dev/null +++ b/sdks/node/src/plugins/codex.ts @@ -0,0 +1,320 @@ +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; + +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); +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function buildHandler(scriptPath: string, statusMessage: string): JsonObject { + return { + type: 'command', + command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, + commandWindows: `${quoteWindows(process.execPath)} ${quoteWindows(scriptPath)}`, + timeout: 10, + statusMessage, + }; +} + +function eventGroups(hooks: JsonObject, event: string): JsonObject[] { + const value = hooks[event]; + if (value === undefined) return []; + if (!Array.isArray(value) || !value.every(isObject)) { + throw new Error(`Codex hooks config field "hooks.${event}" must be an array of objects`); + } + 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 { + 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) => isManagedCommand(command, scriptName, agentId), + ); +} + +function withoutElydora(groups: JsonObject[], agentId?: string): JsonObject[] { + const result: JsonObject[] = []; + for (const group of groups) { + if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) { + throw new Error('Codex hook matcher group must contain a hooks array'); + } + const handlers = group.hooks.filter((handler) => !isElydoraHandler(handler, agentId)); + if (handlers.length > 0) result.push({ ...group, hooks: handlers }); + } + return result; +} + +function findHandler(groups: JsonObject[], statusMessage: string): JsonObject | undefined { + for (const group of groups) { + 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) => candidate.statusMessage === statusMessage + && isElydoraHandler(candidate), + ); + if (isObject(handler)) return handler; + } + return undefined; +} + +function commandReferences(handler: JsonObject, scriptPath: string): boolean { + return [handler.command, handler.commandWindows].some( + (command) => typeof command === 'string' && command.includes(scriptPath), + ); +} + +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; +} + +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.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 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), + }); + } +} + +function getHooks(settings: JsonObject): JsonObject { + if (settings.hooks === undefined) return {}; + if (!isObject(settings.hooks)) throw new Error('Codex hooks config field "hooks" must be an object'); + 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: 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 directory of entries) { + if (!directory.isDirectory()) continue; + const agentDir = path.join(root, directory.name); + 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 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 = [ + ...withoutElydora(eventGroups(hooks, 'PreToolUse')), + { matcher: '*', hooks: [buildHandler(config.guardScriptPath, GUARD_STATUS)] }, + ]; + hooks.PostToolUse = [ + ...withoutElydora(eventGroups(hooks, 'PostToolUse')), + { matcher: '*', hooks: [buildHandler(config.hookScriptPath, AUDIT_STATUS)] }, + ]; + + await writeSettings(configPath, { ...settings, hooks }); + console.log(' Codex: run /hooks to review and trust the Elydora hooks.'); + }, + + async uninstall(agentId?: string): Promise { + const configPath = resolveConfigPath(); + const settings = await readSettings(configPath); + if (!settings) return; + const hooks = getHooks(settings); + hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId); + hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId); + if (isOwnedSettings(settings, hooks)) { + await removeSettings(configPath); + } else { + await writeSettings(configPath, { ...settings, hooks }); + } + }, + + async status(): Promise { + const configPath = resolveConfigPath(); + const settings = await readSettings(configPath); + let guard: JsonObject | undefined; + let audit: JsonObject | undefined; + if (settings) { + const hooks = getHooks(settings); + guard = findHandler(eventGroups(hooks, 'PreToolUse'), GUARD_STATUS); + audit = findHandler(eventGroups(hooks, 'PostToolUse'), AUDIT_STATUS); + } + const hookConfigured = Boolean(guard && audit); + const hookScriptExists = guard && audit ? await runtimeScriptsExist(guard, audit) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath, + }; + }, +}; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index b89cdec..b42866b 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -13,4 +13,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }], ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], + ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], ]); diff --git a/sdks/node/test/codex-plugin.test.mjs b/sdks/node/test/codex-plugin.test.mjs new file mode 100644 index 0000000..60f3d84 --- /dev/null +++ b/sdks/node/test/codex-plugin.test.mjs @@ -0,0 +1,344 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, 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/codex.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; + +function runNode(args, env, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + 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 findHandler(settings, event, statusMessage) { + for (const group of settings.hooks?.[event] ?? []) { + const handler = group.hooks?.find((item) => item.statusMessage === statusMessage); + if (handler) return handler; + } + return undefined; +} + +async function runPlugin(homeDir, method, argument) { + const script = ` + import { codexPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await codexPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', script], + { + HOME: homeDir, + USERPROFILE: homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + ); +} + +async function createFixture({ guardSource, hookSource, existingSettings } = {}) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-')); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const configDir = path.join(homeDir, '.codex'); + await mkdir(agentDir, { recursive: true }); + await mkdir(configDir, { recursive: true }); + + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + 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: 'codex', + })); + + const configPath = path.join(configDir, 'hooks.json'); + if (existingSettings !== undefined) { + await writeFile(configPath, typeof existingSettings === 'string' + ? existingSettings + : JSON.stringify(existingSettings, null, 2)); + } + + const installResult = await runPlugin(homeDir, 'install', { + agentName: 'codex', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath, + hookScriptPath, + }); + + return { + agentDir, + configPath, + guardScriptPath, + homeDir, + hookScriptPath, + installResult, + async close() { + await rm(homeDir, { recursive: true, force: true }); + }, + }; +} + +test('Codex is registered as a native hook integration', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('codex'), { + name: 'OpenAI Codex', + configDir: '~/.codex', + configFile: 'hooks.json', + }); +}); + +test('Codex install preserves existing hooks and is idempotent', async () => { + const existing = { + description: 'Workspace hooks', + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'existing-command' }] }], + }, + }; + const fixture = await createFixture({ existingSettings: existing }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.match(fixture.installResult.stdout, /run \/hooks to review and trust/i); + const secondInstall = await runPlugin(fixture.homeDir, 'install', { + agentName: 'codex', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(secondInstall.code, 0, secondInstall.stderr); + + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(settings.description, 'Workspace hooks'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, 'existing-command'); + assert.equal(settings.hooks.PreToolUse.length, 1); + assert.equal(settings.hooks.PostToolUse.length, 1); + assert.equal(settings.hooks.PreToolUse[0].matcher, '*'); + assert.equal(settings.hooks.PostToolUse[0].matcher, '*'); + } finally { + await fixture.close(); + } +}); + +test('Codex hook commands enforce freezes and forward the official event payload', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-codex-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 settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = findHandler(settings, 'PreToolUse', 'Checking Elydora agent state'); + const audit = findHandler(settings, 'PostToolUse', 'Recording Elydora tool use'); + assert(guard); + assert(audit); + + const commandKey = process.platform === 'win32' ? 'commandWindows' : 'command'; + const guardResult = await runCommand(guard[commandKey], JSON.stringify({ + hook_event_name: 'PreToolUse', + session_id: 'session-1', + tool_name: 'Bash', + tool_input: { command: 'echo test' }, + })); + assert.equal(guardResult.code, 2); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + + const payload = { + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_name: 'Bash', + tool_use_id: 'call-1', + tool_input: { command: 'echo test' }, + tool_response: { output: 'test' }, + }; + const auditResult = await runCommand(audit[commandKey], JSON.stringify(payload)); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), payload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Codex status requires both runtime scripts and uninstall preserves other hooks', async () => { + const fixture = await createFixture({ existingSettings: { + hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'existing-command' }] }] }, + } }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const status = await runPlugin(fixture.homeDir, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + + await rm(fixture.guardScriptPath); + const degradedStatus = await runPlugin(fixture.homeDir, 'status', null); + assert.equal(JSON.parse(degradedStatus.stdout).installed, false); + + const uninstall = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(settings.hooks.PreToolUse.length, 1); + assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, 'existing-command'); + assert.deepEqual(settings.hooks.PostToolUse, []); + } finally { + await fixture.close(); + } +}); + +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 { + assert.equal(fixture.installResult.code, 1); + assert.match(fixture.installResult.stderr, /parse Codex hooks config/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); + } finally { + await fixture.close(); + } +}); diff --git a/test/integration-catalog.test.mjs b/test/integration-catalog.test.mjs index 45118ce..1042a28 100644 --- a/test/integration-catalog.test.mjs +++ b/test/integration-catalog.test.mjs @@ -24,6 +24,11 @@ const expectedProviderIds = [ const expectedCustomIntegrationIds = ['enterprise', 'gui', 'other', 'sdk']; const expectedAdapterKeys = ['go', 'node', 'python']; +const adapterSourcePaths = { + go: (id) => `sdks/go/cmd/elydora/plugins/${id}.go`, + node: (id) => `sdks/node/src/plugins/${id}.ts`, + python: (id) => `sdks/python/elydora/plugins/${id}.py`, +}; const requiredProviderFields = [ 'id', 'name', @@ -43,6 +48,16 @@ async function readJson(path) { return JSON.parse(await readFile(new URL(path, root), 'utf8')); } +async function sourceExists(path) { + try { + await readFile(new URL(path, root)); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + function deliveryState(adapters) { const availableCount = Object.values(adapters).filter(Boolean).length; if (availableCount === expectedAdapterKeys.length) return 'available'; @@ -142,6 +157,23 @@ test('schema freezes the provider contract and supported enums', async () => { ]); }); +test('adapter delivery claims resolve to source files in every SDK mirror', async () => { + const catalog = await readJson('integrations/catalog.json'); + + for (const provider of catalog.providers) { + for (const [language, delivered] of Object.entries(provider.adapters)) { + const resolveSourcePath = adapterSourcePaths[language]; + assert.ok(resolveSourcePath, `unknown adapter language: ${language}`); + const sourcePath = resolveSourcePath(provider.id); + assert.equal( + await sourceExists(sourcePath), + delivered, + `${provider.id} ${language} delivery state disagrees with ${sourcePath}`, + ); + } + } +}); + test('high-drift providers retain their verified hook contracts', async () => { const catalog = await readJson('integrations/catalog.json'); const providers = new Map(catalog.providers.map((provider) => [provider.id, provider]));