fix: harden Codex hook lifecycle
This commit is contained in:
1 parent
2214c00b94
commit
eb4fc69185
2 files changed
+258
-34
No files matched your search
+150
-34
@@ -1,12 +1,17 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
|
import type { FileHandle } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
import { SUPPORTED_AGENTS } from './registry.js';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const AGENT_KEY = 'codex';
|
const AGENT_KEY = 'codex';
|
||||||
|
const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement';
|
||||||
const GUARD_STATUS = 'Checking Elydora agent state';
|
const GUARD_STATUS = 'Checking Elydora agent state';
|
||||||
const AUDIT_STATUS = 'Recording Elydora tool use';
|
const AUDIT_STATUS = 'Recording Elydora tool use';
|
||||||
|
const GUARD_SCRIPT = 'guard.js';
|
||||||
|
const AUDIT_SCRIPT = 'hook.js';
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
@@ -15,6 +20,18 @@ function isObject(value: unknown): value is JsonObject {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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 {
|
function resolveConfigPath(): string {
|
||||||
const configDir = entry.configDir.replace(/^~/, os.homedir());
|
const configDir = entry.configDir.replace(/^~/, os.homedir());
|
||||||
return path.join(configDir, entry.configFile);
|
return path.join(configDir, entry.configFile);
|
||||||
@@ -47,11 +64,22 @@ function eventGroups(hooks: JsonObject, event: string): JsonObject[] {
|
|||||||
return value;
|
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 {
|
function isElydoraHandler(handler: JsonObject, agentId?: string): boolean {
|
||||||
if (handler.statusMessage !== GUARD_STATUS && handler.statusMessage !== AUDIT_STATUS) return false;
|
const scriptName = handler.statusMessage === GUARD_STATUS
|
||||||
if (!agentId) return true;
|
? GUARD_SCRIPT
|
||||||
|
: handler.statusMessage === AUDIT_STATUS
|
||||||
|
? AUDIT_SCRIPT
|
||||||
|
: undefined;
|
||||||
|
if (!scriptName) return false;
|
||||||
return [handler.command, handler.commandWindows].some(
|
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 {
|
function findHandler(groups: JsonObject[], statusMessage: string): JsonObject | undefined {
|
||||||
for (const group of groups) {
|
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(
|
const handler = group.hooks.find(
|
||||||
(candidate) => isObject(candidate) && candidate.statusMessage === statusMessage,
|
(candidate) => candidate.statusMessage === statusMessage
|
||||||
|
&& isElydoraHandler(candidate),
|
||||||
);
|
);
|
||||||
if (isObject(handler)) return handler;
|
if (isObject(handler)) return handler;
|
||||||
}
|
}
|
||||||
@@ -84,37 +115,82 @@ function commandReferences(handler: JsonObject, scriptPath: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSettings(configPath: string): Promise<JsonObject | undefined> {
|
async function readJsonObject(filePath: string, label: string): Promise<JsonObject | undefined> {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = await fsp.readFile(configPath, 'utf-8');
|
raw = await fsp.readFile(filePath, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isObject(error) && error.code === 'ENOENT') return undefined;
|
if (hasErrorCode(error, 'ENOENT')) return undefined;
|
||||||
throw new Error(`Read Codex hooks config: ${error instanceof Error ? error.message : String(error)}`);
|
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
value = JSON.parse(raw);
|
value = JSON.parse(raw);
|
||||||
} catch (error) {
|
} 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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeSettings(configPath: string, settings: JsonObject): Promise<void> {
|
async function readSettings(configPath: string): Promise<JsonObject | undefined> {
|
||||||
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
return readJsonObject(configPath, 'Codex hooks config');
|
||||||
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
}
|
||||||
|
|
||||||
|
async function failWrite(
|
||||||
|
handle: FileHandle | undefined,
|
||||||
|
tempPath: string,
|
||||||
|
label: string,
|
||||||
|
cause: unknown,
|
||||||
|
): Promise<never> {
|
||||||
|
const errors = [asError(cause)];
|
||||||
|
if (handle) {
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tempPath, JSON.stringify(settings, null, 2) + '\n', {
|
await handle.close();
|
||||||
encoding: 'utf-8',
|
} catch (error) {
|
||||||
mode: 0o600,
|
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<void> {
|
||||||
|
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);
|
await fsp.rename(tempPath, configPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fsp.unlink(tempPath).catch(() => undefined);
|
await failWrite(handle, tempPath, `Codex hooks config at ${configPath}`, error);
|
||||||
throw new Error(`Write Codex hooks config: ${error instanceof Error ? error.message : String(error)}`);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSettings(configPath: string): Promise<void> {
|
||||||
|
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 };
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
async function runtimeScriptsExist(guard: JsonObject, audit: JsonObject): Promise<boolean> {
|
||||||
const root = path.join(os.homedir(), '.elydora');
|
const root = path.join(os.homedir(), '.elydora');
|
||||||
let entries;
|
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(root, { withFileTypes: true });
|
entries = await fsp.readdir(root, { withFileTypes: true });
|
||||||
} catch {
|
} catch (error) {
|
||||||
return false;
|
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) {
|
for (const directory of entries) {
|
||||||
if (!directory.isDirectory()) continue;
|
if (!directory.isDirectory()) continue;
|
||||||
const agentDir = path.join(root, directory.name);
|
const agentDir = path.join(root, directory.name);
|
||||||
try {
|
const guardPath = path.join(agentDir, GUARD_SCRIPT);
|
||||||
const config = JSON.parse(await fsp.readFile(path.join(agentDir, 'config.json'), 'utf-8'));
|
const hookPath = path.join(agentDir, AUDIT_SCRIPT);
|
||||||
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;
|
if (!commandReferences(guard, guardPath) || !commandReferences(audit, hookPath)) continue;
|
||||||
await Promise.all([fsp.access(guardPath), fsp.access(hookPath)]);
|
|
||||||
return true;
|
const configPath = path.join(agentDir, 'config.json');
|
||||||
} catch {
|
const config = await readJsonObject(configPath, 'Elydora runtime config');
|
||||||
continue;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codexPlugin: AgentPlugin = {
|
export const codexPlugin: AgentPlugin = {
|
||||||
async install(config: InstallConfig): Promise<void> {
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
if (!config.agentId) throw new Error('agentId is required');
|
||||||
const configPath = resolveConfigPath();
|
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);
|
const hooks = getHooks(settings);
|
||||||
|
|
||||||
hooks.PreToolUse = [
|
hooks.PreToolUse = [
|
||||||
@@ -177,7 +289,11 @@ export const codexPlugin: AgentPlugin = {
|
|||||||
const hooks = getHooks(settings);
|
const hooks = getHooks(settings);
|
||||||
hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId);
|
hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId);
|
||||||
hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId);
|
hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId);
|
||||||
|
if (isOwnedSettings(settings, hooks)) {
|
||||||
|
await removeSettings(configPath);
|
||||||
|
} else {
|
||||||
await writeSettings(configPath, { ...settings, hooks });
|
await writeSettings(configPath, { ...settings, hooks });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(): Promise<PluginStatus> {
|
async status(): Promise<PluginStatus> {
|
||||||
|
|||||||
@@ -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 () => {
|
test('Codex install preserves malformed config for recovery', async () => {
|
||||||
const fixture = await createFixture({ existingSettings: '{ malformed' });
|
const fixture = await createFixture({ existingSettings: '{ malformed' });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user