magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -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, ", ")
@@ -43,18 +43,25 @@ func TestRemapBrowseHost(t *testing.T) {
43
43
 
44
44
  func TestBrowseURL(t *testing.T) {
45
45
  cases := []struct {
46
- in, want string
46
+ in, want, tok string
47
47
  }{
48
- {"https://example.com/", "http://p/b/https/example.com/"},
49
- {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1"},
50
- {"http://192.168.1.5:8080/x", "http://p/b/http/192.168.1.5:8080/x"},
51
- {"mailto:a@b.c", "mailto:a@b.c"},
48
+ {"https://example.com/", "http://p/b/https/example.com/", ""},
49
+ {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1", ""},
50
+ {"http://192.168.1.5:8080/x", "http://p/b/http/192.168.1.5:8080/x", ""},
51
+ {"mailto:a@b.c", "mailto:a@b.c", ""},
52
+ // 令牌拼进查询串:无 query 用 ?,有 query 用 &(APK 跨站 iframe 下
53
+ // SameSite=Lax cookie 不随子资源发送,令牌必须走在 URL 里)。
54
+ {"https://example.com/", "http://p/b/https/example.com/?magicToken=T", "T"},
55
+ {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1&magicToken=T", "T"},
52
56
  }
53
57
  for _, c := range cases {
54
- if got := browseURL(mustURL(t, c.in), testProxyBase); got != c.want {
55
- t.Errorf("browseURL(%q) = %q,期望 %q", c.in, got, c.want)
58
+ if got := browseURL(mustURL(t, c.in), testProxyBase, c.tok); got != c.want {
59
+ t.Errorf("browseURL(%q, tok=%q) = %q,期望 %q", c.in, c.tok, got, c.want)
56
60
  }
57
61
  }
62
+ if got := browseURL(nil, testProxyBase, "T"); got != "" {
63
+ t.Errorf("browseURL(nil) 应为空串,实际 %q", got)
64
+ }
58
65
  }
59
66
 
60
67
  func TestStripMagicToken(t *testing.T) {
@@ -86,7 +93,7 @@ func TestRewriteBrowseHTML(t *testing.T) {
86
93
  `<img srcset="/1.png 1x, /2.png 2x">` +
87
94
  `<script>var s="/y";</script>` +
88
95
  `</body></html>`
89
- out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase))
96
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, ""))
90
97
 
91
98
  wants := []string{
92
99
  `href="http://p/b/https/example.com/x"`,
@@ -131,7 +138,7 @@ func TestRewriteCSS(t *testing.T) {
131
138
  page := mustURL(t, "https://example.com/css/style.css")
132
139
  in := `.a{background:url('/img/a.png')} .b{background:url(../img/b.png)}` +
133
140
  `@import "sub.css"; @import url('/other.css');`
134
- out := rewriteCSS(in, page, testProxyBase)
141
+ out := rewriteCSS(in, page, testProxyBase, "")
135
142
  wants := []string{
136
143
  `url(http://p/b/https/example.com/img/a.png)`,
137
144
  `url(http://p/b/https/example.com/img/b.png)`,
@@ -145,6 +152,60 @@ func TestRewriteCSS(t *testing.T) {
145
152
  }
146
153
  }
147
154
 
155
+ // TestRewriteBrowseHTMLScriptSrc <script> 的**开标签属性**必须改写(src 等),
156
+ // 内容原样保留。不改的后果://cdn.example.com/a.js 这类协议相对地址绕过中转
157
+ // 直连手机网络 —— 桌面端才能访问的内网站点照样裂(百度首页就挂着两个这种脚本)。
158
+ func TestRewriteBrowseHTMLScriptSrc(t *testing.T) {
159
+ page := mustURL(t, "https://example.com/a/b")
160
+ in := `<script src="//cdn.example.com/a.js"></script>` +
161
+ `<script src="/local.js" defer></script>` +
162
+ `<script>var s="/y"; var u="https://keep.example.com/z";</script>` +
163
+ `<script type="application/json">{"src":"/not-a-url"}</script>`
164
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, ""))
165
+
166
+ wants := []string{
167
+ `<script src="http://p/b/https/cdn.example.com/a.js"></script>`,
168
+ `<script src="http://p/b/https/example.com/local.js" defer></script>`,
169
+ }
170
+ for _, w := range wants {
171
+ if !strings.Contains(out, w) {
172
+ t.Errorf("script 开标签应被改写,缺少 %q\n实际:%s", w, out)
173
+ }
174
+ }
175
+ notWants := []string{
176
+ `/b/https/example.com/y`, // 脚本内字符串不许动
177
+ `/b/https/keep.example.com/z`, // 脚本内的绝对 URL 字符串也不许动
178
+ `/b/https/example.com/not-a-url`, // JSON 脚本块内容不许动
179
+ }
180
+ for _, nw := range notWants {
181
+ if strings.Contains(out, nw) {
182
+ t.Errorf("脚本内容不应被改写,却包含 %q\n实际:%s", nw, out)
183
+ }
184
+ }
185
+ if !strings.Contains(out, `var s="/y";`) || !strings.Contains(out, `var u="https://keep.example.com/z";`) {
186
+ t.Errorf("脚本内容应原样保留,实际:%s", out)
187
+ }
188
+ }
189
+
190
+ // TestRewriteBrowseHTMLToken 令牌要拼进每条改写出的 URL —— 跨站 iframe(APK 壳)下
191
+ // SameSite=Lax cookie 不随子资源请求发送,URL 里的令牌是唯一凭据。
192
+ func TestRewriteBrowseHTMLToken(t *testing.T) {
193
+ page := mustURL(t, "https://example.com/a/b")
194
+ in := `<img src="pic.png"><a href="/x?q=1">l</a>` +
195
+ `<div style="background:url('/bg.png')"></div>`
196
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, "T"))
197
+ wants := []string{
198
+ `src="http://p/b/https/example.com/a/pic.png?magicToken=T"`,
199
+ `href="http://p/b/https/example.com/x?q=1&magicToken=T"`,
200
+ `url(http://p/b/https/example.com/bg.png?magicToken=T)`,
201
+ }
202
+ for _, w := range wants {
203
+ if !strings.Contains(out, w) {
204
+ t.Errorf("改写 URL 应带令牌,缺少 %q\n实际:%s", w, out)
205
+ }
206
+ }
207
+ }
208
+
148
209
  // TestHandleBrowseE2E 端到端:起一个「拒绝内嵌」的上游站点,经中转取回,
149
210
  // 断言 (1) X-Frame-Options 被剥掉、(2) 页内链接被改写到本中转。
150
211
  func TestHandleBrowseE2E(t *testing.T) {
@@ -197,3 +258,40 @@ func TestHandleBrowseAuth(t *testing.T) {
197
258
  t.Errorf("缺令牌应 401,实际 %d", resp.StatusCode)
198
259
  }
199
260
  }
261
+
262
+ // TestHandleBrowseTokenInURL 开了令牌时:改写出的 URL 必须带 ?magicToken=,
263
+ // 且这个参数**不准透传给上游**(stripMagicToken 剥掉)。
264
+ func TestHandleBrowseTokenInURL(t *testing.T) {
265
+ var gotQuery string
266
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267
+ gotQuery = r.URL.RawQuery
268
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
269
+ _, _ = io.WriteString(w, `<html><head></head><body><img src="/pic.png"></body></html>`)
270
+ }))
271
+ defer upstream.Close()
272
+
273
+ up, err := url.Parse(upstream.URL)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ s := &Service{opts: Options{Token: "sekret"}}
278
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
279
+ defer proxy.Close()
280
+
281
+ resp, err := http.Get(proxy.URL + browsePrefix + "http/" + up.Host + "/?magicToken=sekret")
282
+ if err != nil {
283
+ t.Fatal(err)
284
+ }
285
+ defer resp.Body.Close()
286
+ body, _ := io.ReadAll(resp.Body)
287
+ if resp.StatusCode != http.StatusOK {
288
+ t.Fatalf("带正确令牌应 200,实际 %d", resp.StatusCode)
289
+ }
290
+ want := `src="` + proxy.URL + browsePrefix + "http/" + up.Host + "/pic.png?magicToken=sekret" + `"`
291
+ if !strings.Contains(string(body), want) {
292
+ t.Errorf("改写出的子资源 URL 应带令牌,期望包含 %q\n实际:%s", want, body)
293
+ }
294
+ if strings.Contains(gotQuery, "magicToken") {
295
+ t.Errorf("magicToken 不准透传给上游,实际上游收到 query=%q", gotQuery)
296
+ }
297
+ }