fix(qwen): sync Go hooks with Qwen Code 0.20
This commit is contained in:
1 parent
8eecb67722
commit
b9ab86c8ee
17 files changed
+2889
-710
No files matched your search
+1
-1
@@ -98,7 +98,7 @@ Auggie installation commits `~/.augment/settings.json`, both platform wrappers,
|
|||||||
|
|
||||||
Factory Droid 0.175 installation follows its whole-source precedence: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Elydora writes the current `{ "hooks": { ... } }` root contract, preserves JSONC comments and formatting, accepts future hook events, and removes exact legacy Elydora commands during migration. System, project, and nested-folder settings remain read-only policy inputs; `hooksDisabled` and organization-managed hook policy block installation before runtime creation. The guard, native-payload audit runtime, strict config, canonical private key, and every affected hook document commit in one rollback-capable transaction. Windows commands use Factory's native PowerShell execution contract and propagate `$LASTEXITCODE`. Run `/hooks` in Droid after installation to review the effective local and organization-managed hooks.
|
Factory Droid 0.175 installation follows its whole-source precedence: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Elydora writes the current `{ "hooks": { ... } }` root contract, preserves JSONC comments and formatting, accepts future hook events, and removes exact legacy Elydora commands during migration. System, project, and nested-folder settings remain read-only policy inputs; `hooksDisabled` and organization-managed hook policy block installation before runtime creation. The guard, native-payload audit runtime, strict config, canonical private key, and every affected hook document commit in one rollback-capable transaction. Windows commands use Factory's native PowerShell execution contract and propagate `$LASTEXITCODE`. Run `/hooks` in Droid after installation to review the effective local and organization-managed 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.
|
Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Elydora follows Qwen's `QWEN_HOME` and `QWEN_RUNTIME_DIR` `.env` bootstrap, reads system defaults, user, trusted workspace, and system override settings in official precedence order, and keeps every non-user source read-only. The adapter preserves JSONC formatting and future event payloads, validates all 21 current events plus command, HTTP, and prompt handlers, applies JavaScript regular-expression semantics only to matcher-enabled events, and owns one named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple. Guard, native-payload audit, strict runtime metadata, canonical private key, and user settings commit in one rollback-capable transaction guarded by source snapshots. Healthy status requires the enabled exact triple and exact generated runtime sources. Run `/hooks` in Qwen Code after installation to review the effective hooks.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +1,115 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// QwenPlugin manages Qwen Code native user hooks.
|
||||||
type QwenPlugin struct {
|
type QwenPlugin struct {
|
||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type qwenRuntimeConfig struct {
|
// ManagesGuardRuntime reports that Qwen commits its guard, audit runtime, and
|
||||||
OrgID string `json:"org_id"`
|
// settings in one transaction.
|
||||||
AgentID string `json:"agent_id"`
|
func (p *QwenPlugin) ManagesGuardRuntime() bool {
|
||||||
KID string `json:"kid"`
|
return true
|
||||||
BaseURL string `json:"base_url"`
|
}
|
||||||
Token string `json:"token"`
|
|
||||||
AgentName string `json:"agent_name"`
|
// PreflightInstall validates every effective source and runtime identity before
|
||||||
|
// the CLI writes any managed file.
|
||||||
|
func (p *QwenPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
|
sources, err := readQwenSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _, err = preflightQwenInstallation(config, sources)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenInstalledGroups(
|
||||||
|
nodePath, guardPath, auditPath string,
|
||||||
|
) map[string]map[string]any {
|
||||||
|
return map[string]map[string]any{
|
||||||
|
"PreToolUse": buildQwenGroup(nodePath, guardPath, qwenGuardHookName),
|
||||||
|
"PostToolUse": buildQwenGroup(nodePath, auditPath, qwenAuditHookName),
|
||||||
|
"PostToolUseFailure": buildQwenGroup(
|
||||||
|
nodePath,
|
||||||
|
auditPath,
|
||||||
|
qwenAuditHookName,
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *QwenPlugin) Install(config InstallConfig) error {
|
func (p *QwenPlugin) Install(config InstallConfig) error {
|
||||||
runtimeRoot, agentDirectory, err := qwenAgentDirectory(config.AgentID)
|
sources, err := readQwenSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
document, err := readQwenDocument()
|
paths, nodePath, err := preflightQwenInstallation(config, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
expectedGuard := filepath.Join(agentDirectory, qwenGuardScript)
|
rendered, err := renderQwenDocument(
|
||||||
if !sameQwenPath(config.GuardScriptPath, expectedGuard) {
|
sources.user,
|
||||||
return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard)
|
"",
|
||||||
}
|
paths.runtimeRoot,
|
||||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
qwenInstalledGroups(nodePath, paths.guardPath, paths.auditPath),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !guardExists {
|
prepared, err := prepareQwenInstallation(config, sources, rendered)
|
||||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
|
||||||
}
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateQwenRegexes(nodePath, document.hooks); err != nil {
|
if err := commitQwenInstallation(prepared, p.rename); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditPath := filepath.Join(agentDirectory, qwenAuditScript)
|
fmt.Printf("Qwen Code hooks: %s\n", sources.user.filePath)
|
||||||
rendered, err := renderQwenDocument(document, "", runtimeRoot, map[string]map[string]any{
|
fmt.Println("Qwen Code verification: run /hooks.")
|
||||||
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *QwenPlugin) Uninstall(agentID string) error {
|
func (p *QwenPlugin) Uninstall(agentID string) error {
|
||||||
|
sources, err := readQwenSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := qwenRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
document, err := readQwenDocument()
|
rendered, err := renderQwenDocument(
|
||||||
|
sources.user,
|
||||||
|
agentID,
|
||||||
|
runtimeRoot,
|
||||||
|
map[string]map[string]any{},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !document.exists {
|
if !rendered.changed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rendered, err := renderQwenDocument(document, agentID, runtimeRoot, nil)
|
change, preconditions, err := prepareQwenUninstall(sources, rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
change, err := prepareRenderedQwenChange(rendered)
|
return commitQwenUninstall(change, preconditions, p.rename)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges([]*fileChange{change}, "Write Qwen Code settings", p.rename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *QwenPlugin) Status() (PluginStatus, error) {
|
func (p *QwenPlugin) Status() (PluginStatus, error) {
|
||||||
entry := SupportedAgents[qwenAgentKey]
|
entry := SupportedAgents[qwenAgentKey]
|
||||||
status := PluginStatus{AgentName: qwenAgentKey, DisplayName: entry.Name}
|
status := PluginStatus{AgentName: qwenAgentKey, DisplayName: entry.Name}
|
||||||
document, err := readQwenDocument()
|
sources, err := readQwenSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = document.filePath
|
status.ConfigPath = sources.user.filePath
|
||||||
if document.hooksDisabled {
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := qwenRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
contracts := qwenRuntimeContracts(document.hooks, runtimeRoot)
|
contracts := qwenRuntimeContracts(sources.user.hooks, runtimeRoot)
|
||||||
status.HookConfigured = len(contracts) > 0
|
status.HookConfigured = !sources.disableControl.disabled && len(contracts) > 0
|
||||||
if !status.HookConfigured {
|
if !status.HookConfigured {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
@@ -117,57 +120,3 @@ func (p *QwenPlugin) Status() (PluginStatus, error) {
|
|||||||
status.Installed = status.HookScriptExists
|
status.Installed = status.HookScriptExists
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenAgentDirectory(agentID string) (string, string, error) {
|
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(agentID)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return runtimeRoot, agentDirectory, 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), 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,116 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const qwenPOSIXApostrophe = `'"'"'`
|
||||||
|
|
||||||
|
type qwenRuntimeReference struct {
|
||||||
|
agentID string
|
||||||
|
scriptPath 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 parseQwenCommand(command string) (string, string, bool) {
|
||||||
|
start := 0
|
||||||
|
suffix := ""
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if !strings.HasPrefix(command, "& ") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
start = 2
|
||||||
|
suffix = "; exit $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
executable, next, ok := readQwenQuotedArgument(command, start)
|
||||||
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
script, end, ok := readQwenQuotedArgument(command, next+1)
|
||||||
|
return executable, script, ok && command[end:] == suffix &&
|
||||||
|
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 isQwenNodeExecutable(filePath string) bool {
|
||||||
|
name := strings.ToLower(filepath.Base(filePath))
|
||||||
|
return name == "node" || name == "node.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenRuntimeReferenceForCommand(
|
||||||
|
command, scriptName, runtimeRoot string,
|
||||||
|
) (qwenRuntimeReference, bool) {
|
||||||
|
executable, scriptPath, ok := parseQwenCommand(command)
|
||||||
|
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
||||||
|
!isQwenNodeExecutable(executable) || filepath.Base(scriptPath) != scriptName {
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
|
if !sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
|
}
|
||||||
|
agentID := filepath.Base(agentDirectory)
|
||||||
|
if agentID == "" || agentID == "." || agentID == ".." {
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
|
}
|
||||||
|
return qwenRuntimeReference{agentID: agentID, scriptPath: scriptPath}, true
|
||||||
|
}
|
||||||
@@ -6,14 +6,24 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qwenSystemDefaultsKind = "system-defaults"
|
||||||
|
qwenUserKind = "user"
|
||||||
|
qwenWorkspaceKind = "workspace"
|
||||||
|
qwenSystemKind = "system"
|
||||||
|
)
|
||||||
|
|
||||||
type qwenDocument struct {
|
type qwenDocument struct {
|
||||||
|
kind string
|
||||||
filePath string
|
filePath string
|
||||||
exists bool
|
exists bool
|
||||||
raw []byte
|
raw []byte
|
||||||
|
snapshot *managedFileSnapshot
|
||||||
root map[string]any
|
root map[string]any
|
||||||
hooks qwenHookSettings
|
hooks qwenHookSettings
|
||||||
hasHooksContainer bool
|
hasHooksContainer bool
|
||||||
hooksDisabled bool
|
disableAllHooks *bool
|
||||||
|
folderTrustEnabled *bool
|
||||||
ownedFile bool
|
ownedFile bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,23 +34,79 @@ type qwenRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenDocumentLabel(filePath string) string {
|
func qwenSourceLabel(kind string) string {
|
||||||
return fmt.Sprintf("Qwen Code settings at %s", filePath)
|
switch kind {
|
||||||
|
case qwenSystemDefaultsKind:
|
||||||
|
return "Qwen Code system defaults"
|
||||||
|
case qwenWorkspaceKind:
|
||||||
|
return "Qwen Code workspace settings"
|
||||||
|
case qwenSystemKind:
|
||||||
|
return "Qwen Code system override settings"
|
||||||
|
default:
|
||||||
|
return "Qwen Code user settings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseQwenDocument(exists bool, filePath string, raw []byte) (*qwenDocument, error) {
|
func qwenDocumentLabel(document *qwenDocument) string {
|
||||||
label := qwenDocumentLabel(filePath)
|
if document == nil {
|
||||||
|
return "Qwen Code settings"
|
||||||
|
}
|
||||||
|
return qwenSourceLabel(document.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQwenFolderTrust(
|
||||||
|
root map[string]any,
|
||||||
|
label string,
|
||||||
|
) (*bool, error) {
|
||||||
|
securityValue, exists := root["security"]
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
security, ok := securityValue.(map[string]any)
|
||||||
|
if !ok || security == nil {
|
||||||
|
return nil, fmt.Errorf(`%s field "security" must be an object`, label)
|
||||||
|
}
|
||||||
|
folderTrustValue, exists := security["folderTrust"]
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
folderTrust, ok := folderTrustValue.(map[string]any)
|
||||||
|
if !ok || folderTrust == nil {
|
||||||
|
return nil, fmt.Errorf(`%s field "security.folderTrust" must be an object`, label)
|
||||||
|
}
|
||||||
|
enabledValue, exists := folderTrust["enabled"]
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
enabled, ok := enabledValue.(bool)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
`%s field "security.folderTrust.enabled" must be a boolean`,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return &enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseQwenDocument(
|
||||||
|
kind string,
|
||||||
|
exists bool,
|
||||||
|
filePath string,
|
||||||
|
raw []byte,
|
||||||
|
snapshot *managedFileSnapshot,
|
||||||
|
) (*qwenDocument, error) {
|
||||||
|
label := fmt.Sprintf("%s at %s", qwenSourceLabel(kind), filePath)
|
||||||
root, err := decodeJSONCObject(raw, label, false)
|
root, err := decodeJSONCObject(raw, label, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
disabled := false
|
var disabled *bool
|
||||||
if value, hasFlag := root["disableAllHooks"]; hasFlag {
|
if value, hasFlag := root["disableAllHooks"]; hasFlag {
|
||||||
flag, ok := value.(bool)
|
flag, ok := value.(bool)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label)
|
return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label)
|
||||||
}
|
}
|
||||||
disabled = flag
|
disabled = &flag
|
||||||
}
|
}
|
||||||
hooks := qwenHookSettings{}
|
hooks := qwenHookSettings{}
|
||||||
hooksValue, hasHooks := root["hooks"]
|
hooksValue, hasHooks := root["hooks"]
|
||||||
@@ -50,15 +116,25 @@ func parseQwenDocument(exists bool, filePath string, raw []byte) (*qwenDocument,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
folderTrustEnabled, err := readQwenFolderTrust(root, label)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &qwenDocument{
|
return &qwenDocument{
|
||||||
filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), root: root,
|
kind: kind, filePath: filePath, exists: exists,
|
||||||
hooks: hooks, hasHooksContainer: hasHooks, hooksDisabled: disabled,
|
raw: append([]byte(nil), raw...), snapshot: snapshot, root: root,
|
||||||
ownedFile: bytes.HasPrefix(raw, []byte(qwenOwnedFileMarker)),
|
hooks: hooks, hasHooksContainer: hasHooks,
|
||||||
|
disableAllHooks: disabled, folderTrustEnabled: folderTrustEnabled,
|
||||||
|
ownedFile: kind == qwenUserKind && bytes.HasPrefix(raw, []byte(qwenOwnedFileMarker)),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOwnedQwenDocument(filePath string) (*qwenDocument, error) {
|
func createQwenDocument(kind, filePath string) (*qwenDocument, error) {
|
||||||
return parseQwenDocument(false, filePath, []byte(qwenOwnedFileMarker+"\n{}\n"))
|
raw := []byte("{}\n")
|
||||||
|
if kind == qwenUserKind {
|
||||||
|
raw = []byte(qwenOwnedFileMarker + "\n{}\n")
|
||||||
|
}
|
||||||
|
return parseQwenDocument(kind, false, filePath, raw, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderQwenDocument(
|
func renderQwenDocument(
|
||||||
@@ -66,13 +142,16 @@ func renderQwenDocument(
|
|||||||
agentID, runtimeRoot string,
|
agentID, runtimeRoot string,
|
||||||
additions map[string]map[string]any,
|
additions map[string]map[string]any,
|
||||||
) (*qwenRenderedDocument, error) {
|
) (*qwenRenderedDocument, error) {
|
||||||
label := qwenDocumentLabel(document.filePath)
|
if document == nil {
|
||||||
|
return nil, fmt.Errorf("Qwen Code user settings are required")
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s at %s", qwenDocumentLabel(document), document.filePath)
|
||||||
editor, err := newJSONCEditor(document.raw, label, false)
|
editor, err := newJSONCEditor(document.raw, label, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
removals := managedQwenRemovals(document.hooks, agentID, runtimeRoot)
|
removals := managedQwenRemovals(document.hooks, agentID, runtimeRoot)
|
||||||
for _, event := range qwenToolEvents {
|
for _, event := range qwenManagedEvents {
|
||||||
eventRemovals := make([]qwenManagedRemoval, 0)
|
eventRemovals := make([]qwenManagedRemoval, 0)
|
||||||
for _, removal := range removals {
|
for _, removal := range removals {
|
||||||
if removal.event == event {
|
if removal.event == event {
|
||||||
@@ -98,7 +177,13 @@ func renderQwenDocument(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(eventRemovals) > 0 {
|
if len(eventRemovals) > 0 {
|
||||||
current, parseErr := parseQwenDocument(document.exists, document.filePath, editor.pack())
|
current, parseErr := parseQwenDocument(
|
||||||
|
document.kind,
|
||||||
|
document.exists,
|
||||||
|
document.filePath,
|
||||||
|
editor.pack(),
|
||||||
|
document.snapshot,
|
||||||
|
)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil, parseErr
|
return nil, parseErr
|
||||||
}
|
}
|
||||||
@@ -109,7 +194,13 @@ func renderQwenDocument(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
current, err := parseQwenDocument(document.exists, document.filePath, editor.pack())
|
current, err := parseQwenDocument(
|
||||||
|
document.kind,
|
||||||
|
document.exists,
|
||||||
|
document.filePath,
|
||||||
|
editor.pack(),
|
||||||
|
document.snapshot,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -118,12 +209,18 @@ func renderQwenDocument(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, event := range qwenToolEvents {
|
for _, event := range qwenManagedEvents {
|
||||||
group, exists := additions[event]
|
group, exists := additions[event]
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current, err = parseQwenDocument(document.exists, document.filePath, editor.pack())
|
current, err = parseQwenDocument(
|
||||||
|
document.kind,
|
||||||
|
document.exists,
|
||||||
|
document.filePath,
|
||||||
|
editor.pack(),
|
||||||
|
document.snapshot,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -140,13 +237,22 @@ func renderQwenDocument(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
next := editor.pack()
|
next := editor.pack()
|
||||||
nextDocument, err := parseQwenDocument(document.exists, document.filePath, next)
|
nextDocument, err := parseQwenDocument(
|
||||||
|
document.kind,
|
||||||
|
document.exists,
|
||||||
|
document.filePath,
|
||||||
|
next,
|
||||||
|
document.snapshot,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0
|
remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0
|
||||||
return &qwenRenderedDocument{
|
return &qwenRenderedDocument{
|
||||||
document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove,
|
document: document,
|
||||||
|
changed: remove || !bytes.Equal(next, document.raw),
|
||||||
|
next: next,
|
||||||
|
remove: remove,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -13,12 +11,25 @@ const (
|
|||||||
qwenAgentKey = "qwen"
|
qwenAgentKey = "qwen"
|
||||||
qwenGuardScript = "guard.js"
|
qwenGuardScript = "guard.js"
|
||||||
qwenAuditScript = "hook.js"
|
qwenAuditScript = "hook.js"
|
||||||
|
qwenGuardHookName = "elydora-guard"
|
||||||
|
qwenAuditHookName = "elydora-audit"
|
||||||
qwenHookTimeout = float64(10_000)
|
qwenHookTimeout = float64(10_000)
|
||||||
qwenOwnedFileMarker = "// Managed by Elydora"
|
qwenOwnedFileMarker = "// Managed by Elydora"
|
||||||
qwenPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var qwenToolEvents = [...]string{"PreToolUse", "PostToolUse"}
|
var qwenManagedEvents = [...]string{
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
}
|
||||||
|
|
||||||
|
var qwenEventContracts = [...]struct {
|
||||||
|
event, script, name string
|
||||||
|
}{
|
||||||
|
{"PreToolUse", qwenGuardScript, qwenGuardHookName},
|
||||||
|
{"PostToolUse", qwenAuditScript, qwenAuditHookName},
|
||||||
|
{"PostToolUseFailure", qwenAuditScript, qwenAuditHookName},
|
||||||
|
}
|
||||||
|
|
||||||
type qwenHookSettings map[string]any
|
type qwenHookSettings map[string]any
|
||||||
|
|
||||||
@@ -35,214 +46,208 @@ type qwenRuntimeContract struct {
|
|||||||
auditPath string
|
auditPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQwenCommand(nodePath, scriptPath string) string {
|
func qwenExpectedShell() string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "& " + quoteQwenPowerShell(nodePath) + " " + quoteQwenPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
return "powershell"
|
||||||
}
|
}
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
return "bash"
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteQwenPowerShell(value string) string {
|
func buildQwenGroup(nodePath, scriptPath, name string) map[string]any {
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildQwenGroup(nodePath, scriptPath string) map[string]any {
|
|
||||||
shell := "bash"
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
shell = "powershell"
|
|
||||||
}
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"matcher": "*",
|
|
||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command", "command": buildQwenCommand(nodePath, scriptPath),
|
"type": "command",
|
||||||
"shell": shell, "timeout": qwenHookTimeout,
|
"name": name,
|
||||||
|
"command": buildQwenCommand(nodePath, scriptPath),
|
||||||
|
"shell": qwenExpectedShell(),
|
||||||
|
"timeout": qwenHookTimeout,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseQwenCommand(command string) (string, string, bool) {
|
func currentQwenReference(
|
||||||
start := 0
|
handler map[string]any,
|
||||||
if runtime.GOOS == "windows" {
|
scriptName, hookName, runtimeRoot string,
|
||||||
if !strings.HasPrefix(command, "& ") {
|
) (qwenRuntimeReference, bool) {
|
||||||
return "", "", false
|
if len(handler) != 5 || handler["type"] != "command" ||
|
||||||
}
|
handler["name"] != hookName || handler["shell"] != qwenExpectedShell() ||
|
||||||
start = 2
|
handler["timeout"] != qwenHookTimeout {
|
||||||
}
|
return qwenRuntimeReference{}, false
|
||||||
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)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return qwenRuntimeReference{}, false
|
||||||
}
|
}
|
||||||
executable, scriptPath, ok := parseQwenCommand(command)
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
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 {
|
func legacyQwenReference(
|
||||||
name := filepath.Base(path)
|
handler map[string]any,
|
||||||
if runtime.GOOS == "windows" {
|
scriptName, runtimeRoot string,
|
||||||
return strings.EqualFold(name, "node.exe")
|
) (qwenRuntimeReference, bool) {
|
||||||
|
if len(handler) != 4 || handler["type"] != "command" ||
|
||||||
|
handler["shell"] != qwenExpectedShell() ||
|
||||||
|
handler["timeout"] != qwenHookTimeout {
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
}
|
}
|
||||||
return name == "node"
|
command, ok := handler["command"].(string)
|
||||||
|
if !ok {
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
|
}
|
||||||
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameQwenFileName(left, right string) bool {
|
func managedQwenReference(
|
||||||
if runtime.GOOS == "windows" {
|
handler map[string]any,
|
||||||
return strings.EqualFold(left, right)
|
scriptName, hookName, runtimeRoot string,
|
||||||
|
includeLegacy bool,
|
||||||
|
) (qwenRuntimeReference, bool) {
|
||||||
|
if reference, ok := currentQwenReference(
|
||||||
|
handler,
|
||||||
|
scriptName,
|
||||||
|
hookName,
|
||||||
|
runtimeRoot,
|
||||||
|
); ok {
|
||||||
|
return reference, true
|
||||||
}
|
}
|
||||||
return left == right
|
if includeLegacy {
|
||||||
|
return legacyQwenReference(handler, scriptName, runtimeRoot)
|
||||||
|
}
|
||||||
|
return qwenRuntimeReference{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedQwenRemovals(settings qwenHookSettings, agentID, runtimeRoot string) []qwenManagedRemoval {
|
func exactCurrentQwenGroup(group map[string]any) bool {
|
||||||
|
_, hasHooks := group["hooks"]
|
||||||
|
return len(group) == 1 && hasHooks
|
||||||
|
}
|
||||||
|
|
||||||
|
func exactLegacyQwenGroup(group map[string]any) bool {
|
||||||
|
_, hasHooks := group["hooks"]
|
||||||
|
return len(group) == 2 && hasHooks && group["matcher"] == "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedQwenRemovals(
|
||||||
|
settings qwenHookSettings,
|
||||||
|
agentID, runtimeRoot string,
|
||||||
|
) []qwenManagedRemoval {
|
||||||
removals := make([]qwenManagedRemoval, 0)
|
removals := make([]qwenManagedRemoval, 0)
|
||||||
for _, contract := range []struct{ event, script string }{
|
for _, contract := range qwenEventContracts {
|
||||||
{"PreToolUse", qwenGuardScript}, {"PostToolUse", qwenAuditScript},
|
|
||||||
} {
|
|
||||||
groups, _ := settings[contract.event].([]any)
|
groups, _ := settings[contract.event].([]any)
|
||||||
for groupIndex, groupValue := range groups {
|
for groupIndex, groupValue := range groups {
|
||||||
group := groupValue.(map[string]any)
|
group := groupValue.(map[string]any)
|
||||||
handlers := group["hooks"].([]any)
|
handlers := group["hooks"].([]any)
|
||||||
indexes := make([]int, 0)
|
indexes := make([]int, 0)
|
||||||
for handlerIndex, handlerValue := range handlers {
|
for handlerIndex, handlerValue := range handlers {
|
||||||
managedID, managed := managedQwenAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot)
|
reference, managed := managedQwenReference(
|
||||||
if managed && (agentID == "" || sameQwenAgentID(managedID, agentID)) {
|
handlerValue.(map[string]any),
|
||||||
|
contract.script,
|
||||||
|
contract.name,
|
||||||
|
runtimeRoot,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if managed && (agentID == "" || sameQwenAgentID(reference.agentID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(indexes) == 0 {
|
if len(indexes) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, hasMatcher := group["matcher"]
|
removeGroup := (exactCurrentQwenGroup(group) || exactLegacyQwenGroup(group)) &&
|
||||||
_, hasHooks := group["hooks"]
|
len(indexes) == len(handlers)
|
||||||
exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers)
|
removals = append(removals, qwenManagedRemoval{
|
||||||
removals = append(removals, qwenManagedRemoval{contract.event, groupIndex, indexes, exactGroup})
|
event: contract.event, groupIndex: groupIndex,
|
||||||
|
handlerIndexes: indexes, removeGroup: removeGroup,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removals
|
return removals
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenRuntimeContracts(settings qwenHookSettings, runtimeRoot string) []qwenRuntimeContract {
|
func normalizedQwenAgentID(agentID string) string {
|
||||||
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" {
|
if runtime.GOOS == "windows" {
|
||||||
return strings.ToLower(ids[left]) < strings.ToLower(ids[right])
|
return strings.ToLower(agentID)
|
||||||
|
}
|
||||||
|
return agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenReferencesForEvent(
|
||||||
|
settings qwenHookSettings,
|
||||||
|
event, scriptName, hookName, runtimeRoot string,
|
||||||
|
) map[string][]qwenRuntimeReference {
|
||||||
|
references := map[string][]qwenRuntimeReference{}
|
||||||
|
groups, _ := settings[event].([]any)
|
||||||
|
for _, groupValue := range groups {
|
||||||
|
group := groupValue.(map[string]any)
|
||||||
|
if !exactCurrentQwenGroup(group) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, handlerValue := range group["hooks"].([]any) {
|
||||||
|
reference, ok := currentQwenReference(
|
||||||
|
handlerValue.(map[string]any),
|
||||||
|
scriptName,
|
||||||
|
hookName,
|
||||||
|
runtimeRoot,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := normalizedQwenAgentID(reference.agentID)
|
||||||
|
references[key] = append(references[key], reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenRuntimeContracts(
|
||||||
|
settings qwenHookSettings,
|
||||||
|
runtimeRoot string,
|
||||||
|
) []qwenRuntimeContract {
|
||||||
|
guards := qwenReferencesForEvent(
|
||||||
|
settings,
|
||||||
|
"PreToolUse",
|
||||||
|
qwenGuardScript,
|
||||||
|
qwenGuardHookName,
|
||||||
|
runtimeRoot,
|
||||||
|
)
|
||||||
|
posts := qwenReferencesForEvent(
|
||||||
|
settings,
|
||||||
|
"PostToolUse",
|
||||||
|
qwenAuditScript,
|
||||||
|
qwenAuditHookName,
|
||||||
|
runtimeRoot,
|
||||||
|
)
|
||||||
|
failures := qwenReferencesForEvent(
|
||||||
|
settings,
|
||||||
|
"PostToolUseFailure",
|
||||||
|
qwenAuditScript,
|
||||||
|
qwenAuditHookName,
|
||||||
|
runtimeRoot,
|
||||||
|
)
|
||||||
|
contracts := make([]qwenRuntimeContract, 0)
|
||||||
|
for key, guard := range guards {
|
||||||
|
post := posts[key]
|
||||||
|
failure := failures[key]
|
||||||
|
if len(guard) != 1 || len(post) != 1 || len(failure) != 1 ||
|
||||||
|
!sameQwenPath(post[0].scriptPath, failure[0].scriptPath) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return ids[left] < ids[right]
|
|
||||||
})
|
|
||||||
contracts := make([]qwenRuntimeContract, 0, len(ids))
|
|
||||||
for _, agentID := range ids {
|
|
||||||
root := filepath.Join(runtimeRoot, agentID)
|
|
||||||
contracts = append(contracts, qwenRuntimeContract{
|
contracts = append(contracts, qwenRuntimeContract{
|
||||||
agentID, filepath.Join(root, qwenGuardScript), filepath.Join(root, qwenAuditScript),
|
agentID: guard[0].agentID,
|
||||||
|
guardPath: guard[0].scriptPath,
|
||||||
|
auditPath: post[0].scriptPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
sort.Slice(contracts, func(left, right int) bool {
|
||||||
|
return normalizedQwenAgentID(contracts[left].agentID) <
|
||||||
|
normalizedQwenAgentID(contracts[right].agentID)
|
||||||
|
})
|
||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedQwenIDs(settings qwenHookSettings, event, script, runtimeRoot string) map[string]bool {
|
func qwenRuntimeRoot() (string, error) {
|
||||||
ids := map[string]bool{}
|
return AgentRuntimeRoot()
|
||||||
groups, _ := settings[event].([]any)
|
}
|
||||||
for _, groupValue := range groups {
|
|
||||||
for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) {
|
func qwenContractDirectory(contract qwenRuntimeContract) string {
|
||||||
if agentID, ok := managedQwenAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok {
|
return filepath.Dir(contract.guardPath)
|
||||||
ids[agentID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type qwenRuntimePaths struct {
|
||||||
|
runtimeRoot string
|
||||||
|
agentDirectory string
|
||||||
|
configPath string
|
||||||
|
keyPath string
|
||||||
|
guardPath string
|
||||||
|
auditPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedQwenInstallation struct {
|
||||||
|
changes []*fileChange
|
||||||
|
preconditions []filePrecondition
|
||||||
|
paths *qwenRuntimePaths
|
||||||
|
}
|
||||||
|
|
||||||
|
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,omitempty"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQwenInstallConfig(config InstallConfig) error {
|
||||||
|
for _, field := range []struct{ name, value string }{
|
||||||
|
{"agent name", config.AgentName},
|
||||||
|
{"organization ID", config.OrgID},
|
||||||
|
{"agent ID", config.AgentID},
|
||||||
|
{"key ID", config.KID},
|
||||||
|
{"private key", config.PrivateKey},
|
||||||
|
{"base URL", config.BaseURL},
|
||||||
|
{"guard script path", config.GuardScriptPath},
|
||||||
|
} {
|
||||||
|
if field.value == "" {
|
||||||
|
return fmt.Errorf("%s is required", field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.AgentName != qwenAgentKey {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Qwen Code installation requires agent name %s",
|
||||||
|
qwenAgentKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.OrgID) == "" {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.KID) == "" {
|
||||||
|
return fmt.Errorf("key ID is required")
|
||||||
|
}
|
||||||
|
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||||
|
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenAgentPaths(config InstallConfig) (*qwenRuntimePaths, error) {
|
||||||
|
if err := validateQwenInstallConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := &qwenRuntimePaths{
|
||||||
|
runtimeRoot: runtimeRoot,
|
||||||
|
agentDirectory: agentDirectory,
|
||||||
|
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||||
|
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||||
|
guardPath: filepath.Join(agentDirectory, qwenGuardScript),
|
||||||
|
auditPath: filepath.Join(agentDirectory, qwenAuditScript),
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||||
|
!sameQwenPath(config.GuardScriptPath, paths.guardPath) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora guard runtime must use the managed agent directory: %s",
|
||||||
|
paths.guardPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||||
|
!sameQwenPath(config.HookScript, paths.auditPath)) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora audit runtime must use the managed agent directory: %s",
|
||||||
|
paths.auditPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preflightQwenInstallation(
|
||||||
|
config InstallConfig,
|
||||||
|
sources *qwenSources,
|
||||||
|
) (*qwenRuntimePaths, string, error) {
|
||||||
|
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||||
|
return nil, "", fmt.Errorf("Qwen Code installation requires user settings")
|
||||||
|
}
|
||||||
|
if err := requireQwenHooksEnabled(sources); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := qwenAgentPaths(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := validateQwenRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(nodePath) || !isQwenNodeExecutable(nodePath) {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"Qwen Code hooks require an absolute Node.js executable path",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths, nodePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQwenRuntimeConfig(config InstallConfig) ([]byte, error) {
|
||||||
|
encoded, err := json.MarshalIndent(qwenRuntimeConfig{
|
||||||
|
OrgID: config.OrgID,
|
||||||
|
AgentID: config.AgentID,
|
||||||
|
KID: config.KID,
|
||||||
|
BaseURL: config.BaseURL,
|
||||||
|
Token: config.Token,
|
||||||
|
AgentName: qwenAgentKey,
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
if len(encoded) > maxRuntimeConfigBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
||||||
|
maxRuntimeConfigBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareRenderedQwenChange(
|
||||||
|
rendered *qwenRenderedDocument,
|
||||||
|
) (*fileChange, error) {
|
||||||
|
if rendered == nil || !rendered.changed {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return prepareSnapshotSourceChange(
|
||||||
|
rendered.document.filePath,
|
||||||
|
qwenDocumentLabel(rendered.document),
|
||||||
|
rendered.document.snapshot,
|
||||||
|
rendered.next,
|
||||||
|
0600,
|
||||||
|
rendered.remove,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenReadOnlyPreconditions(
|
||||||
|
sources *qwenSources,
|
||||||
|
changedPath string,
|
||||||
|
) []filePrecondition {
|
||||||
|
if sources == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]filePrecondition, 0, len(sources.preconditions))
|
||||||
|
for _, condition := range sources.preconditions {
|
||||||
|
if changedPath != "" && sameQwenPath(condition.filePath, changedPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, condition)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareQwenInstallation(
|
||||||
|
config InstallConfig,
|
||||||
|
sources *qwenSources,
|
||||||
|
rendered *qwenRenderedDocument,
|
||||||
|
) (*preparedQwenInstallation, error) {
|
||||||
|
paths, _, err := preflightQwenInstallation(config, sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rendered == nil || rendered.document == nil ||
|
||||||
|
!sameQwenPath(rendered.document.filePath, sources.user.filePath) {
|
||||||
|
return nil, fmt.Errorf("Qwen Code rendered user settings are required")
|
||||||
|
}
|
||||||
|
runtimeConfig, err := buildQwenRuntimeConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []struct {
|
||||||
|
path, label string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
paths.guardPath,
|
||||||
|
"Elydora guard runtime",
|
||||||
|
[]byte(generateGuardScript(qwenAgentKey, config.AgentID, "", false, "")),
|
||||||
|
0700,
|
||||||
|
},
|
||||||
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
{
|
||||||
|
paths.auditPath,
|
||||||
|
"Elydora audit runtime",
|
||||||
|
[]byte(buildHookScriptWithOutput(
|
||||||
|
qwenAgentKey,
|
||||||
|
config.AgentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
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)
|
||||||
|
changedPath := ""
|
||||||
|
if rendered.changed {
|
||||||
|
changedPath = rendered.document.filePath
|
||||||
|
}
|
||||||
|
return &preparedQwenInstallation{
|
||||||
|
changes: changes,
|
||||||
|
paths: paths,
|
||||||
|
preconditions: qwenReadOnlyPreconditions(sources, changedPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitQwenInstallation(
|
||||||
|
prepared *preparedQwenInstallation,
|
||||||
|
rename renameFunc,
|
||||||
|
) error {
|
||||||
|
if prepared == nil || prepared.paths == nil {
|
||||||
|
return fmt.Errorf("prepared Qwen Code installation is required")
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(prepared.paths.runtimeRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(prepared.paths.agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeChanges(
|
||||||
|
prepared.changes,
|
||||||
|
"Install Qwen Code hooks",
|
||||||
|
rename,
|
||||||
|
prepared.preconditions...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareQwenUninstall(
|
||||||
|
sources *qwenSources,
|
||||||
|
rendered *qwenRenderedDocument,
|
||||||
|
) (*fileChange, []filePrecondition, error) {
|
||||||
|
change, err := prepareRenderedQwenChange(rendered)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
changedPath := ""
|
||||||
|
if rendered != nil && rendered.changed {
|
||||||
|
changedPath = rendered.document.filePath
|
||||||
|
}
|
||||||
|
return change, qwenReadOnlyPreconditions(sources, changedPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitQwenUninstall(
|
||||||
|
change *fileChange,
|
||||||
|
preconditions []filePrecondition,
|
||||||
|
rename renameFunc,
|
||||||
|
) error {
|
||||||
|
return writeChanges(
|
||||||
|
[]*fileChange{change},
|
||||||
|
"Uninstall Qwen Code hooks",
|
||||||
|
rename,
|
||||||
|
preconditions...,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,148 +1,264 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
||||||
|
|
||||||
func defaultQwenHome() (string, error) {
|
func readQwenRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||||
home, err := os.UserHomeDir()
|
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||||
if err != nil {
|
if err != nil || snapshot == nil {
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
return nil, snapshot != nil, err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".qwen"), nil
|
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||||
|
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||||
|
return config, true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveQwenStoragePath(value string) (string, error) {
|
func requireQwenRuntimeString(
|
||||||
resolved := value
|
config map[string]any,
|
||||||
if value == "~" || len(value) >= 2 && value[0] == '~' && (value[1] == '/' || value[1] == '\\') {
|
field, configPath string,
|
||||||
home, err := os.UserHomeDir()
|
) (string, error) {
|
||||||
if err != nil {
|
value, ok := config[field].(string)
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
}
|
return "", fmt.Errorf(
|
||||||
resolved = home
|
"Elydora runtime config %s is invalid: %s",
|
||||||
if value != "~" {
|
field,
|
||||||
for _, segment := range qwenPathSeparatorPattern.Split(value[2:], -1) {
|
configPath,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQwenRuntimeConfig(
|
||||||
|
config map[string]any,
|
||||||
|
expectedAgentID, configPath string,
|
||||||
|
) error {
|
||||||
|
supported := stringSet(
|
||||||
|
"org_id",
|
||||||
|
"agent_id",
|
||||||
|
"kid",
|
||||||
|
"base_url",
|
||||||
|
"token",
|
||||||
|
"agent_name",
|
||||||
|
)
|
||||||
|
fields := make([]string, 0, len(config))
|
||||||
|
for field := range config {
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, ok := supported[field]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
`Elydora runtime config has unsupported field %q: %s`,
|
||||||
|
field,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := requireQwenRuntimeString(config, "org_id", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := requireQwenRuntimeString(config, "kid", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
agentID, err := requireQwenRuntimeString(config, "agent_id", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sameQwenAgentID(agentID, expectedAgentID) || config["agent_name"] != qwenAgentKey {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime identity does not match Qwen Code hooks: %s",
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := config["token"]; exists {
|
||||||
|
if _, err := requireQwenRuntimeString(config, "token", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseURL, err := requireQwenRuntimeString(config, "base_url", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime config base URL is invalid at %s: %w",
|
||||||
|
configPath,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQwenRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
|
runtimeRoot := filepath.Dir(agentDirectory)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(
|
||||||
|
runtimeRoot,
|
||||||
|
"Elydora runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
config, configExists, err := readQwenRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
artifactExists := false
|
||||||
|
for _, item := range []struct {
|
||||||
|
path string
|
||||||
|
label string
|
||||||
|
limit int64
|
||||||
|
}{
|
||||||
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||||
|
{filepath.Join(agentDirectory, qwenGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, qwenAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||||
|
} {
|
||||||
|
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||||
|
if inspectErr != nil {
|
||||||
|
return inspectErr
|
||||||
|
}
|
||||||
|
artifactExists = artifactExists || exists
|
||||||
|
}
|
||||||
|
if !configExists {
|
||||||
|
if artifactExists {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime identity cannot be verified without config.json: %s",
|
||||||
|
agentDirectory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configuredID, ok := config["agent_id"].(string)
|
||||||
|
if !ok || config["agent_name"] != qwenAgentKey ||
|
||||||
|
!sameQwenAgentID(configuredID, agentID) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime config identity does not match Qwen Code agent %s: %s",
|
||||||
|
agentID,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validQwenContractPaths(contract qwenRuntimeContract) (bool, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
agentDirectory := qwenContractDirectory(contract)
|
||||||
|
return sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
||||||
|
sameQwenPath(
|
||||||
|
contract.guardPath,
|
||||||
|
filepath.Join(agentDirectory, qwenGuardScript),
|
||||||
|
) &&
|
||||||
|
sameQwenPath(
|
||||||
|
contract.auditPath,
|
||||||
|
filepath.Join(agentDirectory, qwenAuditScript),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenRuntimeContractExists(contract qwenRuntimeContract) (bool, error) {
|
||||||
|
validPaths, err := validQwenContractPaths(contract)
|
||||||
|
if err != nil || !validPaths {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
agentDirectory := qwenContractDirectory(contract)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(
|
||||||
|
runtimeRoot,
|
||||||
|
"Elydora runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||||
|
config, configExists, err := readQwenRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
guard, err := readManagedFile(
|
||||||
|
contract.guardPath,
|
||||||
|
"Elydora guard runtime",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
audit, err := readManagedFile(
|
||||||
|
contract.auditPath,
|
||||||
|
"Elydora audit runtime",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !configExists || key == nil || guard == nil || audit == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := validateQwenRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||||
|
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
||||||
|
}
|
||||||
|
expectedGuard := []byte(generateGuardScript(
|
||||||
|
qwenAgentKey,
|
||||||
|
contract.agentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
expectedAudit := []byte(buildHookScriptWithOutput(
|
||||||
|
qwenAgentKey,
|
||||||
|
contract.agentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
return bytes.Equal(guard.contents, expectedGuard) &&
|
||||||
|
bytes.Equal(audit.contents, expectedAudit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenRuntimeFilesExist(contracts []qwenRuntimeContract) (bool, error) {
|
func qwenRuntimeFilesExist(contracts []qwenRuntimeContract) (bool, error) {
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
|
exists, err := qwenRuntimeContractExists(contract)
|
||||||
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if !exists {
|
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 true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,27 +8,70 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQwenInstallRejectsMalformedSettingsBeforeWrites(t *testing.T) {
|
func TestQwenInstallRejectsMalformedKnownSettingsBeforeWrites(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name, source, pattern string
|
name, source, pattern string
|
||||||
}{
|
}{
|
||||||
{"malformed", "{ malformed", "parse Qwen Code settings"},
|
{"malformed", "{ malformed", "parse Qwen Code user settings"},
|
||||||
{"non-object", "[]", "JSON object"},
|
{"non-object", "[]", "JSON object"},
|
||||||
{"trailing comma", `{ "owner": true, }`, "trailing comma"},
|
{"trailing comma", `{ "owner": true, }`, "trailing comma"},
|
||||||
{"duplicate root", `{ "hooks": {}, "hooks": {} }`, "duplicate"},
|
{"duplicate root", `{ "hooks": {}, "hooks": {} }`, "duplicate"},
|
||||||
{"nested duplicate", `{ "hooks": { "PreToolUse": [{ "hooks": [], "hooks": [] }] } }`, "duplicate"},
|
{
|
||||||
|
"nested duplicate",
|
||||||
|
`{ "hooks": { "PreToolUse": [{ "hooks": [], "hooks": [] }] } }`,
|
||||||
|
"duplicate",
|
||||||
|
},
|
||||||
{"invalid disable flag", `{ "disableAllHooks": "yes" }`, "must be a boolean"},
|
{"invalid disable flag", `{ "disableAllHooks": "yes" }`, "must be a boolean"},
|
||||||
|
{"invalid security", `{ "security": [] }`, "security"},
|
||||||
|
{
|
||||||
|
"invalid folder trust",
|
||||||
|
`{ "security": { "folderTrust": { "enabled": "yes" } } }`,
|
||||||
|
"must be a boolean",
|
||||||
|
},
|
||||||
{"hooks array", `{ "hooks": [] }`, "JSON object"},
|
{"hooks array", `{ "hooks": [] }`, "JSON object"},
|
||||||
{"unsupported event", `{ "hooks": { "UnknownEvent": [] } }`, "unsupported field"},
|
{"null hooks", `{ "hooks": null }`, "JSON object"},
|
||||||
{"null event", `{ "hooks": { "PreToolUse": null } }`, "must be an array"},
|
{"null event", `{ "hooks": { "PreToolUse": null } }`, "must be an array"},
|
||||||
{"null group", `{ "hooks": { "PreToolUse": [null] } }`, "must be an object"},
|
{"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"},
|
"invalid matcher type",
|
||||||
{"null handlers", `{ "hooks": { "PreToolUse": [{ "hooks": null }] } }`, "hooks array"},
|
`{ "hooks": { "PreToolUse": [{ "matcher": 1, "hooks": [] }] } }`,
|
||||||
{"missing command", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }`, "non-empty string"},
|
"matcher must be a 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"},
|
"invalid matcher regex",
|
||||||
|
`{ "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 command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing url",
|
||||||
|
`{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }`,
|
||||||
|
"non-empty url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"settings function",
|
||||||
|
`{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function" }] }] } }`,
|
||||||
|
"unsupported type",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invalid timeout",
|
||||||
|
`{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": "ten" }] }] } }`,
|
||||||
|
"finite number",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -40,68 +83,156 @@ func TestQwenInstallRejectsMalformedSettingsBeforeWrites(t *testing.T) {
|
|||||||
if readQwenTestFile(t, fixture.configPath) != test.source {
|
if readQwenTestFile(t, fixture.configPath) != test.source {
|
||||||
t.Fatal("failed install changed Qwen Code settings")
|
t.Fatal("failed install changed Qwen Code settings")
|
||||||
}
|
}
|
||||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
for _, path := range []string{
|
||||||
|
fixture.guardPath,
|
||||||
|
fixture.hookPath,
|
||||||
|
fixture.runtimeConfig,
|
||||||
|
fixture.privateKey,
|
||||||
|
} {
|
||||||
requireMissingQwenFile(t, path)
|
requireMissingQwenFile(t, path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQwenInstallValidatesRoutingAndManagedPathsBeforeWrites(t *testing.T) {
|
func TestQwenPreservesFutureEventsAndLimitsRegexSemantics(t *testing.T) {
|
||||||
t.Run("unreadable home env", func(t *testing.T) {
|
settings := `{
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
"hooks": {
|
||||||
envPath := filepath.Join(fixture.qwenDir, ".env")
|
"FutureEvent": { "shape": "future" },
|
||||||
if err := os.MkdirAll(envPath, 0755); err != nil {
|
"UserPromptSubmit": [{ "matcher": "[", "hooks": [] }]
|
||||||
t.Fatalf("create unreadable env path: %v", err)
|
|
||||||
}
|
}
|
||||||
err := fixture.plugin.Install(fixture.config)
|
}`
|
||||||
if err == nil || !strings.Contains(err.Error(), "Qwen home environment") {
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)})
|
||||||
t.Fatalf("install error = %v", err)
|
installQwenFixture(t, fixture)
|
||||||
|
raw := readQwenTestFile(t, fixture.configPath)
|
||||||
|
for _, marker := range []string{"FutureEvent", `"matcher": "["`} {
|
||||||
|
if !strings.Contains(raw, marker) {
|
||||||
|
t.Fatalf("future-compatible setting %q was lost: %s", marker, raw)
|
||||||
}
|
}
|
||||||
requireMissingQwenFile(t, fixture.configPath)
|
}
|
||||||
requireMissingQwenFile(t, fixture.runtimeConfig)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
func TestQwenInstallRejectsMalformedReadOnlySourceBeforeWrites(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*qwenFixture)
|
path func(*qwenFixture) string
|
||||||
pattern string
|
|
||||||
}{
|
}{
|
||||||
{
|
{"system defaults", func(f *qwenFixture) string { return f.systemDefaults }},
|
||||||
name: "missing guard",
|
{"workspace", func(f *qwenFixture) string { return f.workspaceConfig }},
|
||||||
mutate: func(fixture *qwenFixture) {
|
{"system override", func(f *qwenFixture) string { return f.systemConfig }},
|
||||||
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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
test.mutate(fixture)
|
filePath := test.path(fixture)
|
||||||
|
writeOptionalQwenFile(t, filePath, qwenString("{ malformed"))
|
||||||
err := fixture.plugin.Install(fixture.config)
|
err := fixture.plugin.Install(fixture.config)
|
||||||
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
if err == nil || !strings.Contains(err.Error(), "parse Qwen Code") {
|
||||||
t.Fatalf("install error = %v, want substring %q", err, test.pattern)
|
t.Fatalf("install error = %v", err)
|
||||||
}
|
}
|
||||||
for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
if readQwenTestFile(t, filePath) != "{ malformed" {
|
||||||
|
t.Fatal("failed install changed the read-only source")
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenInstallRejectsEffectiveDisableBeforeWrites(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.systemConfig,
|
||||||
|
qwenJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "disableAllHooks") ||
|
||||||
|
!strings.Contains(err.Error(), fixture.systemConfig) {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
requireMissingQwenFile(t, fixture.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenTransactionProtectsEveryReadOnlySource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*testing.T, *qwenFixture)
|
||||||
|
path func(*qwenFixture) string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"home environment",
|
||||||
|
func(*testing.T, *qwenFixture) {},
|
||||||
|
func(f *qwenFixture) string { return filepath.Join(f.qwenDir, ".env") },
|
||||||
|
"QWEN_HOME=external\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system defaults",
|
||||||
|
func(*testing.T, *qwenFixture) {},
|
||||||
|
func(f *qwenFixture) string { return f.systemDefaults },
|
||||||
|
"{}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace",
|
||||||
|
func(*testing.T, *qwenFixture) {},
|
||||||
|
func(f *qwenFixture) string { return f.workspaceConfig },
|
||||||
|
"{}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system override",
|
||||||
|
func(*testing.T, *qwenFixture) {},
|
||||||
|
func(f *qwenFixture) string { return f.systemConfig },
|
||||||
|
"{}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trusted folders",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
writeOptionalQwenFile(t, f.configPath, qwenJSON(map[string]any{
|
||||||
|
"security": map[string]any{
|
||||||
|
"folderTrust": map[string]any{"enabled": true},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
func(f *qwenFixture) string { return f.trustedFolders },
|
||||||
|
"{}\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{
|
||||||
|
settings: qwenJSON(map[string]any{"owner": "user"}),
|
||||||
|
})
|
||||||
|
test.configure(t, fixture)
|
||||||
|
original := readQwenTestFile(t, fixture.configPath)
|
||||||
|
sourcePath := test.path(fixture)
|
||||||
|
mutated := false
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if !mutated {
|
||||||
|
mutated = true
|
||||||
|
writeOptionalQwenFile(t, sourcePath, qwenString(test.content))
|
||||||
|
}
|
||||||
|
return os.Rename(source, destination)
|
||||||
|
}
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed during") || !mutated {
|
||||||
|
t.Fatalf("transaction error = %v, mutated = %v", err, mutated)
|
||||||
|
}
|
||||||
|
if readQwenTestFile(t, fixture.configPath) != original {
|
||||||
|
t.Fatal("transaction rollback changed user settings")
|
||||||
|
}
|
||||||
|
if readQwenTestFile(t, sourcePath) != test.content {
|
||||||
|
t.Fatal("transaction rollback overwrote the external mutation")
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.guardPath,
|
||||||
|
fixture.hookPath,
|
||||||
|
fixture.runtimeConfig,
|
||||||
|
fixture.privateKey,
|
||||||
|
} {
|
||||||
requireMissingQwenFile(t, path)
|
requireMissingQwenFile(t, path)
|
||||||
}
|
}
|
||||||
|
requireNoQwenStagingFiles(t, fixture.homeDir)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,13 +251,18 @@ func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) {
|
|||||||
return os.Rename(source, destination)
|
return os.Rename(source, destination)
|
||||||
}
|
}
|
||||||
err := fixture.plugin.Install(fixture.config)
|
err := fixture.plugin.Install(fixture.config)
|
||||||
if err == nil || !strings.Contains(err.Error(), "Write Qwen Code installation") || !failed {
|
if err == nil || !strings.Contains(err.Error(), "Install Qwen Code hooks") || !failed {
|
||||||
t.Fatalf("transaction error = %v, failed = %v", err, failed)
|
t.Fatalf("transaction error = %v, failed = %v", err, failed)
|
||||||
}
|
}
|
||||||
if readQwenTestFile(t, fixture.configPath) != original {
|
if readQwenTestFile(t, fixture.configPath) != original {
|
||||||
t.Fatal("transaction rollback did not restore Qwen Code settings")
|
t.Fatal("transaction rollback did not restore Qwen Code settings")
|
||||||
}
|
}
|
||||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
for _, path := range []string{
|
||||||
|
fixture.guardPath,
|
||||||
|
fixture.hookPath,
|
||||||
|
fixture.runtimeConfig,
|
||||||
|
fixture.privateKey,
|
||||||
|
} {
|
||||||
requireMissingQwenFile(t, path)
|
requireMissingQwenFile(t, path)
|
||||||
}
|
}
|
||||||
requireNoQwenStagingFiles(t, fixture.homeDir)
|
requireNoQwenStagingFiles(t, fixture.homeDir)
|
||||||
@@ -152,7 +288,8 @@ func TestQwenStatusSurfacesMalformedRuntimeConfig(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
t.Fatalf("corrupt runtime config: %v", err)
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
if _, err := fixture.plugin.Status(); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||||
t.Fatalf("status error = %v", err)
|
t.Fatalf("status error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQwenCommandsBlockAndForwardNativePayloadByteForByte(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)
|
||||||
|
}
|
||||||
|
guardScript := "const fs = require('node:fs');\n" +
|
||||||
|
"fs.readFileSync(0);\n" +
|
||||||
|
"process.stderr.write('Agent is frozen by Elydora.');\n" +
|
||||||
|
"process.exit(2);\n"
|
||||||
|
if err := os.WriteFile(fixture.guardPath, []byte(guardScript), 0700); err != nil {
|
||||||
|
t.Fatalf("write blocking guard runtime: %v", err)
|
||||||
|
}
|
||||||
|
captureScript := "const fs = require('node:fs');\n" +
|
||||||
|
"fs.writeFileSync(" + string(encodedPath) + ", fs.readFileSync(0));\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)
|
||||||
|
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"}}` + "\n"
|
||||||
|
guard := qwenManagedHandler(t, settings, "PreToolUse", fixture.guardPath)
|
||||||
|
guardResult := runQwenHandler(t, guard, fixture.homeDir, prePayload)
|
||||||
|
if guardResult.exitCode != 2 || guardResult.stdout != "" ||
|
||||||
|
!strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
|
||||||
|
t.Fatalf("guard result = %#v", guardResult)
|
||||||
|
}
|
||||||
|
for _, event := range []string{"PostToolUse", "PostToolUseFailure"} {
|
||||||
|
payload := strings.Replace(prePayload, "PreToolUse", event, 1)
|
||||||
|
handler := qwenManagedHandler(t, settings, event, fixture.hookPath)
|
||||||
|
result := runQwenHandler(t, handler, fixture.homeDir, payload)
|
||||||
|
if result.exitCode != 0 || result.stdout != "" || result.stderr != "" {
|
||||||
|
t.Fatalf("%s result = %#v", event, result)
|
||||||
|
}
|
||||||
|
if readQwenTestFile(t, capturePath) != payload {
|
||||||
|
t.Fatalf("%s changed the native Qwen Code payload", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenPowerShellCommandPropagatesNodeExitCode(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("PowerShell command contract is Windows-specific")
|
||||||
|
}
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
handler := qwenManagedHandler(
|
||||||
|
t,
|
||||||
|
readQwenTestObject(t, fixture.configPath),
|
||||||
|
"PreToolUse",
|
||||||
|
fixture.guardPath,
|
||||||
|
)
|
||||||
|
command := handler["command"].(string)
|
||||||
|
if !strings.HasPrefix(command, "& '") ||
|
||||||
|
!strings.HasSuffix(command, "; exit $LASTEXITCODE") {
|
||||||
|
t.Fatalf("PowerShell command = %q", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenStatusRequiresEnabledExactTripleAndRuntimeFiles(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)
|
||||||
|
hooks := requireQwenObject(t, settings["hooks"])
|
||||||
|
failureGroups := requireQwenArray(t, hooks["PostToolUseFailure"])
|
||||||
|
hooks["PostToolUseFailure"] = append(failureGroups, failureGroups[len(failureGroups)-1])
|
||||||
|
writeQwenTestObject(t, fixture.configPath, settings)
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || status.HookConfigured || status.Installed {
|
||||||
|
t.Fatalf("duplicate status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
settings = readQwenTestObject(t, fixture.configPath)
|
||||||
|
hooks = requireQwenObject(t, settings["hooks"])
|
||||||
|
delete(hooks, "PostToolUseFailure")
|
||||||
|
writeQwenTestObject(t, fixture.configPath, settings)
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || status.HookConfigured || status.Installed {
|
||||||
|
t.Fatalf("incomplete status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.systemConfig,
|
||||||
|
qwenJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
)
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || status.HookConfigured || status.Installed {
|
||||||
|
t.Fatalf("disabled status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(fixture.systemConfig); err != nil {
|
||||||
|
t.Fatalf("remove system settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(fixture.guardPath); err != nil {
|
||||||
|
t.Fatalf("remove guard runtime: %v", err)
|
||||||
|
}
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed {
|
||||||
|
t.Fatalf("missing runtime status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenStatusRequiresCanonicalRuntimeContents(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*testing.T, *qwenFixture)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"guard content",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
if err := os.WriteFile(f.guardPath, []byte("tampered\n"), 0700); err != nil {
|
||||||
|
t.Fatalf("tamper guard: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audit content",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
if err := os.WriteFile(f.hookPath, []byte("tampered\n"), 0700); err != nil {
|
||||||
|
t.Fatalf("tamper audit: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"private key",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
if err := os.WriteFile(f.privateKey, []byte("invalid"), 0600); err != nil {
|
||||||
|
t.Fatalf("tamper private key: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"private key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extra config field",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
config := readQwenTestObject(t, f.runtimeConfig)
|
||||||
|
config["extra"] = true
|
||||||
|
writeQwenTestObject(t, f.runtimeConfig, config)
|
||||||
|
},
|
||||||
|
"unsupported field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"runtime identity",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
config := readQwenTestObject(t, f.runtimeConfig)
|
||||||
|
config["agent_id"] = "other-agent"
|
||||||
|
writeQwenTestObject(t, f.runtimeConfig, config)
|
||||||
|
},
|
||||||
|
"identity does not match",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
test.mutate(t, fixture)
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if test.wantErr == "" {
|
||||||
|
if err != nil || status.Installed || status.HookScriptExists {
|
||||||
|
t.Fatalf("status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) ||
|
||||||
|
status.Installed || status.HookScriptExists {
|
||||||
|
t.Fatalf("status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,18 +11,46 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var qwenEventNames = map[string]bool{
|
var qwenEventNames = stringSet(
|
||||||
"PreToolUse": true, "PostToolUse": true, "PostToolUseFailure": true,
|
"PreToolUse",
|
||||||
"PostToolBatch": true, "Notification": true, "UserPromptSubmit": true,
|
"PostToolUse",
|
||||||
"UserPromptExpansion": true, "SessionStart": true, "Stop": true,
|
"PostToolUseFailure",
|
||||||
"MessageDisplay": true, "SubagentStart": true, "SubagentStop": true,
|
"PostToolBatch",
|
||||||
"PreCompact": true, "PostCompact": true, "SessionEnd": true,
|
"Notification",
|
||||||
"PermissionRequest": true, "PermissionDenied": true, "StopFailure": true,
|
"UserPromptSubmit",
|
||||||
"TodoCreated": true, "TodoCompleted": true, "InstructionsLoaded": true,
|
"UserPromptExpansion",
|
||||||
}
|
"SessionStart",
|
||||||
|
"Stop",
|
||||||
|
"MessageDisplay",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"PostCompact",
|
||||||
|
"SessionEnd",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionDenied",
|
||||||
|
"StopFailure",
|
||||||
|
"TodoCreated",
|
||||||
|
"TodoCompleted",
|
||||||
|
"InstructionsLoaded",
|
||||||
|
)
|
||||||
|
|
||||||
var qwenConfigFields = map[string]bool{
|
var qwenRegexMatcherEvents = [...]string{
|
||||||
"enabled": true, "disabled": true, "notifications": true,
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionDenied",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"PostCompact",
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"StopFailure",
|
||||||
|
"Notification",
|
||||||
|
"InstructionsLoaded",
|
||||||
|
"UserPromptExpansion",
|
||||||
}
|
}
|
||||||
|
|
||||||
type qwenRegexEntry struct {
|
type qwenRegexEntry struct {
|
||||||
@@ -36,24 +64,21 @@ func readQwenHookSettings(value any, label string) (qwenHookSettings, error) {
|
|||||||
return nil, fmt.Errorf("%s must contain a JSON object", label)
|
return nil, fmt.Errorf("%s must contain a JSON object", label)
|
||||||
}
|
}
|
||||||
settings := make(qwenHookSettings, len(object))
|
settings := make(qwenHookSettings, len(object))
|
||||||
for key, item := range object {
|
for event, item := range object {
|
||||||
if qwenConfigFields[key] {
|
if _, known := qwenEventNames[event]; !known {
|
||||||
settings[key] = item
|
settings[event] = item
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !qwenEventNames[key] {
|
|
||||||
return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key)
|
|
||||||
}
|
|
||||||
groups, ok := item.([]any)
|
groups, ok := item.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf(`%s field %q must be an array`, label, key)
|
return nil, fmt.Errorf(`%s field %q must be an array`, label, event)
|
||||||
}
|
}
|
||||||
for groupIndex, group := range groups {
|
for groupIndex, group := range groups {
|
||||||
if err := validateQwenGroup(group, label, key, groupIndex); err != nil {
|
if err := validateQwenGroup(group, label, event, groupIndex); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings[key] = item
|
settings[event] = item
|
||||||
}
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
@@ -94,7 +119,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error {
|
|||||||
}
|
}
|
||||||
kind, _ := handler["type"].(string)
|
kind, _ := handler["type"].(string)
|
||||||
if kind != "command" && kind != "http" && kind != "prompt" {
|
if kind != "command" && kind != "http" && kind != "prompt" {
|
||||||
return fmt.Errorf(`%s type must be "command", "http", or "prompt"`, location)
|
return fmt.Errorf("%s has unsupported type %q", location, kind)
|
||||||
}
|
}
|
||||||
if timeout, exists := handler["timeout"]; exists {
|
if timeout, exists := handler["timeout"]; exists {
|
||||||
number, ok := timeout.(float64)
|
number, ok := timeout.(float64)
|
||||||
@@ -111,7 +136,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error {
|
|||||||
case "command":
|
case "command":
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok || command == "" {
|
if !ok || command == "" {
|
||||||
return fmt.Errorf("%s command must be a non-empty string", location)
|
return fmt.Errorf("%s requires a non-empty command", location)
|
||||||
}
|
}
|
||||||
if err := optionalQwenStringMap(handler, "env", location); err != nil {
|
if err := optionalQwenStringMap(handler, "env", location); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -125,7 +150,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error {
|
|||||||
case "http":
|
case "http":
|
||||||
url, ok := handler["url"].(string)
|
url, ok := handler["url"].(string)
|
||||||
if !ok || url == "" {
|
if !ok || url == "" {
|
||||||
return fmt.Errorf("%s url must be a non-empty string", location)
|
return fmt.Errorf("%s requires a non-empty url", location)
|
||||||
}
|
}
|
||||||
if err := optionalQwenStringMap(handler, "headers", location); err != nil {
|
if err := optionalQwenStringMap(handler, "headers", location); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +170,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error {
|
|||||||
case "prompt":
|
case "prompt":
|
||||||
prompt, ok := handler["prompt"].(string)
|
prompt, ok := handler["prompt"].(string)
|
||||||
if !ok || prompt == "" {
|
if !ok || prompt == "" {
|
||||||
return fmt.Errorf("%s prompt must be a non-empty string", location)
|
return fmt.Errorf("%s requires a non-empty prompt", location)
|
||||||
}
|
}
|
||||||
if err := optionalQwenString(handler, "model", location); err != nil {
|
if err := optionalQwenString(handler, "model", location); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -179,11 +204,11 @@ func optionalQwenStringMap(value map[string]any, key, label string) error {
|
|||||||
}
|
}
|
||||||
entries, ok := item.(map[string]any)
|
entries, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf(`%s field %q must contain string values`, label, key)
|
return fmt.Errorf(`%s field %q must map names to strings`, label, key)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if _, ok := entry.(string); !ok {
|
if _, ok := entry.(string); !ok {
|
||||||
return fmt.Errorf(`%s field %q must contain string values`, label, key)
|
return fmt.Errorf(`%s field %q must map names to strings`, label, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -198,25 +223,41 @@ func allQwenStrings(values []any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateQwenRegexes(nodePath string, settings qwenHookSettings) error {
|
func collectQwenRegexes(sources []qwenHookSettings) []qwenRegexEntry {
|
||||||
entries := make([]qwenRegexEntry, 0)
|
entries := make([]qwenRegexEntry, 0)
|
||||||
for event, value := range settings {
|
for sourceIndex, settings := range sources {
|
||||||
if qwenConfigFields[event] {
|
for _, event := range qwenRegexMatcherEvents {
|
||||||
continue
|
groups, _ := settings[event].([]any)
|
||||||
}
|
for groupIndex, groupValue := range groups {
|
||||||
for index, groupValue := range value.([]any) {
|
|
||||||
group := groupValue.(map[string]any)
|
group := groupValue.(map[string]any)
|
||||||
matcher, ok := group["matcher"].(string)
|
matcher, ok := group["matcher"].(string)
|
||||||
if ok && strings.TrimSpace(matcher) != "" && strings.TrimSpace(matcher) != "*" {
|
if !ok || strings.TrimSpace(matcher) == "" || strings.TrimSpace(matcher) == "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
entries = append(entries, qwenRegexEntry{
|
entries = append(entries, qwenRegexEntry{
|
||||||
Label: fmt.Sprintf(`Qwen Code hooks field %q[%d] matcher`, event, index), Pattern: matcher,
|
Label: fmt.Sprintf(
|
||||||
|
`Qwen Code hook source %d field %q[%d] matcher`,
|
||||||
|
sourceIndex,
|
||||||
|
event,
|
||||||
|
groupIndex,
|
||||||
|
),
|
||||||
|
Pattern: matcher,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQwenJavaScriptMatchers(sources []qwenHookSettings) error {
|
||||||
|
entries := collectQwenRegexes(sources)
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
payload, err := json.Marshal(entries)
|
payload, err := json.Marshal(entries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode Qwen Code regular expressions: %w", err)
|
return fmt.Errorf("encode Qwen Code regular expressions: %w", err)
|
||||||
@@ -244,7 +285,10 @@ for (const entry of entries) {
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
message = runErr.Error()
|
message = runErr.Error()
|
||||||
}
|
}
|
||||||
return fmt.Errorf("Qwen Code matcher must be a valid JavaScript regular expression: %s", message)
|
return fmt.Errorf(
|
||||||
|
"Qwen Code matcher must be a valid JavaScript regular expression: %s",
|
||||||
|
message,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQwenSchemaTracksOfficialEventAndMatcherSets(t *testing.T) {
|
||||||
|
expectedEvents := []string{
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"PostToolBatch",
|
||||||
|
"Notification",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"UserPromptExpansion",
|
||||||
|
"SessionStart",
|
||||||
|
"Stop",
|
||||||
|
"MessageDisplay",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"PostCompact",
|
||||||
|
"SessionEnd",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionDenied",
|
||||||
|
"StopFailure",
|
||||||
|
"TodoCreated",
|
||||||
|
"TodoCompleted",
|
||||||
|
"InstructionsLoaded",
|
||||||
|
}
|
||||||
|
if len(qwenEventNames) != len(expectedEvents) {
|
||||||
|
t.Fatalf("known event count = %d", len(qwenEventNames))
|
||||||
|
}
|
||||||
|
for _, event := range expectedEvents {
|
||||||
|
if _, exists := qwenEventNames[event]; !exists {
|
||||||
|
t.Fatalf("official event is missing: %s", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(qwenRegexMatcherEvents) != 15 {
|
||||||
|
t.Fatalf("matcher event count = %d", len(qwenRegexMatcherEvents))
|
||||||
|
}
|
||||||
|
hooks := qwenHookSettings{}
|
||||||
|
for _, event := range expectedEvents {
|
||||||
|
hooks[event] = []any{map[string]any{
|
||||||
|
"matcher": "tool-(?<name>.+)",
|
||||||
|
"hooks": []any{},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
entries := collectQwenRegexes([]qwenHookSettings{hooks})
|
||||||
|
if len(entries) != 15 {
|
||||||
|
t.Fatalf("collected matcher count = %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenMatcherValidationUsesJavaScriptRegExp(t *testing.T) {
|
||||||
|
t.Run("JavaScript named capture", func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{
|
||||||
|
settings: qwenString(`{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{ "matcher": "(?<tool>read|write)", "hooks": [] }]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
})
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Python named capture", func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{
|
||||||
|
settings: qwenString(`{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{ "matcher": "(?P<tool>read|write)", "hooks": [] }]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
})
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "JavaScript regular expression") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenSchemaAcceptsOfficialCommandHTTPAndPromptHandlers(t *testing.T) {
|
||||||
|
settings := `{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{
|
||||||
|
"matcher": "read|write",
|
||||||
|
"sequential": true,
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"name": "command-hook",
|
||||||
|
"command": "echo test",
|
||||||
|
"shell": "powershell",
|
||||||
|
"timeout": 1000,
|
||||||
|
"env": { "MODE": "test" },
|
||||||
|
"async": false,
|
||||||
|
"description": "command"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"name": "http-hook",
|
||||||
|
"url": "https://hooks.example.test",
|
||||||
|
"headers": { "Authorization": "Bearer token" },
|
||||||
|
"allowedEnvVars": ["HOME"],
|
||||||
|
"once": true,
|
||||||
|
"if": "always"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"name": "prompt-hook",
|
||||||
|
"prompt": "Review this tool call",
|
||||||
|
"model": "qwen3-coder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)})
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
raw := readQwenTestFile(t, fixture.configPath)
|
||||||
|
for _, marker := range []string{"command-hook", "http-hook", "prompt-hook"} {
|
||||||
|
if !strings.Contains(raw, marker) {
|
||||||
|
t.Fatalf("handler %q was lost", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenSchemaRejectsInvalidOfficialHandlerFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, handler, pattern string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"command env",
|
||||||
|
`{"type":"command","command":"x","env":{"A":1}}`,
|
||||||
|
"map names to strings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command async",
|
||||||
|
`{"type":"command","command":"x","async":"yes"}`,
|
||||||
|
"must be a boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command shell",
|
||||||
|
`{"type":"command","command":"x","shell":"cmd"}`,
|
||||||
|
"shell must be",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"http headers",
|
||||||
|
`{"type":"http","url":"https://x.test","headers":{"A":1}}`,
|
||||||
|
"map names to strings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"http environment",
|
||||||
|
`{"type":"http","url":"https://x.test","allowedEnvVars":[1]}`,
|
||||||
|
"array of strings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prompt model",
|
||||||
|
`{"type":"prompt","prompt":"x","model":1}`,
|
||||||
|
"must be a string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
source := `{"hooks":{"PreToolUse":[{"hooks":[` + test.handler + `]}]}}`
|
||||||
|
fixture := prepareQwenFixture(
|
||||||
|
t,
|
||||||
|
qwenFixtureOptions{settings: qwenString(source)},
|
||||||
|
)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQwenInstallValidatesCredentialsAndManagedPathsBeforeWrites(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*qwenFixture)
|
||||||
|
pattern string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"agent name",
|
||||||
|
func(f *qwenFixture) { f.config.AgentName = "other" },
|
||||||
|
"requires agent name qwen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"organization",
|
||||||
|
func(f *qwenFixture) { f.config.OrgID = " " },
|
||||||
|
"organization ID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent id",
|
||||||
|
func(f *qwenFixture) { f.config.AgentID = "../agent" },
|
||||||
|
"single non-empty path segment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key id",
|
||||||
|
func(f *qwenFixture) { f.config.KID = " " },
|
||||||
|
"key ID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"private key",
|
||||||
|
func(f *qwenFixture) { f.config.PrivateKey = "invalid" },
|
||||||
|
"private key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base URL",
|
||||||
|
func(f *qwenFixture) { f.config.BaseURL = "https://api.test/path?secret=x" },
|
||||||
|
"base URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token",
|
||||||
|
func(f *qwenFixture) { f.config.Token = " " },
|
||||||
|
"token must contain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"relative guard",
|
||||||
|
func(f *qwenFixture) { f.config.GuardScriptPath = "guard.js" },
|
||||||
|
"managed agent directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guard outside runtime",
|
||||||
|
func(f *qwenFixture) {
|
||||||
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js")
|
||||||
|
},
|
||||||
|
"managed agent directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audit outside runtime",
|
||||||
|
func(f *qwenFixture) {
|
||||||
|
f.config.HookScript = filepath.Join(f.homeDir, "hook.js")
|
||||||
|
},
|
||||||
|
"audit runtime must use",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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.guardPath,
|
||||||
|
fixture.hookPath,
|
||||||
|
fixture.runtimeConfig,
|
||||||
|
fixture.privateKey,
|
||||||
|
} {
|
||||||
|
requireMissingQwenFile(t, path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenInstallRejectsNonPhysicalSources(t *testing.T) {
|
||||||
|
t.Run("home environment directory", 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 environment directory: %v", err)
|
||||||
|
}
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user settings symlink", func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
target := filepath.Join(filepath.Dir(fixture.homeDir), "settings-target.json")
|
||||||
|
writeOptionalQwenFile(t, target, qwenString("{}\n"))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0755); err != nil {
|
||||||
|
t.Fatalf("create settings directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, fixture.configPath); err != nil {
|
||||||
|
t.Skipf("create settings symlink: %v", err)
|
||||||
|
}
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
requireMissingQwenFile(t, fixture.guardPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("agent runtime symlink", func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
runtimeRoot := filepath.Dir(fixture.agentDir)
|
||||||
|
target := filepath.Join(filepath.Dir(fixture.homeDir), "agent-target")
|
||||||
|
for _, directory := range []string{runtimeRoot, target} {
|
||||||
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||||
|
t.Fatalf("create runtime directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, fixture.agentDir); err != nil {
|
||||||
|
t.Skipf("create runtime symlink: %v", err)
|
||||||
|
}
|
||||||
|
err := fixture.plugin.PreflightInstall(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical directory") {
|
||||||
|
t.Fatalf("preflight error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenPreflightProtectsExistingRuntimeIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*testing.T, *qwenFixture)
|
||||||
|
pattern string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"orphan artifact",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
writeOptionalQwenFile(t, f.guardPath, qwenString("external\n"))
|
||||||
|
},
|
||||||
|
"cannot be verified without config.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"different identity",
|
||||||
|
func(t *testing.T, f *qwenFixture) {
|
||||||
|
writeOptionalQwenFile(t, f.runtimeConfig, qwenJSON(map[string]any{
|
||||||
|
"org_id": "org-1",
|
||||||
|
"agent_id": "other-agent",
|
||||||
|
"kid": "kid-1",
|
||||||
|
"base_url": "https://api.elydora.test",
|
||||||
|
"agent_name": qwenAgentKey,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
"identity does not match",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
test.configure(t, fixture)
|
||||||
|
err := fixture.plugin.PreflightInstall(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||||
|
t.Fatalf("preflight error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenStatusRejectsRuntimeSymlink(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
installQwenFixture(t, fixture)
|
||||||
|
target := filepath.Join(filepath.Dir(fixture.homeDir), "guard-target.js")
|
||||||
|
writeOptionalQwenFile(t, target, qwenString(readQwenTestFile(t, fixture.guardPath)))
|
||||||
|
if err := os.Remove(fixture.guardPath); err != nil {
|
||||||
|
t.Fatalf("remove guard runtime: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, fixture.guardPath); err != nil {
|
||||||
|
t.Skipf("create guard symlink: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.plugin.Status(); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "physical file") {
|
||||||
|
t.Fatalf("status error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"}
|
||||||
|
|
||||||
|
type qwenDisableControl struct {
|
||||||
|
disabled bool
|
||||||
|
source *qwenDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
type qwenSources struct {
|
||||||
|
qwenHome string
|
||||||
|
systemDefaults *qwenDocument
|
||||||
|
user *qwenDocument
|
||||||
|
workspace *qwenDocument
|
||||||
|
system *qwenDocument
|
||||||
|
workspaceActive bool
|
||||||
|
workspaceTrusted bool
|
||||||
|
disableControl qwenDisableControl
|
||||||
|
preconditions []filePrecondition
|
||||||
|
}
|
||||||
|
|
||||||
|
type qwenRoutingResult struct {
|
||||||
|
qwenHome string
|
||||||
|
preconditions []filePrecondition
|
||||||
|
}
|
||||||
|
|
||||||
|
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 qwenComparisonPath(filePath string) string {
|
||||||
|
resolved, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
resolved = filepath.Clean(filePath)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.ToLower(resolved)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveQwenRouting() (*qwenRoutingResult, error) {
|
||||||
|
values := map[string]string{}
|
||||||
|
owned := map[string]bool{}
|
||||||
|
for _, key := range qwenHomeEnvironmentKeys {
|
||||||
|
value, present := os.LookupEnv(key)
|
||||||
|
values[key] = value
|
||||||
|
owned[key] = present
|
||||||
|
}
|
||||||
|
if values["QWEN_HOME"] != "" && values["QWEN_RUNTIME_DIR"] != "" {
|
||||||
|
home, err := resolveQwenStoragePath(values["QWEN_HOME"])
|
||||||
|
return &qwenRoutingResult{qwenHome: home}, err
|
||||||
|
}
|
||||||
|
defaultHome, err := defaultQwenHome()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
initialHome := values["QWEN_HOME"]
|
||||||
|
initialDirectory := defaultHome
|
||||||
|
if initialHome != "" {
|
||||||
|
initialDirectory, err = resolveQwenStoragePath(initialHome)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates := []string{filepath.Join(initialDirectory, ".env")}
|
||||||
|
if initialHome == "" {
|
||||||
|
candidates = append(candidates, filepath.Join(filepath.Dir(initialDirectory), ".env"))
|
||||||
|
}
|
||||||
|
preconditions := make([]filePrecondition, 0, 3)
|
||||||
|
visited := map[string]bool{}
|
||||||
|
readCandidate := func(filePath string) error {
|
||||||
|
resolved, resolveErr := filepath.Abs(filePath)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return fmt.Errorf("resolve Qwen Code home environment path: %w", resolveErr)
|
||||||
|
}
|
||||||
|
key := qwenComparisonPath(resolved)
|
||||||
|
if visited[key] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visited[key] = true
|
||||||
|
snapshot, readErr := readManagedFile(
|
||||||
|
resolved,
|
||||||
|
"Qwen Code home environment",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
preconditions = append(preconditions, filePrecondition{
|
||||||
|
filePath: resolved, label: "Qwen Code home environment",
|
||||||
|
snapshot: snapshot, maximumSize: maxManagedSourceBytes,
|
||||||
|
})
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed := parseDotenv(snapshot.contents)
|
||||||
|
for _, envKey := range qwenHomeEnvironmentKeys {
|
||||||
|
value := parsed[envKey]
|
||||||
|
if value != "" && !owned[envKey] {
|
||||||
|
values[envKey] = value
|
||||||
|
owned[envKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if err := readCandidate(candidate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discoveredHome := values["QWEN_HOME"]
|
||||||
|
if discoveredHome != "" && discoveredHome != initialHome {
|
||||||
|
discoveredDirectory, resolveErr := resolveQwenStoragePath(discoveredHome)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return nil, resolveErr
|
||||||
|
}
|
||||||
|
if !sameQwenPath(discoveredDirectory, initialDirectory) {
|
||||||
|
if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvedHome := defaultHome
|
||||||
|
if values["QWEN_HOME"] != "" {
|
||||||
|
resolvedHome, err = resolveQwenStoragePath(values["QWEN_HOME"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &qwenRoutingResult{
|
||||||
|
qwenHome: resolvedHome,
|
||||||
|
preconditions: preconditions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveQwenHome() (string, error) {
|
||||||
|
routing, err := resolveQwenRouting()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return routing.qwenHome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenSystemSettingsPath() (string, error) {
|
||||||
|
if configured := os.Getenv("QWEN_CODE_SYSTEM_SETTINGS_PATH"); configured != "" {
|
||||||
|
return filepath.Abs(configured)
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return "/Library/Application Support/QwenCode/settings.json", nil
|
||||||
|
case "windows":
|
||||||
|
return `C:\ProgramData\qwen-code\settings.json`, nil
|
||||||
|
default:
|
||||||
|
return "/etc/qwen-code/settings.json", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenSystemDefaultsPath(systemPath string) (string, error) {
|
||||||
|
if configured := os.Getenv("QWEN_CODE_SYSTEM_DEFAULTS_PATH"); configured != "" {
|
||||||
|
return filepath.Abs(configured)
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(systemPath), "system-defaults.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQwenDocument(kind, filePath string) (*qwenDocument, error) {
|
||||||
|
snapshot, err := readManagedFile(filePath, qwenSourceLabel(kind), maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return createQwenDocument(kind, filePath)
|
||||||
|
}
|
||||||
|
return parseQwenDocument(kind, true, filePath, snapshot.contents, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalQwenPath(filePath string) string {
|
||||||
|
canonical, err := filepath.EvalSymlinks(filePath)
|
||||||
|
if err == nil {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
absolute, absErr := filepath.Abs(filePath)
|
||||||
|
if absErr == nil {
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
return filepath.Clean(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenPathWithin(child, parent string) bool {
|
||||||
|
relative, err := filepath.Rel(qwenComparisonPath(parent), qwenComparisonPath(child))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return relative == "." ||
|
||||||
|
(relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) &&
|
||||||
|
!filepath.IsAbs(relative))
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenWorkspaceTrust(
|
||||||
|
system, user *qwenDocument,
|
||||||
|
qwenHome, workspacePath string,
|
||||||
|
) (bool, *filePrecondition, error) {
|
||||||
|
var enabled *bool
|
||||||
|
if user != nil && user.folderTrustEnabled != nil {
|
||||||
|
enabled = user.folderTrustEnabled
|
||||||
|
} else if system != nil {
|
||||||
|
enabled = system.folderTrustEnabled
|
||||||
|
}
|
||||||
|
if enabled == nil || !*enabled {
|
||||||
|
return true, nil, nil
|
||||||
|
}
|
||||||
|
filePath := os.Getenv("QWEN_CODE_TRUSTED_FOLDERS_PATH")
|
||||||
|
var err error
|
||||||
|
if filePath != "" {
|
||||||
|
filePath, err = filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, fmt.Errorf("resolve Qwen Code trusted folders path: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filePath = filepath.Join(qwenHome, "trustedFolders.json")
|
||||||
|
}
|
||||||
|
snapshot, err := readManagedFile(
|
||||||
|
filePath,
|
||||||
|
"Qwen Code trusted folders",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
precondition := &filePrecondition{
|
||||||
|
filePath: filePath, label: "Qwen Code trusted folders",
|
||||||
|
snapshot: snapshot, maximumSize: maxManagedSourceBytes,
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return true, precondition, nil
|
||||||
|
}
|
||||||
|
rules, err := decodeJSONCObject(
|
||||||
|
snapshot.contents,
|
||||||
|
fmt.Sprintf("Qwen Code trusted folders at %s", filePath),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
for rulePath, level := range rules {
|
||||||
|
if level != "TRUST_FOLDER" && level != "TRUST_PARENT" && level != "DO_NOT_TRUST" {
|
||||||
|
return false, nil, fmt.Errorf(
|
||||||
|
"Qwen Code trusted folders has invalid trust level for %q",
|
||||||
|
rulePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workspace := canonicalQwenPath(workspacePath)
|
||||||
|
for rulePath, level := range rules {
|
||||||
|
canonicalRule := canonicalQwenPath(rulePath)
|
||||||
|
trustRoot := canonicalRule
|
||||||
|
if level == "TRUST_PARENT" {
|
||||||
|
trustRoot = filepath.Dir(canonicalRule)
|
||||||
|
}
|
||||||
|
if (level == "TRUST_FOLDER" || level == "TRUST_PARENT") &&
|
||||||
|
qwenPathWithin(workspace, trustRoot) {
|
||||||
|
return true, precondition, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rulePath, level := range rules {
|
||||||
|
if level == "DO_NOT_TRUST" &&
|
||||||
|
qwenComparisonPath(workspace) == qwenComparisonPath(canonicalQwenPath(rulePath)) {
|
||||||
|
return false, precondition, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, precondition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveQwenDisable(
|
||||||
|
systemDefaults, user, workspace, system *qwenDocument,
|
||||||
|
useWorkspace bool,
|
||||||
|
) qwenDisableControl {
|
||||||
|
control := qwenDisableControl{}
|
||||||
|
documents := []*qwenDocument{systemDefaults, user}
|
||||||
|
if useWorkspace {
|
||||||
|
documents = append(documents, workspace)
|
||||||
|
}
|
||||||
|
documents = append(documents, system)
|
||||||
|
for _, document := range documents {
|
||||||
|
if document == nil || document.disableAllHooks == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
control.disabled = *document.disableAllHooks
|
||||||
|
control.source = document
|
||||||
|
}
|
||||||
|
return control
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenSourcePrecondition(document *qwenDocument) filePrecondition {
|
||||||
|
return filePrecondition{
|
||||||
|
filePath: document.filePath,
|
||||||
|
label: qwenDocumentLabel(document),
|
||||||
|
snapshot: document.snapshot,
|
||||||
|
maximumSize: maxManagedSourceBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicateQwenPreconditions(values []filePrecondition) []filePrecondition {
|
||||||
|
result := make([]filePrecondition, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
key := qwenComparisonPath(value.filePath)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQwenSources() (*qwenSources, error) {
|
||||||
|
routing, err := resolveQwenRouting()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
systemPath, err := qwenSystemSettingsPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defaultsPath, err := qwenSystemDefaultsPath(systemPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
workspaceDirectory, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve Qwen Code workspace: %w", err)
|
||||||
|
}
|
||||||
|
workspacePath := filepath.Join(workspaceDirectory, ".qwen", "settings.json")
|
||||||
|
system, err := readQwenDocument(qwenSystemKind, systemPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
systemDefaults, err := readQwenDocument(qwenSystemDefaultsKind, defaultsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := readQwenDocument(
|
||||||
|
qwenUserKind,
|
||||||
|
filepath.Join(routing.qwenHome, "settings.json"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
canonicalWorkspace := canonicalQwenPath(workspaceDirectory)
|
||||||
|
workspaceActive := qwenComparisonPath(canonicalWorkspace) !=
|
||||||
|
qwenComparisonPath(canonicalQwenPath(home))
|
||||||
|
workspace, err := createQwenDocument(qwenWorkspaceKind, workspacePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if workspaceActive {
|
||||||
|
workspace, err = readQwenDocument(qwenWorkspaceKind, workspacePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workspaceTrusted := false
|
||||||
|
var trustPrecondition *filePrecondition
|
||||||
|
if workspaceActive {
|
||||||
|
workspaceTrusted, trustPrecondition, err = qwenWorkspaceTrust(
|
||||||
|
system,
|
||||||
|
user,
|
||||||
|
routing.qwenHome,
|
||||||
|
canonicalWorkspace,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateQwenJavaScriptMatchers([]qwenHookSettings{
|
||||||
|
systemDefaults.hooks,
|
||||||
|
user.hooks,
|
||||||
|
workspace.hooks,
|
||||||
|
system.hooks,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
preconditions := append([]filePrecondition(nil), routing.preconditions...)
|
||||||
|
preconditions = append(
|
||||||
|
preconditions,
|
||||||
|
qwenSourcePrecondition(systemDefaults),
|
||||||
|
qwenSourcePrecondition(user),
|
||||||
|
)
|
||||||
|
if workspaceActive {
|
||||||
|
preconditions = append(preconditions, qwenSourcePrecondition(workspace))
|
||||||
|
}
|
||||||
|
preconditions = append(preconditions, qwenSourcePrecondition(system))
|
||||||
|
if trustPrecondition != nil {
|
||||||
|
preconditions = append(preconditions, *trustPrecondition)
|
||||||
|
}
|
||||||
|
return &qwenSources{
|
||||||
|
qwenHome: routing.qwenHome,
|
||||||
|
systemDefaults: systemDefaults,
|
||||||
|
user: user,
|
||||||
|
workspace: workspace,
|
||||||
|
system: system,
|
||||||
|
workspaceActive: workspaceActive,
|
||||||
|
workspaceTrusted: workspaceTrusted,
|
||||||
|
disableControl: effectiveQwenDisable(
|
||||||
|
systemDefaults,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
system,
|
||||||
|
workspaceActive && workspaceTrusted,
|
||||||
|
),
|
||||||
|
preconditions: deduplicateQwenPreconditions(preconditions),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireQwenHooksEnabled(sources *qwenSources) error {
|
||||||
|
if sources == nil {
|
||||||
|
return fmt.Errorf("Qwen Code settings sources are required")
|
||||||
|
}
|
||||||
|
if !sources.disableControl.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
location := "effective settings"
|
||||||
|
if sources.disableControl.source != nil {
|
||||||
|
location = fmt.Sprintf(
|
||||||
|
"%s at %s",
|
||||||
|
qwenDocumentLabel(sources.disableControl.source),
|
||||||
|
sources.disableControl.source.filePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Qwen Code hooks are disabled by disableAllHooks in %s",
|
||||||
|
location,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQwenHomeUsesOfficialEnvironmentDiscovery(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"+
|
||||||
|
"QWEN_RUNTIME_DIR=runtime-one\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
filepath.Join(fixture.homeDir, ".env"),
|
||||||
|
qwenString("QWEN_HOME="+secondHome+"\n"),
|
||||||
|
)
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
filepath.Join(firstHome, ".env"),
|
||||||
|
qwenString("QWEN_HOME="+secondHome+"\nQWEN_RUNTIME_DIR=runtime-two\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 TestQwenExplicitHomeOwnershipSupportsRelativeTildeAndEmptyValues(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)
|
||||||
|
selected := test.expected(fixture)
|
||||||
|
qwenManagedHandler(
|
||||||
|
t,
|
||||||
|
readQwenTestObject(t, selected),
|
||||||
|
"PreToolUse",
|
||||||
|
fixture.guardPath,
|
||||||
|
)
|
||||||
|
requireMissingQwenFile(t, filepath.Join(ignored, "settings.json"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenRoutingReadsDiscoveredHomeEnvironment(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
discovered := filepath.Join(filepath.Dir(fixture.homeDir), "discovered")
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
filepath.Join(fixture.qwenDir, ".env"),
|
||||||
|
qwenString("QWEN_HOME="+discovered+"\n"),
|
||||||
|
)
|
||||||
|
discoveredEnv := filepath.Join(discovered, ".env")
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
discoveredEnv,
|
||||||
|
qwenString("QWEN_RUNTIME_DIR=runtime-output\n"),
|
||||||
|
)
|
||||||
|
routing, err := resolveQwenRouting()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve Qwen routing: %v", err)
|
||||||
|
}
|
||||||
|
if !sameQwenPath(routing.qwenHome, discovered) {
|
||||||
|
t.Fatalf("Qwen home = %s", routing.qwenHome)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, condition := range routing.preconditions {
|
||||||
|
found = found || sameQwenPath(condition.filePath, discoveredEnv)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("discovered environment is absent from preconditions: %#v", routing.preconditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenRoutingSkipsDotenvWhenBothEnvironmentValuesExist(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
explicit := filepath.Join(filepath.Dir(fixture.homeDir), "explicit")
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
filepath.Join(fixture.qwenDir, ".env"),
|
||||||
|
qwenString("QWEN_HOME=ignored\n"),
|
||||||
|
)
|
||||||
|
t.Setenv("QWEN_HOME", explicit)
|
||||||
|
t.Setenv("QWEN_RUNTIME_DIR", "runtime")
|
||||||
|
routing, err := resolveQwenRouting()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve Qwen routing: %v", err)
|
||||||
|
}
|
||||||
|
if !sameQwenPath(routing.qwenHome, explicit) || len(routing.preconditions) != 0 {
|
||||||
|
t.Fatalf("routing = %#v", routing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenEffectiveDisableUsesOfficialLayerOrderAndTrust(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
trustLevel string
|
||||||
|
systemDisabled *bool
|
||||||
|
wantDisabled bool
|
||||||
|
wantSource string
|
||||||
|
}{
|
||||||
|
{"untrusted workspace", "DO_NOT_TRUST", nil, false, qwenUserKind},
|
||||||
|
{"trusted workspace", "TRUST_FOLDER", nil, true, qwenWorkspaceKind},
|
||||||
|
{"system override", "TRUST_FOLDER", qwenBool(false), false, qwenSystemKind},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{
|
||||||
|
settings: qwenJSON(map[string]any{
|
||||||
|
"disableAllHooks": false,
|
||||||
|
"security": map[string]any{
|
||||||
|
"folderTrust": map[string]any{"enabled": true},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.systemDefaults,
|
||||||
|
qwenJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
)
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.workspaceConfig,
|
||||||
|
qwenJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
)
|
||||||
|
if test.systemDisabled != nil {
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.systemConfig,
|
||||||
|
qwenJSON(map[string]any{"disableAllHooks": *test.systemDisabled}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
writeOptionalQwenFile(
|
||||||
|
t,
|
||||||
|
fixture.trustedFolders,
|
||||||
|
qwenJSON(map[string]any{fixture.workspaceDir: test.trustLevel}),
|
||||||
|
)
|
||||||
|
sources, err := readQwenSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Qwen sources: %v", err)
|
||||||
|
}
|
||||||
|
if sources.workspaceTrusted != (test.trustLevel == "TRUST_FOLDER") ||
|
||||||
|
sources.disableControl.disabled != test.wantDisabled ||
|
||||||
|
sources.disableControl.source == nil ||
|
||||||
|
sources.disableControl.source.kind != test.wantSource {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenWorkspaceAtHomeIsInactive(t *testing.T) {
|
||||||
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
if err := os.Chdir(fixture.homeDir); err != nil {
|
||||||
|
t.Fatalf("enter home directory: %v", err)
|
||||||
|
}
|
||||||
|
sources, err := readQwenSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Qwen sources: %v", err)
|
||||||
|
}
|
||||||
|
if sources.workspaceActive || sources.workspaceTrusted {
|
||||||
|
t.Fatalf("workspace state = active:%v trusted:%v", sources.workspaceActive, sources.workspaceTrusted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qwenBool(value bool) *bool {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -10,36 +9,55 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQwenRegistryUsesOfficialUserSettings(t *testing.T) {
|
func TestQwenRegistryAndRuntimeOwnership(t *testing.T) {
|
||||||
entry := SupportedAgents[qwenAgentKey]
|
entry := SupportedAgents[qwenAgentKey]
|
||||||
if entry.Name != "Qwen Code" || entry.ConfigDir != "~/.qwen" || entry.ConfigFile != "settings.json" {
|
if entry.Name != "Qwen Code" || entry.ConfigDir != "~/.qwen" ||
|
||||||
|
entry.ConfigFile != "settings.json" {
|
||||||
t.Fatalf("Qwen Code registry entry = %#v", entry)
|
t.Fatalf("Qwen Code registry entry = %#v", entry)
|
||||||
}
|
}
|
||||||
if _, ok := NewPlugin(qwenAgentKey).(*QwenPlugin); !ok {
|
plugin, ok := NewPlugin(qwenAgentKey).(*QwenPlugin)
|
||||||
|
if !ok {
|
||||||
t.Fatal("Qwen Code plugin is not registered")
|
t.Fatal("Qwen Code plugin is not registered")
|
||||||
}
|
}
|
||||||
|
if !plugin.ManagesGuardRuntime() {
|
||||||
|
t.Fatal("Qwen Code plugin must own its runtime transaction")
|
||||||
|
}
|
||||||
|
if _, ok := any(plugin).(InstallPreflighter); !ok {
|
||||||
|
t.Fatal("Qwen Code plugin must preflight before CLI writes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQwenInstallPreservesJSONCommentsAndIsIdempotent(t *testing.T) {
|
func TestQwenInstallPreservesSourcesAndIsIdempotent(t *testing.T) {
|
||||||
settings := "{\r\n" +
|
settings := "{\r\n" +
|
||||||
" // Keep this user preference.\r\n" +
|
" // Keep this user preference.\r\n" +
|
||||||
" \"theme\": \"GitHub\",\r\n" +
|
" \"theme\": \"GitHub\",\r\n" +
|
||||||
" \"hooks\": {\r\n" +
|
" \"hooks\": {\r\n" +
|
||||||
|
" \"FutureEvent\": { \"opaque\": true },\r\n" +
|
||||||
" \"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"session-hook\" }] }],\r\n" +
|
" \"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"session-hook\" }] }],\r\n" +
|
||||||
" \"PreToolUse\": [{ \"matcher\": \"read_file\", \"hooks\": [{ \"type\": \"command\", \"command\": \"user-hook\" }] }]\r\n" +
|
" \"PreToolUse\": [{ \"matcher\": \"read_file\", \"hooks\": [{ \"type\": \"command\", \"command\": \"user-hook\" }] }]\r\n" +
|
||||||
" }\r\n" +
|
" }\r\n" +
|
||||||
"}\r\n"
|
"}\r\n"
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)})
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)})
|
||||||
workspaceSettings := filepath.Join(fixture.workspaceDir, ".qwen", "settings.json")
|
writeOptionalQwenFile(
|
||||||
writeOptionalQwenFile(t, workspaceSettings, qwenString("{ \"owner\": \"workspace\" }\n"))
|
t,
|
||||||
|
fixture.workspaceConfig,
|
||||||
|
qwenString("{ \"owner\": \"workspace\" }\n"),
|
||||||
|
)
|
||||||
|
workspaceBefore := readQwenTestFile(t, fixture.workspaceConfig)
|
||||||
installQwenFixture(t, fixture)
|
installQwenFixture(t, fixture)
|
||||||
first := readQwenTestFile(t, fixture.configPath)
|
firstSettings := readQwenTestFile(t, fixture.configPath)
|
||||||
|
firstGuard := readQwenTestFile(t, fixture.guardPath)
|
||||||
|
firstAudit := readQwenTestFile(t, fixture.hookPath)
|
||||||
installQwenFixture(t, fixture)
|
installQwenFixture(t, fixture)
|
||||||
if readQwenTestFile(t, fixture.configPath) != first {
|
if readQwenTestFile(t, fixture.configPath) != firstSettings ||
|
||||||
t.Fatal("idempotent install changed Qwen Code settings")
|
readQwenTestFile(t, fixture.guardPath) != firstGuard ||
|
||||||
|
readQwenTestFile(t, fixture.hookPath) != firstAudit {
|
||||||
|
t.Fatal("idempotent install changed Qwen Code installation")
|
||||||
}
|
}
|
||||||
if !strings.Contains(first, "Keep this user preference") || !strings.Contains(first, "\r\n") {
|
if !strings.Contains(firstSettings, "Keep this user preference") ||
|
||||||
t.Fatalf("settings comments or line endings were lost:\n%s", first)
|
!strings.Contains(firstSettings, "\r\n") ||
|
||||||
|
!strings.Contains(firstSettings, "FutureEvent") {
|
||||||
|
t.Fatalf("settings source fidelity was lost:\n%s", firstSettings)
|
||||||
}
|
}
|
||||||
root := readQwenTestObject(t, fixture.configPath)
|
root := readQwenTestObject(t, fixture.configPath)
|
||||||
if root["theme"] != "GitHub" {
|
if root["theme"] != "GitHub" {
|
||||||
@@ -48,28 +66,52 @@ func TestQwenInstallPreservesJSONCommentsAndIsIdempotent(t *testing.T) {
|
|||||||
hooks := requireQwenObject(t, root["hooks"])
|
hooks := requireQwenObject(t, root["hooks"])
|
||||||
if len(requireQwenArray(t, hooks["SessionStart"])) != 1 ||
|
if len(requireQwenArray(t, hooks["SessionStart"])) != 1 ||
|
||||||
len(requireQwenArray(t, hooks["PreToolUse"])) != 2 ||
|
len(requireQwenArray(t, hooks["PreToolUse"])) != 2 ||
|
||||||
len(requireQwenArray(t, hooks["PostToolUse"])) != 1 {
|
len(requireQwenArray(t, hooks["PostToolUse"])) != 1 ||
|
||||||
|
len(requireQwenArray(t, hooks["PostToolUseFailure"])) != 1 {
|
||||||
t.Fatalf("Qwen hooks = %#v", hooks)
|
t.Fatalf("Qwen hooks = %#v", hooks)
|
||||||
}
|
}
|
||||||
for _, item := range []struct{ event, path string }{
|
for _, item := range []struct {
|
||||||
{"PreToolUse", fixture.guardPath}, {"PostToolUse", fixture.hookPath},
|
event, path, name string
|
||||||
|
}{
|
||||||
|
{"PreToolUse", fixture.guardPath, qwenGuardHookName},
|
||||||
|
{"PostToolUse", fixture.hookPath, qwenAuditHookName},
|
||||||
|
{"PostToolUseFailure", fixture.hookPath, qwenAuditHookName},
|
||||||
} {
|
} {
|
||||||
|
group := qwenManagedGroup(t, root, item.event, item.path)
|
||||||
|
if len(group) != 1 {
|
||||||
|
t.Fatalf("managed group = %#v", group)
|
||||||
|
}
|
||||||
handler := qwenManagedHandler(t, root, item.event, item.path)
|
handler := qwenManagedHandler(t, root, item.event, item.path)
|
||||||
if len(handler) != 4 || handler["type"] != "command" ||
|
if len(handler) != 5 || handler["type"] != "command" ||
|
||||||
handler["shell"] != expectedQwenShell() || handler["timeout"] != float64(10_000) {
|
handler["name"] != item.name || handler["shell"] != qwenExpectedShell() ||
|
||||||
|
handler["timeout"] != qwenHookTimeout {
|
||||||
t.Fatalf("managed handler = %#v", handler)
|
t.Fatalf("managed handler = %#v", handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if readQwenTestObject(t, workspaceSettings)["owner"] != "workspace" {
|
if readQwenTestFile(t, fixture.workspaceConfig) != workspaceBefore {
|
||||||
t.Fatal("workspace settings changed")
|
t.Fatal("workspace settings changed")
|
||||||
}
|
}
|
||||||
|
if firstGuard != generateGuardScript(qwenAgentKey, qwenTestAgentID, "", false, "") {
|
||||||
|
t.Fatal("guard runtime differs from the canonical generator")
|
||||||
|
}
|
||||||
|
if firstAudit != buildHookScriptWithOutput(
|
||||||
|
qwenAgentKey,
|
||||||
|
qwenTestAgentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
t.Fatal("audit runtime differs from the native-payload generator")
|
||||||
|
}
|
||||||
runtimeConfig := readQwenTestObject(t, fixture.runtimeConfig)
|
runtimeConfig := readQwenTestObject(t, fixture.runtimeConfig)
|
||||||
if runtimeConfig["agent_name"] != qwenAgentKey {
|
if runtimeConfig["agent_name"] != qwenAgentKey ||
|
||||||
|
runtimeConfig["agent_id"] != qwenTestAgentID ||
|
||||||
|
runtimeConfig["base_url"] != fixture.config.BaseURL {
|
||||||
t.Fatalf("runtime config = %#v", runtimeConfig)
|
t.Fatalf("runtime config = %#v", runtimeConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQwenInstallReportsReviewCommand(t *testing.T) {
|
func TestQwenInstallReportsOfficialReviewCommand(t *testing.T) {
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
reader, writer, err := os.Pipe()
|
reader, writer, err := os.Pipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,7 +125,12 @@ func TestQwenInstallReportsReviewCommand(t *testing.T) {
|
|||||||
output, readErr := io.ReadAll(reader)
|
output, readErr := io.ReadAll(reader)
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
if installErr != nil || closeErr != nil || readErr != nil {
|
if installErr != nil || closeErr != nil || readErr != nil {
|
||||||
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
|
t.Fatalf(
|
||||||
|
"capture install output: install=%v close=%v read=%v",
|
||||||
|
installErr,
|
||||||
|
closeErr,
|
||||||
|
readErr,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
text := string(output)
|
text := string(output)
|
||||||
for _, marker := range []string{fixture.configPath, "/hooks"} {
|
for _, marker := range []string{fixture.configPath, "/hooks"} {
|
||||||
@@ -93,109 +140,69 @@ func TestQwenInstallReportsReviewCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQwenHomeUsesOfficialUserEnvironmentPrecedence(t *testing.T) {
|
func legacyQwenGroupForTest(
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
t *testing.T,
|
||||||
firstHome := filepath.Join(filepath.Dir(fixture.homeDir), "first # qwen home")
|
event, scriptPath string,
|
||||||
secondHome := filepath.Join(filepath.Dir(fixture.homeDir), "second qwen home")
|
) map[string]any {
|
||||||
writeOptionalQwenFile(
|
t.Helper()
|
||||||
t,
|
nodePath, err := resolveNodeRuntime()
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatalf("marshal capture path: %v", err)
|
t.Fatalf("resolve Node.js: %v", err)
|
||||||
}
|
}
|
||||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
return map[string]any{
|
||||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
"matcher": "*",
|
||||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
|
"hooks": []any{map[string]any{
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
"type": "command",
|
||||||
t.Fatalf("write capture audit runtime: %v", err)
|
"command": buildQwenCommand(nodePath, scriptPath),
|
||||||
}
|
"shell": qwenExpectedShell(),
|
||||||
settings := readQwenTestObject(t, fixture.configPath)
|
"timeout": qwenHookTimeout,
|
||||||
guard := qwenManagedHandler(t, settings, "PreToolUse", fixture.guardPath)
|
}},
|
||||||
audit := qwenManagedHandler(t, settings, "PostToolUse", fixture.hookPath)
|
"event": event,
|
||||||
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) {
|
func legacyQwenGroupValue(t *testing.T, scriptPath string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
group := legacyQwenGroupForTest(t, "", scriptPath)
|
||||||
|
delete(group, "event")
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenInstallMigratesLegacyPairToCurrentTriple(t *testing.T) {
|
||||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||||
|
lookalike := legacyQwenGroupValue(t, fixture.guardPath)
|
||||||
|
requireQwenObject(t, requireQwenArray(t, lookalike["hooks"])[0])["timeout"] = float64(9_000)
|
||||||
|
settings := map[string]any{
|
||||||
|
"owner": "user",
|
||||||
|
"hooks": map[string]any{
|
||||||
|
"PreToolUse": []any{
|
||||||
|
legacyQwenGroupValue(t, fixture.guardPath),
|
||||||
|
lookalike,
|
||||||
|
},
|
||||||
|
"PostToolUse": []any{legacyQwenGroupValue(t, fixture.hookPath)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeQwenTestObject(t, fixture.configPath, settings)
|
||||||
installQwenFixture(t, fixture)
|
installQwenFixture(t, fixture)
|
||||||
status, err := fixture.plugin.Status()
|
root := readQwenTestObject(t, fixture.configPath)
|
||||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
for _, item := range []struct{ event, path string }{
|
||||||
t.Fatalf("installed status = %#v, %v", status, err)
|
{"PreToolUse", fixture.guardPath},
|
||||||
|
{"PostToolUse", fixture.hookPath},
|
||||||
|
{"PostToolUseFailure", fixture.hookPath},
|
||||||
|
} {
|
||||||
|
group := qwenManagedGroup(t, root, item.event, item.path)
|
||||||
|
if len(group) != 1 {
|
||||||
|
t.Fatalf("legacy group remains for %s: %#v", item.event, group)
|
||||||
}
|
}
|
||||||
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
|
preserved := false
|
||||||
writeQwenTestObject(t, fixture.configPath, settings)
|
for _, groupValue := range requireQwenArray(t, requireQwenObject(t, root["hooks"])["PreToolUse"]) {
|
||||||
if err := os.Remove(fixture.guardPath); err != nil {
|
for _, handlerValue := range requireQwenArray(t, requireQwenObject(t, groupValue)["hooks"]) {
|
||||||
t.Fatalf("remove guard runtime: %v", err)
|
preserved = preserved || requireQwenObject(t, handlerValue)["timeout"] == float64(9_000)
|
||||||
}
|
}
|
||||||
status, err = fixture.plugin.Status()
|
}
|
||||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
if !preserved {
|
||||||
t.Fatalf("missing-runtime status = %#v, %v", status, err)
|
t.Fatal("legacy ownership lookalike was removed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,18 +216,12 @@ func TestQwenUninstallPreservesExternalMutationsAndExactOwnership(t *testing.T)
|
|||||||
group["hooks"] = append(requireQwenArray(t, group["hooks"]), map[string]any{
|
group["hooks"] = append(requireQwenArray(t, group["hooks"]), map[string]any{
|
||||||
"type": "command", "command": "user-command",
|
"type": "command", "command": "user-command",
|
||||||
})
|
})
|
||||||
nodePath, err := resolveNodeRuntime()
|
otherAgent := filepath.Join(filepath.Dir(fixture.agentDir), "agent-10")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve Node.js: %v", err)
|
|
||||||
}
|
|
||||||
hooks := requireQwenObject(t, settings["hooks"])
|
hooks := requireQwenObject(t, settings["hooks"])
|
||||||
hooks["PreToolUse"] = append(requireQwenArray(t, hooks["PreToolUse"]), map[string]any{
|
hooks["PreToolUse"] = append(
|
||||||
"matcher": "*",
|
requireQwenArray(t, hooks["PreToolUse"]),
|
||||||
"hooks": []any{map[string]any{
|
legacyQwenGroupValue(t, filepath.Join(otherAgent, qwenGuardScript)),
|
||||||
"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)
|
writeQwenTestObject(t, fixture.configPath, settings)
|
||||||
before := readQwenTestFile(t, fixture.configPath)
|
before := readQwenTestFile(t, fixture.configPath)
|
||||||
if err := fixture.plugin.Uninstall("other-agent"); err != nil {
|
if err := fixture.plugin.Uninstall("other-agent"); err != nil {
|
||||||
@@ -250,6 +251,9 @@ func TestQwenUninstallPreservesExternalMutationsAndExactOwnership(t *testing.T)
|
|||||||
if _, exists := remainingHooks["PostToolUse"]; exists {
|
if _, exists := remainingHooks["PostToolUse"]; exists {
|
||||||
t.Fatalf("PostToolUse remains: %#v", remainingHooks)
|
t.Fatalf("PostToolUse remains: %#v", remainingHooks)
|
||||||
}
|
}
|
||||||
|
if _, exists := remainingHooks["PostToolUseFailure"]; exists {
|
||||||
|
t.Fatalf("PostToolUseFailure remains: %#v", remainingHooks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) {
|
func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) {
|
||||||
@@ -263,38 +267,3 @@ func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
requireMissingQwenFile(t, fixture.configPath)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -17,7 +16,6 @@ const qwenTestAgentID = "agent-1"
|
|||||||
|
|
||||||
type qwenFixtureOptions struct {
|
type qwenFixtureOptions struct {
|
||||||
settings *string
|
settings *string
|
||||||
skipGuard bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type qwenFixture struct {
|
type qwenFixture struct {
|
||||||
@@ -27,6 +25,10 @@ type qwenFixture struct {
|
|||||||
workspaceDir string
|
workspaceDir string
|
||||||
qwenDir string
|
qwenDir string
|
||||||
configPath string
|
configPath string
|
||||||
|
workspaceConfig string
|
||||||
|
systemConfig string
|
||||||
|
systemDefaults string
|
||||||
|
trustedFolders string
|
||||||
agentDir string
|
agentDir string
|
||||||
guardPath string
|
guardPath string
|
||||||
hookPath string
|
hookPath string
|
||||||
@@ -36,6 +38,7 @@ type qwenFixture struct {
|
|||||||
|
|
||||||
type qwenCommandResult struct {
|
type qwenCommandResult struct {
|
||||||
exitCode int
|
exitCode int
|
||||||
|
stdout string
|
||||||
stderr string
|
stderr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,24 +61,25 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture {
|
|||||||
workspaceDir := filepath.Join(homeDir, "workspace")
|
workspaceDir := filepath.Join(homeDir, "workspace")
|
||||||
qwenDir := filepath.Join(homeDir, ".qwen")
|
qwenDir := filepath.Join(homeDir, ".qwen")
|
||||||
configPath := filepath.Join(qwenDir, "settings.json")
|
configPath := filepath.Join(qwenDir, "settings.json")
|
||||||
|
workspaceConfig := filepath.Join(workspaceDir, ".qwen", "settings.json")
|
||||||
|
systemDirectory := filepath.Join(rootDir, "system")
|
||||||
|
systemConfig := filepath.Join(systemDirectory, "settings.json")
|
||||||
|
systemDefaults := filepath.Join(systemDirectory, "system-defaults.json")
|
||||||
|
trustedFolders := filepath.Join(rootDir, "trustedFolders.json")
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", qwenTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", qwenTestAgentID)
|
||||||
guardPath := filepath.Join(agentDir, qwenGuardScript)
|
guardPath := filepath.Join(agentDir, qwenGuardScript)
|
||||||
hookPath := filepath.Join(agentDir, qwenAuditScript)
|
hookPath := filepath.Join(agentDir, qwenAuditScript)
|
||||||
for _, directory := range []string{workspaceDir, agentDir} {
|
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
|
||||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
t.Fatalf("create fixture workspace: %v", err)
|
||||||
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)
|
writeOptionalQwenFile(t, configPath, options.settings)
|
||||||
t.Setenv("HOME", homeDir)
|
t.Setenv("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
unsetQwenTestEnv(t, "QWEN_HOME")
|
unsetQwenTestEnv(t, "QWEN_HOME")
|
||||||
|
unsetQwenTestEnv(t, "QWEN_RUNTIME_DIR")
|
||||||
|
t.Setenv("QWEN_CODE_SYSTEM_SETTINGS_PATH", systemConfig)
|
||||||
|
t.Setenv("QWEN_CODE_SYSTEM_DEFAULTS_PATH", systemDefaults)
|
||||||
|
t.Setenv("QWEN_CODE_TRUSTED_FOLDERS_PATH", trustedFolders)
|
||||||
previousDirectory, err := os.Getwd()
|
previousDirectory, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read current directory: %v", err)
|
t.Fatalf("read current directory: %v", err)
|
||||||
@@ -94,6 +98,10 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture {
|
|||||||
workspaceDir: workspaceDir,
|
workspaceDir: workspaceDir,
|
||||||
qwenDir: qwenDir,
|
qwenDir: qwenDir,
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
|
workspaceConfig: workspaceConfig,
|
||||||
|
systemConfig: systemConfig,
|
||||||
|
systemDefaults: systemDefaults,
|
||||||
|
trustedFolders: trustedFolders,
|
||||||
agentDir: agentDir,
|
agentDir: agentDir,
|
||||||
guardPath: guardPath,
|
guardPath: guardPath,
|
||||||
hookPath: hookPath,
|
hookPath: hookPath,
|
||||||
@@ -126,16 +134,16 @@ func unsetQwenTestEnv(t *testing.T, name string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeOptionalQwenFile(t *testing.T, path string, source *string) {
|
func writeOptionalQwenFile(t *testing.T, filePath string, source *string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if source == nil {
|
if source == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||||
t.Fatalf("create directory for %s: %v", path, err)
|
t.Fatalf("create directory for %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
|
if err := os.WriteFile(filePath, []byte(*source), 0600); err != nil {
|
||||||
t.Fatalf("write %s: %v", path, err)
|
t.Fatalf("write %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,22 +154,24 @@ func installQwenFixture(t *testing.T, fixture *qwenFixture) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readQwenTestFile(t *testing.T, path string) string {
|
func readQwenTestFile(t *testing.T, filePath string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", path, err)
|
t.Fatalf("read %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readQwenTestObject(t *testing.T, path string) map[string]any {
|
func readQwenTestObject(t *testing.T, filePath string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
object, err := decodeJSONCObject(
|
object, err := decodeJSONCObject(
|
||||||
[]byte(readQwenTestFile(t, path)), "Qwen test settings", false,
|
[]byte(readQwenTestFile(t, filePath)),
|
||||||
|
"Qwen test settings",
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode %s: %v", path, err)
|
t.Fatalf("decode %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
return object
|
return object
|
||||||
}
|
}
|
||||||
@@ -184,8 +194,16 @@ func requireQwenArray(t *testing.T, value any) []any {
|
|||||||
return array
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath string) map[string]any {
|
func qwenManagedGroup(
|
||||||
|
t *testing.T,
|
||||||
|
settings map[string]any,
|
||||||
|
event, scriptPath string,
|
||||||
|
) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
expectedName := qwenAuditHookName
|
||||||
|
if event == "PreToolUse" {
|
||||||
|
expectedName = qwenGuardHookName
|
||||||
|
}
|
||||||
hooks := requireQwenObject(t, settings["hooks"])
|
hooks := requireQwenObject(t, settings["hooks"])
|
||||||
for _, groupValue := range requireQwenArray(t, hooks[event]) {
|
for _, groupValue := range requireQwenArray(t, hooks[event]) {
|
||||||
group := requireQwenObject(t, groupValue)
|
group := requireQwenObject(t, groupValue)
|
||||||
@@ -193,7 +211,7 @@ func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath s
|
|||||||
handler := requireQwenObject(t, handlerValue)
|
handler := requireQwenObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
_, candidate, managed := parseQwenCommand(command)
|
_, candidate, managed := parseQwenCommand(command)
|
||||||
if managed && sameQwenPath(candidate, scriptPath) {
|
if managed && handler["name"] == expectedName && sameQwenPath(candidate, scriptPath) {
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +220,11 @@ func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenManagedHandler(t *testing.T, settings map[string]any, event, scriptPath string) map[string]any {
|
func qwenManagedHandler(
|
||||||
|
t *testing.T,
|
||||||
|
settings map[string]any,
|
||||||
|
event, scriptPath string,
|
||||||
|
) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
group := qwenManagedGroup(t, settings, event, scriptPath)
|
group := qwenManagedGroup(t, settings, event, scriptPath)
|
||||||
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
for _, handlerValue := range requireQwenArray(t, group["hooks"]) {
|
||||||
@@ -217,35 +239,49 @@ func qwenManagedHandler(t *testing.T, settings map[string]any, event, scriptPath
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runQwenHandler(t *testing.T, handler map[string]any, homeDir, payload string) qwenCommandResult {
|
func runQwenHandler(
|
||||||
|
t *testing.T,
|
||||||
|
handler map[string]any,
|
||||||
|
homeDir, payload string,
|
||||||
|
) qwenCommandResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
command := handler["command"].(string)
|
command := handler["command"].(string)
|
||||||
var process *exec.Cmd
|
var process *exec.Cmd
|
||||||
if handler["shell"] == "powershell" {
|
if handler["shell"] == "powershell" {
|
||||||
process = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
|
process = exec.Command(
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-Command",
|
||||||
|
command,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
process = exec.Command("bash", "-c", command)
|
process = exec.Command("bash", "-c", command)
|
||||||
}
|
}
|
||||||
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
||||||
process.Stdin = bytes.NewBufferString(payload)
|
process.Stdin = bytes.NewBufferString(payload)
|
||||||
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
process.Stdout = &stdout
|
||||||
process.Stderr = &stderr
|
process.Stderr = &stderr
|
||||||
err := process.Run()
|
err := process.Run()
|
||||||
|
result := qwenCommandResult{stdout: stdout.String(), stderr: stderr.String()}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return qwenCommandResult{stderr: stderr.String()}
|
return result
|
||||||
}
|
}
|
||||||
var exitError *exec.ExitError
|
var exitError *exec.ExitError
|
||||||
if errors.As(err, &exitError) {
|
if errors.As(err, &exitError) {
|
||||||
return qwenCommandResult{exitCode: exitError.ExitCode(), stderr: stderr.String()}
|
result.exitCode = exitError.ExitCode()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
t.Fatalf("run Qwen Code hook command: %v", err)
|
t.Fatalf("run Qwen Code hook command: %v", err)
|
||||||
return qwenCommandResult{}
|
return qwenCommandResult{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireMissingQwenFile(t *testing.T, path string) {
|
func requireMissingQwenFile(t *testing.T, filePath string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected %s to be missing, got %v", path, err)
|
t.Fatalf("expected %s to be missing, got %v", filePath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +301,16 @@ func requireNoQwenStagingFiles(t *testing.T, root string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectedQwenShell() string {
|
func writeQwenTestObject(t *testing.T, filePath string, value map[string]any) {
|
||||||
if runtime.GOOS == "windows" {
|
t.Helper()
|
||||||
return "powershell"
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode %s: %v", filePath, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||||
|
t.Fatalf("create directory for %s: %v", filePath, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filePath, append(encoded, '\n'), 0600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
return "bash"
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type qwenCLIResult struct {
|
||||||
|
stdout string
|
||||||
|
stderr string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func runQwenCLI(
|
||||||
|
t *testing.T,
|
||||||
|
binary, directory string,
|
||||||
|
environment []string,
|
||||||
|
arguments ...string,
|
||||||
|
) qwenCLIResult {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command(binary, arguments...)
|
||||||
|
command.Dir = directory
|
||||||
|
command.Env = environment
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
err := command.Run()
|
||||||
|
return qwenCLIResult{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQwenCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
binary := filepath.Join(root, "elydora")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
binary += ".exe"
|
||||||
|
}
|
||||||
|
build := exec.Command("go", "build", "-o", binary, ".")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
home := filepath.Join(root, "home")
|
||||||
|
workspace := filepath.Join(home, "workspace")
|
||||||
|
if err := os.MkdirAll(workspace, 0755); err != nil {
|
||||||
|
t.Fatalf("create workspace: %v", err)
|
||||||
|
}
|
||||||
|
privateKeyPath := filepath.Join(root, "private.key")
|
||||||
|
tokenPath := filepath.Join(root, "token.txt")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
privateKeyPath,
|
||||||
|
[]byte("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE\n"),
|
||||||
|
0600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write private key: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(tokenPath, []byte("token-1\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write token: %v", err)
|
||||||
|
}
|
||||||
|
environment := append(
|
||||||
|
os.Environ(),
|
||||||
|
"HOME="+home,
|
||||||
|
"USERPROFILE="+home,
|
||||||
|
"QWEN_HOME=",
|
||||||
|
"QWEN_RUNTIME_DIR=",
|
||||||
|
"QWEN_CODE_SYSTEM_SETTINGS_PATH="+filepath.Join(root, "system.json"),
|
||||||
|
"QWEN_CODE_SYSTEM_DEFAULTS_PATH="+filepath.Join(root, "defaults.json"),
|
||||||
|
"QWEN_CODE_TRUSTED_FOLDERS_PATH="+filepath.Join(root, "trusted.json"),
|
||||||
|
)
|
||||||
|
install := runQwenCLI(
|
||||||
|
t,
|
||||||
|
binary,
|
||||||
|
workspace,
|
||||||
|
environment,
|
||||||
|
"install",
|
||||||
|
"--agent", "qwen",
|
||||||
|
"--org-id", "org-1",
|
||||||
|
"--agent-id", qwenTestCLIAgentID,
|
||||||
|
"--kid", "kid-1",
|
||||||
|
"--private-key-file", privateKeyPath,
|
||||||
|
"--token-file", tokenPath,
|
||||||
|
"--base-url", "https://api.elydora.test",
|
||||||
|
)
|
||||||
|
if install.err != nil {
|
||||||
|
t.Fatalf("install Qwen CLI hooks: %v\n%s", install.err, install.stderr)
|
||||||
|
}
|
||||||
|
settingsPath := filepath.Join(home, ".qwen", "settings.json")
|
||||||
|
settings, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Qwen settings: %v", err)
|
||||||
|
}
|
||||||
|
for _, marker := range []string{
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"elydora-guard",
|
||||||
|
"elydora-audit",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(settings), marker) {
|
||||||
|
t.Fatalf("installed settings are missing %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := runQwenCLI(t, binary, workspace, environment, "status", "--agent", "qwen")
|
||||||
|
if status.err != nil || !strings.Contains(status.stdout, "installed") {
|
||||||
|
t.Fatalf("Qwen status: %v\n%s\n%s", status.err, status.stdout, status.stderr)
|
||||||
|
}
|
||||||
|
uninstall := runQwenCLI(
|
||||||
|
t,
|
||||||
|
binary,
|
||||||
|
workspace,
|
||||||
|
environment,
|
||||||
|
"uninstall",
|
||||||
|
"--agent", "qwen",
|
||||||
|
"--agent-id", qwenTestCLIAgentID,
|
||||||
|
)
|
||||||
|
if uninstall.err != nil {
|
||||||
|
t.Fatalf("uninstall Qwen CLI hooks: %v\n%s", uninstall.err, uninstall.stderr)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
settingsPath,
|
||||||
|
filepath.Join(home, ".elydora", qwenTestCLIAgentID),
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("uninstall left %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qwenTestCLIAgentID = "qwen-e2e-agent"
|
||||||
Reference in new issue
Block a user