magic-agent 1.0.3 → 1.0.49
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.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -5,13 +5,20 @@ import (
|
|
|
5
5
|
"bytes"
|
|
6
6
|
"encoding/json"
|
|
7
7
|
"fmt"
|
|
8
|
+
"image"
|
|
9
|
+
"image/png"
|
|
8
10
|
"io"
|
|
11
|
+
"mime/multipart"
|
|
9
12
|
"net/http"
|
|
10
13
|
"net/http/httptest"
|
|
14
|
+
"net/url"
|
|
11
15
|
"os"
|
|
12
16
|
"path/filepath"
|
|
13
17
|
"strings"
|
|
14
18
|
"testing"
|
|
19
|
+
"time"
|
|
20
|
+
|
|
21
|
+
"github.com/darren/magic-agent/internal/session"
|
|
15
22
|
)
|
|
16
23
|
|
|
17
24
|
// fakeCore 写一个假的 magic-agent 脚本:既能回答 --contract / --engines,
|
|
@@ -32,12 +39,16 @@ case "$*" in
|
|
|
32
39
|
exit 0
|
|
33
40
|
;;
|
|
34
41
|
*--sessions*)
|
|
35
|
-
echo '[{"run_id":"run-1","session_id":"sess-1","engine":"claude","state":"done"}
|
|
42
|
+
echo '[{"run_id":"run-1","session_id":"sess-1","engine":"claude","state":"done","source":"pc","client":"magic-client"},
|
|
43
|
+
{"run_id":"run-2","session_id":"sess-2","engine":"claude","state":"done"},
|
|
44
|
+
{"run_id":"run-3","session_id":"sess-3","engine":"claude","state":"done","client":"other-app"},
|
|
45
|
+
{"run_id":"run-4","session_id":"sess-4","engine":"claude","state":"done","source":"pc","client":"magic-test"}]'
|
|
36
46
|
exit 0
|
|
37
47
|
;;
|
|
38
48
|
esac
|
|
39
|
-
#
|
|
40
|
-
|
|
49
|
+
# 把收到的参数与来源客户端环境变量一起回显成一行事件:让「插件有没有把 --session 透传下去」
|
|
50
|
+
# 与「有没有注入 MAGIC_AGENT_CLIENT」都变成可断言的事实。
|
|
51
|
+
printf '{"v":1,"seq":0,"type":"args","raw":"%s","client":"%s"}\n' "$*" "$MAGIC_AGENT_CLIENT"
|
|
41
52
|
echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
|
|
42
53
|
echo '{"v":1,"seq":2,"type":"text","text":"hi"}'
|
|
43
54
|
while IFS= read -r line; do
|
|
@@ -110,46 +121,21 @@ func TestAuthRequired(t *testing.T) {
|
|
|
110
121
|
}
|
|
111
122
|
defer resp.Body.Close()
|
|
112
123
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
113
|
-
t.Errorf("
|
|
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)
|
|
124
|
+
t.Errorf("/desk/health 无令牌应 401,实际 %d", resp.StatusCode)
|
|
136
125
|
}
|
|
137
126
|
}
|
|
138
127
|
|
|
139
|
-
// TestProfileGateEngine profile 白名单之外的引擎必须被拦住(403),
|
|
140
|
-
// 否则「按需个性化配置」就是一句空话。
|
|
141
128
|
func TestProfileGateEngine(t *testing.T) {
|
|
142
|
-
core := fakeCore(t, goodContract)
|
|
143
129
|
prof := DefaultProfile()
|
|
144
130
|
prof.Engines = []string{"claude"}
|
|
145
|
-
svc, err := New(Options{MagicAgent:
|
|
131
|
+
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
|
|
146
132
|
if err != nil {
|
|
147
133
|
t.Fatal(err)
|
|
148
134
|
}
|
|
149
135
|
srv := httptest.NewServer(svc.Handler())
|
|
150
136
|
defer srv.Close()
|
|
151
137
|
|
|
152
|
-
body, _ := json.Marshal(askRequest{Engine: "codex", Prompt: "
|
|
138
|
+
body, _ := json.Marshal(askRequest{Engine: "codex", Prompt: "hi"})
|
|
153
139
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/ask", bytes.NewReader(body))
|
|
154
140
|
req.Header.Set("X-Magic-Token", "tok")
|
|
155
141
|
resp, err := http.DefaultClient.Do(req)
|
|
@@ -158,11 +144,10 @@ func TestProfileGateEngine(t *testing.T) {
|
|
|
158
144
|
}
|
|
159
145
|
defer resp.Body.Close()
|
|
160
146
|
if resp.StatusCode != http.StatusForbidden {
|
|
161
|
-
t.Errorf("
|
|
147
|
+
t.Errorf("白名单外引擎应 403,实际 %d", resp.StatusCode)
|
|
162
148
|
}
|
|
163
149
|
}
|
|
164
150
|
|
|
165
|
-
// sseReader 逐行读 SSE(data: 后面的 JSON)。
|
|
166
151
|
type sseReader struct {
|
|
167
152
|
sc *bufio.Scanner
|
|
168
153
|
resp *http.Response
|
|
@@ -208,6 +193,61 @@ func (r *sseReader) next(t *testing.T) map[string]any {
|
|
|
208
193
|
|
|
209
194
|
func (r *sseReader) close() { _ = r.resp.Body.Close() }
|
|
210
195
|
|
|
196
|
+
// fakeSilentCore 写一个「首段输出前长时间静默」的假 core —— 复现 2026-10-19 手机端
|
|
197
|
+
// 17.7s「连接中断」的真实场景:核心在冷启动/长思考时几十秒不吐一行,期间服务端
|
|
198
|
+
// 一个字节都不写,移动网络或 WebView 就会把这条 SSE 当成死连接掐掉。
|
|
199
|
+
func fakeSilentCore(t *testing.T, contract string) string {
|
|
200
|
+
t.Helper()
|
|
201
|
+
dir := t.TempDir()
|
|
202
|
+
path := filepath.Join(dir, "magic-agent")
|
|
203
|
+
script := `#!/bin/sh
|
|
204
|
+
case "$*" in
|
|
205
|
+
*--contract*)
|
|
206
|
+
echo '` + contract + `'
|
|
207
|
+
exit 0
|
|
208
|
+
;;
|
|
209
|
+
*--engines*)
|
|
210
|
+
echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
|
|
211
|
+
exit 0
|
|
212
|
+
;;
|
|
213
|
+
esac
|
|
214
|
+
sleep 12
|
|
215
|
+
echo '{"v":1,"seq":1,"type":"result","text":"hi"}'
|
|
216
|
+
`
|
|
217
|
+
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
return path
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// TestAskSendsHeartbeatDuringSilence core 长时间不输出时,SSE 必须持续有心跳帧 ——
|
|
224
|
+
// 否则手机端会在首段回复到达前就把连接掐掉(用户看到「没有收到回复 · 连接中断」,
|
|
225
|
+
// 而服务端任务其实完整跑完了)。心跳用 SSE 注释帧(`: ping`),解析端会忽略。
|
|
226
|
+
func TestAskSendsHeartbeatDuringSilence(t *testing.T) {
|
|
227
|
+
svc := newTestService(t, fakeSilentCore(t, goodContract))
|
|
228
|
+
srv := httptest.NewServer(svc.Handler())
|
|
229
|
+
defer srv.Close()
|
|
230
|
+
|
|
231
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "长时间思考"})
|
|
232
|
+
defer sse.close()
|
|
233
|
+
_ = sse.next(t) // open
|
|
234
|
+
|
|
235
|
+
ping := make(chan struct{})
|
|
236
|
+
go func() {
|
|
237
|
+
for sse.sc.Scan() {
|
|
238
|
+
if strings.HasPrefix(strings.TrimSpace(sse.sc.Text()), ": ping") {
|
|
239
|
+
close(ping)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}()
|
|
244
|
+
select {
|
|
245
|
+
case <-ping:
|
|
246
|
+
case <-time.After(12 * time.Second):
|
|
247
|
+
t.Fatal("静默超过 10s 仍未收到 SSE 心跳(': ping')")
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
211
251
|
// TestAskRelaysAndControl 中继与控制通道的端到端:
|
|
212
252
|
// 1. core 的每一行原样到达客户端(顺序与内容都不改);
|
|
213
253
|
// 2. `POST /desk/ask/{id}/control` 写进 core 的 stdin(假 core 会回显,构成可断言证据)。
|
|
@@ -280,7 +320,13 @@ func TestAskRelaysAndControl(t *testing.T) {
|
|
|
280
320
|
}
|
|
281
321
|
}
|
|
282
322
|
|
|
283
|
-
// TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions
|
|
323
|
+
// TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions`),
|
|
324
|
+
// 且只回「本客户端 + 同族 magic-* 来源」那些(原口径见用户 2026-10-12
|
|
325
|
+
// 「不是 magic-agent 客户端调用的任务不显示」;2026-10-15 放宽到名单,见 extraSessionClients)。
|
|
326
|
+
//
|
|
327
|
+
// 登记表目录是全机共享的,假 core 故意返回四种来源:本客户端的、没有 client 的
|
|
328
|
+
// (= 直接跑 CLI / 旧记录)、别的应用的、掌天瓶(magic-test)的 —— 匿名那条与别家应用仍被滤掉,
|
|
329
|
+
// 留下本客户端的 sess-1 与掌天瓶的 sess-4。
|
|
284
330
|
func TestSessionsProxy(t *testing.T) {
|
|
285
331
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
286
332
|
srv := httptest.NewServer(svc.Handler())
|
|
@@ -299,33 +345,83 @@ func TestSessionsProxy(t *testing.T) {
|
|
|
299
345
|
var rows []struct {
|
|
300
346
|
RunID string `json:"run_id"`
|
|
301
347
|
SessionID string `json:"session_id"`
|
|
348
|
+
Client string `json:"client"`
|
|
349
|
+
Source string `json:"source"`
|
|
302
350
|
}
|
|
303
351
|
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
|
|
304
352
|
t.Fatal(err)
|
|
305
353
|
}
|
|
306
|
-
if len(rows) !=
|
|
307
|
-
t.
|
|
354
|
+
if len(rows) != 2 {
|
|
355
|
+
t.Fatalf("应只剩本客户端与 magic-test 的会话(2 条),实际 %d: %+v", len(rows), rows)
|
|
356
|
+
}
|
|
357
|
+
if rows[0].RunID != "run-1" || rows[1].RunID != "run-4" {
|
|
358
|
+
t.Errorf("过滤结果不对:%+v", rows)
|
|
308
359
|
}
|
|
309
360
|
}
|
|
310
361
|
|
|
311
|
-
|
|
312
|
-
// 否则「点历史会话继续」会静默变成新会话(用户看到的是「它不记得上一轮」)。
|
|
313
|
-
func TestAskPassesSession(t *testing.T) {
|
|
362
|
+
func TestAskPassesSaveHistory(t *testing.T) {
|
|
314
363
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
315
364
|
srv := httptest.NewServer(svc.Handler())
|
|
316
365
|
defer srv.Close()
|
|
317
366
|
|
|
318
|
-
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "
|
|
367
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi"})
|
|
319
368
|
defer sse.close()
|
|
320
|
-
_ = sse.next(t)
|
|
369
|
+
_ = sse.next(t)
|
|
370
|
+
raw, _ := sse.next(t)["raw"].(string)
|
|
371
|
+
if !strings.Contains(raw, "--save-registry") || !strings.Contains(raw, "--save-history") {
|
|
372
|
+
t.Errorf("core 应收到两个存档开关,实际 %q", raw)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
func TestAskInjectsClientEnv(t *testing.T) {
|
|
377
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
378
|
+
srv := httptest.NewServer(svc.Handler())
|
|
379
|
+
defer srv.Close()
|
|
380
|
+
|
|
381
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi", Client: "mobile"})
|
|
382
|
+
defer sse.close()
|
|
383
|
+
_ = sse.next(t)
|
|
384
|
+
var ev map[string]any
|
|
385
|
+
for ev == nil || ev["type"] != "args" {
|
|
386
|
+
ev = sse.next(t)
|
|
387
|
+
}
|
|
388
|
+
if got, _ := ev["client"].(string); got != "" && !strings.Contains(got, "magic") {
|
|
389
|
+
t.Errorf("MAGIC_AGENT_CLIENT 应为本插件 AppID,实际 %q", got)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
func TestAskInjectsProfileAppID(t *testing.T) {
|
|
394
|
+
prof := DefaultProfile()
|
|
395
|
+
prof.App.BundleID = "magic-test"
|
|
396
|
+
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
|
|
397
|
+
if err != nil {
|
|
398
|
+
t.Fatal(err)
|
|
399
|
+
}
|
|
400
|
+
srv := httptest.NewServer(svc.Handler())
|
|
401
|
+
defer srv.Close()
|
|
321
402
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
403
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi"})
|
|
404
|
+
defer sse.close()
|
|
405
|
+
_ = sse.next(t)
|
|
406
|
+
var ev map[string]any
|
|
407
|
+
for ev == nil || ev["type"] != "args" {
|
|
408
|
+
ev = sse.next(t)
|
|
409
|
+
}
|
|
410
|
+
if got, _ := ev["client"].(string); got != "magic-test" {
|
|
411
|
+
t.Errorf("MAGIC_AGENT_CLIENT 应为 profile AppID,实际 %q", got)
|
|
326
412
|
}
|
|
327
|
-
|
|
328
|
-
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
func TestAskPassesSession(t *testing.T) {
|
|
416
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
417
|
+
srv := httptest.NewServer(svc.Handler())
|
|
418
|
+
defer srv.Close()
|
|
419
|
+
|
|
420
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Session: "sess-1"})
|
|
421
|
+
defer sse.close()
|
|
422
|
+
_ = sse.next(t)
|
|
423
|
+
if raw, _ := sse.next(t)["raw"].(string); !strings.Contains(raw, "--session sess-1") {
|
|
424
|
+
t.Errorf("应透传 --session sess-1,实际 %q", raw)
|
|
329
425
|
}
|
|
330
426
|
}
|
|
331
427
|
|
|
@@ -369,14 +465,12 @@ func TestAskPassesPermission(t *testing.T) {
|
|
|
369
465
|
}
|
|
370
466
|
}
|
|
371
467
|
|
|
372
|
-
// TestProjectAPINotConfigured 没配项目后端时 `/api/*` 回 501 并**点名 profile 字段**,
|
|
373
|
-
// 不静默 404(静默 404 会让人以为是模块前端写错了)。
|
|
374
468
|
func TestProjectAPINotConfigured(t *testing.T) {
|
|
375
469
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
376
470
|
srv := httptest.NewServer(svc.Handler())
|
|
377
471
|
defer srv.Close()
|
|
378
472
|
|
|
379
|
-
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/
|
|
473
|
+
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/x", nil)
|
|
380
474
|
req.Header.Set("X-Magic-Token", "tok")
|
|
381
475
|
resp, err := http.DefaultClient.Do(req)
|
|
382
476
|
if err != nil {
|
|
@@ -384,34 +478,26 @@ func TestProjectAPINotConfigured(t *testing.T) {
|
|
|
384
478
|
}
|
|
385
479
|
defer resp.Body.Close()
|
|
386
480
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
387
|
-
t.
|
|
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"])
|
|
481
|
+
t.Errorf("未配置 backend 时 /api 应 501,实际 %d", resp.StatusCode)
|
|
395
482
|
}
|
|
396
483
|
}
|
|
397
484
|
|
|
398
|
-
// TestProjectAPIForwards `/api/*` 原样转发;凭据由服务端注入,插件自己的令牌不带去后端。
|
|
399
|
-
//
|
|
400
|
-
// 这三条一起构成「业务视图能搬但业务逻辑不用重写」的前提:路径 / 方法 / 查询不变,
|
|
401
|
-
// 凭据不泄漏到浏览器,两套令牌不互相污染。
|
|
402
485
|
func TestProjectAPIForwards(t *testing.T) {
|
|
403
|
-
type seen struct {
|
|
404
|
-
path, query, auth, magic string
|
|
405
|
-
}
|
|
406
|
-
var got seen
|
|
407
486
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
408
|
-
|
|
409
|
-
|
|
487
|
+
if r.Header.Get("Authorization") != "Bearer srv-token" {
|
|
488
|
+
t.Errorf("后端应收服务端注入的凭据,实际 %q", r.Header.Get("Authorization"))
|
|
489
|
+
}
|
|
490
|
+
if r.Header.Get("X-Magic-Token") != "" {
|
|
491
|
+
t.Error("插件的令牌不应带给后端")
|
|
492
|
+
}
|
|
493
|
+
w.Header().Set("Content-Type", "application/json")
|
|
494
|
+
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
410
495
|
}))
|
|
411
496
|
defer backend.Close()
|
|
412
497
|
|
|
413
498
|
prof := DefaultProfile()
|
|
414
|
-
prof.Backend =
|
|
499
|
+
prof.Backend.URL = backend.URL
|
|
500
|
+
prof.Backend.Token = "srv-token"
|
|
415
501
|
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
|
|
416
502
|
if err != nil {
|
|
417
503
|
t.Fatal(err)
|
|
@@ -419,7 +505,7 @@ func TestProjectAPIForwards(t *testing.T) {
|
|
|
419
505
|
srv := httptest.NewServer(svc.Handler())
|
|
420
506
|
defer srv.Close()
|
|
421
507
|
|
|
422
|
-
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/
|
|
508
|
+
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/x", nil)
|
|
423
509
|
req.Header.Set("X-Magic-Token", "tok")
|
|
424
510
|
resp, err := http.DefaultClient.Do(req)
|
|
425
511
|
if err != nil {
|
|
@@ -427,47 +513,31 @@ func TestProjectAPIForwards(t *testing.T) {
|
|
|
427
513
|
}
|
|
428
514
|
defer resp.Body.Close()
|
|
429
515
|
if resp.StatusCode != http.StatusOK {
|
|
430
|
-
t.Fatalf("/api
|
|
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)
|
|
516
|
+
t.Fatalf("/api 转发应 200,实际 %d", resp.StatusCode)
|
|
441
517
|
}
|
|
442
|
-
|
|
443
|
-
|
|
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)
|
|
518
|
+
b, _ := io.ReadAll(resp.Body)
|
|
519
|
+
if string(b) != `{"ok":true}` {
|
|
520
|
+
t.Errorf("转发响应不对:%s", b)
|
|
451
521
|
}
|
|
452
522
|
}
|
|
453
523
|
|
|
454
|
-
// TestProjectAPIRejectsBadURL 地址写错时启动即失败(比运行时 502 好查)。
|
|
455
524
|
func TestProjectAPIRejectsBadURL(t *testing.T) {
|
|
456
525
|
prof := DefaultProfile()
|
|
457
|
-
prof.Backend =
|
|
458
|
-
|
|
459
|
-
|
|
526
|
+
prof.Backend.URL = "not a url"
|
|
527
|
+
_, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof})
|
|
528
|
+
if err == nil || !strings.Contains(err.Error(), "backend.url") {
|
|
529
|
+
t.Fatalf("非法 backend.url 应启动即报错,实际 %v", err)
|
|
460
530
|
}
|
|
461
531
|
}
|
|
462
532
|
|
|
463
|
-
// TestModulesEndpoint 模块清单从 profile 派生:只回答「有哪些模块、入口在哪、声明了哪些表」。
|
|
464
533
|
func TestModulesEndpoint(t *testing.T) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
{ID: "reqboard", Entry: "reqboard.js", DataTables: []string{"workspaces", "requirements"}},
|
|
534
|
+
dir := t.TempDir()
|
|
535
|
+
if err := os.MkdirAll(filepath.Join(dir, "m1"), 0o755); err != nil {
|
|
536
|
+
t.Fatal(err)
|
|
469
537
|
}
|
|
470
|
-
|
|
538
|
+
prof := DefaultProfile()
|
|
539
|
+
prof.Modules = []Module{{ID: "m1", Entry: "m1/index.html"}}
|
|
540
|
+
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
|
|
471
541
|
if err != nil {
|
|
472
542
|
t.Fatal(err)
|
|
473
543
|
}
|
|
@@ -483,172 +553,545 @@ func TestModulesEndpoint(t *testing.T) {
|
|
|
483
553
|
defer resp.Body.Close()
|
|
484
554
|
var out struct {
|
|
485
555
|
Modules []struct {
|
|
486
|
-
ID
|
|
487
|
-
URL string `json:"url"`
|
|
488
|
-
DataTables []string `json:"dataTables"`
|
|
489
|
-
Loadable bool `json:"loadable"`
|
|
556
|
+
ID string `json:"id"`
|
|
490
557
|
} `json:"modules"`
|
|
491
|
-
Backend string `json:"backend"`
|
|
492
558
|
}
|
|
493
559
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
494
560
|
t.Fatal(err)
|
|
495
561
|
}
|
|
496
|
-
if len(out.Modules) !=
|
|
497
|
-
t.
|
|
562
|
+
if len(out.Modules) != 1 || out.Modules[0].ID != "m1" {
|
|
563
|
+
t.Errorf("模块列表不对:%+v", out.Modules)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
func TestModulesStatic(t *testing.T) {
|
|
568
|
+
uiDir := t.TempDir()
|
|
569
|
+
if err := os.MkdirAll(filepath.Join(uiDir, "shared"), 0o755); err != nil {
|
|
570
|
+
t.Fatal(err)
|
|
571
|
+
}
|
|
572
|
+
if err := os.WriteFile(filepath.Join(uiDir, "shared", "a.js"), []byte("var a=1;"), 0o644); err != nil {
|
|
573
|
+
t.Fatal(err)
|
|
498
574
|
}
|
|
499
|
-
|
|
500
|
-
if
|
|
501
|
-
t.
|
|
575
|
+
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), UIRoot: uiDir, Token: "tok"})
|
|
576
|
+
if err != nil {
|
|
577
|
+
t.Fatal(err)
|
|
578
|
+
}
|
|
579
|
+
srv := httptest.NewServer(svc.Handler())
|
|
580
|
+
defer srv.Close()
|
|
581
|
+
|
|
582
|
+
resp, err := http.Get(srv.URL + "/m/shared/a.js")
|
|
583
|
+
if err != nil {
|
|
584
|
+
t.Fatal(err)
|
|
502
585
|
}
|
|
503
|
-
|
|
504
|
-
|
|
586
|
+
defer resp.Body.Close()
|
|
587
|
+
if resp.StatusCode != http.StatusOK {
|
|
588
|
+
t.Fatalf("共享静态资源应 200,实际 %d", resp.StatusCode)
|
|
505
589
|
}
|
|
506
|
-
|
|
507
|
-
|
|
590
|
+
b, _ := io.ReadAll(resp.Body)
|
|
591
|
+
if string(b) != "var a=1;" {
|
|
592
|
+
t.Errorf("内容不对:%s", b)
|
|
508
593
|
}
|
|
509
594
|
}
|
|
510
595
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
596
|
+
func TestUploadReadBackAndTraversalGuard(t *testing.T) {
|
|
597
|
+
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), Token: "tok"})
|
|
598
|
+
if err != nil {
|
|
599
|
+
t.Fatal(err)
|
|
600
|
+
}
|
|
601
|
+
srv := httptest.NewServer(svc.Handler())
|
|
602
|
+
defer srv.Close()
|
|
603
|
+
|
|
604
|
+
var buf bytes.Buffer
|
|
605
|
+
w := multipartWrite(&buf, "file", "a.txt", []byte("hello"))
|
|
606
|
+
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/upload", &buf)
|
|
607
|
+
req.Header.Set("X-Magic-Token", "tok")
|
|
608
|
+
req.Header.Set("Content-Type", w)
|
|
609
|
+
resp, err := http.DefaultClient.Do(req)
|
|
610
|
+
if err != nil {
|
|
520
611
|
t.Fatal(err)
|
|
521
612
|
}
|
|
522
|
-
|
|
613
|
+
defer resp.Body.Close()
|
|
614
|
+
if resp.StatusCode != http.StatusOK {
|
|
615
|
+
msg, _ := io.ReadAll(resp.Body)
|
|
616
|
+
t.Fatalf("上传应 200,实际 %d(%s)", resp.StatusCode, msg)
|
|
617
|
+
}
|
|
618
|
+
var out struct {
|
|
619
|
+
Path string `json:"path"`
|
|
620
|
+
URL string `json:"url"`
|
|
621
|
+
}
|
|
622
|
+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
523
623
|
t.Fatal(err)
|
|
524
624
|
}
|
|
525
|
-
|
|
625
|
+
|
|
626
|
+
// 回读:<img> 只能走 ?token=。
|
|
627
|
+
read, err := http.Get(srv.URL + out.URL + "?token=tok")
|
|
628
|
+
if err != nil {
|
|
526
629
|
t.Fatal(err)
|
|
527
630
|
}
|
|
528
|
-
|
|
631
|
+
defer read.Body.Close()
|
|
632
|
+
if read.StatusCode != http.StatusOK {
|
|
633
|
+
t.Fatalf("回读应 200,实际 %d", read.StatusCode)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 目录穿越:编码后的 ../ 不能读到上传目录以外。
|
|
637
|
+
bad, err := http.Get(srv.URL + "/desk/uploads/..%2F..%2Fetc%2Fpasswd?token=tok")
|
|
638
|
+
if err != nil {
|
|
529
639
|
t.Fatal(err)
|
|
530
640
|
}
|
|
531
|
-
|
|
532
|
-
if
|
|
641
|
+
defer bad.Body.Close()
|
|
642
|
+
if bad.StatusCode == http.StatusOK {
|
|
643
|
+
t.Error("目录穿越请求不应成功")
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
func TestMediaFileServesImageByPath(t *testing.T) {
|
|
648
|
+
dir := t.TempDir()
|
|
649
|
+
img := filepath.Join(dir, "x.png")
|
|
650
|
+
if err := os.WriteFile(img, []byte{0x89, 'P', 'N', 'G'}, 0o644); err != nil {
|
|
533
651
|
t.Fatal(err)
|
|
534
652
|
}
|
|
653
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
654
|
+
srv := httptest.NewServer(svc.Handler())
|
|
655
|
+
defer srv.Close()
|
|
535
656
|
|
|
536
|
-
|
|
537
|
-
prof.UI.ModulesDir = modDir
|
|
538
|
-
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok", UIRoot: uiDir})
|
|
657
|
+
resp, err := http.Get(srv.URL + "/desk/media?path=" + url.QueryEscape(img) + "&token=tok")
|
|
539
658
|
if err != nil {
|
|
540
659
|
t.Fatal(err)
|
|
541
660
|
}
|
|
661
|
+
defer resp.Body.Close()
|
|
662
|
+
if resp.StatusCode != http.StatusOK {
|
|
663
|
+
t.Fatalf("按路径回读图片应 200,实际 %d", resp.StatusCode)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
func TestPathReadTextAndGuards(t *testing.T) {
|
|
668
|
+
dir := t.TempDir()
|
|
669
|
+
md := filepath.Join(dir, "a.md")
|
|
670
|
+
if err := os.WriteFile(md, []byte("# 标题\n\n正文"), 0o644); err != nil {
|
|
671
|
+
t.Fatal(err)
|
|
672
|
+
}
|
|
673
|
+
bin := filepath.Join(dir, "b.bin")
|
|
674
|
+
if err := os.WriteFile(bin, []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
|
|
675
|
+
t.Fatal(err)
|
|
676
|
+
}
|
|
677
|
+
big := filepath.Join(dir, "c.txt")
|
|
678
|
+
if err := os.WriteFile(big, bytes.Repeat([]byte("a"), maxReadBytes+1), 0o644); err != nil {
|
|
679
|
+
t.Fatal(err)
|
|
680
|
+
}
|
|
681
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
542
682
|
srv := httptest.NewServer(svc.Handler())
|
|
543
683
|
defer srv.Close()
|
|
544
684
|
|
|
545
|
-
|
|
546
|
-
|
|
685
|
+
read := func(path, base string) map[string]any {
|
|
686
|
+
body, _ := json.Marshal(map[string]string{"path": path, "base": base})
|
|
687
|
+
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/path-read", bytes.NewReader(body))
|
|
547
688
|
req.Header.Set("X-Magic-Token", "tok")
|
|
689
|
+
req.Header.Set("Content-Type", "application/json")
|
|
548
690
|
resp, err := http.DefaultClient.Do(req)
|
|
549
691
|
if err != nil {
|
|
550
|
-
t.
|
|
692
|
+
t.Fatal(err)
|
|
551
693
|
}
|
|
552
694
|
defer resp.Body.Close()
|
|
553
|
-
|
|
554
|
-
|
|
695
|
+
if resp.StatusCode != http.StatusOK {
|
|
696
|
+
t.Fatalf("path-read 应 200,实际 %d", resp.StatusCode)
|
|
697
|
+
}
|
|
698
|
+
var out map[string]any
|
|
699
|
+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
700
|
+
t.Fatal(err)
|
|
701
|
+
}
|
|
702
|
+
return out
|
|
555
703
|
}
|
|
556
704
|
|
|
557
|
-
//
|
|
558
|
-
if
|
|
559
|
-
t.
|
|
705
|
+
// 绝对路径读文本。
|
|
706
|
+
if got := read(md, ""); got["text"] != "# 标题\n\n正文" {
|
|
707
|
+
t.Fatalf("文本应原样返回,实际 %v", got)
|
|
560
708
|
}
|
|
561
|
-
//
|
|
562
|
-
if
|
|
563
|
-
t.
|
|
709
|
+
// 相对路径按 base 解析。
|
|
710
|
+
if got := read("a.md", dir); got["text"] != "# 标题\n\n正文" {
|
|
711
|
+
t.Fatalf("相对路径应按 base 解析,实际 %v", got)
|
|
564
712
|
}
|
|
565
|
-
//
|
|
566
|
-
if
|
|
567
|
-
t.
|
|
713
|
+
// 不存在。
|
|
714
|
+
if got := read(filepath.Join(dir, "nope.md"), ""); got["ok"] != false {
|
|
715
|
+
t.Fatalf("不存在的文件应 ok=false,实际 %v", got)
|
|
568
716
|
}
|
|
569
|
-
//
|
|
570
|
-
if
|
|
571
|
-
t.
|
|
717
|
+
// 二进制。
|
|
718
|
+
if got := read(bin, ""); got["binary"] != true {
|
|
719
|
+
t.Fatalf("含 NUL 应判二进制,实际 %v", got)
|
|
572
720
|
}
|
|
573
|
-
//
|
|
574
|
-
if
|
|
575
|
-
t.
|
|
721
|
+
// 超限。
|
|
722
|
+
if got := read(big, ""); got["tooBig"] != true {
|
|
723
|
+
t.Fatalf("超 512KB 应判 tooBig,实际 %v", got)
|
|
576
724
|
}
|
|
577
725
|
}
|
|
578
726
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
t.Errorf("默认 profile 不该有业务模块,实际 %v", p.Modules)
|
|
727
|
+
func TestMediaFileScalesWhenWidthGiven(t *testing.T) {
|
|
728
|
+
dir := t.TempDir()
|
|
729
|
+
img := filepath.Join(dir, "wide.png")
|
|
730
|
+
src := image.NewNRGBA(image.Rect(0, 0, 2000, 1000))
|
|
731
|
+
for y := 0; y < 1000; y++ {
|
|
732
|
+
for x := 0; x < 2000; x++ {
|
|
733
|
+
i := src.PixOffset(x, y)
|
|
734
|
+
src.Pix[i] = uint8(x*7 + y*13)
|
|
735
|
+
src.Pix[i+1] = uint8(x*3 + y*29)
|
|
736
|
+
src.Pix[i+2] = uint8((x * y) >> 3)
|
|
737
|
+
src.Pix[i+3] = 0xff
|
|
591
738
|
}
|
|
592
|
-
}
|
|
739
|
+
}
|
|
740
|
+
f, err := os.Create(img)
|
|
741
|
+
if err != nil {
|
|
742
|
+
t.Fatal(err)
|
|
743
|
+
}
|
|
744
|
+
if err := png.Encode(f, src); err != nil {
|
|
745
|
+
t.Fatal(err)
|
|
746
|
+
}
|
|
747
|
+
f.Close()
|
|
593
748
|
|
|
594
|
-
t
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
})
|
|
749
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
750
|
+
srv := httptest.NewServer(svc.Handler())
|
|
751
|
+
defer srv.Close()
|
|
618
752
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
753
|
+
resp, err := http.Get(srv.URL + "/desk/media?path=" + url.QueryEscape(img) + "&w=720&token=tok")
|
|
754
|
+
if err != nil {
|
|
755
|
+
t.Fatal(err)
|
|
756
|
+
}
|
|
757
|
+
defer resp.Body.Close()
|
|
758
|
+
if resp.StatusCode != http.StatusOK {
|
|
759
|
+
t.Fatalf("缩放回读应 200,实际 %d", resp.StatusCode)
|
|
760
|
+
}
|
|
761
|
+
got, _, err := image.Decode(resp.Body)
|
|
762
|
+
if err != nil {
|
|
763
|
+
t.Fatalf("缩放结果应是可解码图片:%v", err)
|
|
764
|
+
}
|
|
765
|
+
if w := got.Bounds().Dx(); w != 720 {
|
|
766
|
+
t.Fatalf("应按目标宽度 720 缩放,实际 %d", w)
|
|
767
|
+
}
|
|
768
|
+
if h := got.Bounds().Dy(); h != 360 {
|
|
769
|
+
t.Fatalf("应等比缩放到 360 高,实际 %d", h)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
func TestLoadProfileDefaultsAndOverrides(t *testing.T) {
|
|
774
|
+
dir := t.TempDir()
|
|
775
|
+
path := filepath.Join(dir, "p.json")
|
|
776
|
+
if err := os.WriteFile(path, []byte(`{"app":{"bundleId":"app-x"},"engines":["claude"],"backend":{"url":"http://127.0.0.1:9"}}`), 0o600); err != nil {
|
|
777
|
+
t.Fatal(err)
|
|
778
|
+
}
|
|
779
|
+
prof, err := LoadProfile(path)
|
|
780
|
+
if err != nil {
|
|
781
|
+
t.Fatal(err)
|
|
782
|
+
}
|
|
783
|
+
if prof.AppID() != "app-x" {
|
|
784
|
+
t.Errorf("app.bundleId 未生效:%q", prof.AppID())
|
|
785
|
+
}
|
|
786
|
+
if len(prof.Engines) != 1 || prof.Engines[0] != "claude" {
|
|
787
|
+
t.Errorf("engines 未生效:%+v", prof.Engines)
|
|
788
|
+
}
|
|
629
789
|
}
|
|
630
790
|
|
|
631
|
-
// TestWriteErrShape 错误一律以 {"error": "..."} 回,客户端只认这一种形状。
|
|
632
791
|
func TestWriteErrShape(t *testing.T) {
|
|
792
|
+
rec := httptest.NewRecorder()
|
|
793
|
+
writeErr(rec, http.StatusBadRequest, "坏请求")
|
|
794
|
+
if rec.Code != http.StatusBadRequest {
|
|
795
|
+
t.Errorf("状态码 = %d", rec.Code)
|
|
796
|
+
}
|
|
797
|
+
var out struct {
|
|
798
|
+
Error string `json:"error"`
|
|
799
|
+
}
|
|
800
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
801
|
+
t.Fatal(err)
|
|
802
|
+
}
|
|
803
|
+
if out.Error != "坏请求" {
|
|
804
|
+
t.Errorf("error = %q", out.Error)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
func TestSessionDeleteRemovesRunNamedLog(t *testing.T) {
|
|
809
|
+
home := t.TempDir()
|
|
810
|
+
t.Setenv(session.EnvDir, home)
|
|
811
|
+
t.Setenv("HOME", home)
|
|
812
|
+
logDir := filepath.Join(home, ".magic-agent", "sessions")
|
|
813
|
+
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
|
814
|
+
t.Fatal(err)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
now := time.Now()
|
|
818
|
+
rec := session.Record{
|
|
819
|
+
RunID: "run-keep", SessionID: "sess-keep", Engine: "codex",
|
|
820
|
+
State: session.StateDone, StartedAt: now, UpdatedAt: now,
|
|
821
|
+
}
|
|
822
|
+
body, _ := json.Marshal(rec)
|
|
823
|
+
if err := os.WriteFile(filepath.Join(home, "run-keep.json"), body, 0o600); err != nil {
|
|
824
|
+
t.Fatal(err)
|
|
825
|
+
}
|
|
826
|
+
// 日志只落在 run_id 名下(引擎整轮没吐 id → 没改名)。
|
|
827
|
+
logPath := filepath.Join(logDir, "run-keep.jsonl")
|
|
828
|
+
if err := os.WriteFile(logPath, []byte(`{"seq":1,"kind":"user","text":"hi"}`+"\n"), 0o600); err != nil {
|
|
829
|
+
t.Fatal(err)
|
|
830
|
+
}
|
|
831
|
+
|
|
633
832
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
634
833
|
srv := httptest.NewServer(svc.Handler())
|
|
635
834
|
defer srv.Close()
|
|
636
835
|
|
|
637
|
-
req, _ := http.NewRequest(http.
|
|
836
|
+
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/desk/session/sess-keep", nil)
|
|
837
|
+
req.Header.Set("X-Magic-Token", "tok")
|
|
838
|
+
resp, err := http.DefaultClient.Do(req)
|
|
839
|
+
if err != nil {
|
|
840
|
+
t.Fatal(err)
|
|
841
|
+
}
|
|
842
|
+
defer resp.Body.Close()
|
|
843
|
+
if resp.StatusCode != http.StatusOK {
|
|
844
|
+
msg, _ := io.ReadAll(resp.Body)
|
|
845
|
+
t.Fatalf("删会话应 200,实际 %d(%s)", resp.StatusCode, msg)
|
|
846
|
+
}
|
|
847
|
+
var out struct {
|
|
848
|
+
RecordsRemoved int `json:"recordsRemoved"`
|
|
849
|
+
}
|
|
850
|
+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
851
|
+
t.Fatal(err)
|
|
852
|
+
}
|
|
853
|
+
if out.RecordsRemoved != 1 {
|
|
854
|
+
t.Errorf("应删掉 1 条登记记录,实际 %d", out.RecordsRemoved)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// 按 session_id 删,run_id 名下的登记记录与日志都必须一起消失。
|
|
858
|
+
if _, err := os.Stat(filepath.Join(home, "run-keep.json")); !os.IsNotExist(err) {
|
|
859
|
+
t.Error("登记记录 run-keep.json 应已删除")
|
|
860
|
+
}
|
|
861
|
+
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
|
862
|
+
t.Error("日志文件 run-keep.jsonl 应已删除(只删 <session_id>.jsonl 会留下孤儿)")
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// fakeResumeCore 发两段之间停一下、随后自然退出的假 core:用来模拟
|
|
867
|
+
// 「客户端在流中途断线,但服务端把 core 跑完」的断线续传场景。
|
|
868
|
+
func fakeResumeCore(t *testing.T, contract string) string {
|
|
869
|
+
t.Helper()
|
|
870
|
+
dir := t.TempDir()
|
|
871
|
+
path := filepath.Join(dir, "magic-agent")
|
|
872
|
+
script := `#!/bin/sh
|
|
873
|
+
case "$*" in
|
|
874
|
+
*--contract*)
|
|
875
|
+
echo '` + contract + `'
|
|
876
|
+
exit 0
|
|
877
|
+
;;
|
|
878
|
+
*--engines*)
|
|
879
|
+
echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
|
|
880
|
+
exit 0
|
|
881
|
+
;;
|
|
882
|
+
esac
|
|
883
|
+
echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
|
|
884
|
+
echo '{"v":1,"seq":2,"type":"text","text":"前半"}'
|
|
885
|
+
sleep 2
|
|
886
|
+
echo '{"v":1,"seq":3,"type":"text","text":"后半"}'
|
|
887
|
+
echo '{"v":1,"seq":4,"type":"result","text":"前半后半"}'
|
|
888
|
+
`
|
|
889
|
+
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
890
|
+
t.Fatal(err)
|
|
891
|
+
}
|
|
892
|
+
return path
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// askEvents 拉一次 /desk/ask/{id}/events?after= 并解出响应。
|
|
896
|
+
type askEventsResp struct {
|
|
897
|
+
ID string `json:"id"`
|
|
898
|
+
Events []map[string]any `json:"events"`
|
|
899
|
+
Count int `json:"count"`
|
|
900
|
+
After uint64 `json:"after"`
|
|
901
|
+
LastSeq uint64 `json:"lastSeq"`
|
|
902
|
+
Done bool `json:"done"`
|
|
903
|
+
Code *int `json:"code"`
|
|
904
|
+
SnapshotRequired bool `json:"snapshotRequired"`
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
func getAskEvents(t *testing.T, base, token, id string, after uint64) askEventsResp {
|
|
908
|
+
t.Helper()
|
|
909
|
+
req, _ := http.NewRequest(http.MethodGet,
|
|
910
|
+
fmt.Sprintf("%s/desk/ask/%s/events?after=%d", base, url.PathEscape(id), after), nil)
|
|
911
|
+
req.Header.Set("X-Magic-Token", token)
|
|
912
|
+
resp, err := http.DefaultClient.Do(req)
|
|
913
|
+
if err != nil {
|
|
914
|
+
t.Fatal(err)
|
|
915
|
+
}
|
|
916
|
+
defer resp.Body.Close()
|
|
917
|
+
if resp.StatusCode != http.StatusOK {
|
|
918
|
+
msg, _ := io.ReadAll(resp.Body)
|
|
919
|
+
t.Fatalf("/desk/ask/{id}/events 应 200,实际 %d(%s)", resp.StatusCode, msg)
|
|
920
|
+
}
|
|
921
|
+
var out askEventsResp
|
|
922
|
+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
923
|
+
t.Fatal(err)
|
|
924
|
+
}
|
|
925
|
+
return out
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// TestAskEventsResumeAfterCursor 断线续传的服务端半场:
|
|
929
|
+
// 1. 连接还在时,游标能拿到「之后」的事件,且不会重发已看过的;
|
|
930
|
+
// 2. 客户端断连后 core 继续跑完,事件仍然进回放缓冲;
|
|
931
|
+
// 3. core 结束后 done/code 可补拉,运行中列表不再包含它。
|
|
932
|
+
func TestAskEventsResumeAfterCursor(t *testing.T) {
|
|
933
|
+
svc := newTestService(t, fakeResumeCore(t, goodContract))
|
|
934
|
+
srv := httptest.NewServer(svc.Handler())
|
|
935
|
+
defer srv.Close()
|
|
936
|
+
|
|
937
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "讲两句"})
|
|
938
|
+
opened := sse.next(t)
|
|
939
|
+
askID, _ := opened["id"].(string)
|
|
940
|
+
if askID == "" {
|
|
941
|
+
t.Fatalf("open 事件应带 ask id,实际 %v", opened)
|
|
942
|
+
}
|
|
943
|
+
ready := sse.next(t)
|
|
944
|
+
if ready["type"] != "ready" {
|
|
945
|
+
t.Fatalf("应先收到 ready,实际 %v", ready)
|
|
946
|
+
}
|
|
947
|
+
first := sse.next(t)
|
|
948
|
+
if first["type"] != "text" || first["seq"] != float64(2) {
|
|
949
|
+
t.Fatalf("应收到 seq=2 的 text,实际 %v", first)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 游标 = 2:此刻还没有新事件,接口应返回空集且未结束。
|
|
953
|
+
cur := getAskEvents(t, srv.URL, "tok", askID, 2)
|
|
954
|
+
if cur.Count != 0 || cur.Done {
|
|
955
|
+
t.Fatalf("after=2 时不该有新事件且未结束,实际 count=%d done=%v", cur.Count, cur.Done)
|
|
956
|
+
}
|
|
957
|
+
if cur.LastSeq != 2 {
|
|
958
|
+
t.Errorf("lastSeq = %d want 2", cur.LastSeq)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 断开 SSE(模拟手机锁屏 / 切后台),core 不受影响,继续跑完后半段。
|
|
962
|
+
sse.close()
|
|
963
|
+
|
|
964
|
+
deadline := time.Now().Add(10 * time.Second)
|
|
965
|
+
var last askEventsResp
|
|
966
|
+
for time.Now().Before(deadline) {
|
|
967
|
+
last = getAskEvents(t, srv.URL, "tok", askID, 2)
|
|
968
|
+
if last.Done {
|
|
969
|
+
break
|
|
970
|
+
}
|
|
971
|
+
time.Sleep(200 * time.Millisecond)
|
|
972
|
+
}
|
|
973
|
+
if !last.Done {
|
|
974
|
+
t.Fatalf("core 结束后应能补拉到 done,实际 %+v", last)
|
|
975
|
+
}
|
|
976
|
+
if last.Code == nil || *last.Code != 0 {
|
|
977
|
+
t.Errorf("补拉终态应带 code=0,实际 %v", last.Code)
|
|
978
|
+
}
|
|
979
|
+
if last.LastSeq != 4 {
|
|
980
|
+
t.Errorf("lastSeq = %d want 4", last.LastSeq)
|
|
981
|
+
}
|
|
982
|
+
var types []string
|
|
983
|
+
for _, ev := range last.Events {
|
|
984
|
+
s, _ := ev["type"].(string)
|
|
985
|
+
types = append(types, s)
|
|
986
|
+
}
|
|
987
|
+
if strings.Join(types, ",") != "text,result" {
|
|
988
|
+
t.Errorf("断点之后应只补回 text(seq3) 与 result(seq4),实际 %v", types)
|
|
989
|
+
}
|
|
990
|
+
if txt, _ := last.Events[0]["text"].(string); txt != "后半" {
|
|
991
|
+
t.Errorf("补回的第二段正文应为「后半」,实际 %q", txt)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 已结束的 ask 不该再出现在「正在跑」列表里(它只在内存里留一段补拉窗口)。
|
|
995
|
+
reqList, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/asks", nil)
|
|
996
|
+
reqList.Header.Set("X-Magic-Token", "tok")
|
|
997
|
+
respList, err := http.DefaultClient.Do(reqList)
|
|
998
|
+
if err != nil {
|
|
999
|
+
t.Fatal(err)
|
|
1000
|
+
}
|
|
1001
|
+
defer respList.Body.Close()
|
|
1002
|
+
var asks []map[string]any
|
|
1003
|
+
if err := json.NewDecoder(respList.Body).Decode(&asks); err != nil {
|
|
1004
|
+
t.Fatal(err)
|
|
1005
|
+
}
|
|
1006
|
+
if len(asks) != 0 {
|
|
1007
|
+
t.Errorf("结束的 ask 不应出现在运行中列表,实际 %+v", asks)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// 未知 id 诚实报 404(不是空列表,免得客户端以为「还在跑」)。
|
|
1011
|
+
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/ask/ask-nope/events?after=0", nil)
|
|
638
1012
|
req.Header.Set("X-Magic-Token", "tok")
|
|
639
1013
|
resp, err := http.DefaultClient.Do(req)
|
|
640
1014
|
if err != nil {
|
|
641
1015
|
t.Fatal(err)
|
|
642
1016
|
}
|
|
643
1017
|
defer resp.Body.Close()
|
|
644
|
-
if resp.StatusCode != http.
|
|
645
|
-
t.
|
|
1018
|
+
if resp.StatusCode != http.StatusNotFound {
|
|
1019
|
+
t.Errorf("未知 ask 应 404,实际 %d", resp.StatusCode)
|
|
646
1020
|
}
|
|
647
|
-
|
|
648
|
-
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// TestAskEventsIncludesSyntheticPrompt 断点续传要把「用户问的那句」补回来:
|
|
1024
|
+
// core 的 stdout 事件流里只有 ready/text/result,从不发 user(user 只进会话日志),
|
|
1025
|
+
// 所以网关在 /desk/ask 启动前把 prompt 合成一条 type:"user" 塞进回放缓冲(appendPrompt)。
|
|
1026
|
+
// 三条约束逐条钉住:
|
|
1027
|
+
// 1. after=0 的首条就是它:text/attachments 与请求一致,带 prompt_id,**不带 seq**;
|
|
1028
|
+
// 2. 它**不进** SSE 直播流(第一帧仍是 core 的 ready),否则实时端会画出两个用户气泡;
|
|
1029
|
+
// 3. after>0 的增量补拉不再给(否则每补一次就重复一个气泡)。
|
|
1030
|
+
func TestAskEventsIncludesSyntheticPrompt(t *testing.T) {
|
|
1031
|
+
svc := newTestService(t, fakeResumeCore(t, goodContract))
|
|
1032
|
+
srv := httptest.NewServer(svc.Handler())
|
|
1033
|
+
defer srv.Close()
|
|
1034
|
+
|
|
1035
|
+
att := filepath.Join(t.TempDir(), "note.png")
|
|
1036
|
+
if err := os.WriteFile(att, []byte("png"), 0o600); err != nil {
|
|
649
1037
|
t.Fatal(err)
|
|
650
1038
|
}
|
|
651
|
-
|
|
652
|
-
|
|
1039
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{
|
|
1040
|
+
Engine: "claude", Prompt: "讲两句", Attachments: []string{att},
|
|
1041
|
+
})
|
|
1042
|
+
defer sse.close()
|
|
1043
|
+
opened := sse.next(t)
|
|
1044
|
+
askID, _ := opened["id"].(string)
|
|
1045
|
+
if askID == "" {
|
|
1046
|
+
t.Fatalf("open 事件应带 ask id,实际 %v", opened)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// 约束 2:直播流第一帧必须还是 core 的 ready。
|
|
1050
|
+
ready := sse.next(t)
|
|
1051
|
+
if ready["type"] != "ready" {
|
|
1052
|
+
t.Fatalf("直播流首帧应仍是 ready(合成 user 不该进直播流),实际 %v", ready)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// 约束 1:从头补(after=0)时先给合成的提问。
|
|
1056
|
+
cur := getAskEvents(t, srv.URL, "tok", askID, 0)
|
|
1057
|
+
if cur.Count == 0 {
|
|
1058
|
+
t.Fatal("after=0 应至少补回合成的 user 事件")
|
|
1059
|
+
}
|
|
1060
|
+
user := cur.Events[0]
|
|
1061
|
+
if user["type"] != "user" {
|
|
1062
|
+
t.Fatalf("补回的首条应是 user,实际 %v", user)
|
|
1063
|
+
}
|
|
1064
|
+
if txt, _ := user["text"].(string); txt != "讲两句" {
|
|
1065
|
+
t.Errorf("合成 user 的 text = %q want 讲两句", txt)
|
|
1066
|
+
}
|
|
1067
|
+
if pid, _ := user["prompt_id"].(string); pid == "" {
|
|
1068
|
+
t.Error("合成 user 应带 prompt_id(客户端去重的键)")
|
|
1069
|
+
}
|
|
1070
|
+
if _, has := user["seq"]; has {
|
|
1071
|
+
t.Errorf("合成 user 不该带 seq(不能推进客户端游标),实际 %v", user)
|
|
1072
|
+
}
|
|
1073
|
+
atts, _ := user["attachments"].([]any)
|
|
1074
|
+
if len(atts) != 1 {
|
|
1075
|
+
t.Fatalf("合成 user 应带 1 个附件,实际 %v", user["attachments"])
|
|
1076
|
+
}
|
|
1077
|
+
if m, _ := atts[0].(map[string]any); m["path"] != att {
|
|
1078
|
+
t.Errorf("附件 path = %v want %s", atts[0], att)
|
|
653
1079
|
}
|
|
1080
|
+
|
|
1081
|
+
// 约束 3:带游标的增量补拉不再重复给。
|
|
1082
|
+
inc := getAskEvents(t, srv.URL, "tok", askID, 1)
|
|
1083
|
+
for _, ev := range inc.Events {
|
|
1084
|
+
if ev["type"] == "user" {
|
|
1085
|
+
t.Errorf("after>0 的增量补拉不该再给合成 user,实际 %v", ev)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// multipartWrite 把 content 写成一个 multipart/form-data 请求体,返回 Content-Type。
|
|
1091
|
+
func multipartWrite(buf *bytes.Buffer, field, name string, content []byte) string {
|
|
1092
|
+
w := multipart.NewWriter(buf)
|
|
1093
|
+
fw, _ := w.CreateFormFile(field, name)
|
|
1094
|
+
_, _ = fw.Write(content)
|
|
1095
|
+
_ = w.Close()
|
|
1096
|
+
return w.FormDataContentType()
|
|
654
1097
|
}
|