Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

523 lines
20 KiB
Go

package claudecode
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/agents"
)
// agentsApplyOptsZero is the zero value of agents.ApplyOpts. Helper so
// the mode-switching tests don't drag the agents import into every
// call site.
func agentsApplyOptsZero() agents.ApplyOpts { return agents.ApplyOpts{} }
// readSettingsHooks reads settings.local.json from path and returns
// the hooks map (or fails the test if the file is missing / malformed).
// The mode-switching tests assert on hook structure between InstallHook
// calls, so we centralise the read-and-shape-check here.
func readSettingsHooks(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err, "read %s", path)
var settings map[string]any
require.NoError(t, json.Unmarshal(data, &settings))
hooks, ok := settings["hooks"].(map[string]any)
require.True(t, ok, "settings.hooks missing or wrong type")
return hooks
}
// TestHookCommandPathIsEphemeral covers every branch of the
// ephemeral-path detector because this function's output directly
// decides whether a user's settings.local.json gets rewritten on
// re-run. A false positive here would thrash the user's hook
// config; a false negative leaves them with stale /tmp paths.
func TestHookCommandPathIsEphemeral(t *testing.T) {
// /bin/sh is a stable POSIX path, not under any ephemeral root.
// os.Executable() would land in /private/var/folders under go
// test, which is itself ephemeral — hence hard-coding.
const existing = "/bin/sh"
if _, err := os.Stat(existing); err != nil {
t.Skipf("test fixture %s not present: %v", existing, err)
}
missing := filepath.Join("/nonexistent-root-for-gortex-test", "ghost-binary")
cases := []struct {
name string
cmd string
want bool
comment string
}{
{"empty", "", false, "no fields to inspect"},
{"bareName", "gortex hook", false, "PATH lookup happens at fire time"},
{"relative", "./gortex hook", false, "relative paths are user choice, not ephemeral"},
{"tmp", "/tmp/gortex-hook-fix hook", true, "/tmp is wiped between sessions"},
{"varFolders", "/var/folders/x/y/z/gortex hook", true, "macOS go-build cache"},
{"privateTmp", "/private/tmp/gortex hook", true, "macOS resolves /tmp via /private/tmp"},
{"privateVarFolders", "/private/var/folders/x/y/z/gortex hook", true, "fully resolved go-build cache"},
{"missingAbsolute", missing + " hook", true, "absolute path that no longer exists"},
{"healthyAbsolute", existing + " hook", false, "absolute path that exists on disk"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HookCommandPathIsEphemeral(tc.cmd)
assert.Equal(t, tc.want, got, tc.comment)
})
}
}
func TestHealStaleHookCommands(t *testing.T) {
const newCmd = "/opt/homebrew/bin/gortex hook"
t.Run("emptyHooks", func(t *testing.T) {
hooks := map[string]any{}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
})
t.Run("noGortexEntries", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "/usr/local/bin/some-other-tool run"),
},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
entries := hooks["PreToolUse"].([]any)
inner := entries[0].(map[string]any)["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"].(string)
assert.Equal(t, "/usr/local/bin/some-other-tool run", cmd)
})
t.Run("healthyGortexEntryUntouched", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read", "./gortex hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
})
t.Run("staleEntryRewritten", func(t *testing.T) {
hooks := map[string]any{
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 1, got)
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
t.Run("multipleEventsAndMixed", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "./gortex hook"),
makeHookEntry("Read", "/usr/local/bin/lint --strict"),
},
"PreCompact": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 2, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
assert.Equal(t, "/usr/local/bin/lint --strict", extractCmd(t, hooks, "PreToolUse", 1))
assert.Equal(t, newCmd, extractCmd(t, hooks, "PreCompact", 0))
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
}
func TestResolveHookCommand(t *testing.T) {
t.Run("foundOnPath", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.Equal(t, fake+" hook", got, "should resolve to absolute path on PATH")
})
t.Run("notFoundFallsBackToBare", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
got := ResolveHookCommand(io.Discard)
assert.Equal(t, "gortex hook", got, "fallback to bare name keeps init working in sandboxes")
})
t.Run("commandNeverContainsBackslash", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.NotContains(t, got, "\\",
"baked hook command must use forward slashes — Git Bash on Windows mangles backslash paths")
})
t.Run("agreesWithMCPStanzaBinary", func(t *testing.T) {
// The hook command and the MCP server stanza must launch the same
// gortex binary; a side-by-side install must never split them
// across two daemons. With a gortex on PATH the hook pins the
// absolute path while the stanza collapses to the bare name — both
// resolve to the same file.
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
hookBin := strings.TrimSuffix(ResolveHookCommand(io.Discard), " hook")
mcpCmd := agents.ResolveGortexCommand()
// Map the bare name to the PATH gortex it launches, then compare
// the concrete on-disk binaries.
launched := func(cmd string) string {
if cmd == "gortex" {
return fake
}
return cmd
}
assert.Equal(t, launched(mcpCmd), launched(hookBin),
"hook command and MCP stanza must resolve to the same gortex binary")
})
}
// TestShellSafeHookBinary pins the Windows-path fix: a resolved binary
// path with backslashes (as exec.LookPath returns on Windows) must be
// rewritten to forward slashes so Claude Code's shell-run hook does not
// swallow the separators. Runs on every platform because the helper is
// a pure string transform.
func TestShellSafeHookBinary(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"windowsAbsolute", `C:\Users\Borislav\AppData\Local\Programs\gortex\gortex.exe`, "C:/Users/Borislav/AppData/Local/Programs/gortex/gortex.exe"},
{"windowsMixed", `C:\Users\me/gortex.exe`, "C:/Users/me/gortex.exe"},
{"unixAbsolute", "/opt/homebrew/bin/gortex", "/opt/homebrew/bin/gortex"},
{"bareName", "gortex", "gortex"},
{"forwardSlashesUnchanged", "C:/Users/me/gortex.exe", "C:/Users/me/gortex.exe"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := shellSafeHookBinary(tc.in)
assert.Equal(t, tc.want, got)
assert.NotContains(t, got, "\\", "result must be free of backslashes")
})
}
}
func makeHookEntry(matcher, command string) map[string]any {
entry := map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": command,
"timeout": 3000,
},
},
}
if matcher != "" {
entry["matcher"] = matcher
}
return entry
}
func extractCmd(t *testing.T, hooks map[string]any, event string, idx int) string {
t.Helper()
list, ok := hooks[event].([]any)
require.True(t, ok, "event %q missing", event)
require.Greater(t, len(list), idx, "event %q has fewer than %d entries", event, idx+1)
entry, ok := list[idx].(map[string]any)
require.True(t, ok)
inner, ok := entry["hooks"].([]any)
require.True(t, ok)
require.NotEmpty(t, inner)
em, ok := inner[0].(map[string]any)
require.True(t, ok)
cmd, _ := em["command"].(string)
return cmd
}
// ---------------------------------------------------------------------------
// Mode parsing + command rendering
// ---------------------------------------------------------------------------
func TestNormalizeHookMode(t *testing.T) {
cases := map[string]string{
"": HookModeDeny,
"deny": HookModeDeny,
"DENY": HookModeDeny,
" deny ": HookModeDeny,
"enrich": HookModeEnrich,
"Enrich": HookModeEnrich,
"consult-unlock": HookModeConsultUnlock,
"Consult-Unlock": HookModeConsultUnlock,
"nudge": HookModeAdaptiveNudge,
"NUDGE": HookModeAdaptiveNudge,
"adaptive-nudge": HookModeAdaptiveNudge, // accepted alias
"unknown": HookModeDeny, // safe fallback
"off": HookModeDeny, // install flag handles disable separately
}
for input, want := range cases {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, normalizeHookMode(input))
})
}
}
func TestHookCommandWithMode(t *testing.T) {
base := "/usr/local/bin/gortex hook"
assert.Equal(t, base, hookCommandWithMode(base, HookModeDeny),
"deny mode must NOT append --mode flag (back-compat with settings written before --hook-mode existed)")
assert.Equal(t, base+" --mode=enrich", hookCommandWithMode(base, HookModeEnrich),
"enrich mode must append --mode=enrich")
assert.Equal(t, base+" --mode=consult-unlock", hookCommandWithMode(base, HookModeConsultUnlock),
"consult-unlock mode must append --mode=consult-unlock")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, HookModeAdaptiveNudge),
"nudge mode must append --mode=nudge")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, "adaptive-nudge"),
"adaptive-nudge alias must normalize to --mode=nudge")
assert.Equal(t, base, hookCommandWithMode(base, ""),
"empty mode defaults to deny — no flag")
}
// ---------------------------------------------------------------------------
// removeGortexHookEntries — used when switching enrich → deny
// ---------------------------------------------------------------------------
func TestRemoveGortexHookEntries(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
map[string]any{
"hooks": []any{
map[string]any{"type": "command", "command": "/usr/bin/other-tool"},
},
},
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
list := hooks["PostToolUse"].([]any)
require.Len(t, list, 1, "non-Gortex entry must be preserved")
em := list[0].(map[string]any)
inner := em["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"]
assert.Equal(t, "/usr/bin/other-tool", cmd)
}
func TestRemoveGortexHookEntries_DeletesEmptyEvent(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
_, exists := hooks["PostToolUse"]
assert.False(t, exists, "empty event should be deleted, not left as []")
}
func TestRemoveGortexHookEntries_NoOpOnMissingEvent(t *testing.T) {
hooks := map[string]any{}
assert.Equal(t, 0, removeGortexHookEntries(hooks, "PostToolUse"))
}
// ---------------------------------------------------------------------------
// rewriteGortexHookMode — switches mode without touching user fields
// ---------------------------------------------------------------------------
func TestRewriteGortexHookMode_UpdatesCommandPreservingMeta(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
map[string]any{
"matcher": "Read|Grep",
"hooks": []any{
map[string]any{
"type": "command",
"command": "/opt/gortex hook",
"timeout": 3000,
"statusMessage": "custom user-set message",
},
},
},
},
}
rewritten := rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich")
assert.Equal(t, 1, rewritten)
list := hooks["PreToolUse"].([]any)
em := list[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
assert.Equal(t, "/opt/gortex hook --mode=enrich", em["command"])
assert.Equal(t, "custom user-set message", em["statusMessage"],
"rewrite must preserve user-added fields like statusMessage")
}
func TestRewriteGortexHookMode_NoOpOnIdenticalCommand(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob", "/opt/gortex hook")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook"))
}
func TestRewriteGortexHookMode_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Bash", "/usr/bin/other --thing")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich"))
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "/usr/bin/other --thing", cmd,
"non-Gortex entries must NOT be touched even when we're rewriting")
}
// TestUpgradeGortexMatcher_RewritesPreviousCurrent verifies the
// migration path: an existing install pinned to the previous canonical
// matcher (no MCP read tools) upgrades in place so the read-discipline
// nudge starts firing without the user touching settings.json.
func TestUpgradeGortexMatcher_RewritesPreviousCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/opt/gortex hook")},
}
assert.True(t, upgradeGortexMatcher(hooks), "previous-current matcher should upgrade")
got := hooks["PreToolUse"].([]any)[0].(map[string]any)["matcher"].(string)
assert.Equal(t, CurrentPreToolUseMatcher, got)
assert.Contains(t, got, "mcp__gortex__read_file",
"upgraded matcher must include the gortex read tools")
}
func TestUpgradeGortexMatcher_NoOpOnCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry(CurrentPreToolUseMatcher, "/opt/gortex hook")},
}
assert.False(t, upgradeGortexMatcher(hooks), "already-current matcher must not be rewritten")
}
func TestUpgradeGortexMatcher_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/usr/bin/other thing")},
}
assert.False(t, upgradeGortexMatcher(hooks), "a non-Gortex entry must not be upgraded")
}
// ---------------------------------------------------------------------------
// InstallHookWithMode — end-to-end: deny → enrich → deny round-trip
// ---------------------------------------------------------------------------
func TestInstallHookWithMode_DenyMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"deny mode must NOT install a PostToolUse entry")
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook", cmd,
"deny mode keeps the bare command — no --mode flag")
}
func TestInstallHook_InstallsUserPromptSubmit(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "UserPromptSubmit",
"UserPromptSubmit hook must be installed for per-turn context injection")
cmd := extractCmd(t, hooks, "UserPromptSubmit", 0)
assert.Equal(t, "gortex hook", cmd)
}
func TestInstallHookWithMode_EnrichMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse")
postCmd := extractCmd(t, hooks, "PostToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", postCmd)
preCmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", preCmd,
"PreToolUse must use the same --mode=enrich command so it falls back to soft-context")
// PostToolUse uses the matcher restricted to read-shaped tools.
post := hooks["PostToolUse"].([]any)[0].(map[string]any)
assert.Equal(t, CurrentPostToolUseMatcher, post["matcher"])
}
func TestInstallHookWithMode_DenyToEnrichRoundTrip(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
// Initial deny install.
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.NotContains(t, hooks, "PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
// Switch to enrich.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse", "enrich install must add PostToolUse")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PreToolUse", 0),
"PreToolUse command must be rewritten on mode switch")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PostToolUse", 0))
// Switch back to deny — PostToolUse must be removed, PreToolUse rewritten.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse", "switch back to deny must drop PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0),
"switch back to deny must restore bare command on PreToolUse")
}
func TestInstallHookWithMode_EnrichIdempotent(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
for i := range 3 {
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err, "iteration %d", i)
}
hooks := readSettingsHooks(t, settingsPath)
// PostToolUse should have exactly one entry — re-running install
// must not append duplicates.
post := hooks["PostToolUse"].([]any)
assert.Len(t, post, 1, "repeated install must not duplicate the PostToolUse entry")
}
// InstallHook (the back-compat wrapper) must default to deny mode.
func TestInstallHook_DefaultsToDeny(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHook(io.Discard, settingsPath, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"InstallHook (no mode) must behave as deny — no PostToolUse entry")
}