magic-agent 2.3.9 → 2.4.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.
@@ -432,6 +432,12 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
432
432
  | 令牌下限 | 24 字符;弱令牌**拒绝启动**(自动生成的默认令牌是 32 位十六进制,够用) |
433
433
  | 无令牌访问 | `401` —— 不因为是内网就放行 |
434
434
  | 不带 `--lan` | 局域网地址**连不上**(默认就是安全的) |
435
+ | 地址的挑选与顺序 | 虚拟网卡(容器 / 虚拟交换机 / 隧道)的地址**不报**;公网段地址在还有私有地址时也不报;剩下的按「出站主地址(默认路由那张网卡)→ 其余」排。**第一个 = 手机最可能连上的那个** —— 移动端只认 `[0]` |
436
+
437
+ ⚠️ **地址顺序是契约的一部分(2026-10-23 修正)**:以前直接按网卡枚举顺序返回,
438
+ 而企业 VPN 虚拟网卡常常排在真实网卡前面 —— 移动端自动补的「公司远程」环境
439
+ 就被种成了一个手机路由不到的地址,表现为「启动成功但手机连不上」。
440
+ 现在第一个地址保证是手机能连的那个。`/desk/health.lanUrls` 用同一份顺序。
435
441
 
436
442
  扫码配对在 v1 就是「扫这段 URL」(URL 自带令牌)。带 TTL 的配对码交换属于 L2(跨网)的活,
437
443
  需要服务端与令牌存储,**未做**。
@@ -1600,7 +1600,13 @@ button.setrow{font-family:inherit}
1600
1600
  list.forEach(function (c) { if (c && c.id === id) hit = c; });
1601
1601
  /* 用户主动删掉种出来的默认环境:记一笔,下次启动别再自动加回来。 */
1602
1602
  if (hit && hit.seed) { try { localStorage.setItem('magic.remote.seedoff.v1', '1'); } catch (e) {} }
1603
- if (hit && !keepQueue) dropEnvQueue(hit);
1603
+ /* 同一台桌面可能有多条入口(公司本地 / 公司远程)共享一个 eid:
1604
+ 只有删掉最后一条时才清队列,否则删一个别名会把另一个还在用的队列带走。 */
1605
+ var sharedEid = false;
1606
+ if (hit && hit.eid) {
1607
+ sharedEid = list.some(function (c) { return c && c.id !== id && c.eid && c.eid === hit.eid; });
1608
+ }
1609
+ if (hit && !keepQueue && !sharedEid) dropEnvQueue(hit);
1604
1610
  saveConns(list.filter(function (c) { return c.id !== id; }));
1605
1611
  }
1606
1612
  /* 清掉某条环境名下的排队行(eid 桶与老 host 桶都清)。 */
@@ -1671,6 +1677,44 @@ button.setrow{font-family:inherit}
1671
1677
  for (var j = 0; j < l2.length; j++) if (l2[j].id === rec.id) l2[j].seed = true;
1672
1678
  saveConns(l2);
1673
1679
  }
