From 08e355be0b1163bf1ecaa4363fa74eb9fe8e84fd Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 14 May 2026 19:27:57 +0800 Subject: [PATCH] MUL-2167: fix(daemon): resolve agent CLIs via login shell when daemon PATH misses them (#2620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): resolve agent CLIs via login shell when daemon PATH misses them GUI-launched daemons on macOS/Linux do not inherit the user's interactive shell PATH, so fnm/nvm/volta multishells and the Anthropic native installer silently disappear during onboarding even though `claude --version` works in Terminal. Fall back to `$SHELL -ilc` to ask the login shell for the canonical absolute path, then verify it with exec.LookPath before trusting it. Symlinks (fnm/nvm prefix dirs) are resolved while the helper shell is still alive so per-session paths get canonicalised before they vanish. Refs MUL-2167, multica-ai/multica#2512. Co-authored-by: multica-agent * fix(daemon): strip alias shadowing, harden timeout, lazy-resolve via login shell Three follow-ups from the PR #2620 review (Elon): 1. Alias shadowing — `command -v claude` in zsh/bash returns the alias definition, not the binary, and the absolute-path filter then rejects it. The script now `unalias`/`unset -f` the name before lookup so `command -v` falls through to the real PATH binary. This is the exact case behind #2512. 2. Hard timeout — `CommandContext` kills only the shell process. Rc files that background processes inheriting stdout (`direnv hook`, `nvm` shims, plain `&`) keep the pipe open and `cmd.Output()` would block for as long as the survivors live. `Cmd.WaitDelay` forcibly closes the pipes once the cap elapses, so total startup penalty is bounded by `timeout + waitDelay` regardless of rc-file content. 3. Lazy fallback — the resolver no longer runs on every daemon start. `getShellResolved` is `sync.Once`-guarded and only fires when a bare command name actually misses `exec.LookPath`. Users whose PATH already contains every agent never pay the rc-file load cost. Tests: - `TestResolveAgentsViaLoginShell_StripsAliasShadowing` — rc declares `alias fakeclaude=...`, real binary lives on PATH, resolver must return the binary, not the alias text. - `TestResolveAgentsViaLoginShell_HardTimeoutOnBackgroundedStdout` — rc backgrounds a 60s sleeper holding stdout; resolver must return inside `timeout + waitDelay + slack`, not 60s. - `TestLoadConfig_SkipsLoginShellWhenLookPathSucceeds` — when exec.LookPath finds every agent, SHELL (a marker-writing sentinel) must not be invoked. Co-authored-by: multica-agent --------- Co-authored-by: multica-agent --- server/internal/daemon/config.go | 338 +++++++++++++++++++++----- server/internal/daemon/config_test.go | 329 +++++++++++++++++++++++++ 2 files changed, 600 insertions(+), 67 deletions(-) diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 2f04e2e3e..76813a9fb 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -1,12 +1,14 @@ package daemon import ( + "context" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/mattn/go-shellwords" @@ -95,84 +97,88 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{}, err } - // Probe available agent CLIs + // Probe available agent CLIs. exec.LookPath is the primary path, but on + // macOS/Linux a GUI-launched daemon (Electron, Launchpad) does not + // inherit the user's interactive shell PATH — fnm/nvm/volta multishells, + // the Anthropic native installer prefix, and per-user npm prefixes all + // live in dirs that only get added to PATH by ~/.zshrc or ~/.bashrc. + // shellResolvedAgents asks the user's login shell, lazily on first miss, + // to resolve every standard agent name to its canonical absolute path, + // so we can find binaries the bare daemon process can't see. See + // resolveAgentsViaLoginShell for the details and constraints. + // + // Laziness matters: the happy path (every agent on the daemon's PATH or + // pinned to an explicit MULTICA_*_PATH) must not pay the cost of + // spawning the user's login shell — that touches their rc files and + // adds startup latency that scales with whatever they put in there. We + // only fork a shell when a bare command name actually missed LookPath. + var ( + shellResolveOnce sync.Once + shellResolved map[string]string + ) + getShellResolved := func() map[string]string { + shellResolveOnce.Do(func() { + shellResolved = resolveAgentsViaLoginShell(defaultAgentCommandNames) + }) + return shellResolved + } + probe := func(envVar, defaultCmd, modelEnv string) (AgentEntry, bool) { + cmd := envOrDefault(envVar, defaultCmd) + if _, err := exec.LookPath(cmd); err == nil { + return AgentEntry{ + Path: cmd, + Model: strings.TrimSpace(os.Getenv(modelEnv)), + }, true + } + // The shell fallback only rescues bare command names. An operator + // who pinned MULTICA_*_PATH to an absolute or relative path that + // doesn't exist should hard-miss, not silently get a different + // binary. + if strings.ContainsAny(cmd, "/\\") { + return AgentEntry{}, false + } + if path, ok := getShellResolved()[cmd]; ok { + return AgentEntry{ + Path: path, + Model: strings.TrimSpace(os.Getenv(modelEnv)), + }, true + } + return AgentEntry{}, false + } + agents := map[string]AgentEntry{} - claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude") - if _, err := exec.LookPath(claudePath); err == nil { - agents["claude"] = AgentEntry{ - Path: claudePath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")), - } + if e, ok := probe("MULTICA_CLAUDE_PATH", "claude", "MULTICA_CLAUDE_MODEL"); ok { + agents["claude"] = e } - codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex") - if _, err := exec.LookPath(codexPath); err == nil { - agents["codex"] = AgentEntry{ - Path: codexPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")), - } + if e, ok := probe("MULTICA_CODEX_PATH", "codex", "MULTICA_CODEX_MODEL"); ok { + agents["codex"] = e } - opencodePath := envOrDefault("MULTICA_OPENCODE_PATH", "opencode") - if _, err := exec.LookPath(opencodePath); err == nil { - agents["opencode"] = AgentEntry{ - Path: opencodePath, - Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")), - } + if e, ok := probe("MULTICA_OPENCODE_PATH", "opencode", "MULTICA_OPENCODE_MODEL"); ok { + agents["opencode"] = e } - openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw") - if _, err := exec.LookPath(openclawPath); err == nil { - agents["openclaw"] = AgentEntry{ - Path: openclawPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")), - } + if e, ok := probe("MULTICA_OPENCLAW_PATH", "openclaw", "MULTICA_OPENCLAW_MODEL"); ok { + agents["openclaw"] = e } - hermesPath := envOrDefault("MULTICA_HERMES_PATH", "hermes") - if _, err := exec.LookPath(hermesPath); err == nil { - agents["hermes"] = AgentEntry{ - Path: hermesPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_HERMES_MODEL")), - } + if e, ok := probe("MULTICA_HERMES_PATH", "hermes", "MULTICA_HERMES_MODEL"); ok { + agents["hermes"] = e } - geminiPath := envOrDefault("MULTICA_GEMINI_PATH", "gemini") - if _, err := exec.LookPath(geminiPath); err == nil { - agents["gemini"] = AgentEntry{ - Path: geminiPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")), - } + if e, ok := probe("MULTICA_GEMINI_PATH", "gemini", "MULTICA_GEMINI_MODEL"); ok { + agents["gemini"] = e } - piPath := envOrDefault("MULTICA_PI_PATH", "pi") - if _, err := exec.LookPath(piPath); err == nil { - agents["pi"] = AgentEntry{ - Path: piPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")), - } + if e, ok := probe("MULTICA_PI_PATH", "pi", "MULTICA_PI_MODEL"); ok { + agents["pi"] = e } - cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent") - if _, err := exec.LookPath(cursorPath); err == nil { - agents["cursor"] = AgentEntry{ - Path: cursorPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")), - } + if e, ok := probe("MULTICA_CURSOR_PATH", "cursor-agent", "MULTICA_CURSOR_MODEL"); ok { + agents["cursor"] = e } - copilotPath := envOrDefault("MULTICA_COPILOT_PATH", "copilot") - if _, err := exec.LookPath(copilotPath); err == nil { - agents["copilot"] = AgentEntry{ - Path: copilotPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")), - } + if e, ok := probe("MULTICA_COPILOT_PATH", "copilot", "MULTICA_COPILOT_MODEL"); ok { + agents["copilot"] = e } - kimiPath := envOrDefault("MULTICA_KIMI_PATH", "kimi") - if _, err := exec.LookPath(kimiPath); err == nil { - agents["kimi"] = AgentEntry{ - Path: kimiPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")), - } + if e, ok := probe("MULTICA_KIMI_PATH", "kimi", "MULTICA_KIMI_MODEL"); ok { + agents["kimi"] = e } - kiroPath := envOrDefault("MULTICA_KIRO_PATH", "kiro-cli") - if _, err := exec.LookPath(kiroPath); err == nil { - agents["kiro"] = AgentEntry{ - Path: kiroPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_KIRO_MODEL")), - } + if e, ok := probe("MULTICA_KIRO_PATH", "kiro-cli", "MULTICA_KIRO_MODEL"); ok { + agents["kiro"] = e } if len(agents) == 0 { return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, or kiro-cli and ensure it is on PATH") @@ -442,3 +448,201 @@ func shellArgsFromEnv(name string) ([]string, error) { } return args, nil } + +// defaultAgentCommandNames lists the command names the agent probe loop tries +// before any MULTICA_*_PATH override is applied. Kept in sync with the +// `probe(...)` calls in LoadConfig — the shell-fallback resolver uses this +// list to pre-fetch canonical paths for every known agent in a single shell +// invocation, instead of paying the cost-per-miss. +var defaultAgentCommandNames = []string{ + "claude", "codex", "opencode", "openclaw", "hermes", + "gemini", "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", +} + +// loginShellResolveTimeout caps how long the daemon will wait for the user's +// login shell to print canonical agent paths. A broken rc file should not +// block startup — if the shell takes longer than this, we proceed without +// shell-resolved fallbacks and the daemon falls back to the same behaviour +// it had before this code was added. +const loginShellResolveTimeout = 3 * time.Second + +// loginShellResolveWaitDelay is the hard cap that runs *after* +// loginShellResolveTimeout has elapsed and `CommandContext` has signalled the +// shell to exit. The context kills the shell process itself, but rc files in +// the wild routinely background things that inherit stdout (`nvm` shims, +// `direnv hook`, `eval $(starship init)`, plain `&`). Those survivors keep +// the stdout pipe open and `cmd.Output()` will block on EOF for as long as +// they live. Cmd.WaitDelay (Go 1.20+) forcibly closes the pipes and returns +// once this delay elapses, so the total daemon-startup penalty caused by a +// pathological rc file is bounded by `timeout + waitDelay`, not by however +// long the user's background processes happen to run. +const loginShellResolveWaitDelay = 2 * time.Second + +// supportedLoginShells limits which interpreters we will invoke via +// ` -ilc