Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>`. The default alias registry is `~/.config/amesh/acp.json`:

```json
Expand Down
6 changes: 6 additions & 0 deletions docs/past-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 37 additions & 12 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{}
Expand All @@ -389,19 +390,43 @@ func detectedAgentEnv(candidate detectableAgent) map[string]string {
}
}

func lookPathDir(command string) []string {
func commandPathDirs(command string) []string {
if strings.TrimSpace(command) == "" {
return nil
}
path, err := exec.LookPath(command)
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 {
Expand Down
50 changes: 50 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading