feat: sync Go Auggie hook integration
This commit is contained in:
1 parent
e2f319914b
commit
e334e84333
8 files changed
+1430
-2
No files matched your search
@@ -79,6 +79,8 @@ Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_
|
||||
|
||||
Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged.
|
||||
|
||||
Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
@@ -92,6 +94,7 @@ Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-
|
||||
|
||||
| Agent | Key |
|
||||
|-------|-----|
|
||||
| Augment Code CLI | `augment` |
|
||||
| Claude Code | `claudecode` |
|
||||
| OpenAI Codex | `codex` |
|
||||
| Copilot CLI | `copilot` |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package plugins
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AugmentPlugin manages Auggie native global user hooks.
|
||||
type AugmentPlugin struct{}
|
||||
|
||||
func (p *AugmentPlugin) Install(config InstallConfig) error {
|
||||
if config.AgentID == "" {
|
||||
return fmt.Errorf("agent ID is required")
|
||||
}
|
||||
document, err := readAugmentConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.GuardScriptPath == "" {
|
||||
return fmt.Errorf("guard script path is required")
|
||||
}
|
||||
guardExists, err := regularFileExists(
|
||||
config.GuardScriptPath, "Elydora guard runtime",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !guardExists {
|
||||
return fmt.Errorf(
|
||||
"Elydora guard runtime is missing: %s", config.GuardScriptPath,
|
||||
)
|
||||
}
|
||||
hookPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
hookPath = config.HookScript
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeRoot, err := augmentRuntimeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrappers := resolveAugmentWrapperPaths(runtimeRoot, config.AgentID)
|
||||
hooks, _ := removeManagedAugmentHooks(document.hooks, "", runtimeRoot)
|
||||
hooks["PreToolUse"] = append(
|
||||
hooks["PreToolUse"],
|
||||
buildAugmentGroup(buildAugmentHandler(wrappers.guard)),
|
||||
)
|
||||
hooks["PostToolUse"] = append(
|
||||
hooks["PostToolUse"],
|
||||
buildAugmentGroup(buildAugmentHandler(wrappers.audit)),
|
||||
)
|
||||
next := cloneAugmentObject(document.root)
|
||||
next["hooks"] = renderAugmentHooks(hooks)
|
||||
|
||||
runtimeConfig := config
|
||||
runtimeConfig.AgentName = augmentAgentKey
|
||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
if err := writeHookFileAtomic(
|
||||
wrappers.guard,
|
||||
buildAugmentWrapper(nodePath, config.GuardScriptPath),
|
||||
0700,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write Auggie guard wrapper: %w", err)
|
||||
}
|
||||
if err := writeHookFileAtomic(
|
||||
wrappers.audit,
|
||||
buildAugmentWrapper(nodePath, hookPath),
|
||||
0700,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write Auggie audit wrapper: %w", err)
|
||||
}
|
||||
if err := writeHookJSONObjectAtomic(document.configPath, next); err != nil {
|
||||
return fmt.Errorf("write Auggie settings: %w", err)
|
||||
}
|
||||
fmt.Println("Auggie: user-level PreToolUse and PostToolUse hooks installed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AugmentPlugin) Uninstall(agentID string) error {
|
||||
document, err := readAugmentConfig()
|
||||
if err != nil || !document.exists {
|
||||
return err
|
||||
}
|
||||
runtimeRoot, err := augmentRuntimeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hooks, changed := removeManagedAugmentHooks(
|
||||
document.hooks, agentID, runtimeRoot,
|
||||
)
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
next := cloneAugmentObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(next, "hooks")
|
||||
} else {
|
||||
next["hooks"] = renderAugmentHooks(hooks)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return removeHookFile(document.configPath, "Auggie settings")
|
||||
}
|
||||
return writeHookJSONObjectAtomic(document.configPath, next)
|
||||
}
|
||||
|
||||
func (p *AugmentPlugin) Status() (PluginStatus, error) {
|
||||
document, err := readAugmentConfig()
|
||||
status := PluginStatus{
|
||||
AgentName: augmentAgentKey,
|
||||
DisplayName: "Augment Code CLI",
|
||||
ConfigPath: document.configPath,
|
||||
}
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
runtimeRoot, err := augmentRuntimeRoot()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
contracts := augmentRuntimeContracts(document.hooks, runtimeRoot)
|
||||
if len(contracts) == 0 {
|
||||
return status, nil
|
||||
}
|
||||
status.HookConfigured = true
|
||||
status.HookScriptExists, err = augmentRuntimeFilesExist(
|
||||
contracts, runtimeRoot,
|
||||
)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status.Installed = status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
augmentAgentKey = "augment"
|
||||
augmentGuardScript = "guard.js"
|
||||
augmentAuditScript = "hook.js"
|
||||
augmentHookTimeout = float64(10_000)
|
||||
augmentPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
var (
|
||||
augmentToolEvents = map[string]struct{}{
|
||||
"PreToolUse": {},
|
||||
"PostToolUse": {},
|
||||
}
|
||||
augmentSessionEvents = map[string]struct{}{
|
||||
"Stop": {},
|
||||
"SessionStart": {},
|
||||
"SessionEnd": {},
|
||||
"Notification": {},
|
||||
"PromptSubmit": {},
|
||||
}
|
||||
)
|
||||
|
||||
type augmentGroup struct {
|
||||
object map[string]any
|
||||
handlers []map[string]any
|
||||
}
|
||||
|
||||
type augmentHooks map[string][]augmentGroup
|
||||
|
||||
type augmentDocument struct {
|
||||
exists bool
|
||||
configPath string
|
||||
root map[string]any
|
||||
hooks augmentHooks
|
||||
}
|
||||
|
||||
type augmentWrapperPaths struct {
|
||||
guard string
|
||||
audit string
|
||||
}
|
||||
|
||||
type augmentRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
guardWrapper string
|
||||
auditWrapper string
|
||||
}
|
||||
|
||||
func cloneAugmentObject(value map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
clone[key] = item
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func validateAugmentHandler(
|
||||
value any,
|
||||
event string,
|
||||
groupIndex, handlerIndex int,
|
||||
) (map[string]any, error) {
|
||||
label := fmt.Sprintf(
|
||||
"Auggie settings handler hooks.%s[%d].hooks[%d]",
|
||||
event, groupIndex, handlerIndex,
|
||||
)
|
||||
handler, ok := value.(map[string]any)
|
||||
if !ok || handler == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
handlerType, ok := handler["type"].(string)
|
||||
if !ok || handlerType != "command" {
|
||||
return nil, fmt.Errorf(`%s type must be "command"`, label)
|
||||
}
|
||||
command, ok := handler["command"].(string)
|
||||
if !ok || command == "" {
|
||||
return nil, fmt.Errorf("%s requires a non-empty command", label)
|
||||
}
|
||||
if arguments, exists := handler["args"]; exists {
|
||||
values, ok := arguments.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s args must be an array of strings", label)
|
||||
}
|
||||
for _, argument := range values {
|
||||
if _, ok := argument.(string); !ok {
|
||||
return nil, fmt.Errorf("%s args must be an array of strings", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if timeoutValue, exists := handler["timeout"]; exists {
|
||||
timeout, ok := timeoutValue.(float64)
|
||||
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
|
||||
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
||||
}
|
||||
}
|
||||
return cloneAugmentObject(handler), nil
|
||||
}
|
||||
|
||||
func validateAugmentMetadata(value any, label string) error {
|
||||
metadata, ok := value.(map[string]any)
|
||||
if !ok || metadata == nil {
|
||||
return fmt.Errorf("%s metadata must be an object", label)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"includeConversationData",
|
||||
"includeMCPMetadata",
|
||||
"includeUserContext",
|
||||
} {
|
||||
if field, exists := metadata[key]; exists {
|
||||
if _, ok := field.(bool); !ok {
|
||||
return fmt.Errorf("%s metadata.%s must be a boolean", label, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup, error) {
|
||||
label := fmt.Sprintf("Auggie settings group hooks.%s[%d]", event, groupIndex)
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return augmentGroup{}, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
if _, sessionEvent := augmentSessionEvents[event]; sessionEvent {
|
||||
if _, hasMatcher := object["matcher"]; hasMatcher {
|
||||
return augmentGroup{}, fmt.Errorf("%s matcher is only supported for tool events", label)
|
||||
}
|
||||
}
|
||||
if matcher, exists := object["matcher"]; exists {
|
||||
if _, ok := matcher.(string); !ok {
|
||||
return augmentGroup{}, fmt.Errorf("%s matcher must be a string", label)
|
||||
}
|
||||
}
|
||||
if metadata, exists := object["metadata"]; exists {
|
||||
if err := validateAugmentMetadata(metadata, label); err != nil {
|
||||
return augmentGroup{}, err
|
||||
}
|
||||
}
|
||||
values, ok := object["hooks"].([]any)
|
||||
if !ok {
|
||||
return augmentGroup{}, fmt.Errorf("%s must contain a hooks array", label)
|
||||
}
|
||||
handlers := make([]map[string]any, 0, len(values))
|
||||
for handlerIndex, value := range values {
|
||||
handler, err := validateAugmentHandler(value, event, groupIndex, handlerIndex)
|
||||
if err != nil {
|
||||
return augmentGroup{}, err
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil
|
||||
}
|
||||
|
||||
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
||||
value, exists := root["hooks"]
|
||||
if !exists {
|
||||
return augmentHooks{}, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf(`Auggie settings field "hooks" must be an object`)
|
||||
}
|
||||
hooks := make(augmentHooks, len(object))
|
||||
for event, groupValue := range object {
|
||||
if _, toolEvent := augmentToolEvents[event]; !toolEvent {
|
||||
if _, sessionEvent := augmentSessionEvents[event]; !sessionEvent {
|
||||
return nil, fmt.Errorf(
|
||||
`Auggie settings field "hooks.%s" uses an unsupported event`,
|
||||
event,
|
||||
)
|
||||
}
|
||||
}
|
||||
values, ok := groupValue.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
`Auggie settings field "hooks.%s" must be an array`,
|
||||
event,
|
||||
)
|
||||
}
|
||||
groups := make([]augmentGroup, 0, len(values))
|
||||
for groupIndex, value := range values {
|
||||
group, err := validateAugmentGroup(value, event, groupIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, group)
|
||||
}
|
||||
hooks[event] = groups
|
||||
}
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
||||
result := make(map[string]any, len(hooks))
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneAugmentObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
object["hooks"] = handlers
|
||||
values = append(values, object)
|
||||
}
|
||||
result[event] = values
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readAugmentWindowsArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '"' {
|
||||
return "", false
|
||||
}
|
||||
var value strings.Builder
|
||||
for index := 1; index < len(command); index++ {
|
||||
if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' {
|
||||
value.WriteByte('"')
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if command[index] == '"' {
|
||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
||||
}
|
||||
value.WriteByte(command[index])
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '\'' {
|
||||
return "", false
|
||||
}
|
||||
var value strings.Builder
|
||||
for index := 1; index < len(command); {
|
||||
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
|
||||
value.WriteByte('\'')
|
||||
index += len(augmentPOSIXApostrophe)
|
||||
continue
|
||||
}
|
||||
if command[index] == '\'' {
|
||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
||||
}
|
||||
value.WriteByte(command[index])
|
||||
index++
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseAugmentCommand(command string) (string, bool) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return readAugmentWindowsArgument(command)
|
||||
}
|
||||
return readAugmentPOSIXArgument(command)
|
||||
}
|
||||
|
||||
func normalizeAugmentPath(value string) string {
|
||||
absolute, err := filepath.Abs(value)
|
||||
if err == nil {
|
||||
value = absolute
|
||||
}
|
||||
value = filepath.ToSlash(filepath.Clean(value))
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sameAugmentPath(left, right string) bool {
|
||||
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
|
||||
}
|
||||
|
||||
func sameAugmentAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func sameAugmentFileName(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func managedAugmentAgentID(
|
||||
handler map[string]any,
|
||||
wrapperName, runtimeRoot string,
|
||||
) (string, bool) {
|
||||
if handler["type"] != "command" || handler["timeout"] != augmentHookTimeout {
|
||||
return "", false
|
||||
}
|
||||
if _, hasArguments := handler["args"]; hasArguments {
|
||||
return "", false
|
||||
}
|
||||
command, ok := handler["command"].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
wrapperPath, ok := parseAugmentCommand(command)
|
||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(wrapperPath)
|
||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
||||
}
|
||||
|
||||
func removeManagedAugmentGroups(
|
||||
groups []augmentGroup,
|
||||
wrapperName, agentID, runtimeRoot string,
|
||||
) ([]augmentGroup, bool) {
|
||||
result := make([]augmentGroup, 0, len(groups))
|
||||
changed := false
|
||||
for _, group := range groups {
|
||||
handlers := make([]map[string]any, 0, len(group.handlers))
|
||||
groupChanged := false
|
||||
for _, handler := range group.handlers {
|
||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
||||
if remove {
|
||||
changed = true
|
||||
groupChanged = true
|
||||
continue
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
if len(handlers) > 0 || !groupChanged {
|
||||
result = append(result, augmentGroup{object: group.object, handlers: handlers})
|
||||
}
|
||||
}
|
||||
return result, changed
|
||||
}
|
||||
|
||||
func removeManagedAugmentHooks(
|
||||
hooks augmentHooks,
|
||||
agentID, runtimeRoot string,
|
||||
) (augmentHooks, bool) {
|
||||
result := make(augmentHooks, len(hooks))
|
||||
for event, groups := range hooks {
|
||||
result[event] = groups
|
||||
}
|
||||
changed := false
|
||||
for _, contract := range []struct{ event, wrapper string }{
|
||||
{"PreToolUse", augmentGuardWrapperName()},
|
||||
{"PostToolUse", augmentAuditWrapperName()},
|
||||
} {
|
||||
groups, eventChanged := removeManagedAugmentGroups(
|
||||
result[contract.event], contract.wrapper, agentID, runtimeRoot,
|
||||
)
|
||||
if !eventChanged {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if len(groups) == 0 {
|
||||
delete(result, contract.event)
|
||||
} else {
|
||||
result[contract.event] = groups
|
||||
}
|
||||
}
|
||||
return result, changed
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const augmentRegexValidator = `import fs from "node:fs";
|
||||
const pattern = fs.readFileSync(0, "utf8");
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
} catch (error) {
|
||||
process.stderr.write(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}`
|
||||
|
||||
func augmentGuardWrapperName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "augment-guard.cmd"
|
||||
}
|
||||
return "augment-guard.sh"
|
||||
}
|
||||
|
||||
func augmentAuditWrapperName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "augment-hook.cmd"
|
||||
}
|
||||
return "augment-hook.sh"
|
||||
}
|
||||
|
||||
func augmentHomeDirectory() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
func augmentRuntimeRoot() (string, error) {
|
||||
home, err := augmentHomeDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".elydora"), nil
|
||||
}
|
||||
|
||||
func augmentConfigPath() (string, error) {
|
||||
home, err := augmentHomeDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".augment", "settings.json"), nil
|
||||
}
|
||||
|
||||
func readAugmentConfig() (augmentDocument, error) {
|
||||
configPath, err := augmentConfigPath()
|
||||
if err != nil {
|
||||
return augmentDocument{}, err
|
||||
}
|
||||
root, exists, err := readHookJSONObject(configPath, "Auggie settings")
|
||||
if err != nil {
|
||||
return augmentDocument{}, err
|
||||
}
|
||||
hooks, err := readAugmentHooks(root)
|
||||
if err != nil {
|
||||
return augmentDocument{}, err
|
||||
}
|
||||
return augmentDocument{
|
||||
exists: exists,
|
||||
configPath: configPath,
|
||||
root: root,
|
||||
hooks: hooks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
|
||||
events := make([]string, 0, len(hooks))
|
||||
for event := range hooks {
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Strings(events)
|
||||
for _, event := range events {
|
||||
for groupIndex, group := range hooks[event] {
|
||||
matcher, exists := group.object["matcher"].(string)
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
// #nosec G204 -- nodePath is resolved with exec.LookPath and matcher data uses stdin.
|
||||
command := exec.Command(
|
||||
nodePath, "--input-type=module", "--eval", augmentRegexValidator,
|
||||
)
|
||||
command.Stdin = strings.NewReader(matcher)
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"Auggie settings group hooks.%s[%d] matcher "+
|
||||
"must be a valid JavaScript regular expression: %s",
|
||||
event, groupIndex, message,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quoteAugmentWindowsCommand(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
func quoteAugmentBatchArgument(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, "%", "%%") + `"`
|
||||
}
|
||||
|
||||
func buildAugmentCommand(wrapperPath string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return quoteAugmentWindowsCommand(wrapperPath)
|
||||
}
|
||||
return quotePOSIXArgument(wrapperPath)
|
||||
}
|
||||
|
||||
func buildAugmentWrapper(runtimePath, scriptPath string) []byte {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []byte(
|
||||
"@echo off\r\n" +
|
||||
quoteAugmentBatchArgument(runtimePath) + " " +
|
||||
quoteAugmentBatchArgument(scriptPath) + "\r\n" +
|
||||
"exit /b %errorlevel%\r\n",
|
||||
)
|
||||
}
|
||||
return []byte(
|
||||
"#!/bin/sh\nexec " +
|
||||
quotePOSIXArgument(runtimePath) + " " +
|
||||
quotePOSIXArgument(scriptPath) + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
func buildAugmentHandler(wrapperPath string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "command",
|
||||
"command": buildAugmentCommand(wrapperPath),
|
||||
"timeout": augmentHookTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAugmentGroup(handler map[string]any) augmentGroup {
|
||||
return augmentGroup{
|
||||
object: map[string]any{"matcher": ".*"},
|
||||
handlers: []map[string]any{handler},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAugmentWrapperPaths(runtimeRoot, agentID string) augmentWrapperPaths {
|
||||
agentDirectory := filepath.Join(runtimeRoot, agentID)
|
||||
return augmentWrapperPaths{
|
||||
guard: filepath.Join(agentDirectory, augmentGuardWrapperName()),
|
||||
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
||||
}
|
||||
}
|
||||
|
||||
func managedAugmentIDs(
|
||||
groups []augmentGroup,
|
||||
wrapperName, runtimeRoot string,
|
||||
) map[string]string {
|
||||
result := map[string]string{}
|
||||
for _, group := range groups {
|
||||
for _, handler := range group.handlers {
|
||||
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
if managed {
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
result[key] = agentID
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func augmentRuntimeContracts(
|
||||
hooks augmentHooks,
|
||||
runtimeRoot string,
|
||||
) []augmentRuntimeContract {
|
||||
guards := managedAugmentIDs(
|
||||
hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot,
|
||||
)
|
||||
audits := managedAugmentIDs(
|
||||
hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot,
|
||||
)
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
if _, exists := audits[key]; exists {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
contracts := make([]augmentRuntimeContract, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
agentID := guards[key]
|
||||
agentDirectory := filepath.Join(runtimeRoot, agentID)
|
||||
contracts = append(contracts, augmentRuntimeContract{
|
||||
agentID: agentID,
|
||||
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
|
||||
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
|
||||
guardWrapper: filepath.Join(agentDirectory, augmentGuardWrapperName()),
|
||||
auditWrapper: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
||||
})
|
||||
}
|
||||
return contracts
|
||||
}
|
||||
|
||||
func augmentRuntimeFilesExist(
|
||||
contracts []augmentRuntimeContract,
|
||||
runtimeRoot string,
|
||||
) (bool, error) {
|
||||
entries, err := os.ReadDir(runtimeRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf(
|
||||
"read Elydora runtime directory at %s: %w", runtimeRoot, err,
|
||||
)
|
||||
}
|
||||
for _, contract := range contracts {
|
||||
var entryName string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && sameAugmentAgentID(entry.Name(), contract.agentID) {
|
||||
entryName = entry.Name()
|
||||
break
|
||||
}
|
||||
}
|
||||
if entryName == "" {
|
||||
continue
|
||||
}
|
||||
agentDirectory := filepath.Join(runtimeRoot, entryName)
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
config, exists, err := readHookJSONObject(
|
||||
configPath, "Elydora runtime config",
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
agentName, ok := config["agent_name"].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf(
|
||||
`Elydora runtime config at %s field "agent_name" must be a string`,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
if agentName != augmentAgentKey {
|
||||
continue
|
||||
}
|
||||
files := []struct {
|
||||
path string
|
||||
label string
|
||||
}{
|
||||
{contract.guardPath, "Elydora guard runtime"},
|
||||
{contract.auditPath, "Elydora audit runtime"},
|
||||
{contract.guardWrapper, "Auggie guard wrapper"},
|
||||
{contract.auditWrapper, "Auggie audit wrapper"},
|
||||
}
|
||||
complete := true
|
||||
for _, file := range files {
|
||||
exists, err := regularFileExists(file.path, file.label)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if complete {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAugmentRegistryAndFactory(t *testing.T) {
|
||||
entry := SupportedAgents[augmentAgentKey]
|
||||
if entry.Name != "Augment Code CLI" ||
|
||||
entry.ConfigDir != "~/.augment" ||
|
||||
entry.ConfigFile != "settings.json" {
|
||||
t.Fatalf("Auggie registry entry = %#v", entry)
|
||||
}
|
||||
if _, ok := NewPlugin(augmentAgentKey).(*AugmentPlugin); !ok {
|
||||
t.Fatalf("Auggie plugin factory returned %T", NewPlugin(augmentAgentKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentGeneratedArgumentParsers(t *testing.T) {
|
||||
posixPath := "/tmp/home with spaces/agent's/augment-guard.sh"
|
||||
posixCommand := quotePOSIXArgument(posixPath)
|
||||
parsed, ok := readAugmentPOSIXArgument(posixCommand)
|
||||
if !ok || parsed != posixPath {
|
||||
t.Fatalf("parse POSIX wrapper = %q, %v", parsed, ok)
|
||||
}
|
||||
windowsPath := `C:\home with spaces\.elydora\agent-1\augment-guard.cmd`
|
||||
windowsCommand := quoteAugmentWindowsCommand(windowsPath)
|
||||
parsed, ok = readAugmentWindowsArgument(windowsCommand)
|
||||
if !ok || parsed != windowsPath {
|
||||
t.Fatalf("parse Windows wrapper = %q, %v", parsed, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentInstallPreservesSettingsAndIsIdempotent(t *testing.T) {
|
||||
existing := `{
|
||||
"telemetryEnabled": false,
|
||||
"hooks": {
|
||||
"SessionStart": [{"hooks":[{"type":"command","command":"existing-command","args":["one"],"timeout":5000}],"metadata":{"includeUserContext":true},"label":"keep"}],
|
||||
"PreToolUse": [{"matcher":"launch-process","hooks":[{"type":"command","command":"user-command"}]}]
|
||||
}
|
||||
}`
|
||||
fixture := prepareAugmentFixture(
|
||||
t, augmentFixtureOptions{existingRaw: &existing},
|
||||
)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("repeat Auggie install: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
if settings["telemetryEnabled"] != false {
|
||||
t.Fatalf("telemetry setting changed: %#v", settings)
|
||||
}
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
if len(requireArray(t, hooks["SessionStart"])) != 1 ||
|
||||
len(requireArray(t, hooks["PreToolUse"])) != 2 ||
|
||||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
||||
t.Fatalf("unexpected hooks after repeat install: %#v", hooks)
|
||||
}
|
||||
preGroups := requireArray(t, hooks["PreToolUse"])
|
||||
managedGroup := requireObject(t, preGroups[len(preGroups)-1])
|
||||
if managedGroup["matcher"] != ".*" {
|
||||
t.Fatalf("managed matcher = %#v", managedGroup["matcher"])
|
||||
}
|
||||
guard := augmentTestManagedHandler(
|
||||
t, settings, "PreToolUse", fixture.guardWrapper,
|
||||
)
|
||||
audit := augmentTestManagedHandler(
|
||||
t, settings, "PostToolUse", fixture.auditWrapper,
|
||||
)
|
||||
for _, handler := range []map[string]any{guard, audit} {
|
||||
wantKeys := map[string]struct{}{"type": {}, "command": {}, "timeout": {}}
|
||||
if len(handler) != len(wantKeys) {
|
||||
t.Fatalf("handler has extra fields: %#v", handler)
|
||||
}
|
||||
for key := range handler {
|
||||
if _, exists := wantKeys[key]; !exists {
|
||||
t.Fatalf("handler has unexpected field %q", key)
|
||||
}
|
||||
}
|
||||
if handler["type"] != "command" || handler["timeout"] != augmentHookTimeout {
|
||||
t.Fatalf("unexpected handler: %#v", handler)
|
||||
}
|
||||
}
|
||||
guardWrapper, err := os.ReadFile(fixture.guardWrapper)
|
||||
if err != nil || !strings.Contains(string(guardWrapper), augmentGuardScript) {
|
||||
t.Fatalf("guard wrapper = %q, %v", guardWrapper, err)
|
||||
}
|
||||
auditWrapper, err := os.ReadFile(fixture.auditWrapper)
|
||||
if err != nil || !strings.Contains(string(auditWrapper), augmentAuditScript) {
|
||||
t.Fatalf("audit wrapper = %q, %v", auditWrapper, err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.HasPrefix(string(guardWrapper), "@echo off\r\n") ||
|
||||
!strings.Contains(string(guardWrapper), "exit /b %errorlevel%") {
|
||||
t.Fatalf("invalid Windows wrapper: %q", guardWrapper)
|
||||
}
|
||||
} else {
|
||||
info, statErr := os.Stat(fixture.guardWrapper)
|
||||
if statErr != nil || info.Mode().Perm()&0100 == 0 ||
|
||||
!strings.HasPrefix(string(guardWrapper), "#!/bin/sh\nexec ") {
|
||||
t.Fatalf("invalid POSIX wrapper: %q, %#v, %v", guardWrapper, info, statErr)
|
||||
}
|
||||
}
|
||||
runtimeConfig := readAugmentTestObject(t, fixture.runtimeConfig)
|
||||
if runtimeConfig["agent_name"] != augmentAgentKey {
|
||||
t.Fatalf("runtime agent name = %#v", runtimeConfig["agent_name"])
|
||||
}
|
||||
for _, path := range []string{
|
||||
filepath.Join(fixture.workspaceDir, ".augment", "settings.json"),
|
||||
filepath.Join(fixture.workspaceDir, ".augment", "settings.local.json"),
|
||||
} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace settings written at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentWrappersBlockAndForwardPayloadByteForByte(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||
captureJSON, err := json.Marshal(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal capture path: %v", err)
|
||||
}
|
||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||
"process.stdin.on('end', () => fs.writeFileSync(" +
|
||||
string(captureJSON) + ", Buffer.concat(chunks)));\n"
|
||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
||||
t.Fatalf("write capture hook: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
prePayload := `{"hook_event_name":"PreToolUse","conversation_id":"conversation-1","workspace_roots":["workspace"],"tool_name":"launch-process","tool_input":{"command":"echo test"},"is_mcp_tool":false}`
|
||||
guard := augmentTestManagedHandler(
|
||||
t, settings, "PreToolUse", fixture.guardWrapper,
|
||||
)
|
||||
exitCode, stderr := runAugmentCommand(
|
||||
t, guard["command"].(string), fixture.homeDir, prePayload,
|
||||
)
|
||||
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||
}
|
||||
postPayload := `{"hook_event_name":"PostToolUse","conversation_id":"conversation-1","workspace_roots":["workspace"],"tool_name":"launch-process","tool_input":{"command":"echo test"},"tool_output":"test","is_mcp_tool":false}`
|
||||
audit := augmentTestManagedHandler(
|
||||
t, settings, "PostToolUse", fixture.auditWrapper,
|
||||
)
|
||||
exitCode, stderr = runAugmentCommand(
|
||||
t, audit["command"].(string), fixture.homeDir, postPayload,
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||
}
|
||||
captured, err := os.ReadFile(capturePath)
|
||||
if err != nil || string(captured) != postPayload {
|
||||
t.Fatalf("captured payload = %q, %v", captured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentStatusRequiresPairCoreRuntimesAndWrappers(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured ||
|
||||
!status.HookScriptExists {
|
||||
t.Fatalf("installed status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.Remove(fixture.guardWrapper); err != nil {
|
||||
t.Fatalf("remove guard wrapper: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured ||
|
||||
status.HookScriptExists {
|
||||
t.Fatalf("missing-wrapper status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("restore Auggie hooks: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.hookPath); err != nil {
|
||||
t.Fatalf("remove hook runtime: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookScriptExists {
|
||||
t.Fatalf("missing-runtime status = %#v, %v", status, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("restore Auggie hooks: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
delete(requireObject(t, settings["hooks"]), "PostToolUse")
|
||||
writeAugmentTestObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("incomplete-pair status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentStatusSkipsIncompleteEarlierContract(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
for _, item := range []struct{ event, wrapper string }{
|
||||
{"PreToolUse", augmentGuardWrapperName()},
|
||||
{"PostToolUse", augmentAuditWrapperName()},
|
||||
} {
|
||||
hooks[item.event] = append(
|
||||
requireArray(t, hooks[item.event]),
|
||||
map[string]any{"hooks": []any{buildAugmentHandler(filepath.Join(
|
||||
filepath.Dir(fixture.agentDir), "agent-0", item.wrapper,
|
||||
))}},
|
||||
)
|
||||
}
|
||||
writeAugmentTestObject(t, fixture.configPath, settings)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed {
|
||||
t.Fatalf("status with earlier incomplete contract = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentUninstallPreservesExactUserOwnership(t *testing.T) {
|
||||
existing := `{"owner":"user","hooks":{"Notification":[]}}`
|
||||
fixture := prepareAugmentFixture(
|
||||
t, augmentFixtureOptions{existingRaw: &existing},
|
||||
)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
preGroups := requireArray(t, hooks["PreToolUse"])
|
||||
preGroups = append([]any{map[string]any{
|
||||
"hooks": []any{}, "label": "keep empty group",
|
||||
}}, preGroups...)
|
||||
managedGroup := requireObject(t, preGroups[1])
|
||||
managedGroup["hooks"] = append(
|
||||
requireArray(t, managedGroup["hooks"]),
|
||||
map[string]any{"type": "command", "command": "user-command"},
|
||||
)
|
||||
preGroups = append(preGroups,
|
||||
map[string]any{"hooks": []any{buildAugmentHandler(
|
||||
fixture.guardWrapper + ".backup",
|
||||
)}},
|
||||
map[string]any{"hooks": []any{buildAugmentHandler(filepath.Join(
|
||||
filepath.Dir(fixture.agentDir), "agent-10", augmentGuardWrapperName(),
|
||||
))}},
|
||||
)
|
||||
hooks["PreToolUse"] = preGroups
|
||||
writeAugmentTestObject(t, fixture.configPath, settings)
|
||||
uninstallID := augmentTestAgentID
|
||||
if runtime.GOOS == "windows" {
|
||||
uninstallID = "AGENT-1"
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
|
||||
t.Fatalf("uninstall Auggie hooks: %v", err)
|
||||
}
|
||||
remaining := readAugmentTestObject(t, fixture.configPath)
|
||||
remainingHooks := requireObject(t, remaining["hooks"])
|
||||
if remaining["owner"] != "user" ||
|
||||
len(requireArray(t, remainingHooks["PreToolUse"])) != 4 {
|
||||
t.Fatalf("user settings changed: %#v", remaining)
|
||||
}
|
||||
if !reflect.DeepEqual(requireArray(t, remainingHooks["Notification"]), []any{}) {
|
||||
t.Fatalf("empty notification changed: %#v", remainingHooks["Notification"])
|
||||
}
|
||||
raw, _ := os.ReadFile(fixture.configPath)
|
||||
if !strings.Contains(string(raw), "backup") ||
|
||||
!strings.Contains(string(raw), "agent-10") {
|
||||
t.Fatalf("similar handlers were removed: %s", raw)
|
||||
}
|
||||
if _, exists := remainingHooks["PostToolUse"]; exists {
|
||||
t.Fatalf("managed PostToolUse remains: %#v", remainingHooks["PostToolUse"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentInstallReplacesStaleAndPreservesEmptyGroups(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
settings := readAugmentTestObject(t, fixture.configPath)
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
hooks["PreToolUse"] = append(
|
||||
[]any{map[string]any{"hooks": []any{}, "label": "keep empty group"}},
|
||||
requireArray(t, hooks["PreToolUse"])...,
|
||||
)
|
||||
for _, item := range []struct{ event, wrapper string }{
|
||||
{"PreToolUse", augmentGuardWrapperName()},
|
||||
{"PostToolUse", augmentAuditWrapperName()},
|
||||
} {
|
||||
hooks[item.event] = append(
|
||||
requireArray(t, hooks[item.event]),
|
||||
map[string]any{"hooks": []any{buildAugmentHandler(filepath.Join(
|
||||
filepath.Dir(fixture.agentDir), "agent-old", item.wrapper,
|
||||
))}},
|
||||
)
|
||||
}
|
||||
writeAugmentTestObject(t, fixture.configPath, settings)
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("replace stale Auggie hooks: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(fixture.configPath)
|
||||
current := readAugmentTestObject(t, fixture.configPath)
|
||||
currentHooks := requireObject(t, current["hooks"])
|
||||
if strings.Contains(string(raw), "agent-old") ||
|
||||
len(requireArray(t, currentHooks["PreToolUse"])) != 2 ||
|
||||
len(requireArray(t, currentHooks["PostToolUse"])) != 1 {
|
||||
t.Fatalf("stale hooks remain: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentUninstallRemovesOwnedSettings(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(augmentTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Auggie hooks: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("owned Auggie settings remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentMalformedSettingsPreventRuntimeWrites(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, raw, want string }{
|
||||
{"malformed", "{ malformed", "parse Auggie settings"},
|
||||
{"null-root", "null", "must contain a JSON object"},
|
||||
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
|
||||
{"unknown-event", `{"hooks":{"UnknownEvent":[]}}`, "unsupported event"},
|
||||
{"null-event", `{"hooks":{"PreToolUse":null}}`, "must be an array"},
|
||||
{"null-group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
|
||||
{"session-matcher", `{"hooks":{"SessionStart":[{"matcher":".*","hooks":[]}]}}`, "only supported for tool events"},
|
||||
{"null-matcher", `{"hooks":{"PreToolUse":[{"matcher":null,"hooks":[]}]}}`, "matcher must be a string"},
|
||||
{"invalid-matcher", `{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`, "valid JavaScript regular expression"},
|
||||
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"},
|
||||
{"http-handler", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","command":"x"}]}]}}`, `type must be "command"`},
|
||||
{"empty-command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty command"},
|
||||
{"bad-args", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","args":[1]}]}]}}`, "array of strings"},
|
||||
{"zero-timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite number"},
|
||||
{"bad-metadata", `{"hooks":{"PreToolUse":[{"hooks":[],"metadata":{"includeUserContext":"yes"}}]}}`, "must be a boolean"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(
|
||||
t, augmentFixtureOptions{existingRaw: &testCase.raw},
|
||||
)
|
||||
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)
|
||||
}
|
||||
raw, readErr := os.ReadFile(fixture.configPath)
|
||||
if readErr != nil || string(raw) != testCase.raw {
|
||||
t.Fatalf("original settings changed: %q, %v", raw, readErr)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.hookPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentMissingGuardPreventsWrites(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(
|
||||
t, augmentFixtureOptions{withoutGuard: true},
|
||||
)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.configPath,
|
||||
fixture.hookPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("write occurred at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentStatusSurfacesMalformedRuntimeMetadata(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
fixture.runtimeConfig, []byte("{ malformed"), 0600,
|
||||
); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil ||
|
||||
!strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentAtomicWritesLeaveNoTemporaryFiles(t *testing.T) {
|
||||
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Auggie hooks: %v", err)
|
||||
}
|
||||
for _, directory := range []string{
|
||||
fixture.agentDir, filepath.Dir(fixture.configPath),
|
||||
} {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read directory %s: %v", directory, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("temporary file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const augmentTestAgentID = "agent-1"
|
||||
|
||||
type augmentFixtureOptions struct {
|
||||
existingRaw *string
|
||||
withoutGuard bool
|
||||
}
|
||||
|
||||
type augmentFixture struct {
|
||||
plugin *AugmentPlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
workspaceDir string
|
||||
agentDir string
|
||||
configPath string
|
||||
guardPath string
|
||||
hookPath string
|
||||
guardWrapper string
|
||||
auditWrapper string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
}
|
||||
|
||||
func augmentString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func prepareAugmentFixture(
|
||||
t *testing.T,
|
||||
options augmentFixtureOptions,
|
||||
) *augmentFixture {
|
||||
t.Helper()
|
||||
homeDir := filepath.Join(t.TempDir(), "home with spaces and 'quote")
|
||||
workspaceDir := filepath.Join(homeDir, "workspace")
|
||||
agentDir := filepath.Join(homeDir, ".elydora", augmentTestAgentID)
|
||||
configPath := filepath.Join(homeDir, ".augment", "settings.json")
|
||||
guardPath := filepath.Join(agentDir, augmentGuardScript)
|
||||
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
t.Fatalf("create agent directory: %v", err)
|
||||
}
|
||||
if !options.withoutGuard {
|
||||
source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
|
||||
if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil {
|
||||
t.Fatalf("write guard runtime: %v", err)
|
||||
}
|
||||
}
|
||||
if options.existingRaw != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
t.Fatalf("create Auggie config directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil {
|
||||
t.Fatalf("write Auggie settings: %v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
return &augmentFixture{
|
||||
plugin: &AugmentPlugin{},
|
||||
homeDir: homeDir,
|
||||
workspaceDir: workspaceDir,
|
||||
agentDir: agentDir,
|
||||
config: InstallConfig{
|
||||
AgentName: augmentAgentKey,
|
||||
OrgID: "org-1",
|
||||
AgentID: augmentTestAgentID,
|
||||
PrivateKey: "test-key",
|
||||
KID: "kid-1",
|
||||
BaseURL: "https://api.elydora.test",
|
||||
GuardScriptPath: guardPath,
|
||||
},
|
||||
configPath: configPath,
|
||||
guardPath: guardPath,
|
||||
hookPath: filepath.Join(agentDir, augmentAuditScript),
|
||||
guardWrapper: filepath.Join(agentDir, augmentGuardWrapperName()),
|
||||
auditWrapper: filepath.Join(agentDir, augmentAuditWrapperName()),
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
}
|
||||
}
|
||||
|
||||
func readAugmentTestObject(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeAugmentTestObject(t *testing.T, path string, value map[string]any) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Auggie settings: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatalf("write Auggie settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func augmentTestManagedHandler(
|
||||
t *testing.T,
|
||||
settings map[string]any,
|
||||
event, wrapperPath string,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
expected := buildAugmentCommand(wrapperPath)
|
||||
hooks := requireObject(t, settings["hooks"])
|
||||
for _, groupValue := range requireArray(t, hooks[event]) {
|
||||
group := requireObject(t, groupValue)
|
||||
for _, handlerValue := range requireArray(t, group["hooks"]) {
|
||||
handler := requireObject(t, handlerValue)
|
||||
if handler["command"] == expected {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s handler for %s not found", event, wrapperPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAugmentCommand(
|
||||
t *testing.T,
|
||||
command, homeDir, payload string,
|
||||
) (int, string) {
|
||||
t.Helper()
|
||||
var process *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
commandFile := filepath.Join(t.TempDir(), "run-auggie-hook.cmd")
|
||||
contents := []byte("@echo off\r\n" + command + "\r\n")
|
||||
if err := os.WriteFile(commandFile, contents, 0600); err != nil {
|
||||
t.Fatalf("write Auggie command file: %v", err)
|
||||
}
|
||||
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
||||
} else {
|
||||
process = exec.Command("sh", "-c", command)
|
||||
}
|
||||
process.Env = append(
|
||||
os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir,
|
||||
)
|
||||
process.Stdin = bytes.NewBufferString(payload)
|
||||
var stderr bytes.Buffer
|
||||
process.Stderr = &stderr
|
||||
err := process.Run()
|
||||
if err == nil {
|
||||
return 0, stderr.String()
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return exitError.ExitCode(), stderr.String()
|
||||
}
|
||||
t.Fatalf("run Auggie hook command: %v", err)
|
||||
return -1, stderr.String()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type AgentRegistryEntry struct {
|
||||
|
||||
// SupportedAgents is the canonical registry of all supported agent integrations.
|
||||
var SupportedAgents = map[string]AgentRegistryEntry{
|
||||
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
|
||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
||||
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
|
||||
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||
@@ -26,6 +27,8 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
||||
// Returns nil if the agent is not supported.
|
||||
func NewPlugin(agentName string) AgentPlugin {
|
||||
switch agentName {
|
||||
case "augment":
|
||||
return &AugmentPlugin{}
|
||||
case "claudecode":
|
||||
return &ClaudeCodePlugin{}
|
||||
case "codex":
|
||||
|
||||
Reference in new issue
Block a user