magic-agent 1.0.111 → 2.1.5

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.
@@ -88,6 +88,7 @@ func main() {
88
88
  UIRoot: *uiDir,
89
89
  MobileRoot: *mobileDir,
90
90
  Token: tok,
91
+ Lan: *lan,
91
92
  BrowseUA: *browseUA,
92
93
  })
93
94
  if err != nil {
@@ -0,0 +1,135 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ )
11
+
12
+ // 移动端 APK 的分发(2026-10-22,用户:「移动端版本跟着 npm 走」+「在版本号的地方
13
+ // 可以显示版本也可检测最新版本号并触发升级」)。
14
+ //
15
+ // 版本号只有一个来源:npm 包里的 package.json。构建时 magic-test 的
16
+ // tools/build-mobile-apk.cjs 把同一个版本写进 APK 的 versionName,并把 release 包放到
17
+ // npm/apk/ 下随包发布。本插件负责把它通过局域网递给手机:
18
+ //
19
+ // GET /desk/app -> {version, size, url}(手机「我的」→「版本」点一下先问这个)
20
+ // GET /desk/app.apk -> 真正的安装包(浏览器/系统下载器用 ?token= 过鉴权)
21
+ //
22
+ // 为什么由桌面端发:手机和桌面已经配对、这条路一定通;不依赖 GitHub Release / 应用市场,
23
+ // 也不需要把 7MB 的 APK 塞进每个平台的 npm 二进制目录。
24
+
25
+ // apkManifest 与 npm/apk/version.json 对应,由 magic-test 的打包脚本生成。
26
+ type apkManifest struct {
27
+ Version string `json:"version"`
28
+ VersionCode int `json:"versionCode"`
29
+ }
30
+
31
+ // findBundledAPK 从可执行文件所在目录向上找 npm/apk/magic-agent-mobile-release.apk。
32
+ //
33
+ // 两种布局都要能命中:
34
+ // - 开发态:<repo>/bin/magic-client → <repo>/npm/apk/...
35
+ // - npm 全局:<pkg>/npm/dist/<os>-<arch>/magic-client → <pkg>/npm/apk/...
36
+ //
37
+ // 不写死层级:向上最多找 6 层,遇到根就停。cwd 也找一遍 —— 开发时从别的目录启动、
38
+ // 但包目录在 cwd 下面时仍能命中。
39
+ func findBundledAPK() string {
40
+ const rel = "npm/apk/magic-agent-mobile-release.apk"
41
+ var roots []string
42
+ addAncestors := func(start string) {
43
+ dir, err := filepath.Abs(start)
44
+ if err != nil {
45
+ return
46
+ }
47
+ for i := 0; i < 6; i++ {
48
+ roots = append(roots, dir)
49
+ parent := filepath.Dir(dir)
50
+ if parent == dir {
51
+ break
52
+ }
53
+ dir = parent
54
+ }
55
+ }
56
+ if exe, err := os.Executable(); err == nil {
57
+ if real, err := filepath.EvalSymlinks(exe); err == nil {
58
+ exe = real
59
+ }
60
+ addAncestors(filepath.Dir(exe))
61
+ }
62
+ if wd, err := os.Getwd(); err == nil {
63
+ addAncestors(wd)
64
+ }
65
+ for _, root := range roots {
66
+ p := filepath.Join(root, filepath.FromSlash(rel))
67
+ if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
68
+ return p
69
+ }
70
+ }
71
+ return ""
72
+ }
73
+
74
+ // apkVersion 读 version.json;缺失时从文件名兜底(magic-agent-mobile-<ver>-release.apk)。
75
+ func apkVersion(path string) string {
76
+ b, err := os.ReadFile(filepath.Join(filepath.Dir(path), "version.json"))
77
+ if err == nil {
78
+ var m apkManifest
79
+ if json.Unmarshal(b, &m) == nil && strings.TrimSpace(m.Version) != "" {
80
+ return strings.TrimSpace(m.Version)
81
+ }
82
+ }
83
+ name := filepath.Base(path)
84
+ name = strings.TrimSuffix(name, ".apk")
85
+ name = strings.TrimPrefix(name, "magic-agent-mobile-")
86
+ name = strings.TrimSuffix(name, "-release")
87
+ if name != "" && name != filepath.Base(path) {
88
+ return name
89
+ }
90
+ return ""
91
+ }
92
+
93
+ func (s *Service) apkAvailable() bool {
94
+ if s.apkPath == "" {
95
+ return false
96
+ }
97
+ st, err := os.Stat(s.apkPath)
98
+ return err == nil && st.Mode().IsRegular()
99
+ }
100
+
101
+ // handleAppInfo GET /desk/app:告诉手机「桌面端这份 npm 包里带的 APK 是哪一版」。
102
+ // 没带 APK 时也回 200 + 空 version —— 手机据此说「暂无可安装包」,而不是把 404 当网络错误。
103
+ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
104
+ out := struct {
105
+ Version string `json:"version"`
106
+ Size int64 `json:"size"`
107
+ URL string `json:"url"`
108
+ }{}
109
+ if s.apkAvailable() {
110
+ if st, err := os.Stat(s.apkPath); err == nil {
111
+ out.Version = apkVersion(s.apkPath)
112
+ out.Size = st.Size()
113
+ out.URL = "/desk/app.apk"
114
+ }
115
+ }
116
+ writeJSON(w, http.StatusOK, out)
117
+ }
118
+
119
+ // handleAppAPK GET /desk/app.apk:把包里的 APK 原样送出去。
120
+ // token 可以走查询参数 —— 系统浏览器/下载器带不了 X-Magic-Token,guard 已经支持 ?token=。
121
+ func (s *Service) handleAppAPK(w http.ResponseWriter, r *http.Request) {
122
+ if !s.apkAvailable() {
123
+ writeErr(w, http.StatusNotFound, "这个 magic-client 包里没有 APK(npm/apk/ 为空)")
124
+ return
125
+ }
126
+ ver := apkVersion(s.apkPath)
127
+ name := "magic-agent-mobile.apk"
128
+ if ver != "" {
129
+ name = "magic-agent-mobile-" + ver + ".apk"
130
+ }
131
+ w.Header().Set("Content-Type", "application/vnd.android.package-archive")
132
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
133
+ w.Header().Set("Cache-Control", "no-store")
134
+ http.ServeFile(w, r, s.apkPath)
135
+ }
@@ -0,0 +1,99 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ )
11
+
12
+ // 2026-10-22:移动端「我的」→「版本」点一下要能问桌面端「包里带的 APK 是哪一版」,
13
+ // 再决定是否触发下载。这里钉住两个接口的形状与空包时的行为。
14
+
15
+ func writeTestAPK(t *testing.T, dir, version string) string {
16
+ t.Helper()
17
+ apk := filepath.Join(dir, "magic-agent-mobile-release.apk")
18
+ body := []byte("fake-apk-" + version)
19
+ if err := os.WriteFile(apk, body, 0o644); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ m := apkManifest{Version: version, VersionCode: 20102}
23
+ b, _ := json.Marshal(m)
24
+ if err := os.WriteFile(filepath.Join(dir, "version.json"), b, 0o644); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ return apk
28
+ }
29
+
30
+ func TestAppInfoAndAPK(t *testing.T) {
31
+ dir := t.TempDir()
32
+ apk := writeTestAPK(t, dir, "2.1.2")
33
+ s := &Service{apkPath: apk}
34
+
35
+ rec := httptest.NewRecorder()
36
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
37
+ if rec.Code != http.StatusOK {
38
+ t.Fatalf("app info code = %d, want 200", rec.Code)
39
+ }
40
+ var got struct {
41
+ Version string `json:"version"`
42
+ Size int64 `json:"size"`
43
+ URL string `json:"url"`
44
+ }
45
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if got.Version != "2.1.2" || got.URL != "/desk/app.apk" || got.Size != int64(len("fake-apk-2.1.2")) {
49
+ t.Fatalf("app info = %+v", got)
50
+ }
51
+
52
+ rec = httptest.NewRecorder()
53
+ s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
54
+ if rec.Code != http.StatusOK {
55
+ t.Fatalf("apk code = %d, want 200", rec.Code)
56
+ }
57
+ if rec.Body.String() != "fake-apk-2.1.2" {
58
+ t.Fatalf("apk body = %q", rec.Body.String())
59
+ }
60
+ if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.android.package-archive" {
61
+ t.Fatalf("content-type = %q", ct)
62
+ }
63
+ if cd := rec.Header().Get("Content-Disposition"); cd == "" {
64
+ t.Fatal("缺少 Content-Disposition,浏览器会拿不到 .apk 文件名")
65
+ }
66
+ }
67
+
68
+ func TestAppInfoWithoutAPK(t *testing.T) {
69
+ s := &Service{}
70
+ rec := httptest.NewRecorder()
71
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
72
+ if rec.Code != http.StatusOK {
73
+ t.Fatalf("code = %d, want 200(没包也要让手机能说「暂无」)", rec.Code)
74
+ }
75
+ var got map[string]any
76
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if got["version"] != "" || got["url"] != "" {
80
+ t.Fatalf("空包 info = %+v", got)
81
+ }
82
+
83
+ rec = httptest.NewRecorder()
84
+ s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
85
+ if rec.Code != http.StatusNotFound {
86
+ t.Fatalf("空包 apk code = %d, want 404", rec.Code)
87
+ }
88
+ }
89
+
90
+ func TestAPKVersionFallsBackToFilename(t *testing.T) {
91
+ dir := t.TempDir()
92
+ apk := filepath.Join(dir, "magic-agent-mobile-3.2.1-release.apk")
93
+ if err := os.WriteFile(apk, []byte("x"), 0o644); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ if got := apkVersion(apk); got != "3.2.1" {
97
+ t.Fatalf("apkVersion = %q, want 3.2.1", got)
98
+ }
99
+ }
@@ -0,0 +1,99 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ )
10
+
11
+ /* 移动端「环境配置」的远端存储(2026-10-22 用户:
12
+ * 「环境的配置应该在远程上 不是浏览器缓存 否则同一个环境多个客户端配置还不一致」)。
13
+ *
14
+ * 为什么放在桌面端:默认引擎/模型、团队配置、空间选择这些描述的是**这台桌面怎么干活**,
15
+ * 不是某台手机的屏幕习惯。存在浏览器 localStorage 里,手机 A 改了手机 B 不知道;
16
+ * 换一台手机连同一台桌面,看到的还是另一套配置。
17
+ *
18
+ * GET /desk/prefs -> 这台桌面当前的配置 JSON(没有就是 {})
19
+ * PUT /desk/prefs -> 整份覆盖(移动端改完推上来)
20
+ *
21
+ * 只存一个 JSON 文件,字段由移动端定义、桌面端不解释 —— 这样加一个配置项不用改这里。
22
+ * 落在 ~/.magic-agent/mobile-prefs.json,与 core 的其它数据同层次。
23
+ *
24
+ * 不做版本/合并:多端同时改就是「后写覆盖」。这些是偏好而不是业务数据,
25
+ * 冲突代价低;真需要并发控制时再加 version 字段也不迟。
26
+ */
27
+
28
+ const mobilePrefsMaxBytes = 256 << 10
29
+
30
+ func mobilePrefsPath() string {
31
+ home, err := os.UserHomeDir()
32
+ if err != nil || home == "" {
33
+ return ""
34
+ }
35
+ return filepath.Join(home, ".magic-agent", "mobile-prefs.json")
36
+ }
37
+
38
+ func (s *Service) handlePrefs(w http.ResponseWriter, r *http.Request) {
39
+ switch r.Method {
40
+ case http.MethodGet:
41
+ w.Header().Set("Cache-Control", "no-store")
42
+ p := mobilePrefsPath()
43
+ if p == "" {
44
+ writeJSON(w, http.StatusOK, map[string]any{})
45
+ return
46
+ }
47
+ b, err := os.ReadFile(p)
48
+ if err != nil || len(b) == 0 {
49
+ // 没存过 = 空配置,不把「文件不存在」当成错误。
50
+ writeJSON(w, http.StatusOK, map[string]any{})
51
+ return
52
+ }
53
+ if !json.Valid(b) {
54
+ writeJSON(w, http.StatusOK, map[string]any{})
55
+ return
56
+ }
57
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
58
+ _, _ = w.Write(b)
59
+
60
+ case http.MethodPut:
61
+ body, err := io.ReadAll(io.LimitReader(r.Body, mobilePrefsMaxBytes+1))
62
+ if err != nil {
63
+ writeErr(w, http.StatusBadRequest, "读请求体失败:"+err.Error())
64
+ return
65
+ }
66
+ if len(body) > mobilePrefsMaxBytes {
67
+ writeErr(w, http.StatusRequestEntityTooLarge, "配置超过 256KB")
68
+ return
69
+ }
70
+ var v map[string]any
71
+ if err := json.Unmarshal(body, &v); err != nil {
72
+ writeErr(w, http.StatusBadRequest, "不是合法的 JSON 对象")
73
+ return
74
+ }
75
+ p := mobilePrefsPath()
76
+ if p == "" {
77
+ writeErr(w, http.StatusInternalServerError, "找不到用户目录")
78
+ return
79
+ }
80
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
81
+ writeErr(w, http.StatusInternalServerError, "建目录失败:"+err.Error())
82
+ return
83
+ }
84
+ // 先写临时文件再 rename:多端同时推的时候不会读到写了一半的 JSON。
85
+ tmp := p + ".tmp"
86
+ if err := os.WriteFile(tmp, body, 0o600); err != nil {
87
+ writeErr(w, http.StatusInternalServerError, "写配置失败:"+err.Error())
88
+ return
89
+ }
90
+ if err := os.Rename(tmp, p); err != nil {
91
+ writeErr(w, http.StatusInternalServerError, "落盘失败:"+err.Error())
92
+ return
93
+ }
94
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
95
+
96
+ default:
97
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 GET / PUT")
98
+ }
99
+ }
@@ -15,6 +15,7 @@ import (
15
15
  "io"
16
16
  "log"
17
17
  "math"
18
+ "net"
18
19
  "net/http"
19
20
  "net/http/httputil"
20
21
  "net/url"
@@ -62,6 +63,12 @@ type Options struct {
62
63
  // Token 访问令牌;空 = 启动时随机生成。
63
64
  Token string
64
65
  Logger *log.Logger
66
+ // APKPath 可选:要分发给移动端的 APK 路径。空 = 自动在包内 npm/apk/ 下找
67
+ // (见 apk.go)。显式给路径主要给测试与特殊布局用。
68
+ APKPath string
69
+ // Lan 是否绑到了局域网(--lan)。移动端「环境」菜单要靠它拿到手机可用的入口,
70
+ // 否则用户只能手抄 IP。绑回环时不报(报了也连不上)。
71
+ Lan bool
65
72
  // BrowseUA 内置浏览器中转使用的 User-Agent;空 = 用内置的 Android Chrome。
66
73
  // 为什么可配:UA 会决定站点给哪一版页面。中国大陆出口 + 移动 UA 时,Google 会跳到
67
74
  // http://www.google.cn/m(该路径本身就是 404);换成桌面 UA 则走香港跳转、可正常打开。
@@ -88,6 +95,16 @@ type Service struct {
88
95
 
89
96
  // 需求自动归纳的调度状态(每工作区一个防抖计时器,见 requirements.go)。
90
97
  reqAuto reqAuto
98
+
99
+ // 随包发布的移动端 APK(空 = 这个包里没带)。见 apk.go。
100
+ apkPath string
101
+
102
+ /* 公网入口探测(2026-10-22):隧道是**壳**起的,插件不知道它的域名,而 quick tunnel
103
+ 每次启动都换域名。这里缓存 probePublicURL 的结果,让 /desk/health 不必每次都扫端口。 */
104
+ pubMu sync.Mutex
105
+ pubURL string
106
+ pubExp time.Time
107
+ pubBusy bool
91
108
  }
92
109
 
93
110
  // Contract core `--contract` 里本插件关心的部分。
@@ -134,6 +151,14 @@ func New(opts Options) (*Service, error) {
134
151
  opts.MagicAgent = "magic-agent"
135
152
  }
136
153
  s := &Service{opts: opts, log: opts.Logger, runs: map[string]*askRun{}}
154
+ // 移动端 APK:显式路径优先;否则在包内 npm/apk/ 找(随 npm 包发布,见 apk.go)。
155
+ s.apkPath = strings.TrimSpace(opts.APKPath)
156
+ if s.apkPath == "" {
157
+ s.apkPath = findBundledAPK()
158
+ }
159
+ if s.apkPath != "" {
160
+ s.log.Printf("移动端 APK:%s(v%s)", s.apkPath, apkVersion(s.apkPath))
161
+ }
137
162
 
138
163
  // 项目后端的反代一次建好:profile 合法就给代理,非法就启动即失败
139
164
  //(地址写错比运行时 502 更容易查——启动日志里说清楚)。
@@ -178,6 +203,10 @@ func (s *Service) Handler() http.Handler {
178
203
  mux := http.NewServeMux()
179
204
  mux.HandleFunc("/desk/health", s.guard(s.handleHealth))
180
205
  mux.HandleFunc("/desk/contract", s.guard(s.handleContract))
206
+ // 移动端 APK:版本信息 + 安装包本身(版本号跟 npm 包走,见 apk.go)。
207
+ mux.HandleFunc("/desk/app", s.guard(s.handleAppInfo))
208
+ // 系统浏览器/下载器带不了自定义头,这个接口靠 guard 支持的 ?token= 过鉴权。
209
+ mux.HandleFunc("/desk/app.apk", s.guard(s.handleAppAPK))
181
210
  mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
182
211
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
183
212
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
@@ -192,6 +221,9 @@ func (s *Service) Handler() http.Handler {
192
221
  // 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
193
222
  mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
194
223
  mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
224
+ // 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
225
+ // 不再各存各的 localStorage(见 prefs.go)。
226
+ mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
195
227
  // 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
196
228
  // 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
197
229
  mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
@@ -362,12 +394,171 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
362
394
  // 而且 HEAD 请求在 WebView 里常被记成 ERR_ABORTED 的失败请求(导航抢跑),
363
395
  // 排查时是纯噪音。探活本来就要打这一枪,顺手带一句最省。
364
396
  "remoteUI": s.hasMobileUI(),
397
+ // 手机可用的局域网入口(2026-10-22,只增字段,不升 contractVersion)。
398
+ // 移动端「环境」菜单据此自动给出「公司远程」那条,用户不必手抄 IP。
399
+ // 只给地址、不带令牌:页面自己握着令牌,别把门锁写进响应体里。
400
+ // 没绑局域网 / 拿不到网卡地址 → null。
401
+ "lanUrls": s.lanURLs(r.Host),
402
+ // 公网入口(2026-10-22):本机 cloudflared quick tunnel 的域名,空 = 没开公网。
403
+ // 移动端「公司远程」优先用这条(不在同一个 Wi-Fi 也能连);拿不到才退回 lanUrls。
404
+ "publicUrl": s.publicURL(),
365
405
  // 团队角色清单(2026-10-22):移动端「团队模式」页要按角色配路由,
366
406
  // 角色名不能由客户端硬编码。老 core 没有 teamRoles → null,客户端回落内置清单。
367
407
  "teamRoles": s.contract.TeamRoles,
368
408
  })
369
409
  }
370
410
 
411
+ // lanURLs 服务端自己上报的手机可用入口(形如 http://10.0.0.5:58849/)。
412
+ //
413
+ // 端口从请求的 Host 里取:服务可能绑 0 端口由系统分配,这里不必再存一份。
414
+ // 只返回地址、不带令牌 —— 页面已经握着令牌,别把门锁写进响应体。
415
+ func (s *Service) lanURLs(hostport string) []string {
416
+ if !s.opts.Lan {
417
+ return nil
418
+ }
419
+ _, portStr, err := net.SplitHostPort(hostport)
420
+ if err != nil {
421
+ return nil
422
+ }
423
+ port, err := strconv.Atoi(portStr)
424
+ if err != nil || port <= 0 {
425
+ return nil
426
+ }
427
+ addrs := LanAddresses(port)
428
+ out := make([]string, 0, len(addrs))
429
+ for _, a := range addrs {
430
+ out = append(out, "http://"+a+"/")
431
+ }
432
+ return out
433
+ }
434
+
435
+ // publicTTL 公网入口探测结果的缓存时长。quick tunnel 换域名只发生在重启/开关时,
436
+ // 两分钟足够新;过期后是**后台**刷新,请求路径上不阻塞。
437
+ const publicTTL = 2 * time.Minute
438
+
439
+ // quickTunnelPortLo/Hi cloudflared 的本地 metrics 端口范围。
440
+ // 端口由 cloudflared 自己从 20241 起顺延分配(实测 20241–20244),这里留足余量。
441
+ const (
442
+ quickTunnelPortLo = 20240
443
+ quickTunnelPortHi = 20280
444
+ )
445
+
446
+ // publicURL 返回本服务对外的 Cloudflare quick tunnel 地址(形如 https://xxx.trycloudflare.com)。
447
+ //
448
+ // 为什么要探测而不是配置:隧道是**壳(掌天瓶)**起的,插件不知道它的域名;quick tunnel
449
+ // 每次启动都换域名,写死或让用户手抄都不成立。cloudflared 把域名放在本地 metrics 的
450
+ // /quicktunnel 里,扫一小段端口就能拿到候选,再用**本服务自己的令牌**回打一次
451
+ // /desk/health 确认「这条隧道确实指向我」(别人的隧道令牌不同 → 401)。
452
+ //
453
+ // 空返回 = 没开公网 / 还没扫到;调用方(移动端)退回局域网地址,不假装有。
454
+ func (s *Service) publicURL() string {
455
+ s.pubMu.Lock()
456
+ cur, exp, busy := s.pubURL, s.pubExp, s.pubBusy
457
+ s.pubMu.Unlock()
458
+ if busy || time.Now().Before(exp) {
459
+ return cur
460
+ }
461
+ s.pubMu.Lock()
462
+ if s.pubBusy {
463
+ s.pubMu.Unlock()
464
+ return s.pubURL
465
+ }
466
+ s.pubBusy = true
467
+ s.pubMu.Unlock()
468
+
469
+ go func() {
470
+ u := s.probePublicURL()
471
+ s.pubMu.Lock()
472
+ changed := u != s.pubURL
473
+ s.pubURL = u
474
+ s.pubExp = time.Now().Add(publicTTL)
475
+ s.pubBusy = false
476
+ s.pubMu.Unlock()
477
+ if changed && s.log != nil {
478
+ if u == "" {
479
+ s.log.Printf("公网入口:未发现(本机没有指向本服务的 cloudflared quick tunnel)")
480
+ } else {
481
+ s.log.Printf("公网入口:%s", u)
482
+ }
483
+ }
484
+ }()
485
+ return cur
486
+ }
487
+
488
+ // probePublicURL 扫 cloudflared 的 metrics 端口,找出指向本服务的那条 quick tunnel。
489
+ func (s *Service) probePublicURL() string {
490
+ tok := strings.TrimSpace(s.opts.Token)
491
+ if tok == "" {
492
+ return ""
493
+ }
494
+ hosts := make(chan string, quickTunnelPortHi-quickTunnelPortLo+1)
495
+ for p := quickTunnelPortLo; p <= quickTunnelPortHi; p++ {
496
+ go func(port int) {
497
+ cl := &http.Client{Timeout: 400 * time.Millisecond}
498
+ resp, err := cl.Get(fmt.Sprintf("http://127.0.0.1:%d/quicktunnel", port))
499
+ if err != nil {
500
+ hosts <- ""
501
+ return
502
+ }
503
+ defer resp.Body.Close()
504
+ var v struct {
505
+ Hostname string `json:"hostname"`
506
+ }
507
+ if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&v) != nil {
508
+ hosts <- ""
509
+ return
510
+ }
511
+ hosts <- strings.TrimSpace(v.Hostname)
512
+ }(p)
513
+ }
514
+ var cands []string
515
+ for i := 0; i <= quickTunnelPortHi-quickTunnelPortLo; i++ {
516
+ if h := <-hosts; h != "" {
517
+ cands = append(cands, h)
518
+ }
519
+ }
520
+ if len(cands) == 0 {
521
+ return ""
522
+ }
523
+
524
+ // 用本服务的令牌回打一次:只有真正指向本服务的隧道才认这个令牌。
525
+ var (
526
+ mu sync.Mutex
527
+ found string
528
+ wg sync.WaitGroup
529
+ )
530
+ for _, h := range cands {
531
+ wg.Add(1)
532
+ go func(host string) {
533
+ defer wg.Done()
534
+ req, err := http.NewRequest(http.MethodGet, "https://"+host+"/desk/health", nil)
535
+ if err != nil {
536
+ return
537
+ }
538
+ req.Header.Set("X-Magic-Token", tok)
539
+ resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
540
+ if err != nil {
541
+ return
542
+ }
543
+ defer resp.Body.Close()
544
+ if resp.StatusCode != http.StatusOK {
545
+ return
546
+ }
547
+ var v map[string]any
548
+ if json.NewDecoder(resp.Body).Decode(&v) != nil || v["ok"] != true {
549
+ return
550
+ }
551
+ mu.Lock()
552
+ if found == "" {
553
+ found = "https://" + host
554
+ }
555
+ mu.Unlock()
556
+ }(h)
557
+ }
558
+ wg.Wait()
559
+ return found
560
+ }
561
+
371
562
  // handleContract 原样转发 core 的契约(客户端拿它做能力降级,不需要经过插件再解释一遍)。
372
563
  func (s *Service) handleContract(w http.ResponseWriter, r *http.Request) {
373
564
  s.proxyCore(w, r, "--contract")
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "2.1.3",
3
+ "versionCode": 20103,
4
+ "builtAt": "2026-10-22T11:18:59.511Z"
5
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "1.0.111",
3
+ "version": "2.1.5",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "npm/bin/",
23
23
  "npm/lib/",
24
24
  "npm/dist/",
25
+ "npm/apk/",
25
26
  "npm/install.js",
26
27
  "cmd/",
27
28
  "internal/",