From 78cb95e2884b897c23512b598f2b7dde67872856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 16:53:34 -0400 Subject: [PATCH] fix(go-cli): confine agent runtime paths --- sdks/go/README.md | 2 + sdks/go/cmd/elydora/main.go | 75 ++------- sdks/go/cmd/elydora/main_test.go | 136 +++++++++++++++ sdks/go/cmd/elydora/plugins/droid.go | 9 +- sdks/go/cmd/elydora/plugins/hooktemplate.go | 10 +- sdks/go/cmd/elydora/plugins/jsonutil.go | 12 +- sdks/go/cmd/elydora/plugins/qwen.go | 9 +- sdks/go/cmd/elydora/plugins/runtime_paths.go | 143 ++++++++++++++++ .../cmd/elydora/plugins/runtime_paths_test.go | 111 ++++++++++++ sdks/go/cmd/elydora/runtime_discovery.go | 159 ++++++++++++++++++ 10 files changed, 588 insertions(+), 78 deletions(-) create mode 100644 sdks/go/cmd/elydora/main_test.go create mode 100644 sdks/go/cmd/elydora/plugins/runtime_paths.go create mode 100644 sdks/go/cmd/elydora/plugins/runtime_paths_test.go create mode 100644 sdks/go/cmd/elydora/runtime_discovery.go diff --git a/sdks/go/README.md b/sdks/go/README.md index 82a84ac..31f5467 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -60,6 +60,8 @@ func main() { The SDK includes a CLI for installing audit hooks into AI coding agents. +Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID. + ```bash go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest diff --git a/sdks/go/cmd/elydora/main.go b/sdks/go/cmd/elydora/main.go index 87dc1c6..55e8ff5 100644 --- a/sdks/go/cmd/elydora/main.go +++ b/sdks/go/cmd/elydora/main.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "flag" "fmt" "os" @@ -12,7 +11,7 @@ import ( "github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins" ) -const version = "1.1.0" +const version = "1.2.1" func main() { if len(os.Args) < 2 { @@ -92,16 +91,13 @@ func cmdInstall(args []string) { } // Generate guard script (PreToolUse — freeze enforcement) - guardScriptPath, err := guardScriptPathForAgent(*agentID) + agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + guardScriptPath := filepath.Join(agentDirectory, "guard.js") guardScript := plugins.GenerateGuardScript(*agent, *agentID) - if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err) - os.Exit(1) - } if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil { fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err) os.Exit(1) @@ -149,13 +145,14 @@ func cmdUninstall(args []string) { os.Exit(1) } - // Resolve agentID: if not given, scan ~/.elydora/*/config.json for matching agent_name - resolvedAgentID := *agentID + resolvedAgentID, agentDirectory, agentDirectoryExists, err := resolveAgentRuntimeForUninstall(*agent, *agentID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } if resolvedAgentID == "" { - resolvedAgentID = findAgentIDByName(*agent) - if resolvedAgentID == "" { - fmt.Fprintf(os.Stderr, "Warning: could not find agent ID for %q in ~/.elydora/*/config.json\n", *agent) - } + fmt.Fprintf(os.Stderr, "Error: could not find agent ID for %q in ~/.elydora/*/config.json; pass --agent-id explicitly\n", *agent) + os.Exit(1) } if err := plugin.Uninstall(resolvedAgentID); err != nil { @@ -164,51 +161,15 @@ func cmdUninstall(args []string) { } // Remove the per-agent directory - if resolvedAgentID != "" { - home, err := os.UserHomeDir() - if err == nil { - agentDir := filepath.Join(home, ".elydora", resolvedAgentID) - if err := os.RemoveAll(agentDir); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to remove agent directory %s: %v\n", agentDir, err) - } else { - fmt.Printf(" Removed agent directory: %s\n", agentDir) - } + if agentDirectoryExists { + if err := os.RemoveAll(agentDirectory); err != nil { + fmt.Fprintf(os.Stderr, "Error removing agent directory %s: %v\n", agentDirectory, err) + os.Exit(1) } + fmt.Printf(" Removed agent directory: %s\n", agentDirectory) } } -// findAgentIDByName scans ~/.elydora/*/config.json for a config whose agent_name -// matches the given name and returns the directory name (agent ID). -func findAgentIDByName(agentName string) string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - elydoraDir := filepath.Join(home, ".elydora") - entries, err := os.ReadDir(elydoraDir) - if err != nil { - return "" - } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - configPath := filepath.Join(elydoraDir, entry.Name(), "config.json") - data, err := os.ReadFile(configPath) - if err != nil { - continue - } - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - continue - } - if name, _ := config["agent_name"].(string); name == agentName { - return entry.Name() - } - } - return "" -} - // --------------------------------------------------------------------------- // status // --------------------------------------------------------------------------- @@ -285,9 +246,9 @@ func sortedAgentNames() []string { // guardScriptPathForAgent returns the path to ~/.elydora//guard.js. func guardScriptPathForAgent(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "guard.js"), nil + return filepath.Join(agentDirectory, "guard.js"), nil } diff --git a/sdks/go/cmd/elydora/main_test.go b/sdks/go/cmd/elydora/main_test.go new file mode 100644 index 0000000..00d290f --- /dev/null +++ b/sdks/go/cmd/elydora/main_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setMainTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) { + setMainTestHome(t) + _, err := guardScriptPathForAgent("../escape") + if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { + t.Fatalf("guardScriptPathForAgent() error = %v", err) + } +} + +func TestFindAgentIDByNameReturnsValidatedDirectoryIdentity(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "agent-1") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"opencode","agent_id":"agent-1"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + agentID, err := findAgentIDByName("opencode") + if err != nil { + t.Fatalf("findAgentIDByName() error = %v", err) + } + if agentID != "agent-1" { + t.Fatalf("agent ID = %q, want agent-1", agentID) + } +} + +func TestFindAgentIDByNameRejectsConfigDirectoryMismatch(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "stored-directory") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"opencode","agent_id":"different-agent"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "crosses its runtime directory") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsAmbiguousRuntimes(t *testing.T) { + home := setMainTestHome(t) + for _, agentID := range []string{"agent-1", "agent-2"} { + runtimeDirectory := filepath.Join(home, ".elydora", agentID) + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"opencode","agent_id":"` + agentID + `"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "multiple") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} + +func TestExplicitUninstallRejectsAgentOwnershipMismatch(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "agent-1") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"codex","agent_id":"agent-1"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, _, _, err := resolveAgentRuntimeForUninstall("opencode", "agent-1") + if err == nil || !strings.Contains(err.Error(), "belongs to") { + t.Fatalf("resolveAgentRuntimeForUninstall() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsLinkedRuntime(t *testing.T) { + home := setMainTestHome(t) + root := filepath.Join(home, ".elydora") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := t.TempDir() + link := filepath.Join(root, "agent-1") + if err := os.Symlink(target, link); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsLinkedConfig(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "agent-1") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := filepath.Join(t.TempDir(), "config.json") + config := []byte(`{"agent_name":"opencode","agent_id":"agent-1"}`) + if err := os.WriteFile(target, config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.Symlink(target, filepath.Join(runtimeDirectory, "config.json")); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/droid.go b/sdks/go/cmd/elydora/plugins/droid.go index 1cab914..8bf8172 100644 --- a/sdks/go/cmd/elydora/plugins/droid.go +++ b/sdks/go/cmd/elydora/plugins/droid.go @@ -153,14 +153,15 @@ func (p *DroidPlugin) Status() (PluginStatus, error) { } func droidAgentDirectory(agentID string) (string, string, error) { - if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { - return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") - } runtimeRoot, err := droidRuntimeRoot() if err != nil { return "", "", err } - return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", err + } + return runtimeRoot, agentDirectory, nil } func droidSettingsDocument(settings *droidDocument) *droidDocument { diff --git a/sdks/go/cmd/elydora/plugins/hooktemplate.go b/sdks/go/cmd/elydora/plugins/hooktemplate.go index bf3be2d..f89303c 100644 --- a/sdks/go/cmd/elydora/plugins/hooktemplate.go +++ b/sdks/go/cmd/elydora/plugins/hooktemplate.go @@ -12,7 +12,7 @@ import ( // It always exits 0 so it never blocks the host agent. func GenerateHookScript(destPath string, config InstallConfig) error { dir := filepath.Dir(destPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := EnsurePrivateDirectory(dir); err != nil { return fmt.Errorf("create hook script directory: %w", err) } @@ -32,13 +32,9 @@ func GenerateHookScript(destPath string, config InstallConfig) error { // ~/.elydora//config.json and ~/.elydora//private.key so that // the generated hook script can read them at runtime without embedding secrets. func writeAgentConfig(config InstallConfig) error { - home, err := os.UserHomeDir() + agentDir, err := PrepareAgentRuntimeDirectory(config.AgentID) if err != nil { - return fmt.Errorf("resolve home directory: %w", err) - } - agentDir := filepath.Join(home, ".elydora", config.AgentID) - if err := os.MkdirAll(agentDir, 0755); err != nil { - return fmt.Errorf("create agent directory: %w", err) + return err } baseURL := config.BaseURL diff --git a/sdks/go/cmd/elydora/plugins/jsonutil.go b/sdks/go/cmd/elydora/plugins/jsonutil.go index 5d017a9..729b391 100644 --- a/sdks/go/cmd/elydora/plugins/jsonutil.go +++ b/sdks/go/cmd/elydora/plugins/jsonutil.go @@ -55,18 +55,18 @@ func writeJSONFile(path string, data map[string]interface{}) error { // hookScriptPath returns the default path for the hook script inside ~/.elydora//. func hookScriptPath(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "hook.js"), nil + return filepath.Join(agentDirectory, "hook.js"), nil } // guardScriptPath returns the default path for the guard script inside ~/.elydora//. func guardScriptPath(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "guard.js"), nil + return filepath.Join(agentDirectory, "guard.js"), nil } diff --git a/sdks/go/cmd/elydora/plugins/qwen.go b/sdks/go/cmd/elydora/plugins/qwen.go index 697c2aa..ab81eb8 100644 --- a/sdks/go/cmd/elydora/plugins/qwen.go +++ b/sdks/go/cmd/elydora/plugins/qwen.go @@ -119,14 +119,15 @@ func (p *QwenPlugin) Status() (PluginStatus, error) { } func qwenAgentDirectory(agentID string) (string, string, error) { - if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { - return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") - } runtimeRoot, err := qwenRuntimeRoot() if err != nil { return "", "", err } - return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", err + } + return runtimeRoot, agentDirectory, nil } func prepareQwenInstallationChanges( diff --git a/sdks/go/cmd/elydora/plugins/runtime_paths.go b/sdks/go/cmd/elydora/plugins/runtime_paths.go new file mode 100644 index 0000000..9ec7ef2 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/runtime_paths.go @@ -0,0 +1,143 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +var windowsDeviceName = regexp.MustCompile( + `(?i)^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)`, +) + +// AgentRuntimeRoot returns the local root for all Elydora agent runtimes. +func AgentRuntimeRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + root, err := filepath.Abs(filepath.Join(home, ".elydora")) + if err != nil { + return "", fmt.Errorf("resolve Elydora runtime root: %w", err) + } + return root, nil +} + +// ResolveAgentRuntimeDirectory confines an agent ID to one portable path segment. +func ResolveAgentRuntimeDirectory(agentID string) (string, error) { + if invalidAgentDirectoryName(agentID) { + return "", fmt.Errorf( + "invalid agent ID for local storage: %q must be a single non-empty path segment with a portable file name", + agentID, + ) + } + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + candidate := filepath.Join(root, agentID) + relative, err := filepath.Rel(root, candidate) + if err != nil { + return "", fmt.Errorf("resolve agent runtime path for %q: %w", agentID, err) + } + if relative == "." || relative == ".." || filepath.IsAbs(relative) || + strings.HasPrefix(relative, ".."+string(os.PathSeparator)) || + strings.ContainsRune(relative, os.PathSeparator) { + return "", fmt.Errorf("agent ID escapes the local storage directory: %q", agentID) + } + if _, err := RequirePhysicalDirectory(root); err != nil { + return "", err + } + if _, err := RequirePhysicalDirectory(candidate); err != nil { + return "", err + } + return candidate, nil +} + +func invalidAgentDirectoryName(agentID string) bool { + if agentID == "" || agentID == "." || agentID == ".." || + strings.ContainsAny(agentID, `<>:"/\|?*`) || + strings.HasPrefix(agentID, " ") || strings.HasSuffix(agentID, ".") || + strings.HasSuffix(agentID, " ") || + windowsDeviceName.MatchString(agentID) { + return true + } + for _, character := range agentID { + if character <= 31 { + return true + } + } + return false +} + +// EnsurePrivateDirectory creates an owner-only physical directory. +func EnsurePrivateDirectory(path string) error { + if err := os.MkdirAll(path, 0700); err != nil { + return fmt.Errorf("create private directory %s: %w", path, err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect private directory %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("private directory path is not a physical directory: %s", path) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0700); err != nil { + return fmt.Errorf("restrict private directory %s: %w", path, err) + } + } + return nil +} + +// PrepareAgentRuntimeDirectory validates and creates the private runtime path. +func PrepareAgentRuntimeDirectory(agentID string) (string, error) { + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", err + } + if err := EnsurePrivateDirectory(root); err != nil { + return "", err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return "", err + } + return agentDirectory, nil +} + +// RequirePhysicalDirectory reports whether a path is an existing physical directory. +func RequirePhysicalDirectory(path string) (bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect agent runtime directory %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("agent runtime path is not a physical directory: %s", path) + } + return true, nil +} + +// RequirePhysicalFile reports whether a path is an existing physical regular file. +func RequirePhysicalFile(path string) (bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect agent runtime file %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, fmt.Errorf("agent runtime config is not a physical file: %s", path) + } + return true, nil +} diff --git a/sdks/go/cmd/elydora/plugins/runtime_paths_test.go b/sdks/go/cmd/elydora/plugins/runtime_paths_test.go new file mode 100644 index 0000000..9c352e7 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/runtime_paths_test.go @@ -0,0 +1,111 @@ +package plugins + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func setRuntimeTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func TestResolveAgentRuntimeDirectoryRejectsUnsafePortableNames(t *testing.T) { + setRuntimeTestHome(t) + invalid := []string{ + "../escape", + `..\escape`, + "C:escape", + "agent.", + "agent ", + " agent", + "CON", + "COM¹.log", + ".", + "..", + } + for _, agentID := range invalid { + t.Run(agentID, func(t *testing.T) { + _, err := ResolveAgentRuntimeDirectory(agentID) + if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { + t.Fatalf("ResolveAgentRuntimeDirectory(%q) error = %v", agentID, err) + } + }) + } +} + +func TestPrepareAgentRuntimeDirectoryCreatesPrivatePhysicalChild(t *testing.T) { + home := setRuntimeTestHome(t) + agentDirectory, err := PrepareAgentRuntimeDirectory("agent-1") + if err != nil { + t.Fatalf("PrepareAgentRuntimeDirectory() error = %v", err) + } + want := filepath.Join(home, ".elydora", "agent-1") + if agentDirectory != want { + t.Fatalf("agent directory = %q, want %q", agentDirectory, want) + } + for _, directory := range []string{filepath.Dir(agentDirectory), agentDirectory} { + info, err := os.Lstat(directory) + if err != nil { + t.Fatalf("Lstat(%q) error = %v", directory, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("%q is not a physical directory", directory) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0700 { + t.Fatalf("%q mode = %o, want 700", directory, info.Mode().Perm()) + } + } +} + +func TestPrepareAgentRuntimeDirectoryRejectsSymlinkRoot(t *testing.T) { + home := setRuntimeTestHome(t) + target := t.TempDir() + root := filepath.Join(home, ".elydora") + if err := os.Symlink(target, root); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + _, err := PrepareAgentRuntimeDirectory("agent-1") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("PrepareAgentRuntimeDirectory() error = %v", err) + } +} + +func TestResolveAgentRuntimeDirectoryRejectsSymlinkChild(t *testing.T) { + home := setRuntimeTestHome(t) + root := filepath.Join(home, ".elydora") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := t.TempDir() + link := filepath.Join(root, "agent-1") + if err := os.Symlink(target, link); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + _, err := ResolveAgentRuntimeDirectory("agent-1") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err) + } +} + +func TestRequirePhysicalFileRejectsSymlink(t *testing.T) { + setRuntimeTestHome(t) + target := filepath.Join(t.TempDir(), "target.json") + if err := os.WriteFile(target, []byte("{}"), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + link := filepath.Join(t.TempDir(), "config.json") + if err := os.Symlink(target, link); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + _, err := RequirePhysicalFile(link) + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("RequirePhysicalFile() error = %v", err) + } +} diff --git a/sdks/go/cmd/elydora/runtime_discovery.go b/sdks/go/cmd/elydora/runtime_discovery.go new file mode 100644 index 0000000..f37b68b --- /dev/null +++ b/sdks/go/cmd/elydora/runtime_discovery.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins" +) + +type installedAgentRuntime struct { + agentID string + agentName string +} + +type runtimeIdentity struct { + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` +} + +func readInstalledAgentRuntime(directoryName string) (*installedAgentRuntime, error) { + agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(directoryName) + if err != nil { + return nil, err + } + exists, err := plugins.RequirePhysicalDirectory(agentDirectory) + if err != nil || !exists { + return nil, err + } + + configPath := filepath.Join(agentDirectory, "config.json") + configExists, err := plugins.RequirePhysicalFile(configPath) + if err != nil || !configExists { + return nil, err + } + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read agent config %s: %w", configPath, err) + } + var identity runtimeIdentity + if err := json.Unmarshal(data, &identity); err != nil { + return nil, fmt.Errorf("parse agent config %s: %w", configPath, err) + } + if identity.AgentID == "" || identity.AgentName == "" { + return nil, fmt.Errorf("agent config %s has an invalid runtime identity", configPath) + } + if identity.AgentID != directoryName { + return nil, fmt.Errorf("agent config %s crosses its runtime directory", configPath) + } + if _, err := plugins.ResolveAgentRuntimeDirectory(identity.AgentID); err != nil { + return nil, fmt.Errorf("validate agent config %s: %w", configPath, err) + } + return &installedAgentRuntime{ + agentID: identity.AgentID, agentName: identity.AgentName, + }, nil +} + +func discoverInstalledAgentRuntimes() ([]installedAgentRuntime, error) { + runtimeRoot, err := plugins.AgentRuntimeRoot() + if err != nil { + return nil, err + } + exists, err := plugins.RequirePhysicalDirectory(runtimeRoot) + if err != nil || !exists { + return nil, err + } + entries, err := os.ReadDir(runtimeRoot) + if err != nil { + return nil, fmt.Errorf("read Elydora runtime root %s: %w", runtimeRoot, err) + } + + runtimes := make([]installedAgentRuntime, 0, len(entries)) + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + return nil, fmt.Errorf( + "agent runtime path is not a physical directory: %s", + filepath.Join(runtimeRoot, entry.Name()), + ) + } + if !entry.IsDir() { + continue + } + runtime, err := readInstalledAgentRuntime(entry.Name()) + if err != nil { + return nil, err + } + if runtime != nil { + runtimes = append(runtimes, *runtime) + } + } + return runtimes, nil +} + +// findAgentIDByName returns one validated runtime identity for an agent name. +func findAgentIDByName(agentName string) (string, error) { + runtimes, err := discoverInstalledAgentRuntimes() + if err != nil { + return "", err + } + foundAgentID := "" + for _, runtime := range runtimes { + if runtime.agentName != agentName { + continue + } + if foundAgentID != "" { + return "", fmt.Errorf( + "multiple %q agent runtimes found; pass --agent-id explicitly", + agentName, + ) + } + foundAgentID = runtime.agentID + } + return foundAgentID, nil +} + +func resolveAgentRuntimeForUninstall( + agentName, explicitAgentID string, +) (agentID, agentDirectory string, directoryExists bool, err error) { + runtimeRoot, err := plugins.AgentRuntimeRoot() + if err != nil { + return "", "", false, err + } + runtimeRootExists, err := plugins.RequirePhysicalDirectory(runtimeRoot) + if err != nil { + return "", "", false, err + } + + agentID = explicitAgentID + if agentID == "" { + agentID, err = findAgentIDByName(agentName) + if err != nil || agentID == "" { + return agentID, "", false, err + } + } + agentDirectory, err = plugins.ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", false, err + } + if runtimeRootExists { + directoryExists, err = plugins.RequirePhysicalDirectory(agentDirectory) + if err != nil { + return "", "", false, err + } + } + if directoryExists { + runtime, err := readInstalledAgentRuntime(agentID) + if err != nil { + return "", "", false, err + } + if runtime != nil && runtime.agentName != agentName { + return "", "", false, fmt.Errorf( + "agent runtime %q belongs to %q, not %q", + agentID, runtime.agentName, agentName, + ) + } + } + return agentID, agentDirectory, directoryExists, nil +}