mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
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>
166 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|