fix(node): align Kimi hooks with official contracts

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 04:07:19 -04:00
1 parent 4998d79c0b
commit 2eddd5cf88
17 files changed
+1831 -860

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
import os from 'node:os';
import path from 'node:path';
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export interface KimiRuntimeReference {
readonly agentId: string;
readonly scriptPath: string;
}
export function sameKimiPath(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 sameKimiAgentId(left: string, right: string): boolean {
return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function windowsPowerShellPath(): string {
const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined;
const systemRoot = configuredRoot
&& path.win32.isAbsolute(configuredRoot)
&& !/["%\r\n]/.test(configuredRoot)
? configuredRoot
: 'C:\\Windows';
return path.win32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
}
function windowsCommand(scriptPath: string): string {
const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
const encoded = Buffer.from(source, 'utf16le').toString('base64');
return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
}
export function buildKimiCommand(scriptPath: string): string {
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Kimi 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 {
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
if (!match
|| !path.win32.isAbsolute(match[1])
|| path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined;
const encoded = match[2];
const buffer = Buffer.from(encoded, 'base64');
if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined;
return parsePowerShellSource(buffer.toString('utf16le'));
}
function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined {
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
return match ? [match[1], match[2]] : undefined;
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
return basename === 'node' || basename === 'node.exe';
}
export function kimiRuntimeReference(
command: string,
scriptName: string,
): KimiRuntimeReference | undefined {
const parsed = parsePosixCommand(command)
?? parseWindowsCommand(command)
?? parseLegacyWindowsCommand(command);
if (!parsed
|| !path.isAbsolute(parsed[0])
|| !path.isAbsolute(parsed[1])
|| !isNodeExecutable(parsed[0])
|| path.basename(parsed[1]) !== scriptName) return undefined;
const agentDirectory = path.dirname(parsed[1]);
if (!sameKimiPath(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] };
}
+295
View File
@@ -0,0 +1,295 @@
import {
buildKimiCommand,
kimiRuntimeReference,
sameKimiAgentId,
type KimiRuntimeReference,
} from './kimi-command.js';
export const AGENT_KEY = 'kimi';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
const SHARED_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'UserPromptSubmit',
'Stop',
'StopFailure',
'SessionStart',
'SessionEnd',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PostCompact',
'Notification',
] as const;
export const STABLE_EVENTS = new Set<string>([
...SHARED_EVENTS,
'PermissionRequest',
'PermissionResult',
'Interrupt',
]);
export const LEGACY_EVENTS = new Set<string>(SHARED_EVENTS);
export type TomlObject = Record<string, unknown>;
export type ManagedKimiEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure';
export interface KimiHook extends TomlObject {
readonly event: string;
readonly command: string;
readonly matcher?: string;
readonly timeout?: number;
}
export interface KimiContract {
readonly generation: 'stable' | 'legacy';
readonly runtimeName: 'Kimi Code' | 'kimi-cli';
readonly label: string;
readonly directoryLabel: string;
readonly configPath: string;
readonly events: ReadonlySet<string>;
}
export interface KimiConfigDocument {
readonly contract: KimiContract;
readonly exists: boolean;
readonly root: TomlObject;
readonly hooks: readonly KimiHook[];
readonly usesHookTables: boolean;
readonly raw?: string;
}
export interface RenderedKimiDocument {
readonly document: KimiConfigDocument;
readonly changed: boolean;
readonly next?: string;
}
export interface KimiRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
readonly configPath: string;
}
function isObject(value: unknown): value is TomlObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasOwn(value: TomlObject, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function exactManagedKeys(hook: KimiHook): boolean {
return Object.keys(hook).sort().join('|') === 'command|event|timeout';
}
function managedReference(
hook: KimiHook,
event: ManagedKimiEvent,
scriptName: string,
): KimiRuntimeReference | undefined {
if (!exactManagedKeys(hook)
|| hook.event !== event
|| hook.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
return kimiRuntimeReference(hook.command, scriptName);
}
function managedAgentId(
hook: KimiHook,
event: ManagedKimiEvent,
scriptName: string,
): string | undefined {
return managedReference(hook, event, scriptName)?.agentId;
}
export function validateKimiHook(
value: unknown,
contract: KimiContract,
index: number,
): KimiHook {
if (!isObject(value)) throw new Error(`${contract.label} hook ${index + 1} must be a table`);
const supportedFields = new Set(['event', 'matcher', 'command', 'timeout']);
const unsupportedField = Object.keys(value).find((key) => !supportedFields.has(key));
if (unsupportedField) {
throw new Error(`${contract.label} hook ${index + 1} has unsupported field "${unsupportedField}"`);
}
if (typeof value.event !== 'string' || !contract.events.has(value.event)) {
throw new Error(
`${contract.label} hook ${index + 1} has unsupported event "${String(value.event)}"`,
);
}
if (typeof value.command !== 'string' || value.command.length === 0) {
throw new Error(`${contract.label} hook ${index + 1} requires a non-empty command`);
}
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
throw new Error(`${contract.label} hook ${index + 1} matcher must be a string`);
}
const timeout = value.timeout;
if (timeout !== undefined
&& (!Number.isInteger(timeout) || (timeout as number) < 1 || (timeout as number) > 600)) {
throw new Error(`${contract.label} hook ${index + 1} timeout must be an integer from 1 to 600`);
}
return value as KimiHook;
}
function readHooks(root: TomlObject, contract: KimiContract): readonly KimiHook[] {
if (root.hooks === undefined) return [];
if (!Array.isArray(root.hooks)) throw new Error(`${contract.label} field "hooks" must be an array`);
return root.hooks.map((hook, index) => validateKimiHook(hook, contract, index));
}
function documentUsesHookTables(nodes: readonly unknown[]): boolean {
return nodes.some((node) => {
if (!isObject(node) || node.type !== 'TableArray' || !isObject(node.key)) return false;
if (!isObject(node.key.item) || !Array.isArray(node.key.item.value)) return false;
return node.key.item.value.length === 1 && node.key.item.value[0] === 'hooks';
});
}
export async function parseKimiDocument(
contract: KimiContract,
raw: string,
): Promise<KimiConfigDocument> {
let parsed: { readonly toJsObject: unknown; readonly cst: readonly unknown[] };
try {
const { TomlDocument } = await import('@decimalturn/toml-patch');
parsed = new TomlDocument(raw);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse ${contract.label} at ${contract.configPath}: ${message}`, {
cause: error instanceof Error ? error : new Error(message),
});
}
if (!isObject(parsed.toJsObject)) {
throw new Error(`${contract.label} at ${contract.configPath} must contain a TOML table`);
}
return {
contract,
exists: true,
root: parsed.toJsObject,
hooks: readHooks(parsed.toJsObject, contract),
usesHookTables: documentUsesHookTables(parsed.cst),
raw,
};
}
export function createKimiDocument(contract: KimiContract): KimiConfigDocument {
return { contract, exists: false, root: {}, hooks: [], usesHookTables: false };
}
async function renderHookSource(
document: KimiConfigDocument,
hooks: readonly KimiHook[],
): Promise<string> {
const {
patch: patchToml,
stringify: stringifyToml,
TomlFormat,
} = await import('@decimalturn/toml-patch');
const nextRoot: TomlObject = { ...document.root, hooks };
if (document.usesHookTables) return patchToml(document.raw ?? '', nextRoot);
let base = document.raw ?? '';
if (hasOwn(document.root, 'hooks')) {
const rootWithoutHooks = { ...document.root };
delete rootWithoutHooks.hooks;
base = patchToml(base, rootWithoutHooks);
}
const format = base.length > 0
? TomlFormat.autoDetectFormat(base)
: TomlFormat.default();
const hookTables = hooks.length > 0 ? stringifyToml({ hooks }, format) : '';
if (base.length === 0) return hookTables;
if (hookTables.length === 0) return base;
const newline = format.newLine;
const separator = base.endsWith(newline + newline)
? ''
: base.endsWith(newline) ? newline : newline + newline;
return base + separator + hookTables;
}
export async function renderKimiDocument(
document: KimiConfigDocument,
hooks: readonly KimiHook[],
): Promise<RenderedKimiDocument> {
if (!document.exists && hooks.length === 0) return { document, changed: false };
const next = await renderHookSource(document, hooks);
if (hooks.length === 0 && next.trim().length === 0) {
return { document, changed: document.exists };
}
return { document, changed: next !== document.raw, next };
}
export function buildKimiHook(event: ManagedKimiEvent, scriptPath: string): KimiHook {
return {
event,
command: buildKimiCommand(scriptPath),
timeout: HOOK_TIMEOUT_SECONDS,
};
}
function expectedManagedContract(event: string): readonly [ManagedKimiEvent, string] | undefined {
if (event === 'PreToolUse') return ['PreToolUse', GUARD_SCRIPT];
if (event === 'PostToolUse') return ['PostToolUse', AUDIT_SCRIPT];
if (event === 'PostToolUseFailure') return ['PostToolUseFailure', AUDIT_SCRIPT];
return undefined;
}
export function removeManagedKimiHooks(
hooks: readonly KimiHook[],
agentId?: string,
): KimiHook[] {
return hooks.filter((hook) => {
const expected = expectedManagedContract(hook.event);
if (!expected) return true;
const managedId = managedAgentId(hook, expected[0], expected[1]);
if (!managedId) return true;
return agentId !== undefined && !sameKimiAgentId(managedId, agentId);
});
}
function referencesForEvent(
hooks: readonly KimiHook[],
event: ManagedKimiEvent,
scriptName: string,
): Map<string, KimiRuntimeReference[]> {
const result = new Map<string, KimiRuntimeReference[]>();
for (const hook of hooks) {
const reference = managedReference(hook, event, scriptName);
if (!reference) continue;
const key = process.platform === 'win32'
? reference.agentId.toLowerCase()
: reference.agentId;
const existing = result.get(key) ?? [];
existing.push(reference);
result.set(key, existing);
}
return result;
}
export function kimiRuntimeContracts(
documents: readonly KimiConfigDocument[],
): KimiRuntimeContract[] {
const contracts: KimiRuntimeContract[] = [];
for (const document of documents) {
const guards = referencesForEvent(document.hooks, 'PreToolUse', GUARD_SCRIPT);
const successes = referencesForEvent(document.hooks, 'PostToolUse', AUDIT_SCRIPT);
const failures = referencesForEvent(document.hooks, 'PostToolUseFailure', AUDIT_SCRIPT);
for (const [key, guard] of guards) {
const success = successes.get(key);
const failure = failures.get(key);
if (guard.length !== 1 || success?.length !== 1 || failure?.length !== 1) continue;
contracts.push({
agentId: guard[0].agentId,
guardPath: guard[0].scriptPath,
auditPath: success[0].scriptPath,
configPath: document.contract.configPath,
});
}
}
return contracts;
}
+110
View File
@@ -0,0 +1,110 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type KimiConfigDocument,
type RenderedKimiDocument,
} from './kimi-contract.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'Kimi';
export type KimiRuntimePaths = ManagedRuntimePaths;
export type PreparedKimiInstallation = PreparedManagedInstallation;
export type { RenameFile };
function hookLocations(documents: readonly KimiConfigDocument[]) {
return documents.map((document) => ({
directoryLabel: document.contract.directoryLabel,
filePath: document.contract.configPath,
}));
}
export async function preflightKimiInstallation(
config: InstallConfig,
documents: readonly KimiConfigDocument[],
): Promise<KimiRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: hookLocations(documents),
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
function renderedSource(rendered: RenderedKimiDocument): string {
const source = rendered.next ?? rendered.document.raw;
if (source === undefined) {
throw new Error(`${rendered.document.contract.label} installation source is missing`);
}
return source;
}
export async function prepareKimiInstallation(
config: InstallConfig,
rendered: readonly RenderedKimiDocument[],
): Promise<PreparedKimiInstallation> {
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources: rendered.map((item) => ({
directoryLabel: item.document.contract.directoryLabel,
label: item.document.contract.label,
filePath: item.document.contract.configPath,
expectedSource: item.document.raw,
source: renderedSource(item),
})),
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitKimiInstallation(
prepared: PreparedKimiInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
export async function prepareKimiUninstall(
rendered: readonly RenderedKimiDocument[],
): Promise<PreparedManagedTransaction> {
const changed = rendered.filter((item) => item.changed);
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
filePath: item.document.contract.configPath,
label: item.document.contract.label,
next: item.next,
mode: 0o600,
expectedSource: item.document.raw,
verifyExpectedSource: true,
})));
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: changed.map((item) => ({
path: path.dirname(item.document.contract.configPath),
label: item.document.contract.directoryLabel,
})),
changes: changes.filter((change) => change !== undefined),
};
}
export async function commitKimiUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+177
View File
@@ -0,0 +1,177 @@
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
AGENT_KEY,
LEGACY_EVENTS,
STABLE_EVENTS,
createKimiDocument,
parseKimiDocument,
type KimiConfigDocument,
type KimiContract,
type KimiRuntimeContract,
type TomlObject,
} from './kimi-contract.js';
import { sameKimiAgentId, sameKimiPath } from './kimi-command.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject } from './strict-json.js';
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function pathEntryExists(filePath: string, label: string): Promise<boolean> {
try {
await fsp.lstat(filePath);
return true;
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
function stableContract(configPath: string): KimiContract {
return {
generation: 'stable',
runtimeName: 'Kimi Code',
label: 'Kimi Code hooks config',
directoryLabel: 'Kimi Code home directory',
configPath,
events: STABLE_EVENTS,
};
}
function legacyContract(configPath: string): KimiContract {
return {
generation: 'legacy',
runtimeName: 'kimi-cli',
label: 'kimi-cli legacy hooks config',
directoryLabel: 'kimi-cli legacy home directory',
configPath,
events: LEGACY_EVENTS,
};
}
export async function resolveKimiContracts(): Promise<KimiContract[]> {
const home = os.homedir();
const configuredHome = process.env.KIMI_CODE_HOME;
const explicitHome = configuredHome === undefined || configuredHome === ''
? undefined
: path.resolve(configuredHome);
const stableHome = explicitHome ?? path.join(home, '.kimi-code');
const legacyHome = path.join(home, '.kimi');
const stable = stableContract(path.join(stableHome, 'config.toml'));
const legacy = legacyContract(path.join(legacyHome, 'config.toml'));
if (sameKimiPath(stable.configPath, legacy.configPath)) return [stable];
const stableDetected = explicitHome !== undefined
|| await pathEntryExists(stableHome, 'Kimi Code home');
const legacyDetected = await pathEntryExists(legacyHome, 'kimi-cli legacy home');
if (legacyDetected && !stableDetected) return [legacy];
return legacyDetected ? [stable, legacy] : [stable];
}
async function readKimiDocument(contract: KimiContract): Promise<KimiConfigDocument> {
await inspectPhysicalDirectory(path.dirname(contract.configPath), contract.directoryLabel);
const snapshot = await readPhysicalFile(contract.configPath, contract.label);
return snapshot
? parseKimiDocument(contract, snapshot.contents)
: createKimiDocument(contract);
}
export async function readKimiDocuments(): Promise<KimiConfigDocument[]> {
const contracts = await resolveKimiContracts();
const documents: KimiConfigDocument[] = [];
for (const contract of contracts) documents.push(await readKimiDocument(contract));
return documents;
}
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: TomlObject,
contract: KimiRuntimeContract,
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 (!sameKimiAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
throw new Error(`Elydora runtime identity does not match Kimi 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: asError(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: KimiRuntimeContract): Promise<boolean> {
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
if (!sameKimiPath(path.dirname(agentDirectory), runtimeRoot)
|| !sameKimiPath(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 kimiRuntimeFilesExist(
contracts: readonly KimiRuntimeContract[],
): Promise<boolean> {
for (const contract of contracts) {
if (await runtimeContractExists(contract)) return true;
}
return false;
}
+55 -464
View File
@@ -1,491 +1,82 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
buildKimiHook,
kimiRuntimeContracts,
removeManagedKimiHooks,
renderKimiDocument,
type KimiConfigDocument,
type KimiHook,
} from './kimi-contract.js';
import {
commitKimiInstallation,
commitKimiUninstall,
preflightKimiInstallation,
prepareKimiInstallation,
prepareKimiUninstall,
} from './kimi-installation.js';
import { kimiRuntimeFilesExist, readKimiDocuments } from './kimi-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'kimi';
const GUARD_SCRIPT = 'guard.js';
const AUDIT_SCRIPT = 'hook.js';
const HOOK_TIMEOUT_SECONDS = 10;
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
const SHARED_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'UserPromptSubmit',
'Stop',
'StopFailure',
'SessionStart',
'SessionEnd',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PostCompact',
'Notification',
] as const;
const MODERN_EVENTS = new Set<string>([
...SHARED_EVENTS,
'PermissionRequest',
'PermissionResult',
'Interrupt',
]);
const LEGACY_EVENTS = new Set<string>(SHARED_EVENTS);
type TomlObject = Record<string, unknown>;
interface KimiHook extends TomlObject {
readonly event: string;
readonly command: string;
readonly matcher?: string;
readonly timeout?: number;
}
interface KimiContract {
readonly runtimeName: 'Kimi Code' | 'kimi-cli';
readonly label: string;
readonly configPath: string;
readonly events: ReadonlySet<string>;
}
interface KimiConfigDocument {
readonly contract: KimiContract;
readonly exists: boolean;
readonly raw: string;
readonly root: TomlObject;
readonly hooks: KimiHook[];
readonly usesHookTables: boolean;
}
interface RuntimeContract {
readonly guard: string;
readonly audit: string;
readonly configPath: string;
}
type ConfigMutation =
| { readonly kind: 'none' }
| { readonly kind: 'remove'; readonly document: KimiConfigDocument }
| { readonly kind: 'write'; readonly document: KimiConfigDocument; readonly raw: string };
function isObject(value: unknown): value is TomlObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasOwn(value: TomlObject, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function hasErrorCode(error: unknown, code: string): boolean {
return isObject(error) && error.code === code;
}
async function pathExists(filePath: string, label: string): Promise<boolean> {
try {
await fsp.stat(filePath);
return true;
} catch (error) {
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) });
}
}
async function legacyCliOnPath(): Promise<boolean> {
const pathValue = process.env.PATH;
if (!pathValue) return false;
const names = process.platform === 'win32'
? ['kimi-cli.exe', 'kimi-cli.cmd', 'kimi-cli.bat', 'kimi-cli.com', 'kimi-cli']
: ['kimi-cli'];
for (const value of pathValue.split(path.delimiter)) {
const directory = value.replace(/^"(.*)"$/, '$1');
if (!directory) continue;
for (const name of names) {
const candidate = path.join(directory, name);
try {
const stats = await fsp.stat(candidate);
if (stats.isFile() && (process.platform === 'win32' || (stats.mode & 0o111) !== 0)) return true;
} catch (error) {
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) continue;
throw new Error(`Inspect kimi-cli executable at ${candidate}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
}
return false;
}
async function resolveContracts(): Promise<KimiContract[]> {
const home = os.homedir();
const explicitKimiHome = process.env.KIMI_CODE_HOME || undefined;
const kimiHome = explicitKimiHome ?? path.join(home, '.kimi-code');
const modern: KimiContract = {
runtimeName: 'Kimi Code',
label: 'Kimi Code hooks config',
configPath: path.join(kimiHome, 'config.toml'),
events: MODERN_EVENTS,
};
const legacy: KimiContract = {
runtimeName: 'kimi-cli',
label: 'kimi-cli legacy hooks config',
configPath: path.join(home, '.kimi', 'config.toml'),
events: LEGACY_EVENTS,
};
const modernDetected = explicitKimiHome !== undefined || await pathExists(kimiHome, 'Kimi Code home');
const legacyDetected = await pathExists(legacy.configPath, legacy.label) || await legacyCliOnPath();
if (legacyDetected && !modernDetected) return [legacy];
return legacyDetected ? [modern, legacy] : [modern];
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
}
function buildCommand(scriptPath: string): string {
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
return `${quote(process.execPath)} ${quote(scriptPath)}`;
}
function buildHook(event: 'PreToolUse' | 'PostToolUse', scriptPath: string): KimiHook {
return { event, command: buildCommand(scriptPath), timeout: HOOK_TIMEOUT_SECONDS };
}
function validateHook(value: unknown, contract: KimiContract, index: number): KimiHook {
if (!isObject(value)) {
throw new Error(`${contract.label} hook ${index + 1} must be a table`);
}
const supportedFields = new Set(['event', 'matcher', 'command', 'timeout']);
const unsupportedField = Object.keys(value).find((key) => !supportedFields.has(key));
if (unsupportedField) {
throw new Error(`${contract.label} hook ${index + 1} has unsupported field "${unsupportedField}"`);
}
if (typeof value.event !== 'string' || !contract.events.has(value.event)) {
throw new Error(`${contract.label} hook ${index + 1} has unsupported event "${String(value.event)}"`);
}
if (typeof value.command !== 'string' || value.command.length === 0) {
throw new Error(`${contract.label} hook ${index + 1} requires a non-empty command`);
}
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
throw new Error(`${contract.label} hook ${index + 1} matcher must be a string`);
}
if (
value.timeout !== undefined
&& (!Number.isInteger(value.timeout) || (value.timeout as number) < 1 || (value.timeout as number) > 600)
) {
throw new Error(`${contract.label} hook ${index + 1} timeout must be an integer from 1 to 600`);
}
return value as KimiHook;
}
function readHooks(root: TomlObject, contract: KimiContract): KimiHook[] {
if (root.hooks === undefined) return [];
if (!Array.isArray(root.hooks)) throw new Error(`${contract.label} field "hooks" must be an array`);
return root.hooks.map((hook, index) => validateHook(hook, contract, index));
}
function documentUsesHookTables(nodes: readonly unknown[]): boolean {
return nodes.some((node) => {
if (!isObject(node) || node.type !== 'TableArray' || !isObject(node.key)) return false;
if (!isObject(node.key.item) || !Array.isArray(node.key.item.value)) return false;
return node.key.item.value.length === 1 && node.key.item.value[0] === 'hooks';
});
}
async function readConfig(contract: KimiContract): Promise<KimiConfigDocument> {
let raw: string;
try {
raw = await fsp.readFile(contract.configPath, 'utf-8');
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
return { contract, exists: false, raw: '', root: {}, hooks: [], usesHookTables: false };
}
throw new Error(`Read ${contract.label} at ${contract.configPath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
let document: { readonly toJsObject: unknown; readonly cst: readonly unknown[] };
try {
const { TomlDocument } = await import('@decimalturn/toml-patch');
document = new TomlDocument(raw);
} catch (error) {
throw new Error(`Failed to parse ${contract.label} at ${contract.configPath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
const root: unknown = document.toJsObject;
if (!isObject(root)) throw new Error(`${contract.label} at ${contract.configPath} must contain a TOML table`);
return {
contract,
exists: true,
raw,
root,
hooks: readHooks(root, contract),
usesHookTables: documentUsesHookTables(document.cst),
};
}
async function readAllConfigs(): Promise<KimiConfigDocument[]> {
const documents: KimiConfigDocument[] = [];
for (const contract of await resolveContracts()) documents.push(await readConfig(contract));
return documents;
}
async function renderHooks(document: KimiConfigDocument, hooks: KimiHook[]): Promise<string> {
const {
patch: patchToml,
stringify: stringifyToml,
TomlFormat,
} = await import('@decimalturn/toml-patch');
const nextRoot: TomlObject = { ...document.root, hooks };
if (document.usesHookTables) return patchToml(document.raw, nextRoot);
let base = document.raw;
if (hasOwn(document.root, 'hooks')) {
const rootWithoutHooks = { ...document.root };
delete rootWithoutHooks.hooks;
base = patchToml(document.raw, rootWithoutHooks);
}
const format = document.raw.length > 0
? TomlFormat.autoDetectFormat(document.raw)
: TomlFormat.default();
const hookTables = stringifyToml({ hooks }, format);
if (base.length === 0) return hookTables;
const newline = format.newLine;
const separator = base.endsWith(newline + newline) ? '' : base.endsWith(newline) ? newline : newline + newline;
return base + separator + hookTables;
}
function normalizeForComparison(value: string): string {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
function commandReferencesPath(command: string, filePath: string): boolean {
return normalizeForComparison(command).includes(normalizeForComparison(filePath));
}
function isManagedHook(
hook: KimiHook,
event: 'PreToolUse' | 'PostToolUse',
scriptName: string,
agentId?: string,
): boolean {
if (hook.event !== event) return false;
if (agentId) {
const scriptPath = path.join(os.homedir(), '.elydora', agentId, scriptName);
return commandReferencesPath(hook.command, scriptPath);
}
const command = normalizeForComparison(hook.command);
const runtimeMarker = normalizeForComparison(`${path.sep}.elydora${path.sep}`);
const scriptMarker = normalizeForComparison(`${path.sep}${scriptName}`);
return command.includes(runtimeMarker) && command.includes(scriptMarker);
}
function withoutManagedHooks(hooks: KimiHook[], agentId?: string): KimiHook[] {
return hooks.filter((hook) => !isManagedHook(hook, 'PreToolUse', GUARD_SCRIPT, agentId)
&& !isManagedHook(hook, 'PostToolUse', AUDIT_SCRIPT, agentId));
}
async function failWrite(
handle: FileHandle | undefined,
tempPath: string,
function installedHooks(
document: KimiConfigDocument,
cause: unknown,
): Promise<never> {
const errors = [asError(cause)];
if (handle) {
try {
await handle.close();
} catch (error) {
errors.push(asError(error));
}
}
try {
await fsp.unlink(tempPath);
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error));
}
const message = `Write ${document.contract.label} at ${document.contract.configPath}: ${errorMessage(cause)}`;
if (errors.length > 1) throw new AggregateError(errors, message);
throw new Error(message, { cause: errors[0] });
}
async function writeConfig(document: KimiConfigDocument, raw: string): Promise<void> {
const configPath = document.contract.configPath;
const directory = path.dirname(configPath);
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`);
let handle: FileHandle | undefined;
try {
handle = await fsp.open(tempPath, 'wx', 0o600);
await handle.writeFile(raw, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
await fsp.rename(tempPath, configPath);
} catch (error) {
await failWrite(handle, tempPath, document, error);
}
}
async function removeConfig(document: KimiConfigDocument): Promise<void> {
try {
await fsp.unlink(document.contract.configPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return;
throw new Error(
`Remove ${document.contract.label} at ${document.contract.configPath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
}
async function regularFileExists(filePath: string, label: string): Promise<boolean> {
try {
return (await fsp.stat(filePath)).isFile();
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return false;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) });
}
}
async function requireRuntime(filePath: string, label: string): Promise<void> {
if (!filePath) throw new Error(`${label} path is required`);
if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`);
}
async function readJsonObject(filePath: string, label: string): Promise<TomlObject | undefined> {
let raw: string;
try {
raw = await fsp.readFile(filePath, 'utf-8');
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return undefined;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) });
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`);
return value;
}
function findRuntimeContract(document: KimiConfigDocument): RuntimeContract | undefined {
const guard = document.hooks.find((hook) => isManagedHook(hook, 'PreToolUse', GUARD_SCRIPT));
const audit = document.hooks.find((hook) => isManagedHook(hook, 'PostToolUse', AUDIT_SCRIPT));
return guard && audit
? { guard: guard.command, audit: audit.command, configPath: document.contract.configPath }
: undefined;
}
async function runtimeScriptsExist(contracts: RuntimeContract[]): Promise<boolean> {
const root = path.join(os.homedir(), '.elydora');
let entries: Array<{ isDirectory(): boolean; name: string }>;
try {
entries = await fsp.readdir(root, { withFileTypes: true });
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return false;
throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
for (const directory of entries) {
if (!directory.isDirectory()) continue;
const agentDir = path.join(root, directory.name);
const guardPath = path.join(agentDir, GUARD_SCRIPT);
const auditPath = path.join(agentDir, AUDIT_SCRIPT);
if (!contracts.some(({ guard, audit }) => commandReferencesPath(guard, guardPath)
&& commandReferencesPath(audit, auditPath))) continue;
const runtimeConfigPath = path.join(agentDir, 'config.json');
const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config');
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
const [guardExists, auditExists] = await Promise.all([
regularFileExists(guardPath, 'Elydora guard runtime'),
regularFileExists(auditPath, 'Elydora audit runtime'),
]);
return guardExists && auditExists;
}
return false;
}
async function applyMutation(mutation: ConfigMutation): Promise<void> {
if (mutation.kind === 'write') await writeConfig(mutation.document, mutation.raw);
if (mutation.kind === 'remove') await removeConfig(mutation.document);
guardPath: string,
auditPath: string,
): KimiHook[] {
return [
...removeManagedKimiHooks(document.hooks),
buildKimiHook('PreToolUse', guardPath),
buildKimiHook('PostToolUse', auditPath),
buildKimiHook('PostToolUseFailure', auditPath),
];
}
export const kimiPlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const documents = await readKimiDocuments();
await preflightKimiInstallation(config, documents);
},
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error('agentId is required');
const documents = await readAllConfigs();
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
const mutations: ConfigMutation[] = [];
for (const document of documents) {
const hooks = [
...withoutManagedHooks(document.hooks),
buildHook('PreToolUse', config.guardScriptPath),
buildHook('PostToolUse', config.hookScriptPath),
];
mutations.push({ kind: 'write', document, raw: await renderHooks(document, hooks) });
}
for (const mutation of mutations) await applyMutation(mutation);
const documents = await readKimiDocuments();
const paths = await preflightKimiInstallation(config, documents);
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(
document,
installedHooks(document, paths.guardPath, paths.auditPath),
)));
const prepared = await prepareKimiInstallation(config, rendered);
await commitKimiInstallation(prepared);
const runtimes = documents.map(({ contract }) => contract.runtimeName).join(' and ');
console.log(` ${runtimes}: global PreToolUse and PostToolUse hooks installed.`);
console.log(
` ${runtimes}: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.`,
);
},
async uninstall(agentId?: string): Promise<void> {
const documents = await readAllConfigs();
const mutations: ConfigMutation[] = [];
for (const document of documents) {
const hooks = withoutManagedHooks(document.hooks, agentId);
if (hooks.length === document.hooks.length) {
mutations.push({ kind: 'none' });
continue;
}
const raw = await renderHooks(document, hooks);
mutations.push(raw.trim().length === 0
? { kind: 'remove', document }
: { kind: 'write', document, raw });
}
for (const mutation of mutations) await applyMutation(mutation);
const documents = await readKimiDocuments();
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(
document,
removeManagedKimiHooks(document.hooks, agentId),
)));
await commitKimiUninstall(await prepareKimiUninstall(rendered));
},
async status(): Promise<PluginStatus> {
const documents = await readAllConfigs();
const contracts = documents
.map(findRuntimeContract)
.filter((contract): contract is RuntimeContract => contract !== undefined);
const documents = await readKimiDocuments();
const contracts = kimiRuntimeContracts(documents);
const hookConfigured = contracts.length > 0;
const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false;
const hookScriptExists = hookConfigured && await kimiRuntimeFilesExist(contracts);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath: contracts.at(-1)?.configPath ?? documents[0]!.contract.configPath,
configPath: contracts.at(-1)?.configPath ?? documents[0].contract.configPath,
};
},
};
+18 -11
View File
@@ -36,6 +36,7 @@ export interface ManagedDirectory {
export interface PreparedManagedTransaction {
readonly displayName: string;
readonly operation?: 'install' | 'uninstall';
readonly directories: readonly ManagedDirectory[];
readonly changes: readonly ManagedFileChange[];
}
@@ -98,7 +99,7 @@ export async function prepareManagedFileChange(
};
}
async function assertUnchanged(change: ManagedFileChange, displayName: string): Promise<void> {
async function assertUnchanged(change: ManagedFileChange, operationName: string): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes);
if ((!current && change.original)
|| (current && !change.original)
@@ -107,7 +108,7 @@ async function assertUnchanged(change: ManagedFileChange, displayName: string):
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during ${displayName} installation: ${change.filePath}`);
throw new Error(`${change.label} changed during ${operationName}: ${change.filePath}`);
}
}
@@ -158,8 +159,8 @@ async function reservePath(filePath: string, label: string): Promise<void> {
}
}
async function stage(change: ManagedFileChange, displayName: string): Promise<StagedChange> {
await assertUnchanged(change, displayName);
async function stage(change: ManagedFileChange, operationName: string): Promise<StagedChange> {
await assertUnchanged(change, operationName);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = change.next === undefined
@@ -205,10 +206,10 @@ async function stage(change: ManagedFileChange, displayName: string): Promise<St
async function commit(
staged: StagedChange,
displayName: string,
operationName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, displayName);
await assertUnchanged(staged.change, operationName);
if (staged.change.next === undefined) {
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.change.filePath, staged.rollbackPath);
@@ -319,12 +320,18 @@ export async function commitManagedTransaction(
transaction: PreparedManagedTransaction,
renameFile: RenameFile = fsp.rename,
): Promise<void> {
const operationName = transaction.operation === 'uninstall'
? `${transaction.displayName} uninstall`
: `${transaction.displayName} installation`;
const failureLabel = transaction.operation === 'uninstall'
? `Uninstall ${transaction.displayName} hooks`
: `Install ${transaction.displayName} hooks`;
const targets = new Set<string>();
for (const change of transaction.changes) {
const key = targetKey(change.filePath);
if (targets.has(key)) {
throw new Error(
`${transaction.displayName} installation contains duplicate file target ${change.filePath}`,
`${operationName} contains duplicate file target ${change.filePath}`,
);
}
targets.add(key);
@@ -334,8 +341,8 @@ export async function commitManagedTransaction(
}
const staged: StagedChange[] = [];
try {
for (const change of transaction.changes) staged.push(await stage(change, transaction.displayName));
for (const item of staged) await commit(item, transaction.displayName, renameFile);
for (const change of transaction.changes) staged.push(await stage(change, operationName));
for (const item of staged) await commit(item, operationName, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
@@ -350,7 +357,7 @@ export async function commitManagedTransaction(
}
throw new AggregateError(
failures,
`Install ${transaction.displayName} hooks: ${errorMessage(error)}`,
`${failureLabel}: ${errorMessage(error)}`,
);
}
const failures: Error[] = [];
@@ -360,7 +367,7 @@ export async function commitManagedTransaction(
if (failures.length > 0) {
throw new AggregateError(
failures,
`Clean ${transaction.displayName} installation transaction files`,
`Clean ${operationName} transaction files`,
);
}
}