diff --git a/AGENTS.md b/AGENTS.md index f9619e1..3c96a69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ This repository publishes the open-source server, console, integration catalog, - Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit each SDK's Claude Code user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects. - Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit each SDK's Gemini CLI user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects. - Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. -- The Node and Python SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources, followed by effective configuration verification with `auggie tools list`. Go validates matcher syntax with its native regex engine and keeps status and uninstall available for recovery. +- All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. diff --git a/sdks/go/README.md b/sdks/go/README.md index a66c0cc..a16c3d3 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -94,7 +94,7 @@ Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HO Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. -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. +Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. Factory Droid installation selects `PreToolUse` and `PostToolUse` independently across `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, and `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, commits runtime and hook changes as one transaction, and reports disabled hooks as unhealthy. Run `/hooks` in Droid after installation to review the effective hooks. diff --git a/sdks/go/cmd/elydora/augment_cli_test.go b/sdks/go/cmd/elydora/augment_cli_test.go new file mode 100644 index 0000000..507911c --- /dev/null +++ b/sdks/go/cmd/elydora/augment_cli_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestAugmentCLIPreflightPreservesMalformedSettings(t *testing.T) { + homeDir := filepath.Join(t.TempDir(), "home") + settingsPath := filepath.Join(homeDir, ".augment", "settings.json") + keyPath := filepath.Join(t.TempDir(), "private.key") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil { + t.Fatalf("create Auggie settings directory: %v", err) + } + if err := os.WriteFile(settingsPath, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("write malformed Auggie settings: %v", err) + } + privateKey := "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" + if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil { + t.Fatalf("write private key: %v", err) + } + command := exec.Command( + "go", + "run", + ".", + "install", + "--agent", + "augment", + "--org-id", + "org-1", + "--agent-id", + "agent-1", + "--private-key-file", + keyPath, + "--kid", + "kid-1", + "--base-url", + "https://api.elydora.com", + ) + command.Env = append( + os.Environ(), + "HOME="+homeDir, + "USERPROFILE="+homeDir, + ) + output, err := command.CombinedOutput() + if err == nil || !strings.Contains( + string(output), + "parse Auggie user settings", + ) { + t.Fatalf("CLI preflight result = %v\n%s", err, output) + } + raw, readErr := os.ReadFile(settingsPath) + if readErr != nil || string(raw) != "{ malformed" { + t.Fatalf("malformed settings changed: %q, %v", raw, readErr) + } + if _, statErr := os.Lstat(filepath.Join(homeDir, ".elydora")); !errors.Is( + statErr, + os.ErrNotExist, + ) { + t.Fatalf("runtime directory created during failed preflight: %v", statErr) + } +} diff --git a/sdks/go/cmd/elydora/plugins/augment.go b/sdks/go/cmd/elydora/plugins/augment.go index a7e4f39..b09a1f4 100644 --- a/sdks/go/cmd/elydora/plugins/augment.go +++ b/sdks/go/cmd/elydora/plugins/augment.go @@ -1,137 +1,139 @@ package plugins -import "fmt" +import ( + "fmt" + "path/filepath" +) // AugmentPlugin manages Auggie native global user hooks. -type AugmentPlugin struct{} +type AugmentPlugin struct { + rename renameFunc +} + +// ManagesGuardRuntime reports that Auggie commits its provider guard together +// with both wrappers, the audit runtime, and user settings. +func (p *AugmentPlugin) ManagesGuardRuntime() bool { + return true +} + +// PreflightInstall validates settings and runtime identity before any write. +func (p *AugmentPlugin) PreflightInstall(config InstallConfig) error { + document, err := readAugmentDocument() + if err != nil { + return err + } + _, _, err = preflightAugmentInstallation(config, document) + return err +} func (p *AugmentPlugin) Install(config InstallConfig) error { - if config.AgentID == "" { - return fmt.Errorf("agent ID is required") - } - document, err := readAugmentConfig() + document, err := readAugmentDocument() if err != nil { return err } - if config.GuardScriptPath == "" { - return fmt.Errorf("guard script path is required") - } - guardExists, err := regularFileExists( - config.GuardScriptPath, "Elydora guard runtime", - ) + paths, nodePath, err := preflightAugmentInstallation(config, document) 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, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot) hooks["PreToolUse"] = append( hooks["PreToolUse"], - buildAugmentGroup(buildAugmentHandler(wrappers.guard)), + buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)), ) hooks["PostToolUse"] = append( hooks["PostToolUse"], - buildAugmentGroup(buildAugmentHandler(wrappers.audit)), + buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)), ) - 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) + rendered, err := renderAugmentDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Auggie user settings: %w", err) } - if err := writeHookFileAtomic( - wrappers.guard, - buildAugmentWrapper(nodePath, config.GuardScriptPath), - 0700, + changes, err := prepareAugmentInstallationChanges( + config, + paths, + nodePath, + rendered, + ) + if err != nil { + return err + } + if err := writeAugmentChanges( + changes, + "Install Augment Code CLI hooks", + p.rename, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.configPath), ); 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) + return 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 { + document, err := readAugmentDocument() + if err != nil { return err } - runtimeRoot, err := augmentRuntimeRoot() + if !document.exists { + return nil + } + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return err } hooks, changed := removeManagedAugmentHooks( - document.hooks, agentID, runtimeRoot, + document.hooks, + agentID, + runtimeRoot, ) if !changed { return nil } - next := cloneAugmentObject(document.root) - if len(hooks) == 0 { - delete(next, "hooks") - } else { - next["hooks"] = renderAugmentHooks(hooks) + rendered, err := renderAugmentDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Auggie user settings: %w", err) } - if len(next) == 0 { - return removeHookFile(document.configPath, "Auggie settings") + change, err := prepareRenderedAugmentChange(rendered) + if err != nil { + return err } - return writeHookJSONObjectAtomic(document.configPath, next) + return writeAugmentChanges( + []*fileChange{change}, + "Uninstall Augment Code CLI hooks", + p.rename, + "", + "", + filepath.Dir(document.configPath), + ) } func (p *AugmentPlugin) Status() (PluginStatus, error) { - document, err := readAugmentConfig() + configPath, pathErr := augmentConfigPath() + entry := SupportedAgents[augmentAgentKey] status := PluginStatus{ - AgentName: augmentAgentKey, - DisplayName: "Augment Code CLI", - ConfigPath: document.configPath, + AgentName: augmentAgentKey, DisplayName: entry.Name, ConfigPath: configPath, } + if pathErr != nil { + return status, pathErr + } + document, err := readAugmentDocument() if err != nil { return status, err } - runtimeRoot, err := augmentRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return status, err } contracts := augmentRuntimeContracts(document.hooks, runtimeRoot) - if len(contracts) == 0 { + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { return status, nil } - status.HookConfigured = true status.HookScriptExists, err = augmentRuntimeFilesExist( - contracts, runtimeRoot, + contracts, + runtimeRoot, ) if err != nil { return status, err diff --git a/sdks/go/cmd/elydora/plugins/augment_contract.go b/sdks/go/cmd/elydora/plugins/augment_contract.go index b157e59..ddab87f 100644 --- a/sdks/go/cmd/elydora/plugins/augment_contract.go +++ b/sdks/go/cmd/elydora/plugins/augment_contract.go @@ -1,9 +1,12 @@ package plugins import ( + "bytes" + "encoding/json" "fmt" "math" "path/filepath" + "reflect" "runtime" "strings" ) @@ -42,6 +45,14 @@ type augmentDocument struct { configPath string root map[string]any hooks augmentHooks + raw []byte +} + +type augmentRenderedDocument struct { + document *augmentDocument + changed bool + next []byte + remove bool } type augmentWrapperPaths struct { @@ -200,6 +211,71 @@ func readAugmentHooks(root map[string]any) (augmentHooks, error) { return hooks, nil } +func parseAugmentDocument(configPath string, raw []byte) (*augmentDocument, error) { + label := fmt.Sprintf("Auggie user settings at %s", configPath) + root, err := decodeStrictJSONObject(raw, label) + if err != nil { + return nil, err + } + hooks, err := readAugmentHooks(root) + if err != nil { + return nil, err + } + return &augmentDocument{ + exists: true, configPath: configPath, root: root, hooks: hooks, + raw: append([]byte(nil), raw...), + }, nil +} + +func createAugmentDocument(configPath string) *augmentDocument { + return &augmentDocument{ + configPath: configPath, + root: map[string]any{}, + hooks: augmentHooks{}, + } +} + +func renderAugmentDocument( + document *augmentDocument, + hooks augmentHooks, +) (*augmentRenderedDocument, error) { + if document == nil { + return nil, fmt.Errorf("Auggie settings document is required") + } + if reflect.DeepEqual(hooks, document.hooks) { + return &augmentRenderedDocument{document: document}, nil + } + if !document.exists && len(hooks) == 0 { + return &augmentRenderedDocument{document: document}, nil + } + root := cloneAugmentObject(document.root) + if len(hooks) == 0 { + delete(root, "hooks") + } else { + root["hooks"] = renderAugmentHooks(hooks) + } + if len(root) == 0 { + return &augmentRenderedDocument{ + document: document, + changed: true, + remove: true, + }, nil + } + next, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Auggie user settings: %w", err) + } + next = append(next, '\n') + if _, err := parseAugmentDocument(document.configPath, next); err != nil { + return nil, fmt.Errorf("validate rendered Auggie user settings: %w", err) + } + return &augmentRenderedDocument{ + document: document, + changed: !document.exists || !bytes.Equal(next, document.raw), + next: next, + }, nil +} + func renderAugmentHooks(hooks augmentHooks) map[string]any { result := make(map[string]any, len(hooks)) for event, groups := range hooks { diff --git a/sdks/go/cmd/elydora/plugins/augment_installation.go b/sdks/go/cmd/elydora/plugins/augment_installation.go new file mode 100644 index 0000000..43e030a --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/augment_installation.go @@ -0,0 +1,226 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type augmentRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string + guardWrapperPath string + auditWrapperPath string +} + +func validateAugmentInstallConfig(config InstallConfig) error { + for _, field := range []struct{ name, value string }{ + {"agent name", config.AgentName}, + {"organization ID", config.OrgID}, + {"agent ID", config.AgentID}, + {"key ID", config.KID}, + {"private key", config.PrivateKey}, + {"base URL", config.BaseURL}, + {"guard script path", config.GuardScriptPath}, + } { + if field.value == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if config.AgentName != augmentAgentKey { + return fmt.Errorf( + "Augment Code CLI installation requires agent name %s", + augmentAgentKey, + ) + } + if strings.TrimSpace(config.OrgID) == "" { + return fmt.Errorf("organization ID is required") + } + if strings.TrimSpace(config.KID) == "" { + return fmt.Errorf("key ID is required") + } + if config.Token != "" && strings.TrimSpace(config.Token) == "" { + return fmt.Errorf("token must contain a non-whitespace value when provided") + } + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { + return err + } + return validateManagedBaseURL(config.BaseURL) +} + +func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) { + if err := validateAugmentInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + wrappers := resolveAugmentWrapperPaths(agentDirectory) + paths := &augmentRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, augmentGuardScript), + auditPath: filepath.Join(agentDirectory, augmentAuditScript), + guardWrapperPath: wrappers.guard, + auditWrapperPath: wrappers.audit, + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameAugmentPath(config.GuardScriptPath, paths.guardPath) { + return nil, fmt.Errorf( + "Elydora guard runtime must use the managed agent directory: %s", + paths.guardPath, + ) + } + if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || + !sameAugmentPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "Elydora audit runtime must use the managed agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightAugmentInstallation( + config InstallConfig, + document *augmentDocument, +) (*augmentRuntimePaths, string, error) { + if document == nil || document.configPath == "" { + return nil, "", fmt.Errorf( + "Augment Code CLI installation requires a user settings document", + ) + } + paths, err := augmentAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateAugmentRuntimeIdentity( + paths.agentDirectory, + config.AgentID, + ); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + if err := requireAugmentAbsoluteNode(nodePath); err != nil { + return nil, "", err + } + if err := validateAugmentMatchers(document.hooks, nodePath); err != nil { + return nil, "", err + } + return paths, nodePath, nil +} + +func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) { + value := map[string]any{ + "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, + "base_url": config.BaseURL, "agent_name": augmentAgentKey, + } + if config.Token != "" { + value["token"] = config.Token + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + encoded = append(encoded, '\n') + if len(encoded) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "Elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + return encoded, nil +} + +func prepareAugmentInstallationChanges( + config InstallConfig, + paths *augmentRuntimePaths, + nodePath string, + rendered *augmentRenderedDocument, +) ([]*fileChange, error) { + if paths == nil || rendered == nil { + return nil, fmt.Errorf("prepared Auggie installation is required") + } + if err := validateAugmentRuntimeIdentity( + paths.agentDirectory, + config.AgentID, + ); err != nil { + return nil, err + } + runtimeConfig, err := buildAugmentRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, + "Elydora guard runtime", + []byte(generateGuardScript( + augmentAgentKey, + config.AgentID, + "", + false, + "", + )), + 0700, + }, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, + { + paths.auditPath, + "Elydora audit runtime", + []byte(buildHookScriptWithOutput( + augmentAgentKey, + config.AgentID, + "", + false, + true, + )), + 0700, + }, + { + paths.guardWrapperPath, + "Auggie guard wrapper", + buildAugmentWrapper(nodePath, paths.guardPath), + 0700, + }, + { + paths.auditWrapperPath, + "Auggie audit wrapper", + buildAugmentWrapper(nodePath, paths.auditPath), + 0700, + }, + } + changes := make([]*fileChange, 0, len(items)+1) + for _, item := range items { + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + documentChange, err := prepareRenderedAugmentChange(rendered) + if err != nil { + return nil, err + } + return append(changes, documentChange), nil +} diff --git a/sdks/go/cmd/elydora/plugins/augment_io.go b/sdks/go/cmd/elydora/plugins/augment_io.go index dc731db..40aeff9 100644 --- a/sdks/go/cmd/elydora/plugins/augment_io.go +++ b/sdks/go/cmd/elydora/plugins/augment_io.go @@ -33,49 +33,91 @@ func augmentAuditWrapperName() string { return "augment-hook.sh" } -func augmentHomeDirectory() (string, error) { +func augmentConfigPath() (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) { +func readAugmentDocument() (*augmentDocument, error) { configPath, err := augmentConfigPath() if err != nil { - return augmentDocument{}, err + return nil, err } - root, exists, err := readHookJSONObject(configPath, "Auggie settings") + if _, err := managedPhysicalDirectoryExists( + filepath.Dir(configPath), + "Auggie configuration directory", + ); err != nil { + return nil, err + } + snapshot, err := readManagedFile( + configPath, + "Auggie user settings", + maxManagedSourceBytes, + ) if err != nil { - return augmentDocument{}, err + return nil, err } - hooks, err := readAugmentHooks(root) - if err != nil { - return augmentDocument{}, err + if snapshot == nil { + return createAugmentDocument(configPath), nil } - return augmentDocument{ - exists: exists, - configPath: configPath, - root: root, - hooks: hooks, - }, nil + return parseAugmentDocument(configPath, snapshot.contents) +} + +func prepareRenderedAugmentChange( + rendered *augmentRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.configPath, + "Auggie user settings", + rendered.document.raw, + rendered.document.exists, + rendered.next, + 0600, + rendered.remove, + ) +} + +func writeAugmentChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot string, + agentDirectory string, + settingsDirectory string, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break + } + } + if !hasChanges { + return nil + } + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err + } + } + if settingsDirectory != "" { + if err := ensureManagedDirectory( + settingsDirectory, + "Auggie configuration directory", + ); err != nil { + return err + } + } + return writeChanges(changes, label, rename) } func validateAugmentMatchers(hooks augmentHooks, nodePath string) error { @@ -106,7 +148,9 @@ func validateAugmentMatchers(hooks augmentHooks, nodePath string) error { return fmt.Errorf( "Auggie settings group hooks.%s[%d] matcher "+ "must be a valid JavaScript regular expression: %s", - event, groupIndex, message, + event, + groupIndex, + message, ) } } @@ -159,134 +203,16 @@ func buildAugmentGroup(handler map[string]any) augmentGroup { } } -func resolveAugmentWrapperPaths(runtimeRoot, agentID string) augmentWrapperPaths { - agentDirectory := filepath.Join(runtimeRoot, agentID) +func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths { 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 - } - } +func requireAugmentAbsoluteNode(nodePath string) error { + if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { + return fmt.Errorf("Auggie hooks require an absolute Node.js executable path") } - 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 + return nil } diff --git a/sdks/go/cmd/elydora/plugins/augment_official_test.go b/sdks/go/cmd/elydora/plugins/augment_official_test.go new file mode 100644 index 0000000..44138d8 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/augment_official_test.go @@ -0,0 +1,53 @@ +package plugins + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestAugmentOfficialBinaryAcceptsInstalledUserHooks(t *testing.T) { + entry := os.Getenv("ELYDORA_AUGGIE_ENTRY") + if entry == "" { + t.Skip("ELYDORA_AUGGIE_ENTRY is unset") + } + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + environment := append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + ) + version := exec.Command(nodePath, entry, "--version") + version.Dir = fixture.projectDir + version.Env = environment + versionOutput, err := version.CombinedOutput() + if err != nil || !strings.Contains(string(versionOutput), "0.33.0") { + t.Fatalf("official Auggie version: %v\n%s", err, versionOutput) + } + load := exec.Command(nodePath, entry, "tools", "list") + load.Dir = fixture.projectDir + load.Env = environment + loadOutput, err := load.CombinedOutput() + if err != nil { + t.Fatalf("official Auggie settings load: %v\n%s", err, loadOutput) + } + output := strings.ToLower(string(loadOutput)) + for _, problem := range []string{ + "invalid settings", + "settings validation", + "failed to parse", + "hook configuration error", + } { + if strings.Contains(output, problem) { + t.Fatalf("official Auggie loader reported %q:\n%s", problem, loadOutput) + } + } +} diff --git a/sdks/go/cmd/elydora/plugins/augment_runtime_test.go b/sdks/go/cmd/elydora/plugins/augment_runtime_test.go new file mode 100644 index 0000000..c071d31 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/augment_runtime_test.go @@ -0,0 +1,290 @@ +package plugins + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func installAugmentRuntimeFixture( + t *testing.T, + status string, +) (*augmentFixture, *codexTestAPI, *httptest.Server) { + t.Helper() + api := &codexTestAPI{status: status} + server := httptest.NewServer(api) + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + fixture.config.BaseURL = server.URL + if err := fixture.plugin.Install(fixture.config); err != nil { + server.Close() + t.Fatalf("install Auggie hooks: %v", err) + } + return fixture, api, server +} + +func augmentOfficialPayload(event string) map[string]any { + value := map[string]any{ + "hook_event_name": event, + "conversation_id": "conversation-1", + "workspace_roots": []any{"C:/workspace"}, + "tool_name": "launch-process", + "tool_input": map[string]any{ + "command": "go test ./...", + "nested": map[string]any{"preserve": true}, + }, + "is_mcp_tool": false, + "conversation_data": []any{ + map[string]any{"role": "user", "content": "preserve this"}, + }, + "mcp_metadata": map[string]any{ + "server": "local", "transport": "stdio", + }, + "user_context": map[string]any{"account": "user-1"}, + "future_field": map[string]any{"survives": []any{"exactly", float64(2)}}, + } + if event == "PostToolUse" { + value["tool_output"] = map[string]any{ + "stdout": "passed", "stderr": "", "exit_code": float64(0), + } + } + return value +} + +func marshalAugmentPayload(t *testing.T, payload map[string]any) []byte { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Auggie payload: %v", err) + } + return encoded +} + +func TestAugmentGuardPropagatesOfficialBlockingExitCode(t *testing.T) { + for _, status := range []string{"frozen", "revoked"} { + t.Run(status, func(t *testing.T) { + fixture, _, server := installAugmentRuntimeFixture(t, status) + defer server.Close() + handler := augmentTestManagedHandler( + t, + readAugmentTestObject(t, fixture.configPath), + "PreToolUse", + fixture.guardWrapper, + ) + exit, stdout, stderr := runAugmentCommand( + t, + handler["command"].(string), + fixture, + marshalAugmentPayload(t, augmentOfficialPayload("PreToolUse")), + ) + if exit != 2 || stdout != "" || + !strings.Contains(strings.ToLower(stderr), status) || + !strings.Contains(stderr, "Tool execution blocked") { + t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr) + } + }) + } +} + +func TestAugmentAuditPreservesCompleteNativePayload(t *testing.T) { + fixture, api, server := installAugmentRuntimeFixture(t, "active") + defer server.Close() + payload := augmentOfficialPayload("PostToolUse") + handler := augmentTestManagedHandler( + t, + readAugmentTestObject(t, fixture.configPath), + "PostToolUse", + fixture.auditWrapper, + ) + exit, stdout, stderr := runAugmentCommand( + t, + handler["command"].(string), + fixture, + marshalAugmentPayload(t, payload), + ) + if exit != 0 || stdout != "" || stderr != "" { + t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr) + } + _, postAuth, operations := api.snapshot() + if postAuth != "Bearer token-1" || + len(operations) != 1 || + !reflect.DeepEqual(operations[0]["payload"], payload) { + t.Fatalf("Auggie native operation = auth %q, %#v", postAuth, operations) + } + if operations[0]["prev_chain_hash"] != + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" { + t.Fatalf("Auggie operation chain = %#v", operations[0]) + } +} + +func TestAugmentRuntimeFailuresRemainObservableAndFailOpen(t *testing.T) { + fixture, _, server := installAugmentRuntimeFixture(t, "active") + settings := readAugmentTestObject(t, fixture.configPath) + guard := augmentTestManagedHandler( + t, + settings, + "PreToolUse", + fixture.guardWrapper, + ) + audit := augmentTestManagedHandler( + t, + settings, + "PostToolUse", + fixture.auditWrapper, + ) + server.Close() + guardExit, _, guardError := runAugmentCommand( + t, + guard["command"].(string), + fixture, + marshalAugmentPayload(t, augmentOfficialPayload("PreToolUse")), + ) + auditExit, _, _ := runAugmentCommand( + t, + audit["command"].(string), + fixture, + marshalAugmentPayload(t, augmentOfficialPayload("PostToolUse")), + ) + malformedExit, _, _ := runAugmentCommand( + t, + audit["command"].(string), + fixture, + []byte("{ malformed"), + ) + log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log")) + if guardExit != 0 || + !strings.Contains(guardError, "Failed to resolve agent status") || + auditExit != 0 || + malformedExit != 0 || + err != nil || + !strings.Contains(string(log), "invalid JSON") || + !strings.Contains(strings.ToLower(string(log)), "fetch failed") { + t.Fatalf( + "fail-open results = (%d, %q), %d, %d, log=%q, err=%v", + guardExit, + guardError, + auditExit, + malformedExit, + log, + err, + ) + } +} + +func TestAugmentStatusRequiresExactRuntimeFiles(t *testing.T) { + for _, name := range []string{ + "config.json", + "private.key", + augmentGuardScript, + augmentAuditScript, + augmentGuardWrapperName(), + augmentAuditWrapperName(), + } { + t.Run("missing "+name, func(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) + } + path := filepath.Join(fixture.agentDir, name) + if err := os.Remove(path); err != nil { + t.Fatalf("remove %s: %v", path, err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || + status.HookScriptExists { + t.Fatalf("missing %s status = %#v, %v", path, status, err) + } + }) + } + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks for wrapper check: %v", err) + } + writeAugmentTestFile( + t, + fixture.auditWrapper, + []byte("tampered wrapper\n"), + 0700, + ) + status, err := fixture.plugin.Status() + if err != nil || status.Installed || status.HookScriptExists { + t.Fatalf("tampered-wrapper status = %#v, %v", status, err) + } + fixture = prepareAugmentFixture(t, augmentFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks for key check: %v", err) + } + writeAugmentTestFile(t, fixture.privateKey, []byte("invalid"), 0600) + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "canonical 32-byte") { + t.Fatalf("invalid-key status error = %v", err) + } +} + +func TestAugmentStatusSurfacesInvalidRuntimeMetadata(t *testing.T) { + for _, testCase := range []struct{ name, source, want string }{ + {"malformed", "{ malformed", "parse Elydora runtime config"}, + {"duplicate", `{"agent_name":"augment","agent_name":"augment"}`, "duplicate key"}, + { + "unsupported", + `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"augment","extra":true}`, + "unsupported field", + }, + { + "identity", + `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"augment"}`, + "identity does not match", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + writeAugmentTestFile( + t, + fixture.runtimeConfig, + []byte(testCase.source), + 0600, + ) + _, err := fixture.plugin.Status() + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("status error = %v, want %q", err, testCase.want) + } + }) + } +} + +func TestAugmentStatusIgnoresIncompleteManagedHookPairs(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) + 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 TestAugmentRuntimeConfigOmitsEmptyToken(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + fixture.config.Token = "" + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + config := readAugmentTestObject(t, fixture.runtimeConfig) + if _, exists := config["token"]; exists { + t.Fatalf("empty token persisted: %#v", config) + } +} diff --git a/sdks/go/cmd/elydora/plugins/augment_status.go b/sdks/go/cmd/elydora/plugins/augment_status.go new file mode 100644 index 0000000..c99b4cd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/augment_status.go @@ -0,0 +1,354 @@ +package plugins + +import ( + "bytes" + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func managedAugmentIDs( + groups []augmentGroup, + wrapperName string, + 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 { + continue + } + 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) + wrappers := resolveAugmentWrapperPaths(agentDirectory) + contracts = append(contracts, augmentRuntimeContract{ + agentID: agentID, + guardPath: filepath.Join(agentDirectory, augmentGuardScript), + auditPath: filepath.Join(agentDirectory, augmentAuditScript), + guardWrapper: wrappers.guard, + auditWrapper: wrappers.audit, + }) + } + return contracts +} + +func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile( + path, + "Elydora runtime config", + maxRuntimeConfigBytes, + ) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err + } + label := fmt.Sprintf("Elydora runtime config at %s", path) + config, err := decodeStrictJSONObject(snapshot.contents, label) + return config, true, err +} + +func requireAugmentRuntimeString( + config map[string]any, + field string, + configPath string, +) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf( + "Elydora runtime config %s is invalid: %s", + field, + configPath, + ) + } + return value, nil +} + +func validateAugmentRuntimeConfig( + config map[string]any, + expectedAgentID string, + configPath string, +) error { + supported := stringSet( + "org_id", + "agent_id", + "kid", + "base_url", + "token", + "agent_name", + ) + fields := make([]string, 0, len(config)) + for field := range config { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := supported[field]; !ok { + return fmt.Errorf( + `Elydora runtime config has unsupported field %q: %s`, + field, + configPath, + ) + } + } + if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameAugmentAgentID(agentID, expectedAgentID) || + config["agent_name"] != augmentAgentKey { + return fmt.Errorf( + "Elydora runtime identity does not match Auggie hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath) + if err != nil { + return err + } + if err := validateManagedBaseURL(baseURL); err != nil { + return fmt.Errorf( + "Elydora runtime config base URL is invalid at %s: %w", + configPath, + err, + ) + } + return nil +} + +func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error { + runtimeRoot := filepath.Dir(agentDirectory) + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return err + } + configPath := filepath.Join(agentDirectory, "config.json") + config, configExists, err := readAugmentRuntimeConfig(configPath) + if err != nil { + return err + } + artifactExists := false + for _, item := range []struct { + path string + label string + limit int64 + }{ + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + {filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + } { + exists, inspectErr := managedPhysicalFileExists( + item.path, + item.label, + item.limit, + ) + if inspectErr != nil { + return inspectErr + } + artifactExists = artifactExists || exists + } + if !configExists { + if artifactExists { + return fmt.Errorf( + "Elydora runtime identity cannot be verified without config.json: %s", + agentDirectory, + ) + } + return nil + } + configuredID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != augmentAgentKey || + !sameAugmentAgentID(configuredID, agentID) { + return fmt.Errorf( + "Elydora runtime config identity does not match Auggie agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func augmentRuntimeContractExists( + contract augmentRuntimeContract, + runtimeRoot string, + nodePath string, +) (bool, error) { + agentDirectory := filepath.Dir(contract.guardPath) + wrappers := resolveAugmentWrapperPaths(agentDirectory) + if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameAugmentPath( + contract.auditPath, + filepath.Join(agentDirectory, augmentAuditScript), + ) || + !sameAugmentPath(contract.guardWrapper, wrappers.guard) || + !sameAugmentPath(contract.auditWrapper, wrappers.audit) { + return false, nil + } + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return false, err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return false, err + } + configPath := filepath.Join(agentDirectory, "config.json") + keyPath := filepath.Join(agentDirectory, "private.key") + config, configExists, err := readAugmentRuntimeConfig(configPath) + if err != nil { + return false, err + } + key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) + if err != nil { + return false, err + } + guard, err := readManagedFile( + contract.guardPath, + "Elydora guard runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + audit, err := readManagedFile( + contract.auditPath, + "Elydora audit runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + guardWrapper, err := readManagedFile( + contract.guardWrapper, + "Auggie guard wrapper", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + auditWrapper, err := readManagedFile( + contract.auditWrapper, + "Auggie audit wrapper", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + if !configExists || key == nil || guard == nil || audit == nil || + guardWrapper == nil || auditWrapper == nil { + return false, nil + } + if err := validateAugmentRuntimeConfig( + config, + contract.agentID, + configPath, + ); err != nil { + return false, err + } + if err := validateManagedPrivateKey(string(key.contents)); err != nil { + return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) + } + return len(guard.contents) > 0 && + len(audit.contents) > 0 && + bytes.Equal( + guardWrapper.contents, + buildAugmentWrapper(nodePath, contract.guardPath), + ) && + bytes.Equal( + auditWrapper.contents, + buildAugmentWrapper(nodePath, contract.auditPath), + ), nil +} + +func augmentRuntimeFilesExist( + contracts []augmentRuntimeContract, + runtimeRoot string, +) (bool, error) { + nodePath, err := resolveNodeRuntime() + if err != nil { + return false, err + } + if err := requireAugmentAbsoluteNode(nodePath); err != nil { + return false, err + } + for _, contract := range contracts { + exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/sdks/go/cmd/elydora/plugins/augment_test.go b/sdks/go/cmd/elydora/plugins/augment_test.go index dad7762..b23b771 100644 --- a/sdks/go/cmd/elydora/plugins/augment_test.go +++ b/sdks/go/cmd/elydora/plugins/augment_test.go @@ -1,7 +1,7 @@ package plugins import ( - "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -10,16 +10,20 @@ import ( "testing" ) -func TestAugmentRegistryAndFactory(t *testing.T) { +func TestAugmentRegistryFactoryAndGuardOwnership(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 { + plugin, ok := NewPlugin(augmentAgentKey).(*AugmentPlugin) + if !ok { t.Fatalf("Auggie plugin factory returned %T", NewPlugin(augmentAgentKey)) } + if !plugin.ManagesGuardRuntime() { + t.Fatal("Auggie plugin must own guard runtime generation") + } } func TestAugmentGeneratedArgumentParsers(t *testing.T) { @@ -46,7 +50,8 @@ func TestAugmentInstallPreservesSettingsAndIsIdempotent(t *testing.T) { } }` fixture := prepareAugmentFixture( - t, augmentFixtureOptions{existingRaw: &existing}, + t, + augmentFixtureOptions{existingRaw: &existing}, ) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Auggie hooks: %v", err) @@ -64,178 +69,77 @@ func TestAugmentInstallPreservesSettingsAndIsIdempotent(t *testing.T) { 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]) + managedGroup := requireObject( + t, + requireArray(t, hooks["PreToolUse"])[1], + ) if managedGroup["matcher"] != ".*" { t.Fatalf("managed matcher = %#v", managedGroup["matcher"]) } guard := augmentTestManagedHandler( - t, settings, "PreToolUse", fixture.guardWrapper, + t, + settings, + "PreToolUse", + fixture.guardWrapper, ) audit := augmentTestManagedHandler( - t, settings, "PostToolUse", fixture.auditWrapper, + 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) + want := map[string]any{ + "type": "command", "command": handler["command"], + "timeout": augmentHookTimeout, } - 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) + if !reflect.DeepEqual(handler, want) { + t.Fatalf("managed 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) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", 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) + for _, item := range []struct { + path, script string + }{ + {fixture.guardWrapper, fixture.guardPath}, + {fixture.auditWrapper, fixture.hookPath}, + } { + actual, err := os.ReadFile(item.path) + if err != nil { + t.Fatalf("read wrapper %s: %v", item.path, err) } - } 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) + want := buildAugmentWrapper(nodePath, item.script) + if string(actual) != string(want) { + t.Fatalf("wrapper %s = %q, want %q", item.path, actual, want) } } runtimeConfig := readAugmentTestObject(t, fixture.runtimeConfig) - if runtimeConfig["agent_name"] != augmentAgentKey { - t.Fatalf("runtime agent name = %#v", runtimeConfig["agent_name"]) + if runtimeConfig["agent_name"] != augmentAgentKey || + runtimeConfig["agent_id"] != augmentTestAgentID { + t.Fatalf("runtime identity = %#v", runtimeConfig) + } + auditSource, err := os.ReadFile(fixture.hookPath) + if err != nil || !strings.Contains(string(auditSource), "const NATIVE_PAYLOAD = true;") { + t.Fatalf("audit runtime native payload = %v, %v", err, strings.Contains(string(auditSource), "const NATIVE_PAYLOAD = true;")) } for _, path := range []string{ - filepath.Join(fixture.workspaceDir, ".augment", "settings.json"), - filepath.Join(fixture.workspaceDir, ".augment", "settings.local.json"), + filepath.Join(fixture.projectDir, ".augment", "settings.json"), + filepath.Join(fixture.projectDir, ".augment", "settings.local.json"), } { - if _, err := os.Stat(path); !os.IsNotExist(err) { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { 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}, + t, + augmentFixtureOptions{existingRaw: &existing}, ) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Auggie hooks: %v", err) @@ -251,12 +155,15 @@ func TestAugmentUninstallPreservesExactUserOwnership(t *testing.T) { requireArray(t, managedGroup["hooks"]), map[string]any{"type": "command", "command": "user-command"}, ) - preGroups = append(preGroups, + 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(), + filepath.Dir(fixture.agentDir), + "agent-10", + augmentGuardWrapperName(), ))}}, ) hooks["PreToolUse"] = preGroups @@ -274,7 +181,10 @@ func TestAugmentUninstallPreservesExactUserOwnership(t *testing.T) { len(requireArray(t, remainingHooks["PreToolUse"])) != 4 { t.Fatalf("user settings changed: %#v", remaining) } - if !reflect.DeepEqual(requireArray(t, remainingHooks["Notification"]), []any{}) { + if !reflect.DeepEqual( + requireArray(t, remainingHooks["Notification"]), + []any{}, + ) { t.Fatalf("empty notification changed: %#v", remainingHooks["Notification"]) } raw, _ := os.ReadFile(fixture.configPath) @@ -287,7 +197,7 @@ func TestAugmentUninstallPreservesExactUserOwnership(t *testing.T) { } } -func TestAugmentInstallReplacesStaleAndPreservesEmptyGroups(t *testing.T) { +func TestAugmentInstallReplacesStaleHooksAndPreservesEmptyGroups(t *testing.T) { fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Auggie hooks: %v", err) @@ -305,7 +215,9 @@ func TestAugmentInstallReplacesStaleAndPreservesEmptyGroups(t *testing.T) { 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, + filepath.Dir(fixture.agentDir), + "agent-old", + item.wrapper, ))}}, ) } @@ -331,14 +243,17 @@ func TestAugmentUninstallRemovesOwnedSettings(t *testing.T) { if err := fixture.plugin.Uninstall(augmentTestAgentID); err != nil { t.Fatalf("uninstall Auggie hooks: %v", err) } - if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) { + if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { 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"}, + {"malformed", "{ malformed", "parse Auggie user settings"}, + {"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate key"}, + {"comments", "{\"hooks\":{} // comment\n}", "invalid character"}, + {"trailing-comma", `{"hooks":{},}`, "invalid character"}, {"null-root", "null", "must contain a JSON object"}, {"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`}, {"unknown-event", `{"hooks":{"UnknownEvent":[]}}`, "unsupported event"}, @@ -356,7 +271,8 @@ func TestAugmentMalformedSettingsPreventRuntimeWrites(t *testing.T) { } { t.Run(testCase.name, func(t *testing.T) { fixture := prepareAugmentFixture( - t, augmentFixtureOptions{existingRaw: &testCase.raw}, + t, + augmentFixtureOptions{existingRaw: &testCase.raw}, ) err := fixture.plugin.Install(fixture.config) if err == nil || !strings.Contains(err.Error(), testCase.want) { @@ -366,75 +282,7 @@ func TestAugmentMalformedSettingsPreventRuntimeWrites(t *testing.T) { 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) - } - } + assertNoAugmentRuntimeWrites(t, fixture) }) } } - -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()) - } - } - } -} diff --git a/sdks/go/cmd/elydora/plugins/augment_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/augment_test_helpers_test.go index d68c794..d2c8080 100644 --- a/sdks/go/cmd/elydora/plugins/augment_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/augment_test_helpers_test.go @@ -11,18 +11,20 @@ import ( "testing" ) -const augmentTestAgentID = "agent-1" +const ( + augmentTestAgentID = "agent-1" + augmentPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" +) type augmentFixtureOptions struct { - existingRaw *string - withoutGuard bool + existingRaw *string } type augmentFixture struct { plugin *AugmentPlugin config InstallConfig homeDir string - workspaceDir string + projectDir string agentDir string configPath string guardPath string @@ -42,49 +44,34 @@ func prepareAugmentFixture( options augmentFixtureOptions, ) *augmentFixture { t.Helper() - homeDir := filepath.Join(t.TempDir(), "home with spaces and 'quote") - workspaceDir := filepath.Join(homeDir, "workspace") + root := t.TempDir() + homeDir := filepath.Join(root, "home with spaces and 'quote") + projectDir := filepath.Join(root, "project with spaces") + if err := os.MkdirAll(projectDir, 0700); err != nil { + t.Fatalf("create project directory: %v", err) + } + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) 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 { + if err := os.MkdirAll(filepath.Dir(configPath), 0700); 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) + guardPath := filepath.Join(agentDir, augmentGuardScript) return &augmentFixture{ - plugin: &AugmentPlugin{}, - homeDir: homeDir, - workspaceDir: workspaceDir, - agentDir: agentDir, + plugin: &AugmentPlugin{}, config: InstallConfig{ - AgentName: augmentAgentKey, - OrgID: "org-1", - AgentID: augmentTestAgentID, - PrivateKey: "test-key", - KID: "kid-1", - BaseURL: "https://api.elydora.test", - GuardScriptPath: guardPath, + AgentName: augmentAgentKey, OrgID: "org-1", AgentID: augmentTestAgentID, + PrivateKey: augmentPrivateKey, KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, }, - configPath: configPath, - guardPath: guardPath, + homeDir: homeDir, projectDir: projectDir, agentDir: agentDir, + configPath: configPath, guardPath: guardPath, hookPath: filepath.Join(agentDir, augmentAuditScript), guardWrapper: filepath.Join(agentDir, augmentGuardWrapperName()), auditWrapper: filepath.Join(agentDir, augmentAuditWrapperName()), @@ -108,19 +95,34 @@ func readAugmentTestObject(t *testing.T, path string) map[string]any { func writeAugmentTestObject(t *testing.T, path string, value map[string]any) { t.Helper() - encoded, err := json.Marshal(value) + encoded, err := json.MarshalIndent(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) + encoded = append(encoded, '\n') + writeAugmentTestFile(t, path, encoded, 0600) +} + +func writeAugmentTestFile( + t *testing.T, + path string, + contents []byte, + mode os.FileMode, +) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create parent directory for %s: %v", path, err) + } + if err := os.WriteFile(path, contents, mode); err != nil { + t.Fatalf("write %s: %v", path, err) } } func augmentTestManagedHandler( t *testing.T, settings map[string]any, - event, wrapperPath string, + event string, + wrapperPath string, ) map[string]any { t.Helper() expected := buildAugmentCommand(wrapperPath) @@ -140,8 +142,10 @@ func augmentTestManagedHandler( func runAugmentCommand( t *testing.T, - command, homeDir, payload string, -) (int, string) { + command string, + fixture *augmentFixture, + payload []byte, +) (int, string, string) { t.Helper() var process *exec.Cmd if runtime.GOOS == "windows" { @@ -154,20 +158,67 @@ func runAugmentCommand( } else { process = exec.Command("sh", "-c", command) } + process.Dir = fixture.projectDir process.Env = append( - os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir, + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, ) - process.Stdin = bytes.NewBufferString(payload) - var stderr bytes.Buffer + process.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout process.Stderr = &stderr err := process.Run() if err == nil { - return 0, stderr.String() + return 0, stdout.String(), stderr.String() } var exitError *exec.ExitError if errors.As(err, &exitError) { - return exitError.ExitCode(), stderr.String() + return exitError.ExitCode(), stdout.String(), stderr.String() } t.Fatalf("run Auggie hook command: %v", err) - return -1, stderr.String() + return -1, stdout.String(), stderr.String() +} + +func assertNoAugmentRuntimeWrites(t *testing.T, fixture *augmentFixture) { + t.Helper() + for _, path := range []string{ + fixture.runtimeConfig, + fixture.privateKey, + fixture.guardPath, + fixture.hookPath, + fixture.guardWrapper, + fixture.auditWrapper, + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime file exists at %s: %v", path, err) + } + } +} + +func assertNoAugmentTransactionArtifacts(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + name := entry.Name() + if filepath.Ext(name) == ".tmp" || filepath.Ext(name) == ".rollback" { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Auggie fixture: %v", err) + } +} + +func augmentSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } } diff --git a/sdks/go/cmd/elydora/plugins/augment_transaction_test.go b/sdks/go/cmd/elydora/plugins/augment_transaction_test.go new file mode 100644 index 0000000..85daf7d --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/augment_transaction_test.go @@ -0,0 +1,393 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func prepareAugmentTestInstallation( + t *testing.T, + fixture *augmentFixture, +) ([]*fileChange, *augmentRuntimePaths, *augmentDocument) { + t.Helper() + document, err := readAugmentDocument() + if err != nil { + t.Fatalf("read Auggie document: %v", err) + } + paths, nodePath, err := preflightAugmentInstallation(fixture.config, document) + if err != nil { + t.Fatalf("preflight Auggie installation: %v", err) + } + hooks, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot) + hooks["PreToolUse"] = append( + hooks["PreToolUse"], + buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)), + ) + hooks["PostToolUse"] = append( + hooks["PostToolUse"], + buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)), + ) + rendered, err := renderAugmentDocument(document, hooks) + if err != nil { + t.Fatalf("render Auggie document: %v", err) + } + changes, err := prepareAugmentInstallationChanges( + fixture.config, + paths, + nodePath, + rendered, + ) + if err != nil { + t.Fatalf("prepare Auggie installation: %v", err) + } + return changes, paths, document +} + +func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) { + original := "{\"telemetryEnabled\":true}\n" + fixture := prepareAugmentFixture( + t, + augmentFixtureOptions{existingRaw: &original}, + ) + failed := false + fixture.plugin.rename = func(source, destination string) error { + if sameAugmentPath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") && + !failed { + failed = true + return errors.New("injected Auggie settings failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Auggie settings failure") { + t.Fatalf("install error = %v", err) + } + raw, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(raw) != original { + t.Fatalf("original settings changed: %q, %v", raw, readErr) + } + assertNoAugmentRuntimeWrites(t, fixture) + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) { + original := `{"telemetryEnabled":true}` + fixture := prepareAugmentFixture( + t, + augmentFixtureOptions{existingRaw: &original}, + ) + changes, paths, document := prepareAugmentTestInstallation(t, fixture) + concurrent := []byte( + "{\"telemetryEnabled\":false,\"hooks\":{\"Notification\":[]}}\n", + ) + if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { + t.Fatalf("write concurrent settings: %v", err) + } + err := writeAugmentChanges( + changes, + "Install Augment Code CLI hooks", + nil, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.configPath), + ) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("prepared install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("concurrent settings changed: %q, %v", actual, readErr) + } + assertNoAugmentRuntimeWrites(t, fixture) + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +} + +func TestAugmentInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) { + source := []byte(`{"owner":"original"}`) + fixture := prepareAugmentFixture( + t, + augmentFixtureOptions{existingRaw: augmentString(string(source))}, + ) + mutated := false + fixture.plugin.rename = func(stagedPath, destination string) error { + if !mutated && strings.HasSuffix(stagedPath, ".tmp") { + mutated = true + external := fixture.configPath + ".external" + if err := os.Rename(fixture.configPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.configPath, source, 0600); err != nil { + return err + } + } + return os.Rename(stagedPath, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("replacement settings changed: %q, %v", actual, readErr) + } + assertNoAugmentRuntimeWrites(t, fixture) + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedAugmentInstallRejectsStaleSettingsBeforeStaging(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + document, err := readAugmentDocument() + if err != nil { + t.Fatalf("read Auggie document: %v", err) + } + paths, nodePath, err := preflightAugmentInstallation(fixture.config, document) + if err != nil { + t.Fatalf("preflight Auggie installation: %v", err) + } + hooks, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot) + hooks["PreToolUse"] = append( + hooks["PreToolUse"], + buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)), + ) + hooks["PostToolUse"] = append( + hooks["PostToolUse"], + buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)), + ) + rendered, err := renderAugmentDocument(document, hooks) + if err != nil { + t.Fatalf("render Auggie document: %v", err) + } + writeAugmentTestFile( + t, + fixture.configPath, + []byte("{\"hooks\":{\"Notification\":[]}}\n"), + 0600, + ) + _, err = prepareAugmentInstallationChanges( + fixture.config, + paths, + nodePath, + rendered, + ) + if err == nil || !strings.Contains(err.Error(), "changed before update") { + t.Fatalf("stale settings error = %v", err) + } + assertNoAugmentRuntimeWrites(t, fixture) + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +} + +func TestAugmentOrphanRuntimeArtifactsFailBeforeSettingsWrites(t *testing.T) { + for _, runtimeName := range []string{ + "private.key", + augmentGuardScript, + augmentAuditScript, + augmentGuardWrapperName(), + augmentAuditWrapperName(), + "chain-state.json", + "status-cache.json", + "error.log", + } { + t.Run(runtimeName, func(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + artifact := filepath.Join(fixture.agentDir, runtimeName) + writeAugmentTestFile(t, artifact, []byte("orphan\n"), 0600) + err := fixture.plugin.Install(fixture.config) + if err == nil || + !strings.Contains(err.Error(), "identity cannot be verified") { + t.Fatalf("orphan runtime error = %v", err) + } + raw, readErr := os.ReadFile(artifact) + if readErr != nil || string(raw) != "orphan\n" { + t.Fatalf("orphan artifact changed: %q, %v", raw, readErr) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings written after orphan error: %v", err) + } + }) + } +} + +func TestAugmentMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + writeAugmentTestObject(t, fixture.runtimeConfig, map[string]any{ + "agent_id": "another-agent", "agent_name": augmentAgentKey, + }) + original, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + err = fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "identity does not match") { + t.Fatalf("identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.runtimeConfig) + if readErr != nil || string(actual) != string(original) { + t.Fatalf("runtime config changed: %q, %v", actual, readErr) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings written after identity error: %v", err) + } +} + +func TestAugmentLinkedSettingsRuntimeAndWrappersAreRejected(t *testing.T) { + for _, kind := range []string{ + "configuration", + "settings", + "runtime", + "agent-directory", + "runtime-config", + "wrapper", + } { + t.Run(kind, func(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + target := filepath.Join(t.TempDir(), kind+"-target") + switch kind { + case "settings": + writeAugmentTestFile(t, target, []byte("{}\n"), 0600) + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create settings directory: %v", err) + } + augmentSymlinkOrSkip(t, target, fixture.configPath) + case "configuration": + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatalf("create target directory: %v", err) + } + if err := os.MkdirAll(filepath.Dir(filepath.Dir(fixture.configPath)), 0700); err != nil { + t.Fatalf("create configuration parent: %v", err) + } + augmentSymlinkOrSkip(t, target, filepath.Dir(fixture.configPath)) + case "runtime": + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatalf("create target directory: %v", err) + } + if err := os.MkdirAll(filepath.Dir(filepath.Dir(fixture.agentDir)), 0700); err != nil { + t.Fatalf("create runtime parent: %v", err) + } + augmentSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir)) + case "agent-directory": + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatalf("create target directory: %v", err) + } + if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil { + t.Fatalf("create runtime root: %v", err) + } + augmentSymlinkOrSkip(t, target, fixture.agentDir) + case "runtime-config": + writeAugmentTestFile( + t, + target, + []byte(`{"agent_id":"agent-1","agent_name":"augment"}`), + 0600, + ) + if err := os.MkdirAll(fixture.agentDir, 0700); err != nil { + t.Fatalf("create agent directory: %v", err) + } + augmentSymlinkOrSkip(t, target, fixture.runtimeConfig) + case "wrapper": + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + writeAugmentTestFile(t, target, []byte("external wrapper\n"), 0600) + if err := os.Remove(fixture.guardWrapper); err != nil { + t.Fatalf("remove guard wrapper: %v", err) + } + augmentSymlinkOrSkip(t, target, fixture.guardWrapper) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical") { + t.Fatalf("linked %s error = %v", kind, err) + } + }) + } +} + +func TestAugmentInstallValidatesRuntimeInputsBeforeWrites(t *testing.T) { + for _, testCase := range []struct { + name, field, value, want string + }{ + {"agent-name", "agent_name", "codex", "requires agent name augment"}, + {"agent-id", "agent_id", "../escape", "invalid agent ID"}, + {"organization", "org_id", " ", "organization ID is required"}, + {"key-id", "kid", " ", "key ID is required"}, + {"private-key", "private_key", "invalid", "canonical 32-byte"}, + {"token", "token", " ", "non-whitespace"}, + {"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"}, + {"guard-path", "guard_script_path", "outside", "managed agent directory"}, + {"audit-path", "hook_script", "outside", "managed agent directory"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + switch testCase.field { + case "agent_name": + fixture.config.AgentName = testCase.value + case "agent_id": + fixture.config.AgentID = testCase.value + case "org_id": + fixture.config.OrgID = testCase.value + case "kid": + fixture.config.KID = testCase.value + case "private_key": + fixture.config.PrivateKey = testCase.value + case "token": + fixture.config.Token = testCase.value + case "base_url": + fixture.config.BaseURL = testCase.value + case "guard_script_path": + fixture.config.GuardScriptPath = testCase.value + case "hook_script": + fixture.config.HookScript = testCase.value + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("validation error = %v, want %q", err, testCase.want) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings written after validation error: %v", err) + } + assertNoAugmentRuntimeWrites(t, fixture) + }) + } +} + +func TestAugmentInstallLeavesNoTransactionArtifacts(t *testing.T) { + fixture := prepareAugmentFixture(t, augmentFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +} + +func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { + original := `{"owner":"user"}` + fixture := prepareAugmentFixture( + t, + augmentFixtureOptions{existingRaw: &original}, + ) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Auggie hooks: %v", err) + } + before, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read installed settings: %v", err) + } + fixture.plugin.rename = func(source, destination string) error { + if sameAugmentPath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Auggie uninstall failure") + } + return os.Rename(source, destination) + } + err = fixture.plugin.Uninstall(augmentTestAgentID) + if err == nil || !strings.Contains(err.Error(), "injected Auggie uninstall failure") { + t.Fatalf("uninstall error = %v", err) + } + after, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(after) != string(before) { + t.Fatalf("Auggie settings changed: %q, %v", after, readErr) + } + assertNoAugmentTransactionArtifacts(t, fixture.homeDir) +}