fix(node): mirror Grok hook loader contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 23:54:35 -04:00
1 parent d627b784f2
commit f01fd92946
7 files changed
+120 -10

No files matched your search

+1 -1
View File
@@ -69,7 +69,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 `<QWEN_HOME>/.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.
+2 -1
View File
@@ -25,6 +25,7 @@ export interface GrokHandler extends JsonObject {
readonly command?: string;
readonly url?: string;
readonly timeout?: number;
readonly env?: Record<string, string> | 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`);
+6 -1
View File
@@ -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);
+7 -2
View File
@@ -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, {
+53
View File
@@ -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();
}
});
+50 -4
View File
@@ -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],