fix: make Codex hooks transactional
This commit is contained in:
1 parent
6a7fdb7595
commit
a9adba1fc1
19 files changed
+2525
-1114
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string, JsonObject[]>;
|
||||
|
||||
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<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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<CodexRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
|
||||
hooksPath,
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareCodexInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedDocument,
|
||||
): Promise<PreparedCodexInstallation> {
|
||||
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<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
return path.join(await codexHomePath(), CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function readDocument(): Promise<CodexDocument> {
|
||||
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<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath: string, contents: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
+43
-292
@@ -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<string, unknown>;
|
||||
|
||||
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<JsonObject | undefined> {
|
||||
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<JsonObject | undefined> {
|
||||
return readJsonObject(configPath, 'Codex hooks config');
|
||||
}
|
||||
|
||||
async function failWrite(
|
||||
handle: FileHandle | undefined,
|
||||
tempPath: string,
|
||||
label: string,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
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<void> {
|
||||
const directory = path.dirname(configPath);
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`);
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fsp.open(tempPath, 'wx', 0o600);
|
||||
await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await fsp.rename(tempPath, configPath);
|
||||
} catch (error) {
|
||||
await failWrite(handle, tempPath, `Codex hooks config at ${configPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSettings(configPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT')) return;
|
||||
throw new Error(`Remove Codex hooks config at ${configPath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
try {
|
||||
const metadata = await fsp.stat(filePath);
|
||||
return metadata.isFile();
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT')) return false;
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function requireRuntime(filePath: string, label: string): Promise<void> {
|
||||
if (!filePath) throw new Error(`${label} path is required`);
|
||||
if (!await regularFileExists(filePath, label)) {
|
||||
throw new Error(`${label} is missing: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runtimeScriptsExist(guard: JsonObject, audit: JsonObject): Promise<boolean> {
|
||||
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<typeof buildHandler>): Record<string, unknown> {
|
||||
return { matcher: '*', hooks: [handler] };
|
||||
}
|
||||
|
||||
export const codexPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
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<void> {
|
||||
const document = await readDocument();
|
||||
await preflightCodexInstallation(config, document.filePath);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
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<void> {
|
||||
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<PluginStatus> {
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
export type CursorHooks = Record<string, JsonObject[]>;
|
||||
|
||||
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<string>();
|
||||
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`);
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
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<FileSnapshot | undefined> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<CursorRuntimePaths> {
|
||||
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<FileChange> {
|
||||
return {
|
||||
filePath,
|
||||
label,
|
||||
next,
|
||||
mode,
|
||||
original: await readPhysicalFile(filePath, label, maximumBytes),
|
||||
};
|
||||
export async function preflightCursorInstallation(
|
||||
config: InstallConfig,
|
||||
): Promise<CursorRuntimePaths> {
|
||||
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<PreparedCursorInstallation> {
|
||||
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<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(change: FileChange): Promise<void> {
|
||||
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<void> {
|
||||
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<StagedChange> {
|
||||
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<void> {
|
||||
await assertUnchanged(staged.change);
|
||||
await renameFile(staged.temporaryPath, staged.change.filePath);
|
||||
staged.committed = true;
|
||||
}
|
||||
|
||||
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
|
||||
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<void> {
|
||||
await Promise.all(
|
||||
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureCursorDirectory(directory: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<FileSnapshot | undefined> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<void> {
|
||||
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<ManagedRuntimePaths> {
|
||||
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<FileChange> {
|
||||
return {
|
||||
filePath,
|
||||
label,
|
||||
next,
|
||||
mode,
|
||||
maximumBytes,
|
||||
original: await readPhysicalFile(filePath, label, maximumBytes),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareManagedInstallation(
|
||||
spec: ManagedInstallationSpec,
|
||||
guardScript: string,
|
||||
auditScript: string,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(change: FileChange, displayName: string): Promise<void> {
|
||||
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<void> {
|
||||
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<StagedChange> {
|
||||
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<void> {
|
||||
await assertUnchanged(staged.change, displayName);
|
||||
await renameFile(staged.temporaryPath, staged.change.filePath);
|
||||
staged.committed = true;
|
||||
}
|
||||
|
||||
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
|
||||
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<void> {
|
||||
await Promise.all(
|
||||
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureManagedDirectory(directory: string, label: string): Promise<void> {
|
||||
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<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
parse,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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());
|
||||
}),
|
||||
};
|
||||
}
|
||||
+380
-286
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user