1680
+ /* 当前连接所属环境的 eid(临时记录会先补号)。
1681
+ 用途:同一台桌面的「公司本地 / 公司远程」两条入口要共享一个环境身份 ——
1682
+ 它们是同一个 magic-client、同一份队列与设置,不能因为地址不同就算两台。 */
1683
+ function activeEnvEid() {
1684
+ var c = activeConn();
1685
+ if (!c) return '';
1686
+ c = ensureEnvId(c) || c;
1687
+ return c.eid || '';
1688
+ }
1689
+ /* 把一条环境记录并入 toEid:队列行与配置槽一起迁过去(幂等)。 */
1690
+ function mergeEnvId(rec, toEid) {
1691
+ if (!rec || !toEid || rec.eid === toEid) return false;
1692
+ var from = rec.eid ? eidKey(rec.eid) : envHashOf(rec);
1693
+ rec.eid = toEid;
1694
+ migrateEnvData(from, eidKey(toEid));
1695
+ return true;
1696
+ }
1697
+ /* 服务端报了实例 id(/desk/health.instanceId):把当前环境钉到 `srv:<id>`。
1698
+ 同一台桌面的本地 / 局域网 / 公网入口会拿到同一个 id —— 于是不同 URL 也是同一个环境:
1699
+ 队列、任务模式、引擎/空间选择都共用一份。老服务端没有该字段 → 什么也不做。 */
1700
+ function adoptServerEid(sid) {
1701
+ sid = String(sid || '').trim();
1702
+ if (!sid) return false;
1703
+ var want = 'srv:' + sid;
1704
+ var cur = activeConn();
1705
+ if (!cur || !cur.id) return false;
1706
+ var list = loadConns(), changed = false;
1707
+ for (var i = 0; i < list.length; i++) {
1708
+ if (!list[i] || list[i].id !== cur.id) continue;
1709
+ if (list[i].eid === want) break;
1710
+ if (list[i].eid) mergeEnvId(list[i], want);
1711
+ else list[i].eid = want;
1712
+ changed = true;
1713
+ break;
1714
+ }
1715
+ if (changed) saveConns(list);
1716
+ return changed;
1717
+ }
1674
1718
  function seedCurrentConn() {
1675
1719
  var url = connUrlNow();
1676
1720
  if (!url || !TOKEN) return;
@@ -1704,6 +1748,10 @@ button.setrow{font-family:inherit}
1704
1748
  var url = String(urls[0] || '').replace(/\/+$/, '');
1705
1749
  if (!url) return;
1706
1750
  if (url === String(connUrlNow()).replace(/\/+$/, '')) return;
1751
+ /* 这些远程入口是**当前这台桌面**自己上报的(/desk/health),
1752
+ 与当前连接是同一个 magic-client —— 新记录必须沿用当前环境的 eid,
1753
+ 否则「公司本地 / 公司远程」会被当成两台,队列与设置各存一份。 */
1754
+ var homeEid = activeEnvEid();
1707
1755
  var list = loadConns();
1708
1756
  /* 隧道换域名(cloudflared 每次重启都换):把上一枚**默认命名**的种子远程记录换到新地址。
1709
1757
  用户改过名字的不动 —— 那条是他自己配的环境。 */
@@ -1722,18 +1770,23 @@ button.setrow{font-family:inherit}
1722
1770
  var c = list[i];
1723
1771
  if (!c) continue;
1724
1772
  if (c.url === url && String(c.token || '') === String(TOKEN)) {
1725
- if (c.seed && c.name === SEED_LOCAL) { c.name = SEED_NAME; saveConns(list); }
1773
+ var changed = false;
1774
+ if (c.seed && c.name === SEED_LOCAL) { c.name = SEED_NAME; changed = true; }
1775
+ /* 老数据 / 先远程后本地:把这条并到当前桌面的 eid 上。 */
1776
+ if (homeEid && mergeEnvId(c, homeEid)) changed = true;
1777
+ if (changed) saveConns(list);
1726
1778
  return;
1727
1779
  }
1728
1780
  if (c.seed && c.url === url) {
1729
1781
  c.token = TOKEN;
1730
1782
  c.id = connIdOf({ url: url, token: TOKEN });
1731
1783
  c.at = Date.now();
1784
+ if (homeEid) mergeEnvId(c, homeEid);
1732
1785
  saveConns(list);
1733
1786
  return;
1734
1787
  }
1735
1788
  }
1736
- seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME, eid: replacedEid }));
1789
+ seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME, eid: homeEid || replacedEid }));
1737
1790
  }
1738
1791
 
1739
1792
  /* ── 设备现场(按环境隔离)──
@@ -3219,6 +3272,9 @@ button.setrow{font-family:inherit}
3219
3272
  CORE_FEATURES = ((a[2] && a[2].features) || []);
3220
3273
  /* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
3221
3274
  if (a[2] && a[2].app) noteActiveMeta({ app: a[2].app });
3275
+ /* 服务实例身份:同一台桌面的多个入口(本地 / 局域网 / 公网)归成一个环境。
3276
+ 必须排在 seedRemoteConns 之前 —— 后者新建「公司远程」时要继承当前 eid。 */
3277
+ if (a[2] && a[2].instanceId) adoptServerEid(a[2].instanceId);
3222
3278
  /* 桌面绑了公网隧道时上报 publicUrl(cloudflared,不在同一 Wi-Fi 也能连),
3223
3279
  没有则退回局域网入口:自动补一条「公司远程」环境,用户不用手抄地址。 */
3224
3280
  if (a[2]) {
@@ -3249,7 +3305,12 @@ button.setrow{font-family:inherit}
3249
3305
  }).catch(function (e) {
3250
3306
  if (seq !== CONN_SEQ) return;
3251
3307
  if (quiet) return; // 后台轮询失败:保留现有列表(见上面的说明)
3252
- historyList.innerHTML = '<div class="history__hint">读取会话失败:' + (e && e.message ? e.message : e) + '</div>';
3308
+ /* 失败原因要带上**实际打的地址**:Photo/WebView 里 "Failed to fetch" 只说「没连上」,
3309
+ 而真正的原因通常是「这台手机到那个 IP 不通」(换了网络 / 环境里存的是别的网段)。
3310
+ 不写地址的话,用户和应用都无从判断该改哪一边。 */
3311
+ var why = (e && e.message ? e.message : String(e));
3312
+ var addr = BASE || location.origin || '(同源)';
3313
+ historyList.innerHTML = '<div class="history__hint">读取会话失败:' + why + '<br>地址:' + addr + '</div>';
3253
3314
  });
3254
3315
  }
