From 586ffa0278e4114002a67b18c71acca2193f0d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 07:47:16 -0400 Subject: [PATCH] fix(gemini): implement official hook contract --- AGENTS.md | 1 + README.md | 2 + src/plugins/gemini-command.ts | 164 +++++++++++++ src/plugins/gemini-config.ts | 211 +++++++++++++++++ src/plugins/gemini-contract.ts | 302 ++++++++++++++++++++++++ src/plugins/gemini-installation.ts | 63 +++++ src/plugins/gemini-io.ts | 149 ++++++++++++ src/plugins/gemini.ts | 210 ++++++----------- test-support/gemini-test-helpers.mjs | 235 +++++++++++++++++++ test/gemini-official.test.mjs | 48 ++++ test/gemini-plugin.test.mjs | 338 +++++++++++++++++++++++++++ test/gemini-runtime.test.mjs | 148 ++++++++++++ test/gemini-transaction.test.mjs | 221 ++++++++++++++++++ 13 files changed, 1950 insertions(+), 142 deletions(-) create mode 100644 src/plugins/gemini-command.ts create mode 100644 src/plugins/gemini-config.ts create mode 100644 src/plugins/gemini-contract.ts create mode 100644 src/plugins/gemini-installation.ts create mode 100644 src/plugins/gemini-io.ts create mode 100644 test-support/gemini-test-helpers.mjs create mode 100644 test/gemini-official.test.mjs create mode 100644 test/gemini-plugin.test.mjs create mode 100644 test/gemini-runtime.test.mjs create mode 100644 test/gemini-transaction.test.mjs diff --git a/AGENTS.md b/AGENTS.md index dca56a7..6f07a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Resolve stable Kimi hooks through `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and treat an empty override as the default. Activate `~/.kimi/config.toml` only when the legacy home exists. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules with ten-second commands; stable Kimi supports its current sixteen-event schema and legacy kimi-cli supports its thirteen-event schema. Preserve the native snake_case payload, propagate exit code `2`, and keep audit delivery fail-open with owner-only error logs. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit every detected config, generated runtime, runtime config, and private key through one rollback-capable transaction. - Resolve Grok Build user hooks through `$GROK_HOME/hooks/*.json` with `~/.grok/hooks/*.json` as the default, and treat an empty override as the default. Keep Claude Code, Cursor, project `.grok/hooks`, plugin, and `hooks-paths` sources read-only. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second command handlers, preserve the complete native camelCase payload, and emit Grok's documented deny JSON plus exit code `2` for frozen or revoked agents. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit Grok's hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. - Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit Claude Code's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects. +- Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit Gemini CLI's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects. - 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. - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell. diff --git a/README.md b/README.md index d3dafdc..2c736b4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to 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. +Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source. + Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control. Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change. diff --git a/src/plugins/gemini-command.ts b/src/plugins/gemini-command.ts new file mode 100644 index 0000000..c9e891f --- /dev/null +++ b/src/plugins/gemini-command.ts @@ -0,0 +1,164 @@ +import os from 'node:os'; +import path from 'node:path'; + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export interface GeminiRuntimeReference { + readonly agentId: string; + readonly scriptPath: string; +} + +export function sameGeminiPath(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; +} + +export function sameGeminiAgentId(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.env.SystemRoot; + 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 `& ${quotePowerShell(windowsPowerShellPath())} -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; +} + +export function buildGeminiCommand(scriptPath: string): string { + if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { + throw new Error('Gemini CLI hook commands require absolute executable and script paths'); + } + return process.platform === 'win32' + ? windowsCommand(scriptPath) + : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; +} + +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: string): readonly [string, string] | 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: string): readonly [string, string] | undefined { + if (!command.startsWith('& ')) return undefined; + const powershell = readPowerShellArgument(command, 2); + if (!powershell + || !path.win32.isAbsolute(powershell.value) + || path.win32.basename(powershell.value).toLowerCase() !== 'powershell.exe') return undefined; + const prefix = ' -NoLogo -NoProfile -NonInteractive -EncodedCommand '; + if (!command.startsWith(prefix, powershell.next)) return undefined; + const encoded = command.slice(powershell.next + prefix.length); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined; + const buffer = Buffer.from(encoded, 'base64'); + if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined; + return parsePowerShellSource(buffer.toString('utf16le')); +} + +function parseLegacyCommand(command: string): readonly [string, string] | undefined { + const match = /^node "([^"\r\n]+)"$/.exec(command); + return match ? ['node', match[1]] : undefined; +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath).toLowerCase(); + return basename === 'node' || basename === 'node.exe'; +} + +export function geminiRuntimeReference( + command: string, + scriptName: string, + includeLegacy = false, +): GeminiRuntimeReference | undefined { + const parsed = parsePosixCommand(command) + ?? parseWindowsCommand(command) + ?? (includeLegacy ? parseLegacyCommand(command) : undefined); + if (!parsed + || !isNodeExecutable(parsed[0]) + || !path.isAbsolute(parsed[1]) + || path.basename(parsed[1]) !== scriptName) return undefined; + if (parsed[0] !== 'node' && !path.isAbsolute(parsed[0])) return undefined; + const agentDirectory = path.dirname(parsed[1]); + if (!sameGeminiPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + if (!agentId || agentId === '.' || agentId === '..') return undefined; + return { agentId, scriptPath: parsed[1] }; +} diff --git a/src/plugins/gemini-config.ts b/src/plugins/gemini-config.ts new file mode 100644 index 0000000..c912efb --- /dev/null +++ b/src/plugins/gemini-config.ts @@ -0,0 +1,211 @@ +import { + applyEdits, + modify, + parse, + parseTree, + printParseErrorCode, + type FormattingOptions, + type JSONPath, + type Node, + type ParseError, +} from 'jsonc-parser'; +import { + MANAGED_EVENTS, + managedGeminiRemovals, + readGeminiHookControls, + readGeminiHooks, + type GeminiGroup, + type GeminiHookControls, + type GeminiHooks, + type ManagedGeminiEvent, +} from './gemini-contract.js'; +import { isObject, type JsonObject } from './strict-json.js'; + +export const GEMINI_OWNED_FILE_MARKER = '// Managed by Elydora'; + +export interface GeminiDocument { + readonly filePath: string; + readonly exists: boolean; + readonly raw: string; + readonly root: JsonObject; + readonly hooks: GeminiHooks; + readonly hookControls: GeminiHookControls; + readonly hasHooksContainer: boolean; + readonly ownedFile: boolean; +} + +export interface RenderedGeminiDocument { + readonly document: GeminiDocument; + readonly changed: boolean; + readonly next?: string; +} + +interface DocumentOptions { + readonly exists: boolean; + readonly filePath: string; + readonly raw: string; +} + +function rejectDuplicateKeys( + node: Node | undefined, + label: string, + location: string[] = [], +): void { + if (!node) return; + if (node.type === 'object') { + const keys = new Set(); + for (const property of node.children ?? []) { + const key = String(property.children?.[0]?.value); + const childLocation = [...location, key]; + if (keys.has(key)) { + throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); + } + keys.add(key); + rejectDuplicateKeys(property.children?.[1], label, childLocation); + } + return; + } + if (node.type === 'array') { + for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); + } +} + +function parseJsoncObject(raw: string, label: string): JsonObject { + const errors: ParseError[] = []; + const value: unknown = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: false, + }); + 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: false, + }), label); + return value; +} + +export function parseGeminiDocument(options: DocumentOptions): GeminiDocument { + const label = `Gemini CLI user settings at ${options.filePath}`; + const root = parseJsoncObject(options.raw, label); + const hasHooksContainer = Object.hasOwn(root, 'hooks'); + return { + ...options, + root, + hooks: readGeminiHooks(root.hooks), + hookControls: readGeminiHookControls(root.hooksConfig), + hasHooksContainer, + ownedFile: options.raw.startsWith(GEMINI_OWNED_FILE_MARKER), + }; +} + +export function createGeminiDocument(filePath: string): GeminiDocument { + return parseGeminiDocument({ + exists: false, + filePath, + raw: `${GEMINI_OWNED_FILE_MARKER}\n{}\n`, + }); +} + +function formatting(raw: string): FormattingOptions { + const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; + const insertSpaces = !indentation?.includes('\t'); + return { + eol: raw.includes('\r\n') ? '\r\n' : '\n', + insertSpaces, + tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, + }; +} + +function change( + raw: string, + jsonPath: JSONPath, + value: unknown, + isArrayInsertion = false, +): string { + return applyEdits(raw, modify(raw, jsonPath, value, { + formattingOptions: formatting(raw), + isArrayInsertion, + })); +} + +function currentDocument(document: GeminiDocument, raw: string): GeminiDocument { + return parseGeminiDocument({ + exists: document.exists, + filePath: document.filePath, + raw, + }); +} + +function removeManagedEntries( + document: GeminiDocument, + raw: string, + agentId?: string, +): string { + const removals = managedGeminiRemovals(document.hooks, agentId); + for (const event of MANAGED_EVENTS) { + const eventRemovals = removals + .filter((removal) => removal.event === event) + .sort((left, right) => right.groupIndex - left.groupIndex); + for (const removal of eventRemovals) { + const groupPath: JSONPath = ['hooks', event, removal.groupIndex]; + if (removal.removeGroup) { + raw = change(raw, groupPath, undefined); + continue; + } + for (const handlerIndex of [...removal.handlerIndexes].sort( + (left, right) => right - left, + )) { + raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + } + } + if (eventRemovals.length > 0) { + const current = currentDocument(document, raw); + if ((current.hooks[event] ?? []).length === 0) { + raw = change(raw, ['hooks', event], undefined); + } + } + } + const current = currentDocument(document, raw); + if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { + raw = change(raw, ['hooks'], undefined); + } + return raw; +} + +function appendGroup( + document: GeminiDocument, + raw: string, + event: ManagedGeminiEvent, + group: GeminiGroup, +): string { + const current = currentDocument(document, raw); + if (current.hooks[event]) { + return change(raw, ['hooks', event, current.hooks[event].length], group, true); + } + return change(raw, ['hooks', event], [group]); +} + +export function renderGeminiDocument( + document: GeminiDocument, + agentId: string | undefined, + additions: ReadonlyMap, +): RenderedGeminiDocument { + let raw = removeManagedEntries(document, document.raw, agentId); + for (const event of MANAGED_EVENTS) { + const group = additions.get(event); + if (group) raw = appendGroup(document, raw, event, group); + } + const current = currentDocument(document, raw); + if (additions.size === 0 + && document.ownedFile + && Object.keys(current.root).length === 0) { + return { document, changed: true, next: undefined }; + } + return { document, changed: raw !== document.raw, next: raw }; +} diff --git a/src/plugins/gemini-contract.ts b/src/plugins/gemini-contract.ts new file mode 100644 index 0000000..c521fb8 --- /dev/null +++ b/src/plugins/gemini-contract.ts @@ -0,0 +1,302 @@ +import { + buildGeminiCommand, + geminiRuntimeReference, + sameGeminiAgentId, + type GeminiRuntimeReference, +} from './gemini-command.js'; +import { isObject, type JsonObject } from './strict-json.js'; + +export const AGENT_KEY = 'gemini'; +export const CONFIG_FILE = 'settings.json'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const GUARD_HOOK_NAME = 'elydora-guard'; +export const AUDIT_HOOK_NAME = 'elydora-audit'; +export const HOOK_TIMEOUT_MS = 10_000; +export const MANAGED_EVENTS = ['BeforeTool', 'AfterTool'] as const; + +const KNOWN_EVENTS = new Set([ + 'BeforeTool', + 'AfterTool', + 'BeforeAgent', + 'Notification', + 'AfterAgent', + 'SessionStart', + 'SessionEnd', + 'PreCompress', + 'BeforeModel', + 'AfterModel', + 'BeforeToolSelection', +]); + +export type ManagedGeminiEvent = (typeof MANAGED_EVENTS)[number]; + +export interface GeminiHandler extends JsonObject { + readonly type: 'command'; + readonly command?: string; + readonly name?: string; + readonly timeout?: number; +} + +export interface GeminiGroup extends JsonObject { + readonly matcher?: string; + readonly sequential?: boolean; + readonly hooks: GeminiHandler[]; +} + +export type GeminiHooks = Record; + +export interface GeminiHookControls { + readonly enabled: boolean; + readonly disabled: readonly string[]; +} + +export interface GeminiRuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +export interface ManagedRemoval { + readonly event: ManagedGeminiEvent; + readonly groupIndex: number; + readonly handlerIndexes: readonly number[]; + readonly removeGroup: boolean; +} + +function optionalString(value: JsonObject, field: string, label: string): void { + if (value[field] !== undefined && typeof value[field] !== 'string') { + throw new Error(`${label} field "${field}" must be a string`); + } +} + +function validateTimeout(value: unknown, label: string): void { + if (value === undefined) return; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error(`${label} timeout must be a non-negative finite number`); + } +} + +function validateEnvironment(value: unknown, label: string): void { + if (value === undefined) return; + if (!isObject(value) + || Object.values(value).some((entry) => typeof entry !== 'string')) { + throw new Error(`${label} env must map names to strings`); + } +} + +function validateHandler( + value: unknown, + event: string, + groupIndex: number, + handlerIndex: number, +): GeminiHandler { + const label = `Gemini CLI settings handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.type !== 'command') { + throw new Error(`${label} has unsupported type "${String(value.type)}"`); + } + optionalString(value, 'name', label); + optionalString(value, 'description', label); + optionalString(value, 'source', label); + validateTimeout(value.timeout, label); + validateEnvironment(value.env, label); + if (typeof value.command !== 'string' || value.command.length === 0) { + throw new Error(`${label} requires a non-empty command`); + } + return value as GeminiHandler; +} + +function validateGroup(value: unknown, event: string, groupIndex: number): GeminiGroup { + const label = `Gemini CLI settings group hooks.${event}[${groupIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.matcher !== undefined && typeof value.matcher !== 'string') { + throw new Error(`${label} matcher must be a string`); + } + if (value.sequential !== undefined && typeof value.sequential !== 'boolean') { + throw new Error(`${label} sequential must be a boolean`); + } + if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`); + return { + ...value, + hooks: value.hooks.map( + (handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex), + ), + } as GeminiGroup; +} + +export function readGeminiHooks(value: unknown): GeminiHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error('Gemini CLI settings field "hooks" must be an object'); + const hooks: GeminiHooks = {}; + for (const [event, groups] of Object.entries(value)) { + if (!Array.isArray(groups)) { + throw new Error(`Gemini CLI settings field "hooks.${event}" must be an array`); + } + hooks[event] = KNOWN_EVENTS.has(event) + ? groups.map((group, index) => validateGroup(group, event, index)) + : groups as GeminiGroup[]; + } + return hooks; +} + +export function readGeminiHookControls(value: unknown): GeminiHookControls { + if (value === undefined) return { enabled: true, disabled: [] }; + if (!isObject(value)) { + throw new Error('Gemini CLI settings field "hooksConfig" must be an object'); + } + const supported = new Set(['enabled', 'disabled', 'notifications']); + const extra = Object.keys(value).find((field) => !supported.has(field)); + if (extra) { + throw new Error(`Gemini CLI settings field "hooksConfig" contains unsupported field "${extra}"`); + } + if (value.enabled !== undefined && typeof value.enabled !== 'boolean') { + throw new Error('Gemini CLI settings field "hooksConfig.enabled" must be a boolean'); + } + if (value.notifications !== undefined && typeof value.notifications !== 'boolean') { + throw new Error('Gemini CLI settings field "hooksConfig.notifications" must be a boolean'); + } + if (value.disabled !== undefined && ( + !Array.isArray(value.disabled) + || value.disabled.some((item) => typeof item !== 'string') + )) { + throw new Error('Gemini CLI settings field "hooksConfig.disabled" must be an array of strings'); + } + return { + enabled: value.enabled !== false, + disabled: value.disabled === undefined ? [] : value.disabled as string[], + }; +} + +export function managedGeminiHooksEnabled(controls: GeminiHookControls): boolean { + return controls.enabled + && !controls.disabled.includes(GUARD_HOOK_NAME) + && !controls.disabled.includes(AUDIT_HOOK_NAME); +} + +export function disabledManagedGeminiEntries( + controls: GeminiHookControls, +): readonly string[] { + return controls.disabled.filter((entry) => ( + entry === GUARD_HOOK_NAME + || entry === AUDIT_HOOK_NAME + || geminiRuntimeReference(entry, GUARD_SCRIPT, true) !== undefined + || geminiRuntimeReference(entry, AUDIT_SCRIPT, true) !== undefined + )); +} + +export function buildGeminiGroup(scriptPath: string, name: string): GeminiGroup { + return { + hooks: [{ + type: 'command', + name, + command: buildGeminiCommand(scriptPath), + timeout: HOOK_TIMEOUT_MS, + }], + }; +} + +function exactManagedGroup(group: GeminiGroup): boolean { + return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks'); +} + +function currentManagedReference( + handler: GeminiHandler, + scriptName: string, + hookName: string, +): GeminiRuntimeReference | undefined { + return Object.keys(handler).sort().join('|') === 'command|name|timeout|type' + && handler.type === 'command' + && handler.name === hookName + && handler.timeout === HOOK_TIMEOUT_MS + && typeof handler.command === 'string' + ? geminiRuntimeReference(handler.command, scriptName) + : undefined; +} + +function legacyManagedReference( + handler: GeminiHandler, + scriptName: string, +): GeminiRuntimeReference | undefined { + return Object.keys(handler).sort().join('|') === 'command|type' + && handler.type === 'command' + && typeof handler.command === 'string' + ? geminiRuntimeReference(handler.command, scriptName, true) + : undefined; +} + +function managedReference( + handler: GeminiHandler, + scriptName: string, + hookName: string, + includeLegacy: boolean, +): GeminiRuntimeReference | undefined { + return currentManagedReference(handler, scriptName, hookName) + ?? (includeLegacy ? legacyManagedReference(handler, scriptName) : undefined); +} + +export function managedGeminiRemovals( + hooks: GeminiHooks, + agentId?: string, +): ManagedRemoval[] { + const removals: ManagedRemoval[] = []; + for (const [event, scriptName, hookName] of [ + ['BeforeTool', GUARD_SCRIPT, GUARD_HOOK_NAME], + ['AfterTool', AUDIT_SCRIPT, AUDIT_HOOK_NAME], + ] as const) { + (hooks[event] ?? []).forEach((group, groupIndex) => { + const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { + const reference = managedReference(handler, scriptName, hookName, true); + return reference + && (agentId === undefined || sameGeminiAgentId(reference.agentId, agentId)) + ? [handlerIndex] + : []; + }); + if (handlerIndexes.length > 0) { + removals.push({ + event, + groupIndex, + handlerIndexes, + removeGroup: exactManagedGroup(group) && handlerIndexes.length === group.hooks.length, + }); + } + }); + } + return removals; +} + +function referencesForEvent( + groups: readonly GeminiGroup[], + scriptName: string, + hookName: string, +): Map { + const references = new Map(); + for (const group of groups) { + if (!exactManagedGroup(group)) continue; + for (const handler of group.hooks) { + const reference = currentManagedReference(handler, scriptName, hookName); + if (!reference) continue; + const key = process.platform === 'win32' + ? reference.agentId.toLowerCase() + : reference.agentId; + references.set(key, [...(references.get(key) ?? []), reference]); + } + } + return references; +} + +export function geminiRuntimeContracts(hooks: GeminiHooks): GeminiRuntimeContract[] { + const guards = referencesForEvent(hooks.BeforeTool ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME); + const audits = referencesForEvent(hooks.AfterTool ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME); + const contracts: GeminiRuntimeContract[] = []; + for (const [key, guard] of guards) { + const audit = audits.get(key); + if (guard.length !== 1 || audit?.length !== 1) continue; + contracts.push({ + agentId: guard[0].agentId, + guardPath: guard[0].scriptPath, + auditPath: audit[0].scriptPath, + }); + } + return contracts; +} diff --git a/src/plugins/gemini-installation.ts b/src/plugins/gemini-installation.ts new file mode 100644 index 0000000..a46587b --- /dev/null +++ b/src/plugins/gemini-installation.ts @@ -0,0 +1,63 @@ +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, +} from './gemini-contract.js'; +import type { RenderedGeminiDocument } from './gemini-config.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; + +const DISPLAY_NAME = 'Gemini CLI'; +const SETTINGS_DIRECTORY_LABEL = 'Gemini CLI configuration directory'; +const SETTINGS_LABEL = 'Gemini CLI user settings'; + +export type GeminiRuntimePaths = ManagedRuntimePaths; +export type PreparedGeminiInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +export async function preflightGeminiInstallation( + config: InstallConfig, + settingsPath: string, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareGeminiInstallation( + config: InstallConfig, + rendered: RenderedGeminiDocument, +): Promise { + const settingsSource = rendered.next ?? rendered.document.raw; + if (settingsSource === undefined) throw new Error('Gemini CLI hook settings are missing'); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: SETTINGS_DIRECTORY_LABEL, + label: SETTINGS_LABEL, + filePath: rendered.document.filePath, + expectedSource: rendered.document.exists ? rendered.document.raw : undefined, + source: settingsSource, + }], + config, + guardOptions: { successOutput: '{}\n' }, + auditOptions: { nativePayload: true, successOutput: '{}\n' }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function commitGeminiInstallation( + prepared: PreparedGeminiInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} diff --git a/src/plugins/gemini-io.ts b/src/plugins/gemini-io.ts new file mode 100644 index 0000000..97469ba --- /dev/null +++ b/src/plugins/gemini-io.ts @@ -0,0 +1,149 @@ +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + CONFIG_FILE, + type GeminiRuntimeContract, +} from './gemini-contract.js'; +import { + createGeminiDocument, + parseGeminiDocument, + type GeminiDocument, + type RenderedGeminiDocument, +} from './gemini-config.js'; +import { sameGeminiAgentId, sameGeminiPath } from './gemini-command.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, +} from './managed-transaction.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +export function geminiConfigurationDirectory(): string { + const configuredHome = process.env.GEMINI_CLI_HOME; + const home = configuredHome ? configuredHome : os.homedir(); + return path.join(home, '.gemini'); +} + +export function geminiSettingsPath(): string { + return path.join(geminiConfigurationDirectory(), CONFIG_FILE); +} + +export async function readGeminiDocument(): Promise { + const filePath = geminiSettingsPath(); + await inspectPhysicalDirectory(path.dirname(filePath), 'Gemini CLI configuration directory'); + const snapshot = await readPhysicalFile(filePath, 'Gemini CLI user settings'); + return snapshot + ? parseGeminiDocument({ + exists: true, + filePath, + raw: snapshot.contents, + }) + : createGeminiDocument(filePath); +} + +export async function writeGeminiDocument(rendered: RenderedGeminiDocument): Promise { + if (!rendered.changed) return; + const change = await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: 'Gemini CLI user settings', + next: rendered.next, + mode: 0o600, + expectedSource: rendered.document.exists ? rendered.document.raw : undefined, + verifyExpectedSource: true, + }); + if (!change) return; + await commitManagedTransaction({ + displayName: 'Gemini CLI', + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.filePath), + label: 'Gemini CLI configuration directory', + }], + changes: [change], + }); +} + +function requireNonEmptyString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); + } + return value; +} + +function validateRuntimeConfig( + config: JsonObject, + contract: GeminiRuntimeContract, + configPath: string, +): void { + const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); + const extra = Object.keys(config).find((key) => !supported.has(key)); + if (extra) { + throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); + } + requireNonEmptyString(config.org_id, 'org_id', configPath); + requireNonEmptyString(config.kid, 'kid', configPath); + const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath); + if (!sameGeminiAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Gemini CLI hooks: ${configPath}`); + } + if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); + const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath); + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch (error) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + if (!['http:', 'https:'].includes(baseUrl.protocol) + || !baseUrl.hostname + || baseUrl.username + || baseUrl.password + || baseUrl.search + || baseUrl.hash) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); + } +} + +function validatePrivateKey(contents: string, keyPath: string): void { + const bytes = Buffer.from(contents, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { + throw new Error(`Elydora private key is invalid: ${keyPath}`); + } +} + +async function runtimeContractExists(contract: GeminiRuntimeContract): Promise { + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!sameGeminiPath(path.dirname(agentDirectory), runtimeRoot) + || !sameGeminiPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false; + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; + if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) { + return false; + } + + const configPath = path.join(agentDirectory, 'config.json'); + const keyPath = path.join(agentDirectory, 'private.key'); + const [config, key, guard, audit] = await Promise.all([ + readPhysicalFile(configPath, 'Elydora runtime config'), + readPhysicalFile(keyPath, 'Elydora private key'), + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + ]); + if (!config || !key || !guard || !audit) return false; + const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); + validateRuntimeConfig(parsed, contract, configPath); + validatePrivateKey(key.contents, keyPath); + return guard.contents.length > 0 && audit.contents.length > 0; +} + +export async function geminiRuntimeFilesExist( + contracts: readonly GeminiRuntimeContract[], +): Promise { + for (const contract of contracts) { + if (await runtimeContractExists(contract)) return true; + } + return false; +} diff --git a/src/plugins/gemini.ts b/src/plugins/gemini.ts index 62da261..20752b7 100644 --- a/src/plugins/gemini.ts +++ b/src/plugins/gemini.ts @@ -1,169 +1,95 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + AUDIT_HOOK_NAME, + GUARD_HOOK_NAME, + buildGeminiGroup, + disabledManagedGeminiEntries, + geminiRuntimeContracts, + managedGeminiHooksEnabled, + type GeminiGroup, + type ManagedGeminiEvent, +} from './gemini-contract.js'; +import { renderGeminiDocument, type GeminiDocument } from './gemini-config.js'; +import { + commitGeminiInstallation, + preflightGeminiInstallation, + prepareGeminiInstallation, +} from './gemini-installation.js'; +import { + geminiRuntimeFilesExist, + readGeminiDocument, + writeGeminiDocument, +} from './gemini-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'gemini'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function resolveConfigDir(): string { - return entry.configDir.replace(/^~/, os.homedir()); -} - -function resolveConfigPath(): string { - return path.join(resolveConfigDir(), entry.configFile); -} - -function buildHookCommand(scriptPath: string): string { - return `node "${scriptPath}"`; -} - -function isElydoraCommand(cmd: string, agentId?: string): boolean { - if (!cmd.includes('elydora')) return false; - if (agentId) { - return cmd.includes(agentId); +function requireEnabledHooks(document: GeminiDocument): void { + if (!document.hookControls.enabled) { + throw new Error(`Gemini CLI hooks are disabled by hooksConfig.enabled: ${document.filePath}`); + } + const disabled = disabledManagedGeminiEntries(document.hookControls); + if (disabled.length > 0) { + throw new Error( + `Gemini CLI hooks are disabled by hooksConfig.disabled: ${disabled.join(', ')}`, + ); } - return true; } -function filterElydoraEntries(arr: Array>, agentId?: string): Array> { - return arr.filter((entry) => { - if (Array.isArray(entry.hooks)) { - const cmds = entry.hooks as Array>; - return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); - } - if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId); - return true; - }); +function installedGroups(guardPath: string, auditPath: string): ReadonlyMap< + ManagedGeminiEvent, + GeminiGroup +> { + return new Map([ + ['BeforeTool', buildGeminiGroup(guardPath, GUARD_HOOK_NAME)], + ['AfterTool', buildGeminiGroup(auditPath, AUDIT_HOOK_NAME)], + ]); } export const geminiPlugin: AgentPlugin = { + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { + const document = await readGeminiDocument(); + requireEnabledHooks(document); + await preflightGeminiInstallation(config, document.filePath); + }, + async install(config: InstallConfig): Promise { - const configDir = resolveConfigDir(); - await fsp.mkdir(configDir, { recursive: true }); - - const configPath = resolveConfigPath(); - let settings: Record = {}; - - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - // Start fresh - } - - if (!settings.hooks || typeof settings.hooks !== 'object') { - settings.hooks = {}; - } - const hooks = settings.hooks as Record; - - // --- BeforeTool (guard — freeze enforcement) --- - if (!Array.isArray(hooks.BeforeTool)) { - hooks.BeforeTool = []; - } - const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array>); - preFiltered.push({ - hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }], - }); - hooks.BeforeTool = preFiltered; - - // --- AfterTool (audit logging) --- - if (!Array.isArray(hooks.AfterTool)) { - hooks.AfterTool = []; - } - const postFiltered = filterElydoraEntries(hooks.AfterTool as Array>); - postFiltered.push({ - hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }], - }); - hooks.AfterTool = postFiltered; - - settings.hooks = hooks; - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const document = await readGeminiDocument(); + requireEnabledHooks(document); + const paths = await preflightGeminiInstallation(config, document.filePath); + const rendered = renderGeminiDocument( + document, + undefined, + installedGroups(paths.guardPath, paths.auditPath), + ); + await commitGeminiInstallation(await prepareGeminiInstallation(config, rendered)); + console.log(` Gemini CLI hooks: ${document.filePath}`); + console.log(' Gemini CLI verification: run /hooks list.'); }, async uninstall(agentId?: string): Promise { - const configPath = resolveConfigPath(); - - let settings: Record; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - return; - } - - const hooks = settings.hooks as Record | undefined; - if (!hooks) return; - - if (Array.isArray(hooks.BeforeTool)) { - hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array>, agentId); - } - if (Array.isArray(hooks.AfterTool)) { - hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array>, agentId); - } - - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const document = await readGeminiDocument(); + await writeGeminiDocument(renderGeminiDocument(document, agentId, new Map())); }, async status(): Promise { - const configPath = resolveConfigPath(); - - let hookConfigured = false; - let hookScriptPath = ''; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - const settings = JSON.parse(raw); - const hooks = settings.hooks as Record | undefined; - if (hooks) { - const checkArr = (arr: unknown) => { - if (!Array.isArray(arr)) return false; - return (arr as Array>).some((entry) => { - if (Array.isArray(entry.hooks)) { - return (entry.hooks as Array>).some( - (h) => typeof h.command === 'string' && h.command.includes('elydora'), - ); - } - return typeof entry.command === 'string' && entry.command.includes('elydora'); - }); - }; - hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool); - - // Extract hook script path from AfterTool command - if (hookConfigured && Array.isArray(hooks.AfterTool)) { - for (const e of hooks.AfterTool as Array>) { - if (Array.isArray(e.hooks)) { - for (const h of e.hooks as Array>) { - const cmd = h.command as string; - if (cmd && cmd.includes('elydora')) { - hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); - } - } - } - } - } - } - } catch { - // Config not readable - } - - let hookScriptExists = false; - if (hookScriptPath) { - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist - } - } - + const document = await readGeminiDocument(); + const contracts = geminiRuntimeContracts(document.hooks); + const hookConfigured = managedGeminiHooksEnabled(document.hookControls) + && contracts.length > 0; + const hookScriptExists = hookConfigured + ? await geminiRuntimeFilesExist(contracts) + : false; return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: document.filePath, }; }, }; diff --git a/test-support/gemini-test-helpers.mjs b/test-support/gemini-test-helpers.mjs new file mode 100644 index 0000000..5d590fd --- /dev/null +++ b/test-support/gemini-test-helpers.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, 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'; +import { parse } from 'jsonc-parser'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/gemini.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/gemini-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/gemini-installation.js'), +).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runProcess(command, args, env, cwd, input = '', unset = []) { + return new Promise((resolve, reject) => { + const childEnv = { ...process.env, ...env }; + for (const key of unset) delete childEnv[key]; + const child = spawn(command, args, { + cwd, + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + 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 runNode(args, env, cwd, input = '', unset = []) { + return runProcess(process.execPath, args, env, cwd, input, unset); +} + +function windowsPowerShell() { + return path.win32.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); +} + +export function runGeminiHook(handler, input, fixture, environment = {}) { + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GEMINI_CLI_HOME: fixture.geminiCliHomeOverride ?? '', + ...environment, + }; + if (process.platform === 'win32') { + return runProcess( + windowsPowerShell(), + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `${handler.command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`, + ], + env, + fixture.projectDir, + input, + ); + } + return runProcess('/bin/bash', ['-c', handler.command], env, fixture.projectDir, input); +} + +export async function writeSettings(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 parseSettings(raw) { + const errors = []; + const value = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: false, + }); + assert.deepEqual(errors, []); + return value; +} + +export async function readSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: parseSettings(raw) }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'gemini', + 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 { geminiPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await geminiPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + ...environment, + }; + const unset = []; + if (fixture.geminiCliHomeOverride === undefined) unset.push('GEMINI_CLI_HOME'); + else env.GEMINI_CLI_HOME = fixture.geminiCliHomeOverride; + return runNode( + ['--input-type=module', '--eval', source], + env, + fixture.projectDir, + '', + unset, + ); +} + +export async function createFixture({ + baseUrl = 'http://127.0.0.1:9', + settings, + explicitGeminiHome = true, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-gemini-')); + const homeDir = path.join( + rootDir, + "home with spaces and 'quote $GEMINI_CWD %GEMINI_CWD%", + ); + const projectDir = path.join(rootDir, 'project with spaces'); + const geminiCliHome = homeDir; + const settingsPath = path.join(geminiCliHome, '.gemini', 'settings.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 (settings !== undefined) await writeSettings(settingsPath, settings); + const fixture = { + agentDir, + baseUrl, + geminiCliHome, + geminiCliHomeOverride: explicitGeminiHome ? geminiCliHome : undefined, + guardScriptPath, + homeDir, + hookScriptPath, + projectDir, + rootDir, + settingsPath, + install(overrides = {}, environment = {}) { + return runPlugin(this, 'install', installConfig(this, overrides), environment); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export function managedHandler(settings, event, name) { + for (const group of settings.hooks?.[event] ?? []) { + if (Object.keys(group).join('|') !== 'hooks' || !Array.isArray(group.hooks)) continue; + const handler = group.hooks.find((candidate) => ( + Object.keys(candidate).sort().join('|') === 'command|name|timeout|type' + && candidate.type === 'command' + && candidate.name === name + && candidate.timeout === 10_000 + )); + if (handler) return handler; + } + return undefined; +} + +export function assertManagedHandler(handler, name) { + assert(handler); + assert.deepEqual(Object.keys(handler).sort(), ['command', 'name', 'timeout', 'type']); + assert.equal(handler.type, 'command'); + assert.equal(handler.name, name); + assert.equal(handler.timeout, 10_000); + assert.equal(typeof handler.command, 'string'); +} + +export function legacyHandler(scriptPath) { + return { type: 'command', command: `node "${scriptPath}"` }; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/gemini-official.test.mjs b/test/gemini-official.test.mjs new file mode 100644 index 0000000..4e7c10c --- /dev/null +++ b/test/gemini-official.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createFixture, + runProcess, +} from '../test-support/gemini-test-helpers.mjs'; + +const geminiEntry = process.env.ELYDORA_GEMINI_ENTRY; + +test('official Gemini CLI accepts the installed user hook contract', { + skip: geminiEntry ? false : 'set ELYDORA_GEMINI_ENTRY to the official Gemini CLI entry file', +}, async () => { + const fixture = await createFixture(); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const environment = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GEMINI_CLI_HOME: fixture.geminiCliHome, + GEMINI_API_KEY: 'official-loader-test-key', + GEMINI_TELEMETRY_ENABLED: 'false', + OTEL_SDK_DISABLED: 'true', + }; + const version = await runProcess( + process.execPath, + [geminiEntry, '--version'], + environment, + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /0\.51\.0/); + + const load = await runProcess( + process.execPath, + [geminiEntry, '--skip-trust', '--list-extensions'], + environment, + fixture.projectDir, + ); + assert.equal(load.code, 0, load.stderr); + assert.doesNotMatch( + `${load.stdout}\n${load.stderr}`, + /invalid settings|settings validation|failed to parse|hook configuration error/i, + ); + } finally { + await fixture.close(); + } +}); diff --git a/test/gemini-plugin.test.mjs b/test/gemini-plugin.test.mjs new file mode 100644 index 0000000..be6e39a --- /dev/null +++ b/test/gemini-plugin.test.mjs @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + assertManagedHandler, + cliPath, + createFixture, + legacyHandler, + managedHandler, + parseSettings, + readSettings, + registryModuleUrl, + runNode, + runPlugin, + VALID_PRIVATE_KEY, + writeSettings, +} from '../test-support/gemini-test-helpers.mjs'; + +const GUARD_NAME = 'elydora-guard'; +const AUDIT_NAME = 'elydora-audit'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +function assertManagedPair(settings) { + const guard = managedHandler(settings, 'BeforeTool', GUARD_NAME); + const audit = managedHandler(settings, 'AfterTool', AUDIT_NAME); + assertManagedHandler(guard, GUARD_NAME); + assertManagedHandler(audit, AUDIT_NAME); + return { guard, audit }; +} + +test('Gemini CLI is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('gemini'), { + name: 'Gemini CLI', + configDir: '~/.gemini', + configFile: 'settings.json', + }); + const fixture = await createFixture(); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GEMINI_CLI_HOME: fixture.geminiCliHome, + }, fixture.projectDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Gemini CLI \(gemini\)/); + } finally { + await fixture.close(); + } +}); + +test('Gemini installs an exact managed pair and preserves JSONC settings', async () => { + const existing = [ + '{', + ' // Keep this user preference.', + ' "theme": "GitHub",', + ' "hooks": {', + ' "FutureEvent": [null],', + ' "BeforeTool": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]', + ' }', + '}', + '', + ].join('\r\n'); + const fixture = await createFixture({ settings: existing }); + const projectSettings = path.join(fixture.projectDir, '.gemini', 'settings.json'); + const systemSettings = path.join(fixture.rootDir, 'system-settings.json'); + await writeSettings(projectSettings, '{ "owner": "project" }\n'); + await writeSettings(systemSettings, '{ "owner": "system" }\n'); + try { + const environment = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettings }; + const first = await fixture.install({}, environment); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /run \/hooks list/i); + const installed = await readSettings(fixture.settingsPath); + assert.match(installed.raw, /Keep this user preference/); + assert.match(installed.raw, /\r\n/); + assert.equal(installed.settings.theme, 'GitHub'); + assert.deepEqual(installed.settings.hooks.FutureEvent, [null]); + assert.equal(installed.settings.hooks.BeforeTool[0].hooks[0].command, 'user-hook'); + assertManagedPair(installed.settings); + + const second = await fixture.install({}, environment); + assert.equal(second.code, 0, second.stderr); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), installed.raw); + assert.equal(await readFile(projectSettings, 'utf-8'), '{ "owner": "project" }\n'); + assert.equal(await readFile(systemSettings, 'utf-8'), '{ "owner": "system" }\n'); + } finally { + await fixture.close(); + } +}); + +test('Gemini resolves official GEMINI_CLI_HOME path semantics', async () => { + const fallback = await createFixture({ explicitGeminiHome: false }); + try { + assert.equal((await fallback.install()).code, 0); + assertManagedPair((await readSettings(fallback.settingsPath)).settings); + } finally { + await fallback.close(); + } + + for (const [value, expected] of [ + ['', (fixture) => fixture.settingsPath], + ['relative gemini', (fixture) => path.join( + fixture.projectDir, + 'relative gemini', + '.gemini', + 'settings.json', + )], + ['~', (fixture) => path.join(fixture.projectDir, '~', '.gemini', 'settings.json')], + ]) { + const fixture = await createFixture(); + fixture.geminiCliHomeOverride = value; + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + assertManagedPair((await readSettings(expected(fixture))).settings); + } finally { + await fixture.close(); + } + } +}); + +test('Gemini rejects malformed official settings before every write', async (t) => { + const cases = [ + ['syntax', '{ malformed', /parse Gemini CLI user settings/i], + ['root', '[]', /must contain a JSON object/i], + ['trailing comma', '{ "theme": true, }', /parse Gemini CLI user settings/i], + ['duplicate', '{ "hooks": {}, "hooks": {} }', /duplicate field "hooks"/i], + ['hooks shape', '{ "hooks": null }', /field "hooks" must be an object/i], + ['event shape', '{ "hooks": { "BeforeTool": null } }', /must be an array/i], + ['group shape', '{ "hooks": { "BeforeTool": [null] } }', /group.*must be an object/i], + ['matcher shape', '{ "hooks": { "BeforeTool": [{ "matcher": 1, "hooks": [] }] } }', /matcher must be a string/i], + ['sequential shape', '{ "hooks": { "BeforeTool": [{ "sequential": 1, "hooks": [] }] } }', /sequential must be a boolean/i], + ['hooks missing', '{ "hooks": { "BeforeTool": [{}] } }', /must contain a hooks array/i], + ['handler shape', '{ "hooks": { "BeforeTool": [{ "hooks": [null] }] } }', /handler.*must be an object/i], + ['handler type', '{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "http" }] }] } }', /unsupported type/i], + ['internal handler type', '{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "runtime", "name": "x" }] }] } }', /unsupported type/i], + ['command value', '{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "" }] }] } }', /non-empty command/i], + ['timeout value', '{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "timeout": -1 }] }] } }', /non-negative finite number/i], + ['environment value', '{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "env": { "A": 1 } }] }] } }', /env must map names to strings/i], + ['controls shape', '{ "hooksConfig": null }', /hooksConfig.*must be an object/i], + ['controls field', '{ "hooksConfig": { "future": true } }', /unsupported field "future"/i], + ['enabled shape', '{ "hooksConfig": { "enabled": "yes" } }', /enabled.*must be a boolean/i], + ['disabled shape', '{ "hooksConfig": { "disabled": [1] } }', /array of strings/i], + ['notifications shape', '{ "hooksConfig": { "notifications": 1 } }', /notifications.*must be a boolean/i], + ]; + for (const [name, settings, pattern] of cases) { + await t.test(name, async () => { + const fixture = await createFixture({ settings }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), settings); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Gemini respects canonical hook controls', async () => { + for (const [settings, pattern] of [ + [{ hooksConfig: { enabled: false } }, /hooksConfig.enabled/i], + [{ hooksConfig: { disabled: [GUARD_NAME] } }, /elydora-guard/i], + [{ hooksConfig: { disabled: [AUDIT_NAME] } }, /elydora-audit/i], + ]) { + const fixture = await createFixture({ settings }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + } + + const legacy = await createFixture(); + try { + await writeSettings(legacy.settingsPath, { + hooksConfig: { disabled: [legacyHandler(legacy.guardScriptPath).command] }, + }); + const result = await legacy.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /hooksConfig.disabled/i); + await assertMissing(legacy.agentDir); + } finally { + await legacy.close(); + } +}); + +test('Gemini migrates exact legacy handlers and preserves ownership lookalikes', async () => { + const fixture = await createFixture(); + const lookalike = { ...legacyHandler(fixture.guardScriptPath) }; + lookalike.command += ' --inspect'; + await writeSettings(fixture.settingsPath, { + hooks: { + BeforeTool: [ + { hooks: [legacyHandler(fixture.guardScriptPath)] }, + { hooks: [lookalike] }, + ], + AfterTool: [{ hooks: [legacyHandler(fixture.hookScriptPath)] }], + }, + }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + let settings = (await readSettings(fixture.settingsPath)).settings; + assertManagedPair(settings); + assert(settings.hooks.BeforeTool.some((group) => group.hooks[0].command === lookalike.command)); + + const managed = settings.hooks.BeforeTool.at(-1); + managed.hooks.push({ type: 'command', command: 'user-command' }); + await writeSettings(fixture.settingsPath, settings); + const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + settings = (await readSettings(fixture.settingsPath)).settings; + assert.deepEqual(settings.hooks.BeforeTool.flatMap((group) => group.hooks), [ + lookalike, + { type: 'command', command: 'user-command' }, + ]); + assert.equal(settings.hooks.AfterTool, undefined); + } finally { + await fixture.close(); + } +}); + +test('Gemini uninstall preserves user settings and removes an owned file', async () => { + const user = await createFixture({ settings: { theme: 'GitHub', hooks: { Notification: [] } } }); + try { + assert.equal((await user.install()).code, 0); + assert.equal((await runPlugin(user, 'uninstall', 'agent-1')).code, 0); + assert.deepEqual((await readSettings(user.settingsPath)).settings, { + theme: 'GitHub', + hooks: { Notification: [] }, + }); + } finally { + await user.close(); + } + + const owned = await createFixture(); + try { + assert.equal((await owned.install()).code, 0); + assert.match(await readFile(owned.settingsPath, 'utf-8'), /^\/\/ Managed by Elydora/); + assert.equal((await runPlugin(owned, 'uninstall', 'agent-1')).code, 0); + await assertMissing(owned.settingsPath); + } finally { + await owned.close(); + } +}); + +test('Gemini status requires enabled exact hooks and strict runtime identity', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + let status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.installed, true); + + let settings = (await readSettings(fixture.settingsPath)).settings; + settings.hooks.AfterTool.push(settings.hooks.AfterTool.at(-1)); + await writeSettings(fixture.settingsPath, settings); + status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.installed, false); + + assert.equal((await fixture.install()).code, 0); + settings = (await readSettings(fixture.settingsPath)).settings; + settings.hooksConfig = { disabled: [AUDIT_NAME] }; + await writeSettings(fixture.settingsPath, settings); + status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout); + assert.equal(status.installed, false); + assert.equal(status.hookConfigured, false); + + settings.hooksConfig = { disabled: [] }; + await writeSettings(fixture.settingsPath, settings); + await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid'); + const invalid = await runPlugin(fixture, 'status', null); + assert.equal(invalid.code, 1); + assert.match(invalid.stderr, /private key is invalid/i); + } finally { + await fixture.close(); + } +}); + +test('Gemini CLI completes install, status, and uninstall end to end', async () => { + const fixture = await createFixture(); + const privateKeyFile = path.join(fixture.rootDir, 'install-private.key'); + const tokenFile = path.join(fixture.rootDir, 'install-token.txt'); + const environment = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GEMINI_CLI_HOME: fixture.geminiCliHome, + }; + try { + await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 }); + await writeFile(tokenFile, 'token-1\n', { mode: 0o600 }); + const install = await runNode([ + '--no-warnings', + cliPath, + 'install', + '--agent', 'gemini', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', fixture.baseUrl, + ], environment, fixture.projectDir); + assert.equal(install.code, 0, install.stderr); + assertManagedPair((await readSettings(fixture.settingsPath)).settings); + + const status = await runNode( + ['--no-warnings', cliPath, 'status'], + environment, + fixture.projectDir, + ); + assert.equal(status.code, 0, status.stderr); + assert.match(status.stdout, /Gemini CLI \(gemini\) \[installed\]/); + + const uninstall = await runNode([ + '--no-warnings', + cliPath, + 'uninstall', + '--agent', 'gemini', + '--agent_id', 'agent-1', + ], environment, fixture.projectDir); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assertMissing(fixture.settingsPath); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); diff --git a/test/gemini-runtime.test.mjs b/test/gemini-runtime.test.mjs new file mode 100644 index 0000000..e378313 --- /dev/null +++ b/test/gemini-runtime.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + readSettings, + runGeminiHook, + startApiServer, +} from '../test-support/gemini-test-helpers.mjs'; + +const GUARD_NAME = 'elydora-guard'; +const AUDIT_NAME = 'elydora-audit'; + +function officialInput(fixture, event) { + return { + session_id: 'session-1', + transcript_path: path.join(fixture.projectDir, 'transcript.jsonl'), + cwd: fixture.projectDir, + hook_event_name: event, + timestamp: '2026-07-19T00:00:00.000Z', + tool_name: 'run_shell_command', + tool_input: { command: 'echo test' }, + ...(event === 'AfterTool' ? { + tool_response: { output: 'test', error: null }, + } : {}), + }; +} + +async function installedHandlers(fixture) { + const settings = (await readSettings(fixture.settingsPath)).settings; + return { + guard: managedHandler(settings, 'BeforeTool', GUARD_NAME), + audit: managedHandler(settings, 'AfterTool', AUDIT_NAME), + }; +} + +test('Gemini guard accepts active agents with valid JSON stdout', async () => { + const api = await startApiServer({ status: 'active' }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const { guard } = await installedHandlers(fixture); + const input = JSON.stringify(officialInput(fixture, 'BeforeTool')); + const result = await runGeminiHook(guard, input, fixture); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), {}); + assert.equal(api.requests.filter((request) => request.method === 'GET').length, 1); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Gemini guard propagates frozen and revoked states through exit code 2', async (t) => { + for (const status of ['frozen', 'revoked']) { + await t.test(status, async () => { + const api = await startApiServer({ status }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { guard } = await installedHandlers(fixture); + const result = await runGeminiHook( + guard, + JSON.stringify(officialInput(fixture, 'BeforeTool')), + fixture, + ); + assert.equal(result.code, 2, result.stderr); + assert.equal(result.stdout, ''); + assert.match(result.stderr, new RegExp(`is ${status}`, 'i')); + } finally { + await fixture.close(); + await api.close(); + } + }); + } +}); + +test('Gemini audit forwards the complete native AfterTool payload', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { audit } = await installedHandlers(fixture); + const payload = officialInput(fixture, 'AfterTool'); + payload.mcp_context = { server_name: 'filesystem', tool_name: 'write_file' }; + payload.original_request_name = 'write_file'; + payload.future_provider_field = { preserved: true }; + const source = JSON.stringify(payload); + const result = await runGeminiHook(audit, source, fixture); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), {}); + const request = api.requests.find((entry) => entry.method === 'POST'); + assert(request); + assert.deepEqual(JSON.parse(request.raw).payload, payload); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Gemini runtimes keep failures observable while preserving fail-open behavior', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const { guard, audit } = await installedHandlers(fixture); + const invalidGuard = await runGeminiHook(guard, '{ malformed', fixture); + assert.equal(invalidGuard.code, 0, invalidGuard.stderr); + assert.deepEqual(JSON.parse(invalidGuard.stdout), {}); + assert.match(invalidGuard.stderr, /invalid JSON/i); + + const auditResult = await runGeminiHook( + audit, + JSON.stringify(officialInput(fixture, 'AfterTool')), + fixture, + ); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.deepEqual(JSON.parse(auditResult.stdout), {}); + assert.match( + await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), + /Elydora audit|fetch failed|ECONNREFUSED/i, + ); + } finally { + await fixture.close(); + } +}); + +test('Gemini runtime artifacts use private modes on POSIX', { + skip: process.platform === 'win32' ? 'POSIX mode bits are not authoritative on Windows' : false, +}, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + for (const [filePath, expected] of [ + [path.join(fixture.agentDir, 'config.json'), 0o600], + [path.join(fixture.agentDir, 'private.key'), 0o600], + [fixture.guardScriptPath, 0o700], + [fixture.hookScriptPath, 0o700], + [fixture.settingsPath, 0o600], + ]) { + assert.equal((await stat(filePath)).mode & 0o777, expected); + } + } finally { + await fixture.close(); + } +}); diff --git a/test/gemini-transaction.test.mjs b/test/gemini-transaction.test.mjs new file mode 100644 index 0000000..5f403b2 --- /dev/null +++ b/test/gemini-transaction.test.mjs @@ -0,0 +1,221 @@ +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, + writeSettings, +} from '../test-support/gemini-test-helpers.mjs'; + +const managedFileNames = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +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.settingsPath, await readFile(fixture.settingsPath, '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.settingsPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +function testEnvironment(fixture, overrides = {}) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GEMINI_CLI_HOME: fixture.geminiCliHome, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)), + }; +} + +test('Gemini 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 { readGeminiDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitGeminiInstallation, + prepareGeminiInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readGeminiDocument(); + const prepared = await prepareGeminiInstallation(config, { document, changed: false }); + let calls = 0; + await commitGeminiInstallation(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], + testEnvironment(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('Gemini detects concurrent settings changes before runtime commit', async () => { + const fixture = await createFixture({ settings: { theme: 'GitHub' } }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"theme":"Atom One","hooks":{"Notification":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readGeminiDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitGeminiInstallation, + prepareGeminiInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readGeminiDocument(); + const prepared = await prepareGeminiInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitGeminiInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...testEnvironment(fixture, { orgId: 'updated' }), + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Gemini CLI installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.settingsPath) { + assert.equal(await readFile(filePath, 'utf-8'), expected); + } + } + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Gemini rejects orphaned and mismatched runtime identities before writes', async (t) => { + await t.test('orphaned runtime', 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 assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } + }); + + await t.test('mismatched identity', async () => { + const fixture = await createFixture(); + try { + await writeSettings(path.join(fixture.agentDir, 'config.json'), { + agent_name: 'gemini', + 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 assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } + }); +}); + +test('Gemini rejects linked configuration, settings, and runtime paths', async (t) => { + for (const kind of ['configuration', 'settings', 'runtime']) { + await t.test(kind, async () => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + await mkdir(fixture.homeDir, { recursive: true }); + let link; + let linkType = 'junction'; + if (kind === 'configuration') { + link = path.dirname(fixture.settingsPath); + } else if (kind === 'runtime') { + link = path.join(fixture.homeDir, '.elydora'); + } else { + await mkdir(path.dirname(fixture.settingsPath), { recursive: true }); + const targetFile = path.join(target, 'settings.json'); + await writeFile(targetFile, '{}\n'); + link = fixture.settingsPath; + linkType = 'file'; + await symlink(targetFile, link, linkType); + } + if (kind !== 'settings') await symlink(target, link, linkType); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical (directory|file)/i); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } finally { + await fixture.close(); + } + }); + } +}); + +test('Gemini installation leaves no transaction artifacts', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +});