magic-agent 2.4.2 → 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
  需要服务端与令牌存储,**未做**。
@@ -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`),
@@ -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
+ }
@@ -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
2
  "version": "2.4.2",
3
3
  "versionCode": 20402,
4
- "builtAt": "2026-10-23T11:49:45.889Z"
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.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {