fix(claude): implement official Go hook contract
This commit is contained in:
1 parent
f71db95502
commit
15d044cf63
17 files changed
+3006
-236
No files matched your search
@@ -0,0 +1,332 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeCodeRegistryUsesOfficialSettingsContract(t *testing.T) {
|
||||
entry := SupportedAgents[claudeAgentKey]
|
||||
if entry.Name != "Claude Code" || entry.ConfigDir != "$CLAUDE_CONFIG_DIR" ||
|
||||
entry.ConfigFile != claudeConfigFile {
|
||||
t.Fatalf("Claude Code registry entry = %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeInstallPreservesSettingsAndWritesExactTriple(t *testing.T) {
|
||||
existing := `{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"model": "sonnet",
|
||||
"disableAllHooks": false,
|
||||
"hooks": {
|
||||
"Notification": [{"matcher":"permission_prompt","hooks":[{"type":"http","url":"https://example.test/hook","timeout":1}] }],
|
||||
"PreToolUse": [{"matcher":"Bash","hooks":[{"type":"command","command":"existing-command","timeout":5}]}]
|
||||
}
|
||||
}
|
||||
`
|
||||
fixture := prepareClaudeFixture(
|
||||
t,
|
||||
claudeFixtureOptions{existingRaw: &existing},
|
||||
)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
settings := readClaudeTestObject(t, fixture.configPath)
|
||||
if settings["model"] != "sonnet" || settings["disableAllHooks"] != false {
|
||||
t.Fatalf("user settings changed: %#v", settings)
|
||||
}
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
if len(requireArray(t, hooks["Notification"])) != 1 ||
|
||||
len(requireArray(t, hooks["PreToolUse"])) != 2 {
|
||||
t.Fatalf("user hooks changed: %#v", hooks)
|
||||
}
|
||||
requireStrictClaudeTriple(t, fixture)
|
||||
first, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read installed settings: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repeat Claude install: %v", err)
|
||||
}
|
||||
second, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil || string(second) != string(first) {
|
||||
t.Fatalf("idempotent settings = %q, %v", second, err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured ||
|
||||
!status.HookScriptExists {
|
||||
t.Fatalf("Claude status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeConfigDirMatchesOfficialResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
present bool
|
||||
configured string
|
||||
expectedParts []string
|
||||
}{
|
||||
{"default", false, "", []string{".claude", "settings.json"}},
|
||||
{"absolute", true, "ABSOLUTE", []string{"custom claude", "settings.json"}},
|
||||
{"relative", true, "relative claude", []string{"relative claude", "settings.json"}},
|
||||
{"empty", true, "", []string{"settings.json"}},
|
||||
{"literal tilde", true, "~", []string{"~", "settings.json"}},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
options := claudeFixtureOptions{configEnvPresent: testCase.present}
|
||||
if testCase.configured == "ABSOLUTE" {
|
||||
root := t.TempDir()
|
||||
options.configEnvOverride = filepath.Join(root, "custom claude")
|
||||
} else {
|
||||
options.configEnvOverride = testCase.configured
|
||||
}
|
||||
fixture := prepareClaudeFixture(t, options)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); err != nil {
|
||||
t.Fatalf("resolved settings missing: %v", err)
|
||||
}
|
||||
for _, part := range testCase.expectedParts {
|
||||
if !strings.Contains(fixture.configPath, part) {
|
||||
t.Fatalf("settings path %q lacks %q", fixture.configPath, part)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMalformedSettingsFailBeforeRuntimeCreation(t *testing.T) {
|
||||
source := "{ malformed"
|
||||
fixture := prepareClaudeFixture(
|
||||
t,
|
||||
claudeFixtureOptions{existingRaw: &source},
|
||||
)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse Claude Code user settings") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != source {
|
||||
t.Fatalf("malformed settings changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoClaudeRuntimeWrites(t, fixture)
|
||||
}
|
||||
|
||||
func TestClaudePreservesEveryOfficialHandlerType(t *testing.T) {
|
||||
source := `{"hooks":{
|
||||
"SessionStart":[{"hooks":[{"type":"prompt","prompt":"Review context","model":"haiku","continueOnBlock":true,"if":"always","once":false,"statusMessage":"Reviewing","timeout":0.5}]}],
|
||||
"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"user-command","args":["--safe"],"async":false,"asyncRewake":true,"shell":"powershell"}]}],
|
||||
"Stop":[{"hooks":[{"type":"agent","prompt":"Verify completion","model":"sonnet"}]}],
|
||||
"Notification":[{"hooks":[{"type":"http","url":"https://example.test/hook","headers":{"Authorization":"Bearer ${TOKEN}"},"allowedEnvVars":["TOKEN"]}]}],
|
||||
"PostToolUse":[{"hooks":[{"type":"mcp_tool","server":"audit","tool":"record","input":{"source":"claude"}}]}]
|
||||
}}`
|
||||
var existing map[string]any
|
||||
if err := json.Unmarshal([]byte(source), &existing); err != nil {
|
||||
t.Fatalf("decode user hooks: %v", err)
|
||||
}
|
||||
userHooks := requireObject(t, existing["hooks"])
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
installed := requireObject(t, readClaudeTestObject(t, fixture.configPath)["hooks"])
|
||||
for event, groups := range userHooks {
|
||||
actual := requireArray(t, installed[event])[0]
|
||||
want := groups.([]any)[0]
|
||||
if !reflect.DeepEqual(actual, want) {
|
||||
t.Fatalf("%s user hook = %#v, want %#v", event, actual, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeInvalidOfficialHookShapesFailBeforeWrites(t *testing.T) {
|
||||
tests := []struct{ name, source, want string }{
|
||||
{"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate key"},
|
||||
{"disable", `{"disableAllHooks":"yes"}`, "must be a boolean"},
|
||||
{"hooks", `{"hooks":null}`, "must be an object"},
|
||||
{"event", `{"hooks":{"MadeUp":[]}}`, "unsupported hook event"},
|
||||
{"event groups", `{"hooks":{"PreToolUse":null}}`, "must be an array"},
|
||||
{"group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
|
||||
{"group field", `{"hooks":{"PreToolUse":[{"hooks":[],"label":"x"}]}}`, "unsupported field"},
|
||||
{"matcher", `{"hooks":{"PreToolUse":[{"matcher":1,"hooks":[]}]}}`, "matcher must be a string"},
|
||||
{"group hooks", `{"hooks":{"PreToolUse":[{}]}}`, "hooks array"},
|
||||
{"handler", `{"hooks":{"PreToolUse":[{"hooks":[null]}]}}`, "handler"},
|
||||
{"type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"file"}]}]}}`, "unsupported type"},
|
||||
{"handler field", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","invented":true}]}]}}`, "unsupported field"},
|
||||
{"command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty string"},
|
||||
{"args", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","args":[1]}]}]}}`, "array of strings"},
|
||||
{"timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite"},
|
||||
{"headers", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","url":"https://example.test","headers":{"A":1}}]}]}}`, "map names to strings"},
|
||||
{"input", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t","input":[]}]}]}}`, "must be an object"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClaudeFixture(
|
||||
t,
|
||||
claudeFixtureOptions{existingRaw: &testCase.source},
|
||||
)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
actual, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(actual) != testCase.source {
|
||||
t.Fatalf("invalid settings changed: %q, %v", actual, readErr)
|
||||
}
|
||||
assertNoClaudeRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDisabledHooksBlockInstallationAndHealth(t *testing.T) {
|
||||
source := `{"disableAllHooks":true}`
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "disabled by disableAllHooks") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
status, statusErr := fixture.plugin.Status()
|
||||
if statusErr != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("disabled status = %#v, %v", status, statusErr)
|
||||
}
|
||||
assertNoClaudeRuntimeWrites(t, fixture)
|
||||
}
|
||||
|
||||
func TestClaudeExactLegacyHooksMigrateAndLookalikesSurvive(t *testing.T) {
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||
guardCommand := "node " + fixture.guardPath
|
||||
auditCommand := "node " + fixture.hookPath
|
||||
lookalike := guardCommand + " --inspect"
|
||||
writeClaudeTestObject(t, fixture.configPath, map[string]any{
|
||||
"hooks": map[string]any{
|
||||
"PreToolUse": []any{
|
||||
map[string]any{"hooks": []any{map[string]any{
|
||||
"type": "command", "command": guardCommand,
|
||||
}}},
|
||||
map[string]any{"hooks": []any{map[string]any{
|
||||
"type": "command", "command": lookalike,
|
||||
}}},
|
||||
},
|
||||
"PostToolUse": []any{map[string]any{"hooks": []any{map[string]any{
|
||||
"type": "command", "command": auditCommand,
|
||||
}}}},
|
||||
},
|
||||
})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("migrate Claude hooks: %v", err)
|
||||
}
|
||||
requireStrictClaudeTriple(t, fixture)
|
||||
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Claude hooks: %v", err)
|
||||
}
|
||||
remaining := readClaudeTestObject(t, fixture.configPath)
|
||||
hooks := requireObject(t, remaining["hooks"])
|
||||
groups := requireArray(t, hooks["PreToolUse"])
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("legacy lookalikes = %#v", hooks)
|
||||
}
|
||||
handler := requireObject(t, requireArray(t, requireObject(t, groups[0])["hooks"])[0])
|
||||
if handler["command"] != lookalike {
|
||||
t.Fatalf("legacy lookalike changed: %#v", handler)
|
||||
}
|
||||
if _, exists := hooks["PostToolUse"]; exists {
|
||||
t.Fatalf("managed success hook remains: %#v", hooks)
|
||||
}
|
||||
if _, exists := hooks["PostToolUseFailure"]; exists {
|
||||
t.Fatalf("managed failure hook remains: %#v", hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeUninstallPreservesUserSettings(t *testing.T) {
|
||||
source := `{"model":"sonnet","hooks":{"Notification":[]}}`
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Claude hooks: %v", err)
|
||||
}
|
||||
settings := readClaudeTestObject(t, fixture.configPath)
|
||||
want := map[string]any{
|
||||
"model": "sonnet", "hooks": map[string]any{"Notification": []any{}},
|
||||
}
|
||||
if !reflect.DeepEqual(settings, want) {
|
||||
t.Fatalf("remaining user settings = %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeUninstallRemovesEntirelyManagedSettingsFile(t *testing.T) {
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Claude hooks: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed settings remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStatusRequiresEnabledUniqueTriple(t *testing.T) {
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
settings := readClaudeTestObject(t, fixture.configPath)
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
delete(hooks, "PostToolUseFailure")
|
||||
writeClaudeTestObject(t, fixture.configPath, settings)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("incomplete status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("restore Claude hooks: %v", err)
|
||||
}
|
||||
settings = readClaudeTestObject(t, fixture.configPath)
|
||||
hooks = requireObject(t, settings["hooks"])
|
||||
groups := requireArray(t, hooks["PostToolUseFailure"])
|
||||
hooks["PostToolUseFailure"] = append(groups, groups[len(groups)-1])
|
||||
writeClaudeTestObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("duplicate status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProjectAndLocalSettingsStayUnchanged(t *testing.T) {
|
||||
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||
projectSettings := filepath.Join(fixture.projectDir, ".claude", "settings.json")
|
||||
localSettings := filepath.Join(fixture.projectDir, ".claude", "settings.local.json")
|
||||
if err := os.MkdirAll(filepath.Dir(projectSettings), 0700); err != nil {
|
||||
t.Fatalf("create project settings directory: %v", err)
|
||||
}
|
||||
projectSource := []byte(`{"hooks":{"PreToolUse":[]}}`)
|
||||
localSource := []byte(`{"model":"haiku"}`)
|
||||
if err := os.WriteFile(projectSettings, projectSource, 0600); err != nil {
|
||||
t.Fatalf("write project settings: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(localSettings, localSource, 0600); err != nil {
|
||||
t.Fatalf("write local settings: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Claude hooks: %v", err)
|
||||
}
|
||||
for path, want := range map[string][]byte{
|
||||
projectSettings: projectSource,
|
||||
localSettings: localSource,
|
||||
} {
|
||||
actual, err := os.ReadFile(path)
|
||||
if err != nil || string(actual) != string(want) {
|
||||
t.Fatalf("read-only source changed at %s: %q, %v", path, actual, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user