magic-agent 2.6.0 → 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 (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -0,0 +1,516 @@
1
+ package client
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "encoding/json"
7
+ "net"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "os"
11
+ "os/exec"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "testing"
16
+ "time"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ )
20
+
21
+ /* 「发现 → 应用」:magic-shorts / magic-video 工作台的启停与反代。
22
+ *
23
+ * ① 清单只从服务端那张表来,状态以**端口探活**为准(外部起的也认);
24
+ * ② start 起假工作台 → running/managed;stop 杀掉整组 → stopped;
25
+ * ③ 起不来必须把输出尾巴回给客户端(ok=false),不能静默当成功;
26
+ * ④ 反代:/workbench/{id}/ 探活后跳 "/";根路径请求带 Referer 时转给工作台,没有时不转。 */
27
+
28
+ // fakeAppRepo 造一个「假工作台源码目录」:node 起一个只回一行 JSON 的 HTTP 服务。
29
+ func fakeAppRepo(t *testing.T) (repo string) {
30
+ t.Helper()
31
+ if _, ok := nodeBin(); !ok {
32
+ t.Skip("这台机器没有 node,跳过工作台启停用例")
33
+ }
34
+ root := t.TempDir()
35
+ repo = filepath.Join(root, "magic-shorts")
36
+ if err := os.MkdirAll(repo, 0o755); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ server := `const http = require('http');
40
+ const port = Number(process.env.PORT || 8899);
41
+ const host = process.env.HOST || '127.0.0.1';
42
+ http.createServer((req, res) => {
43
+ if (req.url.startsWith('/api/ping')) {
44
+ res.writeHead(200, {'Content-Type': 'application/json'});
45
+ return res.end(JSON.stringify({ok: true, via: 'workbench', referer: req.headers.referer || ''}));
46
+ }
47
+ res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
48
+ res.end('<!doctype html><title>fake-shorts</title><script>fetch("/api/ping")</script>');
49
+ }).listen(port, host, () => console.log('fake workbench on ' + host + ':' + port));
50
+ `
51
+ if err := os.WriteFile(filepath.Join(repo, "server.js"), []byte(server), 0o644); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ // magic-video 的源码目录刻意不存在 → 「未找到」那条分支也可断言。
55
+ t.Setenv("MAGIC_CLIENT_SHORTS_DIR", repo)
56
+ t.Setenv("MAGIC_CLIENT_VIDEO_DIR", filepath.Join(root, "magic-video"))
57
+ t.Setenv("MAGIC_CLIENT_APPS_DIR", filepath.Join(root, "apps"))
58
+ t.Setenv("MAGIC_CLIENT_APPS_HOME", filepath.Join(root, "home")) // 端口文件探测不碰真实 ~/
59
+ return repo
60
+ }
61
+
62
+ // freePort 挑一个当前空闲的端口(避免和本机真在跑的 8899 / 7789 撞上)。
63
+ // 取到后立刻关掉,仍有一个很小的竞态窗口 —— 这段窗口里没有别人来抢,够用。
64
+ func freePort(t *testing.T) int {
65
+ t.Helper()
66
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
67
+ if err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ port := ln.Addr().(*net.TCPAddr).Port
71
+ ln.Close()
72
+ return port
73
+ }
74
+
75
+ // probePort 端口上有没有人在应答(外部假工作台就绪用)。
76
+ func probePort(port int) bool {
77
+ resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/")
78
+ if err != nil {
79
+ return false
80
+ }
81
+ resp.Body.Close()
82
+ return true
83
+ }
84
+
85
+ // appsService 进程内服务;port>0 时管理器按「magic-shorts 用这个端口」探活。
86
+ //
87
+ // ⚠️ 管理器走 newAppManager(**不**直接 &appManager{}):落盘目录 / 日志目录都在构造函数里
88
+ //
89
+ // 算好,手搓一个会让 start 的日志与运行态静默落到空目录里。
90
+ func appsService(port int) *Service {
91
+ svc := &Service{opts: Options{}}
92
+ svc.apps = newAppManager(svc)
93
+ if port > 0 {
94
+ svc.apps.specOf = func(spec appSpec) appSpec {
95
+ if spec.ID == "magic-shorts" {
96
+ spec.Port = port
97
+ spec.Urls = func() []int { return []int{port} }
98
+ }
99
+ return spec
100
+ }
101
+ }
102
+ return svc
103
+ }
104
+
105
+ /* isolateShorts 造一个假工作台源码目录与一个临时端口,并让进程内的管理器认这个端口。
106
+ *
107
+ * 为什么需要:本机很可能**真的**开着 magic-shorts / magic-video 工作台(用户自己的),
108
+ * 直接用 8899 / 7789 的用例会被那个外部实例短路成「运行中(外部)」。测试要能重复,
109
+ * 就不能依赖本机状态 —— 端口文件探测也被指向了临时目录(MAGIC_CLIENT_APPS_HOME)。 */
110
+ func isolateShorts(t *testing.T) (svc *Service, repo string, port int) {
111
+ t.Helper()
112
+ repo = fakeAppRepo(t)
113
+ port = freePort(t)
114
+ return appsService(port), repo, port
115
+ }
116
+
117
+ // appSpecWithPort 把注册表里某个应用的端口换成指定端口、源码目录换成假的。
118
+ func appSpecWithPort(t *testing.T, id string, port int, repo string) appSpec {
119
+ t.Helper()
120
+ spec, ok := findApp(id)
121
+ if !ok {
122
+ t.Fatalf("注册表里没有 %s", id)
123
+ }
124
+ spec.Port = port
125
+ spec.Repo = repo
126
+ spec.Urls = func() []int { return []int{port} }
127
+ return spec
128
+ }
129
+
130
+ // 解析 SSE 响应体:事件名 → data 列表。
131
+ func parseSSE(t *testing.T, body string) map[string][]string {
132
+ t.Helper()
133
+ out := map[string][]string{}
134
+ sc := bufio.NewScanner(strings.NewReader(body))
135
+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
136
+ ev := ""
137
+ for sc.Scan() {
138
+ line := sc.Text()
139
+ switch {
140
+ case strings.HasPrefix(line, "event: "):
141
+ ev = strings.TrimSpace(strings.TrimPrefix(line, "event: "))
142
+ case strings.HasPrefix(line, "data: "):
143
+ out[ev] = append(out[ev], strings.TrimPrefix(line, "data: "))
144
+ }
145
+ }
146
+ return out
147
+ }
148
+
149
+ func TestAppsListReportsCatalogAndMissingSource(t *testing.T) {
150
+ repo := fakeAppRepo(t)
151
+ svc := appsService(0)
152
+ spec := appSpecWithPort(t, "magic-shorts", freePort(t), repo)
153
+
154
+ mgr := svc.appMgr()
155
+ item := mgr.info(spec, true)
156
+ if item.State.State != "stopped" || item.State.Running {
157
+ t.Fatalf("没起时状态应为 stopped,实际 %+v", item.State)
158
+ }
159
+ if item.State.URL != "/workbench/magic-shorts/" {
160
+ t.Fatalf("手机入口 = %q,期望 /workbench/magic-shorts/", item.State.URL)
161
+ }
162
+ if item.SourcePath != repo {
163
+ t.Fatalf("源码目录 = %q,期望 %q", item.SourcePath, repo)
164
+ }
165
+
166
+ // 另一项:源码目录不存在 → 状态说明里必须点出来,不能装作能起。
167
+ video := appSpecWithPort(t, "magic-video", freePort(t), filepath.Join(t.TempDir(), "nope"))
168
+ vinfo := mgr.info(video, true)
169
+ // 本机 PATH 上有没有 magic-video 决定是哪一句,但两句都必须点出「源码目录不在」。
170
+ if !strings.Contains(vinfo.Note, "未找到") && !strings.Contains(vinfo.Note, "源码目录不在") {
171
+ t.Fatalf("源码不存在时状态说明应点出来,实际 %q", vinfo.Note)
172
+ }
173
+
174
+ // GET /desk/apps:形状与客户端约定一致。
175
+ rec := httptest.NewRecorder()
176
+ svc.handleApps(rec, httptest.NewRequest(http.MethodGet, "/desk/apps", nil))
177
+ if rec.Code != http.StatusOK {
178
+ t.Fatalf("状态码 = %d", rec.Code)
179
+ }
180
+ var body struct {
181
+ Apps []AppItem `json:"apps"`
182
+ }
183
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
184
+ t.Fatalf("响应不是合法 JSON:%v", err)
185
+ }
186
+ if len(body.Apps) != 2 {
187
+ t.Fatalf("应用数 = %d,期望 2", len(body.Apps))
188
+ }
189
+ }
190
+
191
+ func TestAppsStartStopAndProxy(t *testing.T) {
192
+ svc, repo, port := isolateShorts(t)
193
+ mgr := svc.appMgr()
194
+ spec := appSpecWithPort(t, "magic-shorts", port, repo)
195
+
196
+ // 起
197
+ item, err := mgr.start(spec)
198
+ if err != nil {
199
+ t.Fatalf("启动失败:%v", err)
200
+ }
201
+ if !item.State.Running || !item.State.Managed || item.State.Port != port {
202
+ t.Fatalf("起来后状态不对:%+v", item.State)
203
+ }
204
+ defer func() {
205
+ if _, err := mgr.stop(spec); err != nil {
206
+ t.Logf("收尾停止失败:%v", err)
207
+ }
208
+ }()
209
+
210
+ // 反代:入口 → 302 "/"
211
+ rec := httptest.NewRecorder()
212
+ svc.handleWorkbench(rec, httptest.NewRequest(http.MethodGet, "/workbench/magic-shorts/", nil))
213
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?via=magic-shorts" {
214
+ t.Fatalf("入口应 302 到 /?via=<id>,实际 %d Location=%q", rec.Code, rec.Header().Get("Location"))
215
+ }
216
+
217
+ // 工作台页面在根路径下跑:带 Referer 的根路径请求要转给工作台。
218
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
219
+ req.Host = "192.168.1.9:9100"
220
+ req.Header.Set("Referer", "http://192.168.1.9:9100/workbench/magic-shorts/")
221
+ rec = httptest.NewRecorder()
222
+ svc.serveWorkbench(rec, req, spec, port, "")
223
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "fake-shorts") {
224
+ t.Fatalf("根路径应转给工作台,实际 %d %q", rec.Code, rec.Body.String())
225
+ }
226
+
227
+ // 页面里的 '/api/ping' 也带着同一个 Referer → 同样转给工作台。
228
+ req = httptest.NewRequest(http.MethodGet, "/api/ping", nil)
229
+ req.Host = "192.168.1.9:9100"
230
+ req.Header.Set("Referer", "http://192.168.1.9:9100/workbench/magic-shorts/")
231
+ rec = httptest.NewRecorder()
232
+ svc.serveWorkbench(rec, req, spec, port, "")
233
+ if !strings.Contains(rec.Body.String(), `"via":"workbench"`) {
234
+ t.Fatalf("/api/ping 没转到工作台:%d %q", rec.Code, rec.Body.String())
235
+ }
236
+
237
+ // 子路径反代:/workbench/{id}/api/ping → 工作台 /api/ping
238
+ rec = httptest.NewRecorder()
239
+ svc.handleWorkbench(rec, httptest.NewRequest(http.MethodGet, "/workbench/magic-shorts/api/ping", nil))
240
+ if !strings.Contains(rec.Body.String(), `"via":"workbench"`) {
241
+ t.Fatalf("子路径没转到工作台:%d %q", rec.Code, rec.Body.String())
242
+ }
243
+
244
+ // 别的站点的 Referer 不转(宿主必须与本次请求一致)。
245
+ if _, _, ok := svc.workbenchFor(func() *http.Request {
246
+ r := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
247
+ r.Host = "192.168.1.9:9100"
248
+ r.Header.Set("Referer", "http://evil.example/workbench/magic-shorts/")
249
+ return r
250
+ }()); ok {
251
+ t.Fatal("跨宿主的 Referer 不该被当成工作台请求")
252
+ }
253
+
254
+ /* 内置浏览器可能一个 Referer 都不发(iframe 带 referrerpolicy="no-referrer"),
255
+ 所以只看地址上的 ?via= 也必须认得出是工作台 —— 否则页面起来了、数据全是客户端的。 */
256
+ viaReq := func(path string) *http.Request {
257
+ r := httptest.NewRequest(http.MethodGet, path, nil)
258
+ r.Host = "192.168.1.9:9100"
259
+ return r
260
+ }
261
+ if got, gotPort, ok := svc.workbenchFor(viaReq("/?via=magic-shorts")); !ok || got.ID != "magic-shorts" || gotPort != port {
262
+ t.Fatalf("?via=magic-shorts 应认成工作台,实际 %v %d %v", got.ID, gotPort, ok)
263
+ }
264
+ // 入口那一跳种 cookie:页面里的 '/api/…' 既没 Referer 也没 via 时认它。
265
+ entry := httptest.NewRecorder()
266
+ svc.handleWorkbench(entry, httptest.NewRequest(http.MethodGet, "/workbench/magic-shorts/", nil))
267
+ cookie := ""
268
+ for _, c := range entry.Result().Cookies() {
269
+ if c.Name == workbenchCookie {
270
+ cookie = c.Name + "=" + c.Value
271
+ }
272
+ }
273
+ if cookie == "" {
274
+ t.Fatal("入口应种工作台 cookie")
275
+ }
276
+ apiReq := viaReq("/api/ping")
277
+ apiReq.Header.Set("Cookie", cookie)
278
+ rec = httptest.NewRecorder()
279
+ svc.serveWorkbench(rec, apiReq, spec, port, "")
280
+ if !strings.Contains(rec.Body.String(), `"via":"workbench"`) {
281
+ t.Fatalf("只有 cookie 时 /api/ping 也要转到工作台:%d %q", rec.Code, rec.Body.String())
282
+ }
283
+
284
+ /* 真实入口是 /api/* 的总路由(routeAPI)—— 它和 handleStatic 不是同一条路,
285
+ 路径改写、鉴权顺序都可能不一样,所以单独打一次(曾经这里漏传路径导致 400)。 */
286
+ routeReq := viaReq("/api/ping")
287
+ routeReq.Header.Set("Cookie", cookie)
288
+ routeRec := httptest.NewRecorder()
289
+ svc.routeAPI(routeRec, routeReq)
290
+ if routeRec.Code != http.StatusOK || !strings.Contains(routeRec.Body.String(), `"via":"workbench"`) {
291
+ t.Fatalf("/api/* 应完整转给工作台(路径不改写),实际 %d %q", routeRec.Code, routeRec.Body.String())
292
+ }
293
+ // 没有工作台标记的 /api/* 仍走原路(未配 backend 时是 501,反正不该是工作台的 200)。
294
+ plain := viaReq("/api/ping")
295
+ plainRec := httptest.NewRecorder()
296
+ svc.routeAPI(plainRec, plain)
297
+ if plainRec.Code == http.StatusOK {
298
+ t.Fatalf("无标记的 /api/* 不该转给工作台,实际 %d %q", plainRec.Code, plainRec.Body.String())
299
+ }
300
+
301
+ // 用真没在跑的端口来验「没在跑就不转」:本机可能真的开着 magic-video(用户自己的)。
302
+ svc.apps.specOf = func(orig appSpec) appSpec {
303
+ orig.Port, orig.Urls = freePort(t), func() []int { return []int{orig.Port} }
304
+ return orig
305
+ }
306
+ if _, _, ok := svc.workbenchFor(viaReq("/?via=magic-video")); ok {
307
+ t.Fatal("via 指向没在跑的工作台时不该转发")
308
+ }
309
+ if _, _, ok := svc.workbenchFor(viaReq("/?via=../../etc/passwd")); ok {
310
+ t.Fatal("via 里的垃圾值不该被当成工作台")
311
+ }
312
+
313
+ // 停:整组带走,状态回到 stopped。
314
+ item, err = mgr.stop(spec)
315
+ if err != nil {
316
+ t.Fatalf("停止失败:%v", err)
317
+ }
318
+ if item.State.Running || item.State.State != "stopped" {
319
+ t.Fatalf("停止后状态不对:%+v", item.State)
320
+ }
321
+ if mgr.probeRunning(port, true) {
322
+ t.Fatal("停止后端口仍有应答")
323
+ }
324
+
325
+ // 没在跑时打开入口 → 一句人话(503),不是白屏。
326
+ rec = httptest.NewRecorder()
327
+ svc.handleWorkbench(rec, httptest.NewRequest(http.MethodGet, "/workbench/magic-shorts/", nil))
328
+ if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "没有在运行") {
329
+ t.Fatalf("未运行时入口应 503 + 说明,实际 %d %q", rec.Code, rec.Body.String())
330
+ }
331
+ }
332
+
333
+ // 起不来:end.ok=false 且带上输出尾巴(原因不能吞掉)。
334
+ func TestAppsStartFailureReportsTail(t *testing.T) {
335
+ svc, repo, _ := isolateShorts(t)
336
+ // 换成一个立刻报错退出的入口。
337
+ if err := os.WriteFile(filepath.Join(repo, "server.js"),
338
+ []byte("console.error('EADDRINUSE: listen failed'); process.exit(7);\n"), 0o644); err != nil {
339
+ t.Fatal(err)
340
+ }
341
+
342
+ rec := httptest.NewRecorder()
343
+ svc.handleAppAction(rec, httptest.NewRequest(http.MethodPost, "/desk/apps/magic-shorts/start", nil))
344
+ body := rec.Body.String()
345
+ events := parseSSE(t, body)
346
+ if len(events["end"]) != 1 {
347
+ t.Fatalf("必须恰好一条 end,实际 %v", events)
348
+ }
349
+ var end struct {
350
+ OK bool `json:"ok"`
351
+ Error string `json:"error"`
352
+ App *AppItem `json:"app"`
353
+ }
354
+ _ = json.Unmarshal([]byte(events["end"][0]), &end)
355
+ if end.OK || end.App == nil || end.App.State.Running {
356
+ t.Fatalf("起不来时应 ok=false 且状态不再是运行中,实际 %s", events["end"][0])
357
+ }
358
+ if !strings.Contains(end.Error, "进程已退出") {
359
+ t.Fatalf("错误里应说清是进程退出了,实际 %s", end.Error)
360
+ }
361
+ /* 输出尾巴是**异步**落盘的(node 写 stderr → 文件),end 帧返回时可能还差一拍;
362
+ 组件测试里单独断言它(t.Cleanup 在临时目录清理之前跑)。 */
363
+ t.Cleanup(func() {
364
+ mgr := svc.appMgr()
365
+ deadline := time.Now().Add(2 * time.Second)
366
+ variants := [][]string{end.App.State.LogTail}
367
+ for time.Now().Before(deadline) {
368
+ variants = append(variants, tailFile(mgr.logDir(), "magic-shorts", 12))
369
+ time.Sleep(50 * time.Millisecond)
370
+ }
371
+ for _, lines := range variants {
372
+ if strings.Contains(strings.Join(lines, "\n"), "EADDRINUSE") {
373
+ return
374
+ }
375
+ }
376
+ t.Errorf("起不来的原因(EADDRINUSE)没有落到日志尾巴里:%v", variants[len(variants)-1])
377
+ })
378
+ }
379
+
380
+ // 未知 id / 未知动作一律 404(这条接口不能变成任意命令执行器)。
381
+ // waitPort 等端口真的能连上(起监听的是 goroutine,别抢跑)。
382
+ func waitPort(t *testing.T, port int) {
383
+ t.Helper()
384
+ for i := 0; i < 100; i++ {
385
+ c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), 100*time.Millisecond)
386
+ if err == nil {
387
+ _ = c.Close()
388
+ return
389
+ }
390
+ time.Sleep(20 * time.Millisecond)
391
+ }
392
+ t.Fatalf("端口 %d 一直没起来", port)
393
+ }
394
+
395
+ /* 端口有人在听、但对方不是这套工作台(magic-video 的 7789 就撞见过:一个只服务 /api 的
396
+ * 旧实例占着端口,/ 返回 404)。旧探活只看「TCP 通了」,于是界面写「运行中」、手机点开一片白。
397
+ * 现在必须报 blocked,并把原因说出来。 */
398
+ func TestAppsPortBusyButNotWorkbench(t *testing.T) {
399
+ svc, repo, port := isolateShorts(t)
400
+ ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
401
+ if err != nil {
402
+ t.Skipf("端口 %d 被占,跳过:%v", port, err)
403
+ }
404
+ defer ln.Close()
405
+ go func() {
406
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
407
+ // 只服务接口、根路径 404 —— 正是那个旧实例的行为。
408
+ if strings.HasPrefix(r.URL.Path, "/api/") {
409
+ w.Header().Set("Content-Type", "application/json")
410
+ _, _ = w.Write([]byte(`{"ok":true}`))
411
+ return
412
+ }
413
+ http.NotFound(w, r)
414
+ })}
415
+ _ = srv.Serve(ln)
416
+ }()
417
+ waitPort(t, port)
418
+
419
+ spec := appSpecWithPort(t, "magic-shorts", port, repo)
420
+ item := svc.appMgr().info(spec, true)
421
+ if item.State.Running || item.State.State != "blocked" {
422
+ t.Fatalf("端口被占但不是工作台时应为 blocked,实际 %+v", item.State)
423
+ }
424
+ if !item.State.PortBusy || !strings.Contains(item.State.Error, "404") {
425
+ t.Fatalf("应带 portBusy 与 404 说明,实际 %+v", item.State)
426
+ }
427
+ if !strings.Contains(item.Note, "先到桌面端") {
428
+ t.Fatalf("说明里应给出出路,实际 %q", item.Note)
429
+ }
430
+ // 这时点启动要立刻拒绝,而不是等 node 报 EADDRINUSE。
431
+ if _, err := svc.appMgr().start(spec); err == nil || !strings.Contains(err.Error(), "桌面端") {
432
+ t.Fatalf("端口被占时启动应给出明确错误,实际 %v", err)
433
+ }
434
+ }
435
+
436
+ func TestAppsRejectUnknown(t *testing.T) {
437
+ fakeAppRepo(t)
438
+ svc := appsService(0)
439
+ for _, target := range []string{
440
+ "/desk/apps/rm-rf/start",
441
+ "/desk/apps/magic-shorts/run",
442
+ "/desk/apps/magic-shorts",
443
+ } {
444
+ rec := httptest.NewRecorder()
445
+ svc.handleAppAction(rec, httptest.NewRequest(http.MethodPost, target, nil))
446
+ if rec.Code != http.StatusNotFound {
447
+ t.Fatalf("%s 状态码 = %d,期望 404", target, rec.Code)
448
+ }
449
+ }
450
+ // 路径穿越也拦。
451
+ if _, _, ok := workbenchTarget("/workbench/magic-shorts/../../etc/passwd"); ok {
452
+ t.Fatal("带 .. 的子路径应被拒绝")
453
+ }
454
+ }
455
+
456
+ // 外部起的工作台(不是这里起的):如实显示「运行中 / 不可托管」,停止要拒绝。
457
+ func TestAppsExternalInstanceIsReadOnly(t *testing.T) {
458
+ svc, repo, port := isolateShorts(t)
459
+ spec := appSpecWithPort(t, "magic-shorts", port, repo)
460
+
461
+ // 直接起一个进程模拟「终端里起的」,不经过管理器。
462
+ startExternal(t, repo, port)
463
+ defer stopExternal(t)
464
+
465
+ mgr := svc.appMgr()
466
+ item := mgr.info(spec, true)
467
+ if !item.State.Running || item.State.Managed {
468
+ t.Fatalf("外部实例应显示 running 且 managed=false,实际 %+v", item.State)
469
+ }
470
+ if !strings.Contains(item.Note, "桌面端") {
471
+ t.Fatalf("外部实例应提示到桌面端停,实际 %q", item.Note)
472
+ }
473
+ if _, err := mgr.stop(spec); err == nil || !strings.Contains(err.Error(), "桌面端") {
474
+ t.Fatalf("外部实例不应被手机停止,实际 err=%v", err)
475
+ }
476
+ // 再点启动:已经在跑 → 幂等返回,不重复起。
477
+ got, err := mgr.start(spec)
478
+ if err != nil || !got.State.Running {
479
+ t.Fatalf("已在跑时应幂等成功,实际 %+v %v", got.State, err)
480
+ }
481
+ }
482
+
483
+ // externalProcs 模拟外部工作台进程(用完要停)。
484
+ var externalProcs []int
485
+
486
+ func startExternal(t *testing.T, repo string, port int) {
487
+ t.Helper()
488
+ node, _ := nodeBin()
489
+ cmd := exec.Command(node, filepath.Join(repo, "server.js"))
490
+ cmd.Dir = repo
491
+ cmd.Env = append(os.Environ(), "PORT="+strconv.Itoa(port), "HOST=127.0.0.1")
492
+ agent.ConfigureProcGroup(cmd)
493
+ var out bytes.Buffer
494
+ cmd.Stdout = &out
495
+ cmd.Stderr = &out
496
+ if err := cmd.Start(); err != nil {
497
+ t.Fatal(err)
498
+ }
499
+ externalProcs = append(externalProcs, cmd.Process.Pid)
500
+ go func() { _ = cmd.Wait() }()
501
+ deadline := time.Now().Add(10 * time.Second)
502
+ for time.Now().Before(deadline) {
503
+ if probePort(port) {
504
+ return
505
+ }
506
+ time.Sleep(100 * time.Millisecond)
507
+ }
508
+ t.Fatalf("外部假工作台没起来:%s", out.String())
509
+ }
510
+
511
+ func stopExternal(t *testing.T) {
512
+ for _, pid := range externalProcs {
513
+ _ = agent.KillPID(pid, true)
514
+ }
515
+ externalProcs = nil
516
+ }