diff --git a/docs/local-dev.md b/docs/local-dev.md index ba2b644..b9ce910 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -57,7 +57,7 @@ sh -n scripts/install-amesh-node.sh - The published remote bootstrap path is `curl .../install-amesh-node.sh | ... bash`, so the installer must keep working when Bash reads it from stdin instead of from a file. - The installer now logs whether it is reusing or creating config/state, and on systemd hosts it fails the install if the user service does not remain active after startup. When that happens it prints both `systemctl --user status` and recent `journalctl --user -u amesh-node` output. - `install-amesh-node.sh` also normalizes `~/.acpx/config.json` so ACPX non-interactive health probes start from a valid baseline on first install. -- Detected agents now persist the registering shell's `PATH` into node config. This avoids later service-only regressions where a systemd user unit resolves a different `node` binary than the interactive shell that successfully ran the same agent CLI. +- Detected agents now persist the registering shell's `PATH` into node config and prepend the resolved executable directories for the detected agent CLI and `node`. This avoids later service-only regressions where a systemd user unit or an `fnm` multishell shim resolves a different or stale Node runtime than the interactive shell that successfully ran the same agent CLI. - ACP aliases for external clients can be served locally with `go run ./cmd/amesh acp `. The default alias registry is `~/.config/amesh/acp.json`: ```json diff --git a/docs/past-failures.md b/docs/past-failures.md index 309e942..a23cebd 100644 --- a/docs/past-failures.md +++ b/docs/past-failures.md @@ -113,6 +113,12 @@ - Consequence: a fresh node could advertise agents, yet the dashboard showed runtime errors like `/usr/bin/env: 'node': No such file or directory` or `toSorted is not a function` once the daemon tried to execute them. - Mitigation: detected agent configs now persist the working shell `PATH`, and the installer now fails fast unless `node` `22.x+` is available before it installs the daemon service. Covered by a Go detection test that asserts the saved agent env includes the original `PATH`. +## 2026-05-15: `fnm` multishell shims made saved agent PATH entries go stale + +- Symptom: daemon-side health probes failed with `/usr/bin/env: 'node': No such file or directory` even though detection succeeded in an `fnm` shell and the saved config already included `PATH`. +- Cause: detection persisted the shell's raw PATH order. In `fnm` environments that can put transient multishell shim directories ahead of the stable Node installation path, so later daemon runs reused a dead shim directory. +- Mitigation: detected agent env now prepends the resolved executable directories for both the agent CLI and `node`, then appends the original shell `PATH` as fallback. Covered by a Go regression test that simulates `fnm`-style symlink shims. + ## 2026-05-11: Node inventory had no lightweight way to express multiple working directories - The node config only described base agents, so a single machine could not advertise the same local agent across multiple useful workspaces without hand-editing duplicate agent entries. diff --git a/internal/app/app.go b/internal/app/app.go index 123a95b..b28442f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -326,7 +326,7 @@ func verifiedOpenClawEnv(ctx context.Context, runner acpx.Runner, fallback map[s } baseEntries := filepath.SplitList(os.Getenv("PATH")) - nodeDirs := lookPathDir("node") + nodeDirs := commandPathDirs("node") for _, dir := range candidateDirs { pathEntries := uniquePathEntries([]string{dir}, nodeDirs, baseEntries) env := map[string]string{ @@ -365,21 +365,22 @@ func openClawPathDirs() []string { if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { continue } - clean := filepath.Clean(dir) - if _, ok := seen[clean]; ok { - continue + for _, candidateDir := range executableDirs(path) { + if _, ok := seen[candidateDir]; ok { + continue + } + seen[candidateDir] = struct{}{} + dirs = append(dirs, candidateDir) } - seen[clean] = struct{}{} - dirs = append(dirs, clean) } return dirs } func detectedAgentEnv(candidate detectableAgent) map[string]string { pathEntries := uniquePathEntries( + commandPathDirs(candidate.ACPXAgent), + commandPathDirs("node"), filepath.SplitList(os.Getenv("PATH")), - lookPathDir(candidate.ACPXAgent), - lookPathDir("node"), ) if len(pathEntries) == 0 { return map[string]string{} @@ -389,7 +390,7 @@ func detectedAgentEnv(candidate detectableAgent) map[string]string { } } -func lookPathDir(command string) []string { +func commandPathDirs(command string) []string { if strings.TrimSpace(command) == "" { return nil } @@ -397,11 +398,35 @@ func lookPathDir(command string) []string { if err != nil { return nil } - dir := strings.TrimSpace(filepath.Dir(path)) - if dir == "" { + return executableDirs(path) +} + +func executableDirs(path string) []string { + path = strings.TrimSpace(path) + if path == "" { return nil } - return []string{dir} + + dirs := make([]string, 0, 2) + add := func(dir string) { + dir = strings.TrimSpace(dir) + if dir == "" { + return + } + dir = filepath.Clean(dir) + for _, existing := range dirs { + if existing == dir { + return + } + } + dirs = append(dirs, dir) + } + + if resolved, err := filepath.EvalSymlinks(path); err == nil { + add(filepath.Dir(resolved)) + } + add(filepath.Dir(path)) + return dirs } func uniquePathEntries(groups ...[]string) []string { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b2e7558..134bc29 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -638,6 +638,56 @@ exit 1 } } +func TestDetectAgentsPrefersResolvedExecutableDirsForFNMStyleShims(t *testing.T) { + home := t.TempDir() + shimDir := filepath.Join(t.TempDir(), "fnm-multishell") + stableDir := filepath.Join(t.TempDir(), "fnm-installation", "bin") + t.Setenv("HOME", home) + t.Setenv("PATH", shimDir) + t.Setenv("AMESH_ACPX_PATH", "") + + managed := filepath.Join(home, ".local", "share", "amesh", "acpx", "bin", "acpx") + writeExecutable(t, managed, `#!/bin/sh +if [ "$1" = "--help" ]; then +cat <<'EOF' +Commands: + codex [options] [prompt...] Use codex agent +EOF +exit 0 +fi +exit 1 +`) + writeExecutable(t, filepath.Join(stableDir, "node"), "#!/bin/sh\nexit 0\n") + writeExecutable(t, filepath.Join(stableDir, "codex"), "#!/bin/sh\nexit 0\n") + if err := os.MkdirAll(shimDir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", shimDir, err) + } + if err := os.Symlink(filepath.Join(stableDir, "node"), filepath.Join(shimDir, "node")); err != nil { + t.Fatalf("symlink node shim: %v", err) + } + if err := os.Symlink(filepath.Join(stableDir, "codex"), filepath.Join(shimDir, "codex")); err != nil { + t.Fatalf("symlink codex shim: %v", err) + } + + got := detectAgents(context.Background(), acpx.Runner{}) + want := []nodeconfig.AgentConfig{ + { + ID: "agent-codex", + Name: "Codex", + ACPXAgent: "codex", + Command: managed, + Args: []string{}, + Env: map[string]string{ + "PATH": strings.Join([]string{stableDir, shimDir}, string(os.PathListSeparator)), + }, + Labels: []string{"detected"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("detectAgents() = %#v, want %#v", got, want) + } +} + func TestDetectAgentsVerifiesOpenClawACPReadinessAcrossPathCandidates(t *testing.T) { home := t.TempDir() badDir := filepath.Join(t.TempDir(), "bad-bin")