diff --git a/docs/past-failures.md b/docs/past-failures.md index c4f5cae..15debb0 100644 --- a/docs/past-failures.md +++ b/docs/past-failures.md @@ -6,6 +6,12 @@ - Cause: the node daemon reused the server session id as the ACPX session name, but a stale ACPX session could exist without the backing ACP metadata OpenClaw needs to initialize. - Mitigation: the Go ACPX runner now treats that exact metadata-missing failure as recoverable both during `sessions ensure` and during the first prompt run, recreates the named ACPX session once with `sessions new --name `, and retries. Regression tests cover the ensure path and the OpenClaw prompt-time failure shape. +## 2026-05-13: OpenClaw executable detection could accept a broken wrapper + +- Symptom: `openclaw` was present on `PATH`, but `acpx openclaw` failed before ACP `initialize` because the first executable was a wrapper that depended on unavailable local state. +- Cause: detection treated executable presence as enough, while OpenClaw's ACPX target needs `openclaw acp` to run as a clean stdio ACP server from the daemon environment. +- Mitigation: OpenClaw detection now probes every `openclaw` executable directory on `PATH` with ACPX `sessions ensure`, persists the first PATH ordering that initializes successfully, and omits OpenClaw if none can start ACP. A regression test covers a broken wrapper before a working executable. + ## 2026-05-12: Installer rejected valid Node 24 runtimes - Symptom: remote bootstrap failed early with `could not determine Node.js major version` even though `node -v` reported `v24.13.1`. diff --git a/internal/app/app.go b/internal/app/app.go index 8278465..3ae7bf8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -250,13 +250,21 @@ func detectAgents(ctx context.Context, runner acpx.Runner) []nodeconfig.AgentCon detectedCandidates := detectInstalledAgents(candidates) agents := make([]nodeconfig.AgentConfig, 0, len(candidates)) for _, candidate := range detectedCandidates { + env := detectedAgentEnv(candidate) + if candidate.ACPXAgent == "openclaw" { + var ok bool + env, ok = verifiedOpenClawEnv(ctx, runner, env) + if !ok { + continue + } + } agent := nodeconfig.AgentConfig{ ID: candidate.ID, Name: candidate.Name, ACPXAgent: candidate.ACPXAgent, Command: defaultACPXCommand(), Args: []string{}, - Env: detectedAgentEnv(candidate), + Env: env, Labels: []string{"detected"}, } agents = append(agents, agent) @@ -264,6 +272,62 @@ func detectAgents(ctx context.Context, runner acpx.Runner) []nodeconfig.AgentCon return agents } +func verifiedOpenClawEnv(ctx context.Context, runner acpx.Runner, fallback map[string]string) (map[string]string, bool) { + candidateDirs := openClawPathDirs() + if len(candidateDirs) == 0 { + return nil, false + } + + baseEntries := filepath.SplitList(os.Getenv("PATH")) + nodeDirs := lookPathDir("node") + for _, dir := range candidateDirs { + pathEntries := uniquePathEntries([]string{dir}, nodeDirs, baseEntries) + env := map[string]string{ + "PATH": strings.Join(pathEntries, string(os.PathListSeparator)), + } + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + err := runner.Ensure(probeCtx, acpx.RunRequest{ + Command: defaultACPXCommand(), + Agent: "openclaw", + Session: "amesh-detect-openclaw", + Env: envList(env), + }) + cancel() + if err == nil { + return env, true + } + log.Printf("openclaw ACP readiness probe failed for PATH prefix %s: %v", dir, err) + } + + if len(fallback) > 0 { + return fallback, false + } + return nil, false +} + +func openClawPathDirs() []string { + seen := map[string]struct{}{} + dirs := make([]string, 0) + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + path := filepath.Join(dir, "openclaw") + info, err := os.Stat(path) + if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { + continue + } + clean := filepath.Clean(dir) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + dirs = append(dirs, clean) + } + return dirs +} + func detectedAgentEnv(candidate detectableAgent) map[string]string { pathEntries := uniquePathEntries( filepath.SplitList(os.Getenv("PATH")), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 2ba6ffe..d2161e3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -554,6 +555,55 @@ exit 1 } } +func TestDetectAgentsVerifiesOpenClawACPReadinessAcrossPathCandidates(t *testing.T) { + home := t.TempDir() + badDir := filepath.Join(t.TempDir(), "bad-bin") + goodDir := filepath.Join(t.TempDir(), "good-bin") + nodeDir := filepath.Join(t.TempDir(), "node-bin") + t.Setenv("HOME", home) + t.Setenv("PATH", strings.Join([]string{badDir, goodDir, nodeDir}, string(os.PathListSeparator))) + t.Setenv("AMESH_ACPX_PATH", "") + + managed := filepath.Join(home, ".local", "share", "amesh", "acpx", "bin", "acpx") + writeExecutable(t, managed, fmt.Sprintf(`#!/bin/sh +if [ "$1" = "--help" ]; then +cat <<'EOF' +Commands: + openclaw [options] [prompt...] Use openclaw agent +EOF +exit 0 +fi +if [ "$1" = "openclaw" ] && [ "$2" = "sessions" ] && [ "$3" = "ensure" ]; then + if [ "$(command -v openclaw)" = "%s/openclaw" ]; then + exit 0 + fi + echo "ACP agent exited before initialize completed: wrapper unavailable" >&2 + exit 1 +fi +exit 1 +`, goodDir)) + writeExecutable(t, filepath.Join(nodeDir, "node"), "#!/bin/sh\nexit 0\n") + writeExecutable(t, filepath.Join(badDir, "openclaw"), "#!/bin/sh\nexit 1\n") + writeExecutable(t, filepath.Join(goodDir, "openclaw"), "#!/bin/sh\nexit 0\n") + + got := detectAgents(context.Background(), acpx.Runner{}) + wantPath := strings.Join([]string{goodDir, nodeDir, badDir}, string(os.PathListSeparator)) + want := []nodeconfig.AgentConfig{ + { + ID: "agent-openclaw", + Name: "OpenClaw", + ACPXAgent: "openclaw", + Command: managed, + Args: []string{}, + Env: map[string]string{"PATH": wantPath}, + Labels: []string{"detected"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("detectAgents() = %#v, want %#v", got, want) + } +} + type fakeDaemonClient struct { mu sync.Mutex connectErr error