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.
- package/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- 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 +11 -0
- package/package.json +14 -3
|
@@ -26,8 +26,18 @@ import (
|
|
|
26
26
|
* → 回给手机
|
|
27
27
|
*
|
|
28
28
|
* 用**路径前缀**承载目标地址(而不是 ?url= 全量编码):浏览器解析相对地址时会自动保留
|
|
29
|
-
* 前缀,配上注入的 <base>
|
|
30
|
-
*
|
|
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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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>
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] +
|
|
432
|
+
return []byte(out[:k] + inject + out[k:])
|
|
385
433
|
}
|
|
386
434
|
}
|
|
387
|
-
return []byte(
|
|
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
|
+
}
|