Files
multica/server/internal/daemon/agent_command_names_test.go
Eve 23923b4c3b fix(daemon): discover qodercli via the login shell (MUL-5524)
Qoder is a fully supported provider, but a GUI-launched daemon could never
detect it. Two gaps, both in agent discovery:

- probeAgentCLIs called resolveAgentExecutablePath directly for qoder instead
  of going through the shared probe() helper, so qoder was the only provider
  with no login-shell fallback. A daemon started from Finder/Launchpad (the
  apple.dmg desktop build) does not inherit the interactive shell PATH, so a
  qodercli in an npm global prefix or any ~/.zshrc-added dir stayed invisible
  no matter how often the daemon restarted.
- "qodercli" was missing from defaultAgentCommandNames, which is the only list
  cachedShellResolvedAgents asks the login shell about. Even with the fallback
  wired up, the resolver would not have looked for it.

TestDefaultAgentCommandNamesCoversAllProbes was supposed to catch exactly this,
but it parsed config.go for probe() calls and silently became a no-op when
probeAgentCLIs moved to agents_probe.go. It now parses agents_probe.go and
asserts it found at least one probe() per default command, so a future move
fails loudly instead of passing vacuously.

Pinned-path semantics are unchanged: an absolute/relative MULTICA_QODER_PATH
that does not exist stays a hard miss rather than silently resolving a
different binary.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 14:29:13 +08:00

166 lines
5.7 KiB
Go

package daemon
import (
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
)
// TestDefaultAgentCommandNamesCoversAllProbes guards the invariant documented
// on defaultAgentCommandNames: the shell-fallback resolver only pre-fetches
// canonical paths for the bare command names in that list, so every agent the
// probe loop tries must appear there. A GUI/Launchpad-started daemon does not
// inherit the interactive shell PATH, so an agent missing from this list is
// undetectable when its binary lives only on the login-shell PATH (e.g. an
// `npm install -g` global). This test parses the probe(...) calls so a new
// probe can't silently diverge from the fallback list.
//
// It parses agents_probe.go, which is where probeAgentCLIs now lives. It used
// to parse config.go and silently degraded into a no-op when the probe loop
// moved out of that file — which is how the missing "qodercli" entry survived
// (MUL-5524). probeSourceFile is asserted to actually contain probe() calls so
// a future move fails loudly instead of vacuously passing.
const probeSourceFile = "agents_probe.go"
func TestDefaultAgentCommandNamesCoversAllProbes(t *testing.T) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, probeSourceFile, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", probeSourceFile, err)
}
known := make(map[string]bool, len(defaultAgentCommandNames))
for _, name := range defaultAgentCommandNames {
known[name] = true
}
var missing []string
var probeCalls int
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
ident, ok := call.Fun.(*ast.Ident)
if !ok || ident.Name != "probe" {
return true
}
// probe(envPathVar, commandName, envModelVar): the command name is the
// second argument and is the value pre-fetched by the shell fallback.
if len(call.Args) < 2 {
return true
}
lit, ok := call.Args[1].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
probeCalls++
name := lit.Value
if len(name) >= 2 {
name = name[1 : len(name)-1] // strip surrounding quotes
}
if !known[name] {
missing = append(missing, name)
}
return true
})
// Without this the whole test passes vacuously the moment probeAgentCLIs
// moves to another file, which is exactly how this guard failed before.
if probeCalls < len(defaultAgentCommandNames) {
t.Fatalf("found only %d probe() call(s) with a literal command name in %s, "+
"expected at least %d (one per default agent command); "+
"did the probe loop move, or is a provider probed without probe()?",
probeCalls, probeSourceFile, len(defaultAgentCommandNames))
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("probe() command names missing from defaultAgentCommandNames: %v; "+
"add them so GUI-launched daemons can resolve these agents via the login shell", missing)
}
}
func TestAgentCLIGuardCoversDefaultCommands(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "..", "scripts", "agent-cli-command-names.txt"))
if err != nil {
t.Fatalf("read agent CLI guard names: %v", err)
}
guarded := map[string]bool{}
for lineNumber, line := range strings.Split(string(data), "\n") {
if line != strings.TrimSpace(line) {
t.Fatalf("agent CLI guard name on line %d has surrounding whitespace", lineNumber+1)
}
if line != "" && !strings.HasPrefix(line, "#") {
if !isSafeAgentCLICommandName(line) {
t.Fatalf("agent CLI guard name on line %d contains unsafe characters: %q", lineNumber+1, line)
}
guarded[line] = true
}
}
for _, name := range defaultAgentCommandNames {
if !guarded[name] {
t.Errorf("default agent command %q is not covered by the test guard", name)
}
}
}
func isSafeAgentCLICommandName(name string) bool {
for _, char := range name {
if (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '.' || char == '_' || char == '-' {
continue
}
return false
}
return name != ""
}
func TestAgentCLIGuardDetectsSwallowedFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the full guarded backend suite runs on Linux/macOS")
}
script := filepath.Join("..", "..", "..", "scripts", "go-test-with-agent-cli-guard.sh")
cmd := exec.Command(script, "--", "/bin/sh", "-c", "claude --version --token super-secret >/dev/null 2>&1 || true")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("guard succeeded after a swallowed agent CLI failure: %s", out)
}
if !strings.Contains(string(out), "unexpected agent CLI invocation: claude [arguments redacted]") {
t.Fatalf("guard diagnostic missing invocation: %s", out)
}
if strings.Contains(string(out), "super-secret") {
t.Fatalf("guard diagnostic exposed command arguments: %s", out)
}
}
func TestAgentCLIGuardFailsClosedWhenSetupFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the full guarded backend suite runs on Linux/macOS")
}
invalidTempDir := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(invalidTempDir, []byte("fixture"), 0o600); err != nil {
t.Fatalf("write invalid temp directory fixture: %v", err)
}
executedMarker := filepath.Join(t.TempDir(), "executed")
script := filepath.Join("..", "..", "..", "scripts", "go-test-with-agent-cli-guard.sh")
cmd := exec.Command(script, "--", "/bin/sh", "-c", "printf ran >\"$1\"", "sh", executedMarker)
cmd.Env = append(os.Environ(), "TMPDIR="+invalidTempDir)
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("guard succeeded after setup failure: %s", out)
}
if _, statErr := os.Stat(executedMarker); !os.IsNotExist(statErr) {
t.Fatalf("wrapped command ran after guard setup failure: %v", statErr)
}
}