304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
assertManagedHandler,
|
|
cliPath,
|
|
createFixture,
|
|
legacyCommand,
|
|
managedHandler,
|
|
readGrokConfig,
|
|
registryModuleUrl,
|
|
runNode,
|
|
runPlugin,
|
|
} from '../test-support/grok-test-helpers.mjs';
|
|
|
|
async function assertMissing(filePath) {
|
|
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
|
}
|
|
|
|
function assertManagedTriple(config) {
|
|
assertManagedHandler(managedHandler(config, 'PreToolUse'));
|
|
assertManagedHandler(managedHandler(config, 'PostToolUse'));
|
|
assertManagedHandler(managedHandler(config, 'PostToolUseFailure'));
|
|
assert.equal(
|
|
managedHandler(config, 'PostToolUse').command,
|
|
managedHandler(config, 'PostToolUseFailure').command,
|
|
);
|
|
}
|
|
|
|
test('Grok Build is registered in the SDK and CLI', async () => {
|
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
|
assert.deepEqual(SUPPORTED_AGENTS.get('grok'), {
|
|
name: 'Grok Build',
|
|
configDir: '~/.grok/hooks',
|
|
configFile: 'elydora-audit.json',
|
|
});
|
|
const fixture = await createFixture();
|
|
try {
|
|
const result = await runNode(['--no-warnings', cliPath, 'status'], {
|
|
HOME: fixture.homeDir,
|
|
USERPROFILE: fixture.homeDir,
|
|
GROK_HOME: fixture.grokHome,
|
|
}, fixture.projectDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.match(result.stdout, /Grok Build \(grok\)/);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Grok installs an exact managed triple and preserves valid user hooks', async () => {
|
|
const existing = {
|
|
schemaVersion: 1,
|
|
hooks: {
|
|
SessionStart: [{
|
|
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 }],
|
|
}],
|
|
},
|
|
};
|
|
const source = `${JSON.stringify(existing, null, 2)}\n`;
|
|
const fixture = await createFixture({ config: source });
|
|
try {
|
|
const first = await fixture.install();
|
|
assert.equal(first.code, 0, first.stderr);
|
|
assert.match(first.stdout, /PostToolUseFailure hooks installed/i);
|
|
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);
|
|
|
|
const second = await fixture.install();
|
|
assert.equal(second.code, 0, second.stderr);
|
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), firstDocument.raw);
|
|
await assertMissing(path.join(fixture.homeDir, '.grok', 'hooks', 'elydora-audit.json'));
|
|
await assertMissing(path.join(fixture.homeDir, '.claude', 'settings.json'));
|
|
await assertMissing(path.join(fixture.homeDir, '.cursor', 'hooks.json'));
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
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, 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();
|
|
}
|
|
});
|
|
|
|
test('Grok parses the hook file before creating runtime files', async () => {
|
|
const fixture = await createFixture({ config: '{ malformed' });
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, /parse Grok user hooks/i);
|
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed');
|
|
await assertMissing(fixture.agentDir);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
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],
|
|
['hooks shape', JSON.stringify({ hooks: null }), /field "hooks" must be an object/i],
|
|
['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],
|
|
['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],
|
|
['http value', JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'http', url: '' }] }] } }), /non-empty url/i],
|
|
['negative timeout', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: -1 }] }] } }), /non-negative integer/i],
|
|
['fraction timeout', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 1.5 }] }] } }), /non-negative integer/i],
|
|
['environment shape', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', env: { A: 1 } }] }] } }), /env must map names to strings/i],
|
|
];
|
|
for (const [name, config, pattern] of cases) {
|
|
await t.test(name, async () => {
|
|
const fixture = await createFixture({ config });
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, pattern);
|
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), config);
|
|
await assertMissing(fixture.agentDir);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test('Grok migrates exact legacy commands and preserves ownership lookalikes', async () => {
|
|
const fixture = await createFixture();
|
|
const lookalike = `${legacyCommand(fixture.guardScriptPath)} --inspect`;
|
|
const legacy = {
|
|
hooks: {
|
|
PreToolUse: [
|
|
{ hooks: [{ type: 'command', command: legacyCommand(fixture.guardScriptPath), timeout: 10 }] },
|
|
{ hooks: [{ type: 'command', command: lookalike, timeout: 10 }] },
|
|
],
|
|
PostToolUse: [{ hooks: [{
|
|
type: 'command',
|
|
command: legacyCommand(fixture.hookScriptPath),
|
|
timeout: 10,
|
|
}] }],
|
|
},
|
|
};
|
|
try {
|
|
await mkdir(path.dirname(fixture.configPath), { recursive: true });
|
|
await writeFile(fixture.configPath, JSON.stringify(legacy, null, 2));
|
|
const install = await fixture.install();
|
|
assert.equal(install.code, 0, install.stderr);
|
|
let { config } = await readGrokConfig(fixture.configPath);
|
|
assertManagedTriple(config);
|
|
assert(config.hooks.PreToolUse.some((group) => group.hooks[0].command === lookalike));
|
|
|
|
const managedGroup = config.hooks.PreToolUse.at(-1);
|
|
managedGroup.hooks.push({ type: 'command', command: 'user-command', timeout: 10 });
|
|
await writeFile(fixture.configPath, JSON.stringify(config, null, 2));
|
|
const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1');
|
|
assert.equal(uninstall.code, 0, uninstall.stderr);
|
|
config = (await readGrokConfig(fixture.configPath)).config;
|
|
assert.deepEqual(config.hooks.PreToolUse.flatMap((group) => group.hooks), [
|
|
{ type: 'command', command: lookalike, timeout: 10 },
|
|
{ type: 'command', command: 'user-command', timeout: 10 },
|
|
]);
|
|
assert.equal(config.hooks.PostToolUse, undefined);
|
|
assert.equal(config.hooks.PostToolUseFailure, undefined);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Grok uninstall preserves user config and removes a fully managed file', async () => {
|
|
const userSource = `${JSON.stringify({ owner: 'user', hooks: { Notification: [] } }, null, 2)}\n`;
|
|
const userFixture = await createFixture({ config: userSource });
|
|
try {
|
|
assert.equal((await userFixture.install()).code, 0);
|
|
const result = await runPlugin(userFixture, 'uninstall', 'agent-1');
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.deepEqual((await readGrokConfig(userFixture.configPath)).config, {
|
|
owner: 'user',
|
|
hooks: { Notification: [] },
|
|
});
|
|
} finally {
|
|
await userFixture.close();
|
|
}
|
|
|
|
const managedFixture = await createFixture();
|
|
try {
|
|
assert.equal((await managedFixture.install()).code, 0);
|
|
const result = await runPlugin(managedFixture, 'uninstall', 'agent-1');
|
|
assert.equal(result.code, 0, result.stderr);
|
|
await assertMissing(managedFixture.configPath);
|
|
} finally {
|
|
await managedFixture.close();
|
|
}
|
|
});
|
|
|
|
test('Grok status requires one complete triple, exact identity, and private key', async () => {
|
|
const fixture = await createFixture();
|
|
try {
|
|
assert.equal((await fixture.install()).code, 0);
|
|
let status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
|
|
|
let { config } = await readGrokConfig(fixture.configPath);
|
|
delete config.hooks.PostToolUseFailure;
|
|
await writeFile(fixture.configPath, JSON.stringify(config, null, 2));
|
|
status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
|
|
|
assert.equal((await fixture.install()).code, 0);
|
|
config = (await readGrokConfig(fixture.configPath)).config;
|
|
config.hooks.PostToolUseFailure.push(config.hooks.PostToolUseFailure.at(-1));
|
|
await writeFile(fixture.configPath, JSON.stringify(config, null, 2));
|
|
status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
|
|
|
assert.equal((await fixture.install()).code, 0);
|
|
await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid');
|
|
status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(status.code, 1);
|
|
assert.match(status.stderr, /private key is invalid/i);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Grok installation leaves no transaction files', async () => {
|
|
const fixture = await createFixture();
|
|
try {
|
|
assert.equal((await fixture.install()).code, 0);
|
|
for (const directory of [fixture.agentDir, path.dirname(fixture.configPath)]) {
|
|
const names = await readdir(directory);
|
|
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
|
|
}
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|