magic-agent 1.0.2 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +260 -8
  55. package/internal/agent/custom_models_test.go +204 -7
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +78 -2
  66. package/internal/agent/env_test.go +37 -0
  67. package/internal/agent/itemtracker.go +271 -0
  68. package/internal/agent/launch.go +192 -0
  69. package/internal/agent/launch_test.go +206 -0
  70. package/internal/agent/launch_unix.go +15 -0
  71. package/internal/agent/launch_windows.go +24 -0
  72. package/internal/agent/limitnotice.go +103 -0
  73. package/internal/agent/limitnotice_test.go +211 -0
  74. package/internal/agent/llmengine.go +2 -2
  75. package/internal/agent/models.go +3 -2
  76. package/internal/agent/openclaw.go +4 -3
  77. package/internal/agent/openclaw_acp.go +7 -0
  78. package/internal/agent/openclaw_test.go +2 -2
  79. package/internal/agent/permission.go +100 -11
  80. package/internal/agent/permission_test.go +100 -36
  81. package/internal/agent/repair_models_test.go +194 -0
  82. package/internal/agent/runcmd.go +13 -3
  83. package/internal/agent/runner.go +7 -1
  84. package/internal/agent/runner_test.go +1 -1
  85. package/internal/agent/stream.go +777 -43
  86. package/internal/agent/stream_test.go +3 -2
  87. package/internal/agent/stream_timeline_test.go +905 -0
  88. package/internal/agent/streamjson.go +36 -2
  89. package/internal/agent/toolkind.go +20 -1
  90. package/internal/agent/toolkind_test.go +7 -1
  91. package/internal/agent/tools_suffix_test.go +10 -3
  92. package/internal/agent/trae.go +5 -3
  93. package/internal/agent/workspace_test.go +52 -0
  94. package/internal/cli/ask.go +282 -51
  95. package/internal/cli/ask_test.go +15 -11
  96. package/internal/cli/attach_test.go +32 -20
  97. package/internal/cli/clientcmd.go +156 -0
  98. package/internal/cli/events.go +53 -8
  99. package/internal/cli/events_test.go +230 -0
  100. package/internal/cli/keepalive_test.go +101 -6
  101. package/internal/cli/login.go +61 -4
  102. package/internal/cli/login_test.go +99 -0
  103. package/internal/cli/permission_test.go +5 -1
  104. package/internal/cli/repair.go +60 -0
  105. package/internal/cli/repair_test.go +51 -0
  106. package/internal/cli/root.go +195 -32
  107. package/internal/cli/session.go +183 -29
  108. package/internal/cli/session_test.go +144 -5
  109. package/internal/cli/sessionlog.go +385 -38
  110. package/internal/cli/sessionlog_test.go +708 -0
  111. package/internal/cli/sessionlog_user_test.go +142 -1
  112. package/internal/cli/sessiontitle.go +141 -0
  113. package/internal/client/browse.go +456 -0
  114. package/internal/client/browse_test.go +199 -0
  115. package/internal/client/cors_test.go +35 -0
  116. package/internal/client/daemon.go +525 -0
  117. package/internal/client/daemon_test.go +212 -0
  118. package/internal/client/detach_unix.go +19 -0
  119. package/internal/client/detach_windows.go +27 -0
  120. package/internal/client/pair.go +3 -3
  121. package/internal/client/plugins.go +376 -0
  122. package/internal/client/plugins_test.go +223 -0
  123. package/internal/client/proxy.go +374 -0
  124. package/internal/client/proxy_test.go +212 -0
  125. package/internal/client/service.go +1464 -48
  126. package/internal/client/service_test.go +649 -206
  127. package/internal/client/session_events_test.go +130 -0
  128. package/internal/client/wsproxy.go +113 -0
  129. package/internal/client/wsproxy_test.go +102 -0
  130. package/internal/session/delete.go +151 -0
  131. package/internal/session/delete_test.go +103 -0
  132. package/internal/session/session.go +155 -20
  133. package/internal/session/session_append.go +79 -21
  134. package/internal/session/session_test.go +124 -10
  135. package/internal/session/sessionlog.go +572 -6
  136. package/internal/session/sessionlog_read_test.go +410 -0
  137. package/internal/session/title.go +486 -0
  138. package/internal/session/title_test.go +298 -0
  139. package/npm/bin/magic-agent.js +78 -10
  140. package/npm/bin/magic-client.js +100 -0
  141. package/npm/dist/darwin-arm64/magic-agent +0 -0
  142. package/npm/dist/darwin-arm64/magic-client +0 -0
  143. package/npm/dist/darwin-x64/magic-agent +0 -0
  144. package/npm/dist/darwin-x64/magic-client +0 -0
  145. package/npm/dist/linux-arm64/magic-agent +0 -0
  146. package/npm/dist/linux-arm64/magic-client +0 -0
  147. package/npm/dist/linux-x64/magic-agent +0 -0
  148. package/npm/dist/linux-x64/magic-client +0 -0
  149. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  150. package/npm/dist/win32-x64/magic-client.exe +0 -0
  151. package/npm/install.js +36 -13
  152. package/npm/lib/platform.js +27 -5
  153. package/package.json +8 -3
  154. package/client-ui/pc/js/03-chat-message.js +0 -880
  155. package/client-ui/pc/js/06-ask-answer.js +0 -204
  156. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  157. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  158. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  159. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -92,7 +92,7 @@ func shortSessionsDir(t *testing.T) string {
92
92
 
93
93
  func TestKeepAliveRequiresStream(t *testing.T) {
94
94
  kaEngineForTest(t)
95
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--keep-alive", "-p", "hi")
95
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--keep-alive", "-p", "hi")
96
96
  if err == nil {
97
97
  t.Fatal("--keep-alive 不配 --stream 应报错")
98
98
  }
@@ -143,7 +143,7 @@ func TestKeepAliveIsDefaultForStreaming(t *testing.T) {
143
143
 
144
144
  done := make(chan error, 1)
145
145
  go func() {
146
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--idle", "1s", "-p", "首轮")
146
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--stream", "--idle", "1s", "-p", "首轮")
147
147
  done <- err
148
148
  }()
149
149
 
@@ -183,7 +183,7 @@ func TestKeepAliveCanBeDisabled(t *testing.T) {
183
183
  t.Setenv(session.EnvDir, dir)
184
184
  registerFake(&streamingEngine{name: "fake-ka-off", text: "ok"})
185
185
 
186
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-off", "--stream", "--keep-alive=false", "hi"); err != nil {
186
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-off", "--stream", "--keep-alive=false", "hi"); err != nil {
187
187
  t.Fatalf("ask: %v", err)
188
188
  }
189
189
  recs := mustListSessions(t, dir)
@@ -201,7 +201,7 @@ func TestKeepAliveNotAppliedToNonStream(t *testing.T) {
201
201
  t.Setenv(session.EnvDir, dir)
202
202
  registerFake(&stringEngine{name: "fake-ka-nostream", text: "ok"})
203
203
 
204
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-nostream", "hi"); err != nil {
204
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-nostream", "hi"); err != nil {
205
205
  t.Fatalf("ask: %v", err)
206
206
  }
207
207
  recs := mustListSessions(t, dir)
@@ -255,7 +255,7 @@ func TestAppendToNonKeepAliveSession(t *testing.T) {
255
255
  t.Setenv(session.EnvDir, shortSessionsDir(t))
256
256
  registerFake(&stringEngine{name: "fake-ka-none", text: "ok"})
257
257
 
258
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-none", "普通调用"); err != nil {
258
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-none", "普通调用"); err != nil {
259
259
  t.Fatalf("ask: %v", err)
260
260
  }
261
261
  rows := parseSessions(t, mustSessions(t))
@@ -283,7 +283,7 @@ func TestKeepAliveAppendEndToEnd(t *testing.T) {
283
283
  // ① 起常驻会话(后台跑,空闲 1s 自动收工)
284
284
  done := make(chan error, 1)
285
285
  go func() {
286
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--keep-alive",
286
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--stream", "--keep-alive",
287
287
  "--idle", "1s", "-p", "首轮任务")
288
288
  done <- err
289
289
  }()
@@ -350,6 +350,101 @@ func TestKeepAliveAppendEndToEnd(t *testing.T) {
350
350
  }
351
351
  }
352
352
 
353
+ // TestKeepAliveAppendWithoutRegistry 默认不留档(不开 --save-registry)时,
354
+ // --append 仍要能用 —— 走的是「按约定找 socket」那条路(session.AppendSocketPath)。
355
+ //
356
+ // 为什么这一条重要:常驻会话对 claude/codebuddy 是**默认开**的,而登记表默认不落。
357
+ // 若 --append 只认登记表,用户会撞上「默认能常驻、却追加不了」这个自相矛盾的行为。
358
+ // 代价是这条路径只认 run_id(session_id 要靠记录映射,没有记录就无从查起)。
359
+ func TestKeepAliveAppendWithoutRegistry(t *testing.T) {
360
+ dir := shortSessionsDir(t)
361
+ t.Setenv(session.EnvDir, dir)
362
+
363
+ eng := kaEngineForTest(t)
364
+
365
+ // ① 起常驻会话(**不开** --save-registry)
366
+ done := make(chan error, 1)
367
+ go func() {
368
+ _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--keep-alive",
369
+ "--idle", "5s", "-p", "首轮任务")
370
+ done <- err
371
+ }()
372
+
373
+ // ② 登记表里什么都没有(这正是前提),只能等 socket 文件出现
374
+ if recs := mustListSessions(t, dir); len(recs) != 0 {
375
+ t.Fatalf("默认不该有登记记录,实际 %+v", recs)
376
+ }
377
+ var runID string
378
+ deadline := time.Now().Add(10 * time.Second)
379
+ for time.Now().Before(deadline) && runID == "" {
380
+ ents, _ := os.ReadDir(dir)
381
+ for _, e := range ents {
382
+ if strings.HasSuffix(e.Name(), ".sock") {
383
+ runID = strings.TrimSuffix(e.Name(), ".sock")
384
+ }
385
+ }
386
+ if runID == "" {
387
+ time.Sleep(50 * time.Millisecond)
388
+ }
389
+ }
390
+ if runID == "" {
391
+ t.Fatal("常驻会话没开出追加入口 socket")
392
+ }
393
+ // 按约定推出来的路径必须与实际建出来的同一个(两端算法唯一的交叉点)
394
+ if got, want := session.AppendSocketPath(runID), filepath.Join(dir, runID+".sock"); got != want {
395
+ t.Fatalf("AppendSocketPath = %q want %q", got, want)
396
+ }
397
+
398
+ // ③ 按 run_id 追加(记录不存在的路径)
399
+ stdout, _, err := runAskCmd(t, "", "--append", runID, "-p", "不留档也能追加")
400
+ if err != nil {
401
+ t.Fatalf("--append(无登记记录): %v", err)
402
+ }
403
+ var out struct {
404
+ Type string `json:"type"`
405
+ Queued bool `json:"queued"`
406
+ }
407
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &out); err != nil {
408
+ t.Fatalf("--append 输出不是 JSON: %v (%s)", err, stdout)
409
+ }
410
+ if out.Type != "append" || !out.Queued {
411
+ t.Errorf("--append 结果不对: %+v", out)
412
+ }
413
+
414
+ // ④ 引擎确实收到了;收工后 socket 照旧清掉
415
+ if got := waitAppend(t, eng, "不留档也能追加", 10*time.Second); got != "不留档也能追加" {
416
+ t.Errorf("引擎收到的追加 = %q", got)
417
+ }
418
+ // ⑤ --stop 依赖登记表(pid 只存在那里),所以不留档的会话**停不掉** ——
419
+ // 这是「默认不留痕」的已知代价,明写出来免得当成 bug 报回来。
420
+ if _, _, err := runAskCmd(t, "", "--stop", runID); err == nil {
421
+ t.Error("--stop 对不留档的会话应当报「找不到」(它靠登记表寻址)")
422
+ }
423
+ select {
424
+ case err := <-done:
425
+ if err != nil {
426
+ t.Fatalf("常驻会话收尾报错: %v", err)
427
+ }
428
+ case <-time.After(20 * time.Second):
429
+ t.Fatal("常驻会话没收工")
430
+ }
431
+ }
432
+
433
+ // waitAppend 等引擎收到追加内容(追加是异步投递的,socket 收到 ≠ 引擎已读)。
434
+ func waitAppend(t *testing.T, eng *kaEngine, want string, d time.Duration) string {
435
+ t.Helper()
436
+ deadline := time.Now().Add(d)
437
+ for time.Now().Before(deadline) {
438
+ for _, got := range eng.received() {
439
+ if got == want {
440
+ return got
441
+ }
442
+ }
443
+ time.Sleep(50 * time.Millisecond)
444
+ }
445
+ return ""
446
+ }
447
+
353
448
  // mustListSessions 直接读登记表(比 --sessions 的 JSON 更直接,且能看到 append 字段)。
