fix(go): align Kimi hooks with official contracts
This commit is contained in:
1 parent
fd62dca155
commit
3ddce86c37
17 files changed
+1971
-616
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.
|
- Use each provider's documented blocking mechanism and failure mode.
|
||||||
- Report installation as healthy only when a complete contract references all required runtime files.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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`.
|
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.
|
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 (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -29,8 +30,7 @@ func encodeCodexPowerShell(source string) string {
|
|||||||
runes := utf16.Encode([]rune(source))
|
runes := utf16.Encode([]rune(source))
|
||||||
raw := make([]byte, len(runes)*2)
|
raw := make([]byte, len(runes)*2)
|
||||||
for index, value := range runes {
|
for index, value := range runes {
|
||||||
raw[index*2] = byte(value)
|
binary.LittleEndian.PutUint16(raw[index*2:], value)
|
||||||
raw[index*2+1] = byte(value >> 8)
|
|
||||||
}
|
}
|
||||||
return base64.StdEncoding.EncodeToString(raw)
|
return base64.StdEncoding.EncodeToString(raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type codexRuntimePaths struct {
|
type codexRuntimePaths struct {
|
||||||
@@ -21,51 +16,6 @@ type codexRuntimePaths struct {
|
|||||||
auditPath string
|
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 {
|
func validateCodexInstallConfig(config InstallConfig) error {
|
||||||
for _, field := range []struct{ name, value string }{
|
for _, field := range []struct{ name, value string }{
|
||||||
{"agent name", config.AgentName},
|
{"agent name", config.AgentName},
|
||||||
@@ -83,10 +33,10 @@ func validateCodexInstallConfig(config InstallConfig) error {
|
|||||||
if config.AgentName != codexAgentKey {
|
if config.AgentName != codexAgentKey {
|
||||||
return fmt.Errorf("codex installation requires agent name %s", 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 err
|
||||||
}
|
}
|
||||||
return validateCodexBaseURL(config.BaseURL)
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
||||||
|
|||||||
@@ -2,74 +2,87 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type kimiMutation struct {
|
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
||||||
document kimiDocument
|
type KimiPlugin struct {
|
||||||
raw []byte
|
rename renameFunc
|
||||||
remove bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
// ManagesGuardRuntime reports that Kimi commits its provider guard with the
|
||||||
type KimiPlugin struct{}
|
// audit runtime and every detected user hook document.
|
||||||
|
func (p *KimiPlugin) ManagesGuardRuntime() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Install(config InstallConfig) error {
|
// PreflightInstall validates every detected hook source and runtime identity.
|
||||||
if config.AgentID == "" {
|
func (p *KimiPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
return fmt.Errorf("agent ID is required")
|
|
||||||
}
|
|
||||||
documents, err := readAllKimiConfigs()
|
documents, err := readAllKimiConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.GuardScriptPath == "" {
|
_, _, err = preflightKimiInstallation(config, documents)
|
||||||
return fmt.Errorf("guard script path is required")
|
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !guardExists {
|
|
||||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
|
||||||
}
|
|
||||||
hookPath, err := hookScriptPath(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if config.HookScript != "" {
|
|
||||||
hookPath = config.HookScript
|
|
||||||
}
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
additions := []kimiHook{
|
|
||||||
buildKimiHook("PreToolUse", buildKimiCommand(nodePath, config.GuardScriptPath)),
|
|
||||||
buildKimiHook("PostToolUse", buildKimiCommand(nodePath, hookPath)),
|
|
||||||
}
|
|
||||||
mutations := make([]kimiMutation, 0, len(documents))
|
|
||||||
for _, document := range documents {
|
|
||||||
raw, err := renderKimiHooks(document, keptKimiHookIndices(document.hooks, ""), additions)
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeConfig := config
|
func (p *KimiPlugin) Install(config InstallConfig) error {
|
||||||
runtimeConfig.AgentName = kimiAgentKey
|
documents, err := readAllKimiConfigs()
|
||||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
|
||||||
}
|
|
||||||
for _, mutation := range mutations {
|
|
||||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
paths, nodePath, err := preflightKimiInstallation(config, documents)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: global PreToolUse and PostToolUse hooks installed.\n", kimiRuntimeNames(documents))
|
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
additions = append(additions, hook)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,63 +91,51 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mutations := make([]kimiMutation, 0, len(documents))
|
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
keep := keptKimiHookIndices(document.hooks, agentID)
|
keep, err := keptKimiHookIndices(document.hooks, agentID)
|
||||||
if len(keep) == len(document.hooks) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := renderKimiHooks(document, keep, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := parseKimiDocument(document.contract, raw, true); err != nil {
|
change, err := renderKimiChange(document, keep, nil)
|
||||||
return fmt.Errorf("validate rendered %s: %w", document.contract.label, err)
|
if err != nil {
|
||||||
}
|
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
continue
|
rendered = append(rendered, change)
|
||||||
}
|
}
|
||||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
changes, err := prepareKimiUninstallChanges(rendered)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
return writeKimiChanges(
|
||||||
return nil
|
changes,
|
||||||
|
"Uninstall Kimi hooks",
|
||||||
|
p.rename,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
||||||
documents, err := readAllKimiConfigs()
|
documents, err := readAllKimiConfigs()
|
||||||
|
entry := SupportedAgents[kimiAgentKey]
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: kimiAgentKey,
|
AgentName: kimiAgentKey, DisplayName: entry.Name,
|
||||||
DisplayName: "Kimi Code",
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = documents[0].contract.configPath
|
status.ConfigPath = documents[0].contract.configPath
|
||||||
contracts := make([]kimiRuntimeContract, 0, len(documents))
|
contracts, err := kimiRuntimeContracts(documents)
|
||||||
for _, document := range documents {
|
if err != nil {
|
||||||
contract := kimiRuntimeForDocument(document)
|
return status, err
|
||||||
if contract == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contracts = append(contracts, *contract)
|
|
||||||
status.ConfigPath = contract.configPath
|
|
||||||
}
|
}
|
||||||
if len(contracts) == 0 {
|
if len(contracts) == 0 {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.ConfigPath = contracts[len(contracts)-1].configPath
|
||||||
status.HookConfigured = true
|
status.HookConfigured = true
|
||||||
status.HookScriptExists, err = kimiRuntimeScriptsExist(contracts)
|
status.HookScriptExists, err = kimiRuntimeFilesExist(contracts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -38,8 +37,10 @@ var kimiModernEvents = mergeStringSets(kimiSharedEvents, stringSet(
|
|||||||
))
|
))
|
||||||
|
|
||||||
type kimiContract struct {
|
type kimiContract struct {
|
||||||
|
generation string
|
||||||
runtimeName string
|
runtimeName string
|
||||||
label string
|
label string
|
||||||
|
directoryLabel string
|
||||||
configPath string
|
configPath string
|
||||||
events map[string]struct{}
|
events map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -49,11 +50,13 @@ type kimiHook struct {
|
|||||||
matcher *string
|
matcher *string
|
||||||
command string
|
command string
|
||||||
timeout *int64
|
timeout *int64
|
||||||
|
fieldCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type kimiRuntimeContract struct {
|
type kimiRuntimeContract struct {
|
||||||
guard string
|
agentID string
|
||||||
audit string
|
guardPath string
|
||||||
|
auditPath string
|
||||||
configPath 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)
|
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 {
|
if value, exists := object["matcher"]; exists {
|
||||||
matcher, ok := value.(string)
|
matcher, ok := value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -154,59 +157,124 @@ func kimiHooks(root map[string]any, contract kimiContract) ([]kimiHook, error) {
|
|||||||
return hooks, nil
|
return hooks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeKimiPath(value string) string {
|
func buildKimiHook(event, command string) (kimiHook, error) {
|
||||||
normalized := filepath.ToSlash(value)
|
switch event {
|
||||||
if runtime.GOOS == "windows" {
|
case "PreToolUse", "PostToolUse", "PostToolUseFailure":
|
||||||
return strings.ToLower(normalized)
|
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 {
|
func managedKimiReference(
|
||||||
normalized := strings.TrimSpace(normalizeKimiPath(command))
|
hook kimiHook,
|
||||||
expected := normalizeKimiPath(filePath)
|
event string,
|
||||||
return strings.HasSuffix(normalized, expected) ||
|
scriptName string,
|
||||||
strings.HasSuffix(normalized, expected+`'`) ||
|
) (*kimiRuntimeReference, error) {
|
||||||
strings.HasSuffix(normalized, expected+`"`)
|
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 {
|
func managedKimiEvent(event string) (string, string, bool) {
|
||||||
if hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
switch event {
|
||||||
return false
|
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))
|
indices := make([]int, 0, len(hooks))
|
||||||
for index, hook := range hooks {
|
for index, hook := range hooks {
|
||||||
managed := isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, agentID) ||
|
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
||||||
isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, agentID)
|
var reference *kimiRuntimeReference
|
||||||
if !managed {
|
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)
|
indices = append(indices, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return indices
|
return indices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiRuntimeForDocument(document kimiDocument) *kimiRuntimeContract {
|
func kimiReferenceKey(agentID string) string {
|
||||||
var guard, audit string
|
if runtime.GOOS == "windows" {
|
||||||
for _, hook := range document.hooks {
|
return strings.ToLower(agentID)
|
||||||
if isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, "") {
|
|
||||||
guard = hook.command
|
|
||||||
}
|
}
|
||||||
if isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, "") {
|
return agentID
|
||||||
audit = hook.command
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if guard == "" || audit == "" {
|
key := kimiReferenceKey(reference.agentID)
|
||||||
return nil
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -23,9 +21,8 @@ func isMissingKimiPath(err error) bool {
|
|||||||
return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR)
|
return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiPathExists(path, label string) (bool, error) {
|
func kimiEntryExists(path, label string) (bool, error) {
|
||||||
// #nosec G703 -- KIMI_CODE_HOME is an explicit local-user configuration path.
|
_, err := os.Lstat(path)
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, 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)
|
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyKimiCLIOnPath() (bool, error) {
|
func stableKimiContract(configPath string) kimiContract {
|
||||||
_, err := exec.LookPath("kimi-cli")
|
return kimiContract{
|
||||||
if err == nil {
|
generation: "stable", runtimeName: "Kimi Code",
|
||||||
return true, nil
|
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) {
|
func resolveKimiContracts() ([]kimiContract, error) {
|
||||||
@@ -51,58 +54,59 @@ func resolveKimiContracts() ([]kimiContract, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
explicitHome := os.Getenv("KIMI_CODE_HOME")
|
configuredHome := os.Getenv("KIMI_CODE_HOME")
|
||||||
kimiHome := explicitHome
|
stableHome := filepath.Join(home, ".kimi-code")
|
||||||
if kimiHome == "" {
|
explicitHome := configuredHome != ""
|
||||||
kimiHome = filepath.Join(home, ".kimi-code")
|
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,
|
|
||||||
}
|
}
|
||||||
legacy := kimiContract{
|
legacyHome := filepath.Join(home, ".kimi")
|
||||||
runtimeName: "kimi-cli",
|
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
||||||
label: "kimi-cli legacy hooks config",
|
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
||||||
configPath: filepath.Join(home, ".kimi", "config.toml"),
|
if sameKimiPath(stable.configPath, legacy.configPath) {
|
||||||
events: kimiSharedEvents,
|
return []kimiContract{stable}, nil
|
||||||
}
|
}
|
||||||
modernDetected := explicitHome != ""
|
|
||||||
if !modernDetected {
|
stableDetected := explicitHome
|
||||||
modernDetected, err = kimiPathExists(kimiHome, "Kimi Code home")
|
if !stableDetected {
|
||||||
|
stableDetected, err = kimiEntryExists(stableHome, "Kimi Code home")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
legacyDetected, err := kimiPathExists(legacy.configPath, legacy.label)
|
legacyDetected, err := kimiEntryExists(legacyHome, "kimi-cli legacy home")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !legacyDetected {
|
if legacyDetected && !stableDetected {
|
||||||
legacyDetected, err = legacyKimiCLIOnPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if legacyDetected && !modernDetected {
|
|
||||||
return []kimiContract{legacy}, nil
|
return []kimiContract{legacy}, nil
|
||||||
}
|
}
|
||||||
if legacyDetected {
|
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) {
|
func readKimiConfig(contract kimiContract) (kimiDocument, error) {
|
||||||
raw, err := os.ReadFile(contract.configPath)
|
directory := filepath.Dir(contract.configPath)
|
||||||
|
if _, err := managedPhysicalDirectoryExists(directory, contract.directoryLabel); err != nil {
|
||||||
|
return kimiDocument{}, err
|
||||||
|
}
|
||||||
|
snapshot, err := readManagedFile(
|
||||||
|
contract.configPath,
|
||||||
|
contract.label,
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return kimiDocument{}, err
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
return parseKimiDocument(contract, nil, false)
|
return parseKimiDocument(contract, nil, false)
|
||||||
}
|
}
|
||||||
return kimiDocument{}, fmt.Errorf("read %s at %s: %w", contract.label, contract.configPath, err)
|
return parseKimiDocument(contract, snapshot.contents, true)
|
||||||
}
|
|
||||||
return parseKimiDocument(contract, raw, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAllKimiConfigs() ([]kimiDocument, error) {
|
func readAllKimiConfigs() ([]kimiDocument, error) {
|
||||||
@@ -121,32 +125,6 @@ func readAllKimiConfigs() ([]kimiDocument, error) {
|
|||||||
return documents, nil
|
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 {
|
func kimiRuntimeNames(documents []kimiDocument) string {
|
||||||
names := make([]string, 0, len(documents))
|
names := make([]string, 0, len(documents))
|
||||||
for _, document := range 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func kimiRuntimeScriptsExist(contracts []kimiRuntimeContract) (bool, error) {
|
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||||
home, err := kimiHomeDirectory()
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
root := filepath.Join(home, ".elydora")
|
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
|
||||||
entries, err := os.ReadDir(root)
|
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 {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return err
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||||
|
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
return nil
|
||||||
if !entry.IsDir() {
|
}
|
||||||
continue
|
|
||||||
|
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
|
runtimeRoot := filepath.Dir(agentDirectory)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
agentDir := filepath.Join(root, entry.Name())
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
agentDirectory, "Elydora agent runtime directory",
|
||||||
auditPath := filepath.Join(agentDir, kimiAuditScript)
|
)
|
||||||
if !kimiContractsReferenceRuntime(contracts, guardPath, auditPath) {
|
if err != nil || !directoryExists {
|
||||||
continue
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDir, "config.json")
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
config, configExists, err := readKimiRuntimeConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
if !exists {
|
artifactExists := false
|
||||||
continue
|
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
|
||||||
}
|
}
|
||||||
agentName, ok := config["agent_name"].(string)
|
artifactExists = artifactExists || exists
|
||||||
if !ok {
|
}
|
||||||
return false, fmt.Errorf(
|
if !configExists {
|
||||||
`Elydora runtime config at %s field "agent_name" must be a string`,
|
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,
|
configPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if agentName != kimiAgentKey {
|
return nil
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
auditExists, err := regularFileExists(auditPath, "Elydora audit runtime")
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
||||||
|
!sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return guardExists && auditExists, nil
|
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 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, 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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKimiRegistryUsesModernGlobalContract(t *testing.T) {
|
func TestKimiRegistryUsesStableGlobalContract(t *testing.T) {
|
||||||
entry := SupportedAgents[kimiAgentKey]
|
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)
|
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))
|
t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) {
|
func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) {
|
||||||
modern := "# modern user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
modern := "# stable user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
||||||
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-modern\"\n" +
|
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-stable\"\n" +
|
||||||
"timeout = 30 # keep modern hook\n"
|
"timeout = 30 # keep stable hook\n"
|
||||||
legacy := "# legacy user config\ntelemetry = false\n\n" +
|
legacy := "# legacy user config\ntelemetry = false\n\n" +
|
||||||
"[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n"
|
"[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(modern),
|
modernConfig: kimiString(modern), legacyConfig: kimiString(legacy),
|
||||||
legacyConfig: kimiString(legacy),
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
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 {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("repeat Kimi install: %v", err)
|
t.Fatalf("repeat Kimi install: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, contract := range []struct{ path, comment, command string }{
|
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"},
|
{fixture.legacyPath, "# legacy user config", "existing-legacy"},
|
||||||
} {
|
} {
|
||||||
raw, err := os.ReadFile(contract.path)
|
raw, err := os.ReadFile(contract.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read Kimi config: %v", err)
|
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)
|
t.Fatalf("user config changed: %s", raw)
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, contract.path)
|
hooks := readKimiTestHooks(t, contract.path)
|
||||||
if len(hooks) != 3 {
|
if len(hooks) != 4 {
|
||||||
t.Fatalf("hooks = %#v, want three entries", hooks)
|
t.Fatalf("hooks = %#v, want four entries", hooks)
|
||||||
}
|
}
|
||||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript))
|
requireKimiManagedTriple(t, hooks)
|
||||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript))
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
||||||
modern := "# inline user hook\nhooks = [\n # keep leading\n { event = \"SessionStart\", " +
|
modern := "# inline user hook\nhooks = [\n # keep leading\n" +
|
||||||
"command = \"existing-inline\" }, # keep gap\n] # keep array\n"
|
" { event = \"SessionStart\", command = \"existing-inline\" }, # keep gap\n" +
|
||||||
|
"] # keep array\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(modern),
|
modernConfig: kimiString(modern), withoutLegacyEvidence: true,
|
||||||
withoutLegacyCLI: true,
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install inline Kimi hooks: %v", err)
|
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)
|
t.Fatalf("read inline Kimi config: %v", err)
|
||||||
}
|
}
|
||||||
for _, marker := range []string{
|
for _, marker := range []string{
|
||||||
"# inline user hook",
|
"# inline user hook", "# keep leading", "# keep gap", "# keep array", "hooks = [",
|
||||||
"# keep leading",
|
|
||||||
"# keep gap",
|
|
||||||
"# keep array",
|
|
||||||
"hooks = [",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(raw), marker) {
|
if !strings.Contains(string(raw), marker) {
|
||||||
t.Fatalf("inline marker %q missing from %s", marker, raw)
|
t.Fatalf("inline marker %q missing from %s", marker, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 3 {
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
t.Fatalf("inline Kimi hooks are not idempotent: %s", raw)
|
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{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
useDefaultHome: true,
|
modernConfig: kimiString(source), withoutLegacyEvidence: true,
|
||||||
withoutLegacyCLI: true,
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install modern Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 2 {
|
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
t.Fatalf("modern Kimi config has unexpected hooks")
|
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) {
|
raw, err := os.ReadFile(fixture.modernPath)
|
||||||
t.Fatalf("false legacy migration target exists: %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("read empty inline config: %v", err)
|
||||||
}
|
}
|
||||||
status, err := fixture.plugin.Status()
|
if !strings.Contains(string(raw), "# user container") ||
|
||||||
if err != nil || !status.Installed {
|
!strings.Contains(string(raw), "# keep empty array") ||
|
||||||
t.Fatalf("empty KIMI_CODE_HOME status = %#v, %v", status, err)
|
len(readKimiTestHooks(t, fixture.modernPath)) != 0 {
|
||||||
|
t.Fatalf("empty inline array changed: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiLegacyInstallAvoidsPrematureModernTarget(t *testing.T) {
|
func TestKimiUsesDefaultStableAndLegacyEvidence(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{useDefaultHome: true})
|
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 {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install legacy Kimi hooks: %v", err)
|
t.Fatalf("install legacy Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.legacyPath)) != 2 {
|
requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.legacyPath))
|
||||||
t.Fatalf("legacy Kimi config has unexpected hooks")
|
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
}
|
t.Fatalf("stable config exists: %v", err)
|
||||||
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()
|
status, err := fixture.plugin.Status()
|
||||||
if err != nil || !status.Installed {
|
if err != nil || !status.Installed {
|
||||||
t.Fatalf("legacy status after PATH change = %#v, %v", status, err)
|
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 TestKimiCommandsBlockAndForwardOfficialPayload(t *testing.T) {
|
func TestKimiMigratesExactLegacyCommandsAndPreservesLookalikes(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
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 {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("migrate legacy Kimi commands: %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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
payload := map[string]any{
|
if len(hooks) != 4 {
|
||||||
"hook_event_name": "PreToolUse",
|
t.Fatalf("migrated hooks = %#v", hooks)
|
||||||
"session_id": "session-1",
|
|
||||||
"cwd": fixture.homeDir,
|
|
||||||
"tool_name": "Bash",
|
|
||||||
"tool_input": map[string]any{"command": "echo test"},
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
}
|
}
|
||||||
guard := findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript)
|
requireKimiManagedTriple(t, hooks)
|
||||||
exitCode, stderr := runKimiCommand(t, guard["command"].(string), fixture.homeDir, payload)
|
if !containsKimiCommand(hooks, lookalike) {
|
||||||
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
t.Fatalf("lookalike command removed: %#v", hooks)
|
||||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
|
||||||
}
|
}
|
||||||
payload["hook_event_name"] = "PostToolUse"
|
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
payload["tool_output"] = map[string]any{"output": "test"}
|
t.Fatalf("uninstall migrated Kimi hooks: %v", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
capturedRaw, err := os.ReadFile(capturePath)
|
hooks = readKimiTestHooks(t, fixture.modernPath)
|
||||||
if err != nil {
|
if len(hooks) != 1 || hooks[0]["command"] != lookalike {
|
||||||
t.Fatalf("read captured event: %v", err)
|
t.Fatalf("remaining hooks = %#v", hooks)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiStatusAcceptsEitherContractAndRequiresBothScripts(t *testing.T) {
|
func containsKimiCommand(hooks []map[string]any, command string) bool {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
for _, hook := range hooks {
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if hook["command"] == command {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
return true
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireKimiInstalledStatus(t *testing.T, plugin *KimiPlugin, configPath string) {
|
func TestKimiUninstallPreservesUserConfigsAndRemovesManagedConfigs(t *testing.T) {
|
||||||
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) {
|
|
||||||
userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" +
|
userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" +
|
||||||
"command = \"existing-command\"\ntimeout = 30 # keep timeout\n"
|
"command = \"existing-command\"\ntimeout = 30 # keep timeout\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(userHook),
|
modernConfig: kimiString(userHook), legacyConfig: kimiString(userHook),
|
||||||
legacyConfig: kimiString(userHook),
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
@@ -232,159 +282,129 @@ func TestKimiUninstallPreservesUserHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil || !strings.Contains(string(raw), "# keep timeout") {
|
||||||
t.Fatalf("read user config after uninstall: %v", err)
|
t.Fatalf("user config changed: %s, %v", raw, err)
|
||||||
}
|
|
||||||
if !strings.Contains(string(raw), "# user hook") || !strings.Contains(string(raw), "# keep timeout") {
|
|
||||||
t.Fatalf("user comments changed: %s", raw)
|
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, path)
|
hooks := readKimiTestHooks(t, path)
|
||||||
if len(hooks) != 1 || hooks[0]["command"] != "existing-command" {
|
if len(hooks) != 1 || hooks[0]["command"] != "existing-command" {
|
||||||
t.Fatalf("user hook changed: %#v", hooks)
|
t.Fatalf("user hook changed: %#v", hooks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
managed := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||||
|
if err := managed.plugin.Install(managed.config); err != nil {
|
||||||
|
t.Fatalf("install managed Kimi configs: %v", err)
|
||||||
|
}
|
||||||
|
if err := managed.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
|
t.Fatalf("remove managed Kimi configs: %v", 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 TestKimiOwnershipRequiresExactAgentAndScript(t *testing.T) {
|
func TestKimiStatusRequiresCompleteTripleAndEveryRuntimeFile(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(fixture.modernPath, []byte(userHooks), 0600); err != nil {
|
|
||||||
t.Fatalf("write ownership fixture: %v", err)
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
status, err := fixture.plugin.Status()
|
||||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
if err != nil || !status.Installed || !status.HookConfigured {
|
||||||
|
t.Fatalf("Kimi status = %#v, %v", status, 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) {
|
raw, err := os.ReadFile(fixture.modernPath)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requireNoKimiRuntimeWrites(t, fixture)
|
|
||||||
}
|
|
||||||
|
|
||||||
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`},
|
|
||||||
} {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
raw, readErr := os.ReadFile(fixture.legacyPath)
|
|
||||||
if readErr != nil || string(raw) != testCase.raw {
|
|
||||||
t.Fatalf("invalid config changed: %q, %v", raw, readErr)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatalf("read config directory: %v", err)
|
t.Fatalf("read Kimi config: %v", err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
document, err := parseKimiDocument(
|
||||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
stableKimiContract(fixture.modernPath), raw, true,
|
||||||
t.Fatalf("temporary Kimi config remains at %s", filepath.Join(directory, entry.Name()))
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(filepath.Base(path), func(t *testing.T) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime file: %v", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const kimiTestAgentID = "agent-1"
|
const (
|
||||||
|
kimiTestAgentID = "agent-1"
|
||||||
|
kimiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
|
||||||
|
)
|
||||||
|
|
||||||
type kimiFixtureOptions struct {
|
type kimiFixtureOptions struct {
|
||||||
modernConfig *string
|
modernConfig *string
|
||||||
legacyConfig *string
|
legacyConfig *string
|
||||||
useDefaultHome bool
|
useDefaultHome bool
|
||||||
withoutLegacyCLI bool
|
withoutModernEvidence bool
|
||||||
withoutGuard bool
|
withoutLegacyEvidence bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type kimiFixture struct {
|
type kimiFixture struct {
|
||||||
plugin *KimiPlugin
|
plugin *KimiPlugin
|
||||||
config InstallConfig
|
config InstallConfig
|
||||||
homeDir string
|
homeDir string
|
||||||
binDir string
|
projectDir string
|
||||||
kimiHome string
|
kimiHome string
|
||||||
modernPath string
|
modernPath string
|
||||||
|
legacyHome string
|
||||||
legacyPath string
|
legacyPath string
|
||||||
agentDir string
|
agentDir string
|
||||||
guardPath string
|
guardPath string
|
||||||
hookPath string
|
hookPath string
|
||||||
runtimeConfig string
|
runtimeConfig string
|
||||||
privateKey string
|
privateKey string
|
||||||
legacyCLIPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiString(value string) *string {
|
func kimiString(value string) *string {
|
||||||
@@ -47,45 +50,38 @@ func kimiString(value string) *string {
|
|||||||
|
|
||||||
func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
root := t.TempDir()
|
||||||
binDir := filepath.Join(homeDir, "bin")
|
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
|
||||||
kimiHome := filepath.Join(homeDir, "custom kimi code")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
|
kimiHome := filepath.Join(homeDir, "custom kimi-code")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
kimiHome = filepath.Join(homeDir, ".kimi-code")
|
kimiHome = filepath.Join(homeDir, ".kimi-code")
|
||||||
}
|
}
|
||||||
modernPath := filepath.Join(kimiHome, "config.toml")
|
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)
|
agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID)
|
||||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
||||||
hookPath := filepath.Join(agentDir, kimiAuditScript)
|
hookPath := filepath.Join(agentDir, kimiAuditScript)
|
||||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create agent directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
if options.useDefaultHome && !options.withoutModernEvidence {
|
||||||
t.Fatalf("create binary directory: %v", err)
|
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 !options.withoutLegacyEvidence {
|
||||||
if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil {
|
if err := os.MkdirAll(legacyHome, 0700); err != nil {
|
||||||
t.Fatalf("write guard runtime: %v", err)
|
t.Fatalf("create legacy Kimi home: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeOptionalKimiConfig(t, modernPath, options.modernConfig)
|
writeOptionalKimiConfig(t, modernPath, options.modernConfig)
|
||||||
writeOptionalKimiConfig(t, legacyPath, options.legacyConfig)
|
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("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment"))
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
t.Setenv("KIMI_CODE_HOME", "")
|
t.Setenv("KIMI_CODE_HOME", "")
|
||||||
} else {
|
} else {
|
||||||
@@ -93,27 +89,17 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &kimiFixture{
|
return &kimiFixture{
|
||||||
plugin: &KimiPlugin{},
|
plugin: &KimiPlugin{}, homeDir: homeDir, projectDir: projectDir,
|
||||||
homeDir: homeDir,
|
kimiHome: kimiHome, modernPath: modernPath,
|
||||||
binDir: binDir,
|
legacyHome: legacyHome, legacyPath: legacyPath,
|
||||||
kimiHome: kimiHome,
|
agentDir: agentDir, guardPath: guardPath, hookPath: hookPath,
|
||||||
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,
|
|
||||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
privateKey: filepath.Join(agentDir, "private.key"),
|
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 {
|
if raw == nil {
|
||||||
return
|
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)
|
t.Fatalf("create Kimi config directory: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(*raw), 0600); err != nil {
|
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
|
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()
|
t.Helper()
|
||||||
for _, hook := range hooks {
|
for index := len(hooks) - 1; index >= 0; index-- {
|
||||||
command, _ := hook["command"].(string)
|
hook := hooks[index]
|
||||||
if hook["event"] == event && strings.Contains(command, script) {
|
if hook["event"] == event && hook["timeout"] == int64(10) {
|
||||||
return hook
|
return hook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("%s hook for %s not found", event, script)
|
t.Fatalf("managed %s hook not found", event)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireStrictKimiHook(t *testing.T, hook map[string]any) {
|
func requireStrictKimiHook(t *testing.T, hook map[string]any, event string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
"event": hook["event"],
|
"event": event, "command": hook["command"], "timeout": int64(10),
|
||||||
"command": hook["command"],
|
|
||||||
"timeout": int64(10),
|
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(hook, want) {
|
if !reflect.DeepEqual(hook, want) {
|
||||||
t.Fatalf("hook = %#v, want strict contract %#v", 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()
|
t.Helper()
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal Kimi payload: %v", err)
|
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
|
var process *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd")
|
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 {
|
} else {
|
||||||
process = exec.Command("sh", "-c", command)
|
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)
|
process.Stdin = bytes.NewReader(encoded)
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
process.Stderr = &stderr
|
process.Stderr = &stderr
|
||||||
err = process.Run()
|
err := process.Run()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0, stderr.String()
|
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)
|
t.Fatalf("run Kimi hook command: %v", err)
|
||||||
return -1, stderr.String()
|
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 {
|
type kimiDocument struct {
|
||||||
contract kimiContract
|
contract kimiContract
|
||||||
|
exists bool
|
||||||
raw []byte
|
raw []byte
|
||||||
hooks []kimiHook
|
hooks []kimiHook
|
||||||
layout kimiTomlLayout
|
layout kimiTomlLayout
|
||||||
@@ -31,7 +32,9 @@ type kimiDocument struct {
|
|||||||
|
|
||||||
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
|
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
|
||||||
if !exists {
|
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{}
|
root := map[string]any{}
|
||||||
if err := toml.Unmarshal(raw, &root); err != nil {
|
if err := toml.Unmarshal(raw, &root); err != nil {
|
||||||
@@ -76,6 +79,7 @@ func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocu
|
|||||||
}
|
}
|
||||||
return kimiDocument{
|
return kimiDocument{
|
||||||
contract: contract,
|
contract: contract,
|
||||||
|
exists: true,
|
||||||
raw: append([]byte(nil), raw...),
|
raw: append([]byte(nil), raw...),
|
||||||
hooks: hooks,
|
hooks: hooks,
|
||||||
layout: layout,
|
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