fix(qwen): align Go hooks with Qwen Code 0.20

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 16:06:52 -04:00
1 parent 4521cbe32f
commit dd72793cdb
18 files changed
+2921 -741

No files matched your search

+130 -161
View File
@@ -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)
}
}