diff --git a/AGENTS.md b/AGENTS.md index 2802d34..57f7348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard. - Report installation as healthy only when a complete hook contract references every required event and both generated runtime scripts, runtime config, and private key exist as physical files. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. +- Resolve Codex user hooks through `CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Preserve user TOML, project, plugin, and managed hook sources because Codex composes them additively. Register exact `PreToolUse` and `PostToolUse` matcher groups with ten-second command handlers, preserve the complete native payload, propagate freeze and revocation through exit code `2`, and keep guard lookup and audit delivery fail-open with observable errors. Commit Codex's user hooks, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Use `/hooks` to approve both definition hashes after installation. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte. @@ -39,6 +40,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Avoid compatibility shims without a named public or user configuration contract. - Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. +- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors. - Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. ## Verification diff --git a/README.md b/README.md index 82b9f4f..084d9bf 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The CLI reads the private key and optional API token through hidden terminal pro | GitHub Copilot CLI | `copilot` | | Letta Code | `letta` | -Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. +Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes. Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout. diff --git a/src/plugins/codex-contract.ts b/src/plugins/codex-contract.ts new file mode 100644 index 0000000..699fd14 --- /dev/null +++ b/src/plugins/codex-contract.ts @@ -0,0 +1,373 @@ +import os from 'node:os'; +import path from 'node:path'; +import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +export const AGENT_KEY = 'codex'; +export const CONFIG_FILE = 'hooks.json'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; +export const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement'; +export const GUARD_STATUS = 'Checking Elydora agent state'; +export const AUDIT_STATUS = 'Recording Elydora tool use'; + +export type CodexHooks = Record; + +export interface CodexDocument { + readonly exists: boolean; + readonly filePath: string; + readonly root: JsonObject; + readonly hooks: CodexHooks; + readonly raw?: string; +} + +export interface RenderedDocument { + readonly document: CodexDocument; + readonly changed: boolean; + readonly next?: string; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +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 quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function windowsPowerShellPath(): string { + const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; + const systemRoot = configuredRoot + && path.win32.isAbsolute(configuredRoot) + && !/["%\r\n]/.test(configuredRoot) + ? configuredRoot + : 'C:\\Windows'; + return path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); +} + +function windowsCommand(scriptPath: string): string { + const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; + const encoded = Buffer.from(source, 'utf16le').toString('base64'); + return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; +} + +export function buildHandler(scriptPath: string, statusMessage: string): JsonObject { + return { + type: 'command', + command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, + commandWindows: windowsCommand(scriptPath), + timeout: HOOK_TIMEOUT_SECONDS, + statusMessage, + }; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parsePosixCommand(command: unknown): readonly [string, string] | undefined { + if (typeof command !== 'string') return undefined; + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parsePowerShellSource(source: string): readonly [string, string] | undefined { + if (!source.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(source, 2); + if (!executable || source[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(source, executable.next + 1); + if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; + return [executable.value, script.value]; +} + +function parseWindowsCommand(command: unknown): readonly [string, string] | undefined { + if (typeof command !== 'string') return undefined; + const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); + if (!match + || !path.win32.isAbsolute(match[1]) + || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; + const encoded = match[2]; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined; + const buffer = Buffer.from(encoded, 'base64'); + if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined; + return parsePowerShellSource(buffer.toString('utf16le')); +} + +function parseLegacyWindowsCommand(command: unknown): readonly [string, string] | undefined { + if (typeof command !== 'string') return undefined; + const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); + return match ? [match[1], match[2]] : undefined; +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath); + return basename === 'node' || basename.toLowerCase() === 'node.exe'; +} + +function exactHandlerKeys(handler: JsonObject): boolean { + return Object.keys(handler).sort().join('|') + === 'command|commandWindows|statusMessage|timeout|type'; +} + +function managedScriptPath(handler: JsonObject, statusMessage: string): string | undefined { + if (!exactHandlerKeys(handler) + || handler.type !== 'command' + || handler.timeout !== HOOK_TIMEOUT_SECONDS + || handler.statusMessage !== statusMessage) return undefined; + const posix = parsePosixCommand(handler.command); + const windows = parseWindowsCommand(handler.commandWindows) + ?? parseLegacyWindowsCommand(handler.commandWindows); + if (!posix || !windows + || !path.isAbsolute(posix[0]) + || !path.isAbsolute(posix[1]) + || !isNodeExecutable(posix[0]) + || !isNodeExecutable(windows[0]) + || !samePath(posix[0], windows[0]) + || !samePath(posix[1], windows[1])) return undefined; + return posix[1]; +} + +function managedAgentId( + handler: JsonObject, + scriptName: string, + statusMessage: string, +): string | undefined { + const scriptPath = managedScriptPath(handler, statusMessage); + if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function readHooks(value: unknown, label: string): CodexHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); + const hooks: CodexHooks = {}; + for (const [event, candidate] of Object.entries(value)) { + if (!Array.isArray(candidate)) { + throw new Error(`${label} field "hooks.${event}" must be an array`); + } + hooks[event] = candidate.map((group, groupIndex) => { + if (!isObject(group)) { + throw new Error(`${label} matcher group hooks.${event}[${groupIndex}] must be an object`); + } + if (!Array.isArray(group.hooks)) { + throw new Error( + `${label} matcher group hooks.${event}[${groupIndex}] must contain a hooks array`, + ); + } + for (const [handlerIndex, handler] of group.hooks.entries()) { + if (!isObject(handler)) { + throw new Error( + `${label} handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}] must be an object`, + ); + } + } + return group; + }); + } + return hooks; +} + +export function parseDocument(filePath: string, raw: string): CodexDocument { + const label = `Codex user hooks at ${filePath}`; + const root = parseStrictJsonObject(raw, label); + return { + exists: true, + filePath, + root, + hooks: readHooks(root.hooks, label), + raw, + }; +} + +export function createDocument(filePath: string): CodexDocument { + return { + exists: false, + filePath, + root: { description: OWNED_DESCRIPTION }, + hooks: {}, + }; +} + +function removeFromGroups( + groups: JsonObject[], + scriptName: string, + statusMessage: string, + agentId?: string, +): JsonObject[] { + const result: JsonObject[] = []; + for (const group of groups) { + const handlers = (group.hooks as JsonObject[]).filter((handler) => { + const managedId = managedAgentId(handler, scriptName, statusMessage); + return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId)); + }); + if (handlers.length > 0) result.push({ ...group, hooks: handlers }); + else if (!exactMatcherGroup(group)) result.push({ ...group, hooks: [] }); + } + return result; +} + +export function removeManagedHooks(hooks: CodexHooks, agentId?: string): CodexHooks { + const next: CodexHooks = Object.fromEntries( + Object.entries(hooks).map(([event, groups]) => [event, [...groups]]), + ); + for (const [event, scriptName, statusMessage] of [ + ['PreToolUse', GUARD_SCRIPT, GUARD_STATUS], + ['PostToolUse', AUDIT_SCRIPT, AUDIT_STATUS], + ] as const) { + const groups = removeFromGroups(next[event] ?? [], scriptName, statusMessage, agentId); + if (groups.length > 0) next[event] = groups; + else delete next[event]; + } + return next; +} + +function entirelyManaged(document: CodexDocument): boolean { + if (!document.exists + || document.root.description !== OWNED_DESCRIPTION + || !Object.keys(document.root).every((key) => key === 'description' || key === 'hooks')) { + return false; + } + const events = Object.entries(document.hooks); + if (events.length === 0) return false; + let handlerCount = 0; + for (const [event, groups] of events) { + const contract = event === 'PreToolUse' + ? [GUARD_SCRIPT, GUARD_STATUS] as const + : event === 'PostToolUse' + ? [AUDIT_SCRIPT, AUDIT_STATUS] as const + : undefined; + if (!contract || groups.length === 0) return false; + for (const group of groups) { + const handlers = group.hooks as JsonObject[]; + if (!exactMatcherGroup(group) + || handlers.length === 0 + || handlers.some((handler) => !managedAgentId(handler, contract[0], contract[1]))) { + return false; + } + handlerCount += handlers.length; + } + } + return handlerCount > 0; +} + +export function renderDocument( + document: CodexDocument, + hooks: CodexHooks, +): RenderedDocument { + if (!document.exists && Object.keys(hooks).length === 0) { + return { document, changed: false }; + } + if (Object.keys(hooks).length === 0 && entirelyManaged(document)) { + return { document, changed: true }; + } + const root: JsonObject = { ...document.root }; + if (Object.keys(hooks).length > 0) root.hooks = hooks; + else delete root.hooks; + const next = `${JSON.stringify(root, null, 2)}\n`; + return { document, changed: next !== document.raw, next }; +} + +function exactMatcherGroup(group: JsonObject): boolean { + return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*'; +} + +function managedIds( + groups: JsonObject[], + scriptName: string, + statusMessage: string, +): Map { + const result = new Map(); + for (const group of groups) { + if (!exactMatcherGroup(group)) continue; + for (const handler of group.hooks as JsonObject[]) { + const agentId = managedAgentId(handler, scriptName, statusMessage); + if (!agentId) continue; + const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId; + result.set(key, agentId); + } + } + return result; +} + +export function runtimeContracts(hooks: CodexHooks): RuntimeContract[] { + const guards = managedIds(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_STATUS); + const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_STATUS); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter(([key]) => audits.has(key)) + .map(([, agentId]) => ({ + agentId, + guardPath: path.join(root, agentId, GUARD_SCRIPT), + auditPath: path.join(root, agentId, AUDIT_SCRIPT), + })); +} diff --git a/src/plugins/codex-installation.ts b/src/plugins/codex-installation.ts new file mode 100644 index 0000000..e65da89 --- /dev/null +++ b/src/plugins/codex-installation.ts @@ -0,0 +1,69 @@ +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type RenderedDocument, +} from './codex-contract.js'; +import { + commitManagedInstallation, + managedRuntimePaths, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; + +const DISPLAY_NAME = 'Codex'; +const HOOKS_DIRECTORY_LABEL = 'Codex hooks directory'; +const HOOKS_LABEL = 'Codex user hooks'; + +export type CodexRuntimePaths = ManagedRuntimePaths; +export type PreparedCodexInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +export function codexRuntimePaths(config: InstallConfig): CodexRuntimePaths { + return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function preflightCodexInstallation( + config: InstallConfig, + hooksPath: string, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, + hooksPath, + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareCodexInstallation( + config: InstallConfig, + rendered: RenderedDocument, +): Promise { + if (!rendered.changed && rendered.document.raw === undefined) { + throw new Error('Codex hook installation did not produce a configuration document'); + } + const hooksSource = rendered.next ?? rendered.document.raw; + if (hooksSource === undefined) throw new Error('Codex hook configuration is missing'); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, + hooksLabel: HOOKS_LABEL, + hooksPath: rendered.document.filePath, + expectedHooksSource: rendered.document.raw, + hooksSource, + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function commitCodexInstallation( + prepared: PreparedCodexInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} diff --git a/src/plugins/codex-io.ts b/src/plugins/codex-io.ts new file mode 100644 index 0000000..a98b25e --- /dev/null +++ b/src/plugins/codex-io.ts @@ -0,0 +1,197 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + CONFIG_FILE, + type CodexDocument, + type RenderedDocument, + type RuntimeContract, + createDocument, + parseDocument, +} from './codex-contract.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { parseStrictJsonObject } from './strict-json.js'; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +async function codexHomePath(): Promise { + const configured = process.env.CODEX_HOME; + if (configured === undefined || configured === '') return path.join(os.homedir(), '.codex'); + let metadata; + try { + metadata = await fsp.stat(configured); + } catch (error) { + throw new Error(`Resolve CODEX_HOME at ${configured}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isDirectory()) throw new Error(`CODEX_HOME is not a directory: ${configured}`); + let canonical; + try { + canonical = await fsp.realpath(configured); + } catch (error) { + throw new Error(`Canonicalize CODEX_HOME at ${configured}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!await inspectPhysicalDirectory(canonical, 'CODEX_HOME')) { + throw new Error(`CODEX_HOME is missing: ${canonical}`); + } + return canonical; +} + +export async function codexConfigPath(): Promise { + return path.join(await codexHomePath(), CONFIG_FILE); +} + +export async function readDocument(): Promise { + const filePath = await codexConfigPath(); + const snapshot = await readPhysicalFile(filePath, 'Codex user hooks'); + return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath); +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + } +} + +async function writeExclusive(filePath: string, contents: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', 0o600); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); + } catch (error) { + const failures = [asError(error)]; + if (handle) { + try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); } + } + try { await unlinkOptional(filePath); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) throw new AggregateError(failures, 'Stage Codex user hooks'); + throw new Error(`Stage Codex user hooks: ${errorMessage(error)}`, { cause: failures[0] }); + } +} + +async function assertUnchanged(document: CodexDocument): Promise { + const current = await readPhysicalFile(document.filePath, 'Codex user hooks'); + if (current?.contents !== document.raw) { + throw new Error(`Codex user hooks changed during update: ${document.filePath}`); + } +} + +async function ensureHooksDirectory(directory: string): Promise { + try { + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + } catch (error) { + throw new Error(`Create Codex hooks directory at ${directory}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!await inspectPhysicalDirectory(directory, 'Codex hooks directory')) { + throw new Error(`Codex hooks directory is missing: ${directory}`); + } +} + +export async function writeDocument(rendered: RenderedDocument): Promise { + if (!rendered.changed) return; + await assertUnchanged(rendered.document); + const directory = path.dirname(rendered.document.filePath); + await ensureHooksDirectory(directory); + if (rendered.next === undefined) { + try { + await fsp.unlink(rendered.document.filePath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw new Error( + `Remove Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + } + return; + } + + const temporaryPath = path.join( + directory, + `.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`, + ); + let failure: Error | undefined; + try { + await writeExclusive(temporaryPath, rendered.next); + await assertUnchanged(rendered.document); + await fsp.rename(temporaryPath, rendered.document.filePath); + } catch (error) { + failure = new Error( + `Write Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + let cleanupFailure: Error | undefined; + try { + await unlinkOptional(temporaryPath); + } catch (error) { + cleanupFailure = new Error( + `Clean Codex hook staging file at ${temporaryPath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + if (failure && cleanupFailure) { + throw new AggregateError([failure, cleanupFailure], 'Write Codex user hooks'); + } + if (failure) throw failure; + if (cleanupFailure) throw cleanupFailure; +} + +async function physicalFileExists(filePath: string, label: string): Promise { + return Boolean(await readPhysicalFile(filePath, label)); +} + +function sameAgentId(left: unknown, right: string): boolean { + return typeof left === 'string' && (process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right); +} + +export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + for (const contract of contracts) { + const directory = path.dirname(contract.guardPath); + const configPath = path.join(directory, 'config.json'); + const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config'); + if (!snapshot) continue; + const config = parseStrictJsonObject( + snapshot.contents, + `Elydora runtime config at ${configPath}`, + ); + if (config.agent_name !== AGENT_KEY || !sameAgentId(config.agent_id, contract.agentId)) { + continue; + } + const files = await Promise.all([ + physicalFileExists(contract.guardPath, 'Elydora guard runtime'), + physicalFileExists(contract.auditPath, 'Elydora audit runtime'), + physicalFileExists(path.join(directory, 'private.key'), 'Elydora private key'), + ]); + if (files.every(Boolean)) return true; + } + return false; +} diff --git a/src/plugins/codex.ts b/src/plugins/codex.ts index 49d33af..d4c807f 100644 --- a/src/plugins/codex.ts +++ b/src/plugins/codex.ts @@ -1,320 +1,71 @@ -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 { + AGENT_KEY, + AUDIT_STATUS, + GUARD_STATUS, + buildHandler, + removeManagedHooks, + renderDocument, + runtimeContracts, +} from './codex-contract.js'; +import { + commitCodexInstallation, + preflightCodexInstallation, + prepareCodexInstallation, +} from './codex-installation.js'; +import { readDocument, runtimeFilesExist, writeDocument } from './codex-io.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; +function matcherGroup(handler: ReturnType): Record { + return { matcher: '*', hooks: [handler] }; } 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); + managesRuntime: true, + async preflightInstall(config: InstallConfig): Promise { + const document = await readDocument(); + await preflightCodexInstallation(config, document.filePath); + }, + + async install(config: InstallConfig): Promise { + const document = await readDocument(); + const paths = await preflightCodexInstallation(config, document.filePath); + const hooks = removeManagedHooks(document.hooks); hooks.PreToolUse = [ - ...withoutElydora(eventGroups(hooks, 'PreToolUse')), - { matcher: '*', hooks: [buildHandler(config.guardScriptPath, GUARD_STATUS)] }, + ...(hooks.PreToolUse ?? []), + matcherGroup(buildHandler(paths.guardPath, GUARD_STATUS)), ]; hooks.PostToolUse = [ - ...withoutElydora(eventGroups(hooks, 'PostToolUse')), - { matcher: '*', hooks: [buildHandler(config.hookScriptPath, AUDIT_STATUS)] }, + ...(hooks.PostToolUse ?? []), + matcherGroup(buildHandler(paths.auditPath, AUDIT_STATUS)), ]; - - await writeSettings(configPath, { ...settings, hooks }); - console.log(' Codex: run /hooks to review and trust the Elydora hooks.'); + const prepared = await prepareCodexInstallation(config, renderDocument(document, hooks)); + await commitCodexInstallation(prepared); + console.log(` Codex hooks: ${document.filePath}`); + console.log(' Codex trust: run /hooks and approve both Elydora command 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 }); - } + const document = await readDocument(); + const hooks = removeManagedHooks(document.hooks, agentId); + await writeDocument(renderDocument(document, 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; + const document = await readDocument(); + const contracts = runtimeContracts(document.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts); return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: document.filePath, }; }, }; diff --git a/src/plugins/cursor-contract.ts b/src/plugins/cursor-contract.ts index d5fa40e..5b92f74 100644 --- a/src/plugins/cursor-contract.ts +++ b/src/plugins/cursor-contract.ts @@ -1,12 +1,9 @@ import os from 'node:os'; import path from 'node:path'; -import { - parse, - parseTree, - printParseErrorCode, - type Node, - type ParseError, -} from 'jsonc-parser'; +import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +export { isObject, parseStrictJsonObject }; +export type { JsonObject }; export const AGENT_KEY = 'cursor'; export const CONFIG_FILE = 'hooks.json'; @@ -14,7 +11,6 @@ export const GUARD_SCRIPT = 'guard.js'; export const AUDIT_SCRIPT = 'hook.js'; export const HOOK_TIMEOUT_SECONDS = 10; -export type JsonObject = Record; export type CursorHooks = Record; export interface CursorDocument { @@ -42,10 +38,6 @@ interface ParsedArgument { readonly next: number; } -export function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function samePath(left: string, right: string): boolean { const normalizedLeft = path.resolve(left); const normalizedRight = path.resolve(right); @@ -173,46 +165,6 @@ function managedAgentId(handler: JsonObject, scriptName: string): string | undef return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; } -function rejectDuplicateKeys(node: Node | undefined, label: string, location: string[] = []): void { - if (!node) return; - if (node.type === 'object') { - const keys = new Set(); - for (const property of node.children ?? []) { - const key = String(property.children?.[0]?.value); - const childLocation = [...location, key]; - if (keys.has(key)) { - throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); - } - keys.add(key); - rejectDuplicateKeys(property.children?.[1], label, childLocation); - } - return; - } - if (node.type === 'array') { - for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); - } -} - -export function parseStrictJsonObject(raw: string, label: string): JsonObject { - const errors: ParseError[] = []; - const value: unknown = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: true, - }); - if (errors.length > 0) { - const details = errors - .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) - .join(', '); - throw new Error(`Failed to parse ${label}: ${details}`); - } - if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - rejectDuplicateKeys(parseTree(raw, [], { - allowTrailingComma: false, - disallowComments: true, - }), label); - return value; -} - function readHooks(value: unknown, label: string): CursorHooks { if (value === undefined) return {}; if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); diff --git a/src/plugins/cursor-installation.ts b/src/plugins/cursor-installation.ts index 742b20c..e6552f0 100644 --- a/src/plugins/cursor-installation.ts +++ b/src/plugins/cursor-installation.ts @@ -1,448 +1,77 @@ -import { randomUUID } from 'node:crypto'; -import { constants } from 'node:fs'; -import fsp from 'node:fs/promises'; -import type { FileHandle } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js'; import type { InstallConfig } from './base.js'; +import { + commitManagedInstallation, + managedRuntimePaths, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; import { AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, - type JsonObject, type RenderedDocument, - parseStrictJsonObject, - samePath, } from './cursor-contract.js'; -import { generateGuardScript } from './guard-template.js'; -import { generateHookScript } from './hook-template.js'; import { cursorConfigPath } from './cursor-io.js'; -const MAX_SECRET_BYTES = 64 * 1024; -const MAX_CONFIG_BYTES = 512 * 1024; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +const DISPLAY_NAME = 'Cursor'; +const HOOKS_DIRECTORY_LABEL = 'Cursor hooks directory'; +const HOOKS_LABEL = 'Cursor user hooks'; -interface FileSnapshot { - readonly contents: string; - readonly device: bigint | number; - readonly inode: bigint | number; - readonly mode: number; -} - -interface FileChange { - readonly filePath: string; - readonly label: string; - readonly next: string; - readonly mode: number; - readonly original?: FileSnapshot; -} - -interface StagedChange { - readonly change: FileChange; - readonly temporaryPath: string; - readonly rollbackPath?: string; - committed: boolean; -} - -export interface CursorRuntimePaths { - readonly runtimeRoot: string; - readonly agentDirectory: string; - readonly configPath: string; - readonly keyPath: string; - readonly guardPath: string; - readonly auditPath: string; -} - -export interface PreparedCursorInstallation { - readonly paths: CursorRuntimePaths; - readonly changes: readonly FileChange[]; -} - -export type RenameFile = (source: string, destination: string) => Promise; - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function hasCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code; -} - -async function readPhysicalFile( - filePath: string, - label: string, - maximumBytes = MAX_SOURCE_BYTES, -): Promise { - let before; - try { - before = await fsp.lstat(filePath, { bigint: true }); - } catch (error) { - if (hasCode(error, 'ENOENT')) return undefined; - throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!before.isFile() || before.isSymbolicLink()) { - throw new Error(`${label} path is not a physical file: ${filePath}`); - } - if (before.size > BigInt(maximumBytes)) { - throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); - } - let flags = constants.O_RDONLY; - if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') { - flags |= constants.O_NOFOLLOW; - } - let handle: FileHandle | undefined; - try { - handle = await fsp.open(filePath, flags); - const after = await handle.stat({ bigint: true }); - if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) { - throw new Error(`${label} changed while opening: ${filePath}`); - } - const contents = await handle.readFile('utf-8'); - if (Buffer.byteLength(contents) > maximumBytes) { - throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); - } - return { - contents, - device: after.dev, - inode: after.ino, - mode: Number(after.mode & 0o777n), - }; - } finally { - await handle?.close(); - } -} - -async function inspectDirectory(directory: string, label: string): Promise { - let metadata; - try { - metadata = await fsp.lstat(directory); - } catch (error) { - if (hasCode(error, 'ENOENT')) return false; - throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`${label} is not a physical directory: ${directory}`); - } - return true; -} - -function validateInstallConfig(config: InstallConfig): void { - for (const [field, value] of [ - ['orgId', config.orgId], - ['agentId', config.agentId], - ['kid', config.kid], - ['privateKey', config.privateKey], - ] as const) { - if (!value) throw new Error(`${field} is required`); - } - if (config.agentName !== AGENT_KEY) { - throw new Error(`Cursor installation requires agentName ${AGENT_KEY}`); - } - const seed = Buffer.from(config.privateKey, 'base64url'); - if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) { - throw new Error('privateKey must be a canonical 32-byte base64url value'); - } - const baseUrl = new URL(config.baseUrl); - if (!['http:', 'https:'].includes(baseUrl.protocol)) { - throw new Error('baseUrl must use HTTP or HTTPS'); - } -} +export type CursorRuntimePaths = ManagedRuntimePaths; +export type PreparedCursorInstallation = PreparedManagedInstallation; +export type { RenameFile }; export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths { - validateInstallConfig(config); - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId); - const guardPath = path.join(agentDirectory, GUARD_SCRIPT); - const auditPath = path.join(agentDirectory, AUDIT_SCRIPT); - if (!samePath(config.guardScriptPath, guardPath)) { - throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`); - } - if (!samePath(config.hookScriptPath, auditPath)) { - throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`); - } - return { - runtimeRoot, - agentDirectory, - configPath: path.join(agentDirectory, 'config.json'), - keyPath: path.join(agentDirectory, 'private.key'), - guardPath, - auditPath, - }; + return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); } -function sameAgentId(left: unknown, right: string): boolean { - return typeof left === 'string' && (process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right); -} - -async function validateRuntimeIdentity(paths: CursorRuntimePaths): Promise { - const runtimeRootExists = await inspectDirectory(paths.runtimeRoot, 'Elydora runtime directory'); - if (!runtimeRootExists) return; - const agentDirectoryExists = await inspectDirectory( - paths.agentDirectory, - 'Elydora agent runtime directory', - ); - if (!agentDirectoryExists) return; - const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES); - const artifacts = await Promise.all([ - readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES), - readPhysicalFile(paths.guardPath, 'Elydora guard runtime'), - readPhysicalFile(paths.auditPath, 'Elydora audit runtime'), - readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'), - readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'), - ]); - if (!config) { - if (artifacts.some(Boolean)) { - throw new Error( - `Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`, - ); - } - return; - } - const value = parseStrictJsonObject(config.contents, `Elydora runtime config at ${paths.configPath}`); - if (value.agent_name !== AGENT_KEY || !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) { - throw new Error( - `Elydora runtime config identity does not match Cursor agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`, - ); - } -} - -export async function preflightCursorInstallation(config: InstallConfig): Promise { - const paths = cursorRuntimePaths(config); - await inspectDirectory(path.dirname(cursorConfigPath()), 'Cursor hooks directory'); - await validateRuntimeIdentity(paths); - return paths; -} - -function runtimeConfig(config: InstallConfig): string { - const value: JsonObject = { - org_id: config.orgId, - agent_id: config.agentId, - kid: config.kid, - base_url: config.baseUrl, - ...(config.token ? { token: config.token } : {}), - agent_name: AGENT_KEY, - }; - const encoded = `${JSON.stringify(value, null, 2)}\n`; - if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) { - throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`); - } - return encoded; -} - -async function prepareChange( - filePath: string, - label: string, - next: string, - mode: number, - maximumBytes = MAX_SOURCE_BYTES, -): Promise { - return { - filePath, - label, - next, - mode, - original: await readPhysicalFile(filePath, label, maximumBytes), - }; +export async function preflightCursorInstallation( + config: InstallConfig, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, + hooksPath: cursorConfigPath(), + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); } export async function prepareCursorInstallation( config: InstallConfig, rendered: RenderedDocument, ): Promise { - const paths = await preflightCursorInstallation(config); if (!rendered.changed && rendered.document.raw === undefined) { throw new Error('Cursor hook installation did not produce a configuration document'); } - const hooksConfig = rendered.next ?? rendered.document.raw; - if (hooksConfig === undefined) throw new Error('Cursor hook configuration is missing'); - const changes = await Promise.all([ - prepareChange( - paths.guardPath, - 'Elydora guard runtime', - generateGuardScript(AGENT_KEY, config.agentId, { - failClosed: true, - successOutput: '{"permission":"allow"}\n', - }), - 0o700, - ), - prepareChange(paths.configPath, 'Elydora runtime config', runtimeConfig(config), 0o600, MAX_CONFIG_BYTES), - prepareChange(paths.keyPath, 'Elydora private key', config.privateKey, 0o600, MAX_SECRET_BYTES), - prepareChange( - paths.auditPath, - 'Elydora audit runtime', - generateHookScript(AGENT_KEY, config.agentId, { - failClosed: true, - nativePayload: true, - successOutput: '{}\n', - }), - 0o700, - ), - prepareChange( - rendered.document.filePath, - 'Cursor user hooks', - hooksConfig, - 0o600, - MAX_SOURCE_BYTES, - ), - ]); - const hooksSource = changes.at(-1)!.original?.contents; - if (hooksSource !== rendered.document.raw) { - throw new Error( - `Cursor user hooks changed before installation: ${rendered.document.filePath}`, - ); - } - await validateRuntimeIdentity(paths); - return { paths, changes }; -} - -async function unlinkOptional(filePath: string): Promise { - try { - await fsp.unlink(filePath); - } catch (error) { - if (!hasCode(error, 'ENOENT')) throw error; - } -} - -async function assertUnchanged(change: FileChange): Promise { - const current = await readPhysicalFile(change.filePath, change.label); - if ((!current && change.original) - || (current && !change.original) - || (current && change.original && ( - current.contents !== change.original.contents - || current.device !== change.original.device - || current.inode !== change.original.inode - ))) { - throw new Error(`${change.label} changed during Cursor installation: ${change.filePath}`); - } -} - -async function writeExclusive( - filePath: string, - contents: string, - mode: number, - label: string, -): Promise { - let handle: FileHandle | undefined; - try { - handle = await fsp.open(filePath, 'wx', mode); - await handle.writeFile(contents, 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - if (process.platform !== 'win32') await fsp.chmod(filePath, mode); - } catch (error) { - const failures = [asError(error)]; - if (handle) { - try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); } - } - try { await unlinkOptional(filePath); } catch (cleanupError) { - failures.push(asError(cleanupError)); - } - if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`); - throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] }); - } -} - -async function stage(change: FileChange): Promise { - await assertUnchanged(change); - const token = randomUUID(); - const directory = path.dirname(change.filePath); - const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`); - const rollbackPath = change.original - ? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`) - : undefined; - try { - await writeExclusive(temporaryPath, change.next, change.mode, change.label); - if (rollbackPath && change.original) { - await writeExclusive( - rollbackPath, - change.original.contents, - change.original.mode, - `${change.label} rollback`, - ); - } - return { change, temporaryPath, rollbackPath, committed: false }; - } catch (error) { - const failures = [asError(error)]; - for (const item of [temporaryPath, rollbackPath]) { - if (!item) continue; - try { await unlinkOptional(item); } catch (cleanupError) { - failures.push(asError(cleanupError)); - } - } - if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`); - throw error; - } -} - -async function commit(staged: StagedChange, renameFile: RenameFile): Promise { - await assertUnchanged(staged.change); - await renameFile(staged.temporaryPath, staged.change.filePath); - staged.committed = true; -} - -async function rollback(staged: StagedChange, renameFile: RenameFile): Promise { - if (!staged.committed) return; - if (!staged.change.original) { - await unlinkOptional(staged.change.filePath); - return; - } - if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); - await renameFile(staged.rollbackPath, staged.change.filePath); -} - -async function cleanup(staged: StagedChange): Promise { - await Promise.all( - [staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)), - ); -} - -async function ensureCursorDirectory(directory: string): Promise { - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - await inspectDirectory(directory, 'Cursor hooks directory'); + const hooksSource = rendered.next ?? rendered.document.raw; + if (hooksSource === undefined) throw new Error('Cursor hook configuration is missing'); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, + hooksLabel: HOOKS_LABEL, + hooksPath: rendered.document.filePath, + expectedHooksSource: rendered.document.raw, + hooksSource, + config, + guardOptions: { + failClosed: true, + successOutput: '{"permission":"allow"}\n', + }, + auditOptions: { + failClosed: true, + nativePayload: true, + successOutput: '{}\n', + }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); } export async function commitCursorInstallation( prepared: PreparedCursorInstallation, - renameFile: RenameFile = fsp.rename, + renameFile?: RenameFile, ): Promise { - await ensurePrivateDirectory(prepared.paths.runtimeRoot); - await ensurePrivateDirectory(prepared.paths.agentDirectory); - await ensureCursorDirectory(path.dirname(prepared.changes.at(-1)!.filePath)); - const staged: StagedChange[] = []; - try { - for (const change of prepared.changes) staged.push(await stage(change)); - for (const item of staged) await commit(item, renameFile); - } catch (error) { - const failures = [asError(error)]; - for (const item of [...staged].reverse()) { - try { await rollback(item, renameFile); } catch (rollbackError) { - failures.push(asError(rollbackError)); - } - } - for (const item of staged) { - try { await cleanup(item); } catch (cleanupError) { - failures.push(asError(cleanupError)); - } - } - throw new AggregateError(failures, `Install Cursor hooks: ${errorMessage(error)}`); - } - const failures: Error[] = []; - for (const item of staged) { - try { await cleanup(item); } catch (error) { failures.push(asError(error)); } - } - if (failures.length > 0) { - throw new AggregateError(failures, 'Clean Cursor installation transaction files'); - } + await commitManagedInstallation(prepared, renameFile); } diff --git a/src/plugins/guard-template.ts b/src/plugins/guard-template.ts index 17f6fce..366318b 100644 --- a/src/plugins/guard-template.ts +++ b/src/plugins/guard-template.ts @@ -16,6 +16,7 @@ export function generateGuardScript( 'use strict'; // Elydora agent guard for ${agentName}. Generated by @elydora/sdk. +const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); @@ -55,9 +56,13 @@ function blockAgent(status) { function readStatusCache() { let raw; try { - raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8'); + raw = readProtectedFile( + STATUS_CACHE_PATH, + 'Status cache', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8'); } catch (error) { - if (error.code === 'ENOENT') return undefined; + if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined; throw new Error('Failed to read status cache: ' + error.message); } let cache; @@ -75,26 +80,10 @@ function readStatusCache() { } function writeStatusCache(status) { - const temporaryPath = STATUS_CACHE_PATH + '.' + process.pid + '.tmp'; - try { - fs.writeFileSync(temporaryPath, JSON.stringify({ - status, - cached_at: Date.now(), - }), { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); - try { - fs.renameSync(temporaryPath, STATUS_CACHE_PATH); - } catch (error) { - if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error; - try { fs.unlinkSync(STATUS_CACHE_PATH); } catch (unlinkError) { - if (unlinkError.code !== 'ENOENT') throw unlinkError; - } - fs.renameSync(temporaryPath, STATUS_CACHE_PATH); - } - } finally { - try { fs.unlinkSync(temporaryPath); } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - } + writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', { + status, + cached_at: Date.now(), + }); } async function readInput() { @@ -103,12 +92,14 @@ async function readInput() { const raw = Buffer.concat(chunks).toString('utf-8').trim(); if (!raw) { if (FAIL_CLOSED) throw new Error('Hook input is empty'); + process.stderr.write('[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\\n'); return; } try { JSON.parse(raw); } catch (error) { if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message); + process.stderr.write('[Elydora guard] Hook input is invalid JSON; enforcement continued in fail-open mode: ' + error.message + '\\n'); } } diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts index 5e53447..42b5b45 100644 --- a/src/plugins/hook-template.ts +++ b/src/plugins/hook-template.ts @@ -130,7 +130,7 @@ function logError(error) { try { const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' + (error.stack || error.message || String(error)) + '\\n'; - fs.appendFileSync(ERROR_LOG_PATH, message, { encoding: 'utf-8', mode: 0o600 }); + appendProtectedText(ERROR_LOG_PATH, 'Error log', message); } catch (logFailure) { process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n'); } @@ -139,9 +139,13 @@ function logError(error) { function readChainState() { let raw; try { - raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); + raw = readProtectedFile( + CHAIN_STATE_PATH, + 'Chain state', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8'); } catch (error) { - if (error.code === 'ENOENT') return ZERO_CHAIN_HASH; + if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH; throw new Error('Failed to read chain state: ' + error.message); } let state; @@ -158,29 +162,7 @@ function readChainState() { } function writeChainState(chainHash) { - const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp'; - let descriptor; - try { - descriptor = fs.openSync(temporaryPath, 'wx', 0o600); - fs.writeFileSync(descriptor, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8'); - fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - descriptor = undefined; - try { - fs.renameSync(temporaryPath, CHAIN_STATE_PATH); - } catch (error) { - if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error; - try { fs.unlinkSync(CHAIN_STATE_PATH); } catch (unlinkError) { - if (unlinkError.code !== 'ENOENT') throw unlinkError; - } - fs.renameSync(temporaryPath, CHAIN_STATE_PATH); - } - } finally { - if (descriptor !== undefined) fs.closeSync(descriptor); - try { fs.unlinkSync(temporaryPath); } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - } + writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash }); } function readConfigAndKey() { diff --git a/src/plugins/managed-files.ts b/src/plugins/managed-files.ts new file mode 100644 index 0000000..5b47c0e --- /dev/null +++ b/src/plugins/managed-files.ts @@ -0,0 +1,86 @@ +import { constants } from 'node:fs'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; + +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; + +export interface FileSnapshot { + readonly contents: string; + readonly device: bigint | number; + readonly inode: bigint | number; + readonly mode: number; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +export async function readPhysicalFile( + filePath: string, + label: string, + maximumBytes = MAX_SOURCE_BYTES, +): Promise { + let before; + try { + before = await fsp.lstat(filePath, { bigint: true }); + } catch (error) { + if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return undefined; + throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error(`${label} path is not a physical file: ${filePath}`); + } + if (before.size > BigInt(maximumBytes)) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); + } + let flags = constants.O_RDONLY; + if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') { + flags |= constants.O_NOFOLLOW; + } + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, flags); + const after = await handle.stat({ bigint: true }); + if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) { + throw new Error(`${label} changed while opening: ${filePath}`); + } + const contents = await handle.readFile('utf-8'); + if (Buffer.byteLength(contents) > maximumBytes) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); + } + return { + contents, + device: after.dev, + inode: after.ino, + mode: Number(after.mode & 0o777n), + }; + } finally { + await handle?.close(); + } +} + +export async function inspectPhysicalDirectory(directory: string, label: string): Promise { + let metadata; + try { + metadata = await fsp.lstat(directory); + } catch (error) { + if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false; + throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`${label} is not a physical directory: ${directory}`); + } + return true; +} diff --git a/src/plugins/managed-installation.ts b/src/plugins/managed-installation.ts new file mode 100644 index 0000000..3c9d41e --- /dev/null +++ b/src/plugins/managed-installation.ts @@ -0,0 +1,432 @@ +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 { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js'; +import type { InstallConfig } from './base.js'; +import { generateGuardScript, type GuardScriptOptions } from './guard-template.js'; +import { generateHookScript, type HookScriptOptions } from './hook-template.js'; +import { + inspectPhysicalDirectory, + readPhysicalFile, + type FileSnapshot, +} from './managed-files.js'; +import { parseStrictJsonObject } from './strict-json.js'; + +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; + +interface FileChange { + readonly filePath: string; + readonly label: string; + readonly next: string; + readonly mode: number; + readonly maximumBytes: number; + readonly original?: FileSnapshot; +} +interface StagedChange { + readonly change: FileChange; + readonly temporaryPath: string; + readonly rollbackPath?: string; + committed: boolean; +} +export interface ManagedRuntimePaths { + readonly runtimeRoot: string; + readonly agentDirectory: string; + readonly configPath: string; + readonly keyPath: string; + readonly guardPath: string; + readonly auditPath: string; +} +export interface ManagedInstallationSpec { + readonly agentKey: string; + readonly displayName: string; + readonly hooksDirectoryLabel: string; + readonly hooksLabel: string; + readonly hooksPath: string; + readonly expectedHooksSource?: string; + readonly hooksSource: string; + readonly config: InstallConfig; + readonly guardOptions?: GuardScriptOptions; + readonly auditOptions?: HookScriptOptions; +} +export interface ManagedPreflightSpec { + readonly agentKey: string; + readonly hooksDirectoryLabel: string; + readonly hooksPath: string; + readonly config: InstallConfig; +} +export interface PreparedManagedInstallation { + readonly displayName: string; + readonly hooksDirectoryLabel: string; + readonly paths: ManagedRuntimePaths; + readonly changes: readonly FileChange[]; +} +export type RenameFile = (source: string, destination: string) => Promise; +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +export 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 validateInstallConfig(config: InstallConfig, agentKey: string): void { + for (const [field, value] of [ + ['agentName', config.agentName], + ['orgId', config.orgId], + ['agentId', config.agentId], + ['kid', config.kid], + ['privateKey', config.privateKey], + ['baseUrl', config.baseUrl], + ['guardScriptPath', config.guardScriptPath], + ['hookScriptPath', config.hookScriptPath], + ] as const) { + if (typeof value !== 'string' || !value) throw new Error(`${field} is required`); + } + if (config.token !== undefined && (typeof config.token !== 'string' || !config.token)) { + throw new Error('token must be a non-empty string when provided'); + } + if (config.agentName !== agentKey) { + throw new Error(`${agentKey} installation requires agentName ${agentKey}`); + } + const seed = Buffer.from(config.privateKey, 'base64url'); + if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) { + throw new Error('privateKey must be a canonical 32-byte base64url value'); + } + const baseUrl = new URL(config.baseUrl); + if (!['http:', 'https:'].includes(baseUrl.protocol)) { + throw new Error('baseUrl must use HTTP or HTTPS'); + } + if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) { + throw new Error('baseUrl must exclude credentials, query parameters, and fragments'); + } +} + +export function managedRuntimePaths( + config: InstallConfig, + agentKey: string, + guardScript: string, + auditScript: string, +): ManagedRuntimePaths { + validateInstallConfig(config, agentKey); + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId); + const guardPath = path.join(agentDirectory, guardScript); + const auditPath = path.join(agentDirectory, auditScript); + if (!samePath(config.guardScriptPath, guardPath)) { + throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`); + } + if (!samePath(config.hookScriptPath, auditPath)) { + throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`); + } + return { + runtimeRoot, + agentDirectory, + configPath: path.join(agentDirectory, 'config.json'), + keyPath: path.join(agentDirectory, 'private.key'), + guardPath, + auditPath, + }; +} + +function sameAgentId(left: unknown, right: string): boolean { + return typeof left === 'string' && (process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right); +} + +async function validateRuntimeIdentity( + paths: ManagedRuntimePaths, + agentKey: string, +): Promise { + if (!await inspectPhysicalDirectory(paths.runtimeRoot, 'Elydora runtime directory')) return; + if (!await inspectPhysicalDirectory(paths.agentDirectory, 'Elydora agent runtime directory')) return; + const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES); + const artifacts = await Promise.all([ + readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES), + readPhysicalFile(paths.guardPath, 'Elydora guard runtime'), + readPhysicalFile(paths.auditPath, 'Elydora audit runtime'), + readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'), + readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'), + readPhysicalFile(path.join(paths.agentDirectory, 'error.log'), 'Elydora error log'), + ]); + if (!config) { + if (artifacts.some(Boolean)) { + throw new Error( + `Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`, + ); + } + return; + } + const label = `Elydora runtime config at ${paths.configPath}`; + const value = parseStrictJsonObject(config.contents, label); + if (value.agent_name !== agentKey + || !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) { + throw new Error( + `Elydora runtime config identity does not match ${agentKey} agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`, + ); + } +} + +export async function preflightManagedInstallation( + spec: ManagedPreflightSpec, + guardScript: string, + auditScript: string, +): Promise { + const paths = managedRuntimePaths(spec.config, spec.agentKey, guardScript, auditScript); + await inspectPhysicalDirectory(path.dirname(spec.hooksPath), spec.hooksDirectoryLabel); + await validateRuntimeIdentity(paths, spec.agentKey); + return paths; +} + +function runtimeConfig(config: InstallConfig, agentKey: string): string { + const value = { + org_id: config.orgId, + agent_id: config.agentId, + kid: config.kid, + base_url: config.baseUrl, + ...(config.token ? { token: config.token } : {}), + agent_name: agentKey, + }; + const encoded = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) { + throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`); + } + return encoded; +} + +async function prepareChange( + filePath: string, + label: string, + next: string, + mode: number, + maximumBytes = MAX_SOURCE_BYTES, +): Promise { + return { + filePath, + label, + next, + mode, + maximumBytes, + original: await readPhysicalFile(filePath, label, maximumBytes), + }; +} + +export async function prepareManagedInstallation( + spec: ManagedInstallationSpec, + guardScript: string, + auditScript: string, +): Promise { + const paths = await preflightManagedInstallation(spec, guardScript, auditScript); + const changes = await Promise.all([ + prepareChange( + paths.guardPath, + 'Elydora guard runtime', + generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions), + 0o700, + ), + prepareChange( + paths.configPath, + 'Elydora runtime config', + runtimeConfig(spec.config, spec.agentKey), + 0o600, + MAX_CONFIG_BYTES, + ), + prepareChange( + paths.keyPath, + 'Elydora private key', + spec.config.privateKey, + 0o600, + MAX_SECRET_BYTES, + ), + prepareChange( + paths.auditPath, + 'Elydora audit runtime', + generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions), + 0o700, + ), + prepareChange(spec.hooksPath, spec.hooksLabel, spec.hooksSource, 0o600), + ]); + const hooksSource = changes.at(-1)!.original?.contents; + if (hooksSource !== spec.expectedHooksSource) { + throw new Error(`${spec.hooksLabel} changed before installation: ${spec.hooksPath}`); + } + await validateRuntimeIdentity(paths, spec.agentKey); + return { + displayName: spec.displayName, + hooksDirectoryLabel: spec.hooksDirectoryLabel, + paths, + changes, + }; +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + } +} + +async function assertUnchanged(change: FileChange, displayName: string): Promise { + const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes); + if ((!current && change.original) + || (current && !change.original) + || (current && change.original && ( + current.contents !== change.original.contents + || current.device !== change.original.device + || current.inode !== change.original.inode + ))) { + throw new Error(`${change.label} changed during ${displayName} installation: ${change.filePath}`); + } +} + +async function writeExclusive( + filePath: string, + contents: string, + mode: number, + label: string, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', mode); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + if (process.platform !== 'win32') await fsp.chmod(filePath, mode); + } catch (error) { + const failures = [asError(error)]; + if (handle) { + try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); } + } + try { await unlinkOptional(filePath); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`); + throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] }); + } +} + +async function stage(change: FileChange, displayName: string): Promise { + await assertUnchanged(change, displayName); + const token = randomUUID(); + const directory = path.dirname(change.filePath); + const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`); + const rollbackPath = change.original + ? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`) + : undefined; + try { + await writeExclusive(temporaryPath, change.next, change.mode, change.label); + if (rollbackPath && change.original) { + await writeExclusive( + rollbackPath, + change.original.contents, + change.original.mode, + `${change.label} rollback`, + ); + } + return { change, temporaryPath, rollbackPath, committed: false }; + } catch (error) { + const failures = [asError(error)]; + for (const item of [temporaryPath, rollbackPath]) { + if (!item) continue; + try { await unlinkOptional(item); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`); + throw error; + } +} + +async function commit( + staged: StagedChange, + displayName: string, + renameFile: RenameFile, +): Promise { + await assertUnchanged(staged.change, displayName); + await renameFile(staged.temporaryPath, staged.change.filePath); + staged.committed = true; +} + +async function rollback(staged: StagedChange, renameFile: RenameFile): Promise { + if (!staged.committed) return; + if (!staged.change.original) { + await unlinkOptional(staged.change.filePath); + return; + } + if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); + await renameFile(staged.rollbackPath, staged.change.filePath); +} + +async function cleanup(staged: StagedChange): Promise { + await Promise.all( + [staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)), + ); +} + +async function ensureManagedDirectory(directory: string, label: string): Promise { + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + if (!await inspectPhysicalDirectory(directory, label)) { + throw new Error(`${label} is missing: ${directory}`); + } +} + +export async function commitManagedInstallation( + prepared: PreparedManagedInstallation, + renameFile: RenameFile = fsp.rename, +): Promise { + await ensurePrivateDirectory(prepared.paths.runtimeRoot); + await ensurePrivateDirectory(prepared.paths.agentDirectory); + await ensureManagedDirectory( + path.dirname(prepared.changes.at(-1)!.filePath), + prepared.hooksDirectoryLabel, + ); + const staged: StagedChange[] = []; + try { + for (const change of prepared.changes) { + staged.push(await stage(change, prepared.displayName)); + } + for (const item of staged) await commit(item, prepared.displayName, renameFile); + } catch (error) { + const failures = [asError(error)]; + for (const item of [...staged].reverse()) { + try { await rollback(item, renameFile); } catch (rollbackError) { + failures.push(asError(rollbackError)); + } + } + for (const item of staged) { + try { await cleanup(item); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + throw new AggregateError( + failures, + `Install ${prepared.displayName} hooks: ${errorMessage(error)}`, + ); + } + const failures: Error[] = []; + for (const item of staged) { + try { await cleanup(item); } catch (error) { failures.push(asError(error)); } + } + if (failures.length > 0) { + throw new AggregateError( + failures, + `Clean ${prepared.displayName} installation transaction files`, + ); + } +} diff --git a/src/plugins/runtime-reader-template.ts b/src/plugins/runtime-reader-template.ts index c44785d..a822aca 100644 --- a/src/plugins/runtime-reader-template.ts +++ b/src/plugins/runtime-reader-template.ts @@ -1,18 +1,31 @@ export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024; const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024; +const MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024; -function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) { - const before = fs.lstatSync(filePath); - if (!before.isFile() || before.isSymbolicLink()) { +function hasRuntimeErrorCode(error, code) { + return error && typeof error === 'object' && error.code === code; +} + +function validateProtectedMetadata(metadata, filePath, label, maxBytes) { + if (!metadata.isFile() || metadata.isSymbolicLink()) { throw new Error(label + ' path is not a physical file: ' + filePath); } - if (before.size > maxBytes) { + if (metadata.size > maxBytes) { throw new Error(label + ' exceeds the size limit: ' + filePath); } - if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) { + if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) { throw new Error(label + ' permissions are too broad: ' + filePath); } +} +function inspectProtectedFile(filePath, label, maxBytes) { + const metadata = fs.lstatSync(filePath); + validateProtectedMetadata(metadata, filePath, label, maxBytes); + return metadata; +} + +function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) { + const before = inspectProtectedFile(filePath, label, maxBytes); let flags = fs.constants.O_RDONLY; if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { flags |= fs.constants.O_NOFOLLOW; @@ -20,15 +33,7 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE const descriptor = fs.openSync(filePath, flags); try { const after = fs.fstatSync(descriptor); - if (!after.isFile()) { - throw new Error(label + ' path is not a physical file: ' + filePath); - } - if (after.size > maxBytes) { - throw new Error(label + ' exceeds the size limit: ' + filePath); - } - if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) { - throw new Error(label + ' permissions are too broad: ' + filePath); - } + validateProtectedMetadata(after, filePath, label, maxBytes); if (before.dev !== after.dev || before.ino !== after.ino) { throw new Error(label + ' changed while opening: ' + filePath); } @@ -41,4 +46,99 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE fs.closeSync(descriptor); } } + +function openProtectedAppend(filePath, label, maxBytes) { + for (let attempt = 0; attempt < 3; attempt += 1) { + let before; + try { + before = inspectProtectedFile(filePath, label, maxBytes); + } catch (error) { + if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error; + let createFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND | + fs.constants.O_CREAT | fs.constants.O_EXCL; + if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { + createFlags |= fs.constants.O_NOFOLLOW; + } + try { + const descriptor = fs.openSync(filePath, createFlags, 0o600); + const metadata = fs.fstatSync(descriptor); + validateProtectedMetadata(metadata, filePath, label, maxBytes); + return descriptor; + } catch (createError) { + if (hasRuntimeErrorCode(createError, 'EEXIST')) continue; + throw createError; + } + } + + let flags = fs.constants.O_WRONLY | fs.constants.O_APPEND; + if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { + flags |= fs.constants.O_NOFOLLOW; + } + const descriptor = fs.openSync(filePath, flags); + try { + const after = fs.fstatSync(descriptor); + validateProtectedMetadata(after, filePath, label, maxBytes); + if (before.dev !== after.dev || before.ino !== after.ino) { + throw new Error(label + ' changed while opening: ' + filePath); + } + return descriptor; + } catch (error) { + fs.closeSync(descriptor); + throw error; + } + } + throw new Error(label + ' changed repeatedly while opening: ' + filePath); +} + +function appendProtectedText(filePath, label, text, maxBytes = MAX_PROTECTED_LOG_BYTES) { + const descriptor = openProtectedAppend(filePath, label, maxBytes); + try { + const metadata = fs.fstatSync(descriptor); + const bytes = Buffer.byteLength(text, 'utf-8'); + if (metadata.size + bytes > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + fs.writeFileSync(descriptor, text, 'utf-8'); + fs.fsyncSync(descriptor); + if (process.platform !== 'win32') fs.fchmodSync(descriptor, 0o600); + } finally { + fs.closeSync(descriptor); + } +} + +function writeProtectedJson(filePath, label, value) { + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded, 'utf-8') > MAX_PROTECTED_CONFIG_BYTES) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + const temporaryPath = filePath + '.' + process.pid + '.' + + crypto.randomBytes(8).toString('hex') + '.tmp'; + let descriptor; + try { + descriptor = fs.openSync(temporaryPath, 'wx', 0o600); + fs.writeFileSync(descriptor, encoded, 'utf-8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + if (process.platform !== 'win32') fs.chmodSync(temporaryPath, 0o600); + try { + inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES); + } catch (error) { + if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error; + } + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error; + inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES); + fs.unlinkSync(filePath); + fs.renameSync(temporaryPath, filePath); + } + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { fs.unlinkSync(temporaryPath); } catch (error) { + if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error; + } + } +} `; diff --git a/src/plugins/strict-json.ts b/src/plugins/strict-json.ts new file mode 100644 index 0000000..286025d --- /dev/null +++ b/src/plugins/strict-json.ts @@ -0,0 +1,57 @@ +import { + parse, + parseTree, + printParseErrorCode, + type Node, + type ParseError, +} from 'jsonc-parser'; + +export type JsonObject = Record; + +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function rejectDuplicateKeys( + node: Node | undefined, + label: string, + location: string[] = [], +): void { + if (!node) return; + if (node.type === 'object') { + const keys = new Set(); + for (const property of node.children ?? []) { + const key = String(property.children?.[0]?.value); + const childLocation = [...location, key]; + if (keys.has(key)) { + throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); + } + keys.add(key); + rejectDuplicateKeys(property.children?.[1], label, childLocation); + } + return; + } + if (node.type === 'array') { + for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); + } +} + +export function parseStrictJsonObject(raw: string, label: string): JsonObject { + const errors: ParseError[] = []; + const value: unknown = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: true, + }); + if (errors.length > 0) { + const details = errors + .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) + .join(', '); + throw new Error(`Failed to parse ${label}: ${details}`); + } + if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); + rejectDuplicateKeys(parseTree(raw, [], { + allowTrailingComma: false, + disallowComments: true, + }), label); + return value; +} diff --git a/test-support/codex-test-helpers.mjs b/test-support/codex-test-helpers.mjs new file mode 100644 index 0000000..b9e3107 --- /dev/null +++ b/test-support/codex-test-helpers.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/codex-installation.js'), +).href; + +export function runNode(args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, CODEX_HOME: '', ...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); + }); +} + +export function runHook(handler, input, fixture, environment = {}) { + const command = process.platform === 'win32' ? handler.commandWindows : handler.command; + return new Promise((resolve, reject) => { + const child = process.platform === 'win32' + ? spawn(command, { + shell: true, + cwd: fixture.projectDir, + env: { + ...process.env, + CODEX_HOME: '', + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ...environment, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + : spawn('/bin/sh', ['-c', command], { + cwd: fixture.projectDir, + env: { + ...process.env, + CODEX_HOME: '', + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ...environment, + }, + 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); + }); +} + +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'codex', + orgId: 'org-1', + agentId: 'agent-1', + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export async function runPlugin(fixture, method, argument, environment = {}) { + const source = ` + 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', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + ...environment, + }, + fixture.projectDir, + ); +} + +export async function createFixture({ existingConfig, baseUrl = 'http://127.0.0.1:9' } = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %ELYDORA_HOOK_PATH%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const configPath = path.join(homeDir, '.codex', 'hooks.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(projectDir, { recursive: true }); + if (existingConfig !== undefined) await writeJson(configPath, existingConfig); + return { + agentDir, + baseUrl, + configPath, + guardScriptPath, + homeDir, + hookScriptPath, + projectDir, + rootDir, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export function managedHandler(config, event, statusMessage) { + for (const group of config.hooks?.[event] ?? []) { + const handler = group.hooks?.find((item) => item.statusMessage === statusMessage); + if (handler) return { group, handler }; + } + return undefined; +} + +export function assertNativeHandler(value, statusMessage) { + assert(value); + assert.deepEqual(Object.keys(value.group).sort(), ['hooks', 'matcher']); + assert.equal(value.group.matcher, '*'); + assert.deepEqual( + Object.keys(value.handler).sort(), + ['command', 'commandWindows', 'statusMessage', 'timeout', 'type'], + ); + assert.equal(value.handler.type, 'command'); + assert.equal(value.handler.timeout, 10); + assert.equal(value.handler.statusMessage, statusMessage); + assert.match(value.handler.command, /node(?:\.exe)?/i); + assert.match(value.handler.commandWindows, /^"[^"]+powershell\.exe" .* -EncodedCommand /i); +} + +export function legacyHandler(scriptPath, statusMessage) { + const quotePosix = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + return { + type: 'command', + command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, + commandWindows: `"${process.execPath}" "${scriptPath}"`, + timeout: 10, + statusMessage, + }; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ + headers: request.headers, + method: request.method, + url: request.url, + raw, + }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/codex-plugin.test.mjs b/test/codex-plugin.test.mjs index 60f3d84..f997de0 100644 --- a/test/codex-plugin.test.mjs +++ b/test/codex-plugin.test.mjs @@ -1,120 +1,35 @@ 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 { + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { + VALID_PRIVATE_KEY, + assertNativeHandler, + createFixture, + installConfig, + legacyHandler, + managedHandler, + registryModuleUrl, + runNode, + runPlugin, + writeJson, +} from '../test-support/codex-test-helpers.mjs'; -const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex.js')).href; -const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +const GUARD_STATUS = 'Checking Elydora agent state'; +const AUDIT_STATUS = 'Recording Elydora tool use'; -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 () => { +test('Codex is registered with the native user hooks file', async () => { const { SUPPORTED_AGENTS } = await import(registryModuleUrl); assert.deepEqual(SUPPORTED_AGENTS.get('codex'), { name: 'OpenAI Codex', @@ -123,162 +38,162 @@ test('Codex is registered as a native hook integration', async () => { }); }); -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 }); +test('Codex follows and canonicalizes the official CODEX_HOME root', async () => { + const fixture = await createFixture(); 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, + const target = path.join(fixture.rootDir, 'custom Codex home'); + const configured = path.join(fixture.rootDir, 'codex-home-link'); + await mkdir(target, { recursive: true }); + let codexHome = target; + try { + await symlink(target, configured, 'junction'); + codexHome = configured; + } catch (error) { + if (error?.code !== 'EPERM') throw error; + } + const install = await runPlugin( + fixture, + 'install', + installConfig(fixture), + { CODEX_HOME: codexHome }, + ); + assert.equal(install.code, 0, install.stderr); + const expectedPath = path.join(await realpath(target), 'hooks.json'); + assert.match(await readFile(expectedPath, 'utf-8'), /PreToolUse/); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + + const status = await runPlugin(fixture, 'status', null, { CODEX_HOME: codexHome }); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).configPath, expectedPath); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } +}); + +test('Codex rejects missing and file-backed CODEX_HOME values before writes', async () => { + const fixture = await createFixture(); + try { + const missing = path.join(fixture.rootDir, 'missing-codex-home'); + let result = await runPlugin( + fixture, + 'install', + installConfig(fixture), + { CODEX_HOME: missing }, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /Resolve CODEX_HOME/i); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); + + const filePath = path.join(fixture.rootDir, 'codex-home-file'); + await writeFile(filePath, 'file'); + result = await runPlugin( + fixture, + 'install', + installConfig(fixture), + { CODEX_HOME: filePath }, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /CODEX_HOME is not a directory/i); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Codex install preserves sources, migrates legacy handlers, and is idempotent', async () => { + const fixture = await createFixture(); + try { + await writeJson(fixture.configPath, { + description: 'User hooks', + custom: { keep: true }, + hooks: { + SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'keep' }] }], + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-pre' }] }, + { matcher: '*', hooks: [legacyHandler(fixture.guardScriptPath, GUARD_STATUS)] }, + ], + PostToolUse: [ + { matcher: '*', hooks: [legacyHandler(fixture.hookScriptPath, AUDIT_STATUS)] }, + ], + }, }); - assert.equal(secondInstall.code, 0, secondInstall.stderr); + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /run \/hooks and approve both/i); + const second = await fixture.install(); + assert.equal(second.code, 0, second.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, '*'); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(config.description, 'User hooks'); + assert.deepEqual(config.custom, { keep: true }); + assert.equal(config.hooks.SessionStart[0].hooks[0].command, 'keep'); + assert.equal(config.hooks.PreToolUse.length, 2); + assert.equal(config.hooks.PostToolUse.length, 1); + assert.deepEqual(config.hooks.PreToolUse[0].hooks, [{ type: 'command', command: 'user-pre' }]); + assertNativeHandler(managedHandler(config, 'PreToolUse', GUARD_STATUS), GUARD_STATUS); + assertNativeHandler(managedHandler(config, 'PostToolUse', AUDIT_STATUS), AUDIT_STATUS); } 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 rejects malformed and ambiguous JSON before creating runtime files', async () => { + const invalidConfigs = [ + '{ malformed', + '[]\n', + '{"hooks":null}\n', + '{"hooks":{"PreToolUse":null}}\n', + '{"hooks":{"PreToolUse":[null]}}\n', + '{"hooks":{"PreToolUse":[{"hooks":null}]}}\n', + '{"hooks":{"PreToolUse":[{"hooks":[null]}]}}\n', + '{"hooks":{},"hooks":{}}\n', + '{"hooks":{},}\n', + ]; + for (const existingConfig of invalidConfigs) { + const fixture = await createFixture({ existingConfig }); + try { + const before = await readFile(fixture.configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1, `accepted ${existingConfig}`); + assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } } }); -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' }] }] }, - } }); +test('Codex status requires an exact hook pair, matching identity, and runtime secrets', async () => { + const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const status = await runPlugin(fixture.homeDir, 'status', null); + assert.equal((await fixture.install()).code, 0); + let status = await runPlugin(fixture, 'status', null); assert.equal(status.code, 0, status.stderr); assert.equal(JSON.parse(status.stdout).installed, true); - await rm(fixture.guardScriptPath); - const degradedStatus = await runPlugin(fixture.homeDir, 'status', null); - assert.equal(JSON.parse(degradedStatus.stdout).installed, false); + const keyPath = path.join(fixture.agentDir, 'private.key'); + await rm(keyPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.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(); - } -}); + assert.equal((await fixture.install()).code, 0); + const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + managedHandler(hooks, 'PreToolUse', GUARD_STATUS).group.matcher = 'Bash'; + await writeJson(fixture.configPath, hooks); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).hookConfigured, false); -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((await fixture.install()).code, 0); + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_id: 'another-agent', + agent_name: 'codex', }); - 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 }); - } -}); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); -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); + status = await runPlugin(fixture, 'status', null); assert.equal(status.code, 1); assert.match(status.stderr, /parse Elydora runtime config/i); } finally { @@ -286,58 +201,237 @@ test('Codex status surfaces malformed referenced runtime metadata', async () => } }); -test('Codex status surfaces malformed hook matcher groups', async () => { - const fixture = await createFixture(); +test('Codex uninstall removes exact ownership and preserves lookalike handlers', async () => { + const fixture = await createFixture({ + existingConfig: { + hooks: { + SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'keep' }] }], + }, + }, + }); 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); + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const otherGuard = legacyHandler( + path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js'), + GUARD_STATUS, + ); + const otherAudit = legacyHandler( + path.join(fixture.homeDir, '.elydora', 'agent-10', 'hook.js'), + AUDIT_STATUS, + ); + const lookalike = { + type: 'command', + command: `inspect ${fixture.guardScriptPath}`, + commandWindows: `inspect ${fixture.guardScriptPath}`, + timeout: 10, + statusMessage: GUARD_STATUS, + }; + const modifiedGroup = { + matcher: 'Bash', + hooks: [structuredClone(managedHandler(config, 'PreToolUse', GUARD_STATUS).handler)], + }; + config.hooks.PreToolUse.push( + { matcher: '*', hooks: [otherGuard] }, + { matcher: '*', hooks: [lookalike] }, + modifiedGroup, + ); + config.hooks.PostToolUse.push({ matcher: '*', hooks: [otherAudit] }); + await writeJson(fixture.configPath, config); + + 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.equal(remaining.hooks.SessionStart[0].hooks[0].command, 'keep'); + assert.equal(remaining.hooks.PreToolUse.length, 3); + assert.equal(remaining.hooks.PostToolUse.length, 1); + assert.match(remaining.hooks.PreToolUse[0].hooks[0].command, /agent-10/); + assert.deepEqual(remaining.hooks.PreToolUse[1].hooks[0], lookalike); + assert.deepEqual(remaining.hooks.PreToolUse[2], { matcher: 'Bash', hooks: [] }); } 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 }); +test('Codex confines generated runtimes to the managed agent directory', async () => { + for (const field of ['guardScriptPath', 'hookScriptPath']) { + const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 1); - assert.deepEqual( - JSON.parse(await readFile(fixture.configPath, 'utf-8')), - existingSettings, - ); + const result = await runPlugin(fixture, 'install', installConfig(fixture, { + [field]: path.join(fixture.homeDir, `unmanaged-${field}.js`), + })); + assert.equal(result.code, 1); + assert.match(result.stderr, /managed agent directory/i); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); } finally { await fixture.close(); } } }); -test('Codex install preserves malformed config for recovery', async () => { - const fixture = await createFixture({ existingSettings: '{ malformed' }); +test('Codex validates runtime identity, credentials, and API origin before writes', async () => { + const invalidOverrides = [ + { agentName: 'cursor' }, + { privateKey: 'invalid' }, + { token: '' }, + { baseUrl: 'file:///tmp/elydora' }, + { baseUrl: 'https://user:secret@api.elydora.com' }, + { baseUrl: 'https://api.elydora.com?tenant=one' }, + ]; + for (const overrides of invalidOverrides) { + const fixture = await createFixture(); + try { + const result = await runPlugin(fixture, 'install', installConfig(fixture, overrides)); + assert.equal(result.code, 1, JSON.stringify(overrides)); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } + } +}); + +test('Codex rejects linked hook and runtime files', async (t) => { + for (const kind of ['hooks', 'config', 'key', 'guard', 'audit']) { + const fixture = await createFixture(); + try { + if (kind === 'hooks') { + const target = path.join(fixture.homeDir, 'hooks-target.json'); + const original = '{"hooks":{}}\n'; + await mkdir(path.dirname(fixture.configPath), { recursive: true }); + await writeFile(target, original); + try { + await symlink(target, fixture.configPath); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical file/i); + assert.equal(await readFile(target, 'utf-8'), original); + continue; + } + + assert.equal((await fixture.install()).code, 0); + const filePath = { + config: path.join(fixture.agentDir, 'config.json'), + key: path.join(fixture.agentDir, 'private.key'), + guard: fixture.guardScriptPath, + audit: fixture.hookScriptPath, + }[kind]; + const contents = await readFile(filePath); + const target = path.join(fixture.homeDir, `${kind}-target`); + await writeFile(target, contents); + await rm(filePath); + try { + await symlink(target, filePath); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /physical file/i); + } finally { + await fixture.close(); + } + } +}); + +test('Codex removes an entirely managed file and leaves absent hooks absent', async () => { + const fixture = await createFixture(); 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'); + assert.equal((await fixture.install()).code, 0); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Codex uninstall rejects a linked default hooks directory', async (t) => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const codexDirectory = path.dirname(fixture.configPath); + const target = path.join(fixture.rootDir, 'codex-hooks-target'); + await rename(codexDirectory, target); + try { + await symlink(target, codexDirectory, 'junction'); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical directory/i); + assert.match(await readFile(path.join(target, 'hooks.json'), 'utf-8'), /PreToolUse/); + } finally { + await fixture.close(); + } +}); + +test('Codex installation protects credentials and cleans transaction files', async () => { + const fixture = await createFixture(); + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.configPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); + if (process.platform !== 'win32') { + for (const filePath of [ + fixture.configPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + ]) { + assert.equal((await stat(filePath)).mode & 0o777, 0o600); + } + for (const filePath of [fixture.guardScriptPath, fixture.hookScriptPath]) { + assert.equal((await stat(filePath)).mode & 0o777, 0o700); + } + } + } finally { + await fixture.close(); + } +}); + +test('Codex CLI preflight preserves malformed hooks before runtime creation', async () => { + const fixture = await createFixture({ existingConfig: '{ malformed' }); + try { + const privateKeyPath = path.join(fixture.rootDir, 'private.key'); + await writeFile(privateKeyPath, VALID_PRIVATE_KEY, { mode: 0o600 }); + const result = await runNode([ + path.resolve('dist/cli.js'), + 'install', + '--agent', 'codex', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--private_key_file', privateKeyPath, + '--kid', 'kid-1', + '--base_url', fixture.baseUrl, + ], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + }, fixture.projectDir); + assert.equal(result.code, 1); + assert.match(result.stderr, /Codex user hooks/i); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); } finally { await fixture.close(); } diff --git a/test/codex-runtime.test.mjs b/test/codex-runtime.test.mjs new file mode 100644 index 0000000..45e0c5f --- /dev/null +++ b/test/codex-runtime.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import { readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + runHook, + startApiServer, +} from '../test-support/codex-test-helpers.mjs'; + +const GUARD_STATUS = 'Checking Elydora agent state'; +const AUDIT_STATUS = 'Recording Elydora tool use'; + +function officialPayload(event, overrides = {}) { + return { + session_id: 'session-1', + transcript_path: null, + cwd: 'C:\\workspace', + hook_event_name: event, + model: 'gpt-5.4', + turn_id: 'turn-1', + permission_mode: 'default', + tool_name: 'Bash', + tool_use_id: 'call-1', + tool_input: { command: 'echo test' }, + ...overrides, + }; +} + +test('Codex runtimes enforce active state and preserve official event JSON', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler; + const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler; + + const pre = officialPayload('PreToolUse'); + const guardResult = await runHook(guard, JSON.stringify(pre), fixture); + assert.equal(guardResult.code, 0, guardResult.stderr); + assert.equal(guardResult.stdout, ''); + assert.equal(api.requests[0].method, 'GET'); + assert.equal(api.requests[0].url, '/v1/agents/agent-1'); + assert.equal(api.requests[0].headers.authorization, 'Bearer token-1'); + + const success = officialPayload('PostToolUse', { + tool_response: { output: 'test', exit_code: 0 }, + }); + const successResult = await runHook(audit, JSON.stringify(success), fixture); + assert.equal(successResult.code, 0, successResult.stderr); + assert.equal(successResult.stdout, ''); + + const failure = officialPayload('PostToolUse', { + tool_use_id: 'call-2', + tool_input: { command: 'exit 7' }, + tool_response: { output: '', stderr: 'failed', exit_code: 7 }, + }); + const failureResult = await runHook(audit, JSON.stringify(failure), fixture); + assert.equal(failureResult.code, 0, failureResult.stderr); + assert.equal(failureResult.stdout, ''); + + const operations = api.requests + .filter((request) => request.method === 'POST') + .map((request) => JSON.parse(request.raw)); + assert.equal(operations.length, 2); + assert.deepEqual(operations[0].payload, success); + assert.deepEqual(operations[1].payload, failure); + assert.equal(operations[0].subject.session_id, 'session-1'); + assert.equal(operations[0].action.tool, 'Bash'); + assert.equal(api.requests.at(-1).headers.authorization, 'Bearer token-1'); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Codex guard propagates exit code 2 for frozen and revoked agents', async () => { + for (const status of ['frozen', 'revoked']) { + const api = await startApiServer({ status }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler; + const result = await runHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + fixture, + ); + assert.equal(result.code, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, new RegExp(`agent \\"codex\\" is ${status}`, 'i')); + } finally { + await fixture.close(); + await api.close(); + } + } +}); + +test('Codex fail-open guard reports malformed input, config, and status data', async () => { + const activeApi = await startApiServer(); + const activeFixture = await createFixture({ baseUrl: activeApi.baseUrl }); + try { + assert.equal((await activeFixture.install()).code, 0); + const config = JSON.parse(await readFile(activeFixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler; + const malformed = await runHook(guard, '{ malformed', activeFixture); + assert.equal(malformed.code, 0); + assert.match(malformed.stderr, /invalid JSON.*fail-open/i); + + await rm(path.join(activeFixture.agentDir, 'config.json')); + const missingConfig = await runHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + activeFixture, + ); + assert.equal(missingConfig.code, 0); + assert.match(missingConfig.stderr, /failed to read agent config/i); + } finally { + await activeFixture.close(); + await activeApi.close(); + } + + const invalidApi = await startApiServer({ status: 'unexpected' }); + const invalidFixture = await createFixture({ baseUrl: invalidApi.baseUrl }); + try { + assert.equal((await invalidFixture.install()).code, 0); + const config = JSON.parse(await readFile(invalidFixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler; + const result = await runHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + invalidFixture, + ); + assert.equal(result.code, 0); + assert.match(result.stderr, /invalid agent status/i); + } finally { + await invalidFixture.close(); + await invalidApi.close(); + } +}); + +test('Codex fail-open audit records malformed input and API failures', async () => { + const api = await startApiServer({ operationStatus: 503 }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler; + + const malformed = await runHook(audit, '{ malformed', fixture); + assert.equal(malformed.code, 0); + let log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'); + assert.match(log, /Hook input is invalid JSON/i); + + const failedApi = await runHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { exit_code: 0 } })), + fixture, + ); + assert.equal(failedApi.code, 0); + log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'); + assert.match(log, /Audit API returned HTTP 503/i); + await assert.rejects(readFile(path.join(fixture.agentDir, 'chain-state.json')), { + code: 'ENOENT', + }); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Codex runtimes reject linked cache, chain, and error state', async (t) => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler; + const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler; + + const cacheTarget = path.join(fixture.rootDir, 'cache-target'); + const cachePath = path.join(fixture.agentDir, 'status-cache.json'); + await writeFile(cacheTarget, 'cache sentinel', { mode: 0o600 }); + try { + await symlink(cacheTarget, cachePath); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + const guardResult = await runHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + fixture, + ); + assert.equal(guardResult.code, 0); + assert.match(guardResult.stderr, /Status cache path is not a physical file/i); + assert.equal(await readFile(cacheTarget, 'utf-8'), 'cache sentinel'); + await rm(cachePath); + + const chainTarget = path.join(fixture.rootDir, 'chain-target'); + const chainPath = path.join(fixture.agentDir, 'chain-state.json'); + await writeFile(chainTarget, 'chain sentinel', { mode: 0o600 }); + await symlink(chainTarget, chainPath); + const auditResult = await runHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { exit_code: 0 } })), + fixture, + ); + assert.equal(auditResult.code, 0); + assert.equal(await readFile(chainTarget, 'utf-8'), 'chain sentinel'); + assert.match( + await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), + /Chain state path is not a physical file/i, + ); + await rm(chainPath); + + const errorTarget = path.join(fixture.rootDir, 'error-target'); + const errorPath = path.join(fixture.agentDir, 'error.log'); + await writeFile(errorTarget, 'error sentinel', { mode: 0o600 }); + await rm(errorPath); + await symlink(errorTarget, errorPath); + const errorResult = await runHook(audit, '{ malformed', fixture); + assert.equal(errorResult.code, 0); + assert.match(errorResult.stderr, /Error log path is not a physical file/i); + assert.equal(await readFile(errorTarget, 'utf-8'), 'error sentinel'); + } finally { + await fixture.close(); + await api.close(); + } +}); diff --git a/test/codex-transaction.test.mjs b/test/codex-transaction.test.mjs new file mode 100644 index 0000000..6d15076 --- /dev/null +++ b/test/codex-transaction.test.mjs @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + installationModuleUrl, + installConfig, + ioModuleUrl, + runNode, + writeJson, +} from '../test-support/codex-test-helpers.mjs'; + +const managedFileNames = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function snapshotInstallation(fixture) { + const entries = await Promise.all(managedFileNames.map(async (name) => [ + path.join(fixture.agentDir, name), + await readFile(path.join(fixture.agentDir, name), 'utf-8'), + ])); + entries.push([fixture.configPath, await readFile(fixture.configPath, 'utf-8')]); + return new Map(entries); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, expected] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), expected, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.configPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); +} + +test('Codex rolls back all five files when a transaction commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = ` + import { rename } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readDocument(); + const prepared = await prepareCodexInstallation(config, { document, changed: false }); + let calls = 0; + await commitCodexInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 3) throw new Error('injected rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { + orgId: 'org-updated', + token: 'token-updated', + })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Codex detects concurrent hook changes before committing runtime updates', async () => { + const fixture = await createFixture({ + existingConfig: { + hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ command: 'original' }] }] }, + }, + }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"hooks":{"SessionStart":[{"matcher":"resume","hooks":[{"command":"concurrent"}]}]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readDocument(); + const prepared = await prepareCodexInstallation(config, { document, changed: false }); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitCodexInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_CONCURRENT: concurrent, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Codex installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected); + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Codex rejects a stale hook document before staging files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"hooks":{"SessionStart":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { prepareCodexInstallation } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await prepareCodexInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_CONCURRENT: concurrent, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture)), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected); + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Codex rejects orphaned runtime artifacts before writing hooks', async () => { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity cannot be verified/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n'); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Codex rejects mismatched runtime identity before changing files', async () => { + const fixture = await createFixture(); + try { + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_name: 'codex', + agent_id: 'another-agent', + }); + const configPath = path.join(fixture.agentDir, 'config.json'); + const before = await readFile(configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity does not match/i); + assert.equal(await readFile(configPath, 'utf-8'), before); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Codex rejects linked hooks and runtime directories before writes', async (t) => { + for (const kind of ['codex', 'runtime']) { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + const link = kind === 'codex' + ? path.join(fixture.homeDir, '.codex') + : path.join(fixture.homeDir, '.elydora'); + await mkdir(fixture.homeDir, { recursive: true }); + try { + await symlink(target, link, 'junction'); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical directory/i); + assert.deepEqual(await readdir(target), []); + const untouched = kind === 'codex' + ? path.join(fixture.homeDir, '.elydora') + : fixture.configPath; + await assert.rejects(lstat(untouched), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } + } +}); diff --git a/test/guard-template.test.mjs b/test/guard-template.test.mjs index 0d11953..62baa22 100644 --- a/test/guard-template.test.mjs +++ b/test/guard-template.test.mjs @@ -43,11 +43,11 @@ async function createFixture(status) { }; } -function runGuard(scriptPath, homeDir) { +function runGuard(scriptPath, homeDir, input = '{}') { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [scriptPath], { env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; @@ -55,6 +55,7 @@ function runGuard(scriptPath, homeDir) { child.stderr.on('data', (chunk) => { stderr += chunk; }); child.once('error', reject); child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); }); } @@ -101,3 +102,16 @@ test('cached frozen status keeps the blocking exit code', async () => { await fixture.close(); } }); + +test('fail-open guards report malformed hook input', async () => { + const fixture = await createFixture('active'); + try { + const result = await runGuard(fixture.scriptPath, fixture.homeDir, '{ malformed'); + + assert.equal(result.code, 0); + assert.match(result.stderr, /invalid JSON.*fail-open/i); + assert.equal(result.stdout, ''); + } finally { + await fixture.close(); + } +});