a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
359 lines
12 KiB
Go
359 lines
12 KiB
Go
package agents
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestGlobalGortexMCPEntryUsesProxy verifies that the user-level MCP
|
|
// entry is daemon-only — no cwd-relative `--index .` arg. This is the
|
|
// load-bearing property for gortexhq/gortex#19: Cursor launches the
|
|
// global config with cwd=$HOME, so any cwd-relative indexing would
|
|
// fail the entry-point handshake.
|
|
func TestGlobalGortexMCPEntryUsesProxy(t *testing.T) {
|
|
entry := GlobalGortexMCPEntry()
|
|
|
|
cmd, _ := entry["command"].(string)
|
|
if cmd != "gortex" {
|
|
t.Fatalf("command = %q, want %q", cmd, "gortex")
|
|
}
|
|
|
|
args, _ := entry["args"].([]string)
|
|
if len(args) == 0 || args[0] != "mcp" {
|
|
t.Fatalf("args[0] = %v, want mcp; full args=%v", args, args)
|
|
}
|
|
// The canonical shape is bare ["mcp"]: no cwd-relative --index/--watch
|
|
// (the daemon resolves the workspace per session) and no --proxy
|
|
// (gortex mcp proxies to / auto-starts the daemon on its own).
|
|
if len(args) != 1 || args[0] != "mcp" {
|
|
t.Errorf("global entry must emit canonical [mcp], got %v", args)
|
|
}
|
|
}
|
|
|
|
// TestIsGortexAuthoredMCPEntry checks the heuristic used by
|
|
// UpsertMCPServerWithMigration to decide which existing stanzas are
|
|
// safe to overwrite during a global-install migration.
|
|
func TestIsGortexAuthoredMCPEntry(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "legacy --index . shape (the bug from issue 19)",
|
|
raw: `{"command":"gortex","args":["mcp","--index",".","--watch"]}`,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "current --proxy shape",
|
|
raw: `{"command":"gortex","args":["mcp","--proxy"]}`,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "user wrapper that shells gortex through a script",
|
|
raw: `{"command":"/usr/local/bin/wrap-gortex.sh","args":["mcp"]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "user's own MCP server, not gortex at all",
|
|
raw: `{"command":"other-tool","args":["serve"]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "gortex command but a different subcommand",
|
|
raw: `{"command":"gortex","args":["daemon","start"]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty args list",
|
|
raw: `{"command":"gortex","args":[]}`,
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var v any
|
|
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
got := IsGortexAuthoredMCPEntry(v)
|
|
if got != tc.want {
|
|
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpsertMCPServerWithMigrationReplacesLegacy exercises the
|
|
// migration path: a user with an old global config containing
|
|
// `--index . --watch` should get rewritten to the proxy entry on next
|
|
// `gortex install`, even though opts.Force is off.
|
|
func TestUpsertMCPServerWithMigrationReplacesLegacy(t *testing.T) {
|
|
root := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"gortex": map[string]any{
|
|
"command": "gortex",
|
|
"args": []any{"mcp", "--index", ".", "--watch"},
|
|
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
|
|
},
|
|
},
|
|
}
|
|
want := GlobalGortexMCPEntry()
|
|
|
|
changed := UpsertMCPServerWithMigration(root, "gortex", want, ApplyOpts{})
|
|
if !changed {
|
|
t.Fatalf("expected migration to rewrite legacy entry, got changed=false")
|
|
}
|
|
servers, _ := root["mcpServers"].(map[string]any)
|
|
got, _ := servers["gortex"].(map[string]any)
|
|
if got["command"] != "gortex" {
|
|
t.Fatalf("command lost during migration: %v", got)
|
|
}
|
|
args, _ := got["args"].([]string)
|
|
for _, a := range args {
|
|
if a == "--index" {
|
|
t.Fatalf("migration left legacy --index in args: %v", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpsertMCPServerWithMigrationIdempotent verifies the second run
|
|
// is a no-op once the config is already in the desired shape — no
|
|
// spurious rewrite, no noisy "modified" log.
|
|
func TestUpsertMCPServerWithMigrationIdempotent(t *testing.T) {
|
|
// Use the on-disk shape (args as []any) to mirror what comes back
|
|
// out of json.Unmarshal on an existing mcp.json.
|
|
root := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"gortex": map[string]any{
|
|
"command": "gortex",
|
|
"args": []any{"mcp"},
|
|
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
|
|
},
|
|
},
|
|
}
|
|
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
|
|
t.Errorf("expected no rewrite when entry already matches; got changed=true")
|
|
}
|
|
}
|
|
|
|
// TestUpsertMCPServerWithMigrationPreservesUserCustomization confirms
|
|
// the migration is fingerprint-gated: a user who hand-rolled their
|
|
// own gortex wrapper (non-`gortex` command, or a different subcommand)
|
|
// should not have their config silently overwritten.
|
|
func TestUpsertMCPServerWithMigrationPreservesUserCustomization(t *testing.T) {
|
|
root := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"gortex": map[string]any{
|
|
"command": "/opt/scripts/my-gortex.sh",
|
|
"args": []any{"mcp"},
|
|
},
|
|
},
|
|
}
|
|
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
|
|
t.Errorf("user-customized entry was overwritten without --force")
|
|
}
|
|
// And with --force the same call should overwrite — that's the
|
|
// escape hatch users have when they want Gortex to take over.
|
|
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{Force: true}); !changed {
|
|
t.Errorf("opts.Force must overwrite user-customized entry")
|
|
}
|
|
}
|
|
|
|
// TestIsGortexAuthoredMCPEntryAbsolutePath verifies the broadened
|
|
// detection: the legacy absolute-path command form a pre-fix `gortex
|
|
// install` baked into ~/.claude.json (via os.Executable()) is now
|
|
// recognized as Gortex-authored, so the next install can migrate it to
|
|
// the canonical bare-`gortex` shape. A user's own wrapper script is
|
|
// still left alone. Both / and \ separators are handled regardless of
|
|
// the host OS.
|
|
func TestIsGortexAuthoredMCPEntryAbsolutePath(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "unix absolute path to gortex (legacy os.Executable form)",
|
|
raw: `{"command":"/usr/local/bin/gortex","args":["mcp"]}`,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "windows absolute path to gortex.exe (the issue #201 form)",
|
|
raw: `{"command":"C:\\Users\\daoti\\AppData\\Local\\Programs\\gortex\\gortex.exe","args":["mcp"]}`,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "absolute path to a non-gortex binary",
|
|
raw: `{"command":"/usr/local/bin/something-else","args":["mcp"]}`,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "user wrapper whose basename merely contains gortex",
|
|
raw: `{"command":"/opt/scripts/my-gortex.sh","args":["mcp"]}`,
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var v any
|
|
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got := IsGortexAuthoredMCPEntry(v); got != tc.want {
|
|
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveGortexCommandFrom pins the command-resolution policy that
|
|
// keeps the user-scope ~/.claude.json entry in agreement with the
|
|
// project .mcp.json template (and so avoids Claude Code's "conflicting
|
|
// scopes" diagnostic) while staying robust when gortex isn't on PATH.
|
|
func TestResolveGortexCommandFrom(t *testing.T) {
|
|
sameTrue := func(a, b string) bool { return true }
|
|
sameFalse := func(a, b string) bool { return false }
|
|
notFound := errors.New("executable file not found in $PATH")
|
|
|
|
cases := []struct {
|
|
name string
|
|
exe string
|
|
exeErr error
|
|
lookPath string
|
|
lookErr error
|
|
same func(a, b string) bool
|
|
want string
|
|
}{
|
|
{
|
|
name: "on PATH and same binary -> bare gortex (matches project template)",
|
|
exe: "/usr/local/bin/gortex",
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameTrue,
|
|
want: "gortex",
|
|
},
|
|
{
|
|
name: "installed but not on PATH -> pin absolute path",
|
|
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
|
|
lookPath: "",
|
|
lookErr: notFound,
|
|
same: sameFalse,
|
|
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
|
|
},
|
|
{
|
|
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
|
|
exe: "/opt/build/gortex",
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameFalse,
|
|
want: "/opt/build/gortex",
|
|
},
|
|
{
|
|
name: "go run transient temp build + gortex on PATH -> bare gortex",
|
|
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameFalse,
|
|
want: "gortex",
|
|
},
|
|
{
|
|
name: "nothing resolvable -> bare gortex last resort",
|
|
exe: "",
|
|
exeErr: errors.New("os.Executable failed"),
|
|
lookErr: notFound,
|
|
same: sameFalse,
|
|
want: "gortex",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
|
|
if got != tc.want {
|
|
t.Errorf("resolveGortexCommandFrom() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveGortexHookBinaryFrom mirrors TestResolveGortexCommandFrom's
|
|
// table for the hook resolver, and additionally asserts the load-bearing
|
|
// invariant: the hook binary and the MCP stanza launch the SAME on-disk
|
|
// file in every case. The hook only prefers an absolute path over the
|
|
// bare name (it fires in a shell whose PATH may differ from install
|
|
// time); it must never point at a different gortex than the MCP server.
|
|
func TestResolveGortexHookBinaryFrom(t *testing.T) {
|
|
sameTrue := func(a, b string) bool { return true }
|
|
sameFalse := func(a, b string) bool { return false }
|
|
notFound := errors.New("executable file not found in $PATH")
|
|
|
|
cases := []struct {
|
|
name string
|
|
exe string
|
|
exeErr error
|
|
lookPath string
|
|
lookErr error
|
|
same func(a, b string) bool
|
|
want string
|
|
}{
|
|
{
|
|
name: "on PATH and same binary -> pin absolute (hook avoids PATH at fire time)",
|
|
exe: "/usr/local/bin/gortex",
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameTrue,
|
|
want: "/usr/local/bin/gortex",
|
|
},
|
|
{
|
|
name: "installed but not on PATH -> pin absolute path",
|
|
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
|
|
lookPath: "",
|
|
lookErr: notFound,
|
|
same: sameFalse,
|
|
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
|
|
},
|
|
{
|
|
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
|
|
exe: "/opt/build/gortex",
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameFalse,
|
|
want: "/opt/build/gortex",
|
|
},
|
|
{
|
|
name: "go run transient temp build + gortex on PATH -> PATH gortex, never the temp build",
|
|
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
|
|
lookPath: "/usr/local/bin/gortex",
|
|
same: sameFalse,
|
|
want: "/usr/local/bin/gortex",
|
|
},
|
|
{
|
|
name: "nothing resolvable -> bare gortex last resort",
|
|
exe: "",
|
|
exeErr: errors.New("os.Executable failed"),
|
|
lookErr: notFound,
|
|
same: sameFalse,
|
|
want: "gortex",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := resolveGortexHookBinaryFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
|
|
if got != tc.want {
|
|
t.Errorf("resolveGortexHookBinaryFrom() = %q, want %q", got, tc.want)
|
|
}
|
|
// Map each resolver output to the concrete file it launches:
|
|
// an absolute path is itself; the bare name resolves via PATH
|
|
// to lookPath. The two must never disagree.
|
|
launched := func(resolved string) string {
|
|
if resolved == "gortex" {
|
|
return tc.lookPath
|
|
}
|
|
return resolved
|
|
}
|
|
mcp := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
|
|
if hf, mf := launched(got), launched(mcp); hf != mf {
|
|
t.Errorf("hook/MCP disagree on binary file: hook %q -> %q, mcp %q -> %q", got, hf, mcp, mf)
|
|
}
|
|
})
|
|
}
|
|
}
|