diff --git a/AGENTS.md b/AGENTS.md index b3d059b..de69736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Factory Droid `droid@0.175.0` selects one complete user hook source in this order: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Write the current root `{ "hooks": { ... } }` container, accept unknown future event arrays, preserve JSONC syntax, and remove exact Elydora entries from every discovered user source during migration or uninstall. - Resolve Factory `hooksDisabled` through system, project, nested-folder, and matching `settings.local.json` extension-only precedence before user settings. Treat system `allowManagedHooksOnly` as an installation gate, snapshot every read-only policy layer as a transaction precondition, and direct users to `/hooks` for local and organization-managed review. - Generate Factory Droid guard, strict config, canonical private key, and native-payload audit runtime with every affected hook document in one rollback-capable transaction. Windows commands use Factory's native PowerShell contract with explicit `$LASTEXITCODE` propagation. Healthy status requires one exact current `PreToolUse`/`PostToolUse` pair, strict runtime identity, physical files, and exact generated runtime sources. -- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review. -- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. +- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Follow the official `QWEN_HOME` and `QWEN_RUNTIME_DIR` `.env` bootstrap, then resolve system defaults, user, trusted workspace, and system override settings in precedence order. Keep every non-user settings source and `trustedFolders.json` read-only and guard each source snapshot through the write transaction. +- Preserve JSONC comments, line endings, and unknown future hook fields. Reject trailing commas and duplicate keys, validate all 21 current events plus command, HTTP, and prompt handlers, reject settings-defined function handlers, and apply Node.js `RegExp` semantics only to the 15 matcher-enabled events. +- Own one exact named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple with 10,000 millisecond timeouts. Generate guard, strict runtime config, canonical private key, and native-payload audit runtime with user settings in one rollback-capable transaction. Propagate frozen and revoked agents through exit code `2`; require effective hooks to be enabled, one exact triple, physical runtime files, strict identity, and exact generated sources for healthy status; direct users to `/hooks` for review. ## Code Quality diff --git a/README.md b/README.md index a0502ec..238e3c6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,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. -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 diff --git a/cmd/elydora/plugins/qwen.go b/cmd/elydora/plugins/qwen.go index 5bd123b..51679a1 100644 --- a/cmd/elydora/plugins/qwen.go +++ b/cmd/elydora/plugins/qwen.go @@ -1,112 +1,115 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) +import "fmt" +// QwenPlugin manages Qwen Code native user hooks. type QwenPlugin struct { rename renameFunc } -type qwenRuntimeConfig struct { - OrgID string `json:"org_id"` - AgentID string `json:"agent_id"` - KID string `json:"kid"` - BaseURL string `json:"base_url"` - Token string `json:"token"` - AgentName string `json:"agent_name"` +// ManagesGuardRuntime reports that Qwen commits its guard, audit runtime, and +// settings in one transaction. +func (p *QwenPlugin) ManagesGuardRuntime() bool { + return true +} + +// 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 { - runtimeRoot, agentDirectory, err := qwenAgentDirectory(config.AgentID) + sources, err := readQwenSources() if err != nil { return err } - document, err := readQwenDocument() + paths, nodePath, err := preflightQwenInstallation(config, sources) if err != nil { return err } - expectedGuard := filepath.Join(agentDirectory, qwenGuardScript) - if !sameQwenPath(config.GuardScriptPath, expectedGuard) { - return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard) - } - guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + rendered, err := renderQwenDocument( + sources.user, + "", + paths.runtimeRoot, + qwenInstalledGroups(nodePath, paths.guardPath, paths.auditPath), + ) if err != nil { return err } - if !guardExists { - return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) - } - nodePath, err := resolveNodeRuntime() + prepared, err := prepareQwenInstallation(config, sources, rendered) if err != nil { return err } - if err := validateQwenRegexes(nodePath, document.hooks); err != nil { + if err := commitQwenInstallation(prepared, p.rename); err != nil { return err } - auditPath := filepath.Join(agentDirectory, qwenAuditScript) - rendered, err := renderQwenDocument(document, "", runtimeRoot, map[string]map[string]any{ - "PreToolUse": buildQwenGroup(nodePath, config.GuardScriptPath), - "PostToolUse": buildQwenGroup(nodePath, auditPath), - }) - if err != nil { - return err - } - changes, err := prepareQwenInstallationChanges(config, agentDirectory, auditPath, rendered) - if err != nil { - return err - } - if err := writeChanges(changes, "Write Qwen Code installation", p.rename); err != nil { - return err - } - fmt.Printf(" Qwen Code: user hooks installed at %s\n", document.filePath) - fmt.Println(" Qwen Code: run /hooks to review the Elydora hook changes.") + fmt.Printf("Qwen Code hooks: %s\n", sources.user.filePath) + fmt.Println("Qwen Code verification: run /hooks.") return nil } func (p *QwenPlugin) Uninstall(agentID string) error { + sources, err := readQwenSources() + if err != nil { + return err + } runtimeRoot, err := qwenRuntimeRoot() if err != nil { return err } - document, err := readQwenDocument() + rendered, err := renderQwenDocument( + sources.user, + agentID, + runtimeRoot, + map[string]map[string]any{}, + ) if err != nil { return err } - if !document.exists { + if !rendered.changed { return nil } - rendered, err := renderQwenDocument(document, agentID, runtimeRoot, nil) + change, preconditions, err := prepareQwenUninstall(sources, rendered) if err != nil { return err } - change, err := prepareRenderedQwenChange(rendered) - if err != nil { - return err - } - return writeChanges([]*fileChange{change}, "Write Qwen Code settings", p.rename) + return commitQwenUninstall(change, preconditions, p.rename) } func (p *QwenPlugin) Status() (PluginStatus, error) { entry := SupportedAgents[qwenAgentKey] status := PluginStatus{AgentName: qwenAgentKey, DisplayName: entry.Name} - document, err := readQwenDocument() + sources, err := readQwenSources() if err != nil { return status, err } - status.ConfigPath = document.filePath - if document.hooksDisabled { - return status, nil - } + status.ConfigPath = sources.user.filePath runtimeRoot, err := qwenRuntimeRoot() if err != nil { return status, err } - contracts := qwenRuntimeContracts(document.hooks, runtimeRoot) - status.HookConfigured = len(contracts) > 0 + contracts := qwenRuntimeContracts(sources.user.hooks, runtimeRoot) + status.HookConfigured = !sources.disableControl.disabled && len(contracts) > 0 if !status.HookConfigured { return status, nil } @@ -117,57 +120,3 @@ func (p *QwenPlugin) Status() (PluginStatus, error) { status.Installed = status.HookScriptExists 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 -} diff --git a/cmd/elydora/plugins/qwen_command.go b/cmd/elydora/plugins/qwen_command.go new file mode 100644 index 0000000..561b6e1 --- /dev/null +++ b/cmd/elydora/plugins/qwen_command.go @@ -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 +} diff --git a/cmd/elydora/plugins/qwen_config.go b/cmd/elydora/plugins/qwen_config.go index a8f6e6b..9c5fe2a 100644 --- a/cmd/elydora/plugins/qwen_config.go +++ b/cmd/elydora/plugins/qwen_config.go @@ -6,15 +6,25 @@ import ( "sort" ) +const ( + qwenSystemDefaultsKind = "system-defaults" + qwenUserKind = "user" + qwenWorkspaceKind = "workspace" + qwenSystemKind = "system" +) + type qwenDocument struct { - filePath string - exists bool - raw []byte - root map[string]any - hooks qwenHookSettings - hasHooksContainer bool - hooksDisabled bool - ownedFile bool + kind string + filePath string + exists bool + raw []byte + snapshot *managedFileSnapshot + root map[string]any + hooks qwenHookSettings + hasHooksContainer bool + disableAllHooks *bool + folderTrustEnabled *bool + ownedFile bool } type qwenRenderedDocument struct { @@ -24,23 +34,79 @@ type qwenRenderedDocument struct { remove bool } -func qwenDocumentLabel(filePath string) string { - return fmt.Sprintf("Qwen Code settings at %s", filePath) +func qwenSourceLabel(kind string) string { + 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) { - label := qwenDocumentLabel(filePath) +func qwenDocumentLabel(document *qwenDocument) string { + 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) if err != nil { return nil, err } - disabled := false + var disabled *bool if value, hasFlag := root["disableAllHooks"]; hasFlag { flag, ok := value.(bool) if !ok { return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label) } - disabled = flag + disabled = &flag } hooks := qwenHookSettings{} hooksValue, hasHooks := root["hooks"] @@ -50,15 +116,25 @@ func parseQwenDocument(exists bool, filePath string, raw []byte) (*qwenDocument, return nil, err } } + folderTrustEnabled, err := readQwenFolderTrust(root, label) + if err != nil { + return nil, err + } return &qwenDocument{ - filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), root: root, - hooks: hooks, hasHooksContainer: hasHooks, hooksDisabled: disabled, - ownedFile: bytes.HasPrefix(raw, []byte(qwenOwnedFileMarker)), + kind: kind, filePath: filePath, exists: exists, + raw: append([]byte(nil), raw...), snapshot: snapshot, root: root, + hooks: hooks, hasHooksContainer: hasHooks, + disableAllHooks: disabled, folderTrustEnabled: folderTrustEnabled, + ownedFile: kind == qwenUserKind && bytes.HasPrefix(raw, []byte(qwenOwnedFileMarker)), }, nil } -func createOwnedQwenDocument(filePath string) (*qwenDocument, error) { - return parseQwenDocument(false, filePath, []byte(qwenOwnedFileMarker+"\n{}\n")) +func createQwenDocument(kind, filePath string) (*qwenDocument, error) { + raw := []byte("{}\n") + if kind == qwenUserKind { + raw = []byte(qwenOwnedFileMarker + "\n{}\n") + } + return parseQwenDocument(kind, false, filePath, raw, nil) } func renderQwenDocument( @@ -66,13 +142,16 @@ func renderQwenDocument( agentID, runtimeRoot string, additions map[string]map[string]any, ) (*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) if err != nil { return nil, err } removals := managedQwenRemovals(document.hooks, agentID, runtimeRoot) - for _, event := range qwenToolEvents { + for _, event := range qwenManagedEvents { eventRemovals := make([]qwenManagedRemoval, 0) for _, removal := range removals { if removal.event == event { @@ -98,7 +177,13 @@ func renderQwenDocument( } } 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 { 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 { return nil, err } @@ -118,12 +209,18 @@ func renderQwenDocument( return nil, err } } - for _, event := range qwenToolEvents { + for _, event := range qwenManagedEvents { group, exists := additions[event] if !exists { 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 { return nil, err } @@ -140,13 +237,22 @@ func renderQwenDocument( } } 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 { return nil, err } remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0 return &qwenRenderedDocument{ - document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove, + document: document, + changed: remove || !bytes.Equal(next, document.raw), + next: next, + remove: remove, }, nil } diff --git a/cmd/elydora/plugins/qwen_contract.go b/cmd/elydora/plugins/qwen_contract.go index 482eb5f..a6656d9 100644 --- a/cmd/elydora/plugins/qwen_contract.go +++ b/cmd/elydora/plugins/qwen_contract.go @@ -1,8 +1,6 @@ package plugins import ( - "fmt" - "os" "path/filepath" "runtime" "sort" @@ -13,12 +11,25 @@ const ( qwenAgentKey = "qwen" qwenGuardScript = "guard.js" qwenAuditScript = "hook.js" + qwenGuardHookName = "elydora-guard" + qwenAuditHookName = "elydora-audit" qwenHookTimeout = float64(10_000) 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 @@ -35,214 +46,208 @@ type qwenRuntimeContract struct { auditPath string } -func buildQwenCommand(nodePath, scriptPath string) string { +func qwenExpectedShell() string { 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 { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - -func buildQwenGroup(nodePath, scriptPath string) map[string]any { - shell := "bash" - if runtime.GOOS == "windows" { - shell = "powershell" - } +func buildQwenGroup(nodePath, scriptPath, name string) map[string]any { return map[string]any{ - "matcher": "*", "hooks": []any{map[string]any{ - "type": "command", "command": buildQwenCommand(nodePath, scriptPath), - "shell": shell, "timeout": qwenHookTimeout, + "type": "command", + "name": name, + "command": buildQwenCommand(nodePath, scriptPath), + "shell": qwenExpectedShell(), + "timeout": qwenHookTimeout, }}, } } -func parseQwenCommand(command string) (string, string, bool) { - start := 0 - if runtime.GOOS == "windows" { - if !strings.HasPrefix(command, "& ") { - return "", "", false - } - start = 2 - } - executable, next, ok := readQwenQuotedArgument(command, start) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readQwenQuotedArgument(command, next+1) - expectedSuffix := "" - if runtime.GOOS == "windows" { - expectedSuffix = "; exit $LASTEXITCODE" - } - return executable, script, ok && command[end:] == expectedSuffix && executable != "" && script != "" -} - -func readQwenQuotedArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if runtime.GOOS == "windows" && strings.HasPrefix(command[index:], "''") { - value.WriteByte('\'') - index += 2 - continue - } - if runtime.GOOS != "windows" && strings.HasPrefix(command[index:], qwenPOSIXApostrophe) { - value.WriteByte('\'') - index += len(qwenPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func sameQwenPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameQwenAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func qwenRuntimeRoot() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(home, ".elydora"), nil -} - -func managedQwenAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) { - if len(handler) != 4 || handler["type"] != "command" || handler["timeout"] != qwenHookTimeout { - return "", false - } - expectedShell := "bash" - if runtime.GOOS == "windows" { - expectedShell = "powershell" - } - if handler["shell"] != expectedShell { - return "", false +func currentQwenReference( + handler map[string]any, + scriptName, hookName, runtimeRoot string, +) (qwenRuntimeReference, bool) { + if len(handler) != 5 || handler["type"] != "command" || + handler["name"] != hookName || handler["shell"] != qwenExpectedShell() || + handler["timeout"] != qwenHookTimeout { + return qwenRuntimeReference{}, false } command, ok := handler["command"].(string) if !ok { - return "", false + return qwenRuntimeReference{}, false } - executable, scriptPath, ok := parseQwenCommand(command) - if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || - !isQwenNodeExecutable(executable) || !sameQwenFileName(filepath.Base(scriptPath), scriptName) { - return "", false - } - agentDirectory := filepath.Dir(scriptPath) - if !sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) { - return "", false - } - agentID := filepath.Base(agentDirectory) - return agentID, agentID != "" && agentID != "." && agentID != ".." + return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot) } -func isQwenNodeExecutable(path string) bool { - name := filepath.Base(path) - if runtime.GOOS == "windows" { - return strings.EqualFold(name, "node.exe") +func legacyQwenReference( + handler map[string]any, + scriptName, runtimeRoot string, +) (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 { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) +func managedQwenReference( + handler map[string]any, + 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) - for _, contract := range []struct{ event, script string }{ - {"PreToolUse", qwenGuardScript}, {"PostToolUse", qwenAuditScript}, - } { + for _, contract := range qwenEventContracts { groups, _ := settings[contract.event].([]any) for groupIndex, groupValue := range groups { group := groupValue.(map[string]any) handlers := group["hooks"].([]any) indexes := make([]int, 0) for handlerIndex, handlerValue := range handlers { - managedID, managed := managedQwenAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot) - if managed && (agentID == "" || sameQwenAgentID(managedID, agentID)) { + reference, managed := managedQwenReference( + handlerValue.(map[string]any), + contract.script, + contract.name, + runtimeRoot, + true, + ) + if managed && (agentID == "" || sameQwenAgentID(reference.agentID, agentID)) { indexes = append(indexes, handlerIndex) } } if len(indexes) == 0 { continue } - _, hasMatcher := group["matcher"] - _, hasHooks := group["hooks"] - exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) - removals = append(removals, qwenManagedRemoval{contract.event, groupIndex, indexes, exactGroup}) + removeGroup := (exactCurrentQwenGroup(group) || exactLegacyQwenGroup(group)) && + len(indexes) == len(handlers) + removals = append(removals, qwenManagedRemoval{ + event: contract.event, groupIndex: groupIndex, + handlerIndexes: indexes, removeGroup: removeGroup, + }) } } return removals } -func qwenRuntimeContracts(settings qwenHookSettings, runtimeRoot string) []qwenRuntimeContract { - guards := managedQwenIDs(settings, "PreToolUse", qwenGuardScript, runtimeRoot) - audits := managedQwenIDs(settings, "PostToolUse", qwenAuditScript, runtimeRoot) - ids := make([]string, 0, len(guards)) - for guardID := range guards { - for auditID := range audits { - if sameQwenAgentID(guardID, auditID) { - ids = append(ids, guardID) - break +func normalizedQwenAgentID(agentID string) string { + if runtime.GOOS == "windows" { + 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) } } - sort.Slice(ids, func(left, right int) bool { - if runtime.GOOS == "windows" { - return strings.ToLower(ids[left]) < strings.ToLower(ids[right]) + 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{ - 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 } -func managedQwenIDs(settings qwenHookSettings, event, script, runtimeRoot string) map[string]bool { - ids := map[string]bool{} - groups, _ := settings[event].([]any) - for _, groupValue := range groups { - for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) { - if agentID, ok := managedQwenAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok { - ids[agentID] = true - } - } - } - return ids +func qwenRuntimeRoot() (string, error) { + return AgentRuntimeRoot() +} + +func qwenContractDirectory(contract qwenRuntimeContract) string { + return filepath.Dir(contract.guardPath) } diff --git a/cmd/elydora/plugins/qwen_installation.go b/cmd/elydora/plugins/qwen_installation.go new file mode 100644 index 0000000..6cfd2e5 --- /dev/null +++ b/cmd/elydora/plugins/qwen_installation.go @@ -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..., + ) +} diff --git a/cmd/elydora/plugins/qwen_io.go b/cmd/elydora/plugins/qwen_io.go index f700c75..da132b2 100644 --- a/cmd/elydora/plugins/qwen_io.go +++ b/cmd/elydora/plugins/qwen_io.go @@ -1,148 +1,264 @@ package plugins import ( - "encoding/json" + "bytes" "fmt" - "os" "path/filepath" "regexp" + "sort" + "strings" ) var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`) -func defaultQwenHome() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) +func readQwenRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err } - 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) { - 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) - } - } - } +func requireQwenRuntimeString( + config map[string]any, + field, configPath string, +) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf( + "Elydora runtime config %s is invalid: %s", + field, + configPath, + ) } - 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 + return value, nil } -func qwenHomeFromEnvFile(filePath string) (string, bool, error) { - raw, exists, err := readOptionalFile(filePath, "Qwen home environment") - if err != nil || !exists { - return "", false, err - } - value := parseDotenv(raw)["QWEN_HOME"] - if value == "" { - return "", false, nil - } - resolved, err := resolveQwenStoragePath(value) - return resolved, err == nil, err -} - -func resolveQwenHome() (string, error) { - defaultHome, err := defaultQwenHome() - if err != nil { - return "", err - } - value, explicit := os.LookupEnv("QWEN_HOME") - initialHome := defaultHome - if value != "" { - initialHome, err = resolveQwenStoragePath(value) - if err != nil { - return "", err - } - } - if explicit { - return initialHome, nil - } - for _, candidate := range []string{ - filepath.Join(initialHome, ".env"), filepath.Join(filepath.Dir(initialHome), ".env"), - } { - if discovered, exists, readErr := qwenHomeFromEnvFile(candidate); readErr != nil { - return "", readErr - } else if exists { - return discovered, nil - } - } - return initialHome, nil -} - -func readQwenDocument() (*qwenDocument, error) { - home, err := resolveQwenHome() - if err != nil { - return nil, err - } - configPath := filepath.Join(home, "settings.json") - raw, exists, err := readOptionalFile(configPath, "Qwen Code settings") - if err != nil { - return nil, err - } - if !exists { - return createOwnedQwenDocument(configPath) - } - return parseQwenDocument(true, configPath, raw) -} - -func prepareRenderedQwenChange(rendered *qwenRenderedDocument) (*fileChange, error) { - if rendered == nil || !rendered.changed { - return nil, nil - } - return prepareSourceChange( - rendered.document.filePath, - "Qwen Code settings", - rendered.document.raw, - rendered.document.exists, - rendered.next, - os.FileMode(0600), - rendered.remove, +func 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) { for _, contract := range contracts { - configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") - raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") + exists, err := qwenRuntimeContractExists(contract) if err != nil { return false, err } - if !exists { - continue - } - var config map[string]any - if err := json.Unmarshal(raw, &config); err != nil { - return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) - } - agentID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != qwenAgentKey || !sameQwenAgentID(agentID, contract.agentID) { - continue - } - guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") - if err != nil { - return false, err - } - auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") - if err != nil { - return false, err - } - if guardExists && auditExists { + if exists { return true, nil } } diff --git a/cmd/elydora/plugins/qwen_recovery_test.go b/cmd/elydora/plugins/qwen_recovery_test.go index 113f4bd..be917d4 100644 --- a/cmd/elydora/plugins/qwen_recovery_test.go +++ b/cmd/elydora/plugins/qwen_recovery_test.go @@ -8,27 +8,70 @@ import ( "testing" ) -func TestQwenInstallRejectsMalformedSettingsBeforeWrites(t *testing.T) { +func TestQwenInstallRejectsMalformedKnownSettingsBeforeWrites(t *testing.T) { tests := []struct { name, source, pattern string }{ - {"malformed", "{ malformed", "parse Qwen Code settings"}, + {"malformed", "{ malformed", "parse Qwen Code user settings"}, {"non-object", "[]", "JSON object"}, {"trailing comma", `{ "owner": true, }`, "trailing comma"}, {"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 security", `{ "security": [] }`, "security"}, + { + "invalid folder trust", + `{ "security": { "folderTrust": { "enabled": "yes" } } }`, + "must be a boolean", + }, {"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 group", `{ "hooks": { "PreToolUse": [null] } }`, "must be an object"}, - {"invalid matcher", `{ "hooks": { "PreToolUse": [{ "matcher": "[", "hooks": [] }] } }`, "regular expression"}, - {"invalid sequential", `{ "hooks": { "PreToolUse": [{ "sequential": "yes", "hooks": [] }] } }`, "must be a boolean"}, - {"null handlers", `{ "hooks": { "PreToolUse": [{ "hooks": null }] } }`, "hooks array"}, - {"missing command", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command" }] }] } }`, "non-empty string"}, - {"missing url", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "http" }] }] } }`, "non-empty string"}, - {"invalid handler type", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "function", "command": "x" }] }] } }`, "command"}, - {"invalid timeout", `{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "x", "timeout": "ten" }] }] } }`, "finite number"}, + { + "invalid matcher type", + `{ "hooks": { "PreToolUse": [{ "matcher": 1, "hooks": [] }] } }`, + "matcher must be a string", + }, + { + "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 { t.Run(test.name, func(t *testing.T) { @@ -40,68 +83,156 @@ func TestQwenInstallRejectsMalformedSettingsBeforeWrites(t *testing.T) { if readQwenTestFile(t, fixture.configPath) != test.source { t.Fatal("failed install changed Qwen Code settings") } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + for _, path := range []string{ + fixture.guardPath, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } { requireMissingQwenFile(t, path) } }) } } -func TestQwenInstallValidatesRoutingAndManagedPathsBeforeWrites(t *testing.T) { - t.Run("unreadable home env", func(t *testing.T) { - fixture := prepareQwenFixture(t, qwenFixtureOptions{}) - envPath := filepath.Join(fixture.qwenDir, ".env") - if err := os.MkdirAll(envPath, 0755); err != nil { - t.Fatalf("create unreadable env path: %v", err) +func TestQwenPreservesFutureEventsAndLimitsRegexSemantics(t *testing.T) { + settings := `{ + "hooks": { + "FutureEvent": { "shape": "future" }, + "UserPromptSubmit": [{ "matcher": "[", "hooks": [] }] + } +}` + fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)}) + 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) } - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "Qwen home environment") { - t.Fatalf("install error = %v", err) - } - requireMissingQwenFile(t, fixture.configPath) - requireMissingQwenFile(t, fixture.runtimeConfig) - }) + } +} +func TestQwenInstallRejectsMalformedReadOnlySourceBeforeWrites(t *testing.T) { tests := []struct { - name string - mutate func(*qwenFixture) - pattern string + name string + path func(*qwenFixture) string }{ - { - name: "missing guard", - mutate: func(fixture *qwenFixture) { - if err := os.Remove(fixture.guardPath); err != nil { - t.Fatalf("remove guard: %v", err) - } - }, - pattern: "guard runtime is missing", - }, - { - name: "invalid agent id", - mutate: func(fixture *qwenFixture) { - fixture.config.AgentID = "../agent" - }, - pattern: "single non-empty path segment", - }, - { - name: "guard outside managed directory", - mutate: func(fixture *qwenFixture) { - fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js") - }, - pattern: "managed agent directory", - }, + {"system defaults", func(f *qwenFixture) string { return f.systemDefaults }}, + {"workspace", func(f *qwenFixture) string { return f.workspaceConfig }}, + {"system override", func(f *qwenFixture) string { return f.systemConfig }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { fixture := prepareQwenFixture(t, qwenFixtureOptions{}) - test.mutate(fixture) + filePath := test.path(fixture) + writeOptionalQwenFile(t, filePath, qwenString("{ malformed")) err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), test.pattern) { - t.Fatalf("install error = %v, want substring %q", err, test.pattern) + if err == nil || !strings.Contains(err.Error(), "parse Qwen Code") { + 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) } + requireNoQwenStagingFiles(t, fixture.homeDir) }) } } @@ -120,13 +251,18 @@ func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { return os.Rename(source, destination) } 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) } if readQwenTestFile(t, fixture.configPath) != original { t.Fatal("transaction rollback did not restore Qwen Code settings") } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + for _, path := range []string{ + fixture.guardPath, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } { requireMissingQwenFile(t, path) } requireNoQwenStagingFiles(t, fixture.homeDir) @@ -152,7 +288,8 @@ func TestQwenStatusSurfacesMalformedRuntimeConfig(t *testing.T) { if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { t.Fatalf("corrupt runtime config: %v", err) } - if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "parse Elydora runtime config") { t.Fatalf("status error = %v", err) } } diff --git a/cmd/elydora/plugins/qwen_runtime_test.go b/cmd/elydora/plugins/qwen_runtime_test.go new file mode 100644 index 0000000..dd983a3 --- /dev/null +++ b/cmd/elydora/plugins/qwen_runtime_test.go @@ -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) + } + }) + } +} diff --git a/cmd/elydora/plugins/qwen_schema.go b/cmd/elydora/plugins/qwen_schema.go index 5fb66b6..20539a4 100644 --- a/cmd/elydora/plugins/qwen_schema.go +++ b/cmd/elydora/plugins/qwen_schema.go @@ -11,18 +11,46 @@ import ( "time" ) -var qwenEventNames = map[string]bool{ - "PreToolUse": true, "PostToolUse": true, "PostToolUseFailure": true, - "PostToolBatch": true, "Notification": true, "UserPromptSubmit": true, - "UserPromptExpansion": true, "SessionStart": true, "Stop": true, - "MessageDisplay": true, "SubagentStart": true, "SubagentStop": true, - "PreCompact": true, "PostCompact": true, "SessionEnd": true, - "PermissionRequest": true, "PermissionDenied": true, "StopFailure": true, - "TodoCreated": true, "TodoCompleted": true, "InstructionsLoaded": true, -} +var qwenEventNames = stringSet( + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "UserPromptSubmit", + "UserPromptExpansion", + "SessionStart", + "Stop", + "MessageDisplay", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionEnd", + "PermissionRequest", + "PermissionDenied", + "StopFailure", + "TodoCreated", + "TodoCompleted", + "InstructionsLoaded", +) -var qwenConfigFields = map[string]bool{ - "enabled": true, "disabled": true, "notifications": true, +var qwenRegexMatcherEvents = [...]string{ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "PermissionDenied", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionStart", + "SessionEnd", + "StopFailure", + "Notification", + "InstructionsLoaded", + "UserPromptExpansion", } 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) } settings := make(qwenHookSettings, len(object)) - for key, item := range object { - if qwenConfigFields[key] { - settings[key] = item + for event, item := range object { + if _, known := qwenEventNames[event]; !known { + settings[event] = item continue } - if !qwenEventNames[key] { - return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key) - } groups, ok := item.([]any) if !ok { - return nil, fmt.Errorf(`%s field %q must be an array`, label, key) + return nil, fmt.Errorf(`%s field %q must be an array`, label, event) } 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 } } - settings[key] = item + settings[event] = item } return settings, nil } @@ -94,7 +119,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error { } kind, _ := handler["type"].(string) 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 { number, ok := timeout.(float64) @@ -111,7 +136,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error { case "command": command, ok := handler["command"].(string) 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 { return err @@ -125,7 +150,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error { case "http": url, ok := handler["url"].(string) 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 { return err @@ -145,7 +170,7 @@ func validateQwenHandler(value any, groupLabel string, handlerIndex int) error { case "prompt": prompt, ok := handler["prompt"].(string) 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 { return err @@ -179,11 +204,11 @@ func optionalQwenStringMap(value map[string]any, key, label string) error { } entries, ok := item.(map[string]any) 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 { 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 @@ -198,25 +223,41 @@ func allQwenStrings(values []any) bool { return true } -func validateQwenRegexes(nodePath string, settings qwenHookSettings) error { +func collectQwenRegexes(sources []qwenHookSettings) []qwenRegexEntry { entries := make([]qwenRegexEntry, 0) - for event, value := range settings { - if qwenConfigFields[event] { - continue - } - for index, groupValue := range value.([]any) { - group := groupValue.(map[string]any) - matcher, ok := group["matcher"].(string) - if ok && strings.TrimSpace(matcher) != "" && strings.TrimSpace(matcher) != "*" { + for sourceIndex, settings := range sources { + for _, event := range qwenRegexMatcherEvents { + groups, _ := settings[event].([]any) + for groupIndex, groupValue := range groups { + group := groupValue.(map[string]any) + matcher, ok := group["matcher"].(string) + if !ok || strings.TrimSpace(matcher) == "" || strings.TrimSpace(matcher) == "*" { + continue + } 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 { return nil } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } payload, err := json.Marshal(entries) if err != nil { return fmt.Errorf("encode Qwen Code regular expressions: %w", err) @@ -244,7 +285,10 @@ for (const entry of entries) { if message == "" { 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 } diff --git a/cmd/elydora/plugins/qwen_schema_test.go b/cmd/elydora/plugins/qwen_schema_test.go new file mode 100644 index 0000000..1caf7fe --- /dev/null +++ b/cmd/elydora/plugins/qwen_schema_test.go @@ -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-(?.+)", + "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": "(?read|write)", "hooks": [] }] + } +}`), + }) + installQwenFixture(t, fixture) + }) + + t.Run("Python named capture", func(t *testing.T) { + fixture := prepareQwenFixture(t, qwenFixtureOptions{ + settings: qwenString(`{ + "hooks": { + "PreToolUse": [{ "matcher": "(?Pread|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) + }) + } +} diff --git a/cmd/elydora/plugins/qwen_security_test.go b/cmd/elydora/plugins/qwen_security_test.go new file mode 100644 index 0000000..84a4a86 --- /dev/null +++ b/cmd/elydora/plugins/qwen_security_test.go @@ -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) + } +} diff --git a/cmd/elydora/plugins/qwen_sources.go b/cmd/elydora/plugins/qwen_sources.go new file mode 100644 index 0000000..d23fa24 --- /dev/null +++ b/cmd/elydora/plugins/qwen_sources.go @@ -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, + ) +} diff --git a/cmd/elydora/plugins/qwen_sources_test.go b/cmd/elydora/plugins/qwen_sources_test.go new file mode 100644 index 0000000..0e14c9c --- /dev/null +++ b/cmd/elydora/plugins/qwen_sources_test.go @@ -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 +} diff --git a/cmd/elydora/plugins/qwen_test.go b/cmd/elydora/plugins/qwen_test.go index fcfa9bb..7b608af 100644 --- a/cmd/elydora/plugins/qwen_test.go +++ b/cmd/elydora/plugins/qwen_test.go @@ -1,7 +1,6 @@ package plugins import ( - "encoding/json" "io" "os" "path/filepath" @@ -10,36 +9,55 @@ import ( "testing" ) -func TestQwenRegistryUsesOfficialUserSettings(t *testing.T) { +func TestQwenRegistryAndRuntimeOwnership(t *testing.T) { 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) } - if _, ok := NewPlugin(qwenAgentKey).(*QwenPlugin); !ok { + plugin, ok := NewPlugin(qwenAgentKey).(*QwenPlugin) + if !ok { 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" + " // Keep this user preference.\r\n" + " \"theme\": \"GitHub\",\r\n" + " \"hooks\": {\r\n" + + " \"FutureEvent\": { \"opaque\": true },\r\n" + " \"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"session-hook\" }] }],\r\n" + " \"PreToolUse\": [{ \"matcher\": \"read_file\", \"hooks\": [{ \"type\": \"command\", \"command\": \"user-hook\" }] }]\r\n" + " }\r\n" + "}\r\n" fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)}) - workspaceSettings := filepath.Join(fixture.workspaceDir, ".qwen", "settings.json") - writeOptionalQwenFile(t, workspaceSettings, qwenString("{ \"owner\": \"workspace\" }\n")) + writeOptionalQwenFile( + t, + fixture.workspaceConfig, + qwenString("{ \"owner\": \"workspace\" }\n"), + ) + workspaceBefore := readQwenTestFile(t, fixture.workspaceConfig) 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) - if readQwenTestFile(t, fixture.configPath) != first { - t.Fatal("idempotent install changed Qwen Code settings") + if readQwenTestFile(t, fixture.configPath) != firstSettings || + 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") { - t.Fatalf("settings comments or line endings were lost:\n%s", first) + if !strings.Contains(firstSettings, "Keep this user preference") || + !strings.Contains(firstSettings, "\r\n") || + !strings.Contains(firstSettings, "FutureEvent") { + t.Fatalf("settings source fidelity was lost:\n%s", firstSettings) } root := readQwenTestObject(t, fixture.configPath) if root["theme"] != "GitHub" { @@ -48,28 +66,52 @@ func TestQwenInstallPreservesJSONCommentsAndIsIdempotent(t *testing.T) { hooks := requireQwenObject(t, root["hooks"]) if len(requireQwenArray(t, hooks["SessionStart"])) != 1 || 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) } - for _, item := range []struct{ event, path string }{ - {"PreToolUse", fixture.guardPath}, {"PostToolUse", fixture.hookPath}, + for _, item := range []struct { + 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) - if len(handler) != 4 || handler["type"] != "command" || - handler["shell"] != expectedQwenShell() || handler["timeout"] != float64(10_000) { + if len(handler) != 5 || handler["type"] != "command" || + handler["name"] != item.name || handler["shell"] != qwenExpectedShell() || + handler["timeout"] != qwenHookTimeout { t.Fatalf("managed handler = %#v", handler) } } - if readQwenTestObject(t, workspaceSettings)["owner"] != "workspace" { + if readQwenTestFile(t, fixture.workspaceConfig) != workspaceBefore { 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) - 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) } } -func TestQwenInstallReportsReviewCommand(t *testing.T) { +func TestQwenInstallReportsOfficialReviewCommand(t *testing.T) { fixture := prepareQwenFixture(t, qwenFixtureOptions{}) reader, writer, err := os.Pipe() if err != nil { @@ -83,7 +125,12 @@ func TestQwenInstallReportsReviewCommand(t *testing.T) { output, readErr := io.ReadAll(reader) _ = reader.Close() if installErr != nil || closeErr != nil || readErr != nil { - t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr) + t.Fatalf( + "capture install output: install=%v close=%v read=%v", + installErr, + closeErr, + readErr, + ) } text := string(output) for _, marker := range []string{fixture.configPath, "/hooks"} { @@ -93,109 +140,69 @@ func TestQwenInstallReportsReviewCommand(t *testing.T) { } } -func TestQwenHomeUsesOfficialUserEnvironmentPrecedence(t *testing.T) { - fixture := prepareQwenFixture(t, qwenFixtureOptions{}) - firstHome := filepath.Join(filepath.Dir(fixture.homeDir), "first # qwen home") - secondHome := filepath.Join(filepath.Dir(fixture.homeDir), "second qwen home") - writeOptionalQwenFile( - t, - filepath.Join(fixture.qwenDir, ".env"), - qwenString("export QWEN_HOME = \""+firstHome+"\" # selected by Qwen\n"), - ) - writeOptionalQwenFile(t, filepath.Join(fixture.homeDir, ".env"), qwenString("QWEN_HOME="+secondHome+"\n")) - installQwenFixture(t, fixture) - selected := filepath.Join(firstHome, "settings.json") - qwenManagedHandler(t, readQwenTestObject(t, selected), "PreToolUse", fixture.guardPath) - requireMissingQwenFile(t, filepath.Join(secondHome, "settings.json")) - requireMissingQwenFile(t, fixture.configPath) - status, err := fixture.plugin.Status() - if err != nil || status.ConfigPath != selected || !status.Installed { - t.Fatalf("Qwen status = %#v, %v", status, err) - } -} - -func TestQwenExplicitHomeSupportsRelativeTildeAndEmptyValues(t *testing.T) { - tests := []struct { - name, value string - expected func(*qwenFixture) string - }{ - {"relative", "relative-qwen", func(f *qwenFixture) string { - return filepath.Join(f.workspaceDir, "relative-qwen", "settings.json") - }}, - {"tilde", "~/custom-qwen", func(f *qwenFixture) string { - return filepath.Join(f.homeDir, "custom-qwen", "settings.json") - }}, - {"empty", "", func(f *qwenFixture) string { return f.configPath }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - fixture := prepareQwenFixture(t, qwenFixtureOptions{}) - ignored := filepath.Join(filepath.Dir(fixture.homeDir), "ignored-qwen-home") - writeOptionalQwenFile( - t, filepath.Join(fixture.qwenDir, ".env"), qwenString("QWEN_HOME="+ignored+"\n"), - ) - t.Setenv("QWEN_HOME", test.value) - installQwenFixture(t, fixture) - qwenManagedHandler(t, readQwenTestObject(t, test.expected(fixture)), "PreToolUse", fixture.guardPath) - requireMissingQwenFile(t, filepath.Join(ignored, "settings.json")) - }) - } -} - -func TestQwenCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) { - fixture := prepareQwenFixture(t, qwenFixtureOptions{}) - installQwenFixture(t, fixture) - capturePath := filepath.Join(t.TempDir(), "captured-event.json") - encodedPath, err := json.Marshal(capturePath) +func legacyQwenGroupForTest( + t *testing.T, + event, scriptPath string, +) map[string]any { + t.Helper() + nodePath, err := resolveNodeRuntime() 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 = []; " + - "process.stdin.on('data', chunk => chunks.push(chunk)); " + - "process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" - if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { - t.Fatalf("write capture audit runtime: %v", err) - } - settings := readQwenTestObject(t, fixture.configPath) - guard := qwenManagedHandler(t, settings, "PreToolUse", fixture.guardPath) - audit := qwenManagedHandler(t, settings, "PostToolUse", fixture.hookPath) - prePayload := `{"session_id":"session-1","cwd":"C:/workspace","hook_event_name":"PreToolUse","timestamp":"2026-07-19T00:00:00.000Z","tool_name":"run_shell_command","tool_input":{"command":"echo test"}}` - guardResult := runQwenHandler(t, guard, fixture.homeDir, prePayload) - if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { - t.Fatalf("guard result = %#v", guardResult) - } - postPayload := strings.Replace(prePayload, "PreToolUse", "PostToolUse", 1) - auditResult := runQwenHandler(t, audit, fixture.homeDir, postPayload) - if auditResult.exitCode != 0 { - t.Fatalf("audit result = %#v", auditResult) - } - if readQwenTestFile(t, capturePath) != postPayload { - t.Fatal("audit hook changed the official Qwen Code payload") + return map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", + "command": buildQwenCommand(nodePath, scriptPath), + "shell": qwenExpectedShell(), + "timeout": qwenHookTimeout, + }}, + "event": event, } } -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{}) + 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) - status, err := fixture.plugin.Status() - if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { - t.Fatalf("installed status = %#v, %v", status, err) + root := readQwenTestObject(t, fixture.configPath) + for _, item := range []struct{ event, path string }{ + {"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) + preserved := false + for _, groupValue := range requireQwenArray(t, requireQwenObject(t, root["hooks"])["PreToolUse"]) { + for _, handlerValue := range requireQwenArray(t, requireQwenObject(t, groupValue)["hooks"]) { + preserved = preserved || requireQwenObject(t, handlerValue)["timeout"] == float64(9_000) + } } - settings["disableAllHooks"] = false - writeQwenTestObject(t, fixture.configPath, settings) - if err := os.Remove(fixture.guardPath); err != nil { - t.Fatalf("remove guard runtime: %v", err) - } - status, err = fixture.plugin.Status() - if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { - t.Fatalf("missing-runtime status = %#v, %v", status, err) + if !preserved { + 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{ "type": "command", "command": "user-command", }) - nodePath, err := resolveNodeRuntime() - if err != nil { - t.Fatalf("resolve Node.js: %v", err) - } + otherAgent := filepath.Join(filepath.Dir(fixture.agentDir), "agent-10") hooks := requireQwenObject(t, settings["hooks"]) - hooks["PreToolUse"] = append(requireQwenArray(t, hooks["PreToolUse"]), map[string]any{ - "matcher": "*", - "hooks": []any{map[string]any{ - "type": "command", "command": buildQwenCommand(nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", qwenGuardScript)), - "shell": expectedQwenShell(), "timeout": float64(10_000), - }}, - }) + hooks["PreToolUse"] = append( + requireQwenArray(t, hooks["PreToolUse"]), + legacyQwenGroupValue(t, filepath.Join(otherAgent, qwenGuardScript)), + ) writeQwenTestObject(t, fixture.configPath, settings) before := readQwenTestFile(t, fixture.configPath) if err := fixture.plugin.Uninstall("other-agent"); err != nil { @@ -250,6 +251,9 @@ func TestQwenUninstallPreservesExternalMutationsAndExactOwnership(t *testing.T) if _, exists := remainingHooks["PostToolUse"]; exists { t.Fatalf("PostToolUse remains: %#v", remainingHooks) } + if _, exists := remainingHooks["PostToolUseFailure"]; exists { + t.Fatalf("PostToolUseFailure remains: %#v", remainingHooks) + } } func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) { @@ -263,38 +267,3 @@ func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) { } requireMissingQwenFile(t, fixture.configPath) } - -func TestQwenDotenvParserMatchesOfficialGrammar(t *testing.T) { - tests := []struct { - source string - want map[string]string - }{ - {"QWEN_HOME=plain\n", map[string]string{"QWEN_HOME": "plain"}}, - {"export QWEN_HOME = \"path # one\" # comment\n", map[string]string{"QWEN_HOME": "path # one"}}, - {"QWEN_HOME: path with spaces\n", map[string]string{"QWEN_HOME": "path with spaces"}}, - {"QWEN_HOME=\n", map[string]string{"QWEN_HOME": ""}}, - {"QWEN_HOME='single quoted'\r\n", map[string]string{"QWEN_HOME": "single quoted"}}, - {"QWEN_HOME=`backtick value`\n", map[string]string{"QWEN_HOME": "backtick value"}}, - {"QWEN_HOME=first\nQWEN_HOME=second\n", map[string]string{"QWEN_HOME": "second"}}, - {"OTHER=x\nQWEN_HOME=\"line\\nnext\"\n", map[string]string{"OTHER": "x", "QWEN_HOME": "line\nnext"}}, - } - for _, test := range tests { - got := parseDotenv([]byte(test.source)) - encodedGot, _ := json.Marshal(got) - encodedWant, _ := json.Marshal(test.want) - if string(encodedGot) != string(encodedWant) { - t.Fatalf("parseDotenv(%q) = %s, want %s", test.source, encodedGot, encodedWant) - } - } -} - -func writeQwenTestObject(t *testing.T, path string, value map[string]any) { - t.Helper() - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - t.Fatalf("encode %s: %v", path, err) - } - if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} diff --git a/cmd/elydora/plugins/qwen_test_helpers_test.go b/cmd/elydora/plugins/qwen_test_helpers_test.go index 1068bd1..7f2257b 100644 --- a/cmd/elydora/plugins/qwen_test_helpers_test.go +++ b/cmd/elydora/plugins/qwen_test_helpers_test.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "testing" ) @@ -16,26 +15,30 @@ import ( const qwenTestAgentID = "agent-1" type qwenFixtureOptions struct { - settings *string - skipGuard bool + settings *string } type qwenFixture struct { - plugin *QwenPlugin - config InstallConfig - homeDir string - workspaceDir string - qwenDir string - configPath string - agentDir string - guardPath string - hookPath string - runtimeConfig string - privateKey string + plugin *QwenPlugin + config InstallConfig + homeDir string + workspaceDir string + qwenDir string + configPath string + workspaceConfig string + systemConfig string + systemDefaults string + trustedFolders string + agentDir string + guardPath string + hookPath string + runtimeConfig string + privateKey string } type qwenCommandResult struct { exitCode int + stdout string stderr string } @@ -58,24 +61,25 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture { workspaceDir := filepath.Join(homeDir, "workspace") qwenDir := filepath.Join(homeDir, ".qwen") 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) guardPath := filepath.Join(agentDir, qwenGuardScript) hookPath := filepath.Join(agentDir, qwenAuditScript) - for _, directory := range []string{workspaceDir, agentDir} { - if err := os.MkdirAll(directory, 0755); err != nil { - t.Fatalf("create fixture directory %s: %v", directory, err) - } - } - if !options.skipGuard { - guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" - if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil { - t.Fatalf("write guard runtime: %v", err) - } + if err := os.MkdirAll(workspaceDir, 0755); err != nil { + t.Fatalf("create fixture workspace: %v", err) } writeOptionalQwenFile(t, configPath, options.settings) t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) 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() if err != nil { t.Fatalf("read current directory: %v", err) @@ -89,16 +93,20 @@ func prepareQwenFixture(t *testing.T, options qwenFixtureOptions) *qwenFixture { } }) return &qwenFixture{ - plugin: &QwenPlugin{}, - homeDir: homeDir, - workspaceDir: workspaceDir, - qwenDir: qwenDir, - configPath: configPath, - agentDir: agentDir, - guardPath: guardPath, - hookPath: hookPath, - runtimeConfig: filepath.Join(agentDir, "config.json"), - privateKey: filepath.Join(agentDir, "private.key"), + plugin: &QwenPlugin{}, + homeDir: homeDir, + workspaceDir: workspaceDir, + qwenDir: qwenDir, + configPath: configPath, + workspaceConfig: workspaceConfig, + systemConfig: systemConfig, + systemDefaults: systemDefaults, + trustedFolders: trustedFolders, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), config: InstallConfig{ AgentName: qwenAgentKey, OrgID: "org-1", @@ -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() if source == nil { return } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("create directory for %s: %v", path, err) + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + t.Fatalf("create directory for %s: %v", filePath, err) } - if err := os.WriteFile(path, []byte(*source), 0600); err != nil { - t.Fatalf("write %s: %v", path, err) + if err := os.WriteFile(filePath, []byte(*source), 0600); err != nil { + 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() - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(filePath) if err != nil { - t.Fatalf("read %s: %v", path, err) + t.Fatalf("read %s: %v", filePath, err) } 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() object, err := decodeJSONCObject( - []byte(readQwenTestFile(t, path)), "Qwen test settings", false, + []byte(readQwenTestFile(t, filePath)), + "Qwen test settings", + false, ) if err != nil { - t.Fatalf("decode %s: %v", path, err) + t.Fatalf("decode %s: %v", filePath, err) } return object } @@ -184,8 +194,16 @@ func requireQwenArray(t *testing.T, value any) []any { 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() + expectedName := qwenAuditHookName + if event == "PreToolUse" { + expectedName = qwenGuardHookName + } hooks := requireQwenObject(t, settings["hooks"]) for _, groupValue := range requireQwenArray(t, hooks[event]) { group := requireQwenObject(t, groupValue) @@ -193,7 +211,7 @@ func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath s handler := requireQwenObject(t, handlerValue) command, _ := handler["command"].(string) _, candidate, managed := parseQwenCommand(command) - if managed && sameQwenPath(candidate, scriptPath) { + if managed && handler["name"] == expectedName && sameQwenPath(candidate, scriptPath) { return group } } @@ -202,7 +220,11 @@ func qwenManagedGroup(t *testing.T, settings map[string]any, event, scriptPath s 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() group := qwenManagedGroup(t, settings, event, scriptPath) for _, handlerValue := range requireQwenArray(t, group["hooks"]) { @@ -217,35 +239,49 @@ func qwenManagedHandler(t *testing.T, settings map[string]any, event, scriptPath 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() command := handler["command"].(string) var process *exec.Cmd if handler["shell"] == "powershell" { - process = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + process = exec.Command( + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ) } else { process = exec.Command("bash", "-c", command) } process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) process.Stdin = bytes.NewBufferString(payload) + var stdout bytes.Buffer var stderr bytes.Buffer + process.Stdout = &stdout process.Stderr = &stderr err := process.Run() + result := qwenCommandResult{stdout: stdout.String(), stderr: stderr.String()} if err == nil { - return qwenCommandResult{stderr: stderr.String()} + return result } var exitError *exec.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) return qwenCommandResult{} } -func requireMissingQwenFile(t *testing.T, path string) { +func requireMissingQwenFile(t *testing.T, filePath string) { t.Helper() - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("expected %s to be missing, got %v", path, err) + if _, err := os.Stat(filePath); !os.IsNotExist(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 { - if runtime.GOOS == "windows" { - return "powershell" +func writeQwenTestObject(t *testing.T, filePath string, value map[string]any) { + t.Helper() + 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" } diff --git a/cmd/elydora/qwen_e2e_test.go b/cmd/elydora/qwen_e2e_test.go new file mode 100644 index 0000000..5d04dc9 --- /dev/null +++ b/cmd/elydora/qwen_e2e_test.go @@ -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"