354
449
  func mustListSessions(t *testing.T, dir string) []session.Record {
355
450
  t.Helper()
@@ -14,6 +14,7 @@ import (
14
14
  "os"
15
15
  "os/exec"
16
16
  "strings"
17
+ "sync"
17
18
 
18
19
  "github.com/darren/magic-agent/internal/agent"
19
20
  "github.com/spf13/cobra"
@@ -40,7 +41,11 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
40
41
  return err
41
42
  }
42
43
 
43
- // stdio 直通:交互式界面必须拿到真的 TTY。
44
+ /* stdio 直通:交互式界面必须拿到真的 TTY。
45
+ ⚠️ 这里**刻意不设进程组**(普通引擎调用走 configureProcAttr 自成一组):
46
+ 交互式 TUI 必须在**前台进程组**里,否则它从 tty 读输入会被 SIGTTIN 停住 ——
47
+ 表现就是「一进登录界面就卡住」。代之以「登记子进程 + 退出前带走」的清理
48
+ (见 loginChild / stopLoginChild),保证它不会变成占着账号的孤儿。 */
44
49
  c := exec.Command(bin, args...)
45
50
  c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
46
51
  c.Env = agent.ChildEnvWith(extraEnv)
@@ -49,20 +54,72 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
49
54
  }
