From d3f6c73b50a5d70c109ac7abf2690fd12889e0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 13:19:48 -0400 Subject: [PATCH] feat: sync Go Qwen Code hook integration --- AGENTS.md | 3 +- integrations/catalog.json | 4 +- sdks/go/README.md | 3 + sdks/go/cmd/elydora/plugins/dotenv.go | 35 ++ sdks/go/cmd/elydora/plugins/droid.go | 14 +- sdks/go/cmd/elydora/plugins/droid_config.go | 22 +- sdks/go/cmd/elydora/plugins/droid_io.go | 289 +---------------- .../plugins/droid_test_helpers_test.go | 4 +- .../plugins/{droid_jsonc.go => jsonc.go} | 111 ++++--- sdks/go/cmd/elydora/plugins/qwen.go | 172 ++++++++++ sdks/go/cmd/elydora/plugins/qwen_config.go | 157 +++++++++ sdks/go/cmd/elydora/plugins/qwen_contract.go | 248 +++++++++++++++ sdks/go/cmd/elydora/plugins/qwen_io.go | 150 +++++++++ .../cmd/elydora/plugins/qwen_recovery_test.go | 158 +++++++++ sdks/go/cmd/elydora/plugins/qwen_schema.go | 250 +++++++++++++++ sdks/go/cmd/elydora/plugins/qwen_test.go | 300 ++++++++++++++++++ .../elydora/plugins/qwen_test_helpers_test.go | 273 ++++++++++++++++ sdks/go/cmd/elydora/plugins/registry.go | 3 + sdks/go/cmd/elydora/plugins/transaction.go | 288 +++++++++++++++++ 19 files changed, 2140 insertions(+), 344 deletions(-) create mode 100644 sdks/go/cmd/elydora/plugins/dotenv.go rename sdks/go/cmd/elydora/plugins/{droid_jsonc.go => jsonc.go} (56%) create mode 100644 sdks/go/cmd/elydora/plugins/qwen.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_config.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_contract.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_io.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_recovery_test.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_schema.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_test.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_test_helpers_test.go create mode 100644 sdks/go/cmd/elydora/plugins/transaction.go diff --git a/AGENTS.md b/AGENTS.md index a087ca3..909b250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ This repository publishes the open-source server, console, integration catalog, - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - 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. - Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes. -- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review. +- Qwen Code `0.20.0` resolves user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review. +- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index 8819edf..a329ace 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -479,8 +479,8 @@ "mechanism": "exit_code_2", "failure_mode": "fail_open" }, - "adapters": { "node": true, "python": true, "go": false }, - "delivery_state": "partial", + "adapters": { "node": true, "python": true, "go": true }, + "delivery_state": "available", "source_url": "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/" } ], diff --git a/sdks/go/README.md b/sdks/go/README.md index c6c5912..562b5cb 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -85,6 +85,8 @@ Auggie installation writes user-level hooks to `~/.augment/settings.json` and cr 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. +Qwen Code installation writes native user hooks to `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Elydora follows Qwen's `.env` discovery order, preserves JSON-with-comments source formatting, keeps workspace settings read-only, and commits runtime and settings changes as one transaction. Run `/hooks` in Qwen Code after installation to review the effective hooks. + ### Commands | Command | Description | @@ -112,6 +114,7 @@ Factory Droid installation selects `PreToolUse` and `PostToolUse` independently | Kimi Code | `kimi` | | Letta Code | `letta` | | OpenCode | `opencode` | +| Qwen Code | `qwen` | ## API Reference diff --git a/sdks/go/cmd/elydora/plugins/dotenv.go b/sdks/go/cmd/elydora/plugins/dotenv.go new file mode 100644 index 0000000..8bb9cda --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/dotenv.go @@ -0,0 +1,35 @@ +package plugins + +import ( + "regexp" + "strings" +) + +var dotenvLinePattern = regexp.MustCompile( + "(?m)^\\s*(export\\s+)?([\\w.-]+)(\\s*=\\s*?|:\\s+?)" + + "(\\s*'(\\\\'|[^'])*'|\\s*\"(\\\\\"|[^\"])*\"|" + + "\\s*`(\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(#.*)?$", +) + +func parseDotenv(source []byte) map[string]string { + values := map[string]string{} + normalized := strings.ReplaceAll(string(source), "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + for _, match := range dotenvLinePattern.FindAllStringSubmatch(normalized, -1) { + key := match[2] + value := strings.TrimSpace(match[4]) + quote := byte(0) + if value != "" { + quote = value[0] + } + if len(value) >= 2 && (quote == '\'' || quote == '"' || quote == '`') && value[len(value)-1] == quote { + value = value[1 : len(value)-1] + } + if quote == '"' { + value = strings.ReplaceAll(value, `\n`, "\n") + value = strings.ReplaceAll(value, `\r`, "\r") + } + values[key] = value + } + return values +} diff --git a/sdks/go/cmd/elydora/plugins/droid.go b/sdks/go/cmd/elydora/plugins/droid.go index 739bf41..1cab914 100644 --- a/sdks/go/cmd/elydora/plugins/droid.go +++ b/sdks/go/cmd/elydora/plugins/droid.go @@ -8,7 +8,7 @@ import ( ) type DroidPlugin struct { - rename droidRenameFunc + rename renameFunc } type droidRuntimeConfig struct { @@ -80,7 +80,7 @@ func (p *DroidPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeDroidChanges(changes, "Write Factory Droid installation", p.rename); err != nil { + if err := writeChanges(changes, "Write Factory Droid installation", p.rename); err != nil { return err } fmt.Printf( @@ -102,7 +102,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error { return err } documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings)) - changes := make([]*droidFileChange, 0, len(documents)) + changes := make([]*fileChange, 0, len(documents)) for _, document := range documents { rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil) if renderErr != nil { @@ -114,7 +114,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error { } changes = append(changes, change) } - return writeDroidChanges(changes, "Write Factory Droid hook sources", p.rename) + return writeChanges(changes, "Write Factory Droid hook sources", p.rename) } func (p *DroidPlugin) Status() (PluginStatus, error) { @@ -174,7 +174,7 @@ func prepareDroidInstallationChanges( config InstallConfig, agentDirectory, auditPath string, rendered []*droidRenderedDocument, -) ([]*droidFileChange, error) { +) ([]*fileChange, error) { baseURL := config.BaseURL if baseURL == "" { baseURL = "https://api.elydora.com" @@ -196,9 +196,9 @@ func prepareDroidInstallationChanges( {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600}, {auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700}, } - changes := make([]*droidFileChange, 0, len(runtimeChanges)+len(rendered)) + changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered)) for _, item := range runtimeChanges { - change, changeErr := prepareDroidFileChange(item.path, item.label, item.content, item.mode) + change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode) if changeErr != nil { return nil, changeErr } diff --git a/sdks/go/cmd/elydora/plugins/droid_config.go b/sdks/go/cmd/elydora/plugins/droid_config.go index 276b72d..45a0073 100644 --- a/sdks/go/cmd/elydora/plugins/droid_config.go +++ b/sdks/go/cmd/elydora/plugins/droid_config.go @@ -2,7 +2,6 @@ package plugins import ( "bytes" - "errors" "fmt" "os" "path/filepath" @@ -54,7 +53,7 @@ func readDroidSources() (*droidSources, error) { if err != nil { return nil, err } - rootRaw, rootExists, err := readDroidOptional(rootPath, "Factory Droid hooks") + rootRaw, rootExists, err := readOptionalFile(rootPath, "Factory Droid hooks") if err != nil { return nil, err } @@ -65,7 +64,7 @@ func readDroidSources() (*droidSources, error) { return nil, err } } else { - legacyRaw, legacyExists, readErr := readDroidOptional(legacyPath, "Factory Droid legacy hooks") + legacyRaw, legacyExists, readErr := readOptionalFile(legacyPath, "Factory Droid legacy hooks") if readErr != nil { return nil, readErr } @@ -76,7 +75,7 @@ func readDroidSources() (*droidSources, error) { } } } - settingsRaw, settingsExists, err := readDroidOptional(settingsPath, "Factory Droid settings") + settingsRaw, settingsExists, err := readOptionalFile(settingsPath, "Factory Droid settings") if err != nil { return nil, err } @@ -94,7 +93,7 @@ func readDroidSources() (*droidSources, error) { func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) { label := droidDocumentLabel(kind, filePath) - root, err := decodeDroidJSONCObject(raw, label) + root, err := decodeJSONCObject(raw, label, true) if err != nil { return nil, err } @@ -202,7 +201,7 @@ func renderDroidDocument( agentID, runtimeRoot string, additions map[string]map[string]any, ) (*droidRenderedDocument, error) { - editor, err := newDroidJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath)) + editor, err := newJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath), true) if err != nil { return nil, err } @@ -287,14 +286,3 @@ func displayDroidConfigPath(sources *droidSources) string { } return sources.rootPath } - -func readDroidOptional(path, label string) ([]byte, bool, error) { - raw, err := os.ReadFile(path) // #nosec G304 -- paths are fixed user-level Factory configuration paths. - if err == nil { - return raw, true, nil - } - if errors.Is(err, os.ErrNotExist) { - return nil, false, nil - } - return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err) -} diff --git a/sdks/go/cmd/elydora/plugins/droid_io.go b/sdks/go/cmd/elydora/plugins/droid_io.go index b97ef8b..cdbef39 100644 --- a/sdks/go/cmd/elydora/plugins/droid_io.go +++ b/sdks/go/cmd/elydora/plugins/droid_io.go @@ -1,65 +1,13 @@ package plugins import ( - "bytes" "encoding/json" - "errors" "fmt" - "io" "os" "path/filepath" ) -type droidRenameFunc func(source, destination string) error - -type droidFileChange struct { - filePath string - label string - original []byte - originalMode os.FileMode - existed bool - next []byte - mode os.FileMode - remove bool -} - -type droidStagedChange struct { - change droidFileChange - temporaryPath string - rollbackPath string - committed bool -} - -func prepareDroidFileChange( - filePath, label string, - next []byte, - mode os.FileMode, -) (*droidFileChange, error) { - original, exists, err := readDroidOptional(filePath, label) - if err != nil { - return nil, err - } - if exists && bytes.Equal(original, next) { - return nil, nil - } - if !exists && next == nil { - return nil, nil - } - originalMode := mode - if exists { - info, statErr := os.Stat(filePath) - if statErr != nil { - return nil, fmt.Errorf("read file mode for %s at %s: %w", label, filePath, statErr) - } - originalMode = info.Mode().Perm() - } - return &droidFileChange{ - filePath: filePath, label: label, original: original, originalMode: originalMode, - existed: exists, next: append([]byte(nil), next...), mode: mode, - }, nil -} - -func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*droidFileChange, error) { +func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, error) { if rendered == nil || !rendered.changed { return nil, nil } @@ -69,236 +17,21 @@ func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*droidFileChan } else if rendered.document.kind == "settings" { label = "Factory Droid settings" } - originalMode := os.FileMode(0600) - var original []byte - if rendered.document.exists { - info, err := os.Stat(rendered.document.filePath) - if err != nil { - return nil, fmt.Errorf("read file mode for %s at %s: %w", label, rendered.document.filePath, err) - } - originalMode = info.Mode().Perm() - original = append([]byte(nil), rendered.document.raw...) - } - return &droidFileChange{ - filePath: rendered.document.filePath, label: label, - original: original, existed: rendered.document.exists, - next: append([]byte(nil), rendered.next...), mode: 0600, originalMode: originalMode, - remove: rendered.remove, - }, nil -} - -func writeDroidChanges(changes []*droidFileChange, label string, rename droidRenameFunc) error { - filtered := make([]droidFileChange, 0, len(changes)) - targets := map[string]bool{} - for _, change := range changes { - if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) { - continue - } - target := filepath.Clean(change.filePath) - if targets[target] { - return fmt.Errorf("%s contains duplicate file target %s", label, change.filePath) - } - targets[target] = true - filtered = append(filtered, *change) - } - if len(filtered) == 0 { - return nil - } - if rename == nil { - rename = os.Rename - } - staged := make([]droidStagedChange, 0, len(filtered)) - for _, change := range filtered { - item, err := stageDroidChange(change) - if err != nil { - cleanupErrors := cleanupDroidStaging(staged) - return joinDroidFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed") - } - staged = append(staged, item) - } - for index := range staged { - if err := commitDroidChange(&staged[index], rename); err != nil { - recoveryErrors := rollbackDroidChanges(staged, rename) - recoveryErrors = append(recoveryErrors, cleanupDroidStaging(staged)...) - return joinDroidFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") - } - } - cleanupErrors := cleanupDroidStaging(staged) - if len(cleanupErrors) > 0 { - return joinDroidFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed") - } - return nil -} - -func stageDroidChange(change droidFileChange) (droidStagedChange, error) { - if err := assertDroidUnchanged(change); err != nil { - return droidStagedChange{}, err - } - directory := filepath.Dir(change.filePath) - if err := os.MkdirAll(directory, 0700); err != nil { - return droidStagedChange{}, fmt.Errorf("create directory for %s at %s: %w", change.label, directory, err) - } - staged := droidStagedChange{change: change} - var err error - if change.remove { - staged.rollbackPath, err = reserveDroidPath(directory, filepath.Base(change.filePath), ".rollback") - } else { - staged.temporaryPath, err = writeDroidStagedFile( - directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode, - ) - if err == nil && change.existed { - staged.rollbackPath, err = writeDroidStagedFile( - directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode, - ) - } - } - if err != nil { - cleanupErrors := cleanupDroidStaging([]droidStagedChange{staged}) - return droidStagedChange{}, joinDroidFailure( - fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed", - ) - } - return staged, nil -} - -func writeDroidStagedFile( - directory, basename, suffix string, - content []byte, - mode os.FileMode, -) (string, error) { - file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) - if err != nil { - return "", err - } - path := file.Name() - failed := func(cause error) (string, error) { - return "", errors.Join(cause, file.Close(), removeDroidOptional(path)) - } - if err := file.Chmod(mode); err != nil { - return failed(err) - } - written, err := file.Write(content) - if err != nil { - return failed(err) - } - if written != len(content) { - return failed(io.ErrShortWrite) - } - if err := file.Sync(); err != nil { - return failed(err) - } - if err := file.Close(); err != nil { - return "", errors.Join(err, removeDroidOptional(path)) - } - return path, nil -} - -func reserveDroidPath(directory, basename, suffix string) (string, error) { - file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) - if err != nil { - return "", err - } - path := file.Name() - if err := file.Close(); err != nil { - return "", errors.Join(err, removeDroidOptional(path)) - } - if err := os.Remove(path); err != nil { - return "", err - } - return path, nil -} - -func assertDroidUnchanged(change droidFileChange) error { - current, exists, err := readDroidOptional(change.filePath, change.label) - if err != nil { - return err - } - if exists != change.existed || !bytes.Equal(current, change.original) { - return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath) - } - return nil -} - -func commitDroidChange(staged *droidStagedChange, rename droidRenameFunc) error { - if err := assertDroidUnchanged(staged.change); err != nil { - return err - } - if staged.change.remove { - if staged.rollbackPath == "" { - return fmt.Errorf("missing rollback path for %s", staged.change.label) - } - if err := rename(staged.change.filePath, staged.rollbackPath); err != nil { - return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err) - } - } else { - if staged.temporaryPath == "" { - return fmt.Errorf("missing staged file for %s", staged.change.label) - } - if err := rename(staged.temporaryPath, staged.change.filePath); err != nil { - return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err) - } - } - staged.committed = true - return nil -} - -func rollbackDroidChanges(staged []droidStagedChange, rename droidRenameFunc) []error { - failures := make([]error, 0) - for index := len(staged) - 1; index >= 0; index-- { - item := &staged[index] - if !item.committed { - continue - } - var err error - switch { - case item.change.remove: - err = rename(item.rollbackPath, item.change.filePath) - case item.change.existed: - err = rename(item.rollbackPath, item.change.filePath) - default: - err = removeDroidOptional(item.change.filePath) - } - if err != nil { - failures = append(failures, fmt.Errorf("restore %s at %s: %w", item.change.label, item.change.filePath, err)) - } - } - return failures -} - -func cleanupDroidStaging(staged []droidStagedChange) []error { - failures := make([]error, 0) - for _, item := range staged { - for _, path := range []string{item.temporaryPath, item.rollbackPath} { - if err := removeDroidOptional(path); err != nil { - failures = append(failures, err) - } - } - } - return failures -} - -func removeDroidOptional(path string) error { - if path == "" { - return nil - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove staged Factory Droid file at %s: %w", path, err) - } - return nil -} - -func joinDroidFailure(cause error, related []error, label string) error { - if len(related) == 0 { - return cause - } - errorsWithContext := []error{cause, fmt.Errorf("%s: %w", label, errors.Join(related...))} - return errors.Join(errorsWithContext...) + return prepareSourceChange( + rendered.document.filePath, + label, + rendered.document.raw, + rendered.document.exists, + rendered.next, + os.FileMode(0600), + rendered.remove, + ) } func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { for _, contract := range contracts { configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") - raw, exists, err := readDroidOptional(configPath, "Elydora runtime config") + raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") if err != nil { return false, err } diff --git a/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go index 35c1e1b..eb3307d 100644 --- a/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go @@ -145,7 +145,9 @@ func requireMissingDroidFile(t *testing.T, path string) { func readDroidTestObject(t *testing.T, path string) map[string]any { t.Helper() - standard, err := standardizeDroidJSONC([]byte(readDroidTestFile(t, path))) + standard, err := standardizeJSONC( + []byte(readDroidTestFile(t, path)), "Factory Droid test source", true, + ) if err != nil { t.Fatalf("standardize %s: %v", path, err) } diff --git a/sdks/go/cmd/elydora/plugins/droid_jsonc.go b/sdks/go/cmd/elydora/plugins/jsonc.go similarity index 56% rename from sdks/go/cmd/elydora/plugins/droid_jsonc.go rename to sdks/go/cmd/elydora/plugins/jsonc.go index 3f04bf8..2931616 100644 --- a/sdks/go/cmd/elydora/plugins/droid_jsonc.go +++ b/sdks/go/cmd/elydora/plugins/jsonc.go @@ -10,14 +10,15 @@ import ( "github.com/tailscale/hujson" ) -type droidJSONCEditor struct { +type jsoncEditor struct { value hujson.Value eol string indent string + label string } -func standardizeDroidJSONC(source []byte) ([]byte, error) { - value, err := parseDroidJSONC(source, "JSONC source") +func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) { + value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { return nil, err } @@ -25,18 +26,51 @@ func standardizeDroidJSONC(source []byte) ([]byte, error) { return value.Pack(), nil } -func parseDroidJSONC(source []byte, label string) (hujson.Value, error) { +func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) { value, err := hujson.Parse(source) if err != nil { return hujson.Value{}, fmt.Errorf("parse %s: %w", label, err) } - if err := rejectDroidDuplicateKeys(&value, label, "$"); err != nil { + if !allowTrailingCommas { + if err := rejectJSONCTrailingCommas(&value, label, "$"); err != nil { + return hujson.Value{}, err + } + } + if err := rejectDuplicateJSONCKeys(&value, label, "$"); err != nil { return hujson.Value{}, err } return value, nil } -func rejectDroidDuplicateKeys(value *hujson.Value, label, location string) error { +func rejectJSONCTrailingCommas(value *hujson.Value, label, location string) error { + switch current := value.Value.(type) { + case *hujson.Object: + if len(current.Members) > 0 && current.Members[len(current.Members)-1].Value.AfterExtra != nil { + return fmt.Errorf("%s contains a trailing comma at %s", label, location) + } + for index := range current.Members { + member := ¤t.Members[index] + name := member.Name.Value.(hujson.Literal).String() + if err := rejectJSONCTrailingCommas(&member.Value, label, location+"/"+name); err != nil { + return err + } + } + case *hujson.Array: + if len(current.Elements) > 0 && current.Elements[len(current.Elements)-1].AfterExtra != nil { + return fmt.Errorf("%s contains a trailing comma at %s", label, location) + } + for index := range current.Elements { + if err := rejectJSONCTrailingCommas( + ¤t.Elements[index], label, location+"/"+strconv.Itoa(index), + ); err != nil { + return err + } + } + } + return nil +} + +func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error { switch current := value.Value.(type) { case *hujson.Object: seen := make(map[string]bool, len(current.Members)) @@ -47,13 +81,13 @@ func rejectDroidDuplicateKeys(value *hujson.Value, label, location string) error return fmt.Errorf("%s contains duplicate key %q at %s", label, name, location) } seen[name] = true - if err := rejectDroidDuplicateKeys(&member.Value, label, location+"/"+name); err != nil { + if err := rejectDuplicateJSONCKeys(&member.Value, label, location+"/"+name); err != nil { return err } } case *hujson.Array: for index := range current.Elements { - if err := rejectDroidDuplicateKeys( + if err := rejectDuplicateJSONCKeys( ¤t.Elements[index], label, location+"/"+strconv.Itoa(index), ); err != nil { return err @@ -63,8 +97,8 @@ func rejectDroidDuplicateKeys(value *hujson.Value, label, location string) error return nil } -func decodeDroidJSONCObject(source []byte, label string) (map[string]any, error) { - value, err := parseDroidJSONC(source, label) +func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) { + value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { return nil, err } @@ -80,24 +114,24 @@ func decodeDroidJSONCObject(source []byte, label string) (map[string]any, error) return object, nil } -func newDroidJSONCEditor(source []byte, label string) (*droidJSONCEditor, error) { - value, err := parseDroidJSONC(source, label) +func newJSONCEditor(source []byte, label string, allowTrailingCommas bool) (*jsoncEditor, error) { + value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { return nil, err } - return &droidJSONCEditor{ - value: value, eol: detectDroidEOL(source), indent: detectDroidIndent(source), + return &jsoncEditor{ + value: value, eol: detectJSONCEOL(source), indent: detectJSONCIndent(source), label: label, }, nil } -func detectDroidEOL(source []byte) string { +func detectJSONCEOL(source []byte) string { if bytes.Contains(source, []byte("\r\n")) { return "\r\n" } return "\n" } -func detectDroidIndent(source []byte) string { +func detectJSONCIndent(source []byte) string { normalized := strings.ReplaceAll(string(source), "\r\n", "\n") for _, line := range strings.Split(normalized, "\n") { trimmed := strings.TrimLeft(line, "\t ") @@ -109,34 +143,35 @@ func detectDroidIndent(source []byte) string { return " " } -func (editor *droidJSONCEditor) pack() []byte { +func (editor *jsoncEditor) pack() []byte { return editor.value.Pack() } -func (editor *droidJSONCEditor) remove(path []any) error { - operation := []map[string]any{{"op": "remove", "path": droidJSONPointer(path)}} +func (editor *jsoncEditor) remove(path []any) error { + pointer := jsonPointer(path) + operation := []map[string]any{{"op": "remove", "path": pointer}} patch, err := json.Marshal(operation) if err != nil { - return fmt.Errorf("encode Factory Droid JSONC removal: %w", err) + return fmt.Errorf("encode %s removal: %w", editor.label, err) } if err := editor.value.Patch(patch); err != nil { - return fmt.Errorf("apply Factory Droid JSONC removal at %s: %w", droidJSONPointer(path), err) + return fmt.Errorf("apply %s removal at %s: %w", editor.label, pointer, err) } return nil } -func (editor *droidJSONCEditor) addProperty(objectPath []any, name string, value any) error { - container, err := findDroidJSONCValue(&editor.value, objectPath) +func (editor *jsoncEditor) addProperty(objectPath []any, name string, value any) error { + container, err := findJSONCValue(&editor.value, objectPath, editor.label) if err != nil { return err } object, ok := container.Value.(*hujson.Object) if !ok { - return fmt.Errorf("Factory Droid JSONC path %s must be an object", droidJSONPointer(objectPath)) + return fmt.Errorf("%s path %s must be an object", editor.label, jsonPointer(objectPath)) } for _, member := range object.Members { if member.Name.Value.(hujson.Literal).String() == name { - return fmt.Errorf("Factory Droid JSONC property %q already exists", name) + return fmt.Errorf("%s property %q already exists", editor.label, name) } } propertyDepth := len(objectPath) + 1 @@ -159,14 +194,14 @@ func (editor *droidJSONCEditor) addProperty(objectPath []any, name string, value return nil } -func (editor *droidJSONCEditor) appendArray(arrayPath []any, value any) error { - container, err := findDroidJSONCValue(&editor.value, arrayPath) +func (editor *jsoncEditor) appendArray(arrayPath []any, value any) error { + container, err := findJSONCValue(&editor.value, arrayPath, editor.label) if err != nil { return err } array, ok := container.Value.(*hujson.Array) if !ok { - return fmt.Errorf("Factory Droid JSONC path %s must be an array", droidJSONPointer(arrayPath)) + return fmt.Errorf("%s path %s must be an array", editor.label, jsonPointer(arrayPath)) } arrayDepth := len(arrayPath) formatted, err := editor.formattedValue(value, arrayDepth+1) @@ -185,23 +220,23 @@ func (editor *droidJSONCEditor) appendArray(arrayPath []any, value any) error { return nil } -func (editor *droidJSONCEditor) formattedValue(value any, depth int) (hujson.Value, error) { +func (editor *jsoncEditor) formattedValue(value any, depth int) (hujson.Value, error) { prefix := strings.Repeat(editor.indent, depth) encoded, err := json.MarshalIndent(value, prefix, editor.indent) if err != nil { - return hujson.Value{}, fmt.Errorf("encode Factory Droid hook value: %w", err) + return hujson.Value{}, fmt.Errorf("encode value for %s: %w", editor.label, err) } if editor.eol != "\n" { encoded = bytes.ReplaceAll(encoded, []byte("\n"), []byte(editor.eol)) } parsed, err := hujson.Parse(encoded) if err != nil { - return hujson.Value{}, fmt.Errorf("parse generated Factory Droid hook value: %w", err) + return hujson.Value{}, fmt.Errorf("parse generated value for %s: %w", editor.label, err) } return parsed, nil } -func (editor *droidJSONCEditor) newLeadingExtra(current hujson.Extra, depth int) hujson.Extra { +func (editor *jsoncEditor) newLeadingExtra(current hujson.Extra, depth int) hujson.Extra { standard := []byte(editor.eol + strings.Repeat(editor.indent, depth)) if bytes.Contains(current, []byte("//")) || bytes.Contains(current, []byte("/*")) { combined := append(hujson.Extra(nil), current...) @@ -210,14 +245,14 @@ func (editor *droidJSONCEditor) newLeadingExtra(current hujson.Extra, depth int) return standard } -func findDroidJSONCValue(root *hujson.Value, path []any) (*hujson.Value, error) { +func findJSONCValue(root *hujson.Value, path []any, label string) (*hujson.Value, error) { current := root for _, part := range path { switch key := part.(type) { case string: object, ok := current.Value.(*hujson.Object) if !ok { - return nil, fmt.Errorf("Factory Droid JSONC path %s must traverse an object", droidJSONPointer(path)) + return nil, fmt.Errorf("%s path %s must traverse an object", label, jsonPointer(path)) } found := false for index := range object.Members { @@ -228,22 +263,22 @@ func findDroidJSONCValue(root *hujson.Value, path []any) (*hujson.Value, error) } } if !found { - return nil, fmt.Errorf("Factory Droid JSONC path %s does not exist", droidJSONPointer(path)) + return nil, fmt.Errorf("%s path %s does not exist", label, jsonPointer(path)) } case int: array, ok := current.Value.(*hujson.Array) if !ok || key < 0 || key >= len(array.Elements) { - return nil, fmt.Errorf("Factory Droid JSONC array path %s is out of range", droidJSONPointer(path)) + return nil, fmt.Errorf("%s array path %s is out of range", label, jsonPointer(path)) } current = &array.Elements[key] default: - return nil, fmt.Errorf("unsupported Factory Droid JSONC path part %T", part) + return nil, fmt.Errorf("unsupported %s path part %T", label, part) } } return current, nil } -func droidJSONPointer(path []any) string { +func jsonPointer(path []any) string { var pointer strings.Builder for _, part := range path { pointer.WriteByte('/') diff --git a/sdks/go/cmd/elydora/plugins/qwen.go b/sdks/go/cmd/elydora/plugins/qwen.go new file mode 100644 index 0000000..697c2aa --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen.go @@ -0,0 +1,172 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type QwenPlugin struct { + rename renameFunc +} + +type qwenRuntimeConfig struct { + OrgID string `json:"org_id"` + AgentID string `json:"agent_id"` + KID string `json:"kid"` + BaseURL string `json:"base_url"` + Token string `json:"token"` + AgentName string `json:"agent_name"` +} + +func (p *QwenPlugin) Install(config InstallConfig) error { + runtimeRoot, agentDirectory, err := qwenAgentDirectory(config.AgentID) + if err != nil { + return err + } + document, err := readQwenDocument() + if err != nil { + return err + } + expectedGuard := filepath.Join(agentDirectory, qwenGuardScript) + if !sameQwenPath(config.GuardScriptPath, expectedGuard) { + return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard) + } + 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) + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } + if err := validateQwenRegexes(nodePath, document.hooks); err != nil { + return err + } + auditPath := filepath.Join(agentDirectory, qwenAuditScript) + rendered, err := renderQwenDocument(document, "", runtimeRoot, map[string]map[string]any{ + "PreToolUse": buildQwenGroup(nodePath, config.GuardScriptPath), + "PostToolUse": buildQwenGroup(nodePath, auditPath), + }) + if err != nil { + return err + } + changes, err := prepareQwenInstallationChanges(config, agentDirectory, auditPath, rendered) + if err != nil { + return err + } + if err := writeChanges(changes, "Write Qwen Code installation", p.rename); err != nil { + return err + } + fmt.Printf(" Qwen Code: user hooks installed at %s\n", document.filePath) + fmt.Println(" Qwen Code: run /hooks to review the Elydora hook changes.") + return nil +} + +func (p *QwenPlugin) Uninstall(agentID string) error { + runtimeRoot, err := qwenRuntimeRoot() + if err != nil { + return err + } + document, err := readQwenDocument() + if err != nil { + return err + } + if !document.exists { + return nil + } + rendered, err := renderQwenDocument(document, agentID, runtimeRoot, nil) + if err != nil { + return err + } + change, err := prepareRenderedQwenChange(rendered) + if err != nil { + return err + } + return writeChanges([]*fileChange{change}, "Write Qwen Code settings", p.rename) +} + +func (p *QwenPlugin) Status() (PluginStatus, error) { + entry := SupportedAgents[qwenAgentKey] + status := PluginStatus{AgentName: qwenAgentKey, DisplayName: entry.Name} + document, err := readQwenDocument() + if err != nil { + return status, err + } + status.ConfigPath = document.filePath + if document.hooksDisabled { + return status, nil + } + runtimeRoot, err := qwenRuntimeRoot() + if err != nil { + return status, err + } + contracts := qwenRuntimeContracts(document.hooks, runtimeRoot) + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { + return status, nil + } + status.HookScriptExists, err = qwenRuntimeFilesExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists + return status, nil +} + +func qwenAgentDirectory(agentID string) (string, string, error) { + if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { + return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") + } + runtimeRoot, err := qwenRuntimeRoot() + if err != nil { + return "", "", err + } + return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil +} + +func prepareQwenInstallationChanges( + config InstallConfig, + agentDirectory, auditPath string, + rendered *qwenRenderedDocument, +) ([]*fileChange, error) { + baseURL := config.BaseURL + if baseURL == "" { + baseURL = "https://api.elydora.com" + } + runtimeConfig, err := json.MarshalIndent(qwenRuntimeConfig{ + OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, + BaseURL: baseURL, Token: config.Token, AgentName: qwenAgentKey, + }, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + runtimeConfig = append(runtimeConfig, '\n') + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600}, + {auditPath, "Elydora audit runtime", []byte(buildHookScript(qwenAgentKey, config.AgentID)), 0700}, + } + changes := make([]*fileChange, 0, len(items)+1) + for _, item := range items { + change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode) + if changeErr != nil { + return nil, changeErr + } + changes = append(changes, change) + } + settingsChange, err := prepareRenderedQwenChange(rendered) + if err != nil { + return nil, err + } + changes = append(changes, settingsChange) + return changes, nil +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_config.go b/sdks/go/cmd/elydora/plugins/qwen_config.go new file mode 100644 index 0000000..a8f6e6b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_config.go @@ -0,0 +1,157 @@ +package plugins + +import ( + "bytes" + "fmt" + "sort" +) + +type qwenDocument struct { + filePath string + exists bool + raw []byte + root map[string]any + hooks qwenHookSettings + hasHooksContainer bool + hooksDisabled bool + ownedFile bool +} + +type qwenRenderedDocument struct { + document *qwenDocument + changed bool + next []byte + remove bool +} + +func qwenDocumentLabel(filePath string) string { + return fmt.Sprintf("Qwen Code settings at %s", filePath) +} + +func parseQwenDocument(exists bool, filePath string, raw []byte) (*qwenDocument, error) { + label := qwenDocumentLabel(filePath) + root, err := decodeJSONCObject(raw, label, false) + if err != nil { + return nil, err + } + disabled := false + if value, hasFlag := root["disableAllHooks"]; hasFlag { + flag, ok := value.(bool) + if !ok { + return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label) + } + disabled = flag + } + hooks := qwenHookSettings{} + hooksValue, hasHooks := root["hooks"] + if hasHooks { + hooks, err = readQwenHookSettings(hooksValue, label+` field "hooks"`) + if err != nil { + return nil, err + } + } + return &qwenDocument{ + filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), root: root, + hooks: hooks, hasHooksContainer: hasHooks, hooksDisabled: disabled, + ownedFile: bytes.HasPrefix(raw, []byte(qwenOwnedFileMarker)), + }, nil +} + +func createOwnedQwenDocument(filePath string) (*qwenDocument, error) { + return parseQwenDocument(false, filePath, []byte(qwenOwnedFileMarker+"\n{}\n")) +} + +func renderQwenDocument( + document *qwenDocument, + agentID, runtimeRoot string, + additions map[string]map[string]any, +) (*qwenRenderedDocument, error) { + label := qwenDocumentLabel(document.filePath) + editor, err := newJSONCEditor(document.raw, label, false) + if err != nil { + return nil, err + } + removals := managedQwenRemovals(document.hooks, agentID, runtimeRoot) + for _, event := range qwenToolEvents { + eventRemovals := make([]qwenManagedRemoval, 0) + for _, removal := range removals { + if removal.event == event { + eventRemovals = append(eventRemovals, removal) + } + } + sort.Slice(eventRemovals, func(left, right int) bool { + return eventRemovals[left].groupIndex > eventRemovals[right].groupIndex + }) + for _, removal := range eventRemovals { + groupPath := []any{"hooks", event, removal.groupIndex} + if removal.removeGroup { + if err := editor.remove(groupPath); err != nil { + return nil, err + } + continue + } + sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes))) + for _, handlerIndex := range removal.handlerIndexes { + if err := editor.remove(appendQwenPath(groupPath, "hooks", handlerIndex)); err != nil { + return nil, err + } + } + } + if len(eventRemovals) > 0 { + current, parseErr := parseQwenDocument(document.exists, document.filePath, editor.pack()) + if parseErr != nil { + return nil, parseErr + } + if groups, exists := current.hooks[event].([]any); exists && len(groups) == 0 { + if err := editor.remove([]any{"hooks", event}); err != nil { + return nil, err + } + } + } + } + current, err := parseQwenDocument(document.exists, document.filePath, editor.pack()) + if err != nil { + return nil, err + } + if current.hasHooksContainer && len(current.hooks) == 0 { + if err := editor.remove([]any{"hooks"}); err != nil { + return nil, err + } + } + for _, event := range qwenToolEvents { + group, exists := additions[event] + if !exists { + continue + } + current, err = parseQwenDocument(document.exists, document.filePath, editor.pack()) + if err != nil { + return nil, err + } + switch { + case !current.hasHooksContainer: + err = editor.addProperty(nil, "hooks", map[string]any{event: []any{group}}) + case current.hooks[event] != nil: + err = editor.appendArray([]any{"hooks", event}, group) + default: + err = editor.addProperty([]any{"hooks"}, event, []any{group}) + } + if err != nil { + return nil, err + } + } + next := editor.pack() + nextDocument, err := parseQwenDocument(document.exists, document.filePath, next) + if err != nil { + return nil, err + } + remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0 + return &qwenRenderedDocument{ + document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove, + }, nil +} + +func appendQwenPath(base []any, parts ...any) []any { + path := make([]any, 0, len(base)+len(parts)) + path = append(path, base...) + return append(path, parts...) +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_contract.go b/sdks/go/cmd/elydora/plugins/qwen_contract.go new file mode 100644 index 0000000..482eb5f --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_contract.go @@ -0,0 +1,248 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + qwenAgentKey = "qwen" + qwenGuardScript = "guard.js" + qwenAuditScript = "hook.js" + qwenHookTimeout = float64(10_000) + qwenOwnedFileMarker = "// Managed by Elydora" + qwenPOSIXApostrophe = `'"'"'` +) + +var qwenToolEvents = [...]string{"PreToolUse", "PostToolUse"} + +type qwenHookSettings map[string]any + +type qwenManagedRemoval struct { + event string + groupIndex int + handlerIndexes []int + removeGroup bool +} + +type qwenRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +func buildQwenCommand(nodePath, scriptPath string) string { + if runtime.GOOS == "windows" { + return "& " + quoteQwenPowerShell(nodePath) + " " + quoteQwenPowerShell(scriptPath) + "; exit $LASTEXITCODE" + } + return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) +} + +func quoteQwenPowerShell(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func buildQwenGroup(nodePath, scriptPath string) map[string]any { + shell := "bash" + if runtime.GOOS == "windows" { + shell = "powershell" + } + return map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", "command": buildQwenCommand(nodePath, scriptPath), + "shell": shell, "timeout": qwenHookTimeout, + }}, + } +} + +func parseQwenCommand(command string) (string, string, bool) { + start := 0 + if runtime.GOOS == "windows" { + if !strings.HasPrefix(command, "& ") { + return "", "", false + } + start = 2 + } + executable, next, ok := readQwenQuotedArgument(command, start) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := readQwenQuotedArgument(command, next+1) + expectedSuffix := "" + if runtime.GOOS == "windows" { + expectedSuffix = "; exit $LASTEXITCODE" + } + return executable, script, ok && command[end:] == expectedSuffix && executable != "" && script != "" +} + +func readQwenQuotedArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); { + if runtime.GOOS == "windows" && strings.HasPrefix(command[index:], "''") { + value.WriteByte('\'') + index += 2 + continue + } + if runtime.GOOS != "windows" && strings.HasPrefix(command[index:], qwenPOSIXApostrophe) { + value.WriteByte('\'') + index += len(qwenPOSIXApostrophe) + continue + } + if command[index] == '\'' { + return value.String(), index + 1, true + } + value.WriteByte(command[index]) + index++ + } + return "", start, false +} + +func sameQwenPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameQwenAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func qwenRuntimeRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".elydora"), nil +} + +func managedQwenAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) { + if len(handler) != 4 || handler["type"] != "command" || handler["timeout"] != qwenHookTimeout { + return "", false + } + expectedShell := "bash" + if runtime.GOOS == "windows" { + expectedShell = "powershell" + } + if handler["shell"] != expectedShell { + return "", false + } + command, ok := handler["command"].(string) + if !ok { + return "", false + } + executable, scriptPath, ok := parseQwenCommand(command) + if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || + !isQwenNodeExecutable(executable) || !sameQwenFileName(filepath.Base(scriptPath), scriptName) { + return "", false + } + agentDirectory := filepath.Dir(scriptPath) + if !sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) { + return "", false + } + agentID := filepath.Base(agentDirectory) + return agentID, agentID != "" && agentID != "." && agentID != ".." +} + +func isQwenNodeExecutable(path string) bool { + name := filepath.Base(path) + if runtime.GOOS == "windows" { + return strings.EqualFold(name, "node.exe") + } + return name == "node" +} + +func sameQwenFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func managedQwenRemovals(settings qwenHookSettings, agentID, runtimeRoot string) []qwenManagedRemoval { + removals := make([]qwenManagedRemoval, 0) + for _, contract := range []struct{ event, script string }{ + {"PreToolUse", qwenGuardScript}, {"PostToolUse", qwenAuditScript}, + } { + groups, _ := settings[contract.event].([]any) + for groupIndex, groupValue := range groups { + group := groupValue.(map[string]any) + handlers := group["hooks"].([]any) + indexes := make([]int, 0) + for handlerIndex, handlerValue := range handlers { + managedID, managed := managedQwenAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot) + if managed && (agentID == "" || sameQwenAgentID(managedID, agentID)) { + indexes = append(indexes, handlerIndex) + } + } + if len(indexes) == 0 { + continue + } + _, hasMatcher := group["matcher"] + _, hasHooks := group["hooks"] + exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) + removals = append(removals, qwenManagedRemoval{contract.event, groupIndex, indexes, exactGroup}) + } + } + return removals +} + +func qwenRuntimeContracts(settings qwenHookSettings, runtimeRoot string) []qwenRuntimeContract { + guards := managedQwenIDs(settings, "PreToolUse", qwenGuardScript, runtimeRoot) + audits := managedQwenIDs(settings, "PostToolUse", qwenAuditScript, runtimeRoot) + ids := make([]string, 0, len(guards)) + for guardID := range guards { + for auditID := range audits { + if sameQwenAgentID(guardID, auditID) { + ids = append(ids, guardID) + break + } + } + } + sort.Slice(ids, func(left, right int) bool { + if runtime.GOOS == "windows" { + return strings.ToLower(ids[left]) < strings.ToLower(ids[right]) + } + return ids[left] < ids[right] + }) + contracts := make([]qwenRuntimeContract, 0, len(ids)) + for _, agentID := range ids { + root := filepath.Join(runtimeRoot, agentID) + contracts = append(contracts, qwenRuntimeContract{ + agentID, filepath.Join(root, qwenGuardScript), filepath.Join(root, qwenAuditScript), + }) + } + return contracts +} + +func managedQwenIDs(settings qwenHookSettings, event, script, runtimeRoot string) map[string]bool { + ids := map[string]bool{} + groups, _ := settings[event].([]any) + for _, groupValue := range groups { + for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) { + if agentID, ok := managedQwenAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok { + ids[agentID] = true + } + } + } + return ids +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_io.go b/sdks/go/cmd/elydora/plugins/qwen_io.go new file mode 100644 index 0000000..f700c75 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_io.go @@ -0,0 +1,150 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" +) + +var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`) + +func defaultQwenHome() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".qwen"), nil +} + +func resolveQwenStoragePath(value string) (string, error) { + resolved := value + if value == "~" || len(value) >= 2 && value[0] == '~' && (value[1] == '/' || value[1] == '\\') { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + resolved = home + if value != "~" { + for _, segment := range qwenPathSeparatorPattern.Split(value[2:], -1) { + if segment != "" { + resolved = filepath.Join(resolved, segment) + } + } + } + } + if filepath.IsAbs(resolved) { + return filepath.Clean(resolved), nil + } + absolute, err := filepath.Abs(resolved) + if err != nil { + return "", fmt.Errorf("resolve Qwen Code storage path %q: %w", value, err) + } + return absolute, nil +} + +func qwenHomeFromEnvFile(filePath string) (string, bool, error) { + raw, exists, err := readOptionalFile(filePath, "Qwen home environment") + if err != nil || !exists { + return "", false, err + } + value := parseDotenv(raw)["QWEN_HOME"] + if value == "" { + return "", false, nil + } + resolved, err := resolveQwenStoragePath(value) + return resolved, err == nil, err +} + +func resolveQwenHome() (string, error) { + defaultHome, err := defaultQwenHome() + if err != nil { + return "", err + } + value, explicit := os.LookupEnv("QWEN_HOME") + initialHome := defaultHome + if value != "" { + initialHome, err = resolveQwenStoragePath(value) + if err != nil { + return "", err + } + } + if explicit { + return initialHome, nil + } + for _, candidate := range []string{ + filepath.Join(initialHome, ".env"), filepath.Join(filepath.Dir(initialHome), ".env"), + } { + if discovered, exists, readErr := qwenHomeFromEnvFile(candidate); readErr != nil { + return "", readErr + } else if exists { + return discovered, nil + } + } + return initialHome, nil +} + +func readQwenDocument() (*qwenDocument, error) { + home, err := resolveQwenHome() + if err != nil { + return nil, err + } + configPath := filepath.Join(home, "settings.json") + raw, exists, err := readOptionalFile(configPath, "Qwen Code settings") + if err != nil { + return nil, err + } + if !exists { + return createOwnedQwenDocument(configPath) + } + return parseQwenDocument(true, configPath, raw) +} + +func prepareRenderedQwenChange(rendered *qwenRenderedDocument) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.filePath, + "Qwen Code settings", + rendered.document.raw, + rendered.document.exists, + rendered.next, + os.FileMode(0600), + rendered.remove, + ) +} + +func qwenRuntimeFilesExist(contracts []qwenRuntimeContract) (bool, error) { + for _, contract := range contracts { + configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") + raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists { + continue + } + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) + } + agentID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != qwenAgentKey || !sameQwenAgentID(agentID, contract.agentID) { + continue + } + guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") + if err != nil { + return false, err + } + if guardExists && auditExists { + return true, nil + } + } + return false, nil +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_recovery_test.go b/sdks/go/cmd/elydora/plugins/qwen_recovery_test.go new file mode 100644 index 0000000..113f4bd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_recovery_test.go @@ -0,0 +1,158 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestQwenInstallRejectsMalformedSettingsBeforeWrites(t *testing.T) { + tests := []struct { + name, source, pattern string + }{ + {"malformed", "{ malformed", "parse Qwen Code settings"}, + {"non-object", "[]", "JSON object"}, + {"trailing comma", `{ "owner": true, }`, "trailing comma"}, + {"duplicate root", `{ "hooks": {}, "hooks": {} }`, "duplicate"}, + {"nested duplicate", `{ "hooks": { "PreToolUse": [{ "hooks": [], "hooks": [] }] } }`, "duplicate"}, + {"invalid disable flag", `{ "disableAllHooks": "yes" }`, "must be a boolean"}, + {"hooks array", `{ "hooks": [] }`, "JSON object"}, + {"unsupported event", `{ "hooks": { "UnknownEvent": [] } }`, "unsupported field"}, + {"null event", `{ "hooks": { "PreToolUse": null } }`, "must be an array"}, + {"null group", `{ "hooks": { "PreToolUse": [null] } }`, "must be an object"}, + {"invalid matcher", `{ "hooks": { "PreToolUse": [{ "matcher": "[", "hooks": [] }] } }`, "regular expression"}, + {"invalid sequential", `{ "hooks": { "PreToolUse": [{ "sequential": "yes", "hooks": [] }] } }`, "must be a boolean"}, + {"null handlers", `{ "hooks": { "PreToolUse": [{ "hooks": null }] } }`, "hooks array"}, + {"missing command", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }`, "non-empty string"}, + {"missing url", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }`, "non-empty string"}, + {"invalid handler type", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function", "command": "x" }] }] } }`, "command"}, + {"invalid timeout", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": "ten" }] }] } }`, "finite number"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(test.source)}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("install error = %v, want substring %q", err, test.pattern) + } + if readQwenTestFile(t, fixture.configPath) != test.source { + t.Fatal("failed install changed Qwen Code settings") + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingQwenFile(t, path) + } + }) + } +} + +func TestQwenInstallValidatesRoutingAndManagedPathsBeforeWrites(t *testing.T) { + t.Run("unreadable home env", func(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + envPath := filepath.Join(fixture.qwenDir, ".env") + if err := os.MkdirAll(envPath, 0755); err != nil { + t.Fatalf("create unreadable env path: %v", err) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "Qwen home environment") { + t.Fatalf("install error = %v", err) + } + requireMissingQwenFile(t, fixture.configPath) + requireMissingQwenFile(t, fixture.runtimeConfig) + }) + + tests := []struct { + name string + mutate func(*qwenFixture) + pattern string + }{ + { + name: "missing guard", + mutate: func(fixture *qwenFixture) { + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard: %v", err) + } + }, + pattern: "guard runtime is missing", + }, + { + name: "invalid agent id", + mutate: func(fixture *qwenFixture) { + fixture.config.AgentID = "../agent" + }, + pattern: "single non-empty path segment", + }, + { + name: "guard outside managed directory", + mutate: func(fixture *qwenFixture) { + fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js") + }, + pattern: "managed agent directory", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + test.mutate(fixture) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("install error = %v, want substring %q", err, test.pattern) + } + for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingQwenFile(t, path) + } + }) + } +} + +func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{ + settings: qwenJSON(map[string]any{"owner": "user"}), + }) + original := readQwenTestFile(t, fixture.configPath) + failed := false + fixture.plugin.rename = func(source, destination string) error { + if !failed && sameQwenPath(destination, fixture.configPath) { + failed = true + return errors.New("simulated settings commit failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "Write Qwen Code installation") || !failed { + t.Fatalf("transaction error = %v, failed = %v", err, failed) + } + if readQwenTestFile(t, fixture.configPath) != original { + t.Fatal("transaction rollback did not restore Qwen Code settings") + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingQwenFile(t, path) + } + requireNoQwenStagingFiles(t, fixture.homeDir) +} + +func TestQwenRecoveryCommandsDoNotRequireNodeResolution(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + installQwenFixture(t, fixture) + t.Setenv("PATH", "") + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("status without PATH = %#v, %v", status, err) + } + if err := fixture.plugin.Uninstall(qwenTestAgentID); err != nil { + t.Fatalf("uninstall without PATH: %v", err) + } + requireMissingQwenFile(t, fixture.configPath) +} + +func TestQwenStatusSurfacesMalformedRuntimeConfig(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + installQwenFixture(t, fixture) + 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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_schema.go b/sdks/go/cmd/elydora/plugins/qwen_schema.go new file mode 100644 index 0000000..5fb66b6 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_schema.go @@ -0,0 +1,250 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "os/exec" + "strings" + "time" +) + +var qwenEventNames = map[string]bool{ + "PreToolUse": true, "PostToolUse": true, "PostToolUseFailure": true, + "PostToolBatch": true, "Notification": true, "UserPromptSubmit": true, + "UserPromptExpansion": true, "SessionStart": true, "Stop": true, + "MessageDisplay": true, "SubagentStart": true, "SubagentStop": true, + "PreCompact": true, "PostCompact": true, "SessionEnd": true, + "PermissionRequest": true, "PermissionDenied": true, "StopFailure": true, + "TodoCreated": true, "TodoCompleted": true, "InstructionsLoaded": true, +} + +var qwenConfigFields = map[string]bool{ + "enabled": true, "disabled": true, "notifications": true, +} + +type qwenRegexEntry struct { + Label string `json:"label"` + Pattern string `json:"pattern"` +} + +func readQwenHookSettings(value any, label string) (qwenHookSettings, error) { + object, ok := value.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf("%s must contain a JSON object", label) + } + settings := make(qwenHookSettings, len(object)) + for key, item := range object { + if qwenConfigFields[key] { + settings[key] = item + continue + } + if !qwenEventNames[key] { + return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key) + } + groups, ok := item.([]any) + if !ok { + return nil, fmt.Errorf(`%s field %q must be an array`, label, key) + } + for groupIndex, group := range groups { + if err := validateQwenGroup(group, label, key, groupIndex); err != nil { + return nil, err + } + } + settings[key] = item + } + return settings, nil +} + +func validateQwenGroup(value any, label, event string, groupIndex int) error { + location := fmt.Sprintf(`%s field %q[%d]`, label, event, groupIndex) + group, ok := value.(map[string]any) + if !ok || group == nil { + return fmt.Errorf("%s must be an object", location) + } + if matcher, exists := group["matcher"]; exists { + if _, ok := matcher.(string); !ok { + return fmt.Errorf("%s matcher must be a string", location) + } + } + if sequential, exists := group["sequential"]; exists { + if _, ok := sequential.(bool); !ok { + return fmt.Errorf("%s sequential must be a boolean", location) + } + } + handlers, ok := group["hooks"].([]any) + if !ok { + return fmt.Errorf("%s must contain a hooks array", location) + } + for handlerIndex, handler := range handlers { + if err := validateQwenHandler(handler, location, handlerIndex); err != nil { + return err + } + } + return nil +} + +func validateQwenHandler(value any, groupLabel string, handlerIndex int) error { + location := fmt.Sprintf("%s.hooks[%d]", groupLabel, handlerIndex) + handler, ok := value.(map[string]any) + if !ok || handler == nil { + return fmt.Errorf("%s must be an object", location) + } + kind, _ := handler["type"].(string) + if kind != "command" && kind != "http" && kind != "prompt" { + return fmt.Errorf(`%s type must be "command", "http", or "prompt"`, location) + } + if timeout, exists := handler["timeout"]; exists { + number, ok := timeout.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 { + return fmt.Errorf("%s timeout must be a non-negative finite number", location) + } + } + for _, key := range []string{"name", "description", "statusMessage", "source"} { + if err := optionalQwenString(handler, key, location); err != nil { + return err + } + } + switch kind { + case "command": + command, ok := handler["command"].(string) + if !ok || command == "" { + return fmt.Errorf("%s command must be a non-empty string", location) + } + if err := optionalQwenStringMap(handler, "env", location); err != nil { + return err + } + if err := optionalQwenBoolean(handler, "async", location); err != nil { + return err + } + if shell, exists := handler["shell"]; exists && shell != "bash" && shell != "powershell" { + return fmt.Errorf(`%s shell must be "bash" or "powershell"`, location) + } + case "http": + url, ok := handler["url"].(string) + if !ok || url == "" { + return fmt.Errorf("%s url must be a non-empty string", location) + } + if err := optionalQwenStringMap(handler, "headers", location); err != nil { + return err + } + if err := optionalQwenBoolean(handler, "once", location); err != nil { + return err + } + if err := optionalQwenString(handler, "if", location); err != nil { + return err + } + if allowed, exists := handler["allowedEnvVars"]; exists { + values, ok := allowed.([]any) + if !ok || !allQwenStrings(values) { + return fmt.Errorf("%s allowedEnvVars must be an array of strings", location) + } + } + case "prompt": + prompt, ok := handler["prompt"].(string) + if !ok || prompt == "" { + return fmt.Errorf("%s prompt must be a non-empty string", location) + } + if err := optionalQwenString(handler, "model", location); err != nil { + return err + } + } + return nil +} + +func optionalQwenString(value map[string]any, key, label string) error { + if item, exists := value[key]; exists { + if _, ok := item.(string); !ok { + return fmt.Errorf(`%s field %q must be a string`, label, key) + } + } + return nil +} + +func optionalQwenBoolean(value map[string]any, key, label string) error { + if item, exists := value[key]; exists { + if _, ok := item.(bool); !ok { + return fmt.Errorf(`%s field %q must be a boolean`, label, key) + } + } + return nil +} + +func optionalQwenStringMap(value map[string]any, key, label string) error { + item, exists := value[key] + if !exists { + return nil + } + entries, ok := item.(map[string]any) + if !ok { + return fmt.Errorf(`%s field %q must contain string values`, label, key) + } + for _, entry := range entries { + if _, ok := entry.(string); !ok { + return fmt.Errorf(`%s field %q must contain string values`, label, key) + } + } + return nil +} + +func allQwenStrings(values []any) bool { + for _, value := range values { + if _, ok := value.(string); !ok { + return false + } + } + return true +} + +func validateQwenRegexes(nodePath string, settings qwenHookSettings) error { + entries := make([]qwenRegexEntry, 0) + for event, value := range settings { + if qwenConfigFields[event] { + continue + } + for index, groupValue := range value.([]any) { + group := groupValue.(map[string]any) + matcher, ok := group["matcher"].(string) + if ok && strings.TrimSpace(matcher) != "" && strings.TrimSpace(matcher) != "*" { + entries = append(entries, qwenRegexEntry{ + Label: fmt.Sprintf(`Qwen Code hooks field %q[%d] matcher`, event, index), Pattern: matcher, + }) + } + } + } + if len(entries) == 0 { + return nil + } + payload, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("encode Qwen Code regular expressions: %w", err) + } + validator := `import fs from 'node:fs'; +const entries = JSON.parse(fs.readFileSync(0, 'utf8')); +for (const entry of entries) { + try { new RegExp(entry.pattern); } + catch (error) { + process.stderr.write(entry.label + ': ' + (error instanceof Error ? error.message : String(error))); + process.exit(1); + } +}` + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // #nosec G204 -- nodePath is resolved by exec.LookPath and every argument is fixed. + command := exec.CommandContext(ctx, nodePath, "--input-type=module", "--eval", validator) + command.Stdin = bytes.NewReader(payload) + output, runErr := command.CombinedOutput() + if ctx.Err() != nil { + return fmt.Errorf("Qwen Code regular expression validation timed out: %w", ctx.Err()) + } + if runErr != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = runErr.Error() + } + return fmt.Errorf("Qwen Code matcher must be a valid JavaScript regular expression: %s", message) + } + return nil +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_test.go b/sdks/go/cmd/elydora/plugins/qwen_test.go new file mode 100644 index 0000000..fcfa9bb --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_test.go @@ -0,0 +1,300 @@ +package plugins + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestQwenRegistryUsesOfficialUserSettings(t *testing.T) { + entry := SupportedAgents[qwenAgentKey] + if entry.Name != "Qwen Code" || entry.ConfigDir != "~/.qwen" || entry.ConfigFile != "settings.json" { + t.Fatalf("Qwen Code registry entry = %#v", entry) + } + if _, ok := NewPlugin(qwenAgentKey).(*QwenPlugin); !ok { + t.Fatal("Qwen Code plugin is not registered") + } +} + +func TestQwenInstallPreservesJSONCommentsAndIsIdempotent(t *testing.T) { + settings := "{\r\n" + + " // Keep this user preference.\r\n" + + " \"theme\": \"GitHub\",\r\n" + + " \"hooks\": {\r\n" + + " \"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"session-hook\" }] }],\r\n" + + " \"PreToolUse\": [{ \"matcher\": \"read_file\", \"hooks\": [{ \"type\": \"command\", \"command\": \"user-hook\" }] }]\r\n" + + " }\r\n" + + "}\r\n" + fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)}) + workspaceSettings := filepath.Join(fixture.workspaceDir, ".qwen", "settings.json") + writeOptionalQwenFile(t, workspaceSettings, qwenString("{ \"owner\": \"workspace\" }\n")) + installQwenFixture(t, fixture) + first := readQwenTestFile(t, fixture.configPath) + installQwenFixture(t, fixture) + if readQwenTestFile(t, fixture.configPath) != first { + t.Fatal("idempotent install changed Qwen Code settings") + } + if !strings.Contains(first, "Keep this user preference") || !strings.Contains(first, "\r\n") { + t.Fatalf("settings comments or line endings were lost:\n%s", first) + } + root := readQwenTestObject(t, fixture.configPath) + if root["theme"] != "GitHub" { + t.Fatalf("theme = %#v", root["theme"]) + } + hooks := requireQwenObject(t, root["hooks"]) + if len(requireQwenArray(t, hooks["SessionStart"])) != 1 || + len(requireQwenArray(t, hooks["PreToolUse"])) != 2 || + len(requireQwenArray(t, hooks["PostToolUse"])) != 1 { + t.Fatalf("Qwen hooks = %#v", hooks) + } + for _, item := range []struct{ event, path string }{ + {"PreToolUse", fixture.guardPath}, {"PostToolUse", fixture.hookPath}, + } { + handler := qwenManagedHandler(t, root, item.event, item.path) + if len(handler) != 4 || handler["type"] != "command" || + handler["shell"] != expectedQwenShell() || handler["timeout"] != float64(10_000) { + t.Fatalf("managed handler = %#v", handler) + } + } + if readQwenTestObject(t, workspaceSettings)["owner"] != "workspace" { + t.Fatal("workspace settings changed") + } + runtimeConfig := readQwenTestObject(t, fixture.runtimeConfig) + if runtimeConfig["agent_name"] != qwenAgentKey { + t.Fatalf("runtime config = %#v", runtimeConfig) + } +} + +func TestQwenInstallReportsReviewCommand(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create output pipe: %v", err) + } + original := os.Stdout + os.Stdout = writer + installErr := fixture.plugin.Install(fixture.config) + os.Stdout = original + closeErr := writer.Close() + output, readErr := io.ReadAll(reader) + _ = reader.Close() + if installErr != nil || closeErr != nil || readErr != nil { + t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr) + } + text := string(output) + for _, marker := range []string{fixture.configPath, "/hooks"} { + if !strings.Contains(text, marker) { + t.Fatalf("install output is missing %q: %s", marker, text) + } + } +} + +func TestQwenHomeUsesOfficialUserEnvironmentPrecedence(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + firstHome := filepath.Join(filepath.Dir(fixture.homeDir), "first # qwen home") + secondHome := filepath.Join(filepath.Dir(fixture.homeDir), "second qwen home") + writeOptionalQwenFile( + t, + filepath.Join(fixture.qwenDir, ".env"), + qwenString("export QWEN_HOME = \""+firstHome+"\" # selected by Qwen\n"), + ) + writeOptionalQwenFile(t, filepath.Join(fixture.homeDir, ".env"), qwenString("QWEN_HOME="+secondHome+"\n")) + installQwenFixture(t, fixture) + selected := filepath.Join(firstHome, "settings.json") + qwenManagedHandler(t, readQwenTestObject(t, selected), "PreToolUse", fixture.guardPath) + requireMissingQwenFile(t, filepath.Join(secondHome, "settings.json")) + requireMissingQwenFile(t, fixture.configPath) + status, err := fixture.plugin.Status() + if err != nil || status.ConfigPath != selected || !status.Installed { + t.Fatalf("Qwen status = %#v, %v", status, err) + } +} + +func TestQwenExplicitHomeSupportsRelativeTildeAndEmptyValues(t *testing.T) { + tests := []struct { + name, value string + expected func(*qwenFixture) string + }{ + {"relative", "relative-qwen", func(f *qwenFixture) string { + return filepath.Join(f.workspaceDir, "relative-qwen", "settings.json") + }}, + {"tilde", "~/custom-qwen", func(f *qwenFixture) string { + return filepath.Join(f.homeDir, "custom-qwen", "settings.json") + }}, + {"empty", "", func(f *qwenFixture) string { return f.configPath }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + ignored := filepath.Join(filepath.Dir(fixture.homeDir), "ignored-qwen-home") + writeOptionalQwenFile( + t, filepath.Join(fixture.qwenDir, ".env"), qwenString("QWEN_HOME="+ignored+"\n"), + ) + t.Setenv("QWEN_HOME", test.value) + installQwenFixture(t, fixture) + qwenManagedHandler(t, readQwenTestObject(t, test.expected(fixture)), "PreToolUse", fixture.guardPath) + requireMissingQwenFile(t, filepath.Join(ignored, "settings.json")) + }) + } +} + +func TestQwenCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + installQwenFixture(t, fixture) + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + encodedPath, 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(encodedPath) + ", Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("write capture audit runtime: %v", err) + } + settings := readQwenTestObject(t, fixture.configPath) + guard := qwenManagedHandler(t, settings, "PreToolUse", fixture.guardPath) + audit := qwenManagedHandler(t, settings, "PostToolUse", fixture.hookPath) + prePayload := `{"session_id":"session-1","cwd":"C:/workspace","hook_event_name":"PreToolUse","timestamp":"2026-07-19T00:00:00.000Z","tool_name":"run_shell_command","tool_input":{"command":"echo test"}}` + guardResult := runQwenHandler(t, guard, fixture.homeDir, prePayload) + if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard result = %#v", guardResult) + } + postPayload := strings.Replace(prePayload, "PreToolUse", "PostToolUse", 1) + auditResult := runQwenHandler(t, audit, fixture.homeDir, postPayload) + if auditResult.exitCode != 0 { + t.Fatalf("audit result = %#v", auditResult) + } + if readQwenTestFile(t, capturePath) != postPayload { + t.Fatal("audit hook changed the official Qwen Code payload") + } +} + +func TestQwenStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + installQwenFixture(t, fixture) + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { + t.Fatalf("installed status = %#v, %v", status, err) + } + settings := readQwenTestObject(t, fixture.configPath) + settings["disableAllHooks"] = true + writeQwenTestObject(t, fixture.configPath, settings) + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("disabled status = %#v, %v", status, err) + } + settings["disableAllHooks"] = false + writeQwenTestObject(t, fixture.configPath, settings) + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard runtime: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("missing-runtime status = %#v, %v", status, err) + } +} + +func TestQwenUninstallPreservesExternalMutationsAndExactOwnership(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{ + settings: qwenString(`{"$version":4,"owner":"user"}`), + }) + installQwenFixture(t, fixture) + settings := readQwenTestObject(t, fixture.configPath) + group := qwenManagedGroup(t, settings, "PreToolUse", fixture.guardPath) + group["hooks"] = append(requireQwenArray(t, group["hooks"]), map[string]any{ + "type": "command", "command": "user-command", + }) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + hooks := requireQwenObject(t, settings["hooks"]) + hooks["PreToolUse"] = append(requireQwenArray(t, hooks["PreToolUse"]), map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", "command": buildQwenCommand(nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", qwenGuardScript)), + "shell": expectedQwenShell(), "timeout": float64(10_000), + }}, + }) + writeQwenTestObject(t, fixture.configPath, settings) + before := readQwenTestFile(t, fixture.configPath) + if err := fixture.plugin.Uninstall("other-agent"); err != nil { + t.Fatalf("uninstall other agent: %v", err) + } + if readQwenTestFile(t, fixture.configPath) != before { + t.Fatal("uninstall changed an unrelated installation") + } + agentID := qwenTestAgentID + if runtime.GOOS == "windows" { + agentID = "AGENT-1" + } + if err := fixture.plugin.Uninstall(agentID); err != nil { + t.Fatalf("uninstall Qwen Code hooks: %v", err) + } + remaining := readQwenTestObject(t, fixture.configPath) + if remaining["$version"] != float64(4) || remaining["owner"] != "user" { + t.Fatalf("root metadata = %#v", remaining) + } + remainingRaw := readQwenTestFile(t, fixture.configPath) + for _, marker := range []string{"user-command", "agent-10"} { + if !strings.Contains(remainingRaw, marker) { + t.Fatalf("uninstall removed %q", marker) + } + } + remainingHooks := requireQwenObject(t, remaining["hooks"]) + if _, exists := remainingHooks["PostToolUse"]; exists { + t.Fatalf("PostToolUse remains: %#v", remainingHooks) + } +} + +func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{}) + installQwenFixture(t, fixture) + if !strings.HasPrefix(readQwenTestFile(t, fixture.configPath), qwenOwnedFileMarker) { + t.Fatal("created settings are missing the ownership marker") + } + if err := fixture.plugin.Uninstall(qwenTestAgentID); err != nil { + t.Fatalf("uninstall Qwen Code hooks: %v", err) + } + requireMissingQwenFile(t, fixture.configPath) +} + +func TestQwenDotenvParserMatchesOfficialGrammar(t *testing.T) { + tests := []struct { + source string + want map[string]string + }{ + {"QWEN_HOME=plain\n", map[string]string{"QWEN_HOME": "plain"}}, + {"export QWEN_HOME = \"path # one\" # comment\n", map[string]string{"QWEN_HOME": "path # one"}}, + {"QWEN_HOME: path with spaces\n", map[string]string{"QWEN_HOME": "path with spaces"}}, + {"QWEN_HOME=\n", map[string]string{"QWEN_HOME": ""}}, + {"QWEN_HOME='single quoted'\r\n", map[string]string{"QWEN_HOME": "single quoted"}}, + {"QWEN_HOME=`backtick value`\n", map[string]string{"QWEN_HOME": "backtick value"}}, + {"QWEN_HOME=first\nQWEN_HOME=second\n", map[string]string{"QWEN_HOME": "second"}}, + {"OTHER=x\nQWEN_HOME=\"line\\nnext\"\n", map[string]string{"OTHER": "x", "QWEN_HOME": "line\nnext"}}, + } + for _, test := range tests { + got := parseDotenv([]byte(test.source)) + encodedGot, _ := json.Marshal(got) + encodedWant, _ := json.Marshal(test.want) + if string(encodedGot) != string(encodedWant) { + t.Fatalf("parseDotenv(%q) = %s, want %s", test.source, encodedGot, encodedWant) + } + } +} + +func writeQwenTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("encode %s: %v", path, err) + } + if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/qwen_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/qwen_test_helpers_test.go new file mode 100644 index 0000000..1068bd1 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/qwen_test_helpers_test.go @@ -0,0 +1,273 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const qwenTestAgentID = "agent-1" + +type qwenFixtureOptions struct { + settings *string + skipGuard bool +} + +type qwenFixture struct { + plugin *QwenPlugin + config InstallConfig + homeDir string + workspaceDir string + qwenDir string + configPath string + agentDir string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +type qwenCommandResult struct { + exitCode int + stderr string +} + +func qwenString(value string) *string { + return &value +} + +func qwenJSON(value any) *string { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + panic(err) + } + return qwenString(string(encoded) + "\n") +} + +func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture { + t.Helper() + rootDir := t.TempDir() + homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + workspaceDir := filepath.Join(homeDir, "workspace") + qwenDir := filepath.Join(homeDir, ".qwen") + configPath := filepath.Join(qwenDir, "settings.json") + agentDir := filepath.Join(homeDir, ".elydora", qwenTestAgentID) + guardPath := filepath.Join(agentDir, qwenGuardScript) + hookPath := filepath.Join(agentDir, qwenAuditScript) + for _, directory := range []string{workspaceDir, agentDir} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + if !options.skipGuard { + guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" + if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + writeOptionalQwenFile(t, configPath, options.settings) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + unsetQwenTestEnv(t, "QWEN_HOME") + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("read current directory: %v", err) + } + if err := os.Chdir(workspaceDir); err != nil { + t.Fatalf("enter workspace directory: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDirectory); err != nil { + t.Errorf("restore current directory: %v", err) + } + }) + return &qwenFixture{ + plugin: &QwenPlugin{}, + homeDir: homeDir, + workspaceDir: workspaceDir, + qwenDir: qwenDir, + configPath: configPath, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + config: InstallConfig{ + AgentName: qwenAgentKey, + OrgID: "org-1", + AgentID: qwenTestAgentID, + PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + } +} + +func unsetQwenTestEnv(t *testing.T, name string) { + t.Helper() + value, existed := os.LookupEnv(name) + if err := os.Unsetenv(name); err != nil { + t.Fatalf("unset %s: %v", name, err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv(name, value) + } else { + _ = os.Unsetenv(name) + } + }) +} + +func writeOptionalQwenFile(t *testing.T, path string, source *string) { + t.Helper() + if source == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(*source), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func installQwenFixture(t *testing.T, fixture *qwenFixture) { + t.Helper() + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Qwen Code hooks: %v", err) + } +} + +func readQwenTestFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(raw) +} + +func readQwenTestObject(t *testing.T, path string) map[string]any { + t.Helper() + object, err := decodeJSONCObject( + []byte(readQwenTestFile(t, path)), "Qwen test settings", false, + ) + if err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return object +} + +func requireQwenObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value is not an object: %#v", value) + } + return object +} + +func requireQwenArray(t *testing.T, value any) []any { + t.Helper() + array, ok := value.([]any) + if !ok { + t.Fatalf("value is not an array: %#v", value) + } + return array +} + +func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath string) map[string]any { + t.Helper() + hooks := requireQwenObject(t, settings["hooks"]) + for _, groupValue := range requireQwenArray(t, hooks[event]) { + group := requireQwenObject(t, groupValue) + for _, handlerValue := range requireQwenArray(t, group["hooks"]) { + handler := requireQwenObject(t, handlerValue) + command, _ := handler["command"].(string) + _, candidate, managed := parseQwenCommand(command) + if managed && sameQwenPath(candidate, scriptPath) { + return group + } + } + } + t.Fatalf("managed group for %s not found", scriptPath) + return nil +} + +func qwenManagedHandler(t *testing.T, settings map[string]any, event, scriptPath string) map[string]any { + t.Helper() + group := qwenManagedGroup(t, settings, event, scriptPath) + for _, handlerValue := range requireQwenArray(t, group["hooks"]) { + handler := requireQwenObject(t, handlerValue) + command, _ := handler["command"].(string) + _, candidate, managed := parseQwenCommand(command) + if managed && sameQwenPath(candidate, scriptPath) { + return handler + } + } + t.Fatalf("managed handler for %s not found", scriptPath) + return nil +} + +func runQwenHandler(t *testing.T, handler map[string]any, homeDir, payload string) qwenCommandResult { + t.Helper() + command := handler["command"].(string) + var process *exec.Cmd + if handler["shell"] == "powershell" { + process = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + process = exec.Command("bash", "-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 qwenCommandResult{stderr: stderr.String()} + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return qwenCommandResult{exitCode: exitError.ExitCode(), stderr: stderr.String()} + } + t.Fatalf("run Qwen Code hook command: %v", err) + return qwenCommandResult{} +} + +func requireMissingQwenFile(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be missing, got %v", path, err) + } +} + +func requireNoQwenStagingFiles(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("staging file remains: %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk staging files: %v", err) + } +} + +func expectedQwenShell() string { + if runtime.GOOS == "windows" { + return "powershell" + } + return "bash" +} diff --git a/sdks/go/cmd/elydora/plugins/registry.go b/sdks/go/cmd/elydora/plugins/registry.go index 388bb8e..6c6b2b9 100644 --- a/sdks/go/cmd/elydora/plugins/registry.go +++ b/sdks/go/cmd/elydora/plugins/registry.go @@ -23,6 +23,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, + "qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"}, } // NewPlugin creates a plugin instance for the given agent name. @@ -57,6 +58,8 @@ func NewPlugin(agentName string) AgentPlugin { return &CopilotPlugin{} case "letta": return &LettaPlugin{} + case "qwen": + return &QwenPlugin{} default: return nil } diff --git a/sdks/go/cmd/elydora/plugins/transaction.go b/sdks/go/cmd/elydora/plugins/transaction.go new file mode 100644 index 0000000..8a1e1f5 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/transaction.go @@ -0,0 +1,288 @@ +package plugins + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +type renameFunc func(source, destination string) error + +type fileChange struct { + filePath string + label string + original []byte + originalMode os.FileMode + existed bool + next []byte + mode os.FileMode + remove bool +} + +type stagedChange struct { + change fileChange + temporaryPath string + rollbackPath string + committed bool +} + +func readOptionalFile(path, label string) ([]byte, bool, error) { + raw, err := os.ReadFile(path) // #nosec G304 -- callers constrain paths to managed user configuration files. + if err == nil { + return raw, true, nil + } + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err) +} + +func prepareFileChange(filePath, label string, next []byte, mode os.FileMode) (*fileChange, error) { + original, existed, err := readOptionalFile(filePath, label) + if err != nil { + return nil, err + } + return prepareSourceChange(filePath, label, original, existed, next, mode, false) +} + +func prepareSourceChange( + filePath, label string, + original []byte, + existed bool, + next []byte, + mode os.FileMode, + remove bool, +) (*fileChange, error) { + if !existed { + original = nil + } + if existed && !remove && bytes.Equal(original, next) { + return nil, nil + } + if !existed && (remove || next == nil) { + return nil, nil + } + originalMode := mode + if existed { + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("read file mode for %s at %s: %w", label, filePath, err) + } + originalMode = info.Mode().Perm() + } + return &fileChange{ + filePath: filePath, label: label, original: append([]byte(nil), original...), + originalMode: originalMode, existed: existed, next: append([]byte(nil), next...), + mode: mode, remove: remove, + }, nil +} + +func writeChanges(changes []*fileChange, label string, rename renameFunc) error { + filtered := make([]fileChange, 0, len(changes)) + targets := map[string]bool{} + for _, change := range changes { + if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) { + continue + } + target := filepath.Clean(change.filePath) + if targets[target] { + return fmt.Errorf("%s contains duplicate file target %s", label, change.filePath) + } + targets[target] = true + filtered = append(filtered, *change) + } + if len(filtered) == 0 { + return nil + } + if rename == nil { + rename = os.Rename + } + staged := make([]stagedChange, 0, len(filtered)) + for _, change := range filtered { + item, err := stageChange(change) + if err != nil { + cleanupErrors := cleanupStaging(staged) + return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed") + } + staged = append(staged, item) + } + for index := range staged { + if err := commitChange(&staged[index], rename); err != nil { + recoveryErrors := rollbackChanges(staged, rename) + recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) + return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") + } + } + cleanupErrors := cleanupStaging(staged) + if len(cleanupErrors) > 0 { + return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed") + } + return nil +} + +func stageChange(change fileChange) (stagedChange, error) { + if err := assertFileUnchanged(change); err != nil { + return stagedChange{}, err + } + directory := filepath.Dir(change.filePath) + if err := os.MkdirAll(directory, 0700); err != nil { + return stagedChange{}, fmt.Errorf("create directory for %s at %s: %w", change.label, directory, err) + } + staged := stagedChange{change: change} + var err error + if change.remove { + staged.rollbackPath, err = reserveStagingPath(directory, filepath.Base(change.filePath), ".rollback") + } else { + staged.temporaryPath, err = writeStagedFile( + directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode, + ) + if err == nil && change.existed { + staged.rollbackPath, err = writeStagedFile( + directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode, + ) + } + } + if err != nil { + cleanupErrors := cleanupStaging([]stagedChange{staged}) + return stagedChange{}, joinTransactionFailure( + fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed", + ) + } + return staged, nil +} + +func writeStagedFile( + directory, basename, suffix string, + content []byte, + mode os.FileMode, +) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + failed := func(cause error) (string, error) { + return "", errors.Join(cause, file.Close(), removeOptionalFile(path)) + } + if err := file.Chmod(mode); err != nil { + return failed(err) + } + written, err := file.Write(content) + if err != nil { + return failed(err) + } + if written != len(content) { + return failed(io.ErrShortWrite) + } + if err := file.Sync(); err != nil { + return failed(err) + } + if err := file.Close(); err != nil { + return "", errors.Join(err, removeOptionalFile(path)) + } + return path, nil +} + +func reserveStagingPath(directory, basename, suffix string) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + if err := file.Close(); err != nil { + return "", errors.Join(err, removeOptionalFile(path)) + } + if err := os.Remove(path); err != nil { + return "", err + } + return path, nil +} + +func assertFileUnchanged(change fileChange) error { + current, existed, err := readOptionalFile(change.filePath, change.label) + if err != nil { + return err + } + if existed != change.existed || !bytes.Equal(current, change.original) { + return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath) + } + return nil +} + +func commitChange(staged *stagedChange, rename renameFunc) error { + if err := assertFileUnchanged(staged.change); err != nil { + return err + } + if staged.change.remove { + if staged.rollbackPath == "" { + return fmt.Errorf("missing rollback path for %s", staged.change.label) + } + if err := rename(staged.change.filePath, staged.rollbackPath); err != nil { + return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err) + } + } else { + if staged.temporaryPath == "" { + return fmt.Errorf("missing staged file for %s", staged.change.label) + } + if err := rename(staged.temporaryPath, staged.change.filePath); err != nil { + return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err) + } + } + staged.committed = true + return nil +} + +func rollbackChanges(staged []stagedChange, rename renameFunc) []error { + failures := make([]error, 0) + for index := len(staged) - 1; index >= 0; index-- { + item := &staged[index] + if !item.committed { + continue + } + var err error + switch { + case item.change.remove: + err = rename(item.rollbackPath, item.change.filePath) + case item.change.existed: + err = rename(item.rollbackPath, item.change.filePath) + default: + err = removeOptionalFile(item.change.filePath) + } + if err != nil { + failures = append(failures, fmt.Errorf("restore %s at %s: %w", item.change.label, item.change.filePath, err)) + } + } + return failures +} + +func cleanupStaging(staged []stagedChange) []error { + failures := make([]error, 0) + for _, item := range staged { + for _, path := range []string{item.temporaryPath, item.rollbackPath} { + if err := removeOptionalFile(path); err != nil { + failures = append(failures, err) + } + } + } + return failures +} + +func removeOptionalFile(path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove staged file at %s: %w", path, err) + } + return nil +} + +func joinTransactionFailure(cause error, related []error, label string) error { + if len(related) == 0 { + return cause + } + return errors.Join(cause, fmt.Errorf("%s: %w", label, errors.Join(related...))) +}