magic-agent 1.0.0

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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,654 @@
1
+ package client
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ )
16
+
17
+ // fakeCore 写一个假的 magic-agent 脚本:既能回答 --contract / --engines,
18
+ // 又能在 ask 模式下先发两行事件、再把 stdin 收到的控制命令**回显**成事件。
19
+ // 回显是关键 —— 它让「控制命令有没有真的到 core 的 stdin」变成可断言的事实。
20
+ func fakeCore(t *testing.T, contract string) string {
21
+ t.Helper()
22
+ dir := t.TempDir()
23
+ path := filepath.Join(dir, "magic-agent")
24
+ script := `#!/bin/sh
25
+ case "$*" in
26
+ *--contract*)
27
+ echo '` + contract + `'
28
+ exit 0
29
+ ;;
30
+ *--engines*)
31
+ echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
32
+ exit 0
33
+ ;;
34
+ *--sessions*)
35
+ echo '[{"run_id":"run-1","session_id":"sess-1","engine":"claude","state":"done"}]'
36
+ exit 0
37
+ ;;
38
+ esac
39
+ # 把收到的参数也回显成一行事件:让「插件有没有把 --session 透传下去」变成可断言的事实。
40
+ printf '{"v":1,"seq":0,"type":"args","raw":"%s"}\n' "$*"
41
+ echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
42
+ echo '{"v":1,"seq":2,"type":"text","text":"hi"}'
43
+ while IFS= read -r line; do
44
+ # 转义后再回显:命令里含引号({"op":"ping"}),不转义就不是合法 JSON。
45
+ esc=$(printf '%s' "$line" | sed 's/\\/\\\\/g; s/"/\\"/g')
46
+ printf '{"v":1,"seq":3,"type":"control_echo","raw":"%s"}\n' "$esc"
47
+ done
48
+ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
49
+ `
50
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ return path
54
+ }
55
+
56
+ const goodContract = `{"contractVersion":1,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
57
+
58
+ func newTestService(t *testing.T, core string) *Service {
59
+ t.Helper()
60
+ svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
61
+ if err != nil {
62
+ t.Fatalf("New: %v", err)
63
+ }
64
+ return svc
65
+ }
66
+
67
+ // TestNewRejectsContractMismatch 契约版本或通道能力不匹配时必须**启动即失败**,
68
+ // 而不是带着不匹配的能力跑起来(那类漂移在界面上表现为空白,最难定位)。
69
+ func TestNewRejectsContractMismatch(t *testing.T) {
70
+ t.Run("版本不符", func(t *testing.T) {
71
+ core := fakeCore(t, `{"contractVersion":99,"features":["feature.session.events"],"engines":[]}`)
72
+ _, err := New(Options{MagicAgent: core, Profile: DefaultProfile()})
73
+ if err == nil {
74
+ t.Fatal("版本不符应当报错")
75
+ }
76
+ if !strings.Contains(err.Error(), "契约版本") {
77
+ t.Errorf("报错应说明契约版本,实际: %v", err)
78
+ }
79
+ })
80
+
81
+ t.Run("缺通道能力", func(t *testing.T) {
82
+ core := fakeCore(t, `{"contractVersion":1,"features":["feature.session.events"],"engines":[]}`)
83
+ _, err := New(Options{MagicAgent: core, Profile: DefaultProfile()})
84
+ if err == nil {
85
+ t.Fatal("缺 control 能力应当报错")
86
+ }
87
+ if !strings.Contains(err.Error(), "feature.session.control") {
88
+ t.Errorf("报错应点名缺哪个能力,实际: %v", err)
89
+ }
90
+ })
91
+
92
+ t.Run("正常", func(t *testing.T) {
93
+ svc := newTestService(t, fakeCore(t, goodContract))
94
+ if got := svc.Contract().ContractVersion; got != RequiredContractVersion {
95
+ t.Errorf("contractVersion = %d want %d", got, RequiredContractVersion)
96
+ }
97
+ })
98
+ }
99
+
100
+ // TestAuthRequired 静态资源不鉴权,但 /desk/* 必须带令牌 ——
101
+ // 本地端口对同机所有进程可见,不鉴权等于把引擎执行权交出去。
102
+ func TestAuthRequired(t *testing.T) {
103
+ svc := newTestService(t, fakeCore(t, goodContract))
104
+ srv := httptest.NewServer(svc.Handler())
105
+ defer srv.Close()
106
+
107
+ resp, err := http.Get(srv.URL + "/desk/health")
108
+ if err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ defer resp.Body.Close()
112
+ if resp.StatusCode != http.StatusUnauthorized {
113
+ t.Errorf("无令牌访问 /desk/health 应 401,实际 %d", resp.StatusCode)
114
+ }
115
+
116
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/health", nil)
117
+ req.Header.Set("X-Magic-Token", "tok")
118
+ resp2, err := http.DefaultClient.Do(req)
119
+ if err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ defer resp2.Body.Close()
123
+ if resp2.StatusCode != http.StatusOK {
124
+ t.Errorf("带令牌应 200,实际 %d", resp2.StatusCode)
125
+ }
126
+ var health struct {
127
+ OK bool `json:"ok"`
128
+ ContractVersion int `json:"contractVersion"`
129
+ Features []string `json:"features"`
130
+ }
131
+ if err := json.NewDecoder(resp2.Body).Decode(&health); err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ if !health.OK || health.ContractVersion != 1 || len(health.Features) != 2 {
135
+ t.Errorf("health 内容不对: %+v", health)
136
+ }
137
+ }
138
+
139
+ // TestProfileGateEngine profile 白名单之外的引擎必须被拦住(403),
140
+ // 否则「按需个性化配置」就是一句空话。
141
+ func TestProfileGateEngine(t *testing.T) {
142
+ core := fakeCore(t, goodContract)
143
+ prof := DefaultProfile()
144
+ prof.Engines = []string{"claude"}
145
+ svc, err := New(Options{MagicAgent: core, Profile: prof, Token: "tok"})
146
+ if err != nil {
147
+ t.Fatal(err)
148
+ }
149
+ srv := httptest.NewServer(svc.Handler())
150
+ defer srv.Close()
151
+
152
+ body, _ := json.Marshal(askRequest{Engine: "codex", Prompt: "x"})
153
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/ask", bytes.NewReader(body))
154
+ req.Header.Set("X-Magic-Token", "tok")
155
+ resp, err := http.DefaultClient.Do(req)
156
+ if err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ defer resp.Body.Close()
160
+ if resp.StatusCode != http.StatusForbidden {
161
+ t.Errorf("白名单外的引擎应 403,实际 %d", resp.StatusCode)
162
+ }
163
+ }
164
+
165
+ // sseReader 逐行读 SSE(data: 后面的 JSON)。
166
+ type sseReader struct {
167
+ sc *bufio.Scanner
168
+ resp *http.Response
169
+ }
170
+
171
+ func openSSE(t *testing.T, url, token string, body any) *sseReader {
172
+ t.Helper()
173
+ b, _ := json.Marshal(body)
174
+ req, _ := http.NewRequest(http.MethodPost, url+"/desk/ask", bytes.NewReader(b))
175
+ req.Header.Set("X-Magic-Token", token)
176
+ req.Header.Set("Accept", "text/event-stream")
177
+ resp, err := http.DefaultClient.Do(req)
178
+ if err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ if resp.StatusCode != http.StatusOK {
182
+ defer resp.Body.Close()
183
+ msg, _ := io.ReadAll(resp.Body)
184
+ t.Fatalf("/desk/ask 应 200,实际 %d(%s)", resp.StatusCode, msg)
185
+ }
186
+ sc := bufio.NewScanner(resp.Body)
187
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
188
+ return &sseReader{sc: sc, resp: resp}
189
+ }
190
+
191
+ // next 读到下一条 data: 事件(跳过 event: 行与空行)。
192
+ func (r *sseReader) next(t *testing.T) map[string]any {
193
+ t.Helper()
194
+ for r.sc.Scan() {
195
+ line := strings.TrimSpace(r.sc.Text())
196
+ if !strings.HasPrefix(line, "data: ") {
197
+ continue
198
+ }
199
+ var m map[string]any
200
+ if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err != nil {
201
+ t.Fatalf("SSE 的 data 不是 JSON: %q", line)
202
+ }
203
+ return m
204
+ }
205
+ t.Fatal("SSE 结束但还没读到期望的事件")
206
+ return nil
207
+ }
208
+
209
+ func (r *sseReader) close() { _ = r.resp.Body.Close() }
210
+
211
+ // TestAskRelaysAndControl 中继与控制通道的端到端:
212
+ // 1. core 的每一行原样到达客户端(顺序与内容都不改);
213
+ // 2. `POST /desk/ask/{id}/control` 写进 core 的 stdin(假 core 会回显,构成可断言证据)。
214
+ func TestAskRelaysAndControl(t *testing.T) {
215
+ svc := newTestService(t, fakeCore(t, goodContract))
216
+ srv := httptest.NewServer(svc.Handler())
217
+ defer srv.Close()
218
+
219
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "只回复 hi"})
220
+ defer sse.close()
221
+
222
+ // 第一条是插件自己的 open 事件(带 ask id);之后才是 core 的事件,原样中继。
223
+ opened := sse.next(t)
224
+ askID, _ := opened["id"].(string)
225
+ if askID == "" {
226
+ t.Fatalf("open 事件应带 ask id,实际 %v", opened)
227
+ }
228
+ _ = sse.next(t) // 假 core 的 args 回显行(另一条用例专门断言它)
229
+ first := sse.next(t)
230
+ if first["type"] != "ready" || first["v"] != float64(1) {
231
+ t.Fatalf("第二条应是 core 的 ready(原样中继),实际 %v", first)
232
+ }
233
+ second := sse.next(t)
234
+ if second["type"] != "text" || second["text"] != "hi" {
235
+ t.Fatalf("第三条应是 text,实际 %v", second)
236
+ }
237
+
238
+ // 控制命令走 /desk/ask/{id}/control:id 从 /desk/asks 取(运行中唯一那条)。
239
+ ctrlBody, _ := json.Marshal(map[string]string{"op": "ping"})
240
+ reqList, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/asks", nil)
241
+ reqList.Header.Set("X-Magic-Token", "tok")
242
+ respList, err := http.DefaultClient.Do(reqList)
243
+ if err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ var asks []struct {
247
+ ID string `json:"id"`
248
+ }
249
+ if err := json.NewDecoder(respList.Body).Decode(&asks); err != nil {
250
+ t.Fatal(err)
251
+ }
252
+ respList.Body.Close()
253
+ if len(asks) != 1 {
254
+ t.Fatalf("/desk/asks 应有 1 条运行中的 ask,实际 %d", len(asks))
255
+ }
256
+ if asks[0].ID != askID {
257
+ t.Errorf("/desk/asks 的 id (%s) 应与 open 事件的 id (%s) 一致", asks[0].ID, askID)
258
+ }
259
+
260
+ reqC, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/desk/ask/%s/control", srv.URL, asks[0].ID), bytes.NewReader(ctrlBody))
261
+ reqC.Header.Set("X-Magic-Token", "tok")
262
+ respC, err := http.DefaultClient.Do(reqC)
263
+ if err != nil {
264
+ t.Fatal(err)
265
+ }
266
+ defer respC.Body.Close()
267
+ if respC.StatusCode != http.StatusAccepted {
268
+ msg, _ := io.ReadAll(respC.Body)
269
+ t.Fatalf("控制接口应 202,实际 %d(%s)", respC.StatusCode, msg)
270
+ }
271
+
272
+ // 假 core 会把 stdin 收到的命令回显成 control_echo —— 这就是「命令真的到了 core」的证据。
273
+ echo := sse.next(t)
274
+ if echo["type"] != "control_echo" {
275
+ t.Fatalf("应收到假 core 的回显事件,实际 %v", echo)
276
+ }
277
+ raw, _ := echo["raw"].(string)
278
+ if !strings.Contains(raw, `"op":"ping"`) {
279
+ t.Errorf("回显内容应含 op=ping,实际 %q", raw)
280
+ }
281
+ }
282
+
283
+ // TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions`)。
284
+ func TestSessionsProxy(t *testing.T) {
285
+ svc := newTestService(t, fakeCore(t, goodContract))
286
+ srv := httptest.NewServer(svc.Handler())
287
+ defer srv.Close()
288
+
289
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/sessions", nil)
290
+ req.Header.Set("X-Magic-Token", "tok")
291
+ resp, err := http.DefaultClient.Do(req)
292
+ if err != nil {
293
+ t.Fatal(err)
294
+ }
295
+ defer resp.Body.Close()
296
+ if resp.StatusCode != http.StatusOK {
297
+ t.Fatalf("/desk/sessions 应 200,实际 %d", resp.StatusCode)
298
+ }
299
+ var rows []struct {
300
+ RunID string `json:"run_id"`
301
+ SessionID string `json:"session_id"`
302
+ }
303
+ if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
304
+ t.Fatal(err)
305
+ }
306
+ if len(rows) != 1 || rows[0].SessionID != "sess-1" {
307
+ t.Errorf("应原样转发 core 的会话表,实际 %+v", rows)
308
+ }
309
+ }
310
+
311
+ // TestAskPassesSession 续接参数必须透传到 core 的 `--session` ——
312
+ // 否则「点历史会话继续」会静默变成新会话(用户看到的是「它不记得上一轮」)。
313
+ func TestAskPassesSession(t *testing.T) {
314
+ svc := newTestService(t, fakeCore(t, goodContract))
315
+ srv := httptest.NewServer(svc.Handler())
316
+ defer srv.Close()
317
+
318
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "接着上一轮", Session: "sess-1"})
319
+ defer sse.close()
320
+ _ = sse.next(t) // open
321
+
322
+ args := sse.next(t)
323
+ raw, _ := args["raw"].(string)
324
+ if !strings.Contains(raw, "--session sess-1") {
325
+ t.Errorf("core 收到的参数里应有 --session sess-1,实际 %q", raw)
326
+ }
327
+ if !strings.Contains(raw, "--events") || !strings.Contains(raw, "--control") {
328
+ t.Errorf("core 收到的参数里应有 --events 与 --control,实际 %q", raw)
329
+ }
330
+ }
331
+
332
+ // TestAskPassesPermission 授权档位要透传给 core 的 --permission。
333
+ // 反面同样重要:**没选档位时不能替 core 补一个默认值** —— core 只在显式传过
334
+ // --permission 时才校验引擎支不支持(internal/cli/ask.go::resolvePermissionTier),
335
+ // 我们替它补默认值会把「显式指定」与「沿用默认」混成一种,让不支持的引擎凭空报错。
336
+ func TestAskPassesPermission(t *testing.T) {
337
+ svc := newTestService(t, fakeCore(t, goodContract))
338
+ srv := httptest.NewServer(svc.Handler())
339
+ defer srv.Close()
340
+
341
+ cases := []struct {
342
+ name string
343
+ req askRequest
344
+ want string
345
+ }{
346
+ {"显式选档", askRequest{Engine: "claude", Prompt: "改代码", Permission: "manual"}, "--permission manual"},
347
+ {"accept-edits", askRequest{Engine: "claude", Prompt: "改代码", Permission: "accept-edits"}, "--permission accept-edits"},
348
+ {"full 危险档", askRequest{Engine: "codebuddy", Prompt: "跑起来", Permission: "full"}, "--permission full"},
349
+ {"未选档位就不传", askRequest{Engine: "claude", Prompt: "只读"}, ""},
350
+ {"空白按未选处理", askRequest{Engine: "claude", Prompt: "只读", Permission: " "}, ""},
351
+ }
352
+ for _, c := range cases {
353
+ t.Run(c.name, func(t *testing.T) {
354
+ sse := openSSE(t, srv.URL, "tok", c.req)
355
+ defer sse.close()
356
+ _ = sse.next(t) // open
357
+
358
+ raw, _ := sse.next(t)["raw"].(string)
359
+ if c.want == "" {
360
+ if strings.Contains(raw, "--permission") {
361
+ t.Errorf("未选档位时不该传 --permission,实际 %q", raw)
362
+ }
363
+ return
364
+ }
365
+ if !strings.Contains(raw, c.want) {
366
+ t.Errorf("core 收到的参数里应有 %q,实际 %q", c.want, raw)
367
+ }
368
+ })
369
+ }
370
+ }
371
+
372
+ // TestProjectAPINotConfigured 没配项目后端时 `/api/*` 回 501 并**点名 profile 字段**,
373
+ // 不静默 404(静默 404 会让人以为是模块前端写错了)。
374
+ func TestProjectAPINotConfigured(t *testing.T) {
375
+ svc := newTestService(t, fakeCore(t, goodContract))
376
+ srv := httptest.NewServer(svc.Handler())
377
+ defer srv.Close()
378
+
379
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/docs", nil)
380
+ req.Header.Set("X-Magic-Token", "tok")
381
+ resp, err := http.DefaultClient.Do(req)
382
+ if err != nil {
383
+ t.Fatal(err)
384
+ }
385
+ defer resp.Body.Close()
386
+ if resp.StatusCode != http.StatusNotImplemented {
387
+ t.Fatalf("/api/* 未配后端应 501,实际 %d", resp.StatusCode)
388
+ }
389
+ var m map[string]string
390
+ if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
391
+ t.Fatal(err)
392
+ }
393
+ if !strings.Contains(m["error"], "backend") {
394
+ t.Errorf("错误信息应点名 profile 的 backend 字段,实际 %q", m["error"])
395
+ }
396
+ }
397
+
398
+ // TestProjectAPIForwards `/api/*` 原样转发;凭据由服务端注入,插件自己的令牌不带去后端。
399
+ //
400
+ // 这三条一起构成「业务视图能搬但业务逻辑不用重写」的前提:路径 / 方法 / 查询不变,
401
+ // 凭据不泄漏到浏览器,两套令牌不互相污染。
402
+ func TestProjectAPIForwards(t *testing.T) {
403
+ type seen struct {
404
+ path, query, auth, magic string
405
+ }
406
+ var got seen
407
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
408
+ got = seen{path: r.URL.Path, query: r.URL.RawQuery, auth: r.Header.Get("Authorization"), magic: r.Header.Get("X-Magic-Token")}
409
+ writeJSON(w, http.StatusOK, map[string]any{"表格": "docs", "行数": 3})
410
+ }))
411
+ defer backend.Close()
412
+
413
+ prof := DefaultProfile()
414
+ prof.Backend = BackendConfig{URL: backend.URL, Token: "backend-secret"}
415
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
416
+ if err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ srv := httptest.NewServer(svc.Handler())
420
+ defer srv.Close()
421
+
422
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/docs?page=2", nil)
423
+ req.Header.Set("X-Magic-Token", "tok")
424
+ resp, err := http.DefaultClient.Do(req)
425
+ if err != nil {
426
+ t.Fatal(err)
427
+ }
428
+ defer resp.Body.Close()
429
+ if resp.StatusCode != http.StatusOK {
430
+ t.Fatalf("/api/docs 应 200,实际 %d", resp.StatusCode)
431
+ }
432
+
433
+ if got.path != "/api/docs" {
434
+ t.Errorf("后端收到的路径应为 /api/docs,实际 %q", got.path)
435
+ }
436
+ if got.query != "page=2" {
437
+ t.Errorf("查询串应原样转发,实际 %q", got.query)
438
+ }
439
+ if got.auth != "Bearer backend-secret" {
440
+ t.Errorf("应注入后端凭据,实际 %q", got.auth)
441
+ }
442
+ if got.magic != "" {
443
+ t.Errorf("插件的令牌不该带去后端,实际 %q", got.magic)
444
+ }
445
+ var body map[string]any
446
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
447
+ t.Fatal(err)
448
+ }
449
+ if body["表格"] != "docs" {
450
+ t.Errorf("后端响应应原样回来,实际 %v", body)
451
+ }
452
+ }
453
+
454
+ // TestProjectAPIRejectsBadURL 地址写错时启动即失败(比运行时 502 好查)。
455
+ func TestProjectAPIRejectsBadURL(t *testing.T) {
456
+ prof := DefaultProfile()
457
+ prof.Backend = BackendConfig{URL: "不是地址"}
458
+ if _, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof}); err == nil {
459
+ t.Fatal("非法 backend.url 应当启动即失败")
460
+ }
461
+ }
462
+
463
+ // TestModulesEndpoint 模块清单从 profile 派生:只回答「有哪些模块、入口在哪、声明了哪些表」。
464
+ func TestModulesEndpoint(t *testing.T) {
465
+ prof := DefaultProfile()
466
+ prof.Modules = []Module{
467
+ {ID: "cases", Entry: "cases.js"},
468
+ {ID: "reqboard", Entry: "reqboard.js", DataTables: []string{"workspaces", "requirements"}},
469
+ }
470
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok", UIRoot: t.TempDir()})
471
+ if err != nil {
472
+ t.Fatal(err)
473
+ }
474
+ srv := httptest.NewServer(svc.Handler())
475
+ defer srv.Close()
476
+
477
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/modules", nil)
478
+ req.Header.Set("X-Magic-Token", "tok")
479
+ resp, err := http.DefaultClient.Do(req)
480
+ if err != nil {
481
+ t.Fatal(err)
482
+ }
483
+ defer resp.Body.Close()
484
+ var out struct {
485
+ Modules []struct {
486
+ ID string `json:"id"`
487
+ URL string `json:"url"`
488
+ DataTables []string `json:"dataTables"`
489
+ Loadable bool `json:"loadable"`
490
+ } `json:"modules"`
491
+ Backend string `json:"backend"`
492
+ }
493
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
494
+ t.Fatal(err)
495
+ }
496
+ if len(out.Modules) != 2 {
497
+ t.Fatalf("应有 2 个模块,实际 %d", len(out.Modules))
498
+ }
499
+ // URL 必须带上挂载点前缀 `/modules/`(漏了就是「清单里有点开 404」)。
500
+ if out.Modules[0].ID != "cases" || out.Modules[0].URL != "/modules/cases.js" || !out.Modules[0].Loadable {
501
+ t.Errorf("第一个模块不对: %+v", out.Modules[0])
502
+ }
503
+ if len(out.Modules[1].DataTables) != 2 {
504
+ t.Errorf("模块声明的表应原样回,实际 %+v", out.Modules[1])
505
+ }
506
+ if out.Backend != "" {
507
+ t.Errorf("未配后端时 backend 应为空串,实际 %q", out.Backend)
508
+ }
509
+ }
510
+
511
+ // TestModulesStatic 模块静态资源的两个根与优先级:
512
+ // 先项目自己的 `ui.modulesDir`,再退回插件的 `--ui-dir`;越界读不到根外的东西。
513
+ //
514
+ // 为什么要有独立的模块目录:模块文件属于项目(跟项目仓库一起改),基础版 UI 属于插件。
515
+ // 混在一个目录里,插件升级与模块改动会互相踩。
516
+ func TestModulesStatic(t *testing.T) {
517
+ uiDir := t.TempDir()
518
+ modDir := t.TempDir()
519
+ if err := os.WriteFile(filepath.Join(uiDir, "index.html"), []byte("from-ui"), 0o644); err != nil {
520
+ t.Fatal(err)
521
+ }
522
+ if err := os.WriteFile(filepath.Join(uiDir, "shared.js"), []byte("from-ui"), 0o644); err != nil {
523
+ t.Fatal(err)
524
+ }
525
+ if err := os.WriteFile(filepath.Join(modDir, "shared.js"), []byte("from-project"), 0o644); err != nil {
526
+ t.Fatal(err)
527
+ }
528
+ if err := os.WriteFile(filepath.Join(modDir, "cases.js"), []byte("cases-module"), 0o644); err != nil {
529
+ t.Fatal(err)
530
+ }
531
+ secret := filepath.Join(filepath.Dir(modDir), "secret-"+filepath.Base(modDir)+".txt")
532
+ if err := os.WriteFile(secret, []byte("TOP-SECRET"), 0o644); err != nil {
533
+ t.Fatal(err)
534
+ }
535
+
536
+ prof := DefaultProfile()
537
+ prof.UI.ModulesDir = modDir
538
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok", UIRoot: uiDir})
539
+ if err != nil {
540
+ t.Fatal(err)
541
+ }
542
+ srv := httptest.NewServer(svc.Handler())
543
+ defer srv.Close()
544
+
545
+ get := func(p string) (int, string) {
546
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+p, nil)
547
+ req.Header.Set("X-Magic-Token", "tok")
548
+ resp, err := http.DefaultClient.Do(req)
549
+ if err != nil {
550
+ t.Fatalf("GET %s: %v", p, err)
551
+ }
552
+ defer resp.Body.Close()
553
+ b, _ := io.ReadAll(resp.Body)
554
+ return resp.StatusCode, string(b)
555
+ }
556
+
557
+ // 同名文件:项目目录优先
558
+ if code, body := get("/modules/shared.js"); code != 200 || body != "from-project" {
559
+ t.Errorf("/modules/shared.js 应取项目目录的,实际 %d %q", code, body)
560
+ }
561
+ // 项目目录里没有的,退回 UI 目录
562
+ if code, body := get("/modules/index.html"); code != 200 || body != "from-ui" {
563
+ t.Errorf("/modules/index.html 应退回 UI 目录,实际 %d %q", code, body)
564
+ }
565
+ // 项目模块能读到(这是「业务模块留在项目仓库里」的关键)
566
+ if code, body := get("/modules/cases.js"); code != 200 || body != "cases-module" {
567
+ t.Errorf("/modules/cases.js 应读到项目模块,实际 %d %q", code, body)
568
+ }
569
+ // 都不存在 → 404
570
+ if code, _ := get("/modules/nope.js"); code != http.StatusNotFound {
571
+ t.Errorf("不存在的模块应 404,实际 %d", code)
572
+ }
573
+ // 越界:Clean 把 `..` 收进根内,因此只能命中根内文件或 404,绝不可能读到根外
574
+ if _, body := get("/modules/../" + filepath.Base(secret)); strings.Contains(body, "TOP-SECRET") {
575
+ t.Error("越界路径读到了根外的文件")
576
+ }
577
+ }
578
+
579
+ // TestLoadProfileDefaultsAndOverrides profile 只描述差异:省略的字段要有稳定默认值。
580
+ func TestLoadProfileDefaultsAndOverrides(t *testing.T) {
581
+ t.Run("空路径给默认", func(t *testing.T) {
582
+ p, err := LoadProfile("")
583
+ if err != nil {
584
+ t.Fatal(err)
585
+ }
586
+ if p.UI.Entry != "index.html" || p.AppID() != "magic-client" {
587
+ t.Errorf("默认 profile 不对: entry=%q appID=%q", p.UI.Entry, p.AppID())
588
+ }
589
+ if len(p.Modules) != 0 {
590
+ t.Errorf("默认 profile 不该有业务模块,实际 %v", p.Modules)
591
+ }
592
+ })
593
+
594
+ t.Run("读文件并补默认", func(t *testing.T) {
595
+ dir := t.TempDir()
596
+ path := filepath.Join(dir, "掌天瓶.json")
597
+ content := `{"app":{"name":"掌天瓶","bundleId":"com.magic.test"},"modules":[{"id":"cases","entry":"modules/cases.js"}]}`
598
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
599
+ t.Fatal(err)
600
+ }
601
+ p, err := LoadProfile(path)
602
+ if err != nil {
603
+ t.Fatal(err)
604
+ }
605
+ if p.App.Name != "掌天瓶" || p.AppID() != "com.magic.test" {
606
+ t.Errorf("app 字段没读到: %+v", p.App)
607
+ }
608
+ if p.UI.Entry != "index.html" {
609
+ t.Errorf("省略的 ui.entry 应补默认,实际 %q", p.UI.Entry)
610
+ }
611
+ if len(p.Modules) != 1 || p.Modules[0].ID != "cases" {
612
+ t.Errorf("业务模块清单没读到: %+v", p.Modules)
613
+ }
614
+ if !strings.HasSuffix(p.DataDir(), filepath.Join(".magic-client", "com.magic.test")) {
615
+ t.Errorf("数据目录应按 appID 派生,实际 %q", p.DataDir())
616
+ }
617
+ })
618
+
619
+ t.Run("坏文件报错", func(t *testing.T) {
620
+ dir := t.TempDir()
621
+ path := filepath.Join(dir, "bad.json")
622
+ if err := os.WriteFile(path, []byte("{不是 JSON"), 0o644); err != nil {
623
+ t.Fatal(err)
624
+ }
625
+ if _, err := LoadProfile(path); err == nil {
626
+ t.Fatal("坏 profile 应当报错")
627
+ }
628
+ })
629
+ }
630
+
631
+ // TestWriteErrShape 错误一律以 {"error": "..."} 回,客户端只认这一种形状。
632
+ func TestWriteErrShape(t *testing.T) {
633
+ svc := newTestService(t, fakeCore(t, goodContract))
634
+ srv := httptest.NewServer(svc.Handler())
635
+ defer srv.Close()
636
+
637
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/ask", strings.NewReader(`{"engine":"claude"}`))
638
+ req.Header.Set("X-Magic-Token", "tok")
639
+ resp, err := http.DefaultClient.Do(req)
640
+ if err != nil {
641
+ t.Fatal(err)
642
+ }
643
+ defer resp.Body.Close()
644
+ if resp.StatusCode != http.StatusBadRequest {
645
+ t.Fatalf("空 prompt 应 400,实际 %d", resp.StatusCode)
646
+ }
647
+ var m map[string]string
648
+ if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
649
+ t.Fatal(err)
650
+ }
651
+ if !strings.Contains(m["error"], "prompt") {
652
+ t.Errorf("错误信息应提到 prompt,实际 %q", m["error"])
653
+ }
654
+ }