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,186 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ // 增量续读端点 `GET /desk/session/{id}/events?after=<seq>` 的边界。
14
+ //
15
+ // 这条路由与 `/messages` 共用 `/desk/session/` 这个**子树前缀** —— 所以这一组同时
16
+ // 钉住「分派没串线」:`/messages` 仍全量、`/events` 才增量、未知子路径**明说** 404
17
+ // (而不是默默回一份看起来正常的全量历史 —— 那会把「增量没生效」伪装成「增量没实现」)。
18
+ func TestHandleSessionEvents(t *testing.T) {
19
+ home := t.TempDir()
20
+ dir := filepath.Join(home, ".magic-agent", "sessions")
21
+ if err := os.MkdirAll(dir, 0o700); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ t.Setenv("HOME", home)
25
+ id := "sess-ev"
26
+ lines := []string{
27
+ `{"seq":1,"kind":"user","text":"问题","session_id":"` + id + `"}`,
28
+ `{"seq":2,"kind":"thinking","text":"思考","session_id":"` + id + `"}`,
29
+ `{"seq":3,"kind":"text","text":"正文","session_id":"` + id + `"}`,
30
+ `{"seq":4,"kind":"turn_end","text":"完成","session_id":"` + id + `"}`,
31
+ }
32
+ if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+
36
+ svc := newTestService(t, fakeCore(t, goodContract))
37
+ srv := httptest.NewServer(svc.Handler())
38
+ defer srv.Close()
39
+
40
+ type eventsBody struct {
41
+ Session string `json:"session"`
42
+ Events []map[string]any `json:"events"`
43
+ Count int `json:"count"`
44
+ After uint64 `json:"after"`
45
+ LastSeq uint64 `json:"lastSeq"`
46
+ SnapshotRequired bool `json:"snapshotRequired"`
47
+ }
48
+ get := func(t *testing.T, url string) (int, eventsBody) {
49
+ t.Helper()
50
+ resp := doGET(t, url, true)
51
+ var b eventsBody
52
+ if resp.statusCode == http.StatusOK {
53
+ if err := json.Unmarshal(resp.body, &b); err != nil {
54
+ t.Fatalf("解析响应失败:%v(body=%s)", err, resp.body)
55
+ }
56
+ }
57
+ return resp.statusCode, b
58
+ }
59
+
60
+ t.Run("省略 after = 全量", func(t *testing.T) {
61
+ code, b := get(t, srv.URL+"/desk/session/"+id+"/events?token=tok")
62
+ if code != http.StatusOK {
63
+ t.Fatalf("期望 200,实得 %d", code)
64
+ }
65
+ if len(b.Events) != 4 || b.Count != 4 {
66
+ t.Fatalf("期望 4 条,实得 %d(count=%d)", len(b.Events), b.Count)
67
+ }
68
+ if b.LastSeq != 4 {
69
+ t.Fatalf("lastSeq 期望 4,实得 %d", b.LastSeq)
70
+ }
71
+ if b.SnapshotRequired {
72
+ t.Fatal("全量读不该要求快照重建")
73
+ }
74
+ // 首条必须是 user —— 这条同时验证 core 落盘的 kind:"user" 能被读回来。
75
+ if b.Events[0]["kind"] != "user" {
76
+ t.Fatalf("首条 kind 期望 user,实得 %v", b.Events[0]["kind"])
77
+ }
78
+ })
79
+
80
+ t.Run("after 排他", func(t *testing.T) {
81
+ code, b := get(t, srv.URL+"/desk/session/"+id+"/events?after=2&token=tok")
82
+ if code != http.StatusOK {
83
+ t.Fatalf("期望 200,实得 %d", code)
84
+ }
85
+ if len(b.Events) != 2 {
86
+ t.Fatalf("after=2 期望 2 条(3、4),实得 %d", len(b.Events))
87
+ }
88
+ // 排他:不能把 seq=2 自己再回一遍。
89
+ if seq, _ := b.Events[0]["seq"].(float64); seq != 3 {
90
+ t.Fatalf("首条期望 seq=3,实得 %v", b.Events[0]["seq"])
91
+ }
92
+ if b.After != 2 || b.LastSeq != 4 || b.SnapshotRequired {
93
+ t.Fatalf("元信息不对:after=%d lastSeq=%d snapshot=%v", b.After, b.LastSeq, b.SnapshotRequired)
94
+ }
95
+ })
96
+
97
+ t.Run("追平 = 空增量不要求快照", func(t *testing.T) {
98
+ code, b := get(t, srv.URL+"/desk/session/"+id+"/events?after=4&token=tok")
99
+ if code != http.StatusOK {
100
+ t.Fatalf("期望 200,实得 %d", code)
101
+ }
102
+ if len(b.Events) != 0 {
103
+ t.Fatalf("已追平期望空数组,实得 %d 条", len(b.Events))
104
+ }
105
+ if b.SnapshotRequired {
106
+ t.Fatal("追平不是超前,不该要求快照重建")
107
+ }
108
+ // 空数组而非 null:客户端是「往画布上追加」,null 得处处判空。
109
+ if !strings.Contains(string(doGET(t, srv.URL+"/desk/session/"+id+"/events?after=4&token=tok", true).body), `"events":[]`) {
110
+ t.Fatal("空增量应序列化成 [],不是 null")
111
+ }
112
+ })
113
+
114
+ t.Run("游标超前 = 要求快照重建", func(t *testing.T) {
115
+ code, b := get(t, srv.URL+"/desk/session/"+id+"/events?after=99&token=tok")
116
+ if code != http.StatusOK {
117
+ t.Fatalf("期望 200,实得 %d", code)
118
+ }
119
+ if !b.SnapshotRequired {
120
+ t.Fatal("after 超前于磁盘 maxSeq 时应要求快照重建")
121
+ }
122
+ if b.LastSeq != 4 {
123
+ t.Fatalf("超前时 lastSeq 仍须是文件的 4,实得 %d", b.LastSeq)
124
+ }
125
+ })
126
+
127
+ t.Run("不存在会话 = 空数组不是 null", func(t *testing.T) {
128
+ code, b := get(t, srv.URL+"/desk/session/no-such/events?token=tok")
129
+ if code != http.StatusOK {
130
+ t.Fatalf("期望 200,实得 %d", code)
131
+ }
132
+ if b.Events == nil || len(b.Events) != 0 {
133
+ t.Fatalf("期望空数组,实得 %#v", b.Events)
134
+ }
135
+ body := string(doGET(t, srv.URL+"/desk/session/no-such/events?token=tok", true).body)
136
+ if !strings.Contains(body, `"events":[]`) {
137
+ t.Fatalf("期望 \"events\":[],实得 %s", body)
138
+ }
139
+ })
140
+
141
+ t.Run("after 非法 = 400", func(t *testing.T) {
142
+ // ⚠️ 不能静默当 0:那样客户端以为拿到了增量、实际每次收全量,
143
+ // 性能问题会被伪装成「没问题」。
144
+ for _, bad := range []string{"abc", "-1", "1.5", "99999999999999999999999"} {
145
+ code, _ := get(t, srv.URL+"/desk/session/"+id+"/events?after="+bad+"&token=tok")
146
+ if code != http.StatusBadRequest {
147
+ t.Fatalf("after=%q 期望 400,实得 %d", bad, code)
148
+ }
149
+ }
150
+ })
151
+
152
+ t.Run("未知子路径 = 404", func(t *testing.T) {
153
+ code, _ := get(t, srv.URL+"/desk/session/"+id+"/bogus?token=tok")
154
+ if code != http.StatusNotFound {
155
+ t.Fatalf("未知子路径期望 404,实得 %d", code)
156
+ }
157
+ })
158
+
159
+ t.Run("缺令牌 = 401", func(t *testing.T) {
160
+ resp := doGET(t, srv.URL+"/desk/session/"+id+"/events?after=1", false)
161
+ if resp.statusCode != http.StatusUnauthorized {
162
+ t.Fatalf("期望 401,实得 %d", resp.statusCode)
163
+ }
164
+ })
165
+
166
+ t.Run("路径注入 = 400", func(t *testing.T) {
167
+ resp := doGET(t, srv.URL+"/desk/session/..%2F..%2Fetc%2Fpasswd/events?token=tok", true)
168
+ if resp.statusCode != http.StatusBadRequest {
169
+ t.Fatalf("路径注入期望 400,实得 %d", resp.statusCode)
170
+ }
171
+ })
172
+
173
+ // 分派没串线:/messages 必须仍是全量(否则增量改动会悄悄改掉既有行为)。
174
+ t.Run("messages 仍全量", func(t *testing.T) {
175
+ resp := doGET(t, srv.URL+"/desk/session/"+id+"/messages?token=tok", true)
176
+ var b struct {
177
+ Events []map[string]any `json:"events"`
178
+ }
179
+ if err := json.Unmarshal(resp.body, &b); err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ if len(b.Events) != 4 {
183
+ t.Fatalf("/messages 期望仍返回全部 4 条,实得 %d", len(b.Events))
184
+ }
185
+ })
186
+ }
@@ -0,0 +1,150 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "net/url"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ )
14
+
15
+ // TestHandleSessionMessagesReadAndValidate 验「看历史」路由的边界:
16
+ //
17
+ // - 正常:写出 jsonl 后能读回(保证 plugin 这条线通)
18
+ // - 不存在的会话 → 200 + 空 events,不是 404(空会话是合法状态)
19
+ // - 缺令牌 → 401
20
+ // - POST → 405
21
+ // - 路径注入 `../../etc/passwd` → 400(拒绝 `.` 与 `..` 整段 id)
22
+ // - 空 id(HTTP 客户端归一化后落到 "/desk/session/messages")→ 200 + events=null:
23
+ // 此时 "messages" 成了 id —— 路由不挡。像普通会话一样查,不存在就 200+空 events。
24
+ // 这是合理的:路由没定义「纯 /messages」这条,前缀归一化后当成 id 处理不越权。
25
+ func TestHandleSessionMessagesReadAndValidate(t *testing.T) {
26
+ // DefaultLogDir 用 HOME 解析成 `~/.magic-agent/sessions`。
27
+ // 临时把 HOME 指到 t.TempDir(),fixture 写到 `<temp>/.magic-agent/sessions/<id>.jsonl`,
28
+ // 不然 UserHomeDir 解析出来的目录不是 fixture 所在的地方。
29
+ home := t.TempDir()
30
+ dir := filepath.Join(home, ".magic-agent", "sessions")
31
+ if err := os.MkdirAll(dir, 0o700); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ t.Setenv("HOME", home)
35
+ id := "sess-good"
36
+ path := filepath.Join(dir, id+".jsonl")
37
+ want := []string{
38
+ `{"seq":1,"kind":"thinking","text":"思考","session_id":"` + id + `","at":"2026-09-27T20:00:00Z"}`,
39
+ `{"seq":2,"kind":"text","text":"正文","session_id":"` + id + `","at":"2026-09-27T20:00:01Z"}`,
40
+ `{"seq":3,"kind":"turn_end","text":"完成","session_id":"` + id + `","at":"2026-09-27T20:00:02Z"}`,
41
+ }
42
+ if err := os.WriteFile(path, []byte(strings.Join(want, "\n")+"\n"), 0o600); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+
46
+ svc := newTestService(t, fakeCore(t, goodContract))
47
+ srv := httptest.NewServer(svc.Handler())
48
+ defer srv.Close()
49
+
50
+ // --- 正常:3 条 events,按 seq 升序
51
+ resp := doGET(t, srv.URL+"/desk/session/"+id+"/messages?token=tok", true)
52
+ if resp.statusCode != http.StatusOK {
53
+ t.Fatalf("正常请求期望 200,实得 %d", resp.statusCode)
54
+ }
55
+ var body struct {
56
+ Session string `json:"session"`
57
+ Events []map[string]any `json:"events"`
58
+ }
59
+ if err := json.Unmarshal(resp.body, &body); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ if body.Session != id {
63
+ t.Fatalf("session=%q 实得 %q", id, body.Session)
64
+ }
65
+ if len(body.Events) != 3 {
66
+ t.Fatalf("期望 3 条 events,实得 %d:%v", len(body.Events), body.Events)
67
+ }
68
+ for i, e := range body.Events {
69
+ // want 行是 jsonl 字串原样;Events 经 json.Unmarshal 后是 map —— 这里只验 seq 与 kind。
70
+ var w map[string]any
71
+ _ = json.Unmarshal([]byte(want[i]), &w)
72
+ if e["seq"] != w["seq"] {
73
+ t.Fatalf("event %d seq=%v 期望 %v", i, e["seq"], w["seq"])
74
+ }
75
+ if e["kind"] != w["kind"] {
76
+ t.Fatalf("event %d kind=%v 期望 %v", i, e["kind"], w["kind"])
77
+ }
78
+ }
79
+
80
+ // --- 不存在:200 + events=null
81
+ resp = doGET(t, srv.URL+"/desk/session/no-such/messages?token=tok", true)
82
+ if resp.statusCode != http.StatusOK {
83
+ t.Fatalf("不存在期望 200,实得 %d", resp.statusCode)
84
+ }
85
+ var body2 struct {
86
+ Events []map[string]any `json:"events"`
87
+ }
88
+ _ = json.Unmarshal(resp.body, &body2)
89
+ if body2.Events != nil {
90
+ t.Fatalf("不存在期望 events=null,实得 %v", body2.Events)
91
+ }
92
+
93
+ // --- 缺令牌 → 401
94
+ resp = doGET(t, srv.URL+"/desk/session/"+id+"/messages", false)
95
+ if resp.statusCode != http.StatusUnauthorized {
96
+ t.Fatalf("缺令牌期望 401,实得 %d", resp.statusCode)
97
+ }
98
+
99
+ // --- POST → 405
100
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/session/"+id+"/messages?token=tok", nil)
101
+ req.Header.Set("Origin", "https://localhost")
102
+ res, err := http.DefaultClient.Do(req)
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ res.Body.Close()
107
+ if res.StatusCode != http.StatusMethodNotAllowed {
108
+ t.Fatalf("POST 期望 405,实得 %d", res.StatusCode)
109
+ }
110
+
111
+ // --- 路径注入 `../../etc/passwd` → 400(拒绝 `.` 与 `..` 整段 id)
112
+ resp = doGET(t, srv.URL+"/desk/session/..%2F..%2Fetc%2Fpasswd/messages?token=tok", true)
113
+ if resp.statusCode != http.StatusBadRequest {
114
+ t.Fatalf("路径注入期望 400,实得 %d", resp.statusCode)
115
+ }
116
+
117
+ // --- 空 id(仅 `/messages`) → 实际变 /desk/session/messages(HTTP 客户端会归一化 `//`),
118
+ // 此时 id 落到 "messages" —— 像普通会话一样查(不存在 → 200+空 events)。
119
+ // 这是合理的:路由没有定义"纯 /messages"这条,前缀归一化后当成 id 处理不会越权。
120
+ resp = doGET(t, srv.URL+"/desk/session//messages?token=tok", true)
121
+ if resp.statusCode != http.StatusOK {
122
+ t.Fatalf("前缀归一化后空 id 期望 200,实得 %d", resp.statusCode)
123
+ }
124
+ }
125
+
126
+ // doGET 简单 GET 一把:带 origin、断言状态码、读 body 全文。
127
+ func doGET(t *testing.T, rawURL string, withOrigin bool) (resp struct {
128
+ statusCode int
129
+ body []byte
130
+ }) {
131
+ t.Helper()
132
+ u, err := url.Parse(rawURL)
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
137
+ if withOrigin {
138
+ req.Header.Set("Origin", "https://localhost")
139
+ }
140
+ res, err := http.DefaultClient.Do(req)
141
+ if err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ defer res.Body.Close()
145
+ // 全量读取 body(流式响应可能被自动关流)。
146
+ buf, _ := io.ReadAll(res.Body)
147
+ resp.statusCode = res.StatusCode
148
+ resp.body = buf
149
+ return
150
+ }