fix(go): align Kimi hooks with official contracts
This commit is contained in:
1 parent
fd62dca155
commit
3ddce86c37
17 files changed
+1989
-634
No files matched your search
@@ -23,7 +23,7 @@ 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.
|
||||
- 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.
|
||||
- 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, ignore executable lookup as activation evidence, and collapse equal stable and legacy paths to the stable contract. 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. Status requires strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts.
|
||||
- Preserve Kimi TOML comments, array style, and unrelated formatting through syntax-preserving 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.
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/
|
||||
|
||||
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
||||
|
||||
Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present.
|
||||
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -29,8 +30,7 @@ func encodeCodexPowerShell(source string) string {
|
||||
runes := utf16.Encode([]rune(source))
|
||||
raw := make([]byte, len(runes)*2)
|
||||
for index, value := range runes {
|
||||
raw[index*2] = byte(value)
|
||||
raw[index*2+1] = byte(value >> 8)
|
||||
binary.LittleEndian.PutUint16(raw[index*2:], value)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type codexRuntimePaths struct {
|
||||
@@ -21,51 +16,6 @@ type codexRuntimePaths struct {
|
||||
auditPath string
|
||||
}
|
||||
|
||||
func validateCodexPrivateKey(value string) error {
|
||||
seed, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value {
|
||||
return fmt.Errorf("private key must be a canonical 32-byte base64url value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCodexBaseURL(value string) error {
|
||||
if strings.ContainsRune(value, '\\') {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 || unicode.IsSpace(character) {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
validScheme := strings.EqualFold(parsed.Scheme, "http") ||
|
||||
strings.EqualFold(parsed.Scheme, "https")
|
||||
if !validScheme ||
|
||||
parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return fmt.Errorf(
|
||||
"base URL must exclude credentials, query parameters, and fragments",
|
||||
)
|
||||
}
|
||||
port := parsed.Port()
|
||||
if strings.HasSuffix(parsed.Host, ":") {
|
||||
return fmt.Errorf("base URL must contain a valid port")
|
||||
}
|
||||
if port != "" {
|
||||
value, convertErr := strconv.Atoi(port)
|
||||
if convertErr != nil || value < 1 || value > 65535 {
|
||||
return fmt.Errorf("base URL must contain a valid port")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCodexInstallConfig(config InstallConfig) error {
|
||||
for _, field := range []struct{ name, value string }{
|
||||
{"agent name", config.AgentName},
|
||||
@@ -83,10 +33,10 @@ func validateCodexInstallConfig(config InstallConfig) error {
|
||||
if config.AgentName != codexAgentKey {
|
||||
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
|
||||
}
|
||||
if err := validateCodexPrivateKey(config.PrivateKey); err != nil {
|
||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateCodexBaseURL(config.BaseURL)
|
||||
return validateManagedBaseURL(config.BaseURL)
|
||||
}
|
||||
|
||||
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
||||
|
||||
@@ -2,74 +2,87 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type kimiMutation struct {
|
||||
document kimiDocument
|
||||
raw []byte
|
||||
remove bool
|
||||
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
||||
type KimiPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
||||
type KimiPlugin struct{}
|
||||
// ManagesGuardRuntime reports that Kimi commits its provider guard with the
|
||||
// audit runtime and every detected user hook document.
|
||||
func (p *KimiPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *KimiPlugin) Install(config InstallConfig) error {
|
||||
if config.AgentID == "" {
|
||||
return fmt.Errorf("agent ID is required")
|
||||
}
|
||||
// PreflightInstall validates every detected hook source and runtime identity.
|
||||
func (p *KimiPlugin) PreflightInstall(config InstallConfig) error {
|
||||
documents, err := readAllKimiConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.GuardScriptPath == "" {
|
||||
return fmt.Errorf("guard script path is required")
|
||||
}
|
||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
||||
_, _, err = preflightKimiInstallation(config, documents)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *KimiPlugin) Install(config InstallConfig) error {
|
||||
documents, err := readAllKimiConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !guardExists {
|
||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
||||
}
|
||||
hookPath, err := hookScriptPath(config.AgentID)
|
||||
paths, nodePath, err := preflightKimiInstallation(config, documents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
hookPath = config.HookScript
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
additions := []kimiHook{
|
||||
buildKimiHook("PreToolUse", buildKimiCommand(nodePath, config.GuardScriptPath)),
|
||||
buildKimiHook("PostToolUse", buildKimiCommand(nodePath, hookPath)),
|
||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutations := make([]kimiMutation, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
raw, err := renderKimiHooks(document, keptKimiHookIndices(document.hooks, ""), additions)
|
||||
additions := make([]kimiHook, 0, 3)
|
||||
for _, item := range []struct{ event, command string }{
|
||||
{"PreToolUse", guardCommand},
|
||||
{"PostToolUse", auditCommand},
|
||||
{"PostToolUseFailure", auditCommand},
|
||||
} {
|
||||
hook, err := buildKimiHook(item.event, item.command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseKimiDocument(document.contract, raw, true); err != nil {
|
||||
return fmt.Errorf("validate rendered %s: %w", document.contract.label, err)
|
||||
}
|
||||
mutations = append(mutations, kimiMutation{document: document, raw: raw})
|
||||
additions = append(additions, hook)
|
||||
}
|
||||
|
||||
runtimeConfig := config
|
||||
runtimeConfig.AgentName = kimiAgentKey
|
||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
for _, mutation := range mutations {
|
||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
||||
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
keep, err := keptKimiHookIndices(document.hooks, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change, err := renderKimiChange(document, keep, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rendered = append(rendered, change)
|
||||
}
|
||||
fmt.Printf("%s: global PreToolUse and PostToolUse hooks installed.\n", kimiRuntimeNames(documents))
|
||||
changes, err := prepareKimiInstallationChanges(config, paths, rendered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeKimiChanges(
|
||||
changes,
|
||||
"Install Kimi hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
"%s: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.\n",
|
||||
kimiRuntimeNames(documents),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,63 +91,51 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutations := make([]kimiMutation, 0, len(documents))
|
||||
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
keep := keptKimiHookIndices(document.hooks, agentID)
|
||||
if len(keep) == len(document.hooks) {
|
||||
continue
|
||||
}
|
||||
raw, err := renderKimiHooks(document, keep, nil)
|
||||
keep, err := keptKimiHookIndices(document.hooks, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseKimiDocument(document.contract, raw, true); err != nil {
|
||||
return fmt.Errorf("validate rendered %s: %w", document.contract.label, err)
|
||||
}
|
||||
mutations = append(mutations, kimiMutation{
|
||||
document: document,
|
||||
raw: raw,
|
||||
remove: strings.TrimSpace(string(raw)) == "",
|
||||
})
|
||||
}
|
||||
for _, mutation := range mutations {
|
||||
if mutation.remove {
|
||||
if err := removeKimiConfig(mutation.document); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
||||
change, err := renderKimiChange(document, keep, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rendered = append(rendered, change)
|
||||
}
|
||||
return nil
|
||||
changes, err := prepareKimiUninstallChanges(rendered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeKimiChanges(
|
||||
changes,
|
||||
"Uninstall Kimi hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
||||
documents, err := readAllKimiConfigs()
|
||||
entry := SupportedAgents[kimiAgentKey]
|
||||
status := PluginStatus{
|
||||
AgentName: kimiAgentKey,
|
||||
DisplayName: "Kimi Code",
|
||||
AgentName: kimiAgentKey, DisplayName: entry.Name,
|
||||
}
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status.ConfigPath = documents[0].contract.configPath
|
||||
contracts := make([]kimiRuntimeContract, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
contract := kimiRuntimeForDocument(document)
|
||||
if contract == nil {
|
||||
continue
|
||||
}
|
||||
contracts = append(contracts, *contract)
|
||||
status.ConfigPath = contract.configPath
|
||||
contracts, err := kimiRuntimeContracts(documents)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
if len(contracts) == 0 {
|
||||
return status, nil
|
||||
}
|
||||
status.ConfigPath = contracts[len(contracts)-1].configPath
|
||||
status.HookConfigured = true
|
||||
status.HookScriptExists, err = kimiRuntimeScriptsExist(contracts)
|
||||
status.HookScriptExists, err = kimiRuntimeFilesExist(contracts)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type kimiRuntimeReference struct {
|
||||
agentID string
|
||||
scriptPath string
|
||||
}
|
||||
|
||||
func sameKimiPath(left, right string) bool {
|
||||
left = filepath.Clean(left)
|
||||
right = filepath.Clean(right)
|
||||
if absolute, err := filepath.Abs(left); err == nil {
|
||||
left = absolute
|
||||
}
|
||||
if absolute, err := filepath.Abs(right); err == nil {
|
||||
right = absolute
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func sameKimiAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func buildKimiCommand(runtimePath, scriptPath string) (string, error) {
|
||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
||||
return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return codexWindowsCommand(runtimePath, scriptPath), nil
|
||||
}
|
||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
||||
}
|
||||
|
||||
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
||||
if start >= len(command) {
|
||||
return "", start, false
|
||||
}
|
||||
if command[start] == '"' {
|
||||
closing := strings.IndexByte(command[start+1:], '"')
|
||||
if closing < 0 {
|
||||
return "", start, false
|
||||
}
|
||||
closing += start + 1
|
||||
return command[start+1 : closing], closing + 1, true
|
||||
}
|
||||
end := start
|
||||
for end < len(command) && command[end] != ' ' {
|
||||
if command[end] == '"' || command[end] == '\t' || command[end] == '\r' || command[end] == '\n' {
|
||||
return "", start, false
|
||||
}
|
||||
end++
|
||||
}
|
||||
return command[start:end], end, end > start
|
||||
}
|
||||
|
||||
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
||||
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
||||
if !ok || next >= len(command) || command[next] != ' ' {
|
||||
return "", "", false
|
||||
}
|
||||
scriptPath, end, ok := readKimiLegacyWindowsArgument(command, next+1)
|
||||
if !ok || end != len(command) {
|
||||
return "", "", false
|
||||
}
|
||||
expected := quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
||||
return runtimePath, scriptPath, command == expected
|
||||
}
|
||||
|
||||
func parseKimiCommand(command string) (string, string, bool) {
|
||||
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
|
||||
return runtimePath, scriptPath, true
|
||||
}
|
||||
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok {
|
||||
return runtimePath, scriptPath, true
|
||||
}
|
||||
return parseKimiLegacyWindowsCommand(command)
|
||||
}
|
||||
|
||||
func isKimiNodeExecutable(path string) bool {
|
||||
name := filepath.Base(path)
|
||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
||||
}
|
||||
|
||||
func kimiRuntimeReferenceForCommand(
|
||||
command string,
|
||||
scriptName string,
|
||||
) (*kimiRuntimeReference, error) {
|
||||
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
||||
!isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName {
|
||||
return nil, nil
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return nil, nil
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
if agentID == "" || agentID == "." || agentID == ".." {
|
||||
return nil, nil
|
||||
}
|
||||
return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -38,22 +37,26 @@ var kimiModernEvents = mergeStringSets(kimiSharedEvents, stringSet(
|
||||
))
|
||||
|
||||
type kimiContract struct {
|
||||
runtimeName string
|
||||
label string
|
||||
configPath string
|
||||
events map[string]struct{}
|
||||
generation string
|
||||
runtimeName string
|
||||
label string
|
||||
directoryLabel string
|
||||
configPath string
|
||||
events map[string]struct{}
|
||||
}
|
||||
|
||||
type kimiHook struct {
|
||||
event string
|
||||
matcher *string
|
||||
command string
|
||||
timeout *int64
|
||||
event string
|
||||
matcher *string
|
||||
command string
|
||||
timeout *int64
|
||||
fieldCount int
|
||||
}
|
||||
|
||||
type kimiRuntimeContract struct {
|
||||
guard string
|
||||
audit string
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
configPath string
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ func validateKimiHook(value any, contract kimiContract, index int) (kimiHook, er
|
||||
return kimiHook{}, fmt.Errorf("%s hook %d requires a non-empty command", contract.label, index+1)
|
||||
}
|
||||
|
||||
hook := kimiHook{event: event, command: command}
|
||||
hook := kimiHook{event: event, command: command, fieldCount: len(object)}
|
||||
if value, exists := object["matcher"]; exists {
|
||||
matcher, ok := value.(string)
|
||||
if !ok {
|
||||
@@ -154,59 +157,124 @@ func kimiHooks(root map[string]any, contract kimiContract) ([]kimiHook, error) {
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
func normalizeKimiPath(value string) string {
|
||||
normalized := filepath.ToSlash(value)
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(normalized)
|
||||
func buildKimiHook(event, command string) (kimiHook, error) {
|
||||
switch event {
|
||||
case "PreToolUse", "PostToolUse", "PostToolUseFailure":
|
||||
default:
|
||||
return kimiHook{}, fmt.Errorf("unsupported managed Kimi event: %s", event)
|
||||
}
|
||||
return normalized
|
||||
timeout := kimiHookTimeoutSeconds
|
||||
return kimiHook{
|
||||
event: event, command: command, timeout: &timeout, fieldCount: 3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func kimiCommandEndsWithPath(command, filePath string) bool {
|
||||
normalized := strings.TrimSpace(normalizeKimiPath(command))
|
||||
expected := normalizeKimiPath(filePath)
|
||||
return strings.HasSuffix(normalized, expected) ||
|
||||
strings.HasSuffix(normalized, expected+`'`) ||
|
||||
strings.HasSuffix(normalized, expected+`"`)
|
||||
func managedKimiReference(
|
||||
hook kimiHook,
|
||||
event string,
|
||||
scriptName string,
|
||||
) (*kimiRuntimeReference, error) {
|
||||
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
||||
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
||||
return nil, nil
|
||||
}
|
||||
return kimiRuntimeReferenceForCommand(hook.command, scriptName)
|
||||
}
|
||||
|
||||
func isManagedKimiHook(hook kimiHook, event, scriptName, agentID string) bool {
|
||||
if hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
||||
return false
|
||||
func managedKimiEvent(event string) (string, string, bool) {
|
||||
switch event {
|
||||
case "PreToolUse":
|
||||
return event, kimiGuardScript, true
|
||||
case "PostToolUse", "PostToolUseFailure":
|
||||
return event, kimiAuditScript, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
if agentID != "" {
|
||||
expected := normalizeKimiPath(filepath.Join(".elydora", agentID, scriptName))
|
||||
return kimiCommandEndsWithPath(hook.command, "/"+expected)
|
||||
}
|
||||
normalized := normalizeKimiPath(hook.command)
|
||||
return strings.Contains(normalized, "/.elydora/") &&
|
||||
kimiCommandEndsWithPath(hook.command, "/"+normalizeKimiPath(scriptName))
|
||||
}
|
||||
|
||||
func keptKimiHookIndices(hooks []kimiHook, agentID string) []int {
|
||||
func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
||||
indices := make([]int, 0, len(hooks))
|
||||
for index, hook := range hooks {
|
||||
managed := isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, agentID) ||
|
||||
isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, agentID)
|
||||
if !managed {
|
||||
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
||||
var reference *kimiRuntimeReference
|
||||
var err error
|
||||
if managedEvent {
|
||||
reference, err = managedKimiReference(hook, event, scriptName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
remove := reference != nil &&
|
||||
(agentID == "" || sameKimiAgentID(reference.agentID, agentID))
|
||||
if !remove {
|
||||
indices = append(indices, index)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
func kimiRuntimeForDocument(document kimiDocument) *kimiRuntimeContract {
|
||||
var guard, audit string
|
||||
for _, hook := range document.hooks {
|
||||
if isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, "") {
|
||||
guard = hook.command
|
||||
}
|
||||
if isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, "") {
|
||||
audit = hook.command
|
||||
}
|
||||
func kimiReferenceKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
if guard == "" || audit == "" {
|
||||
return nil
|
||||
return agentID
|
||||
}
|
||||
|
||||
func kimiReferencesForEvent(
|
||||
hooks []kimiHook,
|
||||
event string,
|
||||
scriptName string,
|
||||
) (map[string][]kimiRuntimeReference, error) {
|
||||
result := map[string][]kimiRuntimeReference{}
|
||||
for _, hook := range hooks {
|
||||
reference, err := managedKimiReference(hook, event, scriptName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
key := kimiReferenceKey(reference.agentID)
|
||||
result[key] = append(result[key], *reference)
|
||||
}
|
||||
return &kimiRuntimeContract{guard: guard, audit: audit, configPath: document.contract.configPath}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, error) {
|
||||
contracts := make([]kimiRuntimeContract, 0)
|
||||
for _, document := range documents {
|
||||
guards, err := kimiReferencesForEvent(document.hooks, "PreToolUse", kimiGuardScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
successes, err := kimiReferencesForEvent(document.hooks, "PostToolUse", kimiAuditScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
failures, err := kimiReferencesForEvent(
|
||||
document.hooks, "PostToolUseFailure", kimiAuditScript,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
guard := guards[key]
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||
!sameKimiPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||
continue
|
||||
}
|
||||
contracts = append(contracts, kimiRuntimeContract{
|
||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath, configPath: document.contract.configPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return contracts, nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type kimiRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
type kimiRenderedDocument struct {
|
||||
document kimiDocument
|
||||
next []byte
|
||||
}
|
||||
|
||||
func validateKimiInstallConfig(config InstallConfig) error {
|
||||
for _, field := range []struct{ name, value string }{
|
||||
{"agent name", config.AgentName},
|
||||
{"organization ID", config.OrgID},
|
||||
{"agent ID", config.AgentID},
|
||||
{"key ID", config.KID},
|
||||
{"private key", config.PrivateKey},
|
||||
{"base URL", config.BaseURL},
|
||||
{"guard script path", config.GuardScriptPath},
|
||||
} {
|
||||
if field.value == "" {
|
||||
return fmt.Errorf("%s is required", field.name)
|
||||
}
|
||||
}
|
||||
if config.AgentName != kimiAgentKey {
|
||||
return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey)
|
||||
}
|
||||
if strings.TrimSpace(config.OrgID) == "" {
|
||||
return fmt.Errorf("organization ID is required")
|
||||
}
|
||||
if strings.TrimSpace(config.KID) == "" {
|
||||
return fmt.Errorf("key ID is required")
|
||||
}
|
||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||
}
|
||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateManagedBaseURL(config.BaseURL)
|
||||
}
|
||||
|
||||
func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) {
|
||||
if err := validateKimiInstallConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := &kimiRuntimePaths{
|
||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||
guardPath: filepath.Join(agentDirectory, kimiGuardScript),
|
||||
auditPath: filepath.Join(agentDirectory, kimiAuditScript),
|
||||
}
|
||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||
!sameKimiPath(config.GuardScriptPath, paths.guardPath) {
|
||||
return nil, fmt.Errorf(
|
||||
"guard runtime must use the managed Elydora agent directory: %s",
|
||||
paths.guardPath,
|
||||
)
|
||||
}
|
||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||
!sameKimiPath(config.HookScript, paths.auditPath)) {
|
||||
return nil, fmt.Errorf(
|
||||
"audit runtime must use the managed Elydora agent directory: %s",
|
||||
paths.auditPath,
|
||||
)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func preflightKimiInstallation(
|
||||
config InstallConfig,
|
||||
documents []kimiDocument,
|
||||
) (*kimiRuntimePaths, string, error) {
|
||||
if len(documents) == 0 {
|
||||
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
||||
}
|
||||
paths, err := kimiAgentPaths(config)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return paths, nodePath, nil
|
||||
}
|
||||
|
||||
func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) {
|
||||
value := map[string]any{
|
||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||
"base_url": config.BaseURL, "agent_name": kimiAgentKey,
|
||||
}
|
||||
if config.Token != "" {
|
||||
value["token"] = config.Token
|
||||
}
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
if len(encoded) > maxRuntimeConfigBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
||||
maxRuntimeConfigBytes,
|
||||
)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func renderKimiChange(
|
||||
document kimiDocument,
|
||||
keep []int,
|
||||
additions []kimiHook,
|
||||
) (kimiRenderedDocument, error) {
|
||||
next, err := renderKimiHooks(document, keep, additions)
|
||||
if err != nil {
|
||||
return kimiRenderedDocument{}, err
|
||||
}
|
||||
if strings.TrimSpace(string(next)) != "" {
|
||||
if _, err := parseKimiDocument(document.contract, next, true); err != nil {
|
||||
return kimiRenderedDocument{}, fmt.Errorf(
|
||||
"validate rendered %s: %w", document.contract.label, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return kimiRenderedDocument{document: document, next: next}, nil
|
||||
}
|
||||
|
||||
func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, error) {
|
||||
document := rendered.document
|
||||
if document.exists && bytes.Equal(document.raw, rendered.next) {
|
||||
return nil, nil
|
||||
}
|
||||
remove := document.exists && strings.TrimSpace(string(rendered.next)) == ""
|
||||
return prepareSourceChange(
|
||||
document.contract.configPath,
|
||||
document.contract.label,
|
||||
document.raw,
|
||||
document.exists,
|
||||
rendered.next,
|
||||
0600,
|
||||
remove,
|
||||
)
|
||||
}
|
||||
|
||||
func prepareKimiInstallationChanges(
|
||||
config InstallConfig,
|
||||
paths *kimiRuntimePaths,
|
||||
rendered []kimiRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
runtimeConfig, err := buildKimiRuntimeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{
|
||||
paths.guardPath, "Elydora guard runtime",
|
||||
[]byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false)), 0700,
|
||||
},
|
||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||
{
|
||||
paths.auditPath, "Elydora audit runtime",
|
||||
[]byte(buildHookScriptWithOutput(
|
||||
kimiAgentKey, config.AgentID, "", false, true,
|
||||
)), 0700,
|
||||
},
|
||||
}
|
||||
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
||||
for _, item := range items {
|
||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, document := range rendered {
|
||||
change, err := prepareRenderedKimiChange(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func prepareKimiUninstallChanges(
|
||||
rendered []kimiRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
changes := make([]*fileChange, 0, len(rendered))
|
||||
for _, document := range rendered {
|
||||
change, err := prepareRenderedKimiChange(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func writeKimiChanges(
|
||||
changes []*fileChange,
|
||||
label string,
|
||||
rename renameFunc,
|
||||
runtimeRoot string,
|
||||
agentDirectory string,
|
||||
) error {
|
||||
hasChanges := false
|
||||
for _, change := range changes {
|
||||
if change != nil {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasChanges {
|
||||
return nil
|
||||
}
|
||||
if agentDirectory != "" {
|
||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeChanges(changes, label, rename)
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
@@ -23,9 +21,8 @@ func isMissingKimiPath(err error) bool {
|
||||
return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR)
|
||||
}
|
||||
|
||||
func kimiPathExists(path, label string) (bool, error) {
|
||||
// #nosec G703 -- KIMI_CODE_HOME is an explicit local-user configuration path.
|
||||
_, err := os.Stat(path)
|
||||
func kimiEntryExists(path, label string) (bool, error) {
|
||||
_, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
@@ -35,15 +32,21 @@ func kimiPathExists(path, label string) (bool, error) {
|
||||
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
||||
}
|
||||
|
||||
func legacyKimiCLIOnPath() (bool, error) {
|
||||
_, err := exec.LookPath("kimi-cli")
|
||||
if err == nil {
|
||||
return true, nil
|
||||
func stableKimiContract(configPath string) kimiContract {
|
||||
return kimiContract{
|
||||
generation: "stable", runtimeName: "Kimi Code",
|
||||
label: "Kimi Code hooks config", directoryLabel: "Kimi Code home directory",
|
||||
configPath: configPath, events: kimiModernEvents,
|
||||
}
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func legacyKimiContract(configPath string) kimiContract {
|
||||
return kimiContract{
|
||||
generation: "legacy", runtimeName: "kimi-cli",
|
||||
label: "kimi-cli legacy hooks config",
|
||||
directoryLabel: "kimi-cli legacy home directory",
|
||||
configPath: configPath, events: kimiSharedEvents,
|
||||
}
|
||||
return false, fmt.Errorf("inspect kimi-cli executable on PATH: %w", err)
|
||||
}
|
||||
|
||||
func resolveKimiContracts() ([]kimiContract, error) {
|
||||
@@ -51,58 +54,59 @@ func resolveKimiContracts() ([]kimiContract, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
explicitHome := os.Getenv("KIMI_CODE_HOME")
|
||||
kimiHome := explicitHome
|
||||
if kimiHome == "" {
|
||||
kimiHome = filepath.Join(home, ".kimi-code")
|
||||
configuredHome := os.Getenv("KIMI_CODE_HOME")
|
||||
stableHome := filepath.Join(home, ".kimi-code")
|
||||
explicitHome := configuredHome != ""
|
||||
if explicitHome {
|
||||
stableHome, err = filepath.Abs(configuredHome)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve KIMI_CODE_HOME at %s: %w", configuredHome, err)
|
||||
}
|
||||
}
|
||||
modern := kimiContract{
|
||||
runtimeName: "Kimi Code",
|
||||
label: "Kimi Code hooks config",
|
||||
configPath: filepath.Join(kimiHome, "config.toml"),
|
||||
events: kimiModernEvents,
|
||||
legacyHome := filepath.Join(home, ".kimi")
|
||||
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
||||
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
||||
if sameKimiPath(stable.configPath, legacy.configPath) {
|
||||
return []kimiContract{stable}, nil
|
||||
}
|
||||
legacy := kimiContract{
|
||||
runtimeName: "kimi-cli",
|
||||
label: "kimi-cli legacy hooks config",
|
||||
configPath: filepath.Join(home, ".kimi", "config.toml"),
|
||||
events: kimiSharedEvents,
|
||||
}
|
||||
modernDetected := explicitHome != ""
|
||||
if !modernDetected {
|
||||
modernDetected, err = kimiPathExists(kimiHome, "Kimi Code home")
|
||||
|
||||
stableDetected := explicitHome
|
||||
if !stableDetected {
|
||||
stableDetected, err = kimiEntryExists(stableHome, "Kimi Code home")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
legacyDetected, err := kimiPathExists(legacy.configPath, legacy.label)
|
||||
legacyDetected, err := kimiEntryExists(legacyHome, "kimi-cli legacy home")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !legacyDetected {
|
||||
legacyDetected, err = legacyKimiCLIOnPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if legacyDetected && !modernDetected {
|
||||
if legacyDetected && !stableDetected {
|
||||
return []kimiContract{legacy}, nil
|
||||
}
|
||||
if legacyDetected {
|
||||
return []kimiContract{modern, legacy}, nil
|
||||
return []kimiContract{stable, legacy}, nil
|
||||
}
|
||||
return []kimiContract{modern}, nil
|
||||
return []kimiContract{stable}, nil
|
||||
}
|
||||
|
||||
func readKimiConfig(contract kimiContract) (kimiDocument, error) {
|
||||
raw, err := os.ReadFile(contract.configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return parseKimiDocument(contract, nil, false)
|
||||
}
|
||||
return kimiDocument{}, fmt.Errorf("read %s at %s: %w", contract.label, contract.configPath, err)
|
||||
directory := filepath.Dir(contract.configPath)
|
||||
if _, err := managedPhysicalDirectoryExists(directory, contract.directoryLabel); err != nil {
|
||||
return kimiDocument{}, err
|
||||
}
|
||||
return parseKimiDocument(contract, raw, true)
|
||||
snapshot, err := readManagedFile(
|
||||
contract.configPath,
|
||||
contract.label,
|
||||
maxManagedSourceBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return kimiDocument{}, err
|
||||
}
|
||||
if snapshot == nil {
|
||||
return parseKimiDocument(contract, nil, false)
|
||||
}
|
||||
return parseKimiDocument(contract, snapshot.contents, true)
|
||||
}
|
||||
|
||||
func readAllKimiConfigs() ([]kimiDocument, error) {
|
||||
@@ -121,32 +125,6 @@ func readAllKimiConfigs() ([]kimiDocument, error) {
|
||||
return documents, nil
|
||||
}
|
||||
|
||||
func writeKimiConfig(document kimiDocument, raw []byte) error {
|
||||
if err := writeHookFileAtomic(document.contract.configPath, raw, 0600); err != nil {
|
||||
return fmt.Errorf("write %s at %s: %w", document.contract.label, document.contract.configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeKimiConfig(document kimiDocument) error {
|
||||
if err := removeHookFile(document.contract.configPath, document.contract.label); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKimiCommand(runtimePath, scriptPath string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
||||
}
|
||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
||||
}
|
||||
|
||||
func buildKimiHook(event, command string) kimiHook {
|
||||
timeout := kimiHookTimeoutSeconds
|
||||
return kimiHook{event: event, command: command, timeout: &timeout}
|
||||
}
|
||||
|
||||
func kimiRuntimeNames(documents []kimiDocument) string {
|
||||
names := make([]string, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func installKimiRuntimeFixture(
|
||||
t *testing.T,
|
||||
status string,
|
||||
) (*kimiFixture, *codexTestAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
api := &codexTestAPI{status: status}
|
||||
server := httptest.NewServer(api)
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
fixture.config.BaseURL = server.URL
|
||||
fixture.config.Token = "ely_test_token"
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
server.Close()
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
return fixture, api, server
|
||||
}
|
||||
|
||||
func TestKimiGeneratedRuntimePreservesSuccessAndFailurePayloads(t *testing.T) {
|
||||
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||
defer server.Close()
|
||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||
guard := managedKimiTestHook(t, hooks, "PreToolUse")["command"].(string)
|
||||
success := managedKimiTestHook(t, hooks, "PostToolUse")["command"].(string)
|
||||
failure := managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"].(string)
|
||||
|
||||
if exitCode, stderr := runKimiCommand(
|
||||
t, guard, fixture, kimiOfficialPayload("PreToolUse"),
|
||||
); exitCode != 0 || stderr != "" {
|
||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||
}
|
||||
successPayload := kimiOfficialPayload("PostToolUse")
|
||||
failurePayload := kimiOfficialPayload("PostToolUseFailure")
|
||||
for _, item := range []struct {
|
||||
command string
|
||||
payload map[string]any
|
||||
}{{success, successPayload}, {failure, failurePayload}} {
|
||||
if exitCode, stderr := runKimiCommand(
|
||||
t, item.command, fixture, item.payload,
|
||||
); exitCode != 0 || stderr != "" {
|
||||
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
getAuth, postAuth, operations := api.snapshot()
|
||||
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
|
||||
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
|
||||
}
|
||||
if len(operations) != 2 ||
|
||||
!reflect.DeepEqual(operations[0]["payload"], successPayload) ||
|
||||
!reflect.DeepEqual(operations[1]["payload"], failurePayload) {
|
||||
t.Fatalf("Kimi native operations = %#v", operations)
|
||||
}
|
||||
if operations[0]["prev_chain_hash"] != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
|
||||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
|
||||
t.Fatalf("Kimi operation chain = %#v", operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiGuardPropagatesOfficialBlockExitCode(t *testing.T) {
|
||||
for _, status := range []string{"frozen", "revoked"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
fixture, _, server := installKimiRuntimeFixture(t, status)
|
||||
defer server.Close()
|
||||
command := managedKimiTestHook(
|
||||
t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse",
|
||||
)["command"].(string)
|
||||
exitCode, stderr := runKimiCommand(
|
||||
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||
)
|
||||
if exitCode != 2 || !strings.Contains(stderr, "Tool execution blocked") {
|
||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) {
|
||||
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||
command := managedKimiTestHook(
|
||||
t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse",
|
||||
)["command"].(string)
|
||||
runtimeConfig, err := os.ReadFile(fixture.runtimeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime config: %v", err)
|
||||
}
|
||||
|
||||
malformedExit, malformedError := runKimiRawCommand(
|
||||
t, command, fixture, []byte("{ malformed"),
|
||||
)
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
configExit, configError := runKimiCommand(
|
||||
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||
)
|
||||
if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil {
|
||||
t.Fatalf("restore runtime config: %v", err)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
|
||||
api.mu.Lock()
|
||||
api.status = "unknown"
|
||||
api.mu.Unlock()
|
||||
statusExit, statusError := runKimiCommand(
|
||||
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||
)
|
||||
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
|
||||
server.Close()
|
||||
apiExit, apiError := runKimiCommand(
|
||||
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||
)
|
||||
|
||||
for _, result := range []struct {
|
||||
exit int
|
||||
err string
|
||||
want string
|
||||
}{
|
||||
{malformedExit, malformedError, "invalid JSON"},
|
||||
{configExit, configError, "Failed to read agent config"},
|
||||
{statusExit, statusError, "invalid agent status"},
|
||||
{apiExit, apiError, "Failed to resolve agent status"},
|
||||
} {
|
||||
if result.exit != 0 || !strings.Contains(result.err, result.want) {
|
||||
t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiAuditRecordsMalformedInputAndAPIFailures(t *testing.T) {
|
||||
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||
defer server.Close()
|
||||
command := managedKimiTestHook(
|
||||
t, readKimiTestHooks(t, fixture.modernPath), "PostToolUseFailure",
|
||||
)["command"].(string)
|
||||
|
||||
malformedExit, malformedError := runKimiRawCommand(
|
||||
t, command, fixture, []byte("{ malformed"),
|
||||
)
|
||||
api.mu.Lock()
|
||||
api.postStatus = http.StatusServiceUnavailable
|
||||
api.mu.Unlock()
|
||||
upstreamExit, upstreamError := runKimiCommand(
|
||||
t, command, fixture, kimiOfficialPayload("PostToolUseFailure"),
|
||||
)
|
||||
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||
if malformedExit != 0 || malformedError != "" || upstreamExit != 0 || upstreamError != "" ||
|
||||
err != nil || !strings.Contains(string(log), "invalid JSON") ||
|
||||
!strings.Contains(string(log), "HTTP 503") {
|
||||
t.Fatalf(
|
||||
"audit failures = (%d, %q), (%d, %q), log=%q, err=%v",
|
||||
malformedExit, malformedError, upstreamExit, upstreamError, log, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiFailurePayloadIncludesOfficialErrorShape(t *testing.T) {
|
||||
payload := kimiOfficialPayload("PostToolUseFailure")
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if decoded["hook_event_name"] != "PostToolUseFailure" || decoded["error"] == nil {
|
||||
t.Fatalf("failure payload = %#v", decoded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKimiRejectsLinkedConfigBeforeCreatingRuntime(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
target := filepath.Join(t.TempDir(), "config-target.toml")
|
||||
source := []byte("# protected target\ntelemetry = false\n")
|
||||
if err := os.WriteFile(target, source, 0600); err != nil {
|
||||
t.Fatalf("write config target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil {
|
||||
t.Fatalf("create Kimi config directory: %v", err)
|
||||
}
|
||||
kimiSymlinkOrSkip(t, target, fixture.modernPath)
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||
t.Fatalf("linked config error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(target)
|
||||
if readErr != nil || string(actual) != string(source) {
|
||||
t.Fatalf("linked config target changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
}
|
||||
|
||||
func TestKimiRejectsLinkedConfigAndRuntimeDirectories(t *testing.T) {
|
||||
for _, kind := range []string{"config", "runtime"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
target := filepath.Join(t.TempDir(), kind+"-target")
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create link target: %v", err)
|
||||
}
|
||||
link := fixture.kimiHome
|
||||
if kind == "runtime" {
|
||||
link = filepath.Dir(fixture.agentDir)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(link), 0700); err != nil {
|
||||
t.Fatalf("create link parent: %v", err)
|
||||
}
|
||||
kimiSymlinkOrSkip(t, target, link)
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical directory") {
|
||||
t.Fatalf("linked %s directory error = %v", kind, err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("linked directory target changed: %#v, %v", entries, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiRejectsLinkedRuntimeFilesBeforeConfigWrites(t *testing.T) {
|
||||
for _, name := range []string{"config", "key", "guard", "audit"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
path := map[string]string{
|
||||
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
}[name]
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create runtime directory: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), name+"-target")
|
||||
source := []byte("external\n")
|
||||
if err := os.WriteFile(target, source, 0600); err != nil {
|
||||
t.Fatalf("write runtime target: %v", err)
|
||||
}
|
||||
kimiSymlinkOrSkip(t, target, path)
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||
t.Fatalf("linked %s error = %v", name, err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(target)
|
||||
if readErr != nil || string(actual) != string(source) {
|
||||
t.Fatalf("linked runtime target changed: %q, %v", actual, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(fixture.modernPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("Kimi config exists after rejection: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiRejectsOrphanAndMismatchedRuntimeIdentity(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, config string }{
|
||||
{"orphan", ""},
|
||||
{"mismatch", `{"agent_name":"kimi","agent_id":"another-agent"}`},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||
t.Fatalf("create agent directory: %v", err)
|
||||
}
|
||||
if testCase.config == "" {
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("orphan\n"), 0700); err != nil {
|
||||
t.Fatalf("write orphan guard: %v", err)
|
||||
}
|
||||
} else if err := os.WriteFile(
|
||||
fixture.runtimeConfig, []byte(testCase.config), 0600,
|
||||
); err != nil {
|
||||
t.Fatalf("write mismatched config: %v", err)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime") {
|
||||
t.Fatalf("runtime identity error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Kimi config exists after rejection: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiValidatesIdentityCredentialsAndOriginBeforeWrites(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mutate func(*InstallConfig)
|
||||
}{
|
||||
{"agent name", func(config *InstallConfig) { config.AgentName = "codex" }},
|
||||
{"organization", func(config *InstallConfig) { config.OrgID = " " }},
|
||||
{"agent ID", func(config *InstallConfig) { config.AgentID = "../escape" }},
|
||||
{"key ID", func(config *InstallConfig) { config.KID = "\t" }},
|
||||
{"private key", func(config *InstallConfig) { config.PrivateKey = "invalid" }},
|
||||
{"token", func(config *InstallConfig) { config.Token = " " }},
|
||||
{"scheme", func(config *InstallConfig) { config.BaseURL = "file:///tmp/elydora" }},
|
||||
{"credentials", func(config *InstallConfig) { config.BaseURL = "https://user:secret@api.elydora.com" }},
|
||||
{"query", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com?tenant=one" }},
|
||||
{"backslash", func(config *InstallConfig) { config.BaseURL = `https://api.elydora.com\evil` }},
|
||||
{"space", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com/invalid path" }},
|
||||
{"port", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com:invalid" }},
|
||||
{"guard path", func(config *InstallConfig) { config.GuardScriptPath = "unmanaged.js" }},
|
||||
{"audit path", func(config *InstallConfig) { config.HookScript = "unmanaged.js" }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
testCase.mutate(&fixture.config)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil {
|
||||
t.Fatal("install accepted invalid configuration")
|
||||
}
|
||||
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Kimi config exists after validation failure: %v", err)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiStatusRejectsLinkedRuntimeFiles(t *testing.T) {
|
||||
for _, name := range []string{"config", "key", "guard", "audit"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
path := map[string]string{
|
||||
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
}[name]
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime file: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), name+"-target")
|
||||
if err := os.WriteFile(target, content, 0600); err != nil {
|
||||
t.Fatalf("write linked target: %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove runtime file: %v", err)
|
||||
}
|
||||
kimiSymlinkOrSkip(t, target, path)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err == nil || status.Installed {
|
||||
t.Fatalf("linked runtime status = %#v, %v", status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiStatusValidatesRuntimeMetadataAndKey(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
want string
|
||||
}{
|
||||
{"extra", func(value map[string]any) { value["extra"] = true }, "unsupported field"},
|
||||
{"organization", func(value map[string]any) { value["org_id"] = "" }, "org_id is invalid"},
|
||||
{"identity", func(value map[string]any) { value["agent_id"] = "other" }, "identity"},
|
||||
{"base URL", func(value map[string]any) { value["base_url"] = "file:///tmp" }, "base URL"},
|
||||
{"token", func(value map[string]any) { value["token"] = "" }, "token is invalid"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(fixture.runtimeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime config: %v", err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatalf("decode runtime config: %v", err)
|
||||
}
|
||||
testCase.mutate(value)
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode runtime config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, encoded, 0600); err != nil {
|
||||
t.Fatalf("write runtime config: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err == nil || status.Installed || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("invalid runtime status = %#v, %v", status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
|
||||
t.Fatalf("write invalid private key: %v", err)
|
||||
}
|
||||
if status, err := fixture.plugin.Status(); err == nil || status.Installed ||
|
||||
!strings.Contains(err.Error(), "canonical 32-byte") {
|
||||
t.Fatalf("invalid key status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiOwnershipRequiresExactSupportedFields(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
legacy := legacyKimiCommand(t, fixture.guardPath)
|
||||
source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(legacy) +
|
||||
"\ntimeout = 10\nmatcher = \"Bash\"\n"
|
||||
writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source))
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||
if len(hooks) != 4 || hooks[0]["matcher"] != "Bash" {
|
||||
t.Fatalf("user-owned matcher hook changed: %#v", hooks)
|
||||
}
|
||||
requireKimiManagedTriple(t, hooks)
|
||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||
}
|
||||
hooks = readKimiTestHooks(t, fixture.modernPath)
|
||||
if len(hooks) != 1 || hooks[0]["matcher"] != "Bash" {
|
||||
t.Fatalf("user-owned matcher hook removed: %#v", hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil ||
|
||||
!strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("status error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,70 +2,194 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func kimiRuntimeScriptsExist(contracts []kimiRuntimeContract) (bool, error) {
|
||||
home, err := kimiHomeDirectory()
|
||||
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||
if err != nil || snapshot == nil {
|
||||
return nil, snapshot != nil, err
|
||||
}
|
||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||
return config, true, err
|
||||
}
|
||||
|
||||
func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) {
|
||||
value, ok := config[field].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf(
|
||||
"elydora runtime config %s is invalid: %s", field, configPath,
|
||||
)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateKimiRuntimeConfig(
|
||||
config map[string]any,
|
||||
expectedAgentID string,
|
||||
configPath string,
|
||||
) error {
|
||||
supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name")
|
||||
fields := make([]string, 0, len(config))
|
||||
for field := range config {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
for _, field := range fields {
|
||||
if _, ok := supported[field]; !ok {
|
||||
return fmt.Errorf(
|
||||
`elydora runtime config has unsupported field %q: %s`, field, configPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
agentID, err := requireKimiRuntimeString(config, "agent_id", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
|
||||
return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath)
|
||||
}
|
||||
if _, exists := config["token"]; exists {
|
||||
if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
baseURL, err := requireKimiRuntimeString(config, "base_url", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
|
||||
runtimeRoot := filepath.Dir(agentDirectory)
|
||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||
if err != nil || !rootExists {
|
||||
return err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
agentDirectory, "Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
config, configExists, err := readKimiRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
artifactExists := false
|
||||
for _, item := range []struct {
|
||||
path string
|
||||
label string
|
||||
limit int64
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||
{filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||
} {
|
||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||
if inspectErr != nil {
|
||||
return inspectErr
|
||||
}
|
||||
artifactExists = artifactExists || exists
|
||||
}
|
||||
if !configExists {
|
||||
if artifactExists {
|
||||
return fmt.Errorf(
|
||||
"elydora runtime identity cannot be verified without config.json: %s",
|
||||
agentDirectory,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
configuredID, ok := config["agent_id"].(string)
|
||||
if !ok || config["agent_name"] != kimiAgentKey ||
|
||||
!sameKimiAgentID(configuredID, agentID) {
|
||||
return fmt.Errorf(
|
||||
"elydora runtime config identity does not match Kimi agent %s: %s",
|
||||
agentID,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
root := filepath.Join(home, ".elydora")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
||||
agentDirectory := filepath.Dir(contract.guardPath)
|
||||
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
||||
!sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) {
|
||||
return false, nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
agentDir := filepath.Join(root, entry.Name())
|
||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
||||
auditPath := filepath.Join(agentDir, kimiAuditScript)
|
||||
if !kimiContractsReferenceRuntime(contracts, guardPath, auditPath) {
|
||||
continue
|
||||
}
|
||||
configPath := filepath.Join(agentDir, "config.json")
|
||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||
if err != nil || !rootExists {
|
||||
return false, err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
agentDirectory, "Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return false, err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||
config, configExists, err := readKimiRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !configExists || key == nil || guard == nil || audit == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
||||
}
|
||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
||||
}
|
||||
|
||||
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
||||
for _, contract := range contracts {
|
||||
exists, err := kimiRuntimeContractExists(contract)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
agentName, ok := config["agent_name"].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf(
|
||||
`Elydora runtime config at %s field "agent_name" must be a string`,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
if agentName != kimiAgentKey {
|
||||
continue
|
||||
}
|
||||
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
auditExists, err := regularFileExists(auditPath, "Elydora audit runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return guardExists && auditExists, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func kimiContractsReferenceRuntime(contracts []kimiRuntimeContract, guardPath, auditPath string) bool {
|
||||
for _, contract := range contracts {
|
||||
if kimiCommandEndsWithPath(contract.guard, guardPath) &&
|
||||
kimiCommandEndsWithPath(contract.audit, auditPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2,71 +2,90 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKimiRegistryUsesModernGlobalContract(t *testing.T) {
|
||||
func TestKimiRegistryUsesStableGlobalContract(t *testing.T) {
|
||||
entry := SupportedAgents[kimiAgentKey]
|
||||
if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" || entry.ConfigFile != "config.toml" {
|
||||
if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" ||
|
||||
entry.ConfigFile != "config.toml" {
|
||||
t.Fatalf("Kimi registry entry = %#v", entry)
|
||||
}
|
||||
if _, ok := NewPlugin(kimiAgentKey).(*KimiPlugin); !ok {
|
||||
plugin, ok := NewPlugin(kimiAgentKey).(*KimiPlugin)
|
||||
if !ok || !plugin.ManagesGuardRuntime() {
|
||||
t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) {
|
||||
modern := "# modern user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
||||
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-modern\"\n" +
|
||||
"timeout = 30 # keep modern hook\n"
|
||||
modern := "# stable user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
||||
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-stable\"\n" +
|
||||
"timeout = 30 # keep stable hook\n"
|
||||
legacy := "# legacy user config\ntelemetry = false\n\n" +
|
||||
"[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(modern),
|
||||
legacyConfig: kimiString(legacy),
|
||||
modernConfig: kimiString(modern), legacyConfig: kimiString(legacy),
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
first := map[string][]byte{}
|
||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read first Kimi source: %v", err)
|
||||
}
|
||||
first[path] = raw
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repeat Kimi install: %v", err)
|
||||
}
|
||||
|
||||
for _, contract := range []struct{ path, comment, command string }{
|
||||
{fixture.modernPath, "# modern user config", "existing-modern"},
|
||||
{fixture.modernPath, "# stable user config", "existing-stable"},
|
||||
{fixture.legacyPath, "# legacy user config", "existing-legacy"},
|
||||
} {
|
||||
raw, err := os.ReadFile(contract.path)
|
||||
if err != nil {
|
||||
t.Fatalf("read Kimi config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), contract.comment) || !strings.Contains(string(raw), contract.command) {
|
||||
if !reflect.DeepEqual(raw, first[contract.path]) ||
|
||||
!strings.Contains(string(raw), contract.comment) ||
|
||||
!strings.Contains(string(raw), contract.command) {
|
||||
t.Fatalf("user config changed: %s", raw)
|
||||
}
|
||||
hooks := readKimiTestHooks(t, contract.path)
|
||||
if len(hooks) != 3 {
|
||||
t.Fatalf("hooks = %#v, want three entries", hooks)
|
||||
if len(hooks) != 4 {
|
||||
t.Fatalf("hooks = %#v, want four entries", hooks)
|
||||
}
|
||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript))
|
||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript))
|
||||
requireKimiManagedTriple(t, hooks)
|
||||
}
|
||||
defaultModern := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml")
|
||||
if _, err := os.Stat(defaultModern); !os.IsNotExist(err) {
|
||||
t.Fatalf("false default migration target exists: %v", err)
|
||||
defaultStable := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml")
|
||||
if _, err := os.Stat(defaultStable); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unselected default config exists: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||
} {
|
||||
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("managed runtime missing at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
||||
modern := "# inline user hook\nhooks = [\n # keep leading\n { event = \"SessionStart\", " +
|
||||
"command = \"existing-inline\" }, # keep gap\n] # keep array\n"
|
||||
modern := "# inline user hook\nhooks = [\n # keep leading\n" +
|
||||
" { event = \"SessionStart\", command = \"existing-inline\" }, # keep gap\n" +
|
||||
"] # keep array\n"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(modern),
|
||||
withoutLegacyCLI: true,
|
||||
modernConfig: kimiString(modern), withoutLegacyEvidence: true,
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install inline Kimi hooks: %v", err)
|
||||
@@ -79,150 +98,181 @@ func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
||||
t.Fatalf("read inline Kimi config: %v", err)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"# inline user hook",
|
||||
"# keep leading",
|
||||
"# keep gap",
|
||||
"# keep array",
|
||||
"hooks = [",
|
||||
"# inline user hook", "# keep leading", "# keep gap", "# keep array", "hooks = [",
|
||||
} {
|
||||
if !strings.Contains(string(raw), marker) {
|
||||
t.Fatalf("inline marker %q missing from %s", marker, raw)
|
||||
}
|
||||
}
|
||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 3 {
|
||||
t.Fatalf("inline Kimi hooks are not idempotent: %s", raw)
|
||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||
if len(hooks) != 4 {
|
||||
t.Fatalf("inline hooks = %#v", hooks)
|
||||
}
|
||||
requireKimiManagedTriple(t, hooks)
|
||||
}
|
||||
|
||||
func TestKimiModernDefaultAvoidsLegacyMigrationMarker(t *testing.T) {
|
||||
func TestKimiUninstallPreservesUserOwnedEmptyInlineArray(t *testing.T) {
|
||||
source := "# user container\nhooks = [] # keep empty array\n"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
useDefaultHome: true,
|
||||
withoutLegacyCLI: true,
|
||||
modernConfig: kimiString(source), withoutLegacyEvidence: true,
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install modern Kimi hooks: %v", err)
|
||||
}
|
||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 2 {
|
||||
t.Fatalf("modern Kimi config has unexpected hooks")
|
||||
}
|
||||
if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("false legacy migration target exists: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("empty KIMI_CODE_HOME status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiLegacyInstallAvoidsPrematureModernTarget(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{useDefaultHome: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install legacy Kimi hooks: %v", err)
|
||||
}
|
||||
if len(readKimiTestHooks(t, fixture.legacyPath)) != 2 {
|
||||
t.Fatalf("legacy Kimi config has unexpected hooks")
|
||||
}
|
||||
if _, err := os.Stat(fixture.modernPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("premature modern migration target exists: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.legacyCLIPath); err != nil {
|
||||
t.Fatalf("remove legacy CLI marker: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("legacy status after PATH change = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiCommandsBlockAndForwardOfficialPayload(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||
encodedPath, err := json.Marshal(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal capture path: %v", err)
|
||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||
}
|
||||
source := "const fs = require('node:fs'); const chunks = []; " +
|
||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
|
||||
if err := os.WriteFile(fixture.hookPath, []byte(source), 0700); err != nil {
|
||||
t.Fatalf("write capture runtime: %v", err)
|
||||
raw, err := os.ReadFile(fixture.modernPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read empty inline config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "# user container") ||
|
||||
!strings.Contains(string(raw), "# keep empty array") ||
|
||||
len(readKimiTestHooks(t, fixture.modernPath)) != 0 {
|
||||
t.Fatalf("empty inline array changed: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiUsesDefaultStableAndLegacyEvidence(t *testing.T) {
|
||||
t.Run("stable", func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
useDefaultHome: true, withoutLegacyEvidence: true,
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install stable Kimi hooks: %v", err)
|
||||
}
|
||||
requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.modernPath))
|
||||
if _, err := os.Stat(fixture.legacyPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("legacy config exists: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy", func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
useDefaultHome: true, withoutModernEvidence: true,
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install legacy Kimi hooks: %v", err)
|
||||
}
|
||||
requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.legacyPath))
|
||||
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("stable config exists: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("legacy Kimi status = %#v, %v", status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKimiParsesEverySelectedConfigBeforeRuntimeWrites(t *testing.T) {
|
||||
modern := "# untouched stable\ndefault_model = \"kimi-code/k3\"\n"
|
||||
legacy := "[malformed"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(modern), legacyConfig: kimiString(legacy),
|
||||
})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
fixture.modernPath: modern, fixture.legacyPath: legacy,
|
||||
} {
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(raw) != want {
|
||||
t.Fatalf("config %s changed: %q, %v", path, raw, readErr)
|
||||
}
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
}
|
||||
|
||||
func TestKimiRejectsFieldsAndEventsOutsideEachContract(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name, modern, legacy, want string
|
||||
}{
|
||||
{
|
||||
"unsupported-field",
|
||||
"[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n",
|
||||
"", `unsupported field "cwd"`,
|
||||
},
|
||||
{
|
||||
"legacy-stable-event", "",
|
||||
"[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n",
|
||||
"unsupported event",
|
||||
},
|
||||
{
|
||||
"bad-timeout",
|
||||
"[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n",
|
||||
"", "integer from 1 to 600",
|
||||
},
|
||||
{
|
||||
"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n",
|
||||
"", `field "hooks" must be an array`,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
options := kimiFixtureOptions{}
|
||||
if testCase.modern != "" {
|
||||
options.modernConfig = kimiString(testCase.modern)
|
||||
}
|
||||
if testCase.legacy != "" {
|
||||
options.legacyConfig = kimiString(testCase.legacy)
|
||||
}
|
||||
fixture := prepareKimiFixture(t, options)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiMigratesExactLegacyCommandsAndPreservesLookalikes(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
guard := legacyKimiCommand(t, fixture.guardPath)
|
||||
audit := legacyKimiCommand(t, fixture.hookPath)
|
||||
lookalike := guard + " --inspect"
|
||||
source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(guard) +
|
||||
"\ntimeout = 10\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
||||
strconv.Quote(audit) + "\ntimeout = 10\n\n[[hooks]]\nevent = \"PreToolUse\"\ncommand = " +
|
||||
strconv.Quote(lookalike) + "\ntimeout = 10\n"
|
||||
writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source))
|
||||
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("migrate legacy Kimi commands: %v", err)
|
||||
}
|
||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||
payload := map[string]any{
|
||||
"hook_event_name": "PreToolUse",
|
||||
"session_id": "session-1",
|
||||
"cwd": fixture.homeDir,
|
||||
"tool_name": "Bash",
|
||||
"tool_input": map[string]any{"command": "echo test"},
|
||||
"tool_call_id": "call-1",
|
||||
if len(hooks) != 4 {
|
||||
t.Fatalf("migrated hooks = %#v", hooks)
|
||||
}
|
||||
guard := findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript)
|
||||
exitCode, stderr := runKimiCommand(t, guard["command"].(string), fixture.homeDir, payload)
|
||||
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||
requireKimiManagedTriple(t, hooks)
|
||||
if !containsKimiCommand(hooks, lookalike) {
|
||||
t.Fatalf("lookalike command removed: %#v", hooks)
|
||||
}
|
||||
payload["hook_event_name"] = "PostToolUse"
|
||||
payload["tool_output"] = map[string]any{"output": "test"}
|
||||
audit := findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript)
|
||||
exitCode, stderr = runKimiCommand(t, audit["command"].(string), fixture.homeDir, payload)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall migrated Kimi hooks: %v", err)
|
||||
}
|
||||
capturedRaw, err := os.ReadFile(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read captured event: %v", err)
|
||||
}
|
||||
var captured map[string]any
|
||||
if err := json.Unmarshal(capturedRaw, &captured); err != nil {
|
||||
t.Fatalf("decode captured event: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(captured, payload) {
|
||||
t.Fatalf("captured event = %#v, want %#v", captured, payload)
|
||||
hooks = readKimiTestHooks(t, fixture.modernPath)
|
||||
if len(hooks) != 1 || hooks[0]["command"] != lookalike {
|
||||
t.Fatalf("remaining hooks = %#v", hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiStatusAcceptsEitherContractAndRequiresBothScripts(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.modernPath); err != nil {
|
||||
t.Fatalf("remove modern config: %v", err)
|
||||
}
|
||||
requireKimiInstalledStatus(t, fixture.plugin, fixture.legacyPath)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("reinstall Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.legacyPath); err != nil {
|
||||
t.Fatalf("remove legacy config: %v", err)
|
||||
}
|
||||
requireKimiInstalledStatus(t, fixture.plugin, fixture.modernPath)
|
||||
if err := os.Remove(fixture.guardPath); err != nil {
|
||||
t.Fatalf("remove Kimi guard: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||
t.Fatalf("degraded Kimi status = %#v, %v", status, err)
|
||||
func containsKimiCommand(hooks []map[string]any, command string) bool {
|
||||
for _, hook := range hooks {
|
||||
if hook["command"] == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requireKimiInstalledStatus(t *testing.T, plugin *KimiPlugin, configPath string) {
|
||||
t.Helper()
|
||||
status, err := plugin.Status()
|
||||
if err != nil || !status.Installed || status.ConfigPath != configPath {
|
||||
t.Fatalf("Kimi status = %#v, %v; want installed at %s", status, err, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiUninstallPreservesUserHooks(t *testing.T) {
|
||||
func TestKimiUninstallPreservesUserConfigsAndRemovesManagedConfigs(t *testing.T) {
|
||||
userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" +
|
||||
"command = \"existing-command\"\ntimeout = 30 # keep timeout\n"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(userHook),
|
||||
legacyConfig: kimiString(userHook),
|
||||
modernConfig: kimiString(userHook), legacyConfig: kimiString(userHook),
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
@@ -232,159 +282,129 @@ func TestKimiUninstallPreservesUserHooks(t *testing.T) {
|
||||
}
|
||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read user config after uninstall: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "# user hook") || !strings.Contains(string(raw), "# keep timeout") {
|
||||
t.Fatalf("user comments changed: %s", raw)
|
||||
if err != nil || !strings.Contains(string(raw), "# keep timeout") {
|
||||
t.Fatalf("user config changed: %s, %v", raw, err)
|
||||
}
|
||||
hooks := readKimiTestHooks(t, path)
|
||||
if len(hooks) != 1 || hooks[0]["command"] != "existing-command" {
|
||||
t.Fatalf("user hook changed: %#v", hooks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiOwnershipRequiresExactAgentAndScript(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
backupPath := filepath.Join(fixture.homeDir, ".elydora", kimiTestAgentID, "hook.js.backup")
|
||||
otherAgentPath := filepath.Join(fixture.homeDir, ".elydora", "agent-10", kimiAuditScript)
|
||||
userHooks := "[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
||||
strconv.Quote("node "+backupPath) + "\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
||||
strconv.Quote("node "+otherAgentPath) + "\n"
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0755); err != nil {
|
||||
t.Fatalf("create modern Kimi directory: %v", err)
|
||||
managed := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := managed.plugin.Install(managed.config); err != nil {
|
||||
t.Fatalf("install managed Kimi configs: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.modernPath, []byte(userHooks), 0600); err != nil {
|
||||
t.Fatalf("write ownership fixture: %v", err)
|
||||
if err := managed.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("remove managed Kimi configs: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||
}
|
||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||
if len(hooks) != 2 {
|
||||
t.Fatalf("ownership filter removed user hooks: %#v", hooks)
|
||||
}
|
||||
commands := []any{hooks[0]["command"], hooks[1]["command"]}
|
||||
if !reflect.DeepEqual(commands, []any{"node " + backupPath, "node " + otherAgentPath}) {
|
||||
t.Fatalf("remaining commands = %#v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiUninstallRemovesOwnedConfigs(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||
}
|
||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("owned Kimi config remains at %s: %v", path, err)
|
||||
for _, path := range []string{managed.modernPath, managed.legacyPath} {
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed config remains at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiParsesEveryConfigBeforeRuntimeWrites(t *testing.T) {
|
||||
modern := "# untouched modern\ndefault_model = \"kimi-code/k3\"\n"
|
||||
legacy := "[malformed"
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(modern),
|
||||
legacyConfig: kimiString(legacy),
|
||||
})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
func TestKimiStatusRequiresCompleteTripleAndEveryRuntimeFile(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
for path, want := range map[string]string{fixture.modernPath: modern, fixture.legacyPath: legacy} {
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(raw) != want {
|
||||
t.Fatalf("config %s changed: %q, %v", path, raw, readErr)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured {
|
||||
t.Fatalf("Kimi status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(fixture.modernPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read Kimi config: %v", err)
|
||||
}
|
||||
document, err := parseKimiDocument(
|
||||
stableKimiContract(fixture.modernPath), raw, true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Kimi config: %v", err)
|
||||
}
|
||||
keep := make([]int, 0, len(document.hooks)-1)
|
||||
for index, hook := range document.hooks {
|
||||
if hook.event != "PostToolUseFailure" {
|
||||
keep = append(keep, index)
|
||||
}
|
||||
}
|
||||
requireNoKimiRuntimeWrites(t, fixture)
|
||||
}
|
||||
withoutFailure, err := renderKimiHooks(document, keep, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render incomplete Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.modernPath, withoutFailure, 0600); err != nil {
|
||||
t.Fatalf("write incomplete Kimi hooks: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("incomplete Kimi status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
func TestKimiRejectsInvalidHookContractsBeforeWrites(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, raw, want string }{
|
||||
{"unsupported-field", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n", `unsupported field "cwd"`},
|
||||
{"unsupported-event", "[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n", "unsupported event"},
|
||||
{"bad-matcher", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\nmatcher=1\n", "matcher must be a string"},
|
||||
{"bad-timeout", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n", "integer from 1 to 600"},
|
||||
{"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n", `field "hooks" must be an array`},
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repair Kimi hooks: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
useDefaultHome: true,
|
||||
legacyConfig: kimiString(testCase.raw),
|
||||
})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime file: %v", err)
|
||||
}
|
||||
raw, readErr := os.ReadFile(fixture.legacyPath)
|
||||
if readErr != nil || string(raw) != testCase.raw {
|
||||
t.Fatalf("invalid config changed: %q, %v", raw, readErr)
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove runtime file: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured {
|
||||
t.Fatalf("degraded Kimi status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatalf("restore runtime file: %v", err)
|
||||
}
|
||||
requireNoKimiRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiRejectsMissingGuardBeforeWrites(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutGuard: true})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("config write occurred at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
requireNoKimiRuntimeWrites(t, fixture)
|
||||
}
|
||||
|
||||
func requireNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) {
|
||||
t.Helper()
|
||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) {
|
||||
func TestKimiInstallationLeavesNoTransactionArtifacts(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiAtomicWritesLeaveNoTemporaryFiles(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
for _, directory := range []string{filepath.Dir(fixture.modernPath), filepath.Dir(fixture.legacyPath)} {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read config directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("temporary Kimi config remains at %s", filepath.Join(directory, entry.Name()))
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
if runtime.GOOS != "windows" {
|
||||
for _, path := range []string{
|
||||
fixture.modernPath, fixture.legacyPath,
|
||||
fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect mode for %s: %v", path, err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("mode for %s = %v", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
fixture.config.Token = ""
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks without token: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(fixture.runtimeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime config: %v", err)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
t.Fatalf("decode runtime config: %v", err)
|
||||
}
|
||||
if _, exists := config["token"]; exists {
|
||||
t.Fatalf("empty optional token persisted: %#v", config)
|
||||
}
|
||||
}
|
||||
@@ -15,30 +15,33 @@ import (
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
const kimiTestAgentID = "agent-1"
|
||||
const (
|
||||
kimiTestAgentID = "agent-1"
|
||||
kimiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
|
||||
)
|
||||
|
||||
type kimiFixtureOptions struct {
|
||||
modernConfig *string
|
||||
legacyConfig *string
|
||||
useDefaultHome bool
|
||||
withoutLegacyCLI bool
|
||||
withoutGuard bool
|
||||
modernConfig *string
|
||||
legacyConfig *string
|
||||
useDefaultHome bool
|
||||
withoutModernEvidence bool
|
||||
withoutLegacyEvidence bool
|
||||
}
|
||||
|
||||
type kimiFixture struct {
|
||||
plugin *KimiPlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
binDir string
|
||||
projectDir string
|
||||
kimiHome string
|
||||
modernPath string
|
||||
legacyHome string
|
||||
legacyPath string
|
||||
agentDir string
|
||||
guardPath string
|
||||
hookPath string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
legacyCLIPath string
|
||||
}
|
||||
|
||||
func kimiString(value string) *string {
|
||||
@@ -47,45 +50,38 @@ func kimiString(value string) *string {
|
||||
|
||||
func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
||||
t.Helper()
|
||||
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
||||
binDir := filepath.Join(homeDir, "bin")
|
||||
kimiHome := filepath.Join(homeDir, "custom kimi code")
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
|
||||
projectDir := filepath.Join(root, "project with spaces")
|
||||
kimiHome := filepath.Join(homeDir, "custom kimi-code")
|
||||
if options.useDefaultHome {
|
||||
kimiHome = filepath.Join(homeDir, ".kimi-code")
|
||||
}
|
||||
modernPath := filepath.Join(kimiHome, "config.toml")
|
||||
legacyPath := filepath.Join(homeDir, ".kimi", "config.toml")
|
||||
legacyHome := filepath.Join(homeDir, ".kimi")
|
||||
legacyPath := filepath.Join(legacyHome, "config.toml")
|
||||
agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID)
|
||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
||||
hookPath := filepath.Join(agentDir, kimiAuditScript)
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
t.Fatalf("create agent directory: %v", err)
|
||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||
t.Fatalf("create project directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||
t.Fatalf("create binary directory: %v", err)
|
||||
if options.useDefaultHome && !options.withoutModernEvidence {
|
||||
if err := os.MkdirAll(kimiHome, 0700); err != nil {
|
||||
t.Fatalf("create Kimi Code home: %v", err)
|
||||
}
|
||||
}
|
||||
if !options.withoutGuard {
|
||||
source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
|
||||
if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil {
|
||||
t.Fatalf("write guard runtime: %v", err)
|
||||
if !options.withoutLegacyEvidence {
|
||||
if err := os.MkdirAll(legacyHome, 0700); err != nil {
|
||||
t.Fatalf("create legacy Kimi home: %v", err)
|
||||
}
|
||||
}
|
||||
writeOptionalKimiConfig(t, modernPath, options.modernConfig)
|
||||
writeOptionalKimiConfig(t, legacyPath, options.legacyConfig)
|
||||
|
||||
legacyCLIPath := filepath.Join(binDir, "kimi-cli")
|
||||
if runtime.GOOS == "windows" {
|
||||
legacyCLIPath += ".cmd"
|
||||
}
|
||||
if !options.withoutLegacyCLI {
|
||||
if err := os.WriteFile(legacyCLIPath, []byte(""), 0700); err != nil {
|
||||
t.Fatalf("write legacy CLI marker: %v", err)
|
||||
}
|
||||
}
|
||||
originalPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath)
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment"))
|
||||
if options.useDefaultHome {
|
||||
t.Setenv("KIMI_CODE_HOME", "")
|
||||
} else {
|
||||
@@ -93,27 +89,17 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
||||
}
|
||||
|
||||
return &kimiFixture{
|
||||
plugin: &KimiPlugin{},
|
||||
homeDir: homeDir,
|
||||
binDir: binDir,
|
||||
kimiHome: kimiHome,
|
||||
config: InstallConfig{
|
||||
AgentName: kimiAgentKey,
|
||||
OrgID: "org-1",
|
||||
AgentID: kimiTestAgentID,
|
||||
PrivateKey: "test-key",
|
||||
KID: "kid-1",
|
||||
BaseURL: "https://api.elydora.test",
|
||||
GuardScriptPath: guardPath,
|
||||
},
|
||||
modernPath: modernPath,
|
||||
legacyPath: legacyPath,
|
||||
agentDir: agentDir,
|
||||
guardPath: guardPath,
|
||||
hookPath: hookPath,
|
||||
plugin: &KimiPlugin{}, homeDir: homeDir, projectDir: projectDir,
|
||||
kimiHome: kimiHome, modernPath: modernPath,
|
||||
legacyHome: legacyHome, legacyPath: legacyPath,
|
||||
agentDir: agentDir, guardPath: guardPath, hookPath: hookPath,
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
legacyCLIPath: legacyCLIPath,
|
||||
config: InstallConfig{
|
||||
AgentName: kimiAgentKey, OrgID: "org-1", AgentID: kimiTestAgentID,
|
||||
PrivateKey: kimiPrivateKey, KID: "kid-1", Token: "token-1",
|
||||
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +108,7 @@ func writeOptionalKimiConfig(t *testing.T, path string, raw *string) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create Kimi config directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(*raw), 0600); err != nil {
|
||||
@@ -155,36 +141,87 @@ func readKimiTestHooks(t *testing.T, path string) []map[string]any {
|
||||
return hooks
|
||||
}
|
||||
|
||||
func findKimiTestHook(t *testing.T, hooks []map[string]any, event, script string) map[string]any {
|
||||
func managedKimiTestHook(t *testing.T, hooks []map[string]any, event string) map[string]any {
|
||||
t.Helper()
|
||||
for _, hook := range hooks {
|
||||
command, _ := hook["command"].(string)
|
||||
if hook["event"] == event && strings.Contains(command, script) {
|
||||
for index := len(hooks) - 1; index >= 0; index-- {
|
||||
hook := hooks[index]
|
||||
if hook["event"] == event && hook["timeout"] == int64(10) {
|
||||
return hook
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s hook for %s not found", event, script)
|
||||
t.Fatalf("managed %s hook not found", event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireStrictKimiHook(t *testing.T, hook map[string]any) {
|
||||
func requireStrictKimiHook(t *testing.T, hook map[string]any, event string) {
|
||||
t.Helper()
|
||||
want := map[string]any{
|
||||
"event": hook["event"],
|
||||
"command": hook["command"],
|
||||
"timeout": int64(10),
|
||||
"event": event, "command": hook["command"], "timeout": int64(10),
|
||||
}
|
||||
if !reflect.DeepEqual(hook, want) {
|
||||
t.Fatalf("hook = %#v, want strict contract %#v", hook, want)
|
||||
}
|
||||
command, ok := hook["command"].(string)
|
||||
if !ok || command == "" {
|
||||
t.Fatalf("Kimi command = %#v", hook["command"])
|
||||
}
|
||||
if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") {
|
||||
t.Fatalf("Windows Kimi command is not encoded PowerShell: %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) {
|
||||
func requireKimiManagedTriple(t *testing.T, hooks []map[string]any) {
|
||||
t.Helper()
|
||||
for _, event := range []string{"PreToolUse", "PostToolUse", "PostToolUseFailure"} {
|
||||
requireStrictKimiHook(t, managedKimiTestHook(t, hooks, event), event)
|
||||
}
|
||||
if managedKimiTestHook(t, hooks, "PostToolUse")["command"] !=
|
||||
managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"] {
|
||||
t.Fatal("Kimi success and failure audit commands differ")
|
||||
}
|
||||
}
|
||||
|
||||
func kimiOfficialPayload(event string) map[string]any {
|
||||
payload := map[string]any{
|
||||
"hook_event_name": event,
|
||||
"session_id": "session-1",
|
||||
"cwd": "C:/project",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": map[string]any{"command": "echo test"},
|
||||
"tool_call_id": "call-1",
|
||||
}
|
||||
switch event {
|
||||
case "PostToolUse":
|
||||
payload["tool_output"] = map[string]any{"output": "test", "success": true}
|
||||
case "PostToolUseFailure":
|
||||
payload["error"] = map[string]any{
|
||||
"name": "ToolError", "message": "command failed", "code": "tool.failed",
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func runKimiCommand(
|
||||
t *testing.T,
|
||||
command string,
|
||||
fixture *kimiFixture,
|
||||
payload map[string]any,
|
||||
) (int, string) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Kimi payload: %v", err)
|
||||
}
|
||||
return runKimiRawCommand(t, command, fixture, encoded)
|
||||
}
|
||||
|
||||
func runKimiRawCommand(
|
||||
t *testing.T,
|
||||
command string,
|
||||
fixture *kimiFixture,
|
||||
encoded []byte,
|
||||
) (int, string) {
|
||||
t.Helper()
|
||||
var process *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd")
|
||||
@@ -195,11 +232,17 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an
|
||||
} else {
|
||||
process = exec.Command("sh", "-c", command)
|
||||
}
|
||||
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
||||
process.Dir = fixture.projectDir
|
||||
process.Env = append(
|
||||
os.Environ(),
|
||||
"HOME="+fixture.homeDir,
|
||||
"USERPROFILE="+fixture.homeDir,
|
||||
"ELYDORA_HOOK_PATH=injected-command-fragment",
|
||||
)
|
||||
process.Stdin = bytes.NewReader(encoded)
|
||||
var stderr bytes.Buffer
|
||||
process.Stderr = &stderr
|
||||
err = process.Run()
|
||||
err := process.Run()
|
||||
if err == nil {
|
||||
return 0, stderr.String()
|
||||
}
|
||||
@@ -210,3 +253,53 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an
|
||||
t.Fatalf("run Kimi hook command: %v", err)
|
||||
return -1, stderr.String()
|
||||
}
|
||||
|
||||
func legacyKimiCommand(t *testing.T, scriptPath string) string {
|
||||
t.Helper()
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return quoteWindowsArgument(nodePath) + " " + quoteWindowsArgument(scriptPath)
|
||||
}
|
||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||
}
|
||||
|
||||
func assertNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) {
|
||||
t.Helper()
|
||||
for _, path := range []string{
|
||||
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||
} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoKimiTransactionArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") ||
|
||||
strings.HasSuffix(entry.Name(), ".rollback") {
|
||||
t.Errorf("transaction artifact remains at %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk Kimi fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func kimiSymlinkOrSkip(t *testing.T, target, link string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symbolic links unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type kimiTomlLayout struct {
|
||||
|
||||
type kimiDocument struct {
|
||||
contract kimiContract
|
||||
exists bool
|
||||
raw []byte
|
||||
hooks []kimiHook
|
||||
layout kimiTomlLayout
|
||||
@@ -31,7 +32,9 @@ type kimiDocument struct {
|
||||
|
||||
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
|
||||
if !exists {
|
||||
return kimiDocument{contract: contract, raw: []byte{}, hooks: []kimiHook{}}, nil
|
||||
return kimiDocument{
|
||||
contract: contract, exists: false, raw: []byte{}, hooks: []kimiHook{},
|
||||
}, nil
|
||||
}
|
||||
root := map[string]any{}
|
||||
if err := toml.Unmarshal(raw, &root); err != nil {
|
||||
@@ -76,6 +79,7 @@ func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocu
|
||||
}
|
||||
return kimiDocument{
|
||||
contract: contract,
|
||||
exists: true,
|
||||
raw: append([]byte(nil), raw...),
|
||||
hooks: hooks,
|
||||
layout: layout,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func snapshotKimiFiles(t *testing.T, paths ...string) map[string][]byte {
|
||||
t.Helper()
|
||||
result := make(map[string][]byte, len(paths))
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read snapshot %s: %v", path, err)
|
||||
}
|
||||
result[path] = raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func requireKimiSnapshot(t *testing.T, snapshot map[string][]byte) {
|
||||
t.Helper()
|
||||
for path, want := range snapshot {
|
||||
actual, err := os.ReadFile(path)
|
||||
if err != nil || string(actual) != string(want) {
|
||||
t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) {
|
||||
stable := []byte("# stable owner config\ndefault_model = \"kimi-code/k3\"\n")
|
||||
legacy := []byte("# legacy owner config\ntelemetry = false\n")
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
||||
})
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameKimiPath(destination, fixture.legacyPath) {
|
||||
return errors.New("injected legacy config failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected legacy config failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for path, want := range map[string][]byte{
|
||||
fixture.modernPath: stable, fixture.legacyPath: legacy,
|
||||
} {
|
||||
actual, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(actual) != string(want) {
|
||||
t.Fatalf("config changed at %s: %q, %v", path, actual, readErr)
|
||||
}
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestKimiInstallPreservesConcurrentConfigMutation(t *testing.T) {
|
||||
original := "# original owner config\ndefault_model = \"kimi-code/k3\"\n"
|
||||
concurrent := []byte("# concurrent owner change\ntelemetry = false\n")
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(original), withoutLegacyEvidence: true,
|
||||
})
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||
mutated = true
|
||||
if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent install error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||
if readErr != nil || string(actual) != string(concurrent) {
|
||||
t.Fatalf("concurrent config changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestKimiInstallDetectsConcurrentConfigIdentityReplacement(t *testing.T) {
|
||||
original := []byte("# original owner config\n")
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||
modernConfig: kimiString(string(original)), withoutLegacyEvidence: true,
|
||||
})
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||
mutated = true
|
||||
external := fixture.modernPath + ".external"
|
||||
if err := os.Rename(fixture.modernPath, external); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fixture.modernPath, original, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent identity error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||
if readErr != nil || string(actual) != string(original) {
|
||||
t.Fatalf("replacement config changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestKimiUninstallRollsBackBothConfigRemovals(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Kimi hooks: %v", err)
|
||||
}
|
||||
paths := []string{
|
||||
fixture.guardPath, fixture.runtimeConfig, fixture.privateKey, fixture.hookPath,
|
||||
fixture.modernPath, fixture.legacyPath,
|
||||
}
|
||||
before := snapshotKimiFiles(t, paths...)
|
||||
removals := 0
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if strings.HasSuffix(destination, ".rollback") {
|
||||
removals++
|
||||
if removals == 2 {
|
||||
return errors.New("injected uninstall failure")
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err := fixture.plugin.Uninstall(kimiTestAgentID)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected uninstall failure") {
|
||||
t.Fatalf("uninstall error = %v", err)
|
||||
}
|
||||
requireKimiSnapshot(t, before)
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||
documents, err := readAllKimiConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("read Kimi configs: %v", err)
|
||||
}
|
||||
paths, nodePath, err := preflightKimiInstallation(fixture.config, documents)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Kimi installation: %v", err)
|
||||
}
|
||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build guard command: %v", err)
|
||||
}
|
||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build audit command: %v", err)
|
||||
}
|
||||
additions := make([]kimiHook, 0, 3)
|
||||
for _, item := range []struct{ event, command string }{
|
||||
{"PreToolUse", guardCommand},
|
||||
{"PostToolUse", auditCommand},
|
||||
{"PostToolUseFailure", auditCommand},
|
||||
} {
|
||||
hook, hookErr := buildKimiHook(item.event, item.command)
|
||||
if hookErr != nil {
|
||||
t.Fatalf("build Kimi hook: %v", hookErr)
|
||||
}
|
||||
additions = append(additions, hook)
|
||||
}
|
||||
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
keep, keepErr := keptKimiHookIndices(document.hooks, "")
|
||||
if keepErr != nil {
|
||||
t.Fatalf("select Kimi hooks: %v", keepErr)
|
||||
}
|
||||
change, renderErr := renderKimiChange(document, keep, additions)
|
||||
if renderErr != nil {
|
||||
t.Fatalf("render Kimi hooks: %v", renderErr)
|
||||
}
|
||||
rendered = append(rendered, change)
|
||||
}
|
||||
changes, err := prepareKimiInstallationChanges(fixture.config, paths, rendered)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare Kimi installation: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil {
|
||||
t.Fatalf("create Kimi home: %v", err)
|
||||
}
|
||||
concurrent := []byte("# changed after preparation\n")
|
||||
if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil {
|
||||
t.Fatalf("write concurrent source: %v", err)
|
||||
}
|
||||
|
||||
err = writeKimiChanges(
|
||||
changes,
|
||||
"Install Kimi hooks",
|
||||
nil,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||
if readErr != nil || string(actual) != string(concurrent) {
|
||||
t.Fatalf("prepared source changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoKimiRuntimeWrites(t, fixture)
|
||||
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func validateManagedPrivateKey(value string) error {
|
||||
seed, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value {
|
||||
return fmt.Errorf("private key must be a canonical 32-byte base64url value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManagedBaseURL(value string) error {
|
||||
if strings.ContainsRune(value, '\\') {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 || unicode.IsSpace(character) {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
validScheme := strings.EqualFold(parsed.Scheme, "http") ||
|
||||
strings.EqualFold(parsed.Scheme, "https")
|
||||
if !validScheme || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
|
||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return fmt.Errorf(
|
||||
"base URL must exclude credentials, query parameters, and fragments",
|
||||
)
|
||||
}
|
||||
port := parsed.Port()
|
||||
if strings.HasSuffix(parsed.Host, ":") {
|
||||
return fmt.Errorf("base URL must contain a valid port")
|
||||
}
|
||||
if port != "" {
|
||||
value, convertErr := strconv.Atoi(port)
|
||||
if convertErr != nil || value < 1 || value > 65535 {
|
||||
return fmt.Errorf("base URL must contain a valid port")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user