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
|
@@ -2,6 +2,7 @@ package client
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
|
+
"fmt"
|
|
5
6
|
"net/http"
|
|
6
7
|
"net/http/httptest"
|
|
7
8
|
"os"
|
|
@@ -184,3 +185,132 @@ func TestHandleSessionEvents(t *testing.T) {
|
|
|
184
185
|
}
|
|
185
186
|
})
|
|
186
187
|
}
|
|
188
|
+
|
|
189
|
+
// TestTruncRunes 钉住按字符(不是字节)截断,且不切坏多字节字符。
|
|
190
|
+
func TestTruncRunes(t *testing.T) {
|
|
191
|
+
cases := []struct {
|
|
192
|
+
in string
|
|
193
|
+
max int
|
|
194
|
+
want string
|
|
195
|
+
}{
|
|
196
|
+
{"abc", 2, "ab"},
|
|
197
|
+
{"abc", 3, "abc"},
|
|
198
|
+
{"abc", 5, "abc"},
|
|
199
|
+
{"中文测试", 2, "中文"},
|
|
200
|
+
{"中文测试", 4, "中文测试"},
|
|
201
|
+
{"a中b", 2, "a中"},
|
|
202
|
+
{"😀😀", 1, "😀"},
|
|
203
|
+
{"abc", 0, "abc"},
|
|
204
|
+
}
|
|
205
|
+
for _, c := range cases {
|
|
206
|
+
if got := truncRunes(c.in, c.max); got != c.want {
|
|
207
|
+
t.Errorf("truncRunes(%q, %d) = %q,期望 %q", c.in, c.max, got, c.want)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// TestSessionLogLean 钉住 `?lean=1`(与 CLI 的 --session-log-lean 同一口径):
|
|
213
|
+
//
|
|
214
|
+
// - 不传:事件里的 `snapshot` 原样返回(旧行为,老客户端不受影响)
|
|
215
|
+
// - 传了:**键整个消失**(不是空串 —— CLI 侧 omitempty,两边要逐字段对齐),
|
|
216
|
+
// 其余字段(seq / kind / text / item_id)一个不少
|
|
217
|
+
// - /messages 与 /events 两条接口都必须支持(移动端首屏走前者、续读走后者)
|
|
218
|
+
//
|
|
219
|
+
// 为什么要有这个开关:snapshot 是二次方膨胀的累积字段(实测一条会话 283MB),
|
|
220
|
+
// 浏览器/手机端没人消费它,不裁就要解析整份 300MB+ JSON。
|
|
221
|
+
func TestSessionLogLean(t *testing.T) {
|
|
222
|
+
home := t.TempDir()
|
|
223
|
+
dir := filepath.Join(home, ".magic-agent", "sessions")
|
|
224
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
225
|
+
t.Fatal(err)
|
|
226
|
+
}
|
|
227
|
+
t.Setenv("HOME", home)
|
|
228
|
+
id := "sess-lean"
|
|
229
|
+
longOut := strings.Repeat("工", leanToolOutMax+500) // 超长工具输出(多字节,验证按 rune 截)
|
|
230
|
+
lines := []string{
|
|
231
|
+
`{"seq":1,"kind":"thinking","text":"想","snapshot":"想","item_id":"x_think_1"}`,
|
|
232
|
+
`{"seq":2,"kind":"thinking","text":"更","snapshot":"想更","item_id":"x_think_1"}`,
|
|
233
|
+
`{"seq":3,"kind":"text","text":"好","snapshot":"好","item_id":"x_msg_1"}`,
|
|
234
|
+
`{"seq":4,"kind":"tool_result","text":"` + longOut + `","name":"Bash"}`,
|
|
235
|
+
}
|
|
236
|
+
if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
241
|
+
srv := httptest.NewServer(svc.Handler())
|
|
242
|
+
defer srv.Close()
|
|
243
|
+
|
|
244
|
+
read := func(t *testing.T, url string) []map[string]any {
|
|
245
|
+
t.Helper()
|
|
246
|
+
resp := doGET(t, url, true)
|
|
247
|
+
if resp.statusCode != http.StatusOK {
|
|
248
|
+
t.Fatalf("GET %s 期望 200,实得 %d", url, resp.statusCode)
|
|
249
|
+
}
|
|
250
|
+
var b struct {
|
|
251
|
+
Events []map[string]any `json:"events"`
|
|
252
|
+
}
|
|
253
|
+
if err := json.Unmarshal(resp.body, &b); err != nil {
|
|
254
|
+
t.Fatalf("解析响应失败:%v", err)
|
|
255
|
+
}
|
|
256
|
+
return b.Events
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 默认(不传 lean):snapshot 原样保留 —— 只增参数,不传时行为不变。
|
|
260
|
+
full := read(t, srv.URL+"/desk/session/"+id+"/messages?token=tok")
|
|
261
|
+
if len(full) != 4 {
|
|
262
|
+
t.Fatalf("期望 4 条,实得 %d", len(full))
|
|
263
|
+
}
|
|
264
|
+
if full[1]["snapshot"] != "想更" {
|
|
265
|
+
t.Fatalf("不传 lean 时 snapshot 应原样返回,实得 %v", full[1]["snapshot"])
|
|
266
|
+
}
|
|
267
|
+
if got := full[3]["text"]; got != longOut {
|
|
268
|
+
t.Fatalf("不传 lean 时工具输出应原样返回,实得 %d 字符", len([]rune(fmt.Sprint(got))))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// /messages?lean=1:键消失,其余字段不动;相邻 thinking 并成一条;
|
|
272
|
+
// tool_result 截到 leanToolOutMax 个字符。
|
|
273
|
+
lean := read(t, srv.URL+"/desk/session/"+id+"/messages?lean=1&token=tok")
|
|
274
|
+
if len(lean) != 3 {
|
|
275
|
+
t.Fatalf("相邻 thinking 应并成一条(4 → 3),实得 %d", len(lean))
|
|
276
|
+
}
|
|
277
|
+
if got, want := fmt.Sprint(lean[0]["text"]), "想更"; got != want {
|
|
278
|
+
t.Fatalf("合并后的思考文本应为 %q,实得 %q", want, got)
|
|
279
|
+
}
|
|
280
|
+
if lean[0]["seq"] != float64(2) {
|
|
281
|
+
t.Fatalf("合并后应保留末条 seq(2),实得 %v", lean[0]["seq"])
|
|
282
|
+
}
|
|
283
|
+
for i, e := range lean {
|
|
284
|
+
if _, ok := e["snapshot"]; ok {
|
|
285
|
+
t.Fatalf("第 %d 条仍带 snapshot:%v", i, e)
|
|
286
|
+
}
|
|
287
|
+
if e["text"] == nil || e["seq"] == nil || e["kind"] == nil {
|
|
288
|
+
t.Fatalf("第 %d 条基础字段被误删:%v", i, e)
|
|
289
|
+
}
|
|
290
|
+
if i == 0 && e["item_id"] != "x_think_1" {
|
|
291
|
+
t.Fatalf("item_id 不该被裁掉:%v", e)
|
|
292
|
+
}
|
|
293
|
+
if i == 2 {
|
|
294
|
+
// 按 rune 截:len() 是字节数,中文会 ×3,必须数 rune 才对得上 12000。
|
|
295
|
+
if n := len([]rune(fmt.Sprint(e["text"]))); n != leanToolOutMax {
|
|
296
|
+
t.Fatalf("tool_result 应截到 %d 字符,实得 %d", leanToolOutMax, n)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// /events?after=1&lean=1:增量路径同样裁。
|
|
302
|
+
inc := read(t, srv.URL+"/desk/session/"+id+"/events?after=1&lean=1&token=tok")
|
|
303
|
+
if len(inc) != 3 {
|
|
304
|
+
t.Fatalf("after=1 期望 3 条,实得 %d", len(inc))
|
|
305
|
+
}
|
|
306
|
+
for i, e := range inc {
|
|
307
|
+
if _, ok := e["snapshot"]; ok {
|
|
308
|
+
t.Fatalf("增量第 %d 条仍带 snapshot:%v", i, e)
|
|
309
|
+
}
|
|
310
|
+
if i == 2 {
|
|
311
|
+
if n := len([]rune(fmt.Sprint(e["text"]))); n != leanToolOutMax {
|
|
312
|
+
t.Fatalf("增量里的 tool_result 也应截到 %d 字符,实得 %d", leanToolOutMax, n)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"sync"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/gorilla/websocket"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
/* SOCKS5 over WebSocket。
|
|
14
|
+
*
|
|
15
|
+
* ── 为什么需要它 ──
|
|
16
|
+
* 移动端 VPN 的出口是一个 SOCKS5 服务,但 App 与桌面端之间隔着 Cloudflare 隧道,
|
|
17
|
+
* 而**Cloudflare 的 HTTP 隧道搬不了裸 TCP**(它只转 HTTP/WebSocket)——SOCKS5 连不上。
|
|
18
|
+
* 把 SOCKS5 的字节流整段塞进 WebSocket 之后,隧道就能把它带到公网了。
|
|
19
|
+
*
|
|
20
|
+
* ── 做法 ──
|
|
21
|
+
* 直接把这条 WebSocket 当成一个 net.Conn 交给现成的 serveSOCKS5:
|
|
22
|
+
* 于是 App 侧只要「把 hev 吐出来的 SOCKS5 字节原封不动转发到这个 WebSocket」,
|
|
23
|
+
* 桌面端无需为隧道另写一套协议 —— 两端传的就是同一串字节。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const proxyWSPath = "/proxy-ws"
|
|
27
|
+
|
|
28
|
+
var wsUpgrader = websocket.Upgrader{
|
|
29
|
+
ReadBufferSize: 32 * 1024,
|
|
30
|
+
WriteBufferSize: 32 * 1024,
|
|
31
|
+
// 由令牌把关,这里放宽 Origin:App 侧的 WebView/OkHttp 不会带站内 Origin。
|
|
32
|
+
CheckOrigin: func(r *http.Request) bool { return true },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// handleProxyWS 升级成 WebSocket,然后把它当作一条 SOCKS5 连接处理。
|
|
36
|
+
func (s *Service) handleProxyWS(w http.ResponseWriter, r *http.Request) {
|
|
37
|
+
// 鉴权:与 /b/ 同口径 —— 查询令牌或 cookie。
|
|
38
|
+
if want := strings.TrimSpace(s.opts.Token); want != "" {
|
|
39
|
+
got := strings.TrimSpace(r.URL.Query().Get("token"))
|
|
40
|
+
if got == "" {
|
|
41
|
+
if c, err := r.Cookie(browseCookie); err == nil {
|
|
42
|
+
got = c.Value
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if got != want {
|
|
46
|
+
http.Error(w, "缺少或错误的访问令牌", http.StatusUnauthorized)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
c, err := wsUpgrader.Upgrade(w, r, nil)
|
|
52
|
+
if err != nil {
|
|
53
|
+
return // Upgrade 已经写过响应
|
|
54
|
+
}
|
|
55
|
+
defer func() {
|
|
56
|
+
// 发一个正常的关闭帧再关底层连接:直接 Close 会让对端收到 1006(异常关闭),
|
|
57
|
+
// 客户端会把它当成错误而不是正常结束。
|
|
58
|
+
_ = c.WriteControl(websocket.CloseMessage,
|
|
59
|
+
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
|
|
60
|
+
time.Now().Add(time.Second))
|
|
61
|
+
_ = c.Close()
|
|
62
|
+
}()
|
|
63
|
+
|
|
64
|
+
if err := s.serveSOCKS5(newWSConn(c)); err != nil {
|
|
65
|
+
s.log.Printf("socks5(ws): %v", err)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// wsConn 把一条 WebSocket 伪装成 net.Conn:每个二进制帧就是一段字节。
|
|
70
|
+
type wsConn struct {
|
|
71
|
+
ws *websocket.Conn
|
|
72
|
+
|
|
73
|
+
wmu sync.Mutex // gorilla 不允许并发写
|
|
74
|
+
|
|
75
|
+
rmu sync.Mutex
|
|
76
|
+
rest []byte // 上一帧没读完的剩余字节
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func newWSConn(c *websocket.Conn) *wsConn { return &wsConn{ws: c} }
|
|
80
|
+
|
|
81
|
+
func (c *wsConn) Read(p []byte) (int, error) {
|
|
82
|
+
c.rmu.Lock()
|
|
83
|
+
defer c.rmu.Unlock()
|
|
84
|
+
for len(c.rest) == 0 {
|
|
85
|
+
_, data, err := c.ws.ReadMessage()
|
|
86
|
+
if err != nil {
|
|
87
|
+
return 0, err
|
|
88
|
+
}
|
|
89
|
+
if len(data) == 0 {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
c.rest = data
|
|
93
|
+
}
|
|
94
|
+
n := copy(p, c.rest)
|
|
95
|
+
c.rest = c.rest[n:]
|
|
96
|
+
return n, nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func (c *wsConn) Write(p []byte) (int, error) {
|
|
100
|
+
c.wmu.Lock()
|
|
101
|
+
defer c.wmu.Unlock()
|
|
102
|
+
if err := c.ws.WriteMessage(websocket.BinaryMessage, p); err != nil {
|
|
103
|
+
return 0, err
|
|
104
|
+
}
|
|
105
|
+
return len(p), nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func (c *wsConn) Close() error { return c.ws.Close() }
|
|
109
|
+
func (c *wsConn) LocalAddr() net.Addr { return c.ws.LocalAddr() }
|
|
110
|
+
func (c *wsConn) RemoteAddr() net.Addr { return c.ws.RemoteAddr() }
|
|
111
|
+
func (c *wsConn) SetDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
|
|
112
|
+
func (c *wsConn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
|
|
113
|
+
func (c *wsConn) SetWriteDeadline(t time.Time) error { return c.ws.SetWriteDeadline(t) }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/binary"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"net"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"net/url"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/gorilla/websocket"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// TestProxyWS 端到端:连上 /proxy-ws,在 WebSocket 里跑完整 SOCKS5 握手并取回内容。
|
|
19
|
+
// 这条通过的含意是:SOCKS5 真的能穿过「只能搬 HTTP/WS」的隧道(Cloudflare 那条路的可行性)。
|
|
20
|
+
func TestProxyWS(t *testing.T) {
|
|
21
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
22
|
+
_, _ = io.WriteString(w, "hello-over-websocket")
|
|
23
|
+
}))
|
|
24
|
+
defer upstream.Close()
|
|
25
|
+
uu, _ := url.Parse(upstream.URL)
|
|
26
|
+
|
|
27
|
+
s := &Service{log: testLogger(), opts: Options{Token: "sekret"}}
|
|
28
|
+
srv := httptest.NewServer(s.Handler())
|
|
29
|
+
defer srv.Close()
|
|
30
|
+
|
|
31
|
+
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + proxyWSPath + "?token=sekret"
|
|
32
|
+
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("连 WebSocket 失败:%v", err)
|
|
35
|
+
}
|
|
36
|
+
conn := newWSConn(ws)
|
|
37
|
+
defer conn.Close()
|
|
38
|
+
|
|
39
|
+
// 认证(RFC 1929)
|
|
40
|
+
if _, err := conn.Write([]byte{0x05, 0x01, 0x02}); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
sel := make([]byte, 2)
|
|
44
|
+
if _, err := io.ReadFull(conn, sel); err != nil || sel[1] != 0x02 {
|
|
45
|
+
t.Fatalf("协商失败:%v %v", sel, err)
|
|
46
|
+
}
|
|
47
|
+
auth := []byte{0x01, 5}
|
|
48
|
+
auth = append(auth, "magic"...)
|
|
49
|
+
auth = append(auth, 6)
|
|
50
|
+
auth = append(auth, "sekret"...)
|
|
51
|
+
if _, err := conn.Write(auth); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
ar := make([]byte, 2)
|
|
55
|
+
if _, err := io.ReadFull(conn, ar); err != nil || ar[1] != 0 {
|
|
56
|
+
t.Fatalf("认证失败:%v %v", ar, err)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// CONNECT 到上游 HTTP 服务
|
|
60
|
+
host, portStr, _ := net.SplitHostPort(uu.Host)
|
|
61
|
+
var port int
|
|
62
|
+
_, _ = fmt.Sscanf(portStr, "%d", &port)
|
|
63
|
+
req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))}
|
|
64
|
+
req = append(req, host...)
|
|
65
|
+
p := make([]byte, 2)
|
|
66
|
+
binary.BigEndian.PutUint16(p, uint16(port))
|
|
67
|
+
req = append(req, p...)
|
|
68
|
+
if _, err := conn.Write(req); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
rep := make([]byte, 10)
|
|
72
|
+
if _, err := io.ReadFull(conn, rep); err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
if rep[1] != 0x00 {
|
|
76
|
+
t.Fatalf("CONNECT 被拒:rep=%d", rep[1])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
|
80
|
+
if _, err := io.WriteString(conn, "GET / HTTP/1.1\r\nHost: "+uu.Host+"\r\nConnection: close\r\n\r\n"); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
// 上游会关连接,读完即可;结尾的关闭错误是正常的(真实客户端也是这么处理的)。
|
|
84
|
+
body, _ := io.ReadAll(conn)
|
|
85
|
+
if !strings.Contains(string(body), "hello-over-websocket") {
|
|
86
|
+
t.Errorf("未取到上游内容,实际:%s", body)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// TestProxyWSRejectsBadToken 令牌不对必须拒。
|
|
91
|
+
func TestProxyWSRejectsBadToken(t *testing.T) {
|
|
92
|
+
s := &Service{log: testLogger(), opts: Options{Token: "sekret"}}
|
|
93
|
+
srv := httptest.NewServer(s.Handler())
|
|
94
|
+
defer srv.Close()
|
|
95
|
+
|
|
96
|
+
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + proxyWSPath + "?token=wrong"
|
|
97
|
+
if _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil); err == nil {
|
|
98
|
+
t.Error("错误令牌应当被拒")
|
|
99
|
+
} else if resp != nil && resp.StatusCode != http.StatusUnauthorized {
|
|
100
|
+
t.Errorf("期望 401,实际 %d", resp.StatusCode)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// delete.go - 删除一条会话(登记记录 + 事件日志 + 标题覆盖)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要它(2026-10-19 用户:「从pc端删除的会话移动端还在显示」):
|
|
4
|
+
// 此前「删除」只发生在**客户端本地**(掌天瓶记一座 localStorage 墓碑、从自己的列表拿掉),
|
|
5
|
+
// 而列表的权威来源是**登记表**:移动端 H5 走插件的 `/desk/sessions`、桌面端走
|
|
6
|
+
// `--sessions`,读的都是这一份。于是 PC 上删掉的会话,在移动端照旧列着 ——
|
|
7
|
+
// 「一端删了、另一端还在」正是没有把删除落在共享数据上的必然结果。
|
|
8
|
+
//
|
|
9
|
+
// 语义与插件的 `DELETE /desk/session/{id}`(internal/client/service.go)**逐字对齐**,
|
|
10
|
+
// 两者现在共用这一份实现:
|
|
11
|
+
//
|
|
12
|
+
// · 登记记录:按 session_id 或 run_id 命中的**每一条 run 记录**都删(一条会话每轮一条);
|
|
13
|
+
// · 事件日志:`<session_id>.jsonl` 与 `<run_id>.jsonl` 两个名字都试 —— 引擎不回调
|
|
14
|
+
// OnSessionID 时整轮都叫 `<run_id>.jsonl`(见 ResolveLogID 的说明),只删一个会
|
|
15
|
+
// 留下「列表里没了、磁盘上还占着」的孤儿日志;
|
|
16
|
+
// · 标题覆盖:一并清掉,避免「会话没了、标题表里还留着」。
|
|
17
|
+
//
|
|
18
|
+
// ⚠️ 这是**物理删除**(登记记录与日志文件都不在了),不是「本机隐藏」。
|
|
19
|
+
//
|
|
20
|
+
// 客户端要的是「删了就是删了、到哪台设备都看不到」,隐含代价是历史不可回放 ——
|
|
21
|
+
// 所以客户端的删除入口必须把这件事说清楚(见 magic-test 的删除提示文案)。
|
|
22
|
+
//
|
|
23
|
+
// 幂等:id 不认识、文件本来就不在,都算成功(调用方要的是「它现在不在了」)。
|
|
24
|
+
package session
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"os"
|
|
28
|
+
"path/filepath"
|
|
29
|
+
"strings"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// DeleteResult 删除结果。计数只用于向调用方交代「删掉了什么」,不作判据。
|
|
33
|
+
type DeleteResult struct {
|
|
34
|
+
ID string `json:"id"`
|
|
35
|
+
RecordsRemoved int `json:"records_removed"`
|
|
36
|
+
LogsRemoved int `json:"logs_removed"`
|
|
37
|
+
TitlesRemoved int `json:"titles_removed"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Delete 删掉一条会话:登记记录 + 事件日志 + 标题覆盖。
|
|
41
|
+
// id 可以是 session_id 或 run_id(与 --stop / --session-log 同一套寻址)。
|
|
42
|
+
func Delete(id string) (DeleteResult, error) {
|
|
43
|
+
out := DeleteResult{ID: strings.TrimSpace(id)}
|
|
44
|
+
if out.ID == "" {
|
|
45
|
+
return out, os.ErrInvalid
|
|
46
|
+
}
|
|
47
|
+
/* ⚠️ id 要拼进文件名(`<id>.json` / `<id>.jsonl`)→ 必须挡住路径穿越。
|
|
48
|
+
插件那条路在 parseSessionPath 里已经挡过一次,这里是**共用实现**,
|
|
49
|
+
不能指望每个调用方都记得挡。 */
|
|
50
|
+
if !safeDeleteID(out.ID) {
|
|
51
|
+
return out, os.ErrInvalid
|
|
52
|
+
}
|
|
53
|
+
dir := Dir()
|
|
54
|
+
recs, err := List()
|
|
55
|
+
if err != nil {
|
|
56
|
+
return out, err
|
|
57
|
+
}
|
|
58
|
+
/* ① 先收集「这条会话所有可能的名字」:传进来的 id(可能两个都不在登记表里、
|
|
59
|
+
只有日志文件在),加上命中记录自己的 session_id 与 run_id。 */
|
|
60
|
+
names := map[string]bool{out.ID: true}
|
|
61
|
+
for _, r := range recs {
|
|
62
|
+
if r.RunID != out.ID && r.SessionID != out.ID {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if r.RunID != "" {
|
|
66
|
+
names[r.RunID] = true
|
|
67
|
+
}
|
|
68
|
+
if r.SessionID != "" {
|
|
69
|
+
names[r.SessionID] = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/* ② 再按名字集合删**这一整条会话**的记录:一条会话每轮落一条(run_id 各不同、
|
|
73
|
+
session_id 相同),只删「id 正好等于传进来那个」的那一条,其它轮还在列表里
|
|
74
|
+
—— 表现就是「删了没删干净、刷新又冒出来」。 */
|
|
75
|
+
for _, r := range recs {
|
|
76
|
+
if !names[r.RunID] && !names[r.SessionID] {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if dir == "" || r.RunID == "" {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if err := os.Remove(filepath.Join(dir, r.RunID+".json")); err == nil {
|
|
83
|
+
out.RecordsRemoved++
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for _, logDir := range logDirsForDelete(dir) {
|
|
87
|
+
for one := range names {
|
|
88
|
+
if err := os.Remove(logPath(logDir, one)); err == nil {
|
|
89
|
+
out.LogsRemoved++
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
out.TitlesRemoved = dropTitles(names)
|
|
94
|
+
return out, nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// logDirsForDelete 删日志该去哪些目录(去重)。
|
|
98
|
+
//
|
|
99
|
+
// 默认两者是同一个目录(~/.magic-agent/sessions),但它们**能被分别改写**:
|
|
100
|
+
// 登记表目录认 `MAGIC_AGENT_SESSIONS`,日志目录认 `HOME` ——
|
|
101
|
+
// 只查一个目录就会出现「列表里没了、磁盘上还占着」(孤儿日志)。
|
|
102
|
+
func logDirsForDelete(registryDir string) []string {
|
|
103
|
+
out := make([]string, 0, 2)
|
|
104
|
+
add := func(d string) {
|
|
105
|
+
d = strings.TrimSpace(d)
|
|
106
|
+
if d == "" {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
for _, x := range out {
|
|
110
|
+
if x == d {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out = append(out, d)
|
|
115
|
+
}
|
|
116
|
+
add(registryDir)
|
|
117
|
+
if d, err := DefaultLogDir(); err == nil {
|
|
118
|
+
add(d)
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// safeDeleteID id 能不能安全地当文件名用:非空、非 `.` / `..`、不含路径分隔符与控制字符。
|
|
124
|
+
func safeDeleteID(id string) bool {
|
|
125
|
+
if id == "" || id == "." || id == ".." {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
for _, c := range id {
|
|
129
|
+
if c < 0x20 || c == 0x7f || c == '/' || c == '\\' {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// dropTitles 清掉这些 id 上的标题覆盖,返回清掉的条数。
|
|
137
|
+
//
|
|
138
|
+
// 逐键走 SetTitle(它每次都重新从磁盘读-改-写):删除是低频动作,
|
|
139
|
+
// 为它多写一条「批量清理」的路径不值当 —— 那条路径迟早会跟单键那条分叉。
|
|
140
|
+
func dropTitles(names map[string]bool) int {
|
|
141
|
+
n := 0
|
|
142
|
+
for one := range names {
|
|
143
|
+
if TitleFor(one) == "" {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if err := SetTitle(one, ""); err == nil {
|
|
147
|
+
n++
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return n
|
|
151
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package session
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/* Delete:一条会话的**物理删除**(登记记录 + 事件日志 + 标题覆盖)。
|
|
10
|
+
*
|
|
11
|
+
* 为什么值得钉(2026-10-19 用户:「从pc端删除的会话移动端还在显示」):
|
|
12
|
+
* 列表的权威来源是登记表,各端(PC 的 --sessions、移动端 H5 的 /desk/sessions)读的是同一份。
|
|
13
|
+
* 删除只要漏掉任何一侧,「一端删了、另一处还在」就会重现:
|
|
14
|
+
* · 只删一条 run 记录 → 同一会话的其它轮还在,列表里照旧列着;
|
|
15
|
+
* · 只删 <session_id>.jsonl → 引擎整轮没吐 session_id 的那种(日志叫 <run_id>.jsonl)
|
|
16
|
+
* 会留下孤儿日志:列表里没了、磁盘上还占着;
|
|
17
|
+
* · 标题表不清 → 下次同 id 回来时带着旧标题。
|
|
18
|
+
*/
|
|
19
|
+
func TestDeleteRemovesEverything(t *testing.T) {
|
|
20
|
+
dir := t.TempDir()
|
|
21
|
+
t.Setenv(EnvDir, dir)
|
|
22
|
+
|
|
23
|
+
// 一条会话跨两轮:第一轮还没拿到 session_id(日志落在 run_id 上),第二轮才拿到。
|
|
24
|
+
h1, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid(), PromptHead: "首问"})
|
|
25
|
+
if err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
h1.Finish("sid-1", StateDone)
|
|
29
|
+
h2, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid(), SessionID: "sid-1", PromptHead: "追问"})
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
h2.Finish("sid-1", StateDone)
|
|
34
|
+
|
|
35
|
+
// 两份日志:run_id 那份(首轮没 id 时落的)与 session_id 那份(终名)
|
|
36
|
+
for _, name := range []string{h1.RunID(), "sid-1"} {
|
|
37
|
+
if err := os.WriteFile(filepath.Join(dir, name+".jsonl"), []byte("{\"seq\":1}\n"), 0o600); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if err := SetTitle("sid-1", "某条标题"); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 按 **run_id** 删(客户端手上的 id 可能只是某一轮的 run_id)
|
|
46
|
+
res, err := Delete(h1.RunID())
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("Delete: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if res.RecordsRemoved != 2 {
|
|
51
|
+
t.Errorf("登记记录删了 %d 条,want 2(同一会话的两轮)", res.RecordsRemoved)
|
|
52
|
+
}
|
|
53
|
+
if res.LogsRemoved != 2 {
|
|
54
|
+
t.Errorf("日志删了 %d 份,want 2(<run_id>.jsonl 与 <session_id>.jsonl)", res.LogsRemoved)
|
|
55
|
+
}
|
|
56
|
+
if res.TitlesRemoved != 1 {
|
|
57
|
+
t.Errorf("标题清了 %d 条,want 1", res.TitlesRemoved)
|
|
58
|
+
}
|
|
59
|
+
if recs, _ := List(); len(recs) != 0 {
|
|
60
|
+
t.Errorf("登记表还剩 %d 条记录,want 0(%+v)", len(recs), recs)
|
|
61
|
+
}
|
|
62
|
+
for _, name := range []string{h1.RunID(), "sid-1", h2.RunID()} {
|
|
63
|
+
if fileExists(filepath.Join(dir, name+".jsonl")) {
|
|
64
|
+
t.Errorf("日志 %s.jsonl 还在", name)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if got := TitleFor("sid-1"); got != "" {
|
|
68
|
+
t.Errorf("标题表还有 %q,want 空", got)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
幂等:id 不认识 / 文件本来就不在 / 再删一次 —— 都算成功。
|
|
74
|
+
|
|
75
|
+
客户端的删除入口拿到的可能是一条**只在本地存在**的行(从没进过登记表),
|
|
76
|
+
它要的是「这条别在我面前出现」,不该因为「本来就没有」而报错。
|
|
77
|
+
*/
|
|
78
|
+
func TestDeleteIsIdempotent(t *testing.T) {
|
|
79
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
80
|
+
for i := 0; i < 2; i++ {
|
|
81
|
+
res, err := Delete("run-does-not-exist")
|
|
82
|
+
if err != nil {
|
|
83
|
+
t.Fatalf("第 %d 次 Delete 报错:%v", i+1, err)
|
|
84
|
+
}
|
|
85
|
+
if res.RecordsRemoved != 0 || res.LogsRemoved != 0 {
|
|
86
|
+
t.Errorf("第 %d 次:删了不存在的记录 %+v,want 全 0", i+1, res)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
id 会拼进文件名(`<id>.json` / `<id>.jsonl`)→ 路径穿越必须挡住。
|
|
93
|
+
|
|
94
|
+
插件那条路在 parseSessionPath 里挡过一次,本函数是**共用实现**,不能指望调用方都记得挡。
|
|
95
|
+
*/
|
|
96
|
+
func TestDeleteRejectsUnsafeID(t *testing.T) {
|
|
97
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
98
|
+
for _, bad := range []string{"", ".", "..", "../../etc/passwd", "a/b", "a\\b", "x\x00y"} {
|
|
99
|
+
if _, err := Delete(bad); err == nil {
|
|
100
|
+
t.Errorf("Delete(%q) 没报错,want 拒绝", bad)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|