50
55
 
51
56
  // 提示走 stderr:stdout 留给子进程的交互界面,不被污染。
57
+ // 第三行是给「上一次登录没成功」的情形:残留的登录态得先清掉,否则会一直登不上。
52
58
  fmt.Fprintf(cmd.ErrOrStderr(),
53
59
  "magic-agent: 已按 %s 的账号环境启动 %s\n"+
54
- " 在里面执行 /login 完成登录(退出后本命令结束)\n", eng.Name(), bin)
60
+ " 在里面执行 /login 完成登录(退出后本命令结束)\n"+
61
+ " 若上一次登录没成功、这次进去仍登不上:先执行 /logout 清掉残留登录态,再执行 /login\n",
62
+ eng.Name(), bin)
55
63
 
56
- if runErr := c.Run(); runErr != nil {
64
+ if startErr := c.Start(); startErr != nil {
65
+ return fmt.Errorf("启动 %s 失败: %w", bin, startErr)
66
+ }
67
+ setLoginChild(c)
68
+ defer clearLoginChild()
69
+
70
+ if runErr := c.Wait(); runErr != nil {
57
71
  var ee *exec.ExitError
58
72
  if errors.As(runErr, &ee) {
59
73
  return fmt.Errorf("%s 会话退出码 %d", eng.Name(), ee.ExitCode())
60
74
  }
61
- return fmt.Errorf("启动 %s 失败: %w", bin, runErr)
75
+ return fmt.Errorf("%s 会话异常: %w", eng.Name(), runErr)
62
76
  }
63
77
  return nil
64
78
  }