3255
3316
  /* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
@@ -62,6 +62,7 @@ func main() {
62
62
  proxyAddr = fs.String("proxy-addr", "", "SOCKS5 出口监听地址(供移动端 VPN 隧道核心使用),如 0.0.0.0:1080")
63
63
  browseUA = fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 任意自定义串")
64
64
  readyFile = fs.String("ready-file", "", "监听成功后把 {pid,port,url,lanAddrs} 原子写到这个路径(`magic-client start` 用它拿端口,别手工传)")
65
+ anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。--lan 时自动开启;令牌仍是唯一门锁")
65
66
  )
66
67
  _ = fs.Parse(os.Args[2:])
67
68
 
@@ -90,6 +91,10 @@ func main() {
90
91
  Token: tok,
91
92
  Lan: *lan,
92
93
  BrowseUA: *browseUA,
94
+ /* 绑到局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
95
+ 等于把手机 / 别的设备的页面全判成跨域(用户侧就是「只能本地访问」)。
96
+ 显式 --allow-any-origin 也可以单独打开(绑回环但要给容器页面用)。 */
97
+ AllowAnyOrigin: *anyOrigin || *lan,
93
98
  })
94
99
  if err != nil {
95
100
  // 契约不匹配是**要立刻停**的错误:带着不匹配的能力跑起来只会让人以为是界面 bug。
@@ -425,21 +425,12 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
425
425
  TitleSummarized: summarized,
426
426
  StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
427
427
  UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
428
- HasLog: hasSessionLog(logDir, r),
428
+ HasLog: session.HasSessionLogIn(logDir, r.SessionID, r.RunID),
429
429
  })
430
430
  }
431
431
  return printJSON(cmd.OutOrStdout(), rows)
432
432
  }
433
433
 
434
- // hasSessionLog 这条记录有没有可回放的事件日志。
435
- //
436
- // 日志文件名可能是 **session_id**(终名),也可能是 **run_id** —— 引擎还没吐 id 时
437
- // core 先按 run_id 落盘(见 sessionlog.go 的 setRunID / renameTo),所以两个名字都试一次;
438
- // 决议逻辑(含 run_id → session_id 的翻译)在 session.HasSessionLog 里,读接口共用同一份。
439
- func hasSessionLog(dir string, rec session.Record) bool {
440
- return session.HasSessionLog(dir, rec.SessionID) || session.HasSessionLog(dir, rec.RunID)
441
- }
442
-
443
434
  // runSessionLog 读一段会话的**事件历史**:`--session-log <id> [--after <seq>]`。
444
435
  //
445
436
  // 为什么这条命令必须存在:会话的「查询」原先只有 HTTP 插件能做(`/desk/session/{id}/messages`),
@@ -0,0 +1,59 @@
1
+ package client
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync"
10
+ )
11
+
12
+ /*
13
+ 服务实例的稳定身份(2026-10-23)。
14
+
15
+ 为什么需要:同一台桌面可能有多条入口(公司本地 127.0.0.1、局域网 IP、cloudflared
16
+ 公网域名),它们在客户端看来是不同 URL。移动端要用「这是不是同一台桌面」来决定
17
+ 队列与设置要不要共用一份 —— 只看地址会把同一台机器拆成好几个环境。
18
+
19
+ id 落在 profile 的 DataDir 下(`instance-id`),第一次生成后不再变:
20
+
21
+ · 改端口 / 换隧道域名 / 手机换 Wi-Fi 都不影响;
22
+ · 只删 `instance-id` 才会换身份(等同于「这是一台新桌面」)。
23
+
24
+ /desk/health 把它报给客户端(`instanceId`,只增字段)。老客户端忽略;
25
+ 新客户端据此把同一台桌面的多个入口并成一个环境身份。
26
+ */
27
+ type instanceIDCache struct {
28
+ once sync.Once
29
+ id string
30
+ }
31
+
32
+ func (s *Service) instanceID() string {
33
+ s.inst.once.Do(func() {
34
+ dir := s.opts.Profile.DataDir()
35
+ if strings.TrimSpace(dir) == "" {
36
+ return
37
+ }
38
+ path := filepath.Join(dir, "instance-id")
39
+ if b, err := os.ReadFile(path); err == nil {
40
+ if v := strings.TrimSpace(string(b)); len(v) >= 8 {
41
+ s.inst.id = v
42
+ return
43
+ }
44
+ }
45
+ var buf [8]byte
46
+ if _, err := rand.Read(buf[:]); err != nil {
47
+ return
48
+ }
49
+ id := hex.EncodeToString(buf[:])
50
+ if err := os.MkdirAll(dir, 0o700); err != nil {
51
+ return
52
+ }
53
+ if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil {
54
+ return
55
+ }
56
+ s.inst.id = id
57
+ })
58
+ return s.inst.id
59
+ }
@@ -33,17 +33,142 @@ func ValidateLanToken(token string) error {
33
33
  return nil
34
34
  }
35
35
 
