diff --git a/AGENTS.md b/AGENTS.md index a283458..b1c2d83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ This repository publishes the open-source server, console, integration catalog, - Use each provider's documented blocking mechanism and failure mode. - Report installation as healthy only when a complete contract references all required runtime files. - Keep stable, legacy, and early-access contracts explicit, including their activation commands. -- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker. -- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. +- 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. +- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse every selected document before writing any file. - Write Grok Build integrations to a native user hook file under non-empty `GROK_HOME` or `~/.grok`; keep Claude Code and Cursor compatibility sources read-only. - 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. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. diff --git a/integrations/catalog.json b/integrations/catalog.json index 24d46af..76728c3 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -234,23 +234,45 @@ "$KIMI_CODE_HOME/config.toml", "~/.kimi-code/config.toml" ], - "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" }, - "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" }, + "events": { + "before_tool": "PreToolUse", + "after_tool": "PostToolUse", + "after_tool_failure": "PostToolUseFailure" + }, + "event_fields": { + "name": "tool_name", + "input": "tool_input", + "session": "session_id", + "call_id": "tool_call_id", + "output": "tool_output", + "error": "error" + }, "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" }, "adapters": { "node": true, "python": true, "go": true }, "delivery_state": "available", - "source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks", + "source_url": "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html", "contract_variants": [ { "id": "pythoncli", "release_channel": "legacy", - "activation": "kimi-cli", + "activation": "An existing ~/.kimi home activates the legacy kimi-cli contract", "config_format": "toml", "config_paths": [ "~/.kimi/config.toml" ], - "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" }, - "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" }, + "events": { + "before_tool": "PreToolUse", + "after_tool": "PostToolUse", + "after_tool_failure": "PostToolUseFailure" + }, + "event_fields": { + "name": "tool_name", + "input": "tool_input", + "session": "session_id", + "call_id": "tool_call_id", + "output": "tool_output", + "error": "error" + }, "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" }, "source_url": "https://moonshotai.github.io/kimi-cli/en/customization/hooks.html" } diff --git a/integrations/catalog.schema.json b/integrations/catalog.schema.json index e1e3dcc..127d619 100644 --- a/integrations/catalog.schema.json +++ b/integrations/catalog.schema.json @@ -98,6 +98,10 @@ "after_tool": { "type": "string", "minLength": 1 + }, + "after_tool_failure": { + "type": "string", + "minLength": 1 } } }, @@ -121,6 +125,18 @@ "session": { "type": "string", "minLength": 1 + }, + "call_id": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": "string", + "minLength": 1 + }, + "error": { + "type": "string", + "minLength": 1 } } }, @@ -288,6 +304,10 @@ "after_tool": { "type": "string", "minLength": 1 + }, + "after_tool_failure": { + "type": "string", + "minLength": 1 } } }, @@ -311,6 +331,18 @@ "session": { "type": "string", "minLength": 1 + }, + "call_id": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": "string", + "minLength": 1 + }, + "error": { + "type": "string", + "minLength": 1 } } }, diff --git a/sdks/node/README.md b/sdks/node/README.md index 046baf5..2b6b00c 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -61,7 +61,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. diff --git a/sdks/node/src/plugins/kimi-command.ts b/sdks/node/src/plugins/kimi-command.ts new file mode 100644 index 0000000..6797fbf --- /dev/null +++ b/sdks/node/src/plugins/kimi-command.ts @@ -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] }; +} diff --git a/sdks/node/src/plugins/kimi-contract.ts b/sdks/node/src/plugins/kimi-contract.ts new file mode 100644 index 0000000..efc9a86 --- /dev/null +++ b/sdks/node/src/plugins/kimi-contract.ts @@ -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([ + ...SHARED_EVENTS, + 'PermissionRequest', + 'PermissionResult', + 'Interrupt', +]); +export const LEGACY_EVENTS = new Set(SHARED_EVENTS); + +export type TomlObject = Record; +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; +} + +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 { + 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 { + 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 { + 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 { + const result = new Map(); + 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; +} diff --git a/sdks/node/src/plugins/kimi-installation.ts b/sdks/node/src/plugins/kimi-installation.ts new file mode 100644 index 0000000..6086a23 --- /dev/null +++ b/sdks/node/src/plugins/kimi-installation.ts @@ -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 { + 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 { + 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 { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareKimiUninstall( + rendered: readonly RenderedKimiDocument[], +): Promise { + 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 { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/sdks/node/src/plugins/kimi-io.ts b/sdks/node/src/plugins/kimi-io.ts new file mode 100644 index 0000000..3d2d4eb --- /dev/null +++ b/sdks/node/src/plugins/kimi-io.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + for (const contract of contracts) { + if (await runtimeContractExists(contract)) return true; + } + return false; +} diff --git a/sdks/node/src/plugins/kimi.ts b/sdks/node/src/plugins/kimi.ts index 10b1488..4fdef43 100644 --- a/sdks/node/src/plugins/kimi.ts +++ b/sdks/node/src/plugins/kimi.ts @@ -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([ - ...SHARED_EVENTS, - 'PermissionRequest', - 'PermissionResult', - 'Interrupt', -]); -const LEGACY_EVENTS = new Set(SHARED_EVENTS); - -type TomlObject = Record; - -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; -} - -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 { - 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 { - 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 { - 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 { - 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 { - const documents: KimiConfigDocument[] = []; - for (const contract of await resolveContracts()) documents.push(await readConfig(contract)); - return documents; -} - -async function renderHooks(document: KimiConfigDocument, hooks: KimiHook[]): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { + const documents = await readKimiDocuments(); + await preflightKimiInstallation(config, documents); + }, + async install(config: InstallConfig): Promise { - 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 { - 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 { - 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, }; }, }; diff --git a/sdks/node/src/plugins/managed-transaction.ts b/sdks/node/src/plugins/managed-transaction.ts index e86dd59..cb3290c 100644 --- a/sdks/node/src/plugins/managed-transaction.ts +++ b/sdks/node/src/plugins/managed-transaction.ts @@ -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 { +async function assertUnchanged(change: ManagedFileChange, operationName: string): Promise { 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 { } } -async function stage(change: ManagedFileChange, displayName: string): Promise { - await assertUnchanged(change, displayName); +async function stage(change: ManagedFileChange, operationName: string): Promise { + 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 { - 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 { + 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(); 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`, ); } } diff --git a/sdks/node/test-support/kimi-test-helpers.mjs b/sdks/node/test-support/kimi-test-helpers.mjs new file mode 100644 index 0000000..5b58d19 --- /dev/null +++ b/sdks/node/test-support/kimi-test-helpers.mjs @@ -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()); + }), + }; +} diff --git a/sdks/node/test/kimi-plugin.test.mjs b/sdks/node/test/kimi-plugin.test.mjs index 8df9ec0..3756fb7 100644 --- a/sdks/node/test/kimi-plugin.test.mjs +++ b/sdks/node/test/kimi-plugin.test.mjs @@ -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'); +const userStableConfig = [ + '# stable user config', + 'default_model = "kimi-code/k3"', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "existing-stable"', + 'timeout = 30 # keep stable hook', + '', +].join('\n'); -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); - }); +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 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, - '', - fixture.explicitKimiHome ? [] : ['KIMI_CODE_HOME'], +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 }); +test('Kimi installs an exact managed triple in both configs and is idempotent', async () => { + const fixture = await createFixture({ + stableConfig: userStableConfig, + legacyConfig: userLegacyConfig, + }); 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, - }); - assert.equal(secondInstall.code, 0, secondInstall.stderr); - - for (const [configPath, comment, existingCommand] of [ - [fixture.modernPath, '# modern user config', 'existing-modern'], + 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.equal(config.hooks.length, 3); - assertStrictHook(managedHook(config, 'PreToolUse', 'guard.js')); - assertStrictHook(managedHook(config, 'PostToolUse', 'hook.js')); + assert(config.hooks.some((hook) => hook.command === userCommand)); + assert.equal(config.hooks.length, 4); + assertManagedTriple(config); } - await assert.rejects( - readFile(path.join(fixture.homeDir, '.kimi-code', 'config.toml')), - { code: 'ENOENT' }, - ); + + 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('Kimi Code install does not create a false legacy migration source', async () => { - const fixture = await createFixture({ legacyInstalled: false }); +test('fresh stable Kimi installation leaves the legacy migration source absent', async () => { + const fixture = await createFixture({ legacyDetected: 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' }); + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const { config } = await readKimiConfig(fixture.stablePath); + assert.equal(config.hooks.length, 3); + assertManagedTriple(config); + await assertMissing(fixture.legacyPath); } finally { await fixture.close(); } }); -test('Kimi Code treats an empty home override as the official default', async () => { - const fixture = await createFixture({ explicitKimiHome: false, legacyInstalled: false }); +test('empty KIMI_CODE_HOME resolves to the documented default', async () => { + const fixture = await createFixture({ + explicitKimiHome: false, + legacyDetected: 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 { + 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 { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - await rm(fixture.modernPath); + 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(); diff --git a/sdks/node/test/kimi-runtime.test.mjs b/sdks/node/test/kimi-runtime.test.mjs new file mode 100644 index 0000000..124b59d --- /dev/null +++ b/sdks/node/test/kimi-runtime.test.mjs @@ -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(); + } +}); diff --git a/sdks/node/test/kimi-security.test.mjs b/sdks/node/test/kimi-security.test.mjs new file mode 100644 index 0000000..1dfd30c --- /dev/null +++ b/sdks/node/test/kimi-security.test.mjs @@ -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(); + } +}); diff --git a/sdks/node/test/kimi-transaction.test.mjs b/sdks/node/test/kimi-transaction.test.mjs new file mode 100644 index 0000000..6340fb8 --- /dev/null +++ b/sdks/node/test/kimi-transaction.test.mjs @@ -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(); + } +}); diff --git a/test/integration-catalog-contracts.test.mjs b/test/integration-catalog-contracts.test.mjs index a7d83ef..3724588 100644 --- a/test/integration-catalog-contracts.test.mjs +++ b/test/integration-catalog-contracts.test.mjs @@ -198,8 +198,21 @@ test('high-drift providers retain their verified hook contracts', async () => { assert.deepEqual(providers.get('kimi').events, { before_tool: 'PreToolUse', after_tool: 'PostToolUse', + after_tool_failure: 'PostToolUseFailure', + }); + assert.deepEqual(providers.get('kimi').event_fields, { + name: 'tool_name', + input: 'tool_input', + session: 'session_id', + call_id: 'tool_call_id', + output: 'tool_output', + error: 'error', }); assert.equal(providers.get('kimi').config_format, 'toml'); + assert.equal( + providers.get('kimi').source_url, + 'https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html', + ); assert.deepEqual(providers.get('kimi').config_paths, [ '$KIMI_CODE_HOME/config.toml', '~/.kimi-code/config.toml', @@ -208,17 +221,21 @@ test('high-drift providers retain their verified hook contracts', async () => { { id: 'pythoncli', release_channel: 'legacy', - activation: 'kimi-cli', + activation: 'An existing ~/.kimi home activates the legacy kimi-cli contract', config_format: 'toml', config_paths: ['~/.kimi/config.toml'], events: { before_tool: 'PreToolUse', after_tool: 'PostToolUse', + after_tool_failure: 'PostToolUseFailure', }, event_fields: { name: 'tool_name', input: 'tool_input', session: 'session_id', + call_id: 'tool_call_id', + output: 'tool_output', + error: 'error', }, blocking: { mechanism: 'exit_code_2', diff --git a/test/integration-catalog.test.mjs b/test/integration-catalog.test.mjs index 49cc764..3d0c7f6 100644 --- a/test/integration-catalog.test.mjs +++ b/test/integration-catalog.test.mjs @@ -156,6 +156,9 @@ test('every provider exposes a complete, machine-readable hook contract', async assert.ok(Array.isArray(provider.config_paths)); assert.equal(typeof provider.events.before_tool, 'string'); assert.equal(typeof provider.events.after_tool, 'string'); + if (provider.events.after_tool_failure !== undefined) { + assert.equal(typeof provider.events.after_tool_failure, 'string'); + } assert.ok([ 'any_nonzero_exit', 'exception', @@ -182,6 +185,11 @@ test('every provider exposes a complete, machine-readable hook contract', async assert.equal(typeof provider.event_fields.name, 'string'); assert.equal(typeof provider.event_fields.input, 'string'); assert.equal(typeof provider.event_fields.session, 'string'); + for (const field of ['call_id', 'output', 'error']) { + if (provider.event_fields[field] !== undefined) { + assert.equal(typeof provider.event_fields[field], 'string'); + } + } } for (const variant of provider.contract_variants ?? []) { @@ -192,6 +200,9 @@ test('every provider exposes a complete, machine-readable hook contract', async assert.ok(Array.isArray(variant.config_paths) && variant.config_paths.length > 0); assert.equal(typeof variant.events.before_tool, 'string'); assert.equal(typeof variant.events.after_tool, 'string'); + if (variant.events.after_tool_failure !== undefined) { + assert.equal(typeof variant.events.after_tool_failure, 'string'); + } assert.equal(typeof variant.blocking.mechanism, 'string'); assert.equal(typeof variant.blocking.failure_mode, 'string'); assert.match(variant.source_url, /^https:\/\//); @@ -223,6 +234,8 @@ test('schema freezes the provider contract and supported enums', async () => { providerSchema.properties.blocking.properties.timeout_failure_mode.enum, ['fail_closed', 'fail_open'], ); + assert.equal(providerSchema.properties.events.properties.after_tool_failure.type, 'string'); + assert.equal(providerSchema.properties.event_fields.properties.error.type, 'string'); assert.deepEqual(providerSchema.properties.delivery_state.enum, ['available', 'partial', 'planned']); assert.equal(providerSchema.properties.contract_variants.items.$ref, '#/$defs/contractVariant'); assert.deepEqual(schema.$defs.contractVariant.properties.release_channel.enum, [