65
79
 
80
+ /* ── 交互式登录子进程的清理登记 ──────────────────────────────
81
+ *
82
+ * 为什么单独记一笔:登录是**唯一**不走进程组管理的子进程(理由见上面 runLogin 的
83
+ * 注释 —— 交互式 TUI 必须留在前台组里)。因此它既不在会话登记表(--stop 管不到),
84
+ * 也不会被 installSignalStop 的「杀会话」路径带走。一旦 magic-agent 被 SIGTERM
85
+ * (桌面壳的「停止」按钮、`kill <pid>`)终止,这个 CLI 就成孤儿,继续占着该账号的
86
+ * 配置目录 / 端口,下一次 --login 起来会撞上残留状态 —— 用户看到的就是
87
+ * 「登录失败后再也登不上」。所以退出前必须补一刀。
88
+ */
89
+ var (
90
+ loginMu sync.Mutex
91
+ loginChild *exec.Cmd
92
+ )
93
+
94
+ // setLoginChild 登记正在跑的登录子进程。
95
+ func setLoginChild(c *exec.Cmd) {
96
+ loginMu.Lock()
97
+ loginChild = c
98
+ loginMu.Unlock()
99
+ }
100
+
101
+ // clearLoginChild 注销(登录会话正常结束 / 启动失败时调用)。
102
+ func clearLoginChild() {
103
+ setLoginChild(nil)
104
+ }
105
+
106
+ /* stopLoginChild 杀掉正在跑的登录子进程(若在),返回是否真的动了手。
107
+ *
108
+ * 用**单进程**信号(group=false):它本来就与 magic-agent 同组,负 pid 会把
109
+ * magic-agent 自己一起杀掉。
110
+ */
111
+ func stopLoginChild() bool {
112
+ loginMu.Lock()
113
+ c := loginChild
114
+ loginChild = nil
115
+ loginMu.Unlock()
116
+ if c == nil || c.Process == nil {
117
+ return false
118
+ }
119
+ _ = agent.KillPID(c.Process.Pid, false)
120
+ return true
121
+ }
122
+
66
123
  // loginCapableEngines 列出实现了 LoginRunner 的引擎名(报错提示用)。
67
124
  func loginCapableEngines() string {
68
125
  var names []string
@@ -0,0 +1,99 @@
1
+ package cli
2
+
3
+ // login_test.go - `--login` 的参数校验与「退出前带走登录子进程」的清理契约。
4
+ //
5
+ // 这两件事都是「登录失败后再也登不上」那个用户问题的直接成因:
6
+ // - 参数写错时给一句看得懂的中文(而不是 cobra 的英文裸报错,且退出码要对);
7
+ // - 登录 TUI 必须留在前台进程组里(否则读 tty 被 SIGTTIN 停住),代价是
8
+ // magic-agent 被 SIGTERM / 关终端时必须主动把它带走,不能留成占着账号的孤儿。
9
+
10
+ import (
11
+ "errors"
12
+ "os/exec"
13
+ "runtime"
14
+ "strings"
15
+ "testing"
16
+ "time"
17
+ )
18
+
19
+ // `--login` 后面没跟引擎名:必须给出中文 usage(点名可用引擎),且算参数错(exit 2)。
20
+ // cobra 在解析阶段就报错(走不到 runLogin),所以这条靠 SetFlagErrorFunc 兜住。
21
+ func TestLoginWithoutEngineNameIsUsageError(t *testing.T) {
22
+ _, _, err := runAskCmd(t, "", "--login")
23
+ if err == nil {
24
+ t.Fatal("--login 缺引擎名应报错")
25
+ }
26
+ msg := err.Error()
27
+ if !strings.Contains(msg, "需要引擎名") {
28
+ t.Errorf("应是中文用法提示,实际 %q", msg)
29
+ }
30
+ /* ⚠️ 不断言「提示里点名了 codebuddy」:包内用例会在 Engines() 首次调用前
31
+ agent.Register 假引擎,触发 registry 的已知脆弱点(内置引擎整体不注册,
32
+ 见 agent/engine.go 的守卫注释),那时可用引擎列表本来就是空的。
33
+ 这里只锚定稳定契约:中文提示 + usage(exit 2)。 */
34
+ if _, ok := err.(*usageError); !ok {
35
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
36
+ }
37
+ }
38
+
39
+ // 未知引擎名:usage 错误 + 列出可用引擎,不静默启一个空 bin。
40
+ func TestLoginUnknownEngine(t *testing.T) {
41
+ _, _, err := runAskCmd(t, "", "--login", "no-such-engine")
42
+ if err == nil {
43
+ t.Fatal("未知引擎应报错")
44
+ }
45
+ if !strings.Contains(err.Error(), "未知引擎") {
46
+ t.Errorf("错误应点名未知引擎,实际 %q", err.Error())
47
+ }
48
+ if _, ok := err.(*usageError); !ok {
49
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
50
+ }
51
+ }
52
+
53
+ // 不支持交互式登录的引擎:明确说不必用 --login,而不是硬造一条命令。
54
+ func TestLoginEngineWithoutLoginRunner(t *testing.T) {
55
+ _, _, err := runAskCmd(t, "", "--login", "claude")
56
+ if err == nil {
57
+ t.Fatal("claude 不支持 --login,应报错")
58
+ }
59
+ if !strings.Contains(err.Error(), "不需要 --login") {
60
+ t.Errorf("错误应说明该引擎不需要 --login,实际 %q", err.Error())
61
+ }
62
+ }
63
+
64
+ // 登录子进程必须能被 stopLoginChild 收掉(进程真死)。
65
+ // 为什么值得测:登录是唯一不进进程组的子进程,孤儿化正是「登录失败后再也登不上」的成因。
66
+ //
67
+ // 判据用 c.Wait()(真回收)而不是 PIDAlive —— 被 SIGKILL 的子进程在没人 Wait 之前
68
+ // 是僵尸,仍然会被 kill(pid, 0) 判成「活着」,拿它当判据会假红。
69
+ func TestStopLoginChildKillsProcess(t *testing.T) {
70
+ if runtime.GOOS == "windows" {
71
+ t.Skip("用 sleep 当靶子,Windows 另论")
72
+ }
73
+ c := exec.Command("sleep", "60")
74
+ if err := c.Start(); err != nil {
75
+ t.Fatalf("起靶子进程失败: %v", err)
76
+ }
77
+ setLoginChild(c)
78
+
79
+ if !stopLoginChild() {
80
+ t.Fatal("stopLoginChild 应报告动了手")
81
+ }
82
+
83
+ done := make(chan error, 1)
84
+ go func() { done <- c.Wait() }()
85
+ select {
86
+ case werr := <-done:
87
+ var ee *exec.ExitError
88
+ if !errors.As(werr, &ee) {
89
+ t.Fatalf("靶子应被信号杀掉,实际 err=%v", werr)
90
+ }
91
+ case <-time.After(5 * time.Second):
92
+ t.Fatal("登录子进程未被收掉(5s 内没退出)")
93
+ }
94
+
95
+ // 已经清空:再调一次不应再动手(幂等,避免误杀复用的 pid)。
96
+ if stopLoginChild() {
97
+ t.Error("注销后再调 stopLoginChild 不应再动手")
98
+ }
99
+ }
@@ -139,7 +139,11 @@ func TestEnginesReportsPermission(t *testing.T) {
139
139
  if seen["codebuddy-ai"] != "flag:--permission-mode" {
140
140
  t.Errorf("codebuddy-ai 的 permission 字段 = %q, want flag:--permission-mode", seen["codebuddy-ai"])
141
141
  }
142
- for _, e := range []string{"trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
142
+ // dsh 的档位不走 flag,走子进程环境变量 —— 能力串是 env:<VAR>,客户端据此放行。
143
+ if seen["dsh"] != "env:DSH_PERMISSION_MODE" {
144
+ t.Errorf("dsh 的 permission 字段 = %q, want env:DSH_PERMISSION_MODE", seen["dsh"])
145
+ }
146
+ for _, e := range []string{"trae", "llm", "codex", "openclaw", "arkclaw"} {
143
147
  if v, ok := seen[e]; !ok {
144
148
  t.Errorf("--engines 缺 %s 行", e)
145
149
  } else if v != "none" {
@@ -0,0 +1,60 @@
1
+ package cli
2
+
3
+ // repair.go - `--repair-models <engine>`:还原被反向同步写坏的桌面端 models.json。
4
+ //
5
+ // 为什么要有它:2026-09-30 的 Windows 事故里,magic-agent 把桌面端(WorkBuddy)那份
6
+ // **数组格式**的 models.json 覆盖成了 `{availableModels, models}` 对象格式,桌面端
7
+ // 解析不了 → 模型清单整个为空。同步那一侧已经加了「陌生形状一律不动」的硬约束,
8
+ // 但**已经坏掉的现场**需要一条能自己走回来的路 —— 否则用户只能手抄 apiKey。
9
+ //
10
+ // 还原逻辑在 agent.RepairDesktopModels(见 custom_models.go 的事故还原一节):
11
+ // 只认明确的坏形状才动手,动手前先留 .broken.bak。
12
+
13
+ import (
14
+ "encoding/json"
15
+ "fmt"
16
+ "strings"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ "github.com/spf13/cobra"
20
+ )
21
+
22
+ // runRepairModels 处理 `--repair-models <engine>`。
23
+ func runRepairModels(cmd *cobra.Command, opts *askOptions) error {
24
+ name := strings.TrimSpace(opts.repairModels)
25
+ if name == "" {
26
+ return &usageError{fmt.Errorf(
27
+ "--repair-models 需要引擎名:magic-agent --repair-models <engine>(可用:%s)",
28
+ strings.Join(agent.DesktopEngines(), " / "))}
29
+ }
30
+
31
+ // 引擎名先在这里校验:这是**参数错**(exit 2),而不是运行期失败(exit 1)。
32
+ // agent 层还会再兜一次,但那属于「不该发生」的安全网。
33
+ if agent.DesktopAppHomeOf(name) == "" {
34
+ return &usageError{fmt.Errorf("引擎 %q 没有对应的桌面端数据目录,没有可修的 models.json(可用:%s)",
35
+ name, strings.Join(agent.DesktopEngines(), " / "))}
36
+ }
37
+
38
+ repaired, note, err := agent.RepairDesktopModels(name)
39
+ if err != nil {
40
+ return err
41
+ }
42
+
43
+ // 机器可读的一行结果走 stdout(与 --sessions / --stop 同一族);人看的说明走 stderr。
44
+ if eerr := json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{
45
+ "engine": name,
46
+ "repaired": repaired,
47
+ "note": note,
48
+ }); eerr != nil {
49
+ return eerr
50
+ }
51
+ out := cmd.ErrOrStderr()
52
+ fmt.Fprintf(out, "magic-agent: %s\n", note)
53
+ if !repaired {
54
+ // 没改动不是失败,但必须说清「没动」,不静默(本项目铁律)。
55
+ fmt.Fprintf(out, "magic-agent: 未改动任何文件。形状对不上就不动,避免二次破坏。\n")
56
+ return nil
57
+ }
58
+ fmt.Fprintf(out, "magic-agent: 已还原。请重开桌面端确认模型清单;官方模型由桌面端自己拉取,不在这个文件里。\n")
59
+ return nil
60
+ }
@@ -0,0 +1,51 @@
1
+ package cli
2
+
3
+ // repair_test.go - `--repair-models` 的参数校验。
4
+ //
5
+ // 落地逻辑(形状识别 / 还原 / 备份 / 幂等)在 internal/agent/repair_models_test.go,
6
+ // 这里只锁 CLI 层的契约:引擎名缺失或不支持桌面端时是**参数错**(exit 2),
7
+ // 而不是被当成 prompt 发给引擎。
8
+
9
+ import (
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ func TestRepairModelsWithoutEngineNameIsUsageError(t *testing.T) {
15
+ _, _, err := runAskCmd(t, "", "--repair-models")
16
+ if err == nil {
17
+ t.Fatal("--repair-models 缺引擎名应报错")
18
+ }
19
+ if !strings.Contains(err.Error(), "需要引擎名") {
20
+ t.Errorf("应是中文用法提示,实际 %q", err.Error())
21
+ }
22
+ if _, ok := err.(*usageError); !ok {
23
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
24
+ }
25
+ }
26
+
27
+ // 显式给空串同样走校验(与 --login 同一取舍:用「传过没有」而不是「值非空」判断)。
28
+ func TestRepairModelsEmptyEngineIsUsageError(t *testing.T) {
29
+ _, _, err := runAskCmd(t, "", "--repair-models", "")
30
+ if err == nil {
31
+ t.Fatal("空引擎名应报错")
32
+ }
33
+ if _, ok := err.(*usageError); !ok {
34
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
35
+ }
36
+ }
37
+
38
+ // 没有桌面端数据目录的引擎:参数错,并点名可用值。
39
+ func TestRepairModelsUnsupportedEngine(t *testing.T) {
40
+ _, _, err := runAskCmd(t, "", "--repair-models", "claude")
41
+ if err == nil {
42
+ t.Fatal("claude 没有桌面端 models.json,应报错")
43
+ }
44
+ msg := err.Error()
45
+ if !strings.Contains(msg, "没有对应的桌面端数据目录") {
46
+ t.Errorf("错误应说明该引擎没有桌面端目录,实际 %q", msg)
47
+ }
48
+ if _, ok := err.(*usageError); !ok {
49
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
50
+ }
51
+ }