magic-agent 2.3.9 → 2.4.2

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.
@@ -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。
@@ -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
+ }
@@ -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
  }
@@ -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-23T11:49:45.889Z"
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.2",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {