fix: align Kimi hooks with official contracts
This commit is contained in:
1 parent
9290c1c6f1
commit
d8d4e8d336
13 files changed
+1746
-858
No files matched your search
@@ -24,6 +24,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
||||
- Report installation as healthy only when a complete hook contract references every required event and both generated runtime scripts, runtime config, and private key exist as physical files.
|
||||
- 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.
|
||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
||||
|
||||
@@ -90,7 +90,7 @@ Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/
|
||||
|
||||
Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change.
|
||||
|
||||
Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them.
|
||||
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 hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged.
|
||||
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { parse as parseToml } from '@decimalturn/toml-patch';
|
||||
|
||||
export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url');
|
||||
export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi.js')).href;
|
||||
export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
||||
export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi-contract.js')).href;
|
||||
export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi-io.js')).href;
|
||||
export const installationModuleUrl = pathToFileURL(
|
||||
path.resolve('dist/plugins/kimi-installation.js'),
|
||||
).href;
|
||||
export const cliPath = path.resolve('dist/cli.js');
|
||||
|
||||
export function runNode(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, {
|
||||
cwd,
|
||||
env: childEnv,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
export function runKimiHook(command, input, fixture, environment = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
cwd: fixture.projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
KIMI_CODE_HOME: fixture.kimiHomeOverride ?? '',
|
||||
...environment,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
export function installConfig(fixture, overrides = {}) {
|
||||
return {
|
||||
agentName: 'kimi',
|
||||
orgId: 'org-1',
|
||||
agentId: 'agent-1',
|
||||
privateKey: VALID_PRIVATE_KEY,
|
||||
kid: 'kid-1',
|
||||
token: 'token-1',
|
||||
baseUrl: fixture.baseUrl,
|
||||
guardScriptPath: fixture.guardScriptPath,
|
||||
hookScriptPath: fixture.hookScriptPath,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlugin(fixture, method, argument, environment = {}) {
|
||||
const source = `
|
||||
import { kimiPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||
const result = await kimiPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
||||
if (result !== undefined) console.log(JSON.stringify(result));
|
||||
`;
|
||||
const env = {
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
||||
ELYDORA_TEST_METHOD: method,
|
||||
...environment,
|
||||
};
|
||||
const unset = [];
|
||||
if (fixture.kimiHomeOverride === undefined) unset.push('KIMI_CODE_HOME');
|
||||
else env.KIMI_CODE_HOME = fixture.kimiHomeOverride;
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', source],
|
||||
env,
|
||||
fixture.projectDir,
|
||||
'',
|
||||
unset,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeOptional(filePath, contents) {
|
||||
if (contents === undefined) return;
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function createFixture({
|
||||
baseUrl = 'http://127.0.0.1:9',
|
||||
stableConfig,
|
||||
legacyConfig,
|
||||
stableDetected = true,
|
||||
legacyDetected = true,
|
||||
explicitKimiHome = true,
|
||||
} = {}) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-'));
|
||||
const homeDir = path.join(rootDir, "home with spaces and 'quote %ELYDORA_HOOK_PATH%");
|
||||
const projectDir = path.join(rootDir, 'project with spaces');
|
||||
const kimiHome = explicitKimiHome
|
||||
? path.join(homeDir, 'custom kimi-code')
|
||||
: path.join(homeDir, '.kimi-code');
|
||||
const stablePath = path.join(kimiHome, 'config.toml');
|
||||
const legacyHome = path.join(homeDir, '.kimi');
|
||||
const legacyPath = path.join(legacyHome, 'config.toml');
|
||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
if (stableDetected && !explicitKimiHome) await mkdir(kimiHome, { recursive: true });
|
||||
if (legacyDetected) await mkdir(legacyHome, { recursive: true });
|
||||
await writeOptional(stablePath, stableConfig);
|
||||
await writeOptional(legacyPath, legacyConfig);
|
||||
const fixture = {
|
||||
agentDir,
|
||||
baseUrl,
|
||||
guardScriptPath,
|
||||
homeDir,
|
||||
hookScriptPath,
|
||||
kimiHome,
|
||||
kimiHomeOverride: explicitKimiHome ? kimiHome : undefined,
|
||||
legacyHome,
|
||||
legacyPath,
|
||||
projectDir,
|
||||
rootDir,
|
||||
stablePath,
|
||||
install(overrides = {}) {
|
||||
return runPlugin(this, 'install', installConfig(this, overrides));
|
||||
},
|
||||
async close() {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export function readKimiConfig(filePath) {
|
||||
return import('node:fs/promises')
|
||||
.then(({ readFile }) => readFile(filePath, 'utf-8'))
|
||||
.then((raw) => ({ raw, config: parseToml(raw) }));
|
||||
}
|
||||
|
||||
export function managedHook(config, event) {
|
||||
return config.hooks?.findLast((hook) => hook.event === event && hook.timeout === 10);
|
||||
}
|
||||
|
||||
export function assertManagedHook(hook, event) {
|
||||
assert(hook);
|
||||
assert.deepEqual(Object.keys(hook).sort(), ['command', 'event', 'timeout']);
|
||||
assert.equal(hook.event, event);
|
||||
assert.equal(hook.timeout, 10);
|
||||
if (process.platform === 'win32') {
|
||||
assert.match(hook.command, /^"[^"\r\n]+powershell\.exe" .* -EncodedCommand /i);
|
||||
} else {
|
||||
assert.match(hook.command, /^'[^']*node[^']*' /i);
|
||||
}
|
||||
}
|
||||
|
||||
export function legacyCommand(scriptPath) {
|
||||
if (process.platform === 'win32') return `"${process.execPath}" "${scriptPath}"`;
|
||||
const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
return `${quote(process.execPath)} ${quote(scriptPath)}`;
|
||||
}
|
||||
|
||||
export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
requests.push({
|
||||
headers: request.headers,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
raw,
|
||||
});
|
||||
if (request.method === 'GET') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ agent: { status } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(operationStatus, { 'Content-Type': 'application/json' });
|
||||
response.end(operationStatus >= 200 && operationStatus < 300
|
||||
? JSON.stringify({ operation: { accepted: true } })
|
||||
: JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } }));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
+239
-382
@@ -1,151 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { parse as parseToml } from '@decimalturn/toml-patch';
|
||||
import { stringify as stringifyToml } from '@decimalturn/toml-patch';
|
||||
import {
|
||||
assertManagedHook,
|
||||
cliPath,
|
||||
createFixture,
|
||||
installConfig,
|
||||
legacyCommand,
|
||||
managedHook,
|
||||
readKimiConfig,
|
||||
registryModuleUrl,
|
||||
runNode,
|
||||
runPlugin,
|
||||
} from '../test-support/kimi-test-helpers.mjs';
|
||||
|
||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi.js')).href;
|
||||
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
||||
const cliPath = path.resolve('dist/cli.js');
|
||||
|
||||
function runNode(args, env, 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, {
|
||||
env: childEnv,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
function runCommand(command, input = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
async function runPlugin(fixture, method, argument) {
|
||||
const script = `
|
||||
import { kimiPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||
const result = await kimiPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
||||
if (result !== undefined) console.log(JSON.stringify(result));
|
||||
`;
|
||||
const env = {
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
PATH: fixture.binDir,
|
||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
||||
ELYDORA_TEST_METHOD: method,
|
||||
};
|
||||
if (fixture.explicitKimiHome) env.KIMI_CODE_HOME = fixture.kimiHome;
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', script],
|
||||
env,
|
||||
const userStableConfig = [
|
||||
'# stable user config',
|
||||
'default_model = "kimi-code/k3"',
|
||||
'',
|
||||
fixture.explicitKimiHome ? [] : ['KIMI_CODE_HOME'],
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "existing-stable"',
|
||||
'timeout = 30 # keep stable hook',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const userLegacyConfig = [
|
||||
'# legacy user config',
|
||||
'telemetry = false',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "SessionEnd"',
|
||||
'command = "existing-legacy"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
async function assertMissing(filePath) {
|
||||
await assert.rejects(readFile(filePath), { code: 'ENOENT' });
|
||||
}
|
||||
|
||||
function assertManagedTriple(config) {
|
||||
assertManagedHook(managedHook(config, 'PreToolUse'), 'PreToolUse');
|
||||
assertManagedHook(managedHook(config, 'PostToolUse'), 'PostToolUse');
|
||||
assertManagedHook(managedHook(config, 'PostToolUseFailure'), 'PostToolUseFailure');
|
||||
assert.equal(
|
||||
managedHook(config, 'PostToolUse').command,
|
||||
managedHook(config, 'PostToolUseFailure').command,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeOptional(filePath, contents) {
|
||||
if (contents === undefined) return;
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, contents, 'utf-8');
|
||||
}
|
||||
|
||||
async function createFixture({
|
||||
guardSource,
|
||||
hookSource,
|
||||
modernConfig,
|
||||
legacyConfig,
|
||||
legacyInstalled = true,
|
||||
explicitKimiHome = true,
|
||||
} = {}) {
|
||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-'));
|
||||
const binDir = path.join(homeDir, 'bin');
|
||||
const kimiHome = explicitKimiHome
|
||||
? path.join(homeDir, 'custom-kimi-code')
|
||||
: path.join(homeDir, '.kimi-code');
|
||||
const modernPath = path.join(kimiHome, 'config.toml');
|
||||
const legacyPath = path.join(homeDir, '.kimi', 'config.toml');
|
||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
if (legacyInstalled) {
|
||||
const executable = path.join(binDir, process.platform === 'win32' ? 'kimi-cli.cmd' : 'kimi-cli');
|
||||
await writeFile(executable, '');
|
||||
if (process.platform !== 'win32') await chmod(executable, 0o700);
|
||||
}
|
||||
await writeFile(
|
||||
guardScriptPath,
|
||||
guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
|
||||
);
|
||||
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
|
||||
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
|
||||
agent_id: 'agent-1',
|
||||
agent_name: 'kimi',
|
||||
}));
|
||||
await writeOptional(modernPath, modernConfig);
|
||||
await writeOptional(legacyPath, legacyConfig);
|
||||
|
||||
const fixture = {
|
||||
agentDir,
|
||||
binDir,
|
||||
guardScriptPath,
|
||||
homeDir,
|
||||
hookScriptPath,
|
||||
kimiHome,
|
||||
explicitKimiHome,
|
||||
legacyPath,
|
||||
modernPath,
|
||||
async close() {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
fixture.installResult = await runPlugin(fixture, 'install', {
|
||||
agentName: 'kimi',
|
||||
agentId: 'agent-1',
|
||||
baseUrl: 'https://api.elydora.com',
|
||||
guardScriptPath,
|
||||
hookScriptPath,
|
||||
});
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function managedHook(config, event, scriptName) {
|
||||
return config.hooks.find(
|
||||
(hook) => hook.event === event && hook.command.includes(scriptName),
|
||||
);
|
||||
}
|
||||
|
||||
function assertStrictHook(hook) {
|
||||
assert.deepEqual(Object.keys(hook).sort(), ['command', 'event', 'timeout']);
|
||||
assert.equal(hook.timeout, 10);
|
||||
}
|
||||
|
||||
test('Kimi is registered in the SDK and CLI', async () => {
|
||||
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||
assert.deepEqual(SUPPORTED_AGENTS.get('kimi'), {
|
||||
@@ -154,304 +59,256 @@ test('Kimi is registered in the SDK and CLI', async () => {
|
||||
configFile: 'config.toml',
|
||||
});
|
||||
|
||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-cli-'));
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
const result = await runNode(['--no-warnings', cliPath, 'status'], {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
KIMI_CODE_HOME: path.join(homeDir, '.kimi-code'),
|
||||
});
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
KIMI_CODE_HOME: fixture.kimiHome,
|
||||
}, fixture.projectDir);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.match(result.stdout, /Kimi Code \(kimi\)/);
|
||||
} finally {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi install preserves both user configs and is idempotent', async () => {
|
||||
const modernConfig = [
|
||||
'# modern user config',
|
||||
'default_model = "kimi-code/k3"',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "existing-modern"',
|
||||
'timeout = 30 # keep modern hook',
|
||||
'',
|
||||
].join('\n');
|
||||
const legacyConfig = [
|
||||
'# legacy user config',
|
||||
'telemetry = false',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "SessionEnd"',
|
||||
'command = "existing-legacy"',
|
||||
'',
|
||||
].join('\n');
|
||||
const fixture = await createFixture({ modernConfig, legacyConfig });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
assert.match(fixture.installResult.stdout, /Kimi Code and kimi-cli/i);
|
||||
const secondInstall = await runPlugin(fixture, 'install', {
|
||||
agentName: 'kimi',
|
||||
agentId: 'agent-1',
|
||||
baseUrl: 'https://api.elydora.com',
|
||||
guardScriptPath: fixture.guardScriptPath,
|
||||
hookScriptPath: fixture.hookScriptPath,
|
||||
test('Kimi installs an exact managed triple in both configs and is idempotent', async () => {
|
||||
const fixture = await createFixture({
|
||||
stableConfig: userStableConfig,
|
||||
legacyConfig: userLegacyConfig,
|
||||
});
|
||||
assert.equal(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
for (const [configPath, comment, existingCommand] of [
|
||||
[fixture.modernPath, '# modern user config', 'existing-modern'],
|
||||
try {
|
||||
const first = await fixture.install();
|
||||
assert.equal(first.code, 0, first.stderr);
|
||||
assert.match(first.stdout, /Kimi Code and kimi-cli/i);
|
||||
const firstSources = [];
|
||||
for (const [configPath, comment, userCommand] of [
|
||||
[fixture.stablePath, '# stable user config', 'existing-stable'],
|
||||
[fixture.legacyPath, '# legacy user config', 'existing-legacy'],
|
||||
]) {
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const config = parseToml(raw);
|
||||
const { raw, config } = await readKimiConfig(configPath);
|
||||
firstSources.push(raw);
|
||||
assert.match(raw, new RegExp(comment));
|
||||
assert(config.hooks.some((hook) => hook.command === existingCommand));
|
||||
assert(config.hooks.some((hook) => hook.command === userCommand));
|
||||
assert.equal(config.hooks.length, 4);
|
||||
assertManagedTriple(config);
|
||||
}
|
||||
|
||||
const second = await fixture.install();
|
||||
assert.equal(second.code, 0, second.stderr);
|
||||
assert.equal(await readFile(fixture.stablePath, 'utf-8'), firstSources[0]);
|
||||
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), firstSources[1]);
|
||||
await assertMissing(path.join(fixture.homeDir, '.kimi-code', 'config.toml'));
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh stable Kimi installation leaves the legacy migration source absent', async () => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const { config } = await readKimiConfig(fixture.stablePath);
|
||||
assert.equal(config.hooks.length, 3);
|
||||
assertStrictHook(managedHook(config, 'PreToolUse', 'guard.js'));
|
||||
assertStrictHook(managedHook(config, 'PostToolUse', 'hook.js'));
|
||||
}
|
||||
await assert.rejects(
|
||||
readFile(path.join(fixture.homeDir, '.kimi-code', 'config.toml')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assertManagedTriple(config);
|
||||
await assertMissing(fixture.legacyPath);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi Code install does not create a false legacy migration source', async () => {
|
||||
const fixture = await createFixture({ legacyInstalled: false });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
assert.equal(parseToml(await readFile(fixture.modernPath, 'utf-8')).hooks.length, 2);
|
||||
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
test('empty KIMI_CODE_HOME resolves to the documented default', async () => {
|
||||
const fixture = await createFixture({
|
||||
explicitKimiHome: false,
|
||||
legacyDetected: false,
|
||||
});
|
||||
|
||||
test('Kimi Code treats an empty home override as the official default', async () => {
|
||||
const fixture = await createFixture({ explicitKimiHome: false, legacyInstalled: false });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
fixture.explicitKimiHome = true;
|
||||
fixture.kimiHome = '';
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
fixture.kimiHomeOverride = '';
|
||||
const status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||
assertManagedTriple((await readKimiConfig(fixture.stablePath)).config);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Legacy kimi-cli install does not create a premature migration target', async () => {
|
||||
const fixture = await createFixture({ explicitKimiHome: false });
|
||||
test('legacy-only installation leaves the stable migration target absent', async () => {
|
||||
const fixture = await createFixture({
|
||||
explicitKimiHome: false,
|
||||
stableDetected: false,
|
||||
});
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
assert.equal(parseToml(await readFile(fixture.legacyPath, 'utf-8')).hooks.length, 2);
|
||||
await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' });
|
||||
await rm(fixture.binDir, { recursive: true });
|
||||
const status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assertManagedTriple((await readKimiConfig(fixture.legacyPath)).config);
|
||||
await assertMissing(fixture.stablePath);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi commands block freezes and forward the official payload unchanged', async () => {
|
||||
const capturePath = path.join(os.tmpdir(), `elydora-kimi-event-${process.pid}-${Date.now()}.json`);
|
||||
const hookSource = `
|
||||
const fs = require('node:fs');
|
||||
const chunks = [];
|
||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
||||
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
||||
`;
|
||||
const fixture = await createFixture({ hookSource });
|
||||
test('Kimi parses every selected config before creating runtime files', async () => {
|
||||
const fixture = await createFixture({
|
||||
stableConfig: userStableConfig,
|
||||
legacyConfig: '[malformed',
|
||||
});
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const config = parseToml(await readFile(fixture.modernPath, 'utf-8'));
|
||||
const guard = managedHook(config, 'PreToolUse', 'guard.js');
|
||||
const audit = managedHook(config, 'PostToolUse', 'hook.js');
|
||||
const prePayload = {
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'session-1',
|
||||
cwd: fixture.homeDir,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo test' },
|
||||
tool_call_id: 'call-1',
|
||||
};
|
||||
const guardResult = await runCommand(guard.command, JSON.stringify(prePayload));
|
||||
assert.equal(guardResult.code, 2);
|
||||
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
||||
|
||||
const postPayload = {
|
||||
...prePayload,
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_output: { output: 'test' },
|
||||
};
|
||||
const auditResult = await runCommand(audit.command, JSON.stringify(postPayload));
|
||||
assert.equal(auditResult.code, 0, auditResult.stderr);
|
||||
assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), postPayload);
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /parse kimi-cli legacy hooks config/i);
|
||||
assert.equal(await readFile(fixture.stablePath, 'utf-8'), userStableConfig);
|
||||
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), '[malformed');
|
||||
await assert.rejects(lstat(fixture.agentDir), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await rm(capturePath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi status accepts either runtime contract and requires both scripts', async () => {
|
||||
const fixture = await createFixture();
|
||||
test('Kimi rejects fields and events outside each official contract', async (t) => {
|
||||
for (const [name, stableConfig, legacyConfig, pattern] of [
|
||||
[
|
||||
'unsupported field',
|
||||
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\ncwd = "/tmp"\n',
|
||||
undefined,
|
||||
/unsupported field "cwd"/i,
|
||||
],
|
||||
[
|
||||
'legacy stable-only event',
|
||||
undefined,
|
||||
'[[hooks]]\nevent = "Interrupt"\ncommand = "existing"\n',
|
||||
/unsupported event "Interrupt"/i,
|
||||
],
|
||||
[
|
||||
'timeout bound',
|
||||
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\ntimeout = 601\n',
|
||||
undefined,
|
||||
/integer from 1 to 600/i,
|
||||
],
|
||||
]) {
|
||||
await t.test(name, async () => {
|
||||
const fixture = await createFixture({ stableConfig, legacyConfig });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
await rm(fixture.modernPath);
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, pattern);
|
||||
await assert.rejects(lstat(fixture.agentDir), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi migrates exact legacy commands and preserves command lookalikes', async () => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
const lookalike = `${legacyCommand(fixture.guardScriptPath)} --inspect`;
|
||||
const source = [
|
||||
'[[hooks]]',
|
||||
'event = "PreToolUse"',
|
||||
`command = ${JSON.stringify(legacyCommand(fixture.guardScriptPath))}`,
|
||||
'timeout = 10',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "PostToolUse"',
|
||||
`command = ${JSON.stringify(legacyCommand(fixture.hookScriptPath))}`,
|
||||
'timeout = 10',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "PreToolUse"',
|
||||
`command = ${JSON.stringify(lookalike)}`,
|
||||
'timeout = 10',
|
||||
'',
|
||||
].join('\n');
|
||||
try {
|
||||
await mkdir(path.dirname(fixture.stablePath), { recursive: true });
|
||||
await writeFile(fixture.stablePath, source, { encoding: 'utf-8', mode: 0o600 });
|
||||
const install = await fixture.install();
|
||||
assert.equal(install.code, 0, install.stderr);
|
||||
let config = (await readKimiConfig(fixture.stablePath)).config;
|
||||
assert.equal(config.hooks.length, 4);
|
||||
assert(config.hooks.some((hook) => hook.command === lookalike));
|
||||
assertManagedTriple(config);
|
||||
|
||||
const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||
assert.equal(uninstall.code, 0, uninstall.stderr);
|
||||
config = (await readKimiConfig(fixture.stablePath)).config;
|
||||
assert.deepEqual(config.hooks.map((hook) => ({ ...hook })), [{
|
||||
event: 'PreToolUse',
|
||||
command: lookalike,
|
||||
timeout: 10,
|
||||
}]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi uninstall preserves user config and removes fully managed configs', async () => {
|
||||
const userFixture = await createFixture({
|
||||
stableConfig: userStableConfig,
|
||||
legacyConfig: userLegacyConfig,
|
||||
});
|
||||
try {
|
||||
assert.equal((await userFixture.install()).code, 0);
|
||||
const result = await runPlugin(userFixture, 'uninstall', 'agent-1');
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const stable = await readKimiConfig(userFixture.stablePath);
|
||||
const legacy = await readKimiConfig(userFixture.legacyPath);
|
||||
assert.match(stable.raw, /# keep stable hook/);
|
||||
assert.match(legacy.raw, /# legacy user config/);
|
||||
assert.deepEqual(stable.config.hooks.map((hook) => hook.command), ['existing-stable']);
|
||||
assert.deepEqual(legacy.config.hooks.map((hook) => hook.command), ['existing-legacy']);
|
||||
} 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.stablePath);
|
||||
await assertMissing(managedFixture.legacyPath);
|
||||
} finally {
|
||||
await managedFixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi status requires the complete triple, runtime identity, and private key', async () => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
let status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||
|
||||
const reinstall = await runPlugin(fixture, 'install', {
|
||||
agentName: 'kimi',
|
||||
agentId: 'agent-1',
|
||||
baseUrl: 'https://api.elydora.com',
|
||||
guardScriptPath: fixture.guardScriptPath,
|
||||
hookScriptPath: fixture.hookScriptPath,
|
||||
});
|
||||
assert.equal(reinstall.code, 0, reinstall.stderr);
|
||||
await rm(fixture.legacyPath);
|
||||
status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||
|
||||
await rm(fixture.guardScriptPath);
|
||||
const { config } = await readKimiConfig(fixture.stablePath);
|
||||
config.hooks = config.hooks.filter((hook) => hook.event !== 'PostToolUseFailure');
|
||||
await writeFile(fixture.stablePath, stringifyToml(config), 'utf-8');
|
||||
status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi uninstall preserves user hooks in both contracts', async () => {
|
||||
const userHook = [
|
||||
'# user hook',
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "existing-command"',
|
||||
'timeout = 30 # keep timeout',
|
||||
'',
|
||||
].join('\n');
|
||||
const fixture = await createFixture({ modernConfig: userHook, legacyConfig: userHook });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
for (const configPath of [fixture.modernPath, fixture.legacyPath]) {
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const config = parseToml(raw);
|
||||
assert.match(raw, /# user hook/);
|
||||
assert.match(raw, /# keep timeout/);
|
||||
assert.deepEqual(config.hooks.map((hook) => ({ ...hook })), [{
|
||||
event: 'SessionStart',
|
||||
command: 'existing-command',
|
||||
timeout: 30,
|
||||
}]);
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi uninstall removes configs created entirely by Elydora', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' });
|
||||
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi parses every config before writing either contract', async () => {
|
||||
const modernConfig = '# untouched modern\ndefault_model = "kimi-code/k3"\n';
|
||||
const legacyConfig = '[malformed';
|
||||
const fixture = await createFixture({ modernConfig, legacyConfig });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 1);
|
||||
assert.match(fixture.installResult.stderr, /parse kimi-cli legacy hooks config/i);
|
||||
assert.equal(await readFile(fixture.modernPath, 'utf-8'), modernConfig);
|
||||
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), legacyConfig);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi rejects invalid hook shapes without rewriting user config', async () => {
|
||||
const modernConfig = [
|
||||
'[[hooks]]',
|
||||
'event = "PreToolUse"',
|
||||
'command = "existing-command"',
|
||||
'cwd = "/tmp"',
|
||||
'',
|
||||
].join('\n');
|
||||
const fixture = await createFixture({ modernConfig });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 1);
|
||||
assert.match(fixture.installResult.stderr, /unsupported field "cwd"/i);
|
||||
assert.equal(await readFile(fixture.modernPath, 'utf-8'), modernConfig);
|
||||
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi install rejects missing runtimes before creating configs', async () => {
|
||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-missing-runtime-'));
|
||||
const fixture = {
|
||||
binDir: path.join(homeDir, 'bin'),
|
||||
explicitKimiHome: true,
|
||||
homeDir,
|
||||
kimiHome: path.join(homeDir, 'custom-kimi-code'),
|
||||
modernPath: path.join(homeDir, 'custom-kimi-code', 'config.toml'),
|
||||
legacyPath: path.join(homeDir, '.kimi', 'config.toml'),
|
||||
};
|
||||
try {
|
||||
const result = await runPlugin(fixture, 'install', {
|
||||
agentName: 'kimi',
|
||||
agentId: 'agent-1',
|
||||
guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'),
|
||||
hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'),
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /runtime is missing/i);
|
||||
await assert.rejects(readFile(fixture.modernPath), { code: 'ENOENT' });
|
||||
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi status surfaces malformed referenced runtime metadata', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
|
||||
const status = await runPlugin(fixture, 'status', null);
|
||||
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, /parse Elydora runtime config/i);
|
||||
assert.match(status.stderr, /private key is invalid/i);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi atomic writes leave no temporary files behind', async () => {
|
||||
test('Kimi installation leaves no transaction files', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
for (const directory of [path.dirname(fixture.modernPath), path.dirname(fixture.legacyPath)]) {
|
||||
assert.equal((await readdir(directory)).some((name) => name.endsWith('.tmp')), false);
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
for (const directory of [fixture.agentDir, fixture.kimiHome, fixture.legacyHome]) {
|
||||
const names = await readdir(directory);
|
||||
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createFixture,
|
||||
managedHook,
|
||||
readKimiConfig,
|
||||
runKimiHook,
|
||||
startApiServer,
|
||||
} from '../test-support/kimi-test-helpers.mjs';
|
||||
|
||||
const prePayload = {
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'session-1',
|
||||
cwd: 'C:/project',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo test' },
|
||||
tool_call_id: 'call-1',
|
||||
};
|
||||
|
||||
test('Kimi runtimes enforce active state and preserve success and failure payloads', async () => {
|
||||
const api = await startApiServer();
|
||||
const fixture = await createFixture({
|
||||
baseUrl: api.baseUrl,
|
||||
legacyDetected: false,
|
||||
});
|
||||
try {
|
||||
const install = await fixture.install();
|
||||
assert.equal(install.code, 0, install.stderr);
|
||||
const { config } = await readKimiConfig(fixture.stablePath);
|
||||
const guard = managedHook(config, 'PreToolUse');
|
||||
const successAudit = managedHook(config, 'PostToolUse');
|
||||
const failureAudit = managedHook(config, 'PostToolUseFailure');
|
||||
|
||||
const guardResult = await runKimiHook(
|
||||
guard.command,
|
||||
JSON.stringify(prePayload),
|
||||
fixture,
|
||||
{ ELYDORA_HOOK_PATH: 'injected-command-fragment' },
|
||||
);
|
||||
assert.equal(guardResult.code, 0, guardResult.stderr);
|
||||
|
||||
const successPayload = {
|
||||
...prePayload,
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_output: 'test\n',
|
||||
};
|
||||
const success = await runKimiHook(
|
||||
successAudit.command,
|
||||
JSON.stringify(successPayload),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(success.code, 0, success.stderr);
|
||||
|
||||
const failurePayload = {
|
||||
...prePayload,
|
||||
hook_event_name: 'PostToolUseFailure',
|
||||
error: { name: 'ToolError', message: 'command failed', code: 'tool.failed' },
|
||||
};
|
||||
const failure = await runKimiHook(
|
||||
failureAudit.command,
|
||||
JSON.stringify(failurePayload),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(failure.code, 0, failure.stderr);
|
||||
|
||||
const operations = api.requests
|
||||
.filter((request) => request.method === 'POST')
|
||||
.map((request) => JSON.parse(request.raw));
|
||||
assert.equal(operations.length, 2);
|
||||
assert.deepEqual(operations[0].payload, successPayload);
|
||||
assert.deepEqual(operations[1].payload, failurePayload);
|
||||
assert.equal(operations[0].subject.session_id, 'session-1');
|
||||
assert.equal(operations[1].action.tool, 'Bash');
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi guard propagates exit code 2 for frozen and revoked agents', async (t) => {
|
||||
for (const status of ['frozen', 'revoked']) {
|
||||
await t.test(status, async () => {
|
||||
const api = await startApiServer({ status });
|
||||
const fixture = await createFixture({
|
||||
baseUrl: api.baseUrl,
|
||||
legacyDetected: false,
|
||||
});
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { config } = await readKimiConfig(fixture.stablePath);
|
||||
const result = await runKimiHook(
|
||||
managedHook(config, 'PreToolUse').command,
|
||||
JSON.stringify(prePayload),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(result.code, 2);
|
||||
assert.match(result.stderr, new RegExp(status, 'i'));
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi audit runtime records malformed input and API failures', async () => {
|
||||
const api = await startApiServer({ operationStatus: 503 });
|
||||
const fixture = await createFixture({
|
||||
baseUrl: api.baseUrl,
|
||||
legacyDetected: false,
|
||||
});
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { config } = await readKimiConfig(fixture.stablePath);
|
||||
const command = managedHook(config, 'PostToolUse').command;
|
||||
const malformed = await runKimiHook(command, '{ malformed', fixture);
|
||||
assert.equal(malformed.code, 0);
|
||||
assert.equal(malformed.stderr, '');
|
||||
|
||||
const upstream = await runKimiHook(command, JSON.stringify({
|
||||
...prePayload,
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_output: 'test',
|
||||
}), fixture);
|
||||
assert.equal(upstream.code, 0);
|
||||
assert.equal(upstream.stderr, '');
|
||||
const log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8');
|
||||
assert.match(log, /invalid JSON/i);
|
||||
assert.match(log, /HTTP 503/i);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createFixture,
|
||||
installConfig,
|
||||
runPlugin,
|
||||
} from '../test-support/kimi-test-helpers.mjs';
|
||||
|
||||
async function createLink(t, target, linkPath, type) {
|
||||
try {
|
||||
await symlink(target, linkPath, type);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM') {
|
||||
t.skip(`symbolic links unavailable: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertMissing(filePath) {
|
||||
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||
}
|
||||
|
||||
test('Kimi rejects linked config files before creating runtimes', async (t) => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
const target = path.join(fixture.rootDir, 'config-target.toml');
|
||||
const source = '# protected target\ntelemetry = false\n';
|
||||
await mkdir(path.dirname(fixture.stablePath), { recursive: true });
|
||||
await writeFile(target, source, 'utf-8');
|
||||
if (!await createLink(t, target, fixture.stablePath, 'file')) return;
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /not a physical file/i);
|
||||
assert.equal(await readFile(target, 'utf-8'), source);
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi rejects linked config and runtime directories before writes', async (t) => {
|
||||
for (const kind of ['config', 'runtime']) {
|
||||
await t.test(kind, async (subtest) => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
const target = path.join(fixture.rootDir, `${kind}-target`);
|
||||
await mkdir(target, { recursive: true });
|
||||
const linkPath = kind === 'config'
|
||||
? fixture.kimiHome
|
||||
: path.join(fixture.homeDir, '.elydora');
|
||||
await mkdir(path.dirname(linkPath), { recursive: true });
|
||||
if (!await createLink(subtest, target, linkPath, 'junction')) return;
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /physical directory/i);
|
||||
assert.deepEqual(await readdir(target), []);
|
||||
const untouched = kind === 'config' ? fixture.agentDir : fixture.stablePath;
|
||||
await assertMissing(untouched);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi status rejects a linked private key', async (t) => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const keyPath = path.join(fixture.agentDir, 'private.key');
|
||||
const target = path.join(fixture.rootDir, 'private-key-target');
|
||||
const source = await readFile(keyPath, 'utf-8');
|
||||
await writeFile(target, source, 'utf-8');
|
||||
await rm(keyPath);
|
||||
if (!await createLink(t, target, keyPath, 'file')) return;
|
||||
const status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 1);
|
||||
assert.match(status.stderr, /not a physical file/i);
|
||||
assert.equal(await readFile(target, 'utf-8'), source);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi rejects orphaned and mismatched runtime identity before config writes', async () => {
|
||||
for (const kind of ['orphaned', 'mismatched']) {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
await mkdir(fixture.agentDir, { recursive: true });
|
||||
if (kind === 'orphaned') {
|
||||
await writeFile(fixture.guardScriptPath, 'orphaned guard\n');
|
||||
} else {
|
||||
await writeFile(path.join(fixture.agentDir, 'config.json'), JSON.stringify({
|
||||
agent_name: 'kimi',
|
||||
agent_id: 'another-agent',
|
||||
}));
|
||||
}
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
kind === 'orphaned' ? /identity cannot be verified/i : /identity does not match/i,
|
||||
);
|
||||
await assertMissing(fixture.stablePath);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi validates managed runtime inputs before creating config files', async (t) => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
for (const [name, overrides, pattern] of [
|
||||
['agent name', { agentName: 'codex' }, /requires agentName kimi/i],
|
||||
['private key', { privateKey: 'invalid' }, /canonical 32-byte/i],
|
||||
['API origin', { baseUrl: 'https://api.elydora.com/path?token=secret' }, /query parameters/i],
|
||||
[
|
||||
'runtime path',
|
||||
{ guardScriptPath: path.join(fixture.homeDir, 'outside', 'guard.js') },
|
||||
/managed agent directory/i,
|
||||
],
|
||||
]) {
|
||||
await t.test(name, async () => {
|
||||
const result = await runPlugin(fixture, 'install', installConfig(fixture, overrides));
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, pattern);
|
||||
await assertMissing(fixture.stablePath);
|
||||
await assertMissing(fixture.agentDir);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi status surfaces malformed referenced runtime metadata', async () => {
|
||||
const fixture = await createFixture({ legacyDetected: false });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
|
||||
const status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 1);
|
||||
assert.match(status.stderr, /parse Elydora runtime config/i);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
contractModuleUrl,
|
||||
createFixture,
|
||||
installationModuleUrl,
|
||||
installConfig,
|
||||
ioModuleUrl,
|
||||
runNode,
|
||||
} from '../test-support/kimi-test-helpers.mjs';
|
||||
|
||||
const runtimeNames = ['guard.js', 'config.json', 'private.key', 'hook.js'];
|
||||
|
||||
async function snapshotFixture(fixture) {
|
||||
const paths = [
|
||||
...runtimeNames.map((name) => path.join(fixture.agentDir, name)),
|
||||
fixture.stablePath,
|
||||
fixture.legacyPath,
|
||||
];
|
||||
return new Map(await Promise.all(paths.map(async (filePath) => [
|
||||
filePath,
|
||||
await readFile(filePath, 'utf-8'),
|
||||
])));
|
||||
}
|
||||
|
||||
async function assertSnapshot(snapshot) {
|
||||
for (const [filePath, contents] of snapshot) {
|
||||
assert.equal(await readFile(filePath, 'utf-8'), contents, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoTransactionFiles(fixture) {
|
||||
for (const directory of [fixture.agentDir, fixture.kimiHome, fixture.legacyHome]) {
|
||||
const names = await readdir(directory);
|
||||
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
function runFixtureSource(fixture, source, environment = {}) {
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', source],
|
||||
{
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
KIMI_CODE_HOME: fixture.kimiHome,
|
||||
...environment,
|
||||
},
|
||||
fixture.projectDir,
|
||||
);
|
||||
}
|
||||
|
||||
const prepareInstallSource = `
|
||||
import {
|
||||
buildKimiHook,
|
||||
removeManagedKimiHooks,
|
||||
renderKimiDocument,
|
||||
} from ${JSON.stringify(contractModuleUrl)};
|
||||
import { readKimiDocuments } from ${JSON.stringify(ioModuleUrl)};
|
||||
import {
|
||||
preflightKimiInstallation,
|
||||
prepareKimiInstallation,
|
||||
} from ${JSON.stringify(installationModuleUrl)};
|
||||
const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG);
|
||||
const documents = await readKimiDocuments();
|
||||
const paths = await preflightKimiInstallation(config, documents);
|
||||
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(document, [
|
||||
...removeManagedKimiHooks(document.hooks),
|
||||
buildKimiHook('PreToolUse', paths.guardPath),
|
||||
buildKimiHook('PostToolUse', paths.auditPath),
|
||||
buildKimiHook('PostToolUseFailure', paths.auditPath),
|
||||
])));
|
||||
const prepared = await prepareKimiInstallation(config, rendered);
|
||||
`;
|
||||
|
||||
test('Kimi restores both configs and four runtime files after the final commit fails', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const before = await snapshotFixture(fixture);
|
||||
const source = `${prepareInstallSource}
|
||||
import { rename } from 'node:fs/promises';
|
||||
import { commitKimiInstallation } from ${JSON.stringify(installationModuleUrl)};
|
||||
let calls = 0;
|
||||
await commitKimiInstallation(prepared, async (from, to) => {
|
||||
calls += 1;
|
||||
if (calls === 6) throw new Error('injected legacy config failure');
|
||||
await rename(from, to);
|
||||
});
|
||||
`;
|
||||
const result = await runFixtureSource(fixture, source, {
|
||||
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, {
|
||||
orgId: 'org-updated',
|
||||
token: 'token-updated',
|
||||
})),
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /injected legacy config failure/i);
|
||||
await assertSnapshot(before);
|
||||
await assertNoTransactionFiles(fixture);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi rejects concurrent config changes before committing runtime updates', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const before = await snapshotFixture(fixture);
|
||||
const concurrent = '# concurrent owner change\ntelemetry = false\n';
|
||||
const source = `${prepareInstallSource}
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { commitKimiInstallation } from ${JSON.stringify(installationModuleUrl)};
|
||||
await writeFile(documents[0].contract.configPath, process.env.ELYDORA_CONCURRENT, 'utf-8');
|
||||
await commitKimiInstallation(prepared);
|
||||
`;
|
||||
const result = await runFixtureSource(fixture, source, {
|
||||
ELYDORA_CONCURRENT: concurrent,
|
||||
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })),
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /changed during Kimi installation/i);
|
||||
for (const [filePath, contents] of before) {
|
||||
if (filePath !== fixture.stablePath) {
|
||||
assert.equal(await readFile(filePath, 'utf-8'), contents, filePath);
|
||||
}
|
||||
}
|
||||
assert.equal(await readFile(fixture.stablePath, 'utf-8'), concurrent);
|
||||
await assertNoTransactionFiles(fixture);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Kimi uninstall restores the first config when removing the second config fails', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const before = await snapshotFixture(fixture);
|
||||
const source = `
|
||||
import { rename } from 'node:fs/promises';
|
||||
import {
|
||||
removeManagedKimiHooks,
|
||||
renderKimiDocument,
|
||||
} from ${JSON.stringify(contractModuleUrl)};
|
||||
import { readKimiDocuments } from ${JSON.stringify(ioModuleUrl)};
|
||||
import {
|
||||
commitKimiUninstall,
|
||||
prepareKimiUninstall,
|
||||
} from ${JSON.stringify(installationModuleUrl)};
|
||||
const documents = await readKimiDocuments();
|
||||
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(
|
||||
document,
|
||||
removeManagedKimiHooks(document.hooks, 'agent-1'),
|
||||
)));
|
||||
const prepared = await prepareKimiUninstall(rendered);
|
||||
let calls = 0;
|
||||
await commitKimiUninstall(prepared, async (from, to) => {
|
||||
calls += 1;
|
||||
if (calls === 2) throw new Error('injected uninstall failure');
|
||||
await rename(from, to);
|
||||
});
|
||||
`;
|
||||
const result = await runFixtureSource(fixture, source);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /injected uninstall failure/i);
|
||||
await assertSnapshot(before);
|
||||
await assertNoTransactionFiles(fixture);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user