magic-agent 2.4.3 → 2.6.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 (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -0,0 +1,287 @@
1
+ package client
2
+
3
+ // ab_test.go - agent-browser 封装与 shot-remote 端点的测试。
4
+ //
5
+ // 不连真 daemon(那要真 Chromium):用 go build 现场编一个**假 agent-browser exe**
6
+ // 验证探测链、--json 解析与端点行为。go 编译器不在 PATH 时相关用例跳过
7
+ // (本机开发环境一定有;CI 沙箱也总有)。
8
+
9
+ import (
10
+ "context"
11
+ "encoding/json"
12
+ "io"
13
+ "log"
14
+ "net/http"
15
+ "net/http/httptest"
16
+ "net/url"
17
+ "os"
18
+ "os/exec"
19
+ "path/filepath"
20
+ "runtime"
21
+ "strings"
22
+ "testing"
23
+ )
24
+
25
+ // abNativeExeName 当前平台 agent-browser 原生 exe 的文件名(与 ab.go 同规则)。
26
+ func abNativeExeName() string {
27
+ name := "agent-browser-" + runtime.GOOS + "-" + runtime.GOARCH
28
+ if runtime.GOOS == "windows" {
29
+ name += ".exe"
30
+ }
31
+ return name
32
+ }
33
+
34
+ // fakeABBin 现场编一个假 agent-browser exe,返回其路径。
35
+ //
36
+ // 行为由环境变量 AB_FAKE_MODE 控制(abRun 会把调用方环境透传给子进程):
37
+ //
38
+ // ok → 按子命令吐 {"success":true,"data":…};screenshot 会真写出文件
39
+ // err → 吐 {"success":false,"error":"fake boom"}
40
+ // garbage → 吐非 JSON 乱码
41
+ // missing → 无输出直接退出 1
42
+ func fakeABBin(t *testing.T) string {
43
+ t.Helper()
44
+ src := `package main
45
+
46
+ import (
47
+ "fmt"
48
+ "os"
49
+ "strings"
50
+ )
51
+
52
+ func main() {
53
+ mode := os.Getenv("AB_FAKE_MODE")
54
+ switch mode {
55
+ case "err":
56
+ fmt.Println(` + "`" + `{"success":false,"error":"fake boom"}` + "`" + `)
57
+ os.Exit(1)
58
+ case "garbage":
59
+ fmt.Println("garbage not json at all")
60
+ os.Exit(0)
61
+ case "missing":
62
+ os.Exit(1)
63
+ }
64
+ // 按子命令给一点可断言的 data(跳过全局 flag)。
65
+ sub := ""
66
+ for i, a := range os.Args[1:] {
67
+ if strings.HasPrefix(a, "-") || a == "magic-client" {
68
+ continue
69
+ }
70
+ switch a {
71
+ case "get", "open", "eval", "pdf", "snapshot":
72
+ continue
73
+ case "screenshot":
74
+ // 真的把文件写出来:screenshot 的**最后一个非 flag 参数**是路径
75
+ // (与真 agent-browser 一致 —— flag 必须放在路径前面)。
76
+ out := ""
77
+ for _, b := range os.Args[i+1:] {
78
+ if !strings.HasPrefix(b, "-") {
79
+ out = b
80
+ }
81
+ }
82
+ if out != "" {
83
+ _ = os.WriteFile(out, []byte("png"), 0o600)
84
+ }
85
+ fmt.Println(` + "`" + `{"success":true,"data":{"path":"x"}}` + "`" + `)
86
+ return
87
+ case "url":
88
+ fmt.Println(` + "`" + `{"success":true,"data":"https://example.com/page"}` + "`" + `)
89
+ return
90
+ case "title":
91
+ fmt.Println(` + "`" + `{"success":true,"data":"Example"}` + "`" + `)
92
+ return
93
+ }
94
+ if sub == "" {
95
+ sub = a
96
+ }
97
+ }
98
+ fmt.Println(` + "`" + `{"success":true,"data":{"ok":true}}` + "`" + `)
99
+ }
100
+ `
101
+ dir := t.TempDir()
102
+ srcFile := filepath.Join(dir, "main.go")
103
+ if err := os.WriteFile(srcFile, []byte(src), 0o600); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ exe := filepath.Join(dir, abNativeExeName())
107
+ cmd := exec.Command("go", "build", "-o", exe, srcFile)
108
+ if out, err := cmd.CombinedOutput(); err != nil {
109
+ t.Skipf("go build 假 exe 失败(跳过,不影响其它用例): %v: %s", err, out)
110
+ }
111
+ return exe
112
+ }
113
+
114
+ // TestABBin探测链 环境变量 > npm 前缀 > 都没有时空。
115
+ func TestABBin探测链(t *testing.T) {
116
+ exe := fakeABBin(t)
117
+ // 1) 显式环境变量最优先。
118
+ t.Setenv(EnvAgentBrowserBin, exe)
119
+ if got := abBin(); got != exe {
120
+ t.Fatalf("环境变量应最优先,实际 %q", got)
121
+ }
122
+ // 2) npm 全局前缀(<prefix>/node_modules/agent-browser/bin/<平台名>)。
123
+ t.Setenv(EnvAgentBrowserBin, "")
124
+ prefix := t.TempDir()
125
+ binDir := filepath.Join(prefix, "node_modules", "agent-browser", "bin")
126
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ b, err := os.ReadFile(exe)
130
+ if err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ if err := os.WriteFile(filepath.Join(binDir, abNativeExeName()), b, 0o755); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ t.Setenv("NPM_CONFIG_PREFIX", prefix)
137
+ got := abBin()
138
+ if !strings.Contains(filepath.ToSlash(got), "node_modules/agent-browser/bin/"+abNativeExeName()) {
139
+ t.Fatalf("应从 npm 前缀探测到,实际 %q", got)
140
+ }
141
+ }
142
+
143
+ // TestABRunJSON 解析:--json 成功 / 失败 / 乱码 / 无输出四种形状。
144
+ func TestABRunJSON(t *testing.T) {
145
+ exe := fakeABBin(t)
146
+ t.Setenv(EnvAgentBrowserBin, exe)
147
+
148
+ t.Setenv("AB_FAKE_MODE", "ok")
149
+ data, err := abRun(context.Background(), "get", "url")
150
+ if err != nil {
151
+ t.Fatalf("ok 模式失败: %v", err)
152
+ }
153
+ if string(data) != `"https://example.com/page"` {
154
+ t.Fatalf("data 不对: %s", data)
155
+ }
156
+
157
+ t.Setenv("AB_FAKE_MODE", "err")
158
+ if _, err := abRun(context.Background(), "get", "url"); err == nil || !strings.Contains(err.Error(), "fake boom") {
159
+ t.Fatalf("err 模式应报 fake boom: %v", err)
160
+ }
161
+
162
+ t.Setenv("AB_FAKE_MODE", "garbage")
163
+ if _, err := abRun(context.Background(), "get", "url"); err == nil {
164
+ t.Fatal("乱码输出应报错")
165
+ }
166
+
167
+ t.Setenv("AB_FAKE_MODE", "missing")
168
+ if _, err := abRun(context.Background(), "get", "url"); err == nil {
169
+ t.Fatal("无输出退出应报错")
170
+ }
171
+ }
172
+
173
+ // TestABShot 落盘:假 exe 真写出文件,abShot 返回 nil 且文件在。
174
+ func TestABShot(t *testing.T) {
175
+ exe := fakeABBin(t)
176
+ t.Setenv(EnvAgentBrowserBin, exe)
177
+ t.Setenv("AB_FAKE_MODE", "ok")
178
+ out := filepath.Join(t.TempDir(), "s.png")
179
+ if err := abShot(context.Background(), out, true); err != nil {
180
+ t.Fatalf("截图失败: %v", err)
181
+ }
182
+ if b, err := os.ReadFile(out); err != nil || string(b) != "png" {
183
+ t.Fatalf("截图文件没落盘: %v", err)
184
+ }
185
+ }
186
+
187
+ // abHideAll 把 abBin 的全部探测路径指到空目录(测「没装 agent-browser」的分支)。
188
+ // t.Setenv 自动在测试结束时还原。PATH 上可能有真安装(npm 全局 bin),一并清掉 ——
189
+ // 清 PATH 后 go build 假 exe 的用例会受影响,但 hide 只在 501 分支用,不冲突。
190
+ func abHideAll(t *testing.T) {
191
+ t.Helper()
192
+ t.Setenv(EnvAgentBrowserBin, "")
193
+ t.Setenv("NPM_CONFIG_PREFIX", "")
194
+ t.Setenv("npm_config_prefix", "")
195
+ t.Setenv("APPDATA", t.TempDir()) // Windows 默认前缀指空
196
+ t.Setenv("PATH", t.TempDir()) // LookPath 兜底也指空
197
+ }
198
+
199
+ // TestShotRemoteEndpoint shot-remote 端点:能力位 gate / 参数校验 / 正常路径。
200
+ func TestShotRemoteEndpoint(t *testing.T) {
201
+ exe := fakeABBin(t)
202
+ s := &Service{log: log.New(io.Discard, "", 0), contract: Contract{Features: []string{FeatureRequirementShots}}}
203
+ mux := http.NewServeMux()
204
+ mux.HandleFunc("/desk/requirements/shot-remote", s.handleRequirementShotRemote)
205
+ srv := httptest.NewServer(mux)
206
+ defer srv.Close()
207
+
208
+ // 没配 agent-browser → 501 + 安装提示。
209
+ // ⚠️ 本机可能真装了 agent-browser(npm 默认前缀 / PATH 都能命中):
210
+ // 「没装」分支要把**所有**探测路径都指到空目录,否则测的是真安装。
211
+ abHideAll(t)
212
+ resp, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
213
+ strings.NewReader(`{"url":"https://example.com"}`))
214
+ if err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ body := readAllBody(resp)
218
+ if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(body, "agent-browser") {
219
+ t.Fatalf("没装 agent-browser 应 501 + 提示,实际 %d %s", resp.StatusCode, body)
220
+ }
221
+
222
+ // 配上假 exe → 正常路径回 data(截图落 uploads 目录并真实存在)。
223
+ t.Setenv(EnvAgentBrowserBin, exe)
224
+ t.Setenv("AB_FAKE_MODE", "ok")
225
+ t.Setenv("TMP", t.TempDir()) // uploads 目录走 os.TempDir()
226
+ t.Setenv("TEMP", os.Getenv("TMP"))
227
+ resp2, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
228
+ strings.NewReader(`{"url":"https://example.com"}`))
229
+ if err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ body2 := readAllBody(resp2)
233
+ if resp2.StatusCode != http.StatusOK {
234
+ t.Fatalf("正常路径应 200,实际 %d %s", resp2.StatusCode, body2)
235
+ }
236
+ var out struct {
237
+ OK bool `json:"ok"`
238
+ Data string `json:"data"`
239
+ }
240
+ if err := json.Unmarshal([]byte(body2), &out); err != nil || !out.OK || !strings.HasPrefix(out.Data, "/desk/uploads/") {
241
+ t.Fatalf("响应形状不对: %s", body2)
242
+ }
243
+ // data 指向的文件真实存在。
244
+ name, err := url.PathUnescape(strings.TrimPrefix(out.Data, "/desk/uploads/"))
245
+ if err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if _, err := os.Stat(filepath.Join(os.TempDir(), "magic-client-uploads", name)); err != nil {
249
+ t.Fatalf("截图文件不在 uploads: %v", err)
250
+ }
251
+
252
+ // 非法 url → 400。
253
+ resp3, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
254
+ strings.NewReader(`{"url":"javascript:alert(1)"}`))
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ resp3.Body.Close()
259
+ if resp3.StatusCode != http.StatusBadRequest {
260
+ t.Fatalf("非法 url 应 400,实际 %d", resp3.StatusCode)
261
+ }
262
+ }
263
+
264
+ // TestShotRemote能力位 缺 feature.requirement.shots 必须 501(与 /shots 端点同一杆秤)。
265
+ func TestShotRemote能力位(t *testing.T) {
266
+ s := &Service{log: log.New(io.Discard, "", 0), contract: Contract{Features: []string{}}}
267
+ mux := http.NewServeMux()
268
+ mux.HandleFunc("/desk/requirements/shot-remote", s.handleRequirementShotRemote)
269
+ srv := httptest.NewServer(mux)
270
+ defer srv.Close()
271
+ resp, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
272
+ strings.NewReader(`{"url":"https://example.com"}`))
273
+ if err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ defer resp.Body.Close()
277
+ if resp.StatusCode != http.StatusNotImplemented {
278
+ t.Fatalf("缺能力位应 501,实际 %d", resp.StatusCode)
279
+ }
280
+ }
281
+
282
+ // readAllBody 把响应体读完转成字符串(测试用,错误一律 fatal)。
283
+ func readAllBody(resp *http.Response) string {
284
+ b, _ := io.ReadAll(resp.Body)
285
+ resp.Body.Close()
286
+ return string(b)
287
+ }
@@ -26,8 +26,18 @@ import (
26
26
  * → 回给手机
27
27
  *
28
28
  * 用**路径前缀**承载目标地址(而不是 ?url= 全量编码):浏览器解析相对地址时会自动保留
29
- * 前缀,配上注入的 <base> 就能让同站链接继续走中转。令牌只放在**首帧**的查询串里,
30
- * 首帧响应下发 cookie,后续子资源请求靠 cookie 过鉴权(否则每个 URL 都得带令牌)。
29
+ * 前缀,配上注入的 <base> 就能让同站链接继续走中转。
30
+ *
31
+ * ── 鉴权:令牌进每一条改写出的 URL ──
32
+ * 早先令牌只放在**首帧**的查询串里,首帧响应下发 cookie,后续子资源请求靠 cookie 过鉴权。
33
+ * 那个方案在 APK(Capacitor 壳)里必然失败:壳的首屏 origin 是它自己的本地服务器
34
+ * (localhost),与被代理 iframe 所在的桌面端 LAN origin **跨站**,而 cookie 是
35
+ * SameSite=Lax —— 跨站子资源请求压根不带它,于是改写过的图片/脚本/样式全部 401
36
+ * (首帧有 ?magicToken= 所以页面能出来,百度移动页 CSS 又全内联,看起来就是「图片全裂」)。
37
+ * SameSite=None 要求 Secure,而客户端在局域网是明文 HTTP,此路不通。
38
+ * 现在令牌**直接拼进每条改写出的 URL**(?magicToken=…),上游转发前由 stripMagicToken
39
+ * 剥掉,任何嵌入上下文(同源 / 跨站 / 第三方 cookie 被墙)都不受影响。
40
+ * cookie 仍然照发(同源场景下聊胜于无),但不再是唯一凭据。
31
41
  *
32
42
  * ── 刻意不做的事 ──
33
43
  * - 不转发上游的 Set-Cookie:上游会话存桌面端的 cookie jar,别去污染手机对本服务的 cookie。
@@ -40,8 +50,11 @@ import (
40
50
  const (
41
51
  // browsePrefix 中转路径前缀。
42
52
  browsePrefix = "/b/"
43
- // browseCookie 首帧下发、供子资源请求复用的鉴权 cookie。
53
+ // browseCookie 首帧下发、供子资源请求复用的鉴权 cookie(辅助凭据,见文件头注释)。
44
54
  browseCookie = "magic_browse"
55
+ // browseTokenParam 令牌查询参数名:改写出的每条 URL 都带它(stripMagicToken 会在
56
+ // 转发上游前剥掉)。不用 cookie 独扛鉴权,就是为了跨站 iframe(APK 壳)也能过。
57
+ browseTokenParam = "magicToken"
45
58
  // browseMaxBytes HTML 改写单页size上限;超了直接报错(不静默截断成坏页面)。
46
59
  browseMaxBytes = 12 << 20
47
60
  // browseUA 伪装成 Android Chrome —— 直接拿 Go 默认 UA 会被不少移动站点降级甚至拒绝。
@@ -111,6 +124,16 @@ func (s *Service) browseUserAgent() string {
111
124
 
112
125
  // handleBrowse 中转入口。
113
126
  func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
127
+ // relay 脚本本体:/b/relay.js。特判放在最前面 —— 它是**我们自己的**资源,
128
+ // 不能让 browseTarget 把 "relay.js" 当主机名去抓。
129
+ if strings.TrimPrefix(r.URL.Path, browsePrefix) == "relay.js" {
130
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
131
+ w.Header().Set("Cache-Control", "no-store")
132
+ w.WriteHeader(http.StatusOK)
133
+ _, _ = w.Write([]byte(browseRelayJS))
134
+ return
135
+ }
136
+ // (/b/shotlib.js 已随页面内截图退役:截图改走服务端 agent-browser,见 ab.go。)
114
137
  target, ok := browseTarget(r)
115
138
  if !ok {
116
139
  writeErr(w, http.StatusBadRequest, "用法:/b/<http|https>/<host>/<path>")
@@ -118,9 +141,11 @@ func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
118
141
  }
119
142
  target = remapBrowseHost(target)
120
143
 
121
- // 鉴权:查询令牌(首帧)或 cookie(子资源)。
122
- if want := strings.TrimSpace(s.opts.Token); want != "" {
123
- got := strings.TrimSpace(r.URL.Query().Get("magicToken"))
144
+ // 鉴权:查询令牌(每条改写出的 URL 都带)或 cookie(同源场景下的辅助凭据)。
145
+ // want 同时是改写 URL 时要拼进去的令牌 —— 空表示没开鉴权,URL 保持干净。
146
+ want := strings.TrimSpace(s.opts.Token)
147
+ if want != "" {
148
+ got := strings.TrimSpace(r.URL.Query().Get(browseTokenParam))
124
149
  if got == "" {
125
150
  if c, err := r.Cookie(browseCookie); err == nil {
126
151
  got = c.Value
@@ -130,7 +155,7 @@ func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
130
155
  writeErr(w, http.StatusUnauthorized, "缺少或错误的访问令牌(?magicToken= 或 cookie)")
131
156
  return
132
157
  }
133
- if r.URL.Query().Get("magicToken") != "" {
158
+ if r.URL.Query().Get(browseTokenParam) != "" {
134
159
  http.SetCookie(w, &http.Cookie{
135
160
  Name: browseCookie, Value: want, Path: "/",
136
161
  HttpOnly: true, SameSite: http.SameSiteLaxMode,
@@ -192,7 +217,7 @@ func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
192
217
  // 重定向:把 Location 也改成中转地址,否则浏览器一跳出前缀就离开中转了。
193
218
  if loc := resp.Header.Get("Location"); loc != "" && resp.StatusCode >= 300 && resp.StatusCode < 400 {
194
219
  if abs, perr := target.Parse(loc); perr == nil {
195
- w.Header().Set("Location", browseURL(abs, proxyBase))
220
+ w.Header().Set("Location", browseURL(abs, proxyBase, want))
196
221
  } else {
197
222
  w.Header().Set("Location", loc)
198
223
  }
@@ -210,7 +235,7 @@ func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
210
235
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
211
236
  w.Header().Set("Cache-Control", "no-store")
212
237
  w.WriteHeader(resp.StatusCode)
213
- _, _ = w.Write(rewriteBrowseHTML(body, target, proxyBase))
238
+ _, _ = w.Write(rewriteBrowseHTML(body, target, proxyBase, want))
214
239
  return
215
240
  }
216
241
  // CSS 也要改写:样式表里 background-image / @import 引用的图不经过 HTML,
@@ -223,7 +248,7 @@ func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
223
248
  }
224
249
  w.Header().Set("Content-Type", "text/css; charset=utf-8")
225
250
  w.WriteHeader(resp.StatusCode)
226
- _, _ = w.Write([]byte(rewriteCSS(string(body), target, proxyBase)))
251
+ _, _ = w.Write([]byte(rewriteCSS(string(body), target, proxyBase, want)))
227
252
  return
228
253
  }
229
254
 
@@ -314,11 +339,14 @@ func stripMagicToken(q string) string {
314
339
  // 原因:根相对地址按「所在文档 / 样式表的源」解析,而页面里常有**直连加载**的外站样式表
315
340
  // 或脚本(由 JS 动态插入,我们改写不到)。它们消费到 url(/b/...) 时会解析回自己的源
316
341
  // (实测:被解析成 ms.bdstatic.com/b/...,图片全裂)。绝对地址在任何上下文都指向中转。
317
- func browseURL(abs *url.URL, proxyBase string) string {
318
- if abs == nil || (abs.Scheme != "http" && abs.Scheme != "https") {
319
- if abs == nil {
320
- return ""
321
- }
342
+ //
343
+ // token 非空时拼上 ?magicToken=…:子资源鉴权不能只靠 cookie —— 跨站 iframe(APK 壳)
344
+ // 下 SameSite=Lax 的 cookie 根本不随子资源请求发送,详见文件头注释。
345
+ func browseURL(abs *url.URL, proxyBase, token string) string {
346
+ if abs == nil {
347
+ return ""
348
+ }
349
+ if abs.Scheme != "http" && abs.Scheme != "https" {
322
350
  return abs.String()
323
351
  }
324
352
  p := abs.EscapedPath()
@@ -326,15 +354,22 @@ func browseURL(abs *url.URL, proxyBase string) string {
326
354
  p = "/"
327
355
  }
328
356
  out := proxyBase + browsePrefix + abs.Scheme + "/" + abs.Host + p
329
- if abs.RawQuery != "" {
330
- out += "?" + abs.RawQuery
357
+ q := abs.RawQuery
358
+ if token != "" {
359
+ if q != "" {
360
+ q += "&"
361
+ }
362
+ q += browseTokenParam + "=" + url.QueryEscape(token)
363
+ }
364
+ if q != "" {
365
+ out += "?" + q
331
366
  }
332
367
  return out
333
368
  }
334
369
 
335
370
  // rewriteRef 把一个页面里的引用地址解析成绝对地址后再映射到中转。
336
371
  // data:/javascript:/mailto: 等非网络协议与纯锚点一律不动,避免把页面改坏。
337
- func rewriteRef(raw string, page *url.URL, proxyBase string) string {
372
+ func rewriteRef(raw string, page *url.URL, proxyBase, token string) string {
338
373
  t := strings.TrimSpace(raw)
339
374
  if t == "" {
340
375
  return raw
@@ -353,13 +388,16 @@ func rewriteRef(raw string, page *url.URL, proxyBase string) string {
353
388
  if abs.Scheme != "http" && abs.Scheme != "https" {
354
389
  return raw
355
390
  }
356
- return browseURL(abs, proxyBase)
391
+ return browseURL(abs, proxyBase, token)
357
392
  }
358
393
 
359
394
  // rewriteBrowseHTML 就地改写 HTML:剥掉会妨碍内嵌的头、把所有 URL 指回中转、注入 <base>。
360
395
  //
361
- // 分片处理:<script> 段原样保留(在 JS 里替换 URL 会砸脚本),其余段做属性 / srcset / CSS url() 改写。
362
- func rewriteBrowseHTML(body []byte, page *url.URL, proxyBase string) []byte {
396
+ // 分片处理:<script> 元素拆成「开标签 + 内容」—— 开标签的属性(src 等)要改写
397
+ // (否则 <script src="//cdn.example.com/a.js"> 这类协议相对地址会绕过中转,直连手机网络,
398
+ // 桌面端才能访问的内网站点照样裂),内容原样保留(在 JS 里替换 URL 会砸脚本)。
399
+ // 其余段做属性 / srcset / CSS url() 改写。
400
+ func rewriteBrowseHTML(body []byte, page *url.URL, proxyBase, token string) []byte {
363
401
  html := string(body)
364
402
  html = browseBaseRe.ReplaceAllString(html, "")
365
403
  html = browseCSPRe.ReplaceAllString(html, "")
@@ -367,27 +405,37 @@ func rewriteBrowseHTML(body []byte, page *url.URL, proxyBase string) []byte {
367
405
  var b strings.Builder
368
406
  last := 0
369
407
  for _, loc := range browseScriptRe.FindAllStringIndex(html, -1) {
370
- b.WriteString(rewriteChunk(html[last:loc[0]], page, proxyBase))
371
- b.WriteString(html[loc[0]:loc[1]])
408
+ b.WriteString(rewriteChunk(html[last:loc[0]], page, proxyBase, token))
409
+ elem := html[loc[0]:loc[1]]
410
+ if gt := strings.IndexByte(elem, '>'); gt >= 0 {
411
+ b.WriteString(rewriteChunk(elem[:gt+1], page, proxyBase, token))
412
+ b.WriteString(elem[gt+1:])
413
+ } else {
414
+ b.WriteString(elem)
415
+ }
372
416
  last = loc[1]
373
417
  }
374
- b.WriteString(rewriteChunk(html[last:], page, proxyBase))
418
+ b.WriteString(rewriteChunk(html[last:], page, proxyBase, token))
375
419
  out := b.String()
376
420
 
377
421
  // 注入 <base>:给**没被改写**的相对地址一个基准,指向本中转的页面地址。
378
422
  // 改写过的地址现在是绝对的、不依赖 base(早先用根相对地址 + 指向原站的 base,
379
423
  // 会被解析回原站导致图片全裂,已修)。
380
- base := `<base href="` + htmlEscapeAttr(browseURL(page, proxyBase)) + `">`
424
+ // 同时注入 relay 脚本(/b/relay.js,见 browseinput.go):连回 /browse-input
425
+ // 收指令,在被代理页面里派发合成鼠标/键盘事件 —— 为浏览器 MCP 驱动鼠标铺路。
426
+ // 脚本地址是绝对的(带 proxyBase):页面 URL 可能是任何源,相对地址会解析回原站。
427
+ inject := `<base href="` + htmlEscapeAttr(browseURL(page, proxyBase, "")) + `">` +
428
+ `<script src="` + htmlEscapeAttr(proxyBase+"/b/relay.js") + `" defer></script>`
381
429
  if i := strings.Index(strings.ToLower(out), "<head"); i >= 0 {
382
430
  if j := strings.IndexByte(out[i:], '>'); j >= 0 {
383
431
  k := i + j + 1
384
- return []byte(out[:k] + base + out[k:])
432
+ return []byte(out[:k] + inject + out[k:])
385
433
  }
386
434
  }
387
- return []byte(base + out)
435
+ return []byte(inject + out)
388
436
  }
389
437
 
390
- func rewriteChunk(chunk string, page *url.URL, proxyBase string) string {
438
+ func rewriteChunk(chunk string, page *url.URL, proxyBase, token string) string {
391
439
  chunk = browseAttrRe.ReplaceAllStringFunc(chunk, func(m string) string {
392
440
  sub := browseAttrRe.FindStringSubmatch(m)
393
441
  name := sub[1]
@@ -401,7 +449,7 @@ func rewriteChunk(chunk string, page *url.URL, proxyBase string) string {
401
449
  q = '\''
402
450
  val = sub[4]
403
451
  }
404
- return name + "=" + string(q) + rewriteRef(val, page, proxyBase) + string(q)
452
+ return name + "=" + string(q) + rewriteRef(val, page, proxyBase, token) + string(q)
405
453
  })
406
454
  chunk = browseSrcsetRe.ReplaceAllStringFunc(chunk, func(m string) string {
407
455
  sub := browseSrcsetRe.FindStringSubmatch(m)
@@ -411,17 +459,17 @@ func rewriteChunk(chunk string, page *url.URL, proxyBase string) string {
411
459
  val = sub[3]
412
460
  q = `'`
413
461
  }
414
- return "srcset=" + q + rewriteSrcset(val, page, proxyBase) + q
462
+ return "srcset=" + q + rewriteSrcset(val, page, proxyBase, token) + q
415
463
  })
416
- return rewriteCSS(chunk, page, proxyBase)
464
+ return rewriteCSS(chunk, page, proxyBase, token)
417
465
  }
418
466
 
419
467
  // rewriteCSS 改写样式里的资源引用:url(...) 与 @import "..."。
420
- func rewriteCSS(css string, page *url.URL, proxyBase string) string {
468
+ func rewriteCSS(css string, page *url.URL, proxyBase, token string) string {
421
469
  css = browseCSSURLRe.ReplaceAllStringFunc(css, func(m string) string {
422
470
  sub := browseCSSURLRe.FindStringSubmatch(m)
423
471
  // 统一重建成不带引号的形式:Go 的 RE2 不支持反向引用,没法配对首尾引号。
424
- return "url(" + rewriteRef(sub[1], page, proxyBase) + ")"
472
+ return "url(" + rewriteRef(sub[1], page, proxyBase, token) + ")"
425
473
  })
426
474
  css = browseImportRe.ReplaceAllStringFunc(css, func(m string) string {
427
475
  sub := browseImportRe.FindStringSubmatch(m)
@@ -430,20 +478,20 @@ func rewriteCSS(css string, page *url.URL, proxyBase string) string {
430
478
  if q == "'" {
431
479
  val = sub[3]
432
480
  }
433
- return "@import " + q + rewriteRef(val, page, proxyBase) + q
481
+ return "@import " + q + rewriteRef(val, page, proxyBase, token) + q
434
482
  })
435
483
  return css
436
484
  }
437
485
 
438
486
  // rewriteSrcset 处理 srcset:逗号分隔,每段「URL [descriptor]」,只改 URL 那一段。
439
- func rewriteSrcset(val string, page *url.URL, proxyBase string) string {
487
+ func rewriteSrcset(val string, page *url.URL, proxyBase, token string) string {
440
488
  items := strings.Split(val, ",")
441
489
  for i, it := range items {
442
490
  fields := strings.Fields(strings.TrimSpace(it))
443
491
  if len(fields) == 0 {
444
492
  continue
445
493
  }
446
- fields[0] = rewriteRef(fields[0], page, proxyBase)
494
+ fields[0] = rewriteRef(fields[0], page, proxyBase, token)
447
495
  items[i] = strings.Join(fields, " ")
448
496
  }
449
497
  return strings.Join(items, ", ")