36
+ /* ── 局域网地址的挑选 ──
37
+ *
38
+ * ⚠️ **顺序有讲究,而且这份顺序是「能不能连上」的关键**(2026-10-23 修)。
39
+ *
40
+ * 调用方全都只认**第一个**:
41
+ * · `/desk/health` 的 lanUrls → 移动端自动补的「公司远程」环境取 [0];
42
+ * · `magic-client start` 打印的**第一张**二维码 → 扫出来就是这个地址。
43
+ *
44
+ * 而一台装了企业 VPN / Hyper-V 的机器上有多张网卡,枚举顺序由系统决定,
45
+ * 先出来的常常是**手机根本路由不到**的那张:企业 VPN 虚拟网卡(10.x / 2.x 段)
46
+ * 与虚拟交换机(172.16/12 段)都属于「只在本机或专用隧道里有效」的地址,
47
+ * 手机在同一个 Wi-Fi 下压根到不了它们。
48
+ *
49
+ * 现象就是「启动成功、地址也打印出来了,但手机就是连不上」,而且**扫码比手抄更容易撞上**
50
+ * —— 扫的正是打印出来的第一张码。
51
+ *
52
+ * 现在的口径(三步,详见 orderLanAddresses):
53
+ * 1. 按**名字**剔掉虚拟网卡(容器 / 虚拟交换机 / 隧道 / 回环伪接口)。
54
+ * 2. 再剔掉**公网段**地址(只要还有私有地址):企业 VPN 的虚拟网卡常占一段
55
+ * 公网地址(这里 2.0.0.1),手机在局域网里根本路由不到那一段。
56
+ * 3. 剩下的里,出站主地址排最前(问一次内核要源地址,不发包)—— 那张网卡
57
+ * 就是本机跟外界说话用的那张,手机连它最可能通。
58
+ *
59
+ * 每一步都有兜底:真实网卡没剩就退回虚拟网卡,私有地址没剩就退回公网地址 ——
60
+ * 不能一个地址都不报。其余地址也会列在后面,用户可以手挑 —— 不猜、不假装。
61
+ */
62
+
63
+ // lanCandidate 一个候选局域网地址,以及它所在的网卡。
64
+ type lanCandidate struct {
65
+ ip string
66
+ iface net.Interface
67
+ }
68
+
69
+ // virtualIfaceHints 虚拟 / 容器网卡的**名称特征**(子串匹配,忽略大小写)。
70
+ //
71
+ // 为什么按名字认:这些网卡在 Windows 上叫「vEthernet (Default Switch)」
72
+ // (Hyper-V)、「VMware Network Adapter」;在 Linux 上叫 docker0 / veth* / virbr0;
73
+ // 在 macOS 上叫 utun*。它们承载的地址只在本机或专用隧道内有效,手机连不到。
74
+ var virtualIfaceHints = []string{
75
+ "vethernet", // Hyper-V 虚拟交换机
76
+ "hyper-v",
77
+ "vmware",
78
+ "virtualbox",
79
+ "docker",
80
+ "veth",
81
+ "virbr",
82
+ "br-", // Linux 网桥
83
+ "loopback", // Windows「Loopback Pseudo-Interface」
84
+ "pseudo",
85
+ "npcap",
86
+ "bluetooth", // 蓝牙 PAN
87
+ "utun", // macOS 隧道
88
+ "tailscale",
89
+ "zerotier",
90
+ }
91
+
92
+ // virtualIfaceNames 虚拟网卡的**完整**名称(小写)—— 这些是短名,
93
+ // 用子串匹配会误伤别的网卡("tun" 之于 "Tunnel"、"wg" 之于别的东西),所以整名匹配。
94
+ var virtualIfaceNames = map[string]bool{
95
+ "tun0": true, "tun1": true,
96
+ "tap0": true, "tap1": true,
97
+ "docker0": true,
98
+ "wg0": true, // WireGuard
99
+ "zt0": true, // ZeroTier
100
+ "zt1": true,
101
+ "bridge100": true, // macOS 上的 VMware
102
+ }
103
+
104
+ // isVirtualIface 网卡是不是虚拟 / 容器网卡。
105
+ func isVirtualIface(name string) bool {
106
+ n := strings.ToLower(strings.TrimSpace(name))
107
+ if virtualIfaceNames[n] {
108
+ return true
109
+ }
110
+ for _, h := range virtualIfaceHints {
111
+ if strings.Contains(n, h) {
112
+ return true
113
+ }
114
+ }
115
+ return false
116
+ }
117
+
118
+ // isPrivateIPv4 是不是 RFC1918 私有网段(10/8、172.16/12、192.168/16)。
119
+ //
120
+ // 为什么用它排序:手机在同一张局域网里拿到的也是私有地址,所以私有地址是
121
+ // 「手机连得上」的第一嫌疑。反过来,企业 VPN 的虚拟网卡常占一段**公网**地址
122
+ // (这台机器上 Sangfor aTrust VNIC 占的是 2.0.0.1)—— 手机在局域网里
123
+ // 根本路由不到那一段,把公网地址排前面就是第一个就撞墙。
124
+ func isPrivateIPv4(ip string) bool {
125
+ p := net.ParseIP(ip)
126
+ if p == nil {
127
+ return false
128
+ }
129
+ p = p.To4()
130
+ if p == nil {
131
+ return false
132
+ }
133
+ switch {
134
+ case p[0] == 10:
135
+ return true
136
+ case p[0] == 172 && p[1]&0xf0 == 16:
137
+ return true
138
+ case p[0] == 192 && p[1] == 168:
139
+ return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ // primaryOutboundIP 返回本机对外通信时实际使用的源 IP(默认路由所在网卡的那张)。
145
+ //
146
+ // 怎么问内核:发起一次 UDP「连接」。UDP 的 connect 只查路由表并选定源地址,
147
+ // **一个包都不发** —— 不是在访问外网,也就不会产生任何出网流量。
148
+ // 问不到(没有默认路由 / 离线)时返回空串,调用方退化成保持网卡枚举顺序。
149
+ func primaryOutboundIP() string {
150
+ conn, err := net.Dial("udp", "8.8.8.8:80")
151
+ if err != nil {
152
+ return ""
153
+ }
154
+ defer conn.Close()
155
+ host, _, err := net.SplitHostPort(conn.LocalAddr().String())
156
+ if err != nil {
157
+ return ""
158
+ }
159
+ return host
160
+ }
161
+
36
162
  // LanAddresses 列出本机可用于局域网访问的 IPv4 地址(形如 `192.168.1.5:50022`)。
37
163
  //
38
164
  // 过滤掉回环、链路本地(169.254.x)与非 IPv4:这三类给手机都没用。
39
- // 一台机器可能有多个(有线 + 无线 + 虚拟网卡),所以返回切片让调用方全都打印出来 ——
40
- // 猜错网卡比多打两行地址麻烦得多。
165
+ // 挑选与排序的口径见上面那段注释(虚拟网卡剔除 + 默认路由那张排最前)。
41
166
  func LanAddresses(port int) []string {
42
167
  ifaces, err := net.Interfaces()
43
168
  if err != nil {
44
169
  return nil
45
170
  }
46
- var out []string
171
+ var cands []lanCandidate
47
172
  for _, ifc := range ifaces {
48
173
  if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
49
174
  continue
@@ -61,7 +186,68 @@ func LanAddresses(port int) []string {
61
186
  if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() {
62
187
  continue
63
188
  }
64
- out = append(out, fmt.Sprintf("%s:%d", ip4.String(), port))
189
+ cands = append(cands, lanCandidate{ip: ip4.String(), iface: ifc})
190
+ }
191
+ }
192
+ return orderLanAddresses(cands, primaryOutboundIP(), port)
193
+ }
194
+
195
+ // orderLanAddresses 把候选地址按「手机最可能连得上」排好并拼上端口。
196
+ //
197
+ // 三步:
198
+ // 1. 按**名字**剔掉虚拟网卡(Hyper-V / 容器 / 隧道 / 回环伪接口)——
199
+ // 它们的地址只在本机或专用隧道里有效,手机连不到。
200
+ // 2. 再剔掉**公网段**地址:只要还有私有地址,公网段的就不要
201
+ // (企业 VPN 虚拟网卡常占一段公网地址,而它在 Windows 上报的是连接别名
202
+ // 「本地连接」这种通用名,靠名字抓不到 —— 靠网段能抓到)。
203
+ // 3. 剩下的里,出站主地址(默认路由所在网卡)排最前。
204
+ //
205
+ // 每一步都有兜底:真实网卡没剩就退回虚拟网卡,私有地址没剩就退回公网地址 ——
206
+ // **不能一个地址都不报**。
207
+ //
208
+ // 拆成独立函数是为了能直接喂合成数据做单测 —— 各人机器上的网卡千差万别,
209
+ // 靠真机跑出来的用例没法覆盖「VPN 虚拟网卡恰好排在最前」这种关键情形。
210
+ func orderLanAddresses(cands []lanCandidate, primary string, port int) []string {
211
+ if len(cands) == 0 {
212
+ return nil
213
+ }
214
+ real := make([]lanCandidate, 0, len(cands))
215
+ virtual := make([]lanCandidate, 0, len(cands))
216
+ for _, c := range cands {
217
+ if isVirtualIface(c.iface.Name) {
218
+ virtual = append(virtual, c)
219
+ } else {
220
+ real = append(real, c)
221
+ }
222
+ }
223
+ pool := real
224
+ if len(pool) == 0 {
225
+ // 一张真实网卡都没有:虚拟网卡也得给 —— 不能一个地址都不报。
226
+ pool = virtual
227
+ }
228
+ if priv := filterByPrivate(pool); len(priv) > 0 {
229
+ pool = priv
230
+ }
231
+ out := make([]string, 0, len(pool))
232
+ for _, c := range pool {
233
+ if c.ip == primary {
234
+ out = append(out, fmt.Sprintf("%s:%d", c.ip, port))
235
+ }
236
+ }
237
+ for _, c := range pool {
238
+ if c.ip != primary {
239
+ out = append(out, fmt.Sprintf("%s:%d", c.ip, port))
240
+ }
241
+ }
242
+ return out
243
+ }
244
+
245
+ // filterByPrivate 只留私有网段(RFC1918)的候选。
246
+ func filterByPrivate(cands []lanCandidate) []lanCandidate {
247
+ out := make([]lanCandidate, 0, len(cands))
248
+ for _, c := range cands {
249
+ if isPrivateIPv4(c.ip) {
250
+ out = append(out, c)
65
251
  }
66
252
  }
67
253
  return out
@@ -1,6 +1,7 @@
1
1
  package client
2
2
 
3
3
  import (
4
+ "net"
4
5
  "strings"
5
6
  "testing"
6
7
  )
@@ -51,3 +52,112 @@ func TestLanAddresses(t *testing.T) {
51
52
  // 本机可能只连着回环(离线环境),所以不要求非空,只在有结果时做上面的形状断言。
52
53
  t.Logf("局域网地址:%v", got)
53
54
  }
55
+
56
+ // TestIsVirtualIface 虚拟网卡的识别。
57
+ //
58
+ // 回归背景(2026-10-23):一台装了企业 VPN + Hyper-V 的机器上,网卡枚举里
59
+ // 排最前的是 VPN 虚拟网卡,手机拿到它就连不上 —— 详见 lan.go 那段注释。
60
+ func TestIsVirtualIface(t *testing.T) {
61
+ cases := []struct {
62
+ name string
63
+ want bool
64
+ }{
65
+ {"以太网", false},
66
+ {"Ethernet", false},
67
+ {"WLAN", false},
68
+ {"Wi-Fi", false},
69
+ {"本地连接", false}, // Windows 上真实网卡也可能是中文名
70
+ {"vEthernet (Default Switch)", true},
71
+ {"Hyper-V Virtual Ethernet Adapter", true},
72
+ {"VMware Network Adapter VMnet8", true},
73
+ {"docker0", true},
74
+ {"veth1234", true},
75
+ {"virbr0", true},
76
+ {"br-abcdef", true},
77
+ {"utun3", true},
78
+ {"tailscale0", true},
79
+ {"wg0", true},
80
+ {"Loopback Pseudo-Interface 1", true},
81
+ {" 以太网 ", false}, // 前后空格不算
82
+ }
83
+ for _, tc := range cases {
84
+ if got := isVirtualIface(tc.name); got != tc.want {
85
+ t.Errorf("isVirtualIface(%q) = %v,want %v", tc.name, got, tc.want)
86
+ }
87
+ }
88
+ }
89
+
90
+ // TestIsPrivateIPv4 RFC1918 判定。
91
+ func TestIsPrivateIPv4(t *testing.T) {
92
+ cases := []struct {
93
+ ip string
94
+ want bool
95
+ }{
96
+ {"192.168.31.37", true},
97
+ {"10.0.0.1", true},
98
+ {"172.16.0.1", true},
99
+ {"172.31.255.254", true},
100
+ {"172.32.0.1", false},
101
+ {"172.15.0.1", false},
102
+ {"2.0.0.1", false}, // 企业 VPN 虚拟网卡占的公网段
103
+ {"8.8.8.8", false},
104
+ {"", false},
105
+ }
106
+ for _, tc := range cases {
107
+ if got := isPrivateIPv4(tc.ip); got != tc.want {
108
+ t.Errorf("isPrivateIPv4(%q) = %v,want %v", tc.ip, got, tc.want)
109
+ }
110
+ }
111
+ }
112
+
113
+ // TestOrderLanAddresses 排序:手机最可能连上的那个必须排第一。
114
+ //
115
+ // 回归背景(2026-10-23):真实机器上枚举顺序是
116
+ //
117
+ // [0] 2.0.0.1 ← Sangfor aTrust VNIC(企业 VPN 虚拟网卡)
118
+ // [1] 172.29.64.1 ← Hyper-V 虚拟交换机
119
+ // [2] 192.168.31.37 ← 以太网(手机能连的只有这个)
120
+ //
121
+ // 移动端只取 lanUrls[0],于是「公司远程」环境被种成了 2.0.0.1 —— 手机永远连不上。
122
+ func TestOrderLanAddresses(t *testing.T) {
123
+ cands := []lanCandidate{
124
+ {ip: "2.0.0.1", iface: net.Interface{Name: "本地连接"}}, // VPN 虚拟网卡(Windows 上是中文别名)
125
+ {ip: "172.29.64.1", iface: net.Interface{Name: "vEthernet (Default Switch)"}}, // Hyper-V
126
+ {ip: "192.168.31.37", iface: net.Interface{Name: "以太网"}},
127
+ }
128
+ got := orderLanAddresses(cands, "192.168.31.37", 9100)
129
+ if len(got) == 0 {
130
+ t.Fatal("不该返回空")
131
+ }
132
+ if got[0] != "192.168.31.37:9100" {
133
+ t.Errorf("第一个必须是手机连得上的那个(192.168.31.37),got %v", got)
134
+ }
135
+ for _, a := range got {
136
+ if strings.HasPrefix(a, "2.0.0.1:") || strings.HasPrefix(a, "172.29.") {
137
+ t.Errorf("虚拟网卡地址不该出现在列表里:%s", a)
138
+ }
139
+ }
140
+ }
141
+
142
+ // TestOrderLanAddressesVirtualOnly 只有虚拟网卡时也得给地址 —— 不能一个都不报。
143
+ func TestOrderLanAddressesVirtualOnly(t *testing.T) {
144
+ cands := []lanCandidate{
145
+ {ip: "172.29.64.1", iface: net.Interface{Name: "vEthernet (Default Switch)"}},
146
+ }
147
+ got := orderLanAddresses(cands, "", 9100)
148
+ if len(got) != 1 || got[0] != "172.29.64.1:9100" {
149
+ t.Errorf("没有真实网卡时应退回报虚拟网卡地址,got %v", got)
150
+ }
151
+ }
152
+
153
+ // TestOrderLanAddressesPrimaryFirst 出站主地址在同一档里排最前。
154
+ func TestOrderLanAddressesPrimaryFirst(t *testing.T) {
155
+ cands := []lanCandidate{
156
+ {ip: "192.168.31.10", iface: net.Interface{Name: "以太网 2"}},
157
+ {ip: "192.168.31.37", iface: net.Interface{Name: "以太网"}},
158
+ }
159
+ got := orderLanAddresses(cands, "192.168.31.37", 9100)
160
+ if len(got) != 2 || got[0] != "192.168.31.37:9100" {
161
+ t.Errorf("出站主地址应排最前,got %v", got)
162
+ }
163
+ }
@@ -80,6 +80,14 @@ type Options struct {
80
80
  // 为什么可配:UA 会决定站点给哪一版页面。中国大陆出口 + 移动 UA 时,Google 会跳到
81
81
  // http://www.google.cn/m(该路径本身就是 404);换成桌面 UA 则走香港跳转、可正常打开。
82
82
  BrowseUA string
83
+ // AllowAnyOrigin 对**任意 Origin** 回 CORS 头(2026-10-23)。
84
+ //
85
+ // 默认只放行「本机页面」(见 withCORS):APK / 桌面壳这些容器源的 Origin 各不相同,
86
+ // 自托管部署的入口也不止本机 —— 只放行 loopback 时,从别的设备 / 别的源打开的页面
87
+ // 会被浏览器判成跨域失败(用户侧就是「只能本地访问」)。
88
+ // 打开它 = 任意网页都能**读**响应;真正的门锁仍是令牌(没有 X-Magic-Token 一律 401)。
89
+ // `--lan` 时默认开启(都绑 0.0.0.0 了,再按 loopback 卡 Origin 没有意义)。
90
+ AllowAnyOrigin bool
83
91
  }
84
92
 
85
93
  // Service 本地服务。
@@ -98,6 +106,9 @@ type Service struct {
98
106
  // jobs 服务端作业队列(同一 workspace 串行 + 幂等 + 持久化),见 jobs.go。
99
107
  jobs *jobQueue
100
108
 
109
+ // inst 服务实例的稳定 id(见 instance.go):同一台桌面的多个入口靠它归一。
110
+ inst instanceIDCache
111
+
101
112
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
102
113
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
103
114
  browseOnce sync.Once
@@ -309,7 +320,7 @@ func (s *Service) withCORS(next http.Handler) http.Handler {
309
320
  if origin != "" {
310
321
  // Origin 会随请求方变,不加 Vary 会把一个源的响应喂给另一个源。
311
322
  w.Header().Add("Vary", "Origin")
312
- if localOrigin(origin) {
323
+ if s.opts.AllowAnyOrigin || localOrigin(origin) {
313
324
  w.Header().Set("Access-Control-Allow-Origin", origin)
314
325
  /* ⚠️ 写方法必须点名(2026-10-23 用户:「删除无法删除」)。
315
326
  只写 GET/POST 时,容器里(源=https://localhost)的删除 / 重命名 / 置顶同步
@@ -415,6 +426,10 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
415
426
  // 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
416
427
  // 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
417
428
  "jobs": true,
429
+ // 服务实例的稳定 id(2026-10-23,只增字段):同一台桌面的本地 / 局域网 / 公网
430
+ // 入口可能有多条 URL,客户端据此把它们归一成同一个环境(队列与设置共用)。
431
+ // 老插件没有该字段 → 客户端退回按连接各自算环境。
432
+ "instanceId": s.instanceID(),
418
433
  // 远程移动端界面在不在(见 handleMobileUI)。
419
434
  // 客户端的闸门页要据此决定「跳远程 /m/ 还是用包里自带的 ui.html」。
420
435
  // 为什么不放在 features 里:features 是**core 的能力位**(s.contract.Features),
@@ -71,11 +71,13 @@ const hostedContract = `{"contractVersion":2,"features":["feature.session.events
71
71
 
72
72
  func newTestService(t *testing.T, core string) *Service {
73
73
  t.Helper()
74
- /* 测试互不干扰:作业目录放临时目录;并发上限放大(假 core 不退出,不放大就会把
75
- 同一进程里后续用例堵在队列里)。串行语义由 TestJobs* 用独立服务端覆盖。 */
74
+ /* 测试互不干扰:作业目录 / 实例 id 都放临时目录;并发上限放大(假 core 不退出,
75
+ 不放大就会把同一进程里后续用例堵在队列里)。串行语义由 TestJobs* 用独立服务端覆盖。 */
76
76
  t.Setenv("MAGIC_CLIENT_JOBS_DIR", t.TempDir())
77
77
  t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "64")
78
- svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
78
+ prof := DefaultProfile()
79
+ prof.Storage.Dir = t.TempDir()
80
+ svc, err := New(Options{MagicAgent: core, Profile: prof, Token: "tok"})
79
81
  if err != nil {
80
82
  t.Fatalf("New: %v", err)
81
83
  }
@@ -104,6 +104,29 @@ func HasSessionLog(dir, id string) bool {
104
104
  return err == nil && st.Size() > 0
105
105
  }
106
106
 
107
+ // HasSessionLogIn 判断**给定候选 id** 里有没有可回放的日志(文件在且非空)。
108
+ //
109
+ // 与 HasSessionLog 的区别:**不回查登记表**。调用方(列表页)手里已经有这条记录的
110
+ // session_id / run_id,两个都试一遍就够了;而 HasSessionLog 会走
111
+ // ResolveLogID → Find → List,那是「把全部登记记录读一遍」——
112
+ // 逐行调用就是 O(N²) 次文件读。2026-10-23 实测:登记表 760 条时
113
+ // `magic-agent --sessions` 卡死数分钟、CPU 打满,移动端列表永远停在「读取中…」。
114
+ func HasSessionLogIn(dir string, ids ...string) bool {
115
+ if dir == "" {
116
+ return false
117
+ }
118
+ for _, id := range ids {
119
+ id = strings.TrimSpace(id)
120
+ if id == "" {
121
+ continue
122
+ }
123
+ if st, err := os.Stat(logPath(dir, id)); err == nil && st.Size() > 0 {
124
+ return true
125
+ }
126
+ }
127
+ return false
128
+ }
129
+
107
130
  func fileExists(p string) bool {
108
131
  st, err := os.Stat(p)
109
132
  return err == nil && !st.IsDir()
@@ -623,3 +623,42 @@ func writeRecordForTest(t *testing.T, dir string, rec Record) {
623
623
  t.Fatal(err)
624
624
  }
625
625
  }
626
+
627
+ // TestHasSessionLogIn 列表页用的「按候选 id 直接查日志」:不查登记表。
628
+ //
629
+ // 为什么要单独一个函数(2026-10-23):`--sessions` 原先逐行调 HasSessionLog,
630
+ // 而它内部走 ResolveLogID → Find → List,等于**每行都把全部登记记录读一遍**。
631
+ // 本机登记表涨到 760 条后,整条命令卡死数分钟、CPU 打满,移动端列表永远停在
632
+ // 「读取中…」。这里钉住新函数的行为:两个 id 都认、目录为空即 false、绝不碰登记表。
633
+ func TestHasSessionLogIn(t *testing.T) {
634
+ dir := t.TempDir()
635
+ if err := os.WriteFile(filepath.Join(dir, "sess-a.jsonl"),
636
+ []byte(`{"seq":1,"kind":"text","text":"a"}`+"\n"), 0o600); err != nil {
637
+ t.Fatal(err)
638
+ }
639
+ // 空文件不算有历史(与 HasSessionLog 同口径)。
640
+ if err := os.WriteFile(filepath.Join(dir, "sess-empty.jsonl"), nil, 0o600); err != nil {
641
+ t.Fatal(err)
642
+ }
643
+
644
+ if !HasSessionLogIn(dir, "sess-a") {
645
+ t.Error("按 session_id 应命中")
646
+ }
647
+ if !HasSessionLogIn(dir, "", "sess-a") {
648
+ t.Error("候选里有一个命中就算命中(空串跳过)")
649
+ }
650
+ if HasSessionLogIn(dir, "sess-empty") {
651
+ t.Error("空文件不算有可回放历史")
652
+ }
653
+ if HasSessionLogIn(dir, "nope", "") {
654
+ t.Error("都没有时应为 false")
655
+ }
656
+ if HasSessionLogIn("", "sess-a") {
657
+ t.Error("dir 为空应直接 false(不能去猜默认目录)")
658
+ }
659
+ // 关键:本函数**不依赖**登记表 —— 即使把 EnvDir 指到空目录也必须照常工作。
660
+ t.Setenv(EnvDir, t.TempDir())
661
+ if !HasSessionLogIn(dir, "sess-a") {
662
+ t.Error("HasSessionLogIn 不该依赖登记表目录")
663
+ }
664
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.3.9",
3
- "versionCode": 20309,
4
- "builtAt": "2026-10-23T11:12:09.067Z"
2
+ "version": "2.4.2",
3
+ "versionCode": 20402,
4
+ "builtAt": "2026-10-23T12:24:54.000Z"
5
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": "2.3.9",
3
+ "version": "2.4.3",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {