From 9591a6e655344ccf87090c8978c9388718663a69 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 23:45:33 -0400 Subject: [PATCH] fix(node): align Grok hook loader contract --- AGENTS.md | 2 +- README.md | 2 +- src/plugins/grok-contract.ts | 3 +- src/plugins/grok-io.ts | 7 +++- test-support/grok-test-helpers.mjs | 9 +++-- test/grok-official.test.mjs | 53 +++++++++++++++++++++++++++++ test/grok-plugin.test.mjs | 54 +++++++++++++++++++++++++++--- 7 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 test/grok-official.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 49f0aa1..920bd36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - 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. - 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 Grok Build user hooks through `$GROK_HOME/hooks/*.json`, with `~/.grok/hooks/*.json` selected when the variable is absent. Reject an empty `GROK_HOME` and direct the operator to unset it or provide an absolute home directory; Grok Build 0.2.106 otherwise resolves the empty value to cwd-relative `hooks`. Keep Claude Code, Cursor, project `.grok/hooks`, plugin, and `hooks-paths` sources read-only. Reject matcher-bearing `SessionStart`, `SessionEnd`, `Stop`, and `UserPromptSubmit` groups because the stable loader drops them; preserve matchers on the remaining supported events. Accept a null handler `env` as an empty map. Register exact matchless `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. Verify discovery with `grok inspect --json` and the in-session `/hooks` view. - 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. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. 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. - Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`. diff --git a/README.md b/README.md index 87bc03c..af34e7b 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Factory Droid 0.175.0 installation resolves one active user hook source in this Kimi installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules to every detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the legacy Python CLI's `~/.kimi/config.toml`. A fresh installation targets stable Kimi Code; an existing legacy home activates the migration contract. Empty `KIMI_CODE_HOME` values use the default. The selected TOML files, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Kimi's native snake_case event JSON, propagate freeze and revocation through exit code `2`, and use encoded PowerShell on Windows. Run `/hooks` to inspect the global rules. -Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. +Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json`, with `~/.grok/hooks/elydora-audit.json` selected when `GROK_HOME` is absent. An empty `GROK_HOME` stops installation and status checks with guidance to unset the variable or provide an absolute home directory, preventing Grok Build 0.2.106 from resolving it to cwd-relative `hooks`. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Elydora rejects matcher-bearing `SessionStart`, `SessionEnd`, `Stop`, and `UserPromptSubmit` groups that the stable loader drops; other [xAI hook groups](https://docs.x.ai/build/features/hooks) retain matcher metadata and nullable handler environments. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` and open `/hooks` to inspect the effective hook inventory. Qwen Code 0.20.0 installation writes exact, named `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` command hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). Explicit environment values lead Qwen's home bootstrap, followed by `/.env` or `~/.qwen/.env`, then `~/.env`; a discovered home contributes its own `.env`. System defaults, trusted workspace settings, system overrides, and trust rules remain read-only and participate in effective `disableAllHooks` evaluation. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of every consulted source. Managed handlers use ten-second millisecond timeouts, preserve Qwen's complete native snake_case payload for success and failure events, and propagate frozen or revoked state through exit code `2`. Run `/hooks` after installation and restart active Qwen Code sessions. diff --git a/src/plugins/grok-contract.ts b/src/plugins/grok-contract.ts index c78a0b6..b0dec80 100644 --- a/src/plugins/grok-contract.ts +++ b/src/plugins/grok-contract.ts @@ -25,6 +25,7 @@ export interface GrokHandler extends JsonObject { readonly command?: string; readonly url?: string; readonly timeout?: number; + readonly env?: Record | null; } export interface GrokGroup extends JsonObject { @@ -79,7 +80,7 @@ function validateHandler( throw new Error(`${label} requires a non-empty url`); } validateTimeout(value.timeout, label); - if (value.env !== undefined) { + if (value.env !== undefined && value.env !== null) { if (!isObject(value.env) || Object.values(value.env).some((item) => typeof item !== 'string')) { throw new Error(`${label} env must map names to strings`); diff --git a/src/plugins/grok-io.ts b/src/plugins/grok-io.ts index c62a787..acdd999 100644 --- a/src/plugins/grok-io.ts +++ b/src/plugins/grok-io.ts @@ -15,7 +15,12 @@ const CONFIG_FILE = 'elydora-audit.json'; export function grokConfigPath(): string { const configured = process.env.GROK_HOME; - const grokHome = configured === undefined || configured === '' + if (configured === '') { + throw new Error( + 'GROK_HOME is empty; unset GROK_HOME or set it to an absolute home directory', + ); + } + const grokHome = configured === undefined ? path.join(os.homedir(), '.grok') : path.resolve(configured); return path.join(grokHome, 'hooks', CONFIG_FILE); diff --git a/test-support/grok-test-helpers.mjs b/test-support/grok-test-helpers.mjs index c1949bb..67cfbc5 100644 --- a/test-support/grok-test-helpers.mjs +++ b/test-support/grok-test-helpers.mjs @@ -16,14 +16,15 @@ export const installationModuleUrl = pathToFileURL( ).href; export const cliPath = path.resolve('dist/cli.js'); -export function runNode(args, env, cwd, input = '', unset = []) { +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(process.execPath, args, { + const child = spawn(command, args, { cwd, env: childEnv, stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, }); let stdout = ''; let stderr = ''; @@ -35,6 +36,10 @@ export function runNode(args, env, cwd, input = '', unset = []) { }); } +export function runNode(args, env, cwd, input = '', unset = []) { + return runProcess(process.execPath, args, env, cwd, input, unset); +} + export function runGrokHook(command, input, fixture, environment = {}) { return new Promise((resolve, reject) => { const child = spawn(command, { diff --git a/test/grok-official.test.mjs b/test/grok-official.test.mjs new file mode 100644 index 0000000..a406e74 --- /dev/null +++ b/test/grok-official.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createFixture, + runProcess, +} from '../test-support/grok-test-helpers.mjs'; + +const grokBinary = process.env.ELYDORA_GROK_BINARY; + +test('official Grok Build 0.2.106 discovers the installed matchless hook contract', { + skip: grokBinary ? false : 'set ELYDORA_GROK_BINARY to the official Grok Build executable', +}, 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, + GROK_HOME: fixture.grokHome, + }; + const version = await runProcess( + grokBinary, + ['--version'], + environment, + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /grok 0\.2\.106\b/); + + const inspect = await runProcess( + grokBinary, + ['inspect', '--json'], + environment, + fixture.projectDir, + ); + assert.equal(inspect.code, 0, inspect.stderr); + const report = JSON.parse(inspect.stdout); + assert.equal(report.grokVersion, '0.2.106'); + assert.deepEqual( + report.hooks + .map(({ event, hookType, matcher }) => ({ event, hookType, matcher })) + .sort((left, right) => left.event.localeCompare(right.event)), + [ + { event: 'PostToolUse', hookType: 'command', matcher: null }, + { event: 'PostToolUseFailure', hookType: 'command', matcher: null }, + { event: 'PreToolUse', hookType: 'command', matcher: null }, + ], + ); + } finally { + await fixture.close(); + } +}); diff --git a/test/grok-plugin.test.mjs b/test/grok-plugin.test.mjs index 925916e..3c289fa 100644 --- a/test/grok-plugin.test.mjs +++ b/test/grok-plugin.test.mjs @@ -57,6 +57,19 @@ test('Grok installs an exact managed triple and preserves valid user hooks', asy hooks: [{ type: 'http', url: 'https://example.test/hook', timeout: 0 }], label: 'keep group metadata', }], + SessionEnd: [{ + hooks: [{ type: 'command', command: 'session-end-command' }], + }], + Stop: [{ + hooks: [{ type: 'command', command: 'stop-command' }], + }], + UserPromptSubmit: [{ + hooks: [{ type: 'command', command: 'prompt-command' }], + }], + Notification: [{ + matcher: 'permission_prompt|idle_prompt', + hooks: [{ type: 'command', command: 'notification-command' }], + }], PreToolUse: [{ matcher: 'Bash|run_terminal_command', hooks: [{ type: 'command', command: 'existing-command', timeout: 5 }], @@ -72,6 +85,13 @@ test('Grok installs an exact managed triple and preserves valid user hooks', asy const firstDocument = await readGrokConfig(fixture.configPath); assert.equal(firstDocument.config.schemaVersion, 1); assert.deepEqual(firstDocument.config.hooks.SessionStart, existing.hooks.SessionStart); + assert.deepEqual(firstDocument.config.hooks.SessionEnd, existing.hooks.SessionEnd); + assert.deepEqual(firstDocument.config.hooks.Stop, existing.hooks.Stop); + assert.deepEqual( + firstDocument.config.hooks.UserPromptSubmit, + existing.hooks.UserPromptSubmit, + ); + assert.deepEqual(firstDocument.config.hooks.Notification, existing.hooks.Notification); assert.deepEqual(firstDocument.config.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); assert.equal(firstDocument.config.hooks.PreToolUse.length, 2); assertManagedTriple(firstDocument.config); @@ -87,14 +107,18 @@ test('Grok installs an exact managed triple and preserves valid user hooks', asy } }); -test('empty GROK_HOME resolves to the documented default', async () => { +test('missing GROK_HOME uses the default and an empty override fails safely', async () => { const fixture = await createFixture({ explicitGrokHome: false }); try { assert.equal((await fixture.install()).code, 0); fixture.grokHomeOverride = ''; const status = await runPlugin(fixture, 'status', null); - assert.equal(status.code, 0, status.stderr); - assert.equal(JSON.parse(status.stdout).installed, true); + assert.equal(status.code, 1); + assert.match(status.stderr, /GROK_HOME is empty.*unset.*absolute home/i); + const install = await fixture.install(); + assert.equal(install.code, 1); + assert.match(install.stderr, /GROK_HOME is empty.*unset.*absolute home/i); + await assertMissing(path.join(fixture.projectDir, 'hooks', 'elydora-audit.json')); } finally { await fixture.close(); } @@ -113,6 +137,25 @@ test('Grok parses the hook file before creating runtime files', async () => { } }); +test('Grok preserves an official null handler environment', async () => { + const existing = { + hooks: { + PreToolUse: [{ + hooks: [{ type: 'command', command: 'existing-command', env: null }], + }], + }, + }; + const fixture = await createFixture({ config: JSON.stringify(existing) }); + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const { config } = await readGrokConfig(fixture.configPath); + assert.deepEqual(config.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); + } finally { + await fixture.close(); + } +}); + test('Grok rejects inactive native hook shapes before writes', async (t) => { const cases = [ ['duplicate field', '{"hooks":{},"hooks":{}}', /duplicate field "hooks"/i], @@ -120,7 +163,10 @@ test('Grok rejects inactive native hook shapes before writes', async (t) => { ['event shape', JSON.stringify({ hooks: { PreToolUse: null } }), /must be an array/i], ['group shape', JSON.stringify({ hooks: { PreToolUse: [null] } }), /group.*must be an object/i], ['matcher shape', JSON.stringify({ hooks: { PreToolUse: [{ matcher: 1, hooks: [] }] } }), /matcher must be a string/i], - ['lifecycle matcher', JSON.stringify({ hooks: { SessionStart: [{ matcher: 'x', hooks: [] }] } }), /cannot declare a matcher/i], + ['SessionStart matcher', JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [] }] } }), /cannot declare a matcher/i], + ['SessionEnd matcher', JSON.stringify({ hooks: { SessionEnd: [{ matcher: 'logout', hooks: [] }] } }), /cannot declare a matcher/i], + ['Stop matcher', JSON.stringify({ hooks: { Stop: [{ matcher: 'ignored', hooks: [] }] } }), /cannot declare a matcher/i], + ['UserPromptSubmit matcher', JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: 'ignored', hooks: [] }] } }), /cannot declare a matcher/i], ['handler shape', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [null] }] } }), /handler.*must be an object/i], ['handler type', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'file' }] }] } }), /unsupported type/i], ['command value', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '' }] }] } }), /non-empty command/i],