magic-agent 2.6.0 → 2.6.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.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -0,0 +1,184 @@
1
+ package agent
2
+
3
+ // dsh_caps_cache_test.go - dsh 能力探测的**磁盘**缓存(2026-10-26)。
4
+ //
5
+ // 为什么单测必须钉住这件事:dshCapsCache 是进程内 sync.Map,而 magic-agent
6
+ // 每轮 ask 都是新进程 —— 没有磁盘那层,「一进程一次」实际是「一轮一次」,
7
+ // 每轮续接白等 7~9 秒(实测 dsh 0.2.0-rc.2 的 --profile headless --help)。
8
+ // 这类「看着有缓存、其实每轮都在重探」的回归用眼睛看不出来,只能靠计数。
9
+
10
+ import (
11
+ "context"
12
+ "os"
13
+ "path/filepath"
14
+ "strings"
15
+ "sync"
16
+ "testing"
17
+ "time"
18
+ )
19
+
20
+ // fakeDshCapsBin 造一个假 dsh:把每次被调用的参数记进 calls 文件,并回 help 文本。
21
+ //
22
+ // helpPatch=false 模拟老版本(顶层 --help 里没有 --patch)。
23
+ func fakeDshCapsBin(t *testing.T, helpPatch bool) (bin, callsPath string) {
24
+ t.Helper()
25
+ dir := t.TempDir()
26
+ bin = filepath.Join(dir, "dsh")
27
+ callsPath = filepath.Join(dir, "calls.txt")
28
+ patchLine := ""
29
+ if helpPatch {
30
+ patchLine = `echo " --patch <path> apply a patch file"`
31
+ }
32
+ script := `#!/bin/sh
33
+ echo "$@" >> ` + callsPath + `
34
+ case "$1" in
35
+ --help)
36
+ echo "Usage: dsh [--profile <name>] [--patch <path>] <prompt>"
37
+ ` + patchLine + `
38
+ ;;
39
+ --profile)
40
+ echo "Usage: dsh-headless [--session-id <id>] [--json] <prompt>"
41
+ ;;
42
+ esac
43
+ exit 0
44
+ `
45
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
46
+ t.Fatalf("write fake dsh: %v", err)
47
+ }
48
+ return bin, callsPath
49
+ }
50
+
51
+ // countCalls 读假 dsh 被调用的次数。
52
+ func countCalls(t *testing.T, callsPath string) int {
53
+ t.Helper()
54
+ b, err := os.ReadFile(callsPath)
55
+ if err != nil {
56
+ return 0
57
+ }
58
+ return len(strings.Split(strings.TrimSpace(string(b)), "\n"))
59
+ }
60
+
61
+ // resetDshCapsCache 清掉进程内缓存(每个用例都从「冷进程」起)。
62
+ func resetDshCapsCache() {
63
+ dshCapsCache = sync.Map{}
64
+ }
65
+
66
+ // TestDshCapsDiskCacheAvoidsSecondProbe 第二轮(新进程语义)不该再起子进程。
67
+ //
68
+ // 这是本改动的**核心断言**:把进程内缓存清掉再问一次,如果还是去起进程,
69
+ // 说明磁盘那层没生效 —— 用户侧表现就是每轮续接多等 7~9 秒。
70
+ func TestDshCapsDiskCacheAvoidsSecondProbe(t *testing.T) {
71
+ cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
72
+ t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
73
+
74
+ bin, callsPath := fakeDshCapsBin(t, true)
75
+ resetDshCapsCache()
76
+
77
+ ctx := context.Background()
78
+ first := dshHeadlessCapabilities(ctx, bin)
79
+ if got := countCalls(t, callsPath); got != 2 {
80
+ t.Fatalf("首轮应探测 2 次(headless help + 顶层 help),实际 %d 次", got)
81
+ }
82
+ if !first.SessionID || !first.JSON || !first.Patch {
83
+ t.Fatalf("首轮 caps = %+v,期望三项都为 true", first)
84
+ }
85
+
86
+ // 模拟「下一个 core 进程」:进程内缓存全清,只剩磁盘。
87
+ resetDshCapsCache()
88
+ second := dshHeadlessCapabilities(ctx, bin)
89
+ if got := countCalls(t, callsPath); got != 2 {
90
+ t.Errorf("第二轮不该再起子进程,调用次数仍是 %d(磁盘缓存没命中)", got)
91
+ }
92
+ if second != first {
93
+ t.Errorf("磁盘缓存读回 %+v,与首次探测 %+v 不一致", second, first)
94
+ }
95
+ }
96
+
97
+ // TestDshCapsDiskCacheInvalidatedByBinChange dsh 换了二进制(指纹变)就必须重探。
98
+ //
99
+ // 反过来的风险更严重:升级 dsh 后还拿老结论,续接会被判成「不支持 --session-id」
100
+ // 而直接报错,或者更糟 —— 静默按老形态拼参数。
101
+ func TestDshCapsDiskCacheInvalidatedByBinChange(t *testing.T) {
102
+ cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
103
+ t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
104
+
105
+ // 第一版:顶层 --help 里有 --patch。
106
+ bin, callsPath := fakeDshCapsBin(t, true)
107
+ resetDshCapsCache()
108
+ if !dshHeadlessCapabilities(context.Background(), bin).Patch {
109
+ t.Fatal("第一版应探测到 --patch")
110
+ }
111
+
112
+ // 换内容并确保 mtime 确实变化(同一纳秒内重写会让指纹看不出差别)。
113
+ time.Sleep(20 * time.Millisecond)
114
+ if err := os.WriteFile(bin, []byte(`#!/bin/sh
115
+ echo "$@" >> `+callsPath+`
116
+ case "$1" in
117
+ --help) echo "Usage: dsh [--profile <name>] <prompt>" ;;
118
+ --profile) echo "Usage: dsh-headless [--session-id <id>] [--json] <prompt>" ;;
119
+ esac
120
+ exit 0
121
+ `), 0o755); err != nil {
122
+ t.Fatalf("rewrite fake dsh: %v", err)
123
+ }
124
+ before := countCalls(t, callsPath)
125
+ resetDshCapsCache()
126
+
127
+ caps := dshHeadlessCapabilities(context.Background(), bin)
128
+ if countCalls(t, callsPath) == before {
129
+ t.Error("二进制变了却没重探(指纹判据失效)")
130
+ }
131
+ if caps.Patch {
132
+ t.Error("新版本没有 --patch,重探后不该还是 true")
133
+ }
134
+ if !caps.SessionID || !caps.JSON {
135
+ t.Errorf("caps = %+v,--session-id / --json 应仍为 true", caps)
136
+ }
137
+ }
138
+
139
+ // TestDshCapsCanceledProbeNotCached 探测被取消时不落盘。
140
+ //
141
+ // 为什么值得一条用例:探测中途超时只拿到半截输出,按「探测不到不等于不支持」
142
+ // 的约定会保留乐观默认值。那本来是对的 —— 但**不能把它写进磁盘**,否则
143
+ // 一次抖动会让后续每一轮都跳过真正的探测。
144
+ func TestDshCapsCanceledProbeNotCached(t *testing.T) {
145
+ cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
146
+ t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
147
+
148
+ dir := t.TempDir()
149
+ bin := filepath.Join(dir, "dsh-slow")
150
+ // 故意慢:探测的 ctx 已经取消了,这个脚本还在睡。
151
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\nsleep 5\nexit 0\n"), 0o755); err != nil {
152
+ t.Fatalf("write slow fake dsh: %v", err)
153
+ }
154
+ resetDshCapsCache()
155
+
156
+ ctx, cancel := context.WithCancel(context.Background())
157
+ cancel() // 进函数时就是取消态
158
+ _ = dshHeadlessCapabilities(ctx, bin)
159
+
160
+ if b, err := os.ReadFile(cachePath); err == nil && len(b) > 0 {
161
+ t.Errorf("取消态下不该落盘,却写入了:%s", string(b))
162
+ }
163
+ }
164
+
165
+ // TestDshBinFingerprintChangesWithSize 指纹同时看 mtime 和大小。
166
+ func TestDshBinFingerprintChangesWithSize(t *testing.T) {
167
+ p := filepath.Join(t.TempDir(), "f")
168
+ if err := os.WriteFile(p, []byte("a"), 0o644); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ fp1 := dshBinFingerprint(p)
172
+ if fp1 == "" {
173
+ t.Fatal("指纹为空")
174
+ }
175
+ if err := os.WriteFile(p, []byte("abcdef"), 0o644); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ if fp2 := dshBinFingerprint(p); fp2 == fp1 {
179
+ t.Error("文件大小变了,指纹却没变")
180
+ }
181
+ if got := dshBinFingerprint(filepath.Join(t.TempDir(), "missing")); got != "" {
182
+ t.Errorf("不存在的文件指纹应为空,得到 %q", got)
183
+ }
184
+ }
@@ -164,21 +164,154 @@ type dshHeadlessCaps struct {
164
164
  Patch bool
165
165
  }
166
166
 
167
- var dshCapsCache sync.Map // bin → dshHeadlessCaps
167
+ var dshCapsCache sync.Map // 缓存 key → dshHeadlessCaps
168
168
 
169
- // dshHeadlessCapabilities 探测这台 dsh 支持哪些 headless flag(按 bin 路径缓存)。
169
+ // dshCapsCachePath 能力探测结果的**磁盘**缓存(2026-10-26)。
170
+ //
171
+ // 为什么必须有磁盘这一层:dshCapsCache 是进程内的 sync.Map,而 magic-agent 每次
172
+ // ask 都是**新进程**(客户端 spawn 一个 core 跑一轮)。于是「一进程一次」实际是
173
+ // 「一轮一次」—— 每轮续接都要重跑两条 --help。
174
+ //
175
+ // 实测代价(2026-10-26,dsh 0.2.0-rc.2):
176
+ //
177
+ // dsh --profile headless --help 7.09s / 9.01s / 7.66s(本机环境变量齐全时)
178
+ // 1.31s(干净 env)
179
+ // dsh --help 0.28s
180
+ //
181
+ // 也就是说**每一轮续接白等 7~9 秒**,而且这段是在 turn/start 之前,用户看到的就是
182
+ // 「发出去半天没动静」。本机实测一轮的 7.09s 空窗与此**完全吻合**。
183
+ //
184
+ // 缓存失效判据是 dsh 二进制的指纹(mtime + size):升级/替换 dsh 后指纹变,自动重探。
185
+ // 不用 `--version` 取版本号当 key —— 那要额外起一次进程(0.3s),而 stat 是零成本,
186
+ // 且「换了二进制」正是唯一需要重探的时机。
187
+ func dshCapsCachePath() string {
188
+ if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_DSH_CAPS_CACHE")); p != "" {
189
+ return p
190
+ }
191
+ home, err := os.UserHomeDir()
192
+ if err != nil || home == "" {
193
+ return ""
194
+ }
195
+ return filepath.Join(home, ".magic-agent", "dsh-caps.json")
196
+ }
197
+
198
+ // dshBinFingerprint dsh 二进制的身份(mtime 纳秒 + 大小)。取不到 → 空串(不缓存)。
199
+ func dshBinFingerprint(bin string) string {
200
+ st, err := os.Stat(bin)
201
+ if err != nil || st.IsDir() {
202
+ return ""
203
+ }
204
+ return fmt.Sprintf("%d-%d", st.ModTime().UnixNano(), st.Size())
205
+ }
206
+
207
+ // dshCapsDiskEntry 磁盘缓存的一条记录。
208
+ type dshCapsDiskEntry struct {
209
+ Bin string `json:"bin"`
210
+ Fingerprint string `json:"fingerprint"`
211
+ SessionID bool `json:"sessionId"`
212
+ JSON bool `json:"json"`
213
+ Patch bool `json:"patch"`
214
+ ProbedAt string `json:"probedAt,omitempty"`
215
+ }
216
+
217
+ // dshCapsDiskFile 磁盘缓存文件(按 bin 路径分条,多份 dsh 并存也各自有效)。
218
+ type dshCapsDiskFile struct {
219
+ Version int `json:"version"`
220
+ Entries map[string]dshCapsDiskEntry `json:"entries"`
221
+ }
222
+
223
+ func dshLoadCapsFromDisk(bin, fp string) (dshHeadlessCaps, bool) {
224
+ path := dshCapsCachePath()
225
+ if path == "" || fp == "" {
226
+ return dshHeadlessCaps{}, false
227
+ }
228
+ b, err := os.ReadFile(path)
229
+ if err != nil || len(b) == 0 {
230
+ return dshHeadlessCaps{}, false
231
+ }
232
+ var f dshCapsDiskFile
233
+ if err := json.Unmarshal(b, &f); err != nil {
234
+ return dshHeadlessCaps{}, false
235
+ }
236
+ e, ok := f.Entries[bin]
237
+ if !ok || e.Fingerprint != fp {
238
+ return dshHeadlessCaps{}, false
239
+ }
240
+ return dshHeadlessCaps{SessionID: e.SessionID, JSON: e.JSON, Patch: e.Patch}, true
241
+ }
242
+
243
+ // dshStoreCapsToDisk 写回一条。失败静默 —— 缓存只是提速手段,写不动不能让调用失败。
244
+ func dshStoreCapsToDisk(bin, fp string, caps dshHeadlessCaps) {
245
+ path := dshCapsCachePath()
246
+ if path == "" || fp == "" {
247
+ return
248
+ }
249
+ var f dshCapsDiskFile
250
+ if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
251
+ _ = json.Unmarshal(b, &f)
252
+ }
253
+ if f.Entries == nil {
254
+ f.Entries = map[string]dshCapsDiskEntry{}
255
+ }
256
+ f.Version = 1
257
+ /* 顺手清掉**已经不存在的二进制**留下的条目。
258
+ 不清理的话这个文件会只增不减 —— 测试里的临时 dsh、卸载过的旧版本、换过的
259
+ node 版本路径都会一直留着(实测跑一轮全量测试就塞进十几条 /var/folders 下的
260
+ 临时路径)。每条都要 stat 一次,所以放在写回时才做,读路径上不碰。 */
261
+ for k := range f.Entries {
262
+ if strings.TrimSpace(k) == "" {
263
+ delete(f.Entries, k)
264
+ continue
265
+ }
266
+ if st, err := os.Stat(k); err != nil || st.IsDir() {
267
+ delete(f.Entries, k)
268
+ }
269
+ }
270
+ f.Entries[bin] = dshCapsDiskEntry{
271
+ Bin: bin,
272
+ Fingerprint: fp,
273
+ SessionID: caps.SessionID,
274
+ JSON: caps.JSON,
275
+ Patch: caps.Patch,
276
+ ProbedAt: time.Now().Format(time.RFC3339),
277
+ }
278
+ if dir := filepath.Dir(path); dir != "" {
279
+ _ = os.MkdirAll(dir, 0o700)
280
+ }
281
+ out, err := json.MarshalIndent(f, "", " ")
282
+ if err != nil {
283
+ return
284
+ }
285
+ tmp := path + ".tmp"
286
+ if err := os.WriteFile(tmp, append(out, '\n'), 0o600); err != nil {
287
+ return
288
+ }
289
+ _ = os.Rename(tmp, path)
290
+ }
291
+
292
+ // dshHeadlessCapabilities 探测这台 dsh 支持哪些 headless flag。
293
+ //
294
+ // 三级缓存:进程内 sync.Map → 磁盘(按二进制指纹校验)→ 真起进程探。
295
+ // 见 dshCapsCachePath 的注释:没有磁盘这层,等于每轮续接白等 7~9 秒。
170
296
  //
171
297
  // 为什么必须探测:dsh 启动器在**第一个它不认识的 token 处结束**解析,剩下的都当
172
298
  // app 参数交给 headless —— 老版本不认这俩 flag 时,它们会被拼进**任务文本**
173
- // (静默改写提示词),比直接报错难查得多。探测代价约一次 `--help`,一进程一次。
299
+ // (静默改写提示词),比直接报错难查得多。
174
300
  //
175
301
  // ⚠️ 只有在输出**确实是 headless 的 help**(同时含 "Usage:" 与 "--profile")时才据它判断;
176
302
  // 否则(--help 跑不起来、被包装脚本吞掉、输出被截断)一律按「都支持」处理 ——
177
303
  // 「探测不到」不等于「不支持」,宁可让现代 dsh 正常工作,也不因一次探测抖动把续接判死。
178
304
  func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
179
- if v, ok := dshCapsCache.Load(bin); ok {
305
+ fp := dshBinFingerprint(bin)
306
+ key := bin + "|" + fp
307
+ if v, ok := dshCapsCache.Load(key); ok {
180
308
  return v.(dshHeadlessCaps)
181
309
  }
310
+ // 磁盘命中:连一次子进程都不用起。
311
+ if caps, ok := dshLoadCapsFromDisk(bin, fp); ok {
312
+ dshCapsCache.Store(key, caps)
313
+ return caps
314
+ }
182
315
  caps := dshHeadlessCaps{SessionID: true, JSON: true, Patch: true}
183
316
  if stdout, stderr, err := runCLI(ctx, bin, "--profile", "headless", "--help"); err == nil {
184
317
  help := stdout + stderr
@@ -195,7 +328,13 @@ func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
195
328
  caps.Patch = strings.Contains(help, "--patch")
196
329
  }
197
330
  }
198
- dshCapsCache.Store(bin, caps)
331
+ // ⚠️ 探测**中途被取消 / 超时**时不落盘:那两秒可能连 help 都没跑完,
332
+ // 存下去等于把「没探到」永久固化成「都支持」—— 后续每轮都照着错的判断走,
333
+ // 而用户再也不会遇到一次会纠正它的探测。落盘的必须是完整探完的结果。
334
+ if ctx.Err() == nil && fp != "" {
335
+ dshStoreCapsToDisk(bin, fp, caps)
336
+ }
337
+ dshCapsCache.Store(key, caps)
199
338
  return caps
200
339
  }
201
340
 
@@ -6,8 +6,10 @@ package agent
6
6
  // 「只能属于父进程」的变量。见 childEnvDenylist。
7
7
 
8
8
  import (
9
+ "net"
9
10
  "os"
10
11
  "strings"
12
+ "time"
11
13
  )
12
14
 
13
15
  // serverEnvPrefix 剔除的前缀:父会话内置 HTTP 服务的监听地址。
@@ -98,24 +100,194 @@ var envDenylist = map[string]bool{
98
100
  // - CODEBUDDY_CONFIG_DIR / CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION /
99
101
  // CODEBUDDY_SAFE_DELETE_BULK_GUARD —— 宿主配置/沙箱细节,claude/codebuddy
100
102
  // 不需要。
103
+ // Electron 的「拿 Electron 当 node 跑」开关。宿主应用(WorkBuddy)把它设成 1,
104
+ // 子进程继承后,任何 Electron 可执行文件都会以 node 模式启动 —— 引擎 CLI 不需要
105
+ // 这个身份,留着只会把宿主的启动链路带进来(2026-10-26 实测,见下方 NODE_OPTIONS)。
106
+ "ELECTRON_RUN_AS_NODE": true,
107
+
101
108
  "CODEBUDDY_MCP_CONFIG": true,
102
109
  "CODEBUDDY_CONFIG_DIR": true,
103
110
  "CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION": true,
104
111
  "CODEBUDDY_SAFE_DELETE_BULK_GUARD": true,
105
112
  }
106
113
 
114
+ // hostNodeShim 判断一条 NODE_OPTIONS 是否是**宿主 Electron 应用**注入的垫片。
115
+ //
116
+ // 2026-10-26 实测:从 WorkBuddy 的终端里重启 magic-client,父进程环境带着
117
+ //
118
+ // NODE_OPTIONS=--require=".../WorkBuddy.app/Contents/Resources/app.asar.unpacked/
119
+ // cli/vendor/shim/node-language-shim.cjs"
120
+ // ELECTRON_RUN_AS_NODE=1
121
+ //
122
+ // 于是**每一个**引擎子进程(node 系的 dsh/codex/claude,连 llm 的 node 包装)启动时
123
+ // 都被迫加载那个 shim;shim 要去连 WorkBuddy 的 broker,而 broker 只在 WorkBuddy
124
+ // 运行时存在 —— 报 `connect failed: Connection refused`,任务全部 failed。
125
+ // 不是引擎坏了,是继承了一份不属于它的启动垫片。
126
+ //
127
+ // 只认「.asar / WorkBuddy」这两个特征:用户自己设的 `--max-old-space-size` 之类
128
+ // 照常保留,别误伤。
129
+ func hostNodeShim(val string) bool {
130
+ v := strings.ToLower(val)
131
+ return strings.Contains(v, ".asar") || strings.Contains(v, "workbuddy")
132
+ }
133
+
134
+ // hostShimDir 判断 PATH 里的一段是不是宿主 Electron 应用塞进来的「假命令」目录。
135
+ //
136
+ // 2026-10-26 真机实测(比 NODE_OPTIONS 更隐蔽的那个):从 WorkBuddy 的终端起
137
+ // magic-client,继承到的 PATH **最前面**是
138
+ //
139
+ // /Applications/WorkBuddy.app/.../cli/vendor/shim/brokered-bin
140
+ //
141
+ // 里面有 29 个被替换掉的基础命令(cat / cp / dd / chmod / 7z …)。命中它们之后,
142
+ // 那个 shell 脚本会 require `node-brokered-fs-shim.cjs`,去连 WorkBuddy 的 broker;
143
+ // broker 只在 WorkBuddy 运行时存在 —— 于是引擎一调 `cat` 就
144
+ // `connect failed: Connection refused`,整条任务 failed。
145
+ //
146
+ // 只认 `.asar` 与 `brokered-bin` 两个特征,用户自己的 PATH 顺序原样保留。
147
+ func hostShimDir(seg string) bool {
148
+ v := strings.ToLower(seg)
149
+ return strings.Contains(v, ".asar") || strings.Contains(v, "brokered-bin")
150
+ }
151
+
152
+ // stripHostShimPath 从 PATH 值里摘掉宿主垫片目录,其余段与顺序不动。
153
+ func stripHostShimPath(val string) string {
154
+ parts := strings.Split(val, string(os.PathListSeparator))
155
+ kept := parts[:0]
156
+ for _, p := range parts {
157
+ if p == "" || hostShimDir(p) {
158
+ continue
159
+ }
160
+ kept = append(kept, p)
161
+ }
162
+ return strings.Join(kept, string(os.PathListSeparator))
163
+ }
164
+
165
+ // proxyVarNames 参与「死端口」检查的代理变量名。
166
+ //
167
+ // 大小写两套都列:unix 上 HTTP_PROXY 与 http_proxy 是**两个不同的变量**,
168
+ // 而 Node / Go / curl 各读各的(Node 优先小写、Go 优先大写、curl 只读小写)
169
+ // —— 只清一套等于没清。
170
+ //
171
+ // NO_PROXY **不在列**:它是「绕过名单」而不是代理地址,按地址去探测没有意义。
172
+ var proxyVarNames = []string{
173
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
174
+ "http_proxy", "https_proxy", "all_proxy",
175
+ }
176
+
177
+ // proxyProbeTimeout 探测本机代理端口的上限。
178
+ //
179
+ // 回环地址只有两种结局:立刻连上,或立刻 ECONNREFUSED —— 不存在「慢慢超时」。
180
+ // 留 100ms 只为兜个别平台上 SYN 被丢弃的情况,别让探测本身变成新的延迟。
181
+ const proxyProbeTimeout = 100 * time.Millisecond
182
+
183
+ // loopbackProxyAddr 从一条代理变量值里取出 host:port;**不是本机回环**就返回空。
184
+ //
185
+ // 只认回环是刻意的保守选择:沙箱 / 隧道环境把出网流量转到 127.0.0.1:<会漂移的端口>,
186
+ // 进程启动时拿到的快照一旦过期就是这个状态(见 strippedProxyKeys 的实测数据)。
187
+ // 非回环(公司代理、远程 squid)**一律不碰** —— 那可能只是这一瞬间抖动,清掉会让
188
+ // 本来能出网的引擎彻底断网,代价远大于收益。
189
+ func loopbackProxyAddr(val string) string {
190
+ v := strings.TrimSpace(val)
191
+ if v == "" {
192
+ return ""
193
+ }
194
+ if i := strings.Index(v, "://"); i >= 0 { // 去 scheme:http:// https:// socks5://
195
+ v = v[i+3:]
196
+ }
197
+ if i := strings.IndexAny(v, "/?#"); i >= 0 { // 去 path / query
198
+ v = v[:i]
199
+ }
200
+ if i := strings.LastIndexByte(v, '@'); i >= 0 { // 去 user:pass@
201
+ v = v[i+1:]
202
+ }
203
+ host, port, err := net.SplitHostPort(v)
204
+ if err != nil || port == "" {
205
+ return ""
206
+ }
207
+ host = strings.Trim(host, "[]")
208
+ if !strings.EqualFold(host, "localhost") {
209
+ if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
210
+ return ""
211
+ }
212
+ }
213
+ return net.JoinHostPort(host, port)
214
+ }
215
+
216
+ // proxyAddrDead 本机 addr 上是否已经没人监听。
217
+ func proxyAddrDead(addr string) bool {
218
+ c, err := net.DialTimeout("tcp", addr, proxyProbeTimeout)
219
+ if err != nil {
220
+ return true
221
+ }
222
+ c.Close()
223
+ return false
224
+ }
225
+
226
+ // strippedProxyKeys 返回需要**剔除**的代理变量名(可能为空)。
227
+ //
228
+ // 2026-10-26 实测(本机 dsh 0.2.0-rc.2,同一句提示词,各跑两遍):
229
+ //
230
+ // 代理端口活着 → 首个输出 2.0~2.5s,内容正常
231
+ // 代理端口已死 → 首个输出 **15.4s / 16.7s**,内容是 dsh: TRANSPORT: Connection error.
232
+ // 清掉代理变量 → 首个输出 **2.0s / 2.1s**,内容正常
233
+ //
234
+ // 一次失败的代理连接要白等十几秒才报错 —— 这正是「会话发出去半天没响应」。
235
+ // 而沙箱代理端口随会话漂移(旧会话留下的进程快照必然过期),所以这个状态很常见。
236
+ // 15 秒 vs 至多一次 100ms 的探测,这个取舍没什么可犹豫的。
237
+ //
238
+ // 注意:**只有本机回环地址才会去探**,且同一地址在一次调用里只探一次。
239
+ func strippedProxyKeys() []string {
240
+ dead := map[string]bool{} // addr → 已确认没人监听
241
+ var out []string
242
+ for _, name := range proxyVarNames {
243
+ addr := loopbackProxyAddr(os.Getenv(name))
244
+ if addr == "" {
245
+ continue
246
+ }
247
+ v, seen := dead[addr]
248
+ if !seen {
249
+ v = proxyAddrDead(addr)
250
+ dead[addr] = v
251
+ }
252
+ if v {
253
+ out = append(out, name)
254
+ }
255
+ }
256
+ return out
257
+ }
258
+
107
259
  // environ 返回供子 CLI 继承的环境变量(已剔除父进程专属项)。
108
260
  func environ() []string {
109
261
  src := os.Environ()
110
262
  out := make([]string, 0, len(src))
263
+ /* 先算出「指向死端口的本机代理」,循环里直接不继承。
264
+ 子进程环境里就**不该出现**这个变量 —— 只补空值不行:空值虽然能压下
265
+ exec 的「重复 key 取后者」,但环境块里仍留着一条 `HTTP_PROXY=http://...`
266
+ 的原值,任何「先写入者胜」或自己拼 map 的读取方都还会看到死地址。 */
267
+ deadProxy := map[string]bool{}
268
+ for _, k := range strippedProxyKeys() {
269
+ deadProxy[k] = true
270
+ }
111
271
  for _, kv := range src {
112
272
  name, _, ok := strings.Cut(kv, "=")
113
273
  if !ok {
114
274
  continue
115
275
  }
116
- if envDenied(name) {
276
+ if envDenied(name) || deadProxy[name] {
117
277
  continue
118
278
  }
279
+ if name == "NODE_OPTIONS" && hostNodeShim(kv) {
280
+ continue
281
+ }
282
+ /* PATH 不能整条丢(引擎全靠它找 node/python),只摘掉宿主的假命令目录。 */
283
+ if name == "PATH" {
284
+ if _, v, ok := strings.Cut(kv, "="); ok {
285
+ if cleaned := stripHostShimPath(v); cleaned != v {
286
+ out = append(out, "PATH="+cleaned)
287
+ continue
288
+ }
289
+ }
290
+ }
119
291
  out = append(out, kv)
120
292
  }
121
293
  return out
@@ -9,6 +9,7 @@ package agent
9
9
  // 本文件锁定「SERVER__ 前缀必须被剔除」这一行为,防止回归。
10
10
 
11
11
  import (
12
+ "net"
12
13
  "os"
13
14
  "strings"
14
15
  "testing"
@@ -146,3 +147,116 @@ func TestEnvDenied(t *testing.T) {
146
147
  }
147
148
  }
148
149
  }
150
+
151
+ // ── 本机代理端口已死时的剔除(2026-10-26)─────────────────────────
152
+ //
153
+ // 背景:沙箱 / 隧道环境把出网流量转到 127.0.0.1:<会漂移的端口>,进程启动时
154
+ // 拿到的快照一旦过期,子 CLI 每次请求都要白等十几秒才报
155
+ // `dsh: TRANSPORT: Connection error.` —— 用户看到的就是「发出去半天没响应」。
156
+ // 实测:代理活着 2.0s、代理已死 15.4s、清掉代理 2.0s。
157
+
158
+ func TestLoopbackProxyAddr(t *testing.T) {
159
+ cases := []struct{ in, want string }{
160
+ {"http://127.0.0.1:54107", "127.0.0.1:54107"},
161
+ {"http://127.0.0.1:54107/", "127.0.0.1:54107"},
162
+ {"https://127.0.0.1:8080", "127.0.0.1:8080"},
163
+ {"socks5://127.0.0.1:1080", "127.0.0.1:1080"},
164
+ {"http://user:pass@127.0.0.1:3128", "127.0.0.1:3128"},
165
+ {"http://localhost:8080", "localhost:8080"},
166
+ {"http://[::1]:8080", "[::1]:8080"},
167
+ {"127.0.0.1:9999", "127.0.0.1:9999"},
168
+ // 非回环一律返回空:公司代理 / 远程 squid 不能因为一次抖动就断网。
169
+ {"http://proxy.corp.example:8080", ""},
170
+ {"http://10.0.0.1:8080", ""},
171
+ {"", ""},
172
+ {" ", ""},
173
+ {"http://127.0.0.1", ""}, // 没端口
174
+ {"not a url", ""},
175
+ }
176
+ for _, c := range cases {
177
+ if got := loopbackProxyAddr(c.in); got != c.want {
178
+ t.Errorf("loopbackProxyAddr(%q) = %q, want %q", c.in, got, c.want)
179
+ }
180
+ }
181
+ }
182
+
183
+ // 死端口必须从环境块里**消失**(不是补空值:留着原值会让「先写入者胜」的
184
+ // 读取方仍看到死地址)。大小写两套都要处理(Node/Go/curl 各读各的)。
185
+ func TestEnvironStripsDeadLoopbackProxy(t *testing.T) {
186
+ dead := "http://127.0.0.1:1" // 1 号端口不会有服务
187
+ for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
188
+ t.Setenv(k, dead)
189
+ }
190
+ for _, kv := range environ() {
191
+ name, val, _ := strings.Cut(kv, "=")
192
+ if isProxyNameForTest(name) && val == dead {
193
+ t.Errorf("%s 仍指向死端口:%s", name, kv)
194
+ }
195
+ }
196
+ }
197
+
198
+ // 活着的回环代理必须**原样保留** —— 断掉用户本来能用的代理是更严重的事故。
199
+ func TestEnvironKeepsLiveLoopbackProxy(t *testing.T) {
200
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
201
+ if err != nil {
202
+ t.Skipf("无法监听回环端口:%v", err)
203
+ }
204
+ defer ln.Close()
205
+ go func() {
206
+ for {
207
+ c, err := ln.Accept()
208
+ if err != nil {
209
+ return
210
+ }
211
+ c.Close()
212
+ }
213
+ }()
214
+ live := "http://" + ln.Addr().String()
215
+ t.Setenv("HTTP_PROXY", live)
216
+ t.Setenv("http_proxy", live)
217
+
218
+ for _, kv := range environ() {
219
+ name, val, _ := strings.Cut(kv, "=")
220
+ if name == "HTTP_PROXY" || name == "http_proxy" {
221
+ if val != live {
222
+ t.Fatalf("%s 本来可用却被改了:want %q, got %q", name, live, val)
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // 非回环代理一律不动 —— 哪怕连不上,也可能是这一瞬间抖动。
229
+ func TestEnvironKeepsRemoteProxy(t *testing.T) {
230
+ remote := "http://proxy.invalid.example:3128"
231
+ t.Setenv("HTTP_PROXY", remote)
232
+ for _, kv := range environ() {
233
+ name, val, _ := strings.Cut(kv, "=")
234
+ if name == "HTTP_PROXY" && val != remote {
235
+ t.Fatalf("非回环代理不该被改:want %q, got %q", remote, val)
236
+ }
237
+ }
238
+ }
239
+
240
+ // NO_PROXY 是绕过名单,不是代理地址,绝不能被当成死代理剔掉。
241
+ func TestEnvironKeepsNoProxy(t *testing.T) {
242
+ t.Setenv("NO_PROXY", "localhost,127.0.0.1,.internal")
243
+ found := false
244
+ for _, kv := range environ() {
245
+ if kv == "NO_PROXY=localhost,127.0.0.1,.internal" {
246
+ found = true
247
+ }
248
+ }
249
+ if !found {
250
+ t.Fatal("NO_PROXY 被误删了")
251
+ }
252
+ }
253
+
254
+ // isProxyNameForTest 测试侧的白名单判断(生产侧的白名单在 env.go)。
255
+ func isProxyNameForTest(name string) bool {
256
+ switch name {
257
+ case "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
258
+ "http_proxy", "https_proxy", "all_proxy":
259
+ return true
260
+ }
261
+ return false
262
+ }