feat: add native Qwen Code hook integration
This commit is contained in:
1 parent
577e3609b5
commit
eae9015a61
18 files changed
+2137
-340
No files matched your search
@@ -29,6 +29,8 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
|
|||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
||||||
- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review.
|
- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review.
|
||||||
- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files.
|
- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files.
|
||||||
|
- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for 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.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,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.
|
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
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -115,6 +117,7 @@ Factory Droid installation selects `PreToolUse` and `PostToolUse` independently
|
|||||||
| Kimi Code | `kimi` |
|
| Kimi Code | `kimi` |
|
||||||
| Letta Code | `letta` |
|
| Letta Code | `letta` |
|
||||||
| OpenCode | `opencode` |
|
| OpenCode | `opencode` |
|
||||||
|
| Qwen Code | `qwen` |
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DroidPlugin struct {
|
type DroidPlugin struct {
|
||||||
rename droidRenameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidRuntimeConfig struct {
|
type droidRuntimeConfig struct {
|
||||||
@@ -80,7 +80,7 @@ func (p *DroidPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
@@ -102,7 +102,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings))
|
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings))
|
||||||
changes := make([]*droidFileChange, 0, len(documents))
|
changes := make([]*fileChange, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil)
|
rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil)
|
||||||
if renderErr != nil {
|
if renderErr != nil {
|
||||||
@@ -114,7 +114,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
|
|||||||
}
|
}
|
||||||
changes = append(changes, change)
|
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) {
|
func (p *DroidPlugin) Status() (PluginStatus, error) {
|
||||||
@@ -174,7 +174,7 @@ func prepareDroidInstallationChanges(
|
|||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
agentDirectory, auditPath string,
|
agentDirectory, auditPath string,
|
||||||
rendered []*droidRenderedDocument,
|
rendered []*droidRenderedDocument,
|
||||||
) ([]*droidFileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
baseURL := config.BaseURL
|
baseURL := config.BaseURL
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.elydora.com"
|
baseURL = "https://api.elydora.com"
|
||||||
@@ -196,9 +196,9 @@ func prepareDroidInstallationChanges(
|
|||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600},
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600},
|
||||||
{auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700},
|
{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 {
|
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 {
|
if changeErr != nil {
|
||||||
return nil, changeErr
|
return nil, changeErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -54,7 +53,7 @@ func readDroidSources() (*droidSources, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rootRaw, rootExists, err := readDroidOptional(rootPath, "Factory Droid hooks")
|
rootRaw, rootExists, err := readOptionalFile(rootPath, "Factory Droid hooks")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ func readDroidSources() (*droidSources, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
legacyRaw, legacyExists, readErr := readDroidOptional(legacyPath, "Factory Droid legacy hooks")
|
legacyRaw, legacyExists, readErr := readOptionalFile(legacyPath, "Factory Droid legacy hooks")
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return nil, readErr
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ func readDroidSources() (*droidSources, error) {
|
|||||||
|
|
||||||
func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) {
|
func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) {
|
||||||
label := droidDocumentLabel(kind, filePath)
|
label := droidDocumentLabel(kind, filePath)
|
||||||
root, err := decodeDroidJSONCObject(raw, label)
|
root, err := decodeJSONCObject(raw, label, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -202,7 +201,7 @@ func renderDroidDocument(
|
|||||||
agentID, runtimeRoot string,
|
agentID, runtimeRoot string,
|
||||||
additions map[string]map[string]any,
|
additions map[string]map[string]any,
|
||||||
) (*droidRenderedDocument, error) {
|
) (*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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -287,14 +286,3 @@ func displayDroidConfigPath(sources *droidSources) string {
|
|||||||
}
|
}
|
||||||
return sources.rootPath
|
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)
|
|
||||||
}
|
|
||||||
+10
-277
@@ -1,65 +1,13 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
type droidRenameFunc func(source, destination string) error
|
func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, 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) {
|
|
||||||
if rendered == nil || !rendered.changed {
|
if rendered == nil || !rendered.changed {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -69,236 +17,21 @@ func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*droidFileChan
|
|||||||
} else if rendered.document.kind == "settings" {
|
} else if rendered.document.kind == "settings" {
|
||||||
label = "Factory Droid settings"
|
label = "Factory Droid settings"
|
||||||
}
|
}
|
||||||
originalMode := os.FileMode(0600)
|
return prepareSourceChange(
|
||||||
var original []byte
|
rendered.document.filePath,
|
||||||
if rendered.document.exists {
|
label,
|
||||||
info, err := os.Stat(rendered.document.filePath)
|
rendered.document.raw,
|
||||||
if err != nil {
|
rendered.document.exists,
|
||||||
return nil, fmt.Errorf("read file mode for %s at %s: %w", label, rendered.document.filePath, err)
|
rendered.next,
|
||||||
}
|
os.FileMode(0600),
|
||||||
originalMode = info.Mode().Perm()
|
rendered.remove,
|
||||||
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...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
|
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ func requireMissingDroidFile(t *testing.T, path string) {
|
|||||||
|
|
||||||
func readDroidTestObject(t *testing.T, path string) map[string]any {
|
func readDroidTestObject(t *testing.T, path string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
standard, err := standardizeDroidJSONC([]byte(readDroidTestFile(t, path)))
|
standard, err := standardizeJSONC(
|
||||||
|
[]byte(readDroidTestFile(t, path)), "Factory Droid test source", true,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("standardize %s: %v", path, err)
|
t.Fatalf("standardize %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
)
|
)
|
||||||
|
|
||||||
type droidJSONCEditor struct {
|
type jsoncEditor struct {
|
||||||
value hujson.Value
|
value hujson.Value
|
||||||
eol string
|
eol string
|
||||||
indent string
|
indent string
|
||||||
|
label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func standardizeDroidJSONC(source []byte) ([]byte, error) {
|
func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) {
|
||||||
value, err := parseDroidJSONC(source, "JSONC source")
|
value, err := parseJSONC(source, label, allowTrailingCommas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -25,18 +26,51 @@ func standardizeDroidJSONC(source []byte) ([]byte, error) {
|
|||||||
return value.Pack(), nil
|
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)
|
value, err := hujson.Parse(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hujson.Value{}, fmt.Errorf("parse %s: %w", label, err)
|
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 hujson.Value{}, err
|
||||||
}
|
}
|
||||||
return value, nil
|
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) {
|
switch current := value.Value.(type) {
|
||||||
case *hujson.Object:
|
case *hujson.Object:
|
||||||
seen := make(map[string]bool, len(current.Members))
|
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)
|
return fmt.Errorf("%s contains duplicate key %q at %s", label, name, location)
|
||||||
}
|
}
|
||||||
seen[name] = true
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case *hujson.Array:
|
case *hujson.Array:
|
||||||
for index := range current.Elements {
|
for index := range current.Elements {
|
||||||
if err := rejectDroidDuplicateKeys(
|
if err := rejectDuplicateJSONCKeys(
|
||||||
¤t.Elements[index], label, location+"/"+strconv.Itoa(index),
|
¤t.Elements[index], label, location+"/"+strconv.Itoa(index),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -63,8 +97,8 @@ func rejectDroidDuplicateKeys(value *hujson.Value, label, location string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDroidJSONCObject(source []byte, label string) (map[string]any, error) {
|
func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) {
|
||||||
value, err := parseDroidJSONC(source, label)
|
value, err := parseJSONC(source, label, allowTrailingCommas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -80,24 +114,24 @@ func decodeDroidJSONCObject(source []byte, label string) (map[string]any, error)
|
|||||||
return object, nil
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDroidJSONCEditor(source []byte, label string) (*droidJSONCEditor, error) {
|
func newJSONCEditor(source []byte, label string, allowTrailingCommas bool) (*jsoncEditor, error) {
|
||||||
value, err := parseDroidJSONC(source, label)
|
value, err := parseJSONC(source, label, allowTrailingCommas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &droidJSONCEditor{
|
return &jsoncEditor{
|
||||||
value: value, eol: detectDroidEOL(source), indent: detectDroidIndent(source),
|
value: value, eol: detectJSONCEOL(source), indent: detectJSONCIndent(source), label: label,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectDroidEOL(source []byte) string {
|
func detectJSONCEOL(source []byte) string {
|
||||||
if bytes.Contains(source, []byte("\r\n")) {
|
if bytes.Contains(source, []byte("\r\n")) {
|
||||||
return "\r\n"
|
return "\r\n"
|
||||||
}
|
}
|
||||||
return "\n"
|
return "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectDroidIndent(source []byte) string {
|
func detectJSONCIndent(source []byte) string {
|
||||||
normalized := strings.ReplaceAll(string(source), "\r\n", "\n")
|
normalized := strings.ReplaceAll(string(source), "\r\n", "\n")
|
||||||
for _, line := range strings.Split(normalized, "\n") {
|
for _, line := range strings.Split(normalized, "\n") {
|
||||||
trimmed := strings.TrimLeft(line, "\t ")
|
trimmed := strings.TrimLeft(line, "\t ")
|
||||||
@@ -109,34 +143,35 @@ func detectDroidIndent(source []byte) string {
|
|||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
|
|
||||||
func (editor *droidJSONCEditor) pack() []byte {
|
func (editor *jsoncEditor) pack() []byte {
|
||||||
return editor.value.Pack()
|
return editor.value.Pack()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (editor *droidJSONCEditor) remove(path []any) error {
|
func (editor *jsoncEditor) remove(path []any) error {
|
||||||
operation := []map[string]any{{"op": "remove", "path": droidJSONPointer(path)}}
|
pointer := jsonPointer(path)
|
||||||
|
operation := []map[string]any{{"op": "remove", "path": pointer}}
|
||||||
patch, err := json.Marshal(operation)
|
patch, err := json.Marshal(operation)
|
||||||
if err != nil {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (editor *droidJSONCEditor) addProperty(objectPath []any, name string, value any) error {
|
func (editor *jsoncEditor) addProperty(objectPath []any, name string, value any) error {
|
||||||
container, err := findDroidJSONCValue(&editor.value, objectPath)
|
container, err := findJSONCValue(&editor.value, objectPath, editor.label)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object, ok := container.Value.(*hujson.Object)
|
object, ok := container.Value.(*hujson.Object)
|
||||||
if !ok {
|
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 {
|
for _, member := range object.Members {
|
||||||
if member.Name.Value.(hujson.Literal).String() == name {
|
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
|
propertyDepth := len(objectPath) + 1
|
||||||
@@ -159,14 +194,14 @@ func (editor *droidJSONCEditor) addProperty(objectPath []any, name string, value
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (editor *droidJSONCEditor) appendArray(arrayPath []any, value any) error {
|
func (editor *jsoncEditor) appendArray(arrayPath []any, value any) error {
|
||||||
container, err := findDroidJSONCValue(&editor.value, arrayPath)
|
container, err := findJSONCValue(&editor.value, arrayPath, editor.label)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
array, ok := container.Value.(*hujson.Array)
|
array, ok := container.Value.(*hujson.Array)
|
||||||
if !ok {
|
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)
|
arrayDepth := len(arrayPath)
|
||||||
formatted, err := editor.formattedValue(value, arrayDepth+1)
|
formatted, err := editor.formattedValue(value, arrayDepth+1)
|
||||||
@@ -185,23 +220,23 @@ func (editor *droidJSONCEditor) appendArray(arrayPath []any, value any) error {
|
|||||||
return nil
|
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)
|
prefix := strings.Repeat(editor.indent, depth)
|
||||||
encoded, err := json.MarshalIndent(value, prefix, editor.indent)
|
encoded, err := json.MarshalIndent(value, prefix, editor.indent)
|
||||||
if err != nil {
|
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" {
|
if editor.eol != "\n" {
|
||||||
encoded = bytes.ReplaceAll(encoded, []byte("\n"), []byte(editor.eol))
|
encoded = bytes.ReplaceAll(encoded, []byte("\n"), []byte(editor.eol))
|
||||||
}
|
}
|
||||||
parsed, err := hujson.Parse(encoded)
|
parsed, err := hujson.Parse(encoded)
|
||||||
if err != nil {
|
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
|
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))
|
standard := []byte(editor.eol + strings.Repeat(editor.indent, depth))
|
||||||
if bytes.Contains(current, []byte("//")) || bytes.Contains(current, []byte("/*")) {
|
if bytes.Contains(current, []byte("//")) || bytes.Contains(current, []byte("/*")) {
|
||||||
combined := append(hujson.Extra(nil), current...)
|
combined := append(hujson.Extra(nil), current...)
|
||||||
@@ -210,14 +245,14 @@ func (editor *droidJSONCEditor) newLeadingExtra(current hujson.Extra, depth int)
|
|||||||
return standard
|
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
|
current := root
|
||||||
for _, part := range path {
|
for _, part := range path {
|
||||||
switch key := part.(type) {
|
switch key := part.(type) {
|
||||||
case string:
|
case string:
|
||||||
object, ok := current.Value.(*hujson.Object)
|
object, ok := current.Value.(*hujson.Object)
|
||||||
if !ok {
|
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
|
found := false
|
||||||
for index := range object.Members {
|
for index := range object.Members {
|
||||||
@@ -228,22 +263,22 @@ func findDroidJSONCValue(root *hujson.Value, path []any) (*hujson.Value, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
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:
|
case int:
|
||||||
array, ok := current.Value.(*hujson.Array)
|
array, ok := current.Value.(*hujson.Array)
|
||||||
if !ok || key < 0 || key >= len(array.Elements) {
|
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]
|
current = &array.Elements[key]
|
||||||
default:
|
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
|
return current, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func droidJSONPointer(path []any) string {
|
func jsonPointer(path []any) string {
|
||||||
var pointer strings.Builder
|
var pointer strings.Builder
|
||||||
for _, part := range path {
|
for _, part := range path {
|
||||||
pointer.WriteByte('/')
|
pointer.WriteByte('/')
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
||||||
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
||||||
"letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.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.
|
// NewPlugin creates a plugin instance for the given agent name.
|
||||||
@@ -57,6 +58,8 @@ func NewPlugin(agentName string) AgentPlugin {
|
|||||||
return &CopilotPlugin{}
|
return &CopilotPlugin{}
|
||||||
case "letta":
|
case "letta":
|
||||||
return &LettaPlugin{}
|
return &LettaPlugin{}
|
||||||
|
case "qwen":
|
||||||
|
return &QwenPlugin{}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user