magic-agent 2.4.2 → 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.
- package/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -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 +37 -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 +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -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 +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -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 +33 -9
- package/cmd/magic-client/main.go +57 -26
- 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_resume.go +13 -2
- 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/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- 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 +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- 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 +160 -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/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- 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/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/daemon.go +90 -17
- 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/jobs.go +294 -21
- 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 +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -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/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/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- 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 +1 -5
- 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/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
|
-
*
|
|
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, ", ")
|