chore(go): audit providers kimi grok qwen letta
Move Kimi, Grok, and Qwen onto managed_runtime.go and shell_command.go; share the encoded and shell command builders and the quoted Windows legacy parser with Droid; split managed_changes.go; dedupe Letta's hook entry filtering and stop swallowing settings read errors. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
1ede03d989
commit
34ff4c1ea8
34 files changed
+414
-1869
No files matched your search
@@ -172,53 +172,21 @@ for (const entry of entries) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDroidCommand(nodePath, scriptPath string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return powerShellSource(nodePath, scriptPath)
|
|
||||||
}
|
|
||||||
return posixSource(nodePath, scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
|
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"matcher": "*",
|
"matcher": "*",
|
||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
|
"type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
|
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
|
||||||
if runtime.GOOS != "windows" {
|
executable, script, ok := parseShellCommand(command)
|
||||||
return parsePOSIXCommand(command)
|
if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
|
||||||
}
|
|
||||||
executable, script, ok := parsePowerShellSource(command)
|
|
||||||
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
|
|
||||||
return executable, script, ok
|
return executable, script, ok
|
||||||
}
|
}
|
||||||
return parseDroidLegacyWindowsCommand(command)
|
return parseQuotedWindowsCommand(command)
|
||||||
}
|
|
||||||
|
|
||||||
func parseDroidLegacyWindowsCommand(command string) (string, string, bool) {
|
|
||||||
executable, next, ok := readDroidLegacyWindowsArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readDroidLegacyWindowsArgument(command, next+1)
|
|
||||||
return executable, script, ok && end == len(command) && executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '"' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
end := strings.IndexByte(command[start+1:], '"')
|
|
||||||
if end < 0 {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
end += start + 1
|
|
||||||
value := command[start+1 : end]
|
|
||||||
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedDroidAgentID(
|
func managedDroidAgentID(
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ func (p *GrokPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,21 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "path/filepath"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type grokRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokPath(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 sameGrokAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGrokCommand(runtimePath, scriptPath string) (string, error) {
|
|
||||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Grok hook commands require absolute runtime and script paths",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return encodedWindowsCommand(runtimePath, scriptPath), nil
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteGrokLegacyWindowsArgument(value string) string {
|
|
||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func readGrokLegacyWindowsArgument(
|
|
||||||
command string,
|
|
||||||
start int,
|
|
||||||
) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '"' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] == '"' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
if command[index] == '\r' || command[index] == '\n' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGrokLegacyWindowsCommand(command string) (string, string, bool) {
|
|
||||||
runtimePath, next, ok := readGrokLegacyWindowsArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
scriptPath, end, ok := readGrokLegacyWindowsArgument(command, next+1)
|
|
||||||
if !ok || end != len(command) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
expected := quoteGrokLegacyWindowsArgument(runtimePath) + " " +
|
|
||||||
quoteGrokLegacyWindowsArgument(scriptPath)
|
|
||||||
return runtimePath, scriptPath, command == expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGrokCommand(command string) (string, string, bool) {
|
func parseGrokCommand(command string) (string, string, bool) {
|
||||||
if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
|
if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
|
||||||
return runtimePath, scriptPath, true
|
return runtimePath, scriptPath, true
|
||||||
}
|
}
|
||||||
if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok {
|
return parseQuotedWindowsCommand(command)
|
||||||
return runtimePath, scriptPath, true
|
|
||||||
}
|
|
||||||
return parseGrokLegacyWindowsCommand(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGrokNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokRuntimeReferenceForCommand(
|
func grokRuntimeReferenceForCommand(
|
||||||
command string,
|
command string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*grokRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
runtimePath, scriptPath, ok := parseGrokCommand(command)
|
runtimePath, scriptPath, ok := parseGrokCommand(command)
|
||||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
|
||||||
!isGrokNodeExecutable(runtimePath) ||
|
|
||||||
!sameGrokFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &grokRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -48,12 +46,6 @@ type grokRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type grokRuntimeContract struct {
|
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneGrokObject(value map[string]any) map[string]any {
|
func cloneGrokObject(value map[string]any) map[string]any {
|
||||||
clone := make(map[string]any, len(value))
|
clone := make(map[string]any, len(value))
|
||||||
for key, item := range value {
|
for key, item := range value {
|
||||||
@@ -262,7 +254,7 @@ func exactManagedGrokGroup(group grokGroup) bool {
|
|||||||
func managedGrokReference(
|
func managedGrokReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*grokRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
if len(handler) != 3 || handler["type"] != "command" ||
|
if len(handler) != 3 || handler["type"] != "command" ||
|
||||||
handler["timeout"] != grokHookTimeout {
|
handler["timeout"] != grokHookTimeout {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -303,7 +295,7 @@ func removeManagedGrokGroups(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
remove := reference != nil &&
|
remove := reference != nil &&
|
||||||
(agentID == "" || sameGrokAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
@@ -407,18 +399,11 @@ func renderGrokDocument(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokReferencesForEvent(
|
func grokReferencesForEvent(
|
||||||
groups []grokGroup,
|
groups []grokGroup,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (map[string][]grokRuntimeReference, error) {
|
) (map[string][]managedScriptReference, error) {
|
||||||
result := map[string][]grokRuntimeReference{}
|
result := map[string][]managedScriptReference{}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if !exactManagedGrokGroup(group) {
|
if !exactManagedGrokGroup(group) {
|
||||||
continue
|
continue
|
||||||
@@ -431,14 +416,14 @@ func grokReferencesForEvent(
|
|||||||
if reference == nil {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := grokReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) {
|
func grokRuntimeContracts(hooks grokHooks) ([]managedRuntimeContract, error) {
|
||||||
guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript)
|
guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -459,16 +444,16 @@ func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) {
|
|||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
contracts := make([]grokRuntimeContract, 0, len(keys))
|
contracts := make([]managedRuntimeContract, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
guard := guards[key]
|
guard := guards[key]
|
||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameGrokPath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, grokRuntimeContract{
|
contracts = append(contracts, managedRuntimeContract{
|
||||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
auditPath: success[0].scriptPath,
|
auditPath: success[0].scriptPath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,102 +1,24 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type grokRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGrokInstallConfig(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 != grokAgentKey {
|
|
||||||
return fmt.Errorf("grok installation requires agent name %s", grokAgentKey)
|
|
||||||
}
|
|
||||||
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 grokAgentPaths(config InstallConfig) (*grokRuntimePaths, error) {
|
|
||||||
if err := validateGrokInstallConfig(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 := &grokRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, grokGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, grokAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameGrokPath(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) ||
|
|
||||||
!sameGrokPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightGrokInstallation(
|
func preflightGrokInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
document *grokDocument,
|
document *grokDocument,
|
||||||
) (*grokRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if document == nil {
|
if document == nil {
|
||||||
return nil, "", fmt.Errorf("grok installation requires a hook document")
|
return nil, "", fmt.Errorf("grok installation requires a hook document")
|
||||||
}
|
}
|
||||||
paths, err := grokAgentPaths(config)
|
if err := validateManagedInstallConfig(config, grokAgentKey, "grok"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, grokGuardScript, grokAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateGrokRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, grokAgentKey, "Grok",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
@@ -106,63 +28,23 @@ func preflightGrokInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func grokExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(grokAgentKey, agentID, "", false, "grok")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(grokAgentKey, agentID, "", false, true))
|
||||||
"base_url": config.BaseURL, "agent_name": grokAgentKey,
|
|
||||||
}
|
|
||||||
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 prepareGrokInstallationChanges(
|
func prepareGrokInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *grokRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *grokRenderedDocument,
|
rendered *grokRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildGrokRuntimeConfig(config)
|
guardScript, auditScript := grokExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, grokAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(grokAgentKey, config.AgentID, "", false, "grok")), 0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
grokAgentKey, config.AgentID, "", false, true,
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedGrokChange(rendered)
|
documentChange, err := prepareRenderedGrokChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -85,32 +85,17 @@ func writeGrokChanges(
|
|||||||
agentDirectory string,
|
agentDirectory string,
|
||||||
hooksDirectory string,
|
hooksDirectory string,
|
||||||
) error {
|
) error {
|
||||||
hasChanges := false
|
if !hasFileChanges(changes) {
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(
|
if err := ensureManagedDirectory(
|
||||||
filepath.Dir(hooksDirectory),
|
filepath.Dir(hooksDirectory),
|
||||||
"Grok home directory",
|
"Grok home directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil {
|
return writeManagedChanges(
|
||||||
return err
|
changes, label, rename, runtimeRoot, agentDirectory,
|
||||||
}
|
hooksDirectory, "Grok hooks directory",
|
||||||
return writeChanges(changes, label, rename)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,10 +164,10 @@ func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
|||||||
}, "query parameters"},
|
}, "query parameters"},
|
||||||
{"guard path", func(f *grokFixture) {
|
{"guard path", func(f *grokFixture) {
|
||||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
{"audit path", func(f *grokFixture) {
|
{"audit path", func(f *grokFixture) {
|
||||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func grokRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"fmt"
|
return managedRuntimeFilesExist(contracts, grokAgentKey, "Grok", nil)
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readGrokRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireGrokRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
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 validateGrokRuntimeConfig(
|
|
||||||
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 := requireGrokRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireGrokRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireGrokRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameGrokAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != grokAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity does not match Grok hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireGrokRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireGrokRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGrokRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readGrokRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, grokGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, grokAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != grokAgentKey ||
|
|
||||||
!sameGrokAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Grok agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokRuntimeContractExists(contract grokRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameGrokPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, grokAuditScript),
|
|
||||||
) {
|
|
||||||
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 := readGrokRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateGrokRuntimeConfig(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 grokRuntimeFilesExist(contracts []grokRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := grokRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -27,22 +27,22 @@ func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
}
|
}
|
||||||
command, err := buildGrokCommand(nodePath, fixture.guardPath)
|
command, err := buildEncodedCommand("Grok", nodePath, fixture.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build Grok command: %v", err)
|
t.Fatalf("build Grok command: %v", err)
|
||||||
}
|
}
|
||||||
executable, script, ok := parseGrokCommand(command)
|
executable, script, ok := parseGrokCommand(command)
|
||||||
if !ok || !sameGrokPath(executable, nodePath) ||
|
if !ok || !sameManagedPath(executable, nodePath) ||
|
||||||
!sameGrokPath(script, fixture.guardPath) {
|
!sameManagedPath(script, fixture.guardPath) {
|
||||||
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
|
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
|
||||||
}
|
}
|
||||||
if strings.Contains(command, "%ELYDORA_HOOK_PATH%") {
|
if strings.Contains(command, "%ELYDORA_HOOK_PATH%") {
|
||||||
t.Fatalf("Grok command exposes an expandable path: %q", command)
|
t.Fatalf("Grok command exposes an expandable path: %q", command)
|
||||||
}
|
}
|
||||||
if _, err := buildGrokCommand("node", fixture.guardPath); err == nil {
|
if _, err := buildEncodedCommand("Grok", "node", fixture.guardPath); err == nil {
|
||||||
t.Fatal("accepted a relative Grok runtime path")
|
t.Fatal("accepted a relative Grok runtime path")
|
||||||
}
|
}
|
||||||
if _, err := buildGrokCommand(nodePath, "guard.js"); err == nil {
|
if _, err := buildEncodedCommand("Grok", nodePath, "guard.js"); err == nil {
|
||||||
t.Fatal("accepted a relative Grok script path")
|
t.Fatal("accepted a relative Grok script path")
|
||||||
}
|
}
|
||||||
for _, invalid := range []string{
|
for _, invalid := range []string{
|
||||||
@@ -337,12 +337,12 @@ func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !sameGrokAgentID("AGENT-1", "agent-1") {
|
if !sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("Windows Grok agent IDs should compare case-insensitively")
|
t.Fatal("Windows Grok agent IDs should compare case-insensitively")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sameGrokAgentID("AGENT-1", "agent-1") {
|
if sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("POSIX Grok agent IDs should preserve case")
|
t.Fatal("POSIX Grok agent IDs should preserve case")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,8 +275,7 @@ func legacyGrokCommand(t *testing.T, scriptPath string) string {
|
|||||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return quoteGrokLegacyWindowsArgument(nodePath) + " " +
|
return `"` + nodePath + `" "` + scriptPath + `"`
|
||||||
quoteGrokLegacyWindowsArgument(scriptPath)
|
|
||||||
}
|
}
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(t *testing.T)
|
|||||||
}
|
}
|
||||||
before := snapshotGrokFiles(t, paths...)
|
before := snapshotGrokFiles(t, paths...)
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGrokPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Grok config failure")
|
return errors.New("injected Grok config failure")
|
||||||
}
|
}
|
||||||
@@ -85,11 +85,11 @@ func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("preflight Grok installation: %v", err)
|
t.Fatalf("preflight Grok installation: %v", err)
|
||||||
}
|
}
|
||||||
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build guard command: %v", err)
|
t.Fatalf("build guard command: %v", err)
|
||||||
}
|
}
|
||||||
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build audit command: %v", err)
|
t.Fatalf("build audit command: %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed Grok config: %v", err)
|
t.Fatalf("read installed Grok config: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGrokPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Grok uninstall failure")
|
return errors.New("injected Grok uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -70,12 +70,9 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeKimiChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes, "Install Kimi hooks", p.rename,
|
||||||
"Install Kimi hooks",
|
paths.runtimeRoot, paths.agentDirectory, "", "",
|
||||||
p.rename,
|
|
||||||
paths.runtimeRoot,
|
|
||||||
paths.agentDirectory,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -107,13 +104,7 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeKimiChanges(
|
return writeManagedChanges(changes, "Uninstall Kimi hooks", p.rename, "", "", "", "")
|
||||||
changes,
|
|
||||||
"Uninstall Kimi hooks",
|
|
||||||
p.rename,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
||||||
|
|||||||
@@ -1,49 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"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 encodedWindowsCommand(runtimePath, scriptPath), nil
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
||||||
if start >= len(command) {
|
if start >= len(command) {
|
||||||
return "", start, false
|
return "", start, false
|
||||||
@@ -66,6 +27,7 @@ func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool
|
|||||||
return command[start:end], end, end > start
|
return command[start:end], end, end > start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseKimiLegacyWindowsCommand reads the pre-2.1 quoteWindowsArgument form.
|
||||||
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
||||||
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
@@ -80,40 +42,19 @@ func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseKimiCommand(command string) (string, string, bool) {
|
func parseKimiCommand(command string) (string, string, bool) {
|
||||||
if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
|
if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
|
||||||
return runtimePath, scriptPath, true
|
|
||||||
}
|
|
||||||
if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok {
|
|
||||||
return runtimePath, scriptPath, true
|
return runtimePath, scriptPath, true
|
||||||
}
|
}
|
||||||
return parseKimiLegacyWindowsCommand(command)
|
return parseKimiLegacyWindowsCommand(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKimiNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeReferenceForCommand(
|
func kimiRuntimeReferenceForCommand(
|
||||||
command string,
|
command string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kimiRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
||||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
|
||||||
!isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
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,9 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,20 +52,10 @@ type kimiHook struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kimiRuntimeContract struct {
|
type kimiRuntimeContract struct {
|
||||||
agentID string
|
managedRuntimeContract
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
configPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringSet(values ...string) map[string]struct{} {
|
|
||||||
result := make(map[string]struct{}, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
result[value] = struct{}{}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} {
|
func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} {
|
||||||
result := map[string]struct{}{}
|
result := map[string]struct{}{}
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
@@ -173,7 +161,7 @@ func managedKimiReference(
|
|||||||
hook kimiHook,
|
hook kimiHook,
|
||||||
event string,
|
event string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kimiRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
||||||
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -196,7 +184,7 @@ 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 {
|
||||||
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
||||||
var reference *kimiRuntimeReference
|
var reference *managedScriptReference
|
||||||
var err error
|
var err error
|
||||||
if managedEvent {
|
if managedEvent {
|
||||||
reference, err = managedKimiReference(hook, event, scriptName)
|
reference, err = managedKimiReference(hook, event, scriptName)
|
||||||
@@ -205,7 +193,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
remove := reference != nil &&
|
remove := reference != nil &&
|
||||||
(agentID == "" || sameKimiAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
indices = append(indices, index)
|
indices = append(indices, index)
|
||||||
}
|
}
|
||||||
@@ -213,19 +201,12 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
|||||||
return indices, nil
|
return indices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiReferencesForEvent(
|
func kimiReferencesForEvent(
|
||||||
hooks []kimiHook,
|
hooks []kimiHook,
|
||||||
event string,
|
event string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (map[string][]kimiRuntimeReference, error) {
|
) (map[string][]managedScriptReference, error) {
|
||||||
result := map[string][]kimiRuntimeReference{}
|
result := map[string][]managedScriptReference{}
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
reference, err := managedKimiReference(hook, event, scriptName)
|
reference, err := managedKimiReference(hook, event, scriptName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,7 +215,7 @@ func kimiReferencesForEvent(
|
|||||||
if reference == nil {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := kimiReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -267,12 +248,15 @@ func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, erro
|
|||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameKimiPath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, kimiRuntimeContract{
|
contracts = append(contracts, kimiRuntimeContract{
|
||||||
|
managedRuntimeContract: managedRuntimeContract{
|
||||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
auditPath: success[0].scriptPath, configPath: document.contract.configPath,
|
auditPath: success[0].scriptPath,
|
||||||
|
},
|
||||||
|
configPath: document.contract.configPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,107 +2,32 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type kimiRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type kimiRenderedDocument struct {
|
type kimiRenderedDocument struct {
|
||||||
document kimiDocument
|
document kimiDocument
|
||||||
next []byte
|
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(
|
func preflightKimiInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
documents []kimiDocument,
|
documents []kimiDocument,
|
||||||
) (*kimiRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if len(documents) == 0 {
|
if len(documents) == 0 {
|
||||||
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
||||||
}
|
}
|
||||||
paths, err := kimiAgentPaths(config)
|
if err := validateManagedInstallConfig(config, kimiAgentKey, "kimi"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, kimiGuardScript, kimiAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, kimiAgentKey, "Kimi",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
@@ -112,26 +37,9 @@ func preflightKimiInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func kimiExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(kimiAgentKey, agentID, "", false, "")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(kimiAgentKey, agentID, "", false, true))
|
||||||
"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(
|
func renderKimiChange(
|
||||||
@@ -172,47 +80,21 @@ func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, erro
|
|||||||
|
|
||||||
func prepareKimiInstallationChanges(
|
func prepareKimiInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *kimiRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered []kimiRenderedDocument,
|
rendered []kimiRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildKimiRuntimeConfig(config)
|
guardScript, auditScript := kimiExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, kimiAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
documentChanges, err := prepareKimiUninstallChanges(rendered)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
return append(changes, documentChanges...), nil
|
||||||
}
|
|
||||||
for _, document := range rendered {
|
|
||||||
change, err := prepareRenderedKimiChange(document)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
return changes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareKimiUninstallChanges(
|
func prepareKimiUninstallChanges(
|
||||||
@@ -228,31 +110,3 @@ func prepareKimiUninstallChanges(
|
|||||||
}
|
}
|
||||||
return changes, nil
|
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)
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ func resolveKimiContracts() ([]kimiContract, error) {
|
|||||||
legacyHome := filepath.Join(home, ".kimi")
|
legacyHome := filepath.Join(home, ".kimi")
|
||||||
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
||||||
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
||||||
if sameKimiPath(stable.configPath, legacy.configPath) {
|
if sameManagedPath(stable.configPath, legacy.configPath) {
|
||||||
return []kimiContract{stable}, nil
|
return []kimiContract{stable}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +1,12 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s", field, configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKimiRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name")
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`, field, configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireKimiRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
|
|
||||||
return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireKimiRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory, "Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readKimiRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != kimiAgentKey ||
|
|
||||||
!sameKimiAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Kimi agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
exists, err := kimiRuntimeContractExists(contract)
|
exists, err := managedRuntimeContractExists(
|
||||||
if err != nil {
|
contract.managedRuntimeContract, kimiAgentKey, "Kimi", nil,
|
||||||
return false, err
|
)
|
||||||
}
|
if err != nil || exists {
|
||||||
if exists {
|
return exists, err
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) {
|
|||||||
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
||||||
})
|
})
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameKimiPath(destination, fixture.legacyPath) {
|
if sameManagedPath(destination, fixture.legacyPath) {
|
||||||
return errors.New("injected legacy config failure")
|
return errors.New("injected legacy config failure")
|
||||||
}
|
}
|
||||||
return os.Rename(source, destination)
|
return os.Rename(source, destination)
|
||||||
@@ -160,11 +160,11 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("preflight Kimi installation: %v", err)
|
t.Fatalf("preflight Kimi installation: %v", err)
|
||||||
}
|
}
|
||||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build guard command: %v", err)
|
t.Fatalf("build guard command: %v", err)
|
||||||
}
|
}
|
||||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build audit command: %v", err)
|
t.Fatalf("build audit command: %v", err)
|
||||||
}
|
}
|
||||||
@@ -204,12 +204,9 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
|||||||
t.Fatalf("write concurrent source: %v", err)
|
t.Fatalf("write concurrent source: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = writeKimiChanges(
|
err = writeManagedChanges(
|
||||||
changes,
|
changes, "Install Kimi hooks", nil,
|
||||||
"Install Kimi hooks",
|
paths.runtimeRoot, paths.agentDirectory, "", "",
|
||||||
nil,
|
|
||||||
paths.runtimeRoot,
|
|
||||||
paths.agentDirectory,
|
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ func extractPathFromNodeCommand(command string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withoutElydoraEntries(hookArray any) []any {
|
||||||
|
entries, _ := hookArray.([]any)
|
||||||
|
kept := make([]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if object, ok := entry.(map[string]any); ok && isElydoraHookEntry(object) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, entry)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
func hasElydoraEntry(hookArray any) bool {
|
func hasElydoraEntry(hookArray any) bool {
|
||||||
entries, _ := hookArray.([]any)
|
entries, _ := hookArray.([]any)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
+40
-118
@@ -1,16 +1,16 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LettaPlugin manages the Elydora audit hook for Letta Code.
|
// LettaPlugin merges Elydora hooks into ~/.letta/settings.json.
|
||||||
// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json.
|
|
||||||
type LettaPlugin struct{}
|
type LettaPlugin struct{}
|
||||||
|
|
||||||
func (p *LettaPlugin) configPath() (string, error) {
|
func lettaConfigPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
@@ -18,6 +18,16 @@ func (p *LettaPlugin) configPath() (string, error) {
|
|||||||
return filepath.Join(home, ".letta", "settings.json"), nil
|
return filepath.Join(home, ".letta", "settings.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lettaHookEntry(scriptPath string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": []any{map[string]any{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node " + scriptPath,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Install(config InstallConfig) error {
|
func (p *LettaPlugin) Install(config InstallConfig) error {
|
||||||
scriptPath, err := hookScriptPath(config.AgentID)
|
scriptPath, err := hookScriptPath(config.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,11 +36,9 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
if config.HookScript != "" {
|
if config.HookScript != "" {
|
||||||
scriptPath = config.HookScript
|
scriptPath = config.HookScript
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
return fmt.Errorf("generate hook script: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
guardPath := config.GuardScriptPath
|
guardPath := config.GuardScriptPath
|
||||||
if guardPath == "" {
|
if guardPath == "" {
|
||||||
guardPath, err = guardScriptPath(config.AgentID)
|
guardPath, err = guardScriptPath(config.AgentID)
|
||||||
@@ -38,70 +46,21 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
configPath, err := lettaConfigPath()
|
||||||
configPath, err := p.configPath()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
|
||||||
if hooks == nil {
|
if hooks == nil {
|
||||||
hooks = make(map[string]interface{})
|
hooks = map[string]any{}
|
||||||
}
|
}
|
||||||
|
hooks["PreToolUse"] = append(withoutElydoraEntries(hooks["PreToolUse"]), lettaHookEntry(guardPath))
|
||||||
// --- PreToolUse (guard — freeze enforcement) ---
|
hooks["PostToolUse"] = append(withoutElydoraEntries(hooks["PostToolUse"]), lettaHookEntry(scriptPath))
|
||||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
|
||||||
var preFiltered []interface{}
|
|
||||||
for _, entry := range preToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, entry)
|
|
||||||
}
|
|
||||||
guardEntry := map[string]interface{}{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node " + guardPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, guardEntry)
|
|
||||||
hooks["PreToolUse"] = preFiltered
|
|
||||||
|
|
||||||
// --- PostToolUse (audit logging) ---
|
|
||||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
|
||||||
var postFiltered []interface{}
|
|
||||||
for _, entry := range postToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
postFiltered = append(postFiltered, entry)
|
|
||||||
}
|
|
||||||
hookEntry := map[string]interface{}{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node " + scriptPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
postFiltered = append(postFiltered, hookEntry)
|
|
||||||
hooks["PostToolUse"] = postFiltered
|
|
||||||
|
|
||||||
settings["hooks"] = hooks
|
settings["hooks"] = hooks
|
||||||
|
|
||||||
if err := writeJSONFile(configPath, settings); err != nil {
|
if err := writeJSONFile(configPath, settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,74 +69,44 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Uninstall(agentID string) error {
|
func (p *LettaPlugin) Uninstall(agentID string) error {
|
||||||
configPath, err := p.configPath()
|
configPath, err := lettaConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
|
||||||
if hooks == nil {
|
if hooks == nil {
|
||||||
fmt.Println("No Letta Code hooks found.")
|
fmt.Println("No Letta Code hooks found.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
for _, event := range []string{"PreToolUse", "PostToolUse"} {
|
||||||
// Remove PreToolUse Elydora entries
|
kept := withoutElydoraEntries(hooks[event])
|
||||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
if len(kept) == 0 {
|
||||||
var preFiltered []interface{}
|
delete(hooks, event)
|
||||||
for _, entry := range preToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, entry)
|
|
||||||
}
|
|
||||||
if len(preFiltered) == 0 {
|
|
||||||
delete(hooks, "PreToolUse")
|
|
||||||
} else {
|
} else {
|
||||||
hooks["PreToolUse"] = preFiltered
|
hooks[event] = kept
|
||||||
}
|
|
||||||
|
|
||||||
// Remove PostToolUse Elydora entries
|
|
||||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
|
||||||
var postFiltered []interface{}
|
|
||||||
for _, entry := range postToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
postFiltered = append(postFiltered, entry)
|
|
||||||
}
|
|
||||||
if len(postFiltered) == 0 {
|
|
||||||
delete(hooks, "PostToolUse")
|
|
||||||
} else {
|
|
||||||
hooks["PostToolUse"] = postFiltered
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(settings, "hooks")
|
delete(settings, "hooks")
|
||||||
} else {
|
} else {
|
||||||
settings["hooks"] = hooks
|
settings["hooks"] = hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeJSONFile(configPath, settings); err != nil {
|
if err := writeJSONFile(configPath, settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
scriptPath, _ := hookScriptPath(agentID)
|
for _, resolve := range []func(string) (string, error){hookScriptPath, guardScriptPath} {
|
||||||
if scriptPath != "" {
|
path, err := resolve(agentID)
|
||||||
os.Remove(scriptPath)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s: %w", path, err)
|
||||||
}
|
}
|
||||||
gPath, _ := guardScriptPath(agentID)
|
|
||||||
if gPath != "" {
|
|
||||||
os.Remove(gPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("Uninstalled Elydora hook for Letta Code.")
|
fmt.Println("Uninstalled Elydora hook for Letta Code.")
|
||||||
@@ -185,37 +114,30 @@ func (p *LettaPlugin) Uninstall(agentID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Status() (PluginStatus, error) {
|
func (p *LettaPlugin) Status() (PluginStatus, error) {
|
||||||
configPath, err := p.configPath()
|
configPath, err := lettaConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PluginStatus{}, err
|
return PluginStatus{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: "letta",
|
AgentName: "letta",
|
||||||
DisplayName: "Letta Code",
|
DisplayName: "Letta Code",
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
|
if hooks == nil {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.HookConfigured = hasElydoraEntry(hooks["PreToolUse"]) &&
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
hasElydoraEntry(hooks["PostToolUse"])
|
||||||
if hooks != nil {
|
if scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]); scriptPath != "" {
|
||||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
|
||||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
|
||||||
status.HookConfigured = preConfigured && postConfigured
|
|
||||||
|
|
||||||
// Extract hook script path from the configured command
|
|
||||||
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
|
|
||||||
if scriptPath != "" {
|
|
||||||
if _, err := os.Stat(scriptPath); err == nil {
|
if _, err := os.Stat(scriptPath); err == nil {
|
||||||
status.HookScriptExists = true
|
status.HookScriptExists = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildManagedRuntimeConfig(config InstallConfig, agentKey string) ([]byte, error) {
|
||||||
|
value := map[string]any{
|
||||||
|
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||||
|
"base_url": config.BaseURL, "agent_name": agentKey,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedRuntimeFileChanges prepares the guard, config, key, and audit runtime changes.
|
||||||
|
func managedRuntimeFileChanges(
|
||||||
|
config InstallConfig,
|
||||||
|
paths *managedRuntimePaths,
|
||||||
|
agentKey, guardScript, auditScript string,
|
||||||
|
) ([]*fileChange, error) {
|
||||||
|
runtimeConfig, err := buildManagedRuntimeConfig(config, agentKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []struct {
|
||||||
|
path, label string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{paths.guardPath, "Elydora guard runtime", []byte(guardScript), 0700},
|
||||||
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
{paths.auditPath, "Elydora audit runtime", []byte(auditScript), 0700},
|
||||||
|
}
|
||||||
|
changes := make([]*fileChange, 0, len(items)+1)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFileChanges(changes []*fileChange) bool {
|
||||||
|
for _, change := range changes {
|
||||||
|
if change != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeManagedChanges creates the runtime and settings directories, then commits.
|
||||||
|
func writeManagedChanges(
|
||||||
|
changes []*fileChange,
|
||||||
|
label string,
|
||||||
|
rename renameFunc,
|
||||||
|
runtimeRoot, agentDirectory, settingsDirectory, settingsLabel string,
|
||||||
|
) error {
|
||||||
|
if !hasFileChanges(changes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if agentDirectory != "" {
|
||||||
|
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settingsDirectory != "" {
|
||||||
|
if err := ensureManagedDirectory(settingsDirectory, settingsLabel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeChanges(changes, label, rename)
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -30,6 +28,14 @@ type managedScriptReference struct {
|
|||||||
scriptPath string
|
scriptPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stringSet(values ...string) map[string]struct{} {
|
||||||
|
result := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
var managedRuntimeConfigFields = stringSet(
|
var managedRuntimeConfigFields = stringSet(
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
||||||
)
|
)
|
||||||
@@ -65,24 +71,28 @@ func managedReferenceKey(agentID string) string {
|
|||||||
return agentID
|
return agentID
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveManagedScript accepts absolute ~/.elydora/<agent>/<scriptName> paths.
|
// managedScriptWithin accepts absolute <runtimeRoot>/<agent>/<scriptName> paths.
|
||||||
func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) {
|
func managedScriptWithin(scriptPath, scriptName, runtimeRoot string) *managedScriptReference {
|
||||||
if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
|
if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
agentID := filepath.Base(agentDirectory)
|
||||||
|
if agentID == "" || agentID == "." || agentID == ".." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) {
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
return managedScriptWithin(scriptPath, scriptName, runtimeRoot), nil
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error {
|
func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error {
|
||||||
@@ -401,89 +411,3 @@ func managedRuntimeFilesPresent(contracts []managedRuntimeContract, agentKey str
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildManagedRuntimeConfig(config InstallConfig, agentKey string) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": agentKey,
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// managedRuntimeFileChanges prepares the guard, config, key, and audit runtime changes.
|
|
||||||
func managedRuntimeFileChanges(
|
|
||||||
config InstallConfig,
|
|
||||||
paths *managedRuntimePaths,
|
|
||||||
agentKey, guardScript, auditScript string,
|
|
||||||
) ([]*fileChange, error) {
|
|
||||||
runtimeConfig, err := buildManagedRuntimeConfig(config, agentKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{paths.guardPath, "Elydora guard runtime", []byte(guardScript), 0700},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{paths.auditPath, "Elydora audit runtime", []byte(auditScript), 0700},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return changes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasFileChanges(changes []*fileChange) bool {
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeManagedChanges creates the runtime and settings directories, then commits.
|
|
||||||
func writeManagedChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot, agentDirectory, settingsDirectory, settingsLabel string,
|
|
||||||
) error {
|
|
||||||
if !hasFileChanges(changes) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(settingsDirectory, settingsLabel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ func (p *QwenPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (p *QwenPlugin) Status() (PluginStatus, error) {
|
|||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = sources.user.filePath
|
status.ConfigPath = sources.user.filePath
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const qwenPOSIXApostrophe = `'"'"'`
|
|
||||||
|
|
||||||
type qwenRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildQwenCommand(nodePath, scriptPath string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "& " + quoteQwenPowerShell(nodePath) + " " +
|
|
||||||
quoteQwenPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteQwenPowerShell(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseQwenCommand(command string) (string, string, bool) {
|
|
||||||
start := 0
|
|
||||||
suffix := ""
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
if !strings.HasPrefix(command, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
start = 2
|
|
||||||
suffix = "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
executable, next, ok := readQwenQuotedArgument(command, start)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readQwenQuotedArgument(command, next+1)
|
|
||||||
return executable, script, ok && command[end:] == suffix &&
|
|
||||||
executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func readQwenQuotedArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if runtime.GOOS == "windows" && strings.HasPrefix(command[index:], "''") {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if runtime.GOOS != "windows" && strings.HasPrefix(command[index:], qwenPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(qwenPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameQwenPath(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 sameQwenAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func isQwenNodeExecutable(filePath string) bool {
|
|
||||||
name := strings.ToLower(filepath.Base(filePath))
|
|
||||||
return name == "node" || name == "node.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeReferenceForCommand(
|
|
||||||
command, scriptName, runtimeRoot string,
|
|
||||||
) (qwenRuntimeReference, bool) {
|
|
||||||
executable, scriptPath, ok := parseQwenCommand(command)
|
|
||||||
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
|
||||||
!isQwenNodeExecutable(executable) || filepath.Base(scriptPath) != scriptName {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
if !sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
return qwenRuntimeReference{agentID: agentID, scriptPath: scriptPath}, true
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -40,12 +39,6 @@ type qwenManagedRemoval struct {
|
|||||||
removeGroup bool
|
removeGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type qwenRuntimeContract struct {
|
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenExpectedShell() string {
|
func qwenExpectedShell() string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "powershell"
|
return "powershell"
|
||||||
@@ -58,41 +51,52 @@ func buildQwenGroup(nodePath, scriptPath, name string) map[string]any {
|
|||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"name": name,
|
"name": name,
|
||||||
"command": buildQwenCommand(nodePath, scriptPath),
|
"command": buildShellCommand(nodePath, scriptPath),
|
||||||
"shell": qwenExpectedShell(),
|
"shell": qwenExpectedShell(),
|
||||||
"timeout": qwenHookTimeout,
|
"timeout": qwenHookTimeout,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func qwenRuntimeReferenceForCommand(
|
||||||
|
command, scriptName, runtimeRoot string,
|
||||||
|
) *managedScriptReference {
|
||||||
|
executable, scriptPath, ok := parseShellCommand(command)
|
||||||
|
if !ok || !filepath.IsAbs(executable) || !isNodeExecutable(executable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return managedScriptWithin(scriptPath, scriptName, runtimeRoot)
|
||||||
|
}
|
||||||
|
|
||||||
func currentQwenReference(
|
func currentQwenReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, hookName, runtimeRoot string,
|
scriptName, hookName, runtimeRoot string,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if len(handler) != 5 || handler["type"] != "command" ||
|
if len(handler) != 5 || handler["type"] != "command" ||
|
||||||
handler["name"] != hookName || handler["shell"] != qwenExpectedShell() ||
|
handler["name"] != hookName || handler["shell"] != qwenExpectedShell() ||
|
||||||
handler["timeout"] != qwenHookTimeout {
|
handler["timeout"] != qwenHookTimeout {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyQwenReference reads the pre-2.1 unnamed handler.
|
||||||
func legacyQwenReference(
|
func legacyQwenReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, runtimeRoot string,
|
scriptName, runtimeRoot string,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if len(handler) != 4 || handler["type"] != "command" ||
|
if len(handler) != 4 || handler["type"] != "command" ||
|
||||||
handler["shell"] != qwenExpectedShell() ||
|
handler["shell"] != qwenExpectedShell() ||
|
||||||
handler["timeout"] != qwenHookTimeout {
|
handler["timeout"] != qwenHookTimeout {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
@@ -101,19 +105,19 @@ func managedQwenReference(
|
|||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, hookName, runtimeRoot string,
|
scriptName, hookName, runtimeRoot string,
|
||||||
includeLegacy bool,
|
includeLegacy bool,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if reference, ok := currentQwenReference(
|
if reference := currentQwenReference(
|
||||||
handler,
|
handler,
|
||||||
scriptName,
|
scriptName,
|
||||||
hookName,
|
hookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
); ok {
|
); reference != nil {
|
||||||
return reference, true
|
return reference
|
||||||
}
|
}
|
||||||
if includeLegacy {
|
if includeLegacy {
|
||||||
return legacyQwenReference(handler, scriptName, runtimeRoot)
|
return legacyQwenReference(handler, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func exactCurrentQwenGroup(group map[string]any) bool {
|
func exactCurrentQwenGroup(group map[string]any) bool {
|
||||||
@@ -138,14 +142,15 @@ func managedQwenRemovals(
|
|||||||
handlers := group["hooks"].([]any)
|
handlers := group["hooks"].([]any)
|
||||||
indexes := make([]int, 0)
|
indexes := make([]int, 0)
|
||||||
for handlerIndex, handlerValue := range handlers {
|
for handlerIndex, handlerValue := range handlers {
|
||||||
reference, managed := managedQwenReference(
|
reference := managedQwenReference(
|
||||||
handlerValue.(map[string]any),
|
handlerValue.(map[string]any),
|
||||||
contract.script,
|
contract.script,
|
||||||
contract.name,
|
contract.name,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
if managed && (agentID == "" || sameQwenAgentID(reference.agentID, agentID)) {
|
if reference != nil &&
|
||||||
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,18 +168,11 @@ func managedQwenRemovals(
|
|||||||
return removals
|
return removals
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedQwenAgentID(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenReferencesForEvent(
|
func qwenReferencesForEvent(
|
||||||
settings qwenHookSettings,
|
settings qwenHookSettings,
|
||||||
event, scriptName, hookName, runtimeRoot string,
|
event, scriptName, hookName, runtimeRoot string,
|
||||||
) map[string][]qwenRuntimeReference {
|
) map[string][]managedScriptReference {
|
||||||
references := map[string][]qwenRuntimeReference{}
|
references := map[string][]managedScriptReference{}
|
||||||
groups, _ := settings[event].([]any)
|
groups, _ := settings[event].([]any)
|
||||||
for _, groupValue := range groups {
|
for _, groupValue := range groups {
|
||||||
group := groupValue.(map[string]any)
|
group := groupValue.(map[string]any)
|
||||||
@@ -182,17 +180,17 @@ func qwenReferencesForEvent(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, handlerValue := range group["hooks"].([]any) {
|
for _, handlerValue := range group["hooks"].([]any) {
|
||||||
reference, ok := currentQwenReference(
|
reference := currentQwenReference(
|
||||||
handlerValue.(map[string]any),
|
handlerValue.(map[string]any),
|
||||||
scriptName,
|
scriptName,
|
||||||
hookName,
|
hookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
)
|
)
|
||||||
if !ok {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := normalizedQwenAgentID(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
references[key] = append(references[key], reference)
|
references[key] = append(references[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return references
|
return references
|
||||||
@@ -201,7 +199,7 @@ func qwenReferencesForEvent(
|
|||||||
func qwenRuntimeContracts(
|
func qwenRuntimeContracts(
|
||||||
settings qwenHookSettings,
|
settings qwenHookSettings,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) []qwenRuntimeContract {
|
) []managedRuntimeContract {
|
||||||
guards := qwenReferencesForEvent(
|
guards := qwenReferencesForEvent(
|
||||||
settings,
|
settings,
|
||||||
"PreToolUse",
|
"PreToolUse",
|
||||||
@@ -223,31 +221,23 @@ func qwenRuntimeContracts(
|
|||||||
qwenAuditHookName,
|
qwenAuditHookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
)
|
)
|
||||||
contracts := make([]qwenRuntimeContract, 0)
|
contracts := make([]managedRuntimeContract, 0)
|
||||||
for key, guard := range guards {
|
for key, guard := range guards {
|
||||||
post := posts[key]
|
post := posts[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(post) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(post) != 1 || len(failure) != 1 ||
|
||||||
!sameQwenPath(post[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(post[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, qwenRuntimeContract{
|
contracts = append(contracts, managedRuntimeContract{
|
||||||
agentID: guard[0].agentID,
|
agentID: guard[0].agentID,
|
||||||
guardPath: guard[0].scriptPath,
|
guardPath: guard[0].scriptPath,
|
||||||
auditPath: post[0].scriptPath,
|
auditPath: post[0].scriptPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(contracts, func(left, right int) bool {
|
sort.Slice(contracts, func(left, right int) bool {
|
||||||
return normalizedQwenAgentID(contracts[left].agentID) <
|
return managedReferenceKey(contracts[left].agentID) <
|
||||||
normalizedQwenAgentID(contracts[right].agentID)
|
managedReferenceKey(contracts[right].agentID)
|
||||||
})
|
})
|
||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenRuntimeRoot() (string, error) {
|
|
||||||
return AgentRuntimeRoot()
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenContractDirectory(contract qwenRuntimeContract) string {
|
|
||||||
return filepath.Dir(contract.guardPath)
|
|
||||||
}
|
|
||||||
@@ -1,158 +1,45 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type qwenRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type preparedQwenInstallation struct {
|
type preparedQwenInstallation struct {
|
||||||
changes []*fileChange
|
changes []*fileChange
|
||||||
preconditions []filePrecondition
|
preconditions []filePrecondition
|
||||||
paths *qwenRuntimePaths
|
paths *managedRuntimePaths
|
||||||
}
|
|
||||||
|
|
||||||
type qwenRuntimeConfig struct {
|
|
||||||
OrgID string `json:"org_id"`
|
|
||||||
AgentID string `json:"agent_id"`
|
|
||||||
KID string `json:"kid"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
AgentName string `json:"agent_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateQwenInstallConfig(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 != qwenAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Qwen Code installation requires agent name %s",
|
|
||||||
qwenAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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 qwenAgentPaths(config InstallConfig) (*qwenRuntimePaths, error) {
|
|
||||||
if err := validateQwenInstallConfig(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 := &qwenRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot,
|
|
||||||
agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, qwenGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, qwenAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameQwenPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameQwenPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightQwenInstallation(
|
func preflightQwenInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
sources *qwenSources,
|
sources *qwenSources,
|
||||||
) (*qwenRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||||
return nil, "", fmt.Errorf("Qwen Code installation requires user settings")
|
return nil, "", fmt.Errorf("Qwen Code installation requires user settings")
|
||||||
}
|
}
|
||||||
if err := requireQwenHooksEnabled(sources); err != nil {
|
if err := requireQwenHooksEnabled(sources); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
paths, err := qwenAgentPaths(config)
|
if err := validateManagedInstallConfig(config, qwenAgentKey, "Qwen Code"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, qwenGuardScript, qwenAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateQwenRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, qwenAgentKey, "Qwen Code",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Qwen Code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isQwenNodeExecutable(nodePath) {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"Qwen Code hooks require an absolute Node.js executable path",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQwenRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func qwenExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
encoded, err := json.MarshalIndent(qwenRuntimeConfig{
|
return []byte(generateGuardScript(qwenAgentKey, agentID, "", false, "")),
|
||||||
OrgID: config.OrgID,
|
[]byte(buildHookScriptWithOutput(qwenAgentKey, agentID, "", false, true))
|
||||||
AgentID: config.AgentID,
|
|
||||||
KID: config.KID,
|
|
||||||
BaseURL: config.BaseURL,
|
|
||||||
Token: config.Token,
|
|
||||||
AgentName: qwenAgentKey,
|
|
||||||
}, "", " ")
|
|
||||||
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 prepareRenderedQwenChange(
|
func prepareRenderedQwenChange(
|
||||||
@@ -180,7 +67,7 @@ func qwenReadOnlyPreconditions(
|
|||||||
}
|
}
|
||||||
result := make([]filePrecondition, 0, len(sources.preconditions))
|
result := make([]filePrecondition, 0, len(sources.preconditions))
|
||||||
for _, condition := range sources.preconditions {
|
for _, condition := range sources.preconditions {
|
||||||
if changedPath != "" && sameQwenPath(condition.filePath, changedPath) {
|
if changedPath != "" && sameManagedPath(condition.filePath, changedPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, condition)
|
result = append(result, condition)
|
||||||
@@ -198,47 +85,16 @@ func prepareQwenInstallation(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if rendered == nil || rendered.document == nil ||
|
if rendered == nil || rendered.document == nil ||
|
||||||
!sameQwenPath(rendered.document.filePath, sources.user.filePath) {
|
!sameManagedPath(rendered.document.filePath, sources.user.filePath) {
|
||||||
return nil, fmt.Errorf("Qwen Code rendered user settings are required")
|
return nil, fmt.Errorf("Qwen Code rendered user settings are required")
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildQwenRuntimeConfig(config)
|
guardScript, auditScript := qwenExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, qwenAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(qwenAgentKey, 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(
|
|
||||||
qwenAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if changeErr != nil {
|
|
||||||
return nil, changeErr
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
settingsChange, err := prepareRenderedQwenChange(rendered)
|
settingsChange, err := prepareRenderedQwenChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
|
||||||
|
|
||||||
func readQwenRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireQwenRuntimeString(
|
|
||||||
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 validateQwenRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID, 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 := requireQwenRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireQwenRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireQwenRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameQwenAgentID(agentID, expectedAgentID) || config["agent_name"] != qwenAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Qwen Code hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireQwenRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireQwenRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateQwenRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readQwenRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, qwenGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, qwenAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != qwenAgentKey ||
|
|
||||||
!sameQwenAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Qwen Code agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validQwenContractPaths(contract qwenRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := qwenContractDirectory(contract)
|
|
||||||
return sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
|
||||||
sameQwenPath(
|
|
||||||
contract.guardPath,
|
|
||||||
filepath.Join(agentDirectory, qwenGuardScript),
|
|
||||||
) &&
|
|
||||||
sameQwenPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, qwenAuditScript),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeContractExists(contract qwenRuntimeContract) (bool, error) {
|
|
||||||
validPaths, err := validQwenContractPaths(contract)
|
|
||||||
if err != nil || !validPaths {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := qwenContractDirectory(contract)
|
|
||||||
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 := readQwenRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateQwenRuntimeConfig(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)
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(
|
|
||||||
qwenAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
|
||||||
qwenAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
|
||||||
bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeFilesExist(contracts []qwenRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := qwenRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -244,7 +244,7 @@ func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) {
|
|||||||
original := readQwenTestFile(t, fixture.configPath)
|
original := readQwenTestFile(t, fixture.configPath)
|
||||||
failed := false
|
failed := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if !failed && sameQwenPath(destination, fixture.configPath) {
|
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||||
failed = true
|
failed = true
|
||||||
return errors.New("simulated settings commit failure")
|
return errors.New("simulated settings commit failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
||||||
|
|
||||||
var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"}
|
var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"}
|
||||||
|
|
||||||
type qwenDisableControl struct {
|
type qwenDisableControl struct {
|
||||||
@@ -154,7 +157,7 @@ func resolveQwenRouting() (*qwenRoutingResult, error) {
|
|||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return nil, resolveErr
|
return nil, resolveErr
|
||||||
}
|
}
|
||||||
if !sameQwenPath(discoveredDirectory, initialDirectory) {
|
if !sameManagedPath(discoveredDirectory, initialDirectory) {
|
||||||
if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil {
|
if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ func TestQwenRoutingReadsDiscoveredHomeEnvironment(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve Qwen routing: %v", err)
|
t.Fatalf("resolve Qwen routing: %v", err)
|
||||||
}
|
}
|
||||||
if !sameQwenPath(routing.qwenHome, discovered) {
|
if !sameManagedPath(routing.qwenHome, discovered) {
|
||||||
t.Fatalf("Qwen home = %s", routing.qwenHome)
|
t.Fatalf("Qwen home = %s", routing.qwenHome)
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, condition := range routing.preconditions {
|
for _, condition := range routing.preconditions {
|
||||||
found = found || sameQwenPath(condition.filePath, discoveredEnv)
|
found = found || sameManagedPath(condition.filePath, discoveredEnv)
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatalf("discovered environment is absent from preconditions: %#v", routing.preconditions)
|
t.Fatalf("discovered environment is absent from preconditions: %#v", routing.preconditions)
|
||||||
@@ -127,7 +127,7 @@ func TestQwenRoutingSkipsDotenvWhenBothEnvironmentValuesExist(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve Qwen routing: %v", err)
|
t.Fatalf("resolve Qwen routing: %v", err)
|
||||||
}
|
}
|
||||||
if !sameQwenPath(routing.qwenHome, explicit) || len(routing.preconditions) != 0 {
|
if !sameManagedPath(routing.qwenHome, explicit) || len(routing.preconditions) != 0 {
|
||||||
t.Fatalf("routing = %#v", routing)
|
t.Fatalf("routing = %#v", routing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
func qwenRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
|
return managedRuntimeFilesExist(contracts, qwenAgentKey, "Qwen Code", qwenExpectedScripts)
|
||||||
|
}
|
||||||
@@ -153,7 +153,7 @@ func legacyQwenGroupForTest(
|
|||||||
"matcher": "*",
|
"matcher": "*",
|
||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": buildQwenCommand(nodePath, scriptPath),
|
"command": buildShellCommand(nodePath, scriptPath),
|
||||||
"shell": qwenExpectedShell(),
|
"shell": qwenExpectedShell(),
|
||||||
"timeout": qwenHookTimeout,
|
"timeout": qwenHookTimeout,
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ func qwenManagedGroup(
|
|||||||
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
||||||
handler := requireQwenObject(t, handlerValue)
|
handler := requireQwenObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
_, candidate, managed := parseQwenCommand(command)
|
_, candidate, managed := parseShellCommand(command)
|
||||||
if managed && handler["name"] == expectedName && sameQwenPath(candidate, scriptPath) {
|
if managed && handler["name"] == expectedName && sameManagedPath(candidate, scriptPath) {
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,8 +231,8 @@ func qwenManagedHandler(
|
|||||||
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
||||||
handler := requireQwenObject(t, handlerValue)
|
handler := requireQwenObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
_, candidate, managed := parseQwenCommand(command)
|
_, candidate, managed := parseShellCommand(command)
|
||||||
if managed && sameQwenPath(candidate, scriptPath) {
|
if managed && sameManagedPath(candidate, scriptPath) {
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,3 +196,57 @@ func parseEncodedWindowsCommand(command string) (string, string, bool) {
|
|||||||
}
|
}
|
||||||
return parsePowerShellSource(source)
|
return parsePowerShellSource(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildEncodedCommand(product, runtimePath, scriptPath string) (string, error) {
|
||||||
|
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
||||||
|
return "", fmt.Errorf("%s hook commands require absolute runtime and script paths", product)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return encodedWindowsCommand(runtimePath, scriptPath), nil
|
||||||
|
}
|
||||||
|
return posixSource(runtimePath, scriptPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEncodedCommand(command string) (string, string, bool) {
|
||||||
|
if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
|
||||||
|
return runtimePath, scriptPath, true
|
||||||
|
}
|
||||||
|
return parseEncodedWindowsCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildShellCommand(runtimePath, scriptPath string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return powerShellSource(runtimePath, scriptPath)
|
||||||
|
}
|
||||||
|
return posixSource(runtimePath, scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseShellCommand(command string) (string, string, bool) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return parsePowerShellSource(command)
|
||||||
|
}
|
||||||
|
return parsePOSIXCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQuotedWindowsCommand reads the pre-2.1 `"node" "script"` form.
|
||||||
|
func parseQuotedWindowsCommand(command string) (string, string, bool) {
|
||||||
|
runtimePath, next, ok := readQuotedWindowsArgument(command, 0)
|
||||||
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
scriptPath, end, ok := readQuotedWindowsArgument(command, next+1)
|
||||||
|
return runtimePath, scriptPath, ok && end == len(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQuotedWindowsArgument(command string, start int) (string, int, bool) {
|
||||||
|
if start >= len(command) || command[start] != '"' {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(command[start+1:], '"')
|
||||||
|
if end < 0 {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
end += start + 1
|
||||||
|
value := command[start+1 : end]
|
||||||
|
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user