diff --git a/cmd/elydora/plugins/droid_contract.go b/cmd/elydora/plugins/droid_contract.go index b4f83f5..4474ae1 100644 --- a/cmd/elydora/plugins/droid_contract.go +++ b/cmd/elydora/plugins/droid_contract.go @@ -172,53 +172,21 @@ for (const entry of entries) { 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 { return map[string]any{ "matcher": "*", "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) { - if runtime.GOOS != "windows" { - return parsePOSIXCommand(command) - } - executable, script, ok := parsePowerShellSource(command) - if ok || len(includeLegacy) == 0 || !includeLegacy[0] { + executable, script, ok := parseShellCommand(command) + if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] { return executable, script, ok } - return parseDroidLegacyWindowsCommand(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") + return parseQuotedWindowsCommand(command) } func managedDroidAgentID( diff --git a/cmd/elydora/plugins/grok.go b/cmd/elydora/plugins/grok.go index 6c4a6ae..093ab1b 100644 --- a/cmd/elydora/plugins/grok.go +++ b/cmd/elydora/plugins/grok.go @@ -36,11 +36,11 @@ func (p *GrokPlugin) Install(config InstallConfig) error { if err != nil { return err } - guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath) if err != nil { return err } - auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath) if err != nil { return err } diff --git a/cmd/elydora/plugins/grok_command.go b/cmd/elydora/plugins/grok_command.go index 14c7579..b28c48d 100644 --- a/cmd/elydora/plugins/grok_command.go +++ b/cmd/elydora/plugins/grok_command.go @@ -1,132 +1,21 @@ package plugins -import ( - "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 -} +import "path/filepath" 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 } - if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok { - return runtimePath, scriptPath, true - } - return parseGrokLegacyWindowsCommand(command) -} - -func isGrokNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") + return parseQuotedWindowsCommand(command) } func grokRuntimeReferenceForCommand( command string, scriptName string, -) (*grokRuntimeReference, error) { +) (*managedScriptReference, error) { runtimePath, scriptPath, ok := parseGrokCommand(command) - if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isGrokNodeExecutable(runtimePath) || - !sameGrokFileName(filepath.Base(scriptPath), scriptName) { + if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - 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 + return resolveManagedScript(scriptPath, scriptName) } diff --git a/cmd/elydora/plugins/grok_contract.go b/cmd/elydora/plugins/grok_contract.go index dc5e145..7a58d3e 100644 --- a/cmd/elydora/plugins/grok_contract.go +++ b/cmd/elydora/plugins/grok_contract.go @@ -6,9 +6,7 @@ import ( "fmt" "math" "reflect" - "runtime" "sort" - "strings" ) const ( @@ -48,12 +46,6 @@ type grokRenderedDocument struct { remove bool } -type grokRuntimeContract struct { - agentID string - guardPath string - auditPath string -} - func cloneGrokObject(value map[string]any) map[string]any { clone := make(map[string]any, len(value)) for key, item := range value { @@ -262,7 +254,7 @@ func exactManagedGrokGroup(group grokGroup) bool { func managedGrokReference( handler map[string]any, scriptName string, -) (*grokRuntimeReference, error) { +) (*managedScriptReference, error) { if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != grokHookTimeout { return nil, nil @@ -303,7 +295,7 @@ func removeManagedGrokGroups( return nil, err } remove := reference != nil && - (agentID == "" || sameGrokAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if !remove { kept = append(kept, handler) } @@ -407,18 +399,11 @@ func renderGrokDocument( }, nil } -func grokReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func grokReferencesForEvent( groups []grokGroup, scriptName string, -) (map[string][]grokRuntimeReference, error) { - result := map[string][]grokRuntimeReference{} +) (map[string][]managedScriptReference, error) { + result := map[string][]managedScriptReference{} for _, group := range groups { if !exactManagedGrokGroup(group) { continue @@ -431,14 +416,14 @@ func grokReferencesForEvent( if reference == nil { continue } - key := grokReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } return result, nil } -func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { +func grokRuntimeContracts(hooks grokHooks) ([]managedRuntimeContract, error) { guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript) if err != nil { return nil, err @@ -459,16 +444,16 @@ func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { keys = append(keys, key) } sort.Strings(keys) - contracts := make([]grokRuntimeContract, 0, len(keys)) + contracts := make([]managedRuntimeContract, 0, len(keys)) for _, key := range keys { guard := guards[key] success := successes[key] failure := failures[key] 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 } - contracts = append(contracts, grokRuntimeContract{ + contracts = append(contracts, managedRuntimeContract{ agentID: guard[0].agentID, guardPath: guard[0].scriptPath, auditPath: success[0].scriptPath, }) diff --git a/cmd/elydora/plugins/grok_installation.go b/cmd/elydora/plugins/grok_installation.go index 01bb50a..c4f37f4 100644 --- a/cmd/elydora/plugins/grok_installation.go +++ b/cmd/elydora/plugins/grok_installation.go @@ -1,102 +1,24 @@ package plugins -import ( - "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 -} +import "fmt" func preflightGrokInstallation( config InstallConfig, document *grokDocument, -) (*grokRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil { 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 { 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 } nodePath, err := resolveNodeRuntime() @@ -106,63 +28,23 @@ func preflightGrokInstallation( return paths, nodePath, nil } -func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": 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 grokExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(grokAgentKey, agentID, "", false, "grok")), + []byte(buildHookScriptWithOutput(grokAgentKey, agentID, "", false, true)) } func prepareGrokInstallationChanges( config InstallConfig, - paths *grokRuntimePaths, + paths *managedRuntimePaths, rendered *grokRenderedDocument, ) ([]*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 { 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) if err != nil { return nil, err diff --git a/cmd/elydora/plugins/grok_io.go b/cmd/elydora/plugins/grok_io.go index 0ae9323..9987b6a 100644 --- a/cmd/elydora/plugins/grok_io.go +++ b/cmd/elydora/plugins/grok_io.go @@ -85,32 +85,17 @@ func writeGrokChanges( agentDirectory string, hooksDirectory string, ) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { + 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( filepath.Dir(hooksDirectory), "Grok home directory", ); err != nil { return err } - if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil { - return err - } - return writeChanges(changes, label, rename) + return writeManagedChanges( + changes, label, rename, runtimeRoot, agentDirectory, + hooksDirectory, "Grok hooks directory", + ) } diff --git a/cmd/elydora/plugins/grok_security_test.go b/cmd/elydora/plugins/grok_security_test.go index 7f8b912..1ef7723 100644 --- a/cmd/elydora/plugins/grok_security_test.go +++ b/cmd/elydora/plugins/grok_security_test.go @@ -164,10 +164,10 @@ func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { }, "query parameters"}, {"guard path", func(f *grokFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, {"audit path", func(f *grokFixture) { 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) { fixture := prepareGrokFixture(t, grokFixtureOptions{}) diff --git a/cmd/elydora/plugins/grok_status.go b/cmd/elydora/plugins/grok_status.go index abb696e..3c6cc19 100644 --- a/cmd/elydora/plugins/grok_status.go +++ b/cmd/elydora/plugins/grok_status.go @@ -1,232 +1,5 @@ package plugins -import ( - "fmt" - "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 +func grokRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, grokAgentKey, "Grok", nil) } diff --git a/cmd/elydora/plugins/grok_test.go b/cmd/elydora/plugins/grok_test.go index 613e20d..2dbe3a0 100644 --- a/cmd/elydora/plugins/grok_test.go +++ b/cmd/elydora/plugins/grok_test.go @@ -27,22 +27,22 @@ func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) { if err != nil { t.Fatalf("resolve Node.js runtime: %v", err) } - command, err := buildGrokCommand(nodePath, fixture.guardPath) + command, err := buildEncodedCommand("Grok", nodePath, fixture.guardPath) if err != nil { t.Fatalf("build Grok command: %v", err) } executable, script, ok := parseGrokCommand(command) - if !ok || !sameGrokPath(executable, nodePath) || - !sameGrokPath(script, fixture.guardPath) { + if !ok || !sameManagedPath(executable, nodePath) || + !sameManagedPath(script, fixture.guardPath) { t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok) } if strings.Contains(command, "%ELYDORA_HOOK_PATH%") { 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") } - 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") } for _, invalid := range []string{ @@ -337,12 +337,12 @@ func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) { 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") } return } - if sameGrokAgentID("AGENT-1", "agent-1") { + if sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("POSIX Grok agent IDs should preserve case") } } diff --git a/cmd/elydora/plugins/grok_test_helpers_test.go b/cmd/elydora/plugins/grok_test_helpers_test.go index 7b74cb8..2eb4dc4 100644 --- a/cmd/elydora/plugins/grok_test_helpers_test.go +++ b/cmd/elydora/plugins/grok_test_helpers_test.go @@ -275,8 +275,7 @@ func legacyGrokCommand(t *testing.T, scriptPath string) string { t.Fatalf("resolve Node.js runtime: %v", err) } if runtime.GOOS == "windows" { - return quoteGrokLegacyWindowsArgument(nodePath) + " " + - quoteGrokLegacyWindowsArgument(scriptPath) + return `"` + nodePath + `" "` + scriptPath + `"` } return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) } diff --git a/cmd/elydora/plugins/grok_transaction_test.go b/cmd/elydora/plugins/grok_transaction_test.go index 7c600fa..cc9025f 100644 --- a/cmd/elydora/plugins/grok_transaction_test.go +++ b/cmd/elydora/plugins/grok_transaction_test.go @@ -59,7 +59,7 @@ func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(t *testing.T) } before := snapshotGrokFiles(t, paths...) fixture.plugin.rename = func(source, destination string) error { - if sameGrokPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Grok config failure") } @@ -85,11 +85,11 @@ func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) { if err != nil { t.Fatalf("preflight Grok installation: %v", err) } - guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath) if err != nil { t.Fatalf("build guard command: %v", err) } - auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath) if err != nil { t.Fatalf("build audit command: %v", err) } @@ -179,7 +179,7 @@ func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) { t.Fatalf("read installed Grok config: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameGrokPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Grok uninstall failure") } diff --git a/cmd/elydora/plugins/kimi.go b/cmd/elydora/plugins/kimi.go index 2cf4f6c..6da1afe 100644 --- a/cmd/elydora/plugins/kimi.go +++ b/cmd/elydora/plugins/kimi.go @@ -34,11 +34,11 @@ func (p *KimiPlugin) Install(config InstallConfig) error { if err != nil { return err } - guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath) if err != nil { return err } - auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath) if err != nil { return err } @@ -70,12 +70,9 @@ func (p *KimiPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeKimiChanges( - changes, - "Install Kimi hooks", - p.rename, - paths.runtimeRoot, - paths.agentDirectory, + if err := writeManagedChanges( + changes, "Install Kimi hooks", p.rename, + paths.runtimeRoot, paths.agentDirectory, "", "", ); err != nil { return err } @@ -107,13 +104,7 @@ func (p *KimiPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeKimiChanges( - changes, - "Uninstall Kimi hooks", - p.rename, - "", - "", - ) + return writeManagedChanges(changes, "Uninstall Kimi hooks", p.rename, "", "", "", "") } func (p *KimiPlugin) Status() (PluginStatus, error) { diff --git a/cmd/elydora/plugins/kimi_command.go b/cmd/elydora/plugins/kimi_command.go index 3a1771e..eced803 100644 --- a/cmd/elydora/plugins/kimi_command.go +++ b/cmd/elydora/plugins/kimi_command.go @@ -1,49 +1,10 @@ package plugins import ( - "fmt" "path/filepath" - "runtime" "strings" ) -type kimiRuntimeReference struct { - agentID string - scriptPath string -} - -func sameKimiPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameKimiAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func buildKimiCommand(runtimePath, scriptPath string) (string, error) { - if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths") - } - if runtime.GOOS == "windows" { - return encodedWindowsCommand(runtimePath, scriptPath), nil - } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil -} - func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) { if start >= len(command) { return "", start, false @@ -66,6 +27,7 @@ func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool return command[start:end], end, end > start } +// parseKimiLegacyWindowsCommand reads the pre-2.1 quoteWindowsArgument form. func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0) 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) { - if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok { - return runtimePath, scriptPath, true - } - if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok { + if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok { return runtimePath, scriptPath, true } return parseKimiLegacyWindowsCommand(command) } -func isKimiNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func kimiRuntimeReferenceForCommand( command string, scriptName string, -) (*kimiRuntimeReference, error) { +) (*managedScriptReference, error) { runtimePath, scriptPath, ok := parseKimiCommand(command) - if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName { + if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } diff --git a/cmd/elydora/plugins/kimi_contract.go b/cmd/elydora/plugins/kimi_contract.go index 61e4883..ac699b0 100644 --- a/cmd/elydora/plugins/kimi_contract.go +++ b/cmd/elydora/plugins/kimi_contract.go @@ -2,9 +2,7 @@ package plugins import ( "fmt" - "runtime" "sort" - "strings" ) const ( @@ -54,20 +52,10 @@ type kimiHook struct { } type kimiRuntimeContract struct { - agentID string - guardPath string - auditPath string + managedRuntimeContract 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{} { result := map[string]struct{}{} for _, set := range sets { @@ -173,7 +161,7 @@ func managedKimiReference( hook kimiHook, event string, scriptName string, -) (*kimiRuntimeReference, error) { +) (*managedScriptReference, error) { if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { return nil, nil @@ -196,7 +184,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { indices := make([]int, 0, len(hooks)) for index, hook := range hooks { event, scriptName, managedEvent := managedKimiEvent(hook.event) - var reference *kimiRuntimeReference + var reference *managedScriptReference var err error if managedEvent { reference, err = managedKimiReference(hook, event, scriptName) @@ -205,7 +193,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { } } remove := reference != nil && - (agentID == "" || sameKimiAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if !remove { indices = append(indices, index) } @@ -213,19 +201,12 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { return indices, nil } -func kimiReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func kimiReferencesForEvent( hooks []kimiHook, event string, scriptName string, -) (map[string][]kimiRuntimeReference, error) { - result := map[string][]kimiRuntimeReference{} +) (map[string][]managedScriptReference, error) { + result := map[string][]managedScriptReference{} for _, hook := range hooks { reference, err := managedKimiReference(hook, event, scriptName) if err != nil { @@ -234,7 +215,7 @@ func kimiReferencesForEvent( if reference == nil { continue } - key := kimiReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } return result, nil @@ -267,12 +248,15 @@ func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, erro success := successes[key] failure := failures[key] 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 } contracts = append(contracts, kimiRuntimeContract{ - agentID: guard[0].agentID, guardPath: guard[0].scriptPath, - auditPath: success[0].scriptPath, configPath: document.contract.configPath, + managedRuntimeContract: managedRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }, + configPath: document.contract.configPath, }) } } diff --git a/cmd/elydora/plugins/kimi_installation.go b/cmd/elydora/plugins/kimi_installation.go index 0d6cbd6..a7ccf73 100644 --- a/cmd/elydora/plugins/kimi_installation.go +++ b/cmd/elydora/plugins/kimi_installation.go @@ -2,107 +2,32 @@ package plugins import ( "bytes" - "encoding/json" "fmt" - "os" - "path/filepath" "strings" ) -type kimiRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - type kimiRenderedDocument struct { document kimiDocument next []byte } -func validateKimiInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != kimiAgentKey { - return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) { - if err := validateKimiInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &kimiRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, kimiGuardScript), - auditPath: filepath.Join(agentDirectory, kimiAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameKimiPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameKimiPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} - func preflightKimiInstallation( config InstallConfig, documents []kimiDocument, -) (*kimiRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if len(documents) == 0 { 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 { 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 } nodePath, err := resolveNodeRuntime() @@ -112,26 +37,9 @@ func preflightKimiInstallation( return paths, nodePath, nil } -func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": kimiAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil +func kimiExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(kimiAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(kimiAgentKey, agentID, "", false, true)) } func renderKimiChange( @@ -172,47 +80,21 @@ func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, erro func prepareKimiInstallationChanges( config InstallConfig, - paths *kimiRuntimePaths, + paths *managedRuntimePaths, rendered []kimiRenderedDocument, ) ([]*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 { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false, "")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - kimiAgentKey, config.AgentID, "", false, true, - )), 0700, - }, + documentChanges, err := prepareKimiUninstallChanges(rendered) + if err != nil { + return nil, err } - changes := make([]*fileChange, 0, len(items)+len(rendered)) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } - for _, document := range rendered { - change, err := prepareRenderedKimiChange(document) - if err != nil { - return nil, err - } - changes = append(changes, change) - } - return changes, nil + return append(changes, documentChanges...), nil } func prepareKimiUninstallChanges( @@ -228,31 +110,3 @@ func prepareKimiUninstallChanges( } 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) -} diff --git a/cmd/elydora/plugins/kimi_io.go b/cmd/elydora/plugins/kimi_io.go index 77a308f..452ae23 100644 --- a/cmd/elydora/plugins/kimi_io.go +++ b/cmd/elydora/plugins/kimi_io.go @@ -66,7 +66,7 @@ func resolveKimiContracts() ([]kimiContract, error) { legacyHome := filepath.Join(home, ".kimi") stable := stableKimiContract(filepath.Join(stableHome, "config.toml")) legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml")) - if sameKimiPath(stable.configPath, legacy.configPath) { + if sameManagedPath(stable.configPath, legacy.configPath) { return []kimiContract{stable}, nil } diff --git a/cmd/elydora/plugins/kimi_status.go b/cmd/elydora/plugins/kimi_status.go index 626440e..404f081 100644 --- a/cmd/elydora/plugins/kimi_status.go +++ b/cmd/elydora/plugins/kimi_status.go @@ -1,194 +1,12 @@ 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) { for _, contract := range contracts { - exists, err := kimiRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil + exists, err := managedRuntimeContractExists( + contract.managedRuntimeContract, kimiAgentKey, "Kimi", nil, + ) + if err != nil || exists { + return exists, err } } return false, nil diff --git a/cmd/elydora/plugins/kimi_transaction_test.go b/cmd/elydora/plugins/kimi_transaction_test.go index 22c999e..10c70f9 100644 --- a/cmd/elydora/plugins/kimi_transaction_test.go +++ b/cmd/elydora/plugins/kimi_transaction_test.go @@ -38,7 +38,7 @@ func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) { modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)), }) 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 os.Rename(source, destination) @@ -160,11 +160,11 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) { if err != nil { t.Fatalf("preflight Kimi installation: %v", err) } - guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath) if err != nil { t.Fatalf("build guard command: %v", err) } - auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath) if err != nil { t.Fatalf("build audit command: %v", err) } @@ -204,12 +204,9 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) { t.Fatalf("write concurrent source: %v", err) } - err = writeKimiChanges( - changes, - "Install Kimi hooks", - nil, - paths.runtimeRoot, - paths.agentDirectory, + err = writeManagedChanges( + changes, "Install Kimi hooks", nil, + paths.runtimeRoot, paths.agentDirectory, "", "", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) diff --git a/cmd/elydora/plugins/legacy_hook_entries.go b/cmd/elydora/plugins/legacy_hook_entries.go index 1e68030..bdce413 100644 --- a/cmd/elydora/plugins/legacy_hook_entries.go +++ b/cmd/elydora/plugins/legacy_hook_entries.go @@ -42,6 +42,18 @@ func extractPathFromNodeCommand(command string) string { 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 { entries, _ := hookArray.([]any) for _, entry := range entries { diff --git a/cmd/elydora/plugins/letta.go b/cmd/elydora/plugins/letta.go index 29cb1ec..951b337 100644 --- a/cmd/elydora/plugins/letta.go +++ b/cmd/elydora/plugins/letta.go @@ -1,16 +1,16 @@ package plugins import ( + "errors" "fmt" "os" "path/filepath" ) -// LettaPlugin manages the Elydora audit hook for Letta Code. -// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json. +// LettaPlugin merges Elydora hooks into ~/.letta/settings.json. type LettaPlugin struct{} -func (p *LettaPlugin) configPath() (string, error) { +func lettaConfigPath() (string, error) { home, err := os.UserHomeDir() if err != nil { 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 } +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 { scriptPath, err := hookScriptPath(config.AgentID) if err != nil { @@ -26,11 +36,9 @@ func (p *LettaPlugin) Install(config InstallConfig) error { if config.HookScript != "" { scriptPath = config.HookScript } - if err := GenerateHookScript(scriptPath, config); err != nil { return fmt.Errorf("generate hook script: %w", err) } - guardPath := config.GuardScriptPath if guardPath == "" { guardPath, err = guardScriptPath(config.AgentID) @@ -38,70 +46,21 @@ func (p *LettaPlugin) Install(config InstallConfig) error { return err } } - - configPath, err := p.configPath() + configPath, err := lettaConfigPath() if err != nil { return err } - settings, err := readJSONFile(configPath) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) + hooks, _ := settings["hooks"].(map[string]any) if hooks == nil { - hooks = make(map[string]interface{}) + hooks = map[string]any{} } - - // --- PreToolUse (guard — freeze enforcement) --- - 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 - + hooks["PreToolUse"] = append(withoutElydoraEntries(hooks["PreToolUse"]), lettaHookEntry(guardPath)) + hooks["PostToolUse"] = append(withoutElydoraEntries(hooks["PostToolUse"]), lettaHookEntry(scriptPath)) settings["hooks"] = hooks - if err := writeJSONFile(configPath, settings); err != nil { return err } @@ -110,74 +69,44 @@ func (p *LettaPlugin) Install(config InstallConfig) error { } func (p *LettaPlugin) Uninstall(agentID string) error { - configPath, err := p.configPath() + configPath, err := lettaConfigPath() if err != nil { return err } - settings, err := readJSONFile(configPath) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) + hooks, _ := settings["hooks"].(map[string]any) if hooks == nil { fmt.Println("No Letta Code hooks found.") return nil } - - // Remove PreToolUse Elydora entries - preToolUse, _ := hooks["PreToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } + for _, event := range []string{"PreToolUse", "PostToolUse"} { + kept := withoutElydoraEntries(hooks[event]) + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept } - preFiltered = append(preFiltered, entry) } - if len(preFiltered) == 0 { - delete(hooks, "PreToolUse") - } else { - hooks["PreToolUse"] = preFiltered - } - - // 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 { delete(settings, "hooks") } else { settings["hooks"] = hooks } - if err := writeJSONFile(configPath, settings); err != nil { return err } - if agentID != "" { - scriptPath, _ := hookScriptPath(agentID) - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath(agentID) - if gPath != "" { - os.Remove(gPath) + for _, resolve := range []func(string) (string, error){hookScriptPath, guardScriptPath} { + path, err := resolve(agentID) + 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) + } } } 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) { - configPath, err := p.configPath() + configPath, err := lettaConfigPath() if err != nil { return PluginStatus{}, err } - status := PluginStatus{ AgentName: "letta", DisplayName: "Letta Code", ConfigPath: configPath, } - settings, err := readJSONFile(configPath) if err != nil { + return status, err + } + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { return status, nil } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks != nil { - 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 { - status.HookScriptExists = true - } + status.HookConfigured = hasElydoraEntry(hooks["PreToolUse"]) && + hasElydoraEntry(hooks["PostToolUse"]) + if scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]); scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true } } - status.Installed = status.HookConfigured && status.HookScriptExists return status, nil } diff --git a/cmd/elydora/plugins/managed_changes.go b/cmd/elydora/plugins/managed_changes.go new file mode 100644 index 0000000..963d082 --- /dev/null +++ b/cmd/elydora/plugins/managed_changes.go @@ -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) +} diff --git a/cmd/elydora/plugins/managed_runtime.go b/cmd/elydora/plugins/managed_runtime.go index 685af02..bf582ea 100644 --- a/cmd/elydora/plugins/managed_runtime.go +++ b/cmd/elydora/plugins/managed_runtime.go @@ -1,9 +1,7 @@ package plugins import ( - "encoding/json" "fmt" - "os" "path/filepath" "runtime" "sort" @@ -30,6 +28,14 @@ type managedScriptReference struct { 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( "org_id", "agent_id", "kid", "base_url", "token", "agent_name", ) @@ -65,24 +71,28 @@ func managedReferenceKey(agentID string) string { return agentID } -// resolveManagedScript accepts absolute ~/.elydora// paths. -func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) { +// managedScriptWithin accepts absolute // paths. +func managedScriptWithin(scriptPath, scriptName, runtimeRoot string) *managedScriptReference { if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) { - return nil, nil + return nil } 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() if err != nil { return nil, err } - if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}, nil + return managedScriptWithin(scriptPath, scriptName, runtimeRoot), nil } func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error { @@ -401,89 +411,3 @@ func managedRuntimeFilesPresent(contracts []managedRuntimeContract, agentKey str } 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) -} diff --git a/cmd/elydora/plugins/qwen.go b/cmd/elydora/plugins/qwen.go index 51679a1..c21c067 100644 --- a/cmd/elydora/plugins/qwen.go +++ b/cmd/elydora/plugins/qwen.go @@ -73,7 +73,7 @@ func (p *QwenPlugin) Uninstall(agentID string) error { if err != nil { return err } - runtimeRoot, err := qwenRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return err } @@ -104,7 +104,7 @@ func (p *QwenPlugin) Status() (PluginStatus, error) { return status, err } status.ConfigPath = sources.user.filePath - runtimeRoot, err := qwenRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return status, err } diff --git a/cmd/elydora/plugins/qwen_command.go b/cmd/elydora/plugins/qwen_command.go deleted file mode 100644 index 561b6e1..0000000 --- a/cmd/elydora/plugins/qwen_command.go +++ /dev/null @@ -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 -} diff --git a/cmd/elydora/plugins/qwen_contract.go b/cmd/elydora/plugins/qwen_contract.go index a6656d9..a6d12a1 100644 --- a/cmd/elydora/plugins/qwen_contract.go +++ b/cmd/elydora/plugins/qwen_contract.go @@ -4,7 +4,6 @@ import ( "path/filepath" "runtime" "sort" - "strings" ) const ( @@ -40,12 +39,6 @@ type qwenManagedRemoval struct { removeGroup bool } -type qwenRuntimeContract struct { - agentID string - guardPath string - auditPath string -} - func qwenExpectedShell() string { if runtime.GOOS == "windows" { return "powershell" @@ -58,41 +51,52 @@ func buildQwenGroup(nodePath, scriptPath, name string) map[string]any { "hooks": []any{map[string]any{ "type": "command", "name": name, - "command": buildQwenCommand(nodePath, scriptPath), + "command": buildShellCommand(nodePath, scriptPath), "shell": qwenExpectedShell(), "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( handler map[string]any, scriptName, hookName, runtimeRoot string, -) (qwenRuntimeReference, bool) { +) *managedScriptReference { if len(handler) != 5 || handler["type"] != "command" || handler["name"] != hookName || handler["shell"] != qwenExpectedShell() || handler["timeout"] != qwenHookTimeout { - return qwenRuntimeReference{}, false + return nil } command, ok := handler["command"].(string) if !ok { - return qwenRuntimeReference{}, false + return nil } return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot) } +// legacyQwenReference reads the pre-2.1 unnamed handler. func legacyQwenReference( handler map[string]any, scriptName, runtimeRoot string, -) (qwenRuntimeReference, bool) { +) *managedScriptReference { if len(handler) != 4 || handler["type"] != "command" || handler["shell"] != qwenExpectedShell() || handler["timeout"] != qwenHookTimeout { - return qwenRuntimeReference{}, false + return nil } command, ok := handler["command"].(string) if !ok { - return qwenRuntimeReference{}, false + return nil } return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot) } @@ -101,19 +105,19 @@ func managedQwenReference( handler map[string]any, scriptName, hookName, runtimeRoot string, includeLegacy bool, -) (qwenRuntimeReference, bool) { - if reference, ok := currentQwenReference( +) *managedScriptReference { + if reference := currentQwenReference( handler, scriptName, hookName, runtimeRoot, - ); ok { - return reference, true + ); reference != nil { + return reference } if includeLegacy { return legacyQwenReference(handler, scriptName, runtimeRoot) } - return qwenRuntimeReference{}, false + return nil } func exactCurrentQwenGroup(group map[string]any) bool { @@ -138,14 +142,15 @@ func managedQwenRemovals( handlers := group["hooks"].([]any) indexes := make([]int, 0) for handlerIndex, handlerValue := range handlers { - reference, managed := managedQwenReference( + reference := managedQwenReference( handlerValue.(map[string]any), contract.script, contract.name, runtimeRoot, true, ) - if managed && (agentID == "" || sameQwenAgentID(reference.agentID, agentID)) { + if reference != nil && + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) { indexes = append(indexes, handlerIndex) } } @@ -163,18 +168,11 @@ func managedQwenRemovals( return removals } -func normalizedQwenAgentID(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func qwenReferencesForEvent( settings qwenHookSettings, event, scriptName, hookName, runtimeRoot string, -) map[string][]qwenRuntimeReference { - references := map[string][]qwenRuntimeReference{} +) map[string][]managedScriptReference { + references := map[string][]managedScriptReference{} groups, _ := settings[event].([]any) for _, groupValue := range groups { group := groupValue.(map[string]any) @@ -182,17 +180,17 @@ func qwenReferencesForEvent( continue } for _, handlerValue := range group["hooks"].([]any) { - reference, ok := currentQwenReference( + reference := currentQwenReference( handlerValue.(map[string]any), scriptName, hookName, runtimeRoot, ) - if !ok { + if reference == nil { continue } - key := normalizedQwenAgentID(reference.agentID) - references[key] = append(references[key], reference) + key := managedReferenceKey(reference.agentID) + references[key] = append(references[key], *reference) } } return references @@ -201,7 +199,7 @@ func qwenReferencesForEvent( func qwenRuntimeContracts( settings qwenHookSettings, runtimeRoot string, -) []qwenRuntimeContract { +) []managedRuntimeContract { guards := qwenReferencesForEvent( settings, "PreToolUse", @@ -223,31 +221,23 @@ func qwenRuntimeContracts( qwenAuditHookName, runtimeRoot, ) - contracts := make([]qwenRuntimeContract, 0) + contracts := make([]managedRuntimeContract, 0) for key, guard := range guards { post := posts[key] failure := failures[key] 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 } - contracts = append(contracts, qwenRuntimeContract{ + contracts = append(contracts, managedRuntimeContract{ agentID: guard[0].agentID, guardPath: guard[0].scriptPath, auditPath: post[0].scriptPath, }) } sort.Slice(contracts, func(left, right int) bool { - return normalizedQwenAgentID(contracts[left].agentID) < - normalizedQwenAgentID(contracts[right].agentID) + return managedReferenceKey(contracts[left].agentID) < + managedReferenceKey(contracts[right].agentID) }) return contracts } - -func qwenRuntimeRoot() (string, error) { - return AgentRuntimeRoot() -} - -func qwenContractDirectory(contract qwenRuntimeContract) string { - return filepath.Dir(contract.guardPath) -} diff --git a/cmd/elydora/plugins/qwen_installation.go b/cmd/elydora/plugins/qwen_installation.go index 6cfd2e5..583aa96 100644 --- a/cmd/elydora/plugins/qwen_installation.go +++ b/cmd/elydora/plugins/qwen_installation.go @@ -1,158 +1,45 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type qwenRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} +import "fmt" type preparedQwenInstallation struct { changes []*fileChange preconditions []filePrecondition - paths *qwenRuntimePaths -} - -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 + paths *managedRuntimePaths } func preflightQwenInstallation( config InstallConfig, sources *qwenSources, -) (*qwenRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if sources == nil || sources.user == nil || sources.user.filePath == "" { return nil, "", fmt.Errorf("Qwen Code installation requires user settings") } if err := requireQwenHooksEnabled(sources); err != nil { 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 { 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 } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Qwen Code") if err != nil { 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 } -func buildQwenRuntimeConfig(config InstallConfig) ([]byte, error) { - encoded, err := json.MarshalIndent(qwenRuntimeConfig{ - OrgID: config.OrgID, - 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 qwenExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(qwenAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(qwenAgentKey, agentID, "", false, true)) } func prepareRenderedQwenChange( @@ -180,7 +67,7 @@ func qwenReadOnlyPreconditions( } result := make([]filePrecondition, 0, len(sources.preconditions)) for _, condition := range sources.preconditions { - if changedPath != "" && sameQwenPath(condition.filePath, changedPath) { + if changedPath != "" && sameManagedPath(condition.filePath, changedPath) { continue } result = append(result, condition) @@ -198,47 +85,16 @@ func prepareQwenInstallation( return nil, err } 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") } - runtimeConfig, err := buildQwenRuntimeConfig(config) + guardScript, auditScript := qwenExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, paths, qwenAgentKey, string(guardScript), string(auditScript), + ) if err != nil { 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) if err != nil { return nil, err diff --git a/cmd/elydora/plugins/qwen_io.go b/cmd/elydora/plugins/qwen_io.go deleted file mode 100644 index da132b2..0000000 --- a/cmd/elydora/plugins/qwen_io.go +++ /dev/null @@ -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 -} diff --git a/cmd/elydora/plugins/qwen_recovery_test.go b/cmd/elydora/plugins/qwen_recovery_test.go index be917d4..637b4a2 100644 --- a/cmd/elydora/plugins/qwen_recovery_test.go +++ b/cmd/elydora/plugins/qwen_recovery_test.go @@ -244,7 +244,7 @@ func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { original := readQwenTestFile(t, fixture.configPath) failed := false fixture.plugin.rename = func(source, destination string) error { - if !failed && sameQwenPath(destination, fixture.configPath) { + if !failed && sameManagedPath(destination, fixture.configPath) { failed = true return errors.New("simulated settings commit failure") } diff --git a/cmd/elydora/plugins/qwen_sources.go b/cmd/elydora/plugins/qwen_sources.go index d23fa24..6be9fae 100644 --- a/cmd/elydora/plugins/qwen_sources.go +++ b/cmd/elydora/plugins/qwen_sources.go @@ -4,10 +4,13 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "strings" ) +var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`) + var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"} type qwenDisableControl struct { @@ -154,7 +157,7 @@ func resolveQwenRouting() (*qwenRoutingResult, error) { if resolveErr != nil { return nil, resolveErr } - if !sameQwenPath(discoveredDirectory, initialDirectory) { + if !sameManagedPath(discoveredDirectory, initialDirectory) { if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil { return nil, err } diff --git a/cmd/elydora/plugins/qwen_sources_test.go b/cmd/elydora/plugins/qwen_sources_test.go index 0e14c9c..db07ad9 100644 --- a/cmd/elydora/plugins/qwen_sources_test.go +++ b/cmd/elydora/plugins/qwen_sources_test.go @@ -101,12 +101,12 @@ func TestQwenRoutingReadsDiscoveredHomeEnvironment(t *testing.T) { if err != nil { t.Fatalf("resolve Qwen routing: %v", err) } - if !sameQwenPath(routing.qwenHome, discovered) { + if !sameManagedPath(routing.qwenHome, discovered) { t.Fatalf("Qwen home = %s", routing.qwenHome) } found := false for _, condition := range routing.preconditions { - found = found || sameQwenPath(condition.filePath, discoveredEnv) + found = found || sameManagedPath(condition.filePath, discoveredEnv) } if !found { t.Fatalf("discovered environment is absent from preconditions: %#v", routing.preconditions) @@ -127,7 +127,7 @@ func TestQwenRoutingSkipsDotenvWhenBothEnvironmentValuesExist(t *testing.T) { if err != nil { 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) } } diff --git a/cmd/elydora/plugins/qwen_status.go b/cmd/elydora/plugins/qwen_status.go new file mode 100644 index 0000000..661e55f --- /dev/null +++ b/cmd/elydora/plugins/qwen_status.go @@ -0,0 +1,5 @@ +package plugins + +func qwenRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, qwenAgentKey, "Qwen Code", qwenExpectedScripts) +} diff --git a/cmd/elydora/plugins/qwen_test.go b/cmd/elydora/plugins/qwen_test.go index 7b608af..9b6b0f7 100644 --- a/cmd/elydora/plugins/qwen_test.go +++ b/cmd/elydora/plugins/qwen_test.go @@ -153,7 +153,7 @@ func legacyQwenGroupForTest( "matcher": "*", "hooks": []any{map[string]any{ "type": "command", - "command": buildQwenCommand(nodePath, scriptPath), + "command": buildShellCommand(nodePath, scriptPath), "shell": qwenExpectedShell(), "timeout": qwenHookTimeout, }}, diff --git a/cmd/elydora/plugins/qwen_test_helpers_test.go b/cmd/elydora/plugins/qwen_test_helpers_test.go index a98c11c..f3240a6 100644 --- a/cmd/elydora/plugins/qwen_test_helpers_test.go +++ b/cmd/elydora/plugins/qwen_test_helpers_test.go @@ -211,8 +211,8 @@ func qwenManagedGroup( for _, handlerValue := range requireQwenArray(t, group["hooks"]) { handler := requireQwenObject(t, handlerValue) command, _ := handler["command"].(string) - _, candidate, managed := parseQwenCommand(command) - if managed && handler["name"] == expectedName && sameQwenPath(candidate, scriptPath) { + _, candidate, managed := parseShellCommand(command) + if managed && handler["name"] == expectedName && sameManagedPath(candidate, scriptPath) { return group } } @@ -231,8 +231,8 @@ func qwenManagedHandler( for _, handlerValue := range requireQwenArray(t, group["hooks"]) { handler := requireQwenObject(t, handlerValue) command, _ := handler["command"].(string) - _, candidate, managed := parseQwenCommand(command) - if managed && sameQwenPath(candidate, scriptPath) { + _, candidate, managed := parseShellCommand(command) + if managed && sameManagedPath(candidate, scriptPath) { return handler } } diff --git a/cmd/elydora/plugins/shell_command.go b/cmd/elydora/plugins/shell_command.go index 9690ce6..b11afed 100644 --- a/cmd/elydora/plugins/shell_command.go +++ b/cmd/elydora/plugins/shell_command.go @@ -196,3 +196,57 @@ func parseEncodedWindowsCommand(command string) (string, string, bool) { } 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") +}