magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,607 @@
1
+ package client
2
+
3
+ // singleinstance_test.go - 单实例互斥(2026-10-26 用户:「同时只启动1个」)。
4
+ //
5
+ // 要钉住的是那个**用户可见的故障形态**:两份实例共用作业记录,任务在 B 里跑完,
6
+ // 手机连着的 A 拿不到事件 → 界面「本轮没有收到任何输出(连接可能已断开)」。
7
+ // 所以互斥的判据是「这台机器上还有没有活着的实例」,不是端口。
8
+
9
+ import (
10
+ "encoding/json"
11
+ "os"
12
+ "os/exec"
13
+ "path/filepath"
14
+ "strings"
15
+ "testing"
16
+ "time"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ )
20
+
21
+ // lockInTemp 把锁文件指到临时目录(每个用例独立)。
22
+ //
23
+ // 锁现在按**数据目录**分(不同 AppID 各自一把),所以这里返回的是「数据目录」,
24
+ // 锁文件是它下面的 magic-client.lock —— 同时给返回路径,便于断言文件本身。
25
+ func lockInTemp(t *testing.T) string {
26
+ t.Helper()
27
+ dataDir := t.TempDir()
28
+ t.Setenv(EnvLockPath, filepath.Join(dataDir, "magic-client.lock"))
29
+ return dataDir
30
+ }
31
+
32
+ // lockPathOf 上面那个数据目录对应的锁文件路径。
33
+ func lockPathOf(dataDir string) string { return filepath.Join(dataDir, "magic-client.lock") }
34
+
35
+ // writeHolder 手写一份锁文件(模拟「已有一份在跑」)。
36
+ func writeHolder(t *testing.T, path string, h SingleInstanceHolder) {
37
+ t.Helper()
38
+ b, err := json.Marshal(h)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if err := os.WriteFile(path, b, 0o600); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ }
49
+
50
+ // deadPID 找一个确定不存在的 pid(起一个进程记下再杀掉)。
51
+ func deadPID(t *testing.T) int {
52
+ t.Helper()
53
+ cmd := exec.Command("sleep", "30")
54
+ if err := cmd.Start(); err != nil {
55
+ t.Skipf("起不来占位进程:%v", err)
56
+ }
57
+ pid := cmd.Process.Pid
58
+ _ = cmd.Process.Kill()
59
+ _ = cmd.Wait()
60
+ return pid
61
+ }
62
+
63
+ // TestAcquireSingleInstanceSeparateDataDirsDoNotBlock 不同数据目录可以各跑一份。
64
+ //
65
+ // 这是 2026-10-26 第一版写成「全机单锁」后当场踩到的误伤:本机同时有
66
+ // magic-test 的移动端网关(storage.dir=~/.magic-test,8977)和 magic-agent 自己的
67
+ // 实例(~/.magic-client/magic-agent)。两者作业目录、令牌都不同,本来互不干扰,
68
+ // 全机锁却把其中一份挡在门外。锁的粒度必须是「一份数据目录一把」。
69
+ func TestAcquireSingleInstanceSeparateDataDirsDoNotBlock(t *testing.T) {
70
+ dirA, dirB := t.TempDir(), t.TempDir()
71
+
72
+ // 两个目录各写一把「活着的持有者」,再分别去取 —— 都不能被挡住。
73
+ // 先把 EnvLockPath 清掉,让锁落到各自的 dataDir 下(这才是真实部署形态)。
74
+ t.Setenv(EnvLockPath, "")
75
+
76
+ lkA, holderA, err := AcquireSingleInstance(dirA, 9100)
77
+ if err != nil || lkA == nil {
78
+ t.Fatalf("数据目录 A 没拿到锁:err=%v holder=%+v", err, holderA)
79
+ }
80
+ defer lkA.Release()
81
+
82
+ lkB, holderB, err := AcquireSingleInstance(dirB, 8977)
83
+ if err != nil {
84
+ t.Fatalf("数据目录 B 出错:%v", err)
85
+ }
86
+ if lkB == nil {
87
+ t.Fatalf("不同数据目录竟然被挡住了(holder=%+v)—— 这就是误伤 magic-test 网关的那个 bug", holderB)
88
+ }
89
+ defer lkB.Release()
90
+
91
+ // 而同一个目录上的第二份仍然要被挡住(互斥本身不能因为分目录而失效)。
92
+ lkA2, holderA2, err := AcquireSingleInstance(dirA, 0)
93
+ if err != nil {
94
+ t.Fatalf("同目录第二份出错:%v", err)
95
+ }
96
+ if lkA2 != nil {
97
+ lkA2.Release()
98
+ t.Fatal("同一个数据目录上竟然起了两份")
99
+ }
100
+ if holderA2 == nil || holderA2.PID != os.Getpid() {
101
+ t.Errorf("同目录冲突时没报出占用者:%+v", holderA2)
102
+ }
103
+ }
104
+
105
+ // TestAcquireSingleInstanceFirstWins 空目录下第一份拿到锁。
106
+ func TestAcquireSingleInstanceFirstWins(t *testing.T) {
107
+ dataDir := lockInTemp(t)
108
+ path := lockPathOf(dataDir)
109
+ lk, holder, err := AcquireSingleInstance(dataDir, 9100)
110
+ if err != nil {
111
+ t.Fatalf("AcquireSingleInstance: %v", err)
112
+ }
113
+ if lk == nil {
114
+ t.Fatalf("空目录下没拿到锁,holder=%+v", holder)
115
+ }
116
+ defer lk.Release()
117
+
118
+ h := ReadSingleInstanceHolder(path)
119
+ if h == nil || h.PID != os.Getpid() {
120
+ t.Fatalf("锁文件内容 = %+v,期望 pid=%d", h, os.Getpid())
121
+ }
122
+ if h.Port != 9100 {
123
+ t.Errorf("Port = %d, want 9100", h.Port)
124
+ }
125
+ }
126
+
127
+ // TestAcquireSingleInstanceBlocksSecond 活着的实例占着锁时,第二份必须拿不到。
128
+ //
129
+ // 这是核心断言:拿不到不是错误(err 该为 nil),而是返回 nil + 占用者身份,
130
+ // 让调用方打一句「谁在跑、该去停它」。
131
+ func TestAcquireSingleInstanceBlocksSecond(t *testing.T) {
132
+ dataDir := lockInTemp(t)
133
+ path := lockPathOf(dataDir)
134
+ writeHolder(t, path, SingleInstanceHolder{
135
+ PID: os.Getpid(), Port: 9100, Version: "9.9.9", Since: time.Now(),
136
+ })
137
+
138
+ lk, holder, err := AcquireSingleInstance(dataDir, 0)
139
+ if err != nil {
140
+ t.Fatalf("不该返回 err(那是「有人在跑」,不是 IO 故障):%v", err)
141
+ }
142
+ if lk != nil {
143
+ lk.Release()
144
+ t.Fatal("第二份竟然拿到了锁")
145
+ }
146
+ if holder == nil || holder.PID != os.Getpid() {
147
+ t.Fatalf("占用人身份丢失:%+v", holder)
148
+ }
149
+ if holder.Version != "9.9.9" || holder.Port != 9100 {
150
+ t.Errorf("占用人信息不完整:%+v", holder)
151
+ }
152
+ }
153
+
154
+ // TestAcquireSingleInstanceReclaimsDeadHolder 持有者已死 → 自动回收,不卡住用户。
155
+ func TestAcquireSingleInstanceReclaimsDeadHolder(t *testing.T) {
156
+ dataDir := lockInTemp(t)
157
+ path := lockPathOf(dataDir)
158
+ dead := deadPID(t)
159
+ writeHolder(t, path, SingleInstanceHolder{PID: dead, Since: time.Now()})
160
+
161
+ lk, holder, err := AcquireSingleInstance(dataDir, 0)
162
+ if err != nil {
163
+ t.Fatalf("AcquireSingleInstance: %v", err)
164
+ }
165
+ if lk == nil {
166
+ t.Fatalf("持有者已死却没回收,holder=%+v", holder)
167
+ }
168
+ defer lk.Release()
169
+ if h := ReadSingleInstanceHolder(path); h == nil || h.PID != os.Getpid() {
170
+ t.Errorf("回收后锁内容 = %+v,期望是当前进程", h)
171
+ }
172
+ }
173
+
174
+ // TestAcquireSingleInstanceReclaimsStaleByAge 超过上限的锁按陈旧处理。
175
+ //
176
+ // 为什么要这条:进程崩溃没删锁、或 pid 被系统复用给了一个无关进程时,
177
+ // 「pid 活着」会误判成「有人在跑」,把用户永久锁在外面。时间上限是最后一道保险。
178
+ func TestAcquireSingleInstanceReclaimsStaleByAge(t *testing.T) {
179
+ dataDir := lockInTemp(t)
180
+ path := lockPathOf(dataDir)
181
+ writeHolder(t, path, SingleInstanceHolder{
182
+ PID: os.Getpid(), // pid 活着,但时间太久
183
+ Since: time.Now().Add(-singleInstanceStale - time.Minute),
184
+ })
185
+
186
+ lk, holder, err := AcquireSingleInstance(dataDir, 0)
187
+ if err != nil {
188
+ t.Fatalf("AcquireSingleInstance: %v", err)
189
+ }
190
+ if lk == nil {
191
+ t.Fatalf("超龄锁没被回收,holder=%+v", holder)
192
+ }
193
+ defer lk.Release()
194
+ }
195
+
196
+ // TestStaleLockFileIsNotCached 坏内容 / 老格式不能把用户挡在外面。
197
+ func TestStaleLockFileIsNotCached(t *testing.T) {
198
+ t.Run("半截内容按陈旧处理", func(t *testing.T) {
199
+ dataDir := lockInTemp(t)
200
+ path := lockPathOf(dataDir)
201
+ if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ lk, _, err := AcquireSingleInstance(dataDir, 0)
205
+ if err != nil {
206
+ t.Fatalf("err = %v", err)
207
+ }
208
+ if lk == nil {
209
+ t.Fatal("内容坏掉的锁应被回收")
210
+ }
211
+ lk.Release()
212
+ })
213
+
214
+ t.Run("老格式纯 pid 仍能读出占用者", func(t *testing.T) {
215
+ dataDir := lockInTemp(t)
216
+ path := lockPathOf(dataDir)
217
+ if err := os.WriteFile(path, []byte("999999\n1700000000\n"), 0o600); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ h := ReadSingleInstanceHolder(path)
221
+ if h == nil || h.PID != 999999 {
222
+ t.Fatalf("老格式读成 %+v,期望 pid=999999", h)
223
+ }
224
+ })
225
+ }
226
+
227
+ // TestReleaseDoesNotDeleteOthersLock 只删自己的锁。
228
+ //
229
+ // 场景:本进程的锁被判陈旧后,另一个实例抢到并写下自己的 pid。此时本进程
230
+ // 收尾执行 Release —— 如果无脑删文件,会把别人的互斥一起清掉,两个实例并存。
231
+ func TestReleaseDoesNotDeleteOthersLock(t *testing.T) {
232
+ dataDir := lockInTemp(t)
233
+ path := lockPathOf(dataDir)
234
+ lk, _, err := AcquireSingleInstance(dataDir, 0)
235
+ if err != nil || lk == nil {
236
+ t.Fatalf("拿锁失败:%v", err)
237
+ }
238
+ // 模拟锁被他人接手
239
+ writeHolder(t, path, SingleInstanceHolder{PID: os.Getpid() + 1<<20, Since: time.Now()})
240
+ lk.Release()
241
+ if _, statErr := os.Stat(path); statErr != nil {
242
+ t.Error("Release 删掉了别人的锁文件")
243
+ }
244
+ }
245
+
246
+ // TestReleaseIsIdempotent 重复 Release 不炸。
247
+ func TestReleaseIsIdempotent(t *testing.T) {
248
+ dataDir := lockInTemp(t)
249
+ lk, _, err := AcquireSingleInstance(dataDir, 0)
250
+ if err != nil || lk == nil {
251
+ t.Fatalf("拿锁失败:%v", err)
252
+ }
253
+ lk.Release()
254
+ lk.Release()
255
+ var nilLock *SingleInstanceLock
256
+ nilLock.Release() // nil 也要安全
257
+ }
258
+
259
+ // TestSingleInstanceConflictMessage 报错文本必须说清「该去停谁」。
260
+ //
261
+ // 这条不是文字洁癖:用户看到「已在运行」只会去换端口,换端口解决不了问题
262
+ // (两份实例照样抢作业目录)。必须明确指向 pid 与 stop 命令。
263
+ func TestSingleInstanceConflictMessage(t *testing.T) {
264
+ msg := SingleInstanceConflictMessage(&SingleInstanceHolder{
265
+ PID: 4321, Port: 9100, Version: "2.6.2",
266
+ Exe: "/opt/magic-client",
267
+ Since: time.Now().Add(-2 * time.Minute),
268
+ })
269
+ for _, want := range []string{"4321", "9100", "2.6.2", "magic-client stop", "/opt/magic-client"} {
270
+ if !strings.Contains(msg, want) {
271
+ t.Errorf("报错文本缺少 %q:\n%s", want, msg)
272
+ }
273
+ }
274
+ if msg := SingleInstanceConflictMessage(nil); !strings.Contains(msg, "magic-client stop") {
275
+ t.Errorf("holder 为空时也要给出可执行的下一步:%s", msg)
276
+ }
277
+ }
278
+
279
+ // skipIfKillBlocked 本环境里 agent.KillPID 是否真的生效。
280
+ //
281
+ // 有的沙箱会**静默吞掉** kill:syscall.Kill 返回 nil(没报错),进程照旧活着。
282
+ // 注意探针**必须**走 agent.KillPID —— os/exec 的 Process.Kill 在 runtime 里有另一条
283
+ // 路径,在沙箱里通常照样成功,拿它当判据会得出「kill 好用」的错误结论(本轮实测过)。
284
+ func skipIfKillBlocked(t *testing.T) {
285
+ t.Helper()
286
+ cmd := exec.Command("sleep", "30")
287
+ if err := cmd.Start(); err != nil {
288
+ t.Skipf("起不来探针进程:%v", err)
289
+ }
290
+ defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
291
+ pid := cmd.Process.Pid
292
+ _ = agent.KillPID(pid, false)
293
+ deadline := time.Now().Add(2 * time.Second)
294
+ for time.Now().Before(deadline) {
295
+ if !lockPIDAlive(pid) {
296
+ return
297
+ }
298
+ time.Sleep(20 * time.Millisecond)
299
+ }
300
+ t.Skip("这个环境里 kill 被拦截(返回成功但进程杀不掉),跳过「停掉进程」类断言")
301
+ }
302
+
303
+ // TestStopReclaimsLockHolderWithoutState `magic-client stop` 必须能停掉**不写状态文件**
304
+ // 的实例(前台 serve / 旧版 *.stale)。
305
+ //
306
+ // 为什么:2026-10-26 的现场就是这个 —— 8977 与 54428 上各挂着一份前台 serve,
307
+ // 状态文件不存在,`stop` 答「本来就没在跑」,用户只能手工 kill。没有这条,
308
+ // 单实例锁反而会把用户锁死(新实例被挡在门外,旧实例又停不掉)。
309
+ func TestStopReclaimsLockHolderWithoutState(t *testing.T) {
310
+ skipIfKillBlocked(t)
311
+ dataDir := lockInTemp(t)
312
+ lockPath := lockPathOf(dataDir)
313
+
314
+ // 起一个替身进程,用它充当「另一份不写状态文件的实例」。
315
+ cmd := exec.Command("sleep", "60")
316
+ if err := cmd.Start(); err != nil {
317
+ t.Skipf("起不来替身进程:%v", err)
318
+ }
319
+ defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
320
+
321
+ writeHolder(t, lockPath, SingleInstanceHolder{
322
+ PID: cmd.Process.Pid, Port: 8977, Exe: "/opt/magic-client.stale", Since: time.Now(),
323
+ })
324
+
325
+ statePath := filepath.Join(t.TempDir(), "client.json")
326
+ stopped, st, err := StopDaemon(StopOptions{
327
+ StatePath: statePath, AlsoLockHolder: true, DataDir: dataDir,
328
+ })
329
+ if err != nil {
330
+ t.Fatalf("StopDaemon: %v", err)
331
+ }
332
+ if !stopped {
333
+ t.Fatalf("没停掉锁持有者(st=%+v)—— 用户会陷入「新的起不来、旧的停不掉」", st)
334
+ }
335
+ if st == nil || st.PID != cmd.Process.Pid {
336
+ t.Errorf("回告的实例 = %+v,期望 pid=%d", st, cmd.Process.Pid)
337
+ }
338
+ if lockPIDAlive(cmd.Process.Pid) {
339
+ t.Error("替身进程还活着")
340
+ }
341
+ if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
342
+ t.Errorf("锁文件没清掉(err=%v):下次启动会走「持有者已死」的回收路径", err)
343
+ }
344
+ }
345
+
346
+ // TestStopLeavesLockHolderWhenNotAsked restart 走的是同一条 StopDaemon,
347
+ // 但它绝不能顺手停掉前台实例 —— 参数无从重建,等于「先把用户手上的调试会话杀了再报错」。
348
+ func TestStopLeavesLockHolderWhenNotAsked(t *testing.T) {
349
+ dataDir := lockInTemp(t)
350
+ lockPath := lockPathOf(dataDir)
351
+ cmd := exec.Command("sleep", "60")
352
+ if err := cmd.Start(); err != nil {
353
+ t.Skipf("起不来替身进程:%v", err)
354
+ }
355
+ defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
356
+ writeHolder(t, lockPath, SingleInstanceHolder{PID: cmd.Process.Pid, Since: time.Now()})
357
+
358
+ statePath := filepath.Join(t.TempDir(), "client.json")
359
+ stopped, _, err := StopDaemon(StopOptions{StatePath: statePath})
360
+ if err != nil {
361
+ t.Fatalf("StopDaemon: %v", err)
362
+ }
363
+ if stopped {
364
+ t.Error("没要求也去停了锁持有者")
365
+ }
366
+ if !lockPIDAlive(cmd.Process.Pid) {
367
+ t.Error("替身进程被误杀")
368
+ }
369
+ }
370
+
371
+ // TestLockPIDAlive 探活判据。
372
+ func TestLockPIDAlive(t *testing.T) {
373
+ if !lockPIDAlive(os.Getpid()) {
374
+ t.Error("自己应该是活的")
375
+ }
376
+ if lockPIDAlive(0) || lockPIDAlive(-1) {
377
+ t.Error("非法 pid 应判死")
378
+ }
379
+ dead := deadPID(t)
380
+ if lockPIDAlive(dead) {
381
+ t.Errorf("已退出的 pid %d 被误判为活", dead)
382
+ }
383
+ }
384
+
385
+ /* ── AppID 级占用登记(2026-10-26 补,治 DataDir 漂移)── */
386
+
387
+ // claimInTemp 把 AppID 登记目录指到临时目录。
388
+ func claimInTemp(t *testing.T) string {
389
+ t.Helper()
390
+ dir := t.TempDir()
391
+ t.Setenv(EnvAppIDLockDir, dir)
392
+ t.Setenv(EnvLockPath, "") // DataDir 锁落到各自 dataDir 下
393
+ return dir
394
+ }
395
+
396
+ // TestAppIDClaimBlocksDriftedDataDir 同一 AppID 换了数据目录也要被挡住。
397
+ //
398
+ // 这是本轮最要紧的一条:实测本机 magic-test 既有 ~/.magic-test(profile 写了
399
+ // storage.dir)又有 ~/.magic-client/magic-test(profile 没读到,回落到 AppID 推导)。
400
+ // 两条路径各自一把 DataDir 锁 → **两份都放行**,而作业记录不互通 →
401
+ // 「手机连着 A,任务在 B 里跑完,界面没有收到任何输出」。只看 DataDir 防不住这个。
402
+ func TestAppIDClaimBlocksDriftedDataDir(t *testing.T) {
403
+ claimDir := claimInTemp(t)
404
+
405
+ /* 用一个**真实存活的替身进程**占住 AppID 登记,而不是用本进程 pid。
406
+ 为什么:AcquireAppIDClaim 对「pid == 自己」有幂等续期分支(同一进程重复调用
407
+ 不该被自己挡),用本进程 pid 会走到那条分支上,测不到漂移判定。
408
+ 替身进程必须活着 —— 死了会被判陈旧回收,同样测不到。 */
409
+ cmd := exec.Command("sleep", "30")
410
+ if err := cmd.Start(); err != nil {
411
+ t.Skipf("起不来替身进程:%v", err)
412
+ }
413
+ defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
414
+
415
+ if err := os.MkdirAll(claimDir, 0o700); err != nil {
416
+ t.Fatal(err)
417
+ }
418
+ occupiedDir := t.TempDir()
419
+ writeHolder(t, filepath.Join(claimDir, "com.magic.test.lock"), SingleInstanceHolder{
420
+ PID: cmd.Process.Pid, DataDir: occupiedDir, Since: time.Now(),
421
+ })
422
+
423
+ // 同一 AppID、另一个数据目录(模拟 storage.dir 丢失后的漂移)。
424
+ driftedDir := t.TempDir()
425
+ lk, holder, which, err := AcquireInstanceLocks("com.magic.test", driftedDir, 0)
426
+ if err != nil {
427
+ t.Fatalf("不该返回 IO 错误:%v", err)
428
+ }
429
+ if lk != nil {
430
+ lk.Release()
431
+ t.Fatal("同一 AppID 换了数据目录竟然放行了 —— 这就是 DataDir 漂移漏洞")
432
+ }
433
+ if which != "appid" {
434
+ t.Errorf("which = %q,期望 \"appid\"(要能区分是哪一道闸门挡的)", which)
435
+ }
436
+ if holder == nil || holder.PID != cmd.Process.Pid {
437
+ t.Fatalf("没报出占用者:%+v", holder)
438
+ }
439
+ if !samePath(holder.DataDir, occupiedDir) {
440
+ t.Errorf("占用者的 DataDir = %q,期望 %q —— 用户靠它才知道该去停哪个目录的实例",
441
+ holder.DataDir, occupiedDir)
442
+ }
443
+ }
444
+
445
+ // TestAppIDClaimAllowsDifferentApps 不同 AppID 必须互不影响(magic-test 与 magic-agent 并存)。
446
+ func TestAppIDClaimAllowsDifferentApps(t *testing.T) {
447
+ claimInTemp(t)
448
+
449
+ lkA, hA, _, err := AcquireInstanceLocks("com.magic.test", t.TempDir(), 8977)
450
+ if err != nil || lkA == nil {
451
+ t.Fatalf("magic-test 没起来:err=%v holder=%+v", err, hA)
452
+ }
453
+ defer lkA.Release()
454
+
455
+ lkB, hB, which, err := AcquireInstanceLocks("magic-client", t.TempDir(), 9100)
456
+ if err != nil || lkB == nil {
457
+ t.Fatalf("不同 AppID 被挡住了(which=%s holder=%+v)—— 这就是误伤 8977 网关的那个 bug", which, hB)
458
+ }
459
+ defer lkB.Release()
460
+ }
461
+
462
+ // TestAppIDClaimAllowsSameDataDir 同一 AppID 同一数据目录时,AppID 这道不挡,交给 DataDir 锁判。
463
+ //
464
+ // 为什么:同一份实例重复调用一次(或 CLI 先探一次再真起)不该被自己挡两道。
465
+ // 判据要区分是哪一道挡的 —— 报错文案完全不同,用户照做的动作也不同。
466
+ func TestAppIDClaimAllowsSameDataDir(t *testing.T) {
467
+ claimDir := claimInTemp(t)
468
+ sharedDir := t.TempDir()
469
+
470
+ // 替身进程占住「同一 AppID + 同一数据目录」(见上一条注释:不能用本进程 pid)。
471
+ cmd := exec.Command("sleep", "30")
472
+ if err := cmd.Start(); err != nil {
473
+ t.Skipf("起不来替身进程:%v", err)
474
+ }
475
+ defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
476
+
477
+ if err := os.MkdirAll(claimDir, 0o700); err != nil {
478
+ t.Fatal(err)
479
+ }
480
+ writeHolder(t, filepath.Join(claimDir, "app.one.lock"), SingleInstanceHolder{
481
+ PID: cmd.Process.Pid, DataDir: sharedDir, Since: time.Now(),
482
+ })
483
+ // DataDir 锁也占上(模拟那个进程真的起来了)。
484
+ dirLock, _, err := AcquireSingleInstance(sharedDir, 0)
485
+ if err != nil || dirLock == nil {
486
+ t.Fatalf("预置 DataDir 锁失败:%v", err)
487
+ }
488
+ defer dirLock.Release()
489
+
490
+ lk, holder, which, err := AcquireInstanceLocks("app.one", sharedDir, 0)
491
+ if err != nil {
492
+ t.Fatalf("不该是 IO 错误:%v", err)
493
+ }
494
+ if lk != nil {
495
+ lk.Release()
496
+ t.Fatal("同一数据目录上起了两份 —— DataDir 锁失效了")
497
+ }
498
+ if which != "datadir" {
499
+ t.Errorf("which = %q,期望 \"datadir\"(同一目录不该报成「换了目录」)", which)
500
+ }
501
+ if holder == nil {
502
+ t.Error("没报出占用者")
503
+ }
504
+ }
505
+
506
+ // TestAppIDClaimReclaimsDeadHolder 持有者已死 → 回收,不把用户永久锁在门外。
507
+ func TestAppIDClaimReclaimsDeadHolder(t *testing.T) {
508
+ dir := claimInTemp(t)
509
+ dead := deadPID(t)
510
+ if err := os.MkdirAll(dir, 0o700); err != nil {
511
+ t.Fatal(err)
512
+ }
513
+ writeHolder(t, filepath.Join(dir, "app.dead.lock"), SingleInstanceHolder{
514
+ PID: dead, DataDir: t.TempDir(), Since: time.Now(),
515
+ })
516
+
517
+ lk, holder, which, err := AcquireInstanceLocks("app.dead", t.TempDir(), 0)
518
+ if err != nil || lk == nil {
519
+ t.Fatalf("持有者已死却没回收:err=%v holder=%+v which=%s", err, holder, which)
520
+ }
521
+ lk.Release()
522
+ }
523
+
524
+ // TestAcquireInstanceLocksReleasesBothOnFailure DataDir 挡下时必须把已拿到的 AppID 登记放掉。
525
+ //
526
+ // 否则会留下一条指向本进程的登记 —— 本进程还活着,它就不会被判陈旧,
527
+ // 于是「另一份想用别的目录起」会一直被挡,直到本进程退出。这个泄漏极难排查。
528
+ func TestAcquireInstanceLocksReleasesBothOnFailure(t *testing.T) {
529
+ claimDir := claimInTemp(t)
530
+ sharedDir := t.TempDir()
531
+
532
+ // 先在 sharedDir 上占住 DataDir 锁(模拟「这个目录上已有一份在跑」)。
533
+ other, _, err := AcquireSingleInstance(sharedDir, 0)
534
+ if err != nil || other == nil {
535
+ t.Fatalf("预置锁失败:%v", err)
536
+ }
537
+ defer other.Release()
538
+
539
+ lk, _, which, err := AcquireInstanceLocks("app.leak", sharedDir, 0)
540
+ if err != nil {
541
+ t.Fatalf("不该是 IO 错误:%v", err)
542
+ }
543
+ if lk != nil {
544
+ lk.Release()
545
+ t.Fatal("不该拿到锁")
546
+ }
547
+ if which != "datadir" {
548
+ t.Errorf("which = %q,期望 datadir", which)
549
+ }
550
+ // 关键:AppID 登记必须被清掉。
551
+ claimPath := filepath.Join(claimDir, "app.leak.lock")
552
+ if _, statErr := os.Stat(claimPath); !os.IsNotExist(statErr) {
553
+ t.Errorf("DataDir 挡下后 AppID 登记泄漏了(%s 还在)—— 之后换目录也起不来", claimPath)
554
+ }
555
+ }
556
+
557
+ // TestSanitizeAppID AppID 里的点与斜杠不能变成路径分隔符。
558
+ func TestSanitizeAppID(t *testing.T) {
559
+ cases := map[string]string{
560
+ "com.magic.test": "com.magic.test", // 点保留(bundleId 的常态)
561
+ "a/b": "a_b", // 斜杠必须换掉,否则写到别的目录
562
+ "../evil": ".._evil",
563
+ "": "unknown",
564
+ "名字": "unknown", // 只留 ASCII,全被换掉后兜底
565
+ }
566
+ for in, want := range cases {
567
+ if got := sanitizeAppID(in); got != want {
568
+ t.Errorf("sanitizeAppID(%q) = %q, want %q", in, got, want)
569
+ }
570
+ }
571
+ }
572
+
573
+ // TestSamePath 同一目录的判定(~ 展开、相对路径、符号链接)。
574
+ func TestSamePath(t *testing.T) {
575
+ dir := t.TempDir()
576
+ if !samePath(dir, dir) {
577
+ t.Error("同一路径应判同一份")
578
+ }
579
+ if !samePath(dir, dir+"/") {
580
+ t.Error("末尾斜杠不该算两个目录")
581
+ }
582
+ if !samePath(dir, dir+"/.") {
583
+ t.Error("./ 不该算两个目录")
584
+ }
585
+ if samePath(dir, t.TempDir()) {
586
+ t.Error("不同目录被判成同一份")
587
+ }
588
+ if samePath("", dir) || samePath(dir, "") {
589
+ t.Error("空路径不该算同一份")
590
+ }
591
+ }
592
+
593
+ // TestAppIDConflictMessage 报错必须说清「换目录不算两份应用」。
594
+ func TestAppIDConflictMessage(t *testing.T) {
595
+ msg := AppIDConflictMessage("com.magic.test", &SingleInstanceHolder{
596
+ PID: 4321, DataDir: "/Users/x/.magic-test", Version: "2.6.2",
597
+ Exe: "/opt/magic-client", Since: time.Now().Add(-3 * time.Minute),
598
+ })
599
+ for _, want := range []string{"com.magic.test", "4321", "/Users/x/.magic-test", "magic-client stop"} {
600
+ if !strings.Contains(msg, want) {
601
+ t.Errorf("报错文本缺少 %q:\n%s", want, msg)
602
+ }
603
+ }
604
+ if m := AppIDConflictMessage("x", nil); !strings.Contains(m, "magic-client stop") {
605
+ t.Errorf("holder 为空时也要给出可执行的下一步:%s", m)
606
+ }
607
+ }