magic-agent 1.0.2 → 1.0.46

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 (159) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +260 -8
  55. package/internal/agent/custom_models_test.go +204 -7
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +78 -2
  66. package/internal/agent/env_test.go +37 -0
  67. package/internal/agent/itemtracker.go +271 -0
  68. package/internal/agent/launch.go +192 -0
  69. package/internal/agent/launch_test.go +206 -0
  70. package/internal/agent/launch_unix.go +15 -0
  71. package/internal/agent/launch_windows.go +24 -0
  72. package/internal/agent/limitnotice.go +103 -0
  73. package/internal/agent/limitnotice_test.go +211 -0
  74. package/internal/agent/llmengine.go +2 -2
  75. package/internal/agent/models.go +3 -2
  76. package/internal/agent/openclaw.go +4 -3
  77. package/internal/agent/openclaw_acp.go +7 -0
  78. package/internal/agent/openclaw_test.go +2 -2
  79. package/internal/agent/permission.go +100 -11
  80. package/internal/agent/permission_test.go +100 -36
  81. package/internal/agent/repair_models_test.go +194 -0
  82. package/internal/agent/runcmd.go +13 -3
  83. package/internal/agent/runner.go +7 -1
  84. package/internal/agent/runner_test.go +1 -1
  85. package/internal/agent/stream.go +777 -43
  86. package/internal/agent/stream_test.go +3 -2
  87. package/internal/agent/stream_timeline_test.go +905 -0
  88. package/internal/agent/streamjson.go +36 -2
  89. package/internal/agent/toolkind.go +20 -1
  90. package/internal/agent/toolkind_test.go +7 -1
  91. package/internal/agent/tools_suffix_test.go +10 -3
  92. package/internal/agent/trae.go +5 -3
  93. package/internal/agent/workspace_test.go +52 -0
  94. package/internal/cli/ask.go +282 -51
  95. package/internal/cli/ask_test.go +15 -11
  96. package/internal/cli/attach_test.go +32 -20
  97. package/internal/cli/clientcmd.go +156 -0
  98. package/internal/cli/events.go +53 -8
  99. package/internal/cli/events_test.go +230 -0
  100. package/internal/cli/keepalive_test.go +101 -6
  101. package/internal/cli/login.go +61 -4
  102. package/internal/cli/login_test.go +99 -0
  103. package/internal/cli/permission_test.go +5 -1
  104. package/internal/cli/repair.go +60 -0
  105. package/internal/cli/repair_test.go +51 -0
  106. package/internal/cli/root.go +195 -32
  107. package/internal/cli/session.go +183 -29
  108. package/internal/cli/session_test.go +144 -5
  109. package/internal/cli/sessionlog.go +385 -38
  110. package/internal/cli/sessionlog_test.go +708 -0
  111. package/internal/cli/sessionlog_user_test.go +142 -1
  112. package/internal/cli/sessiontitle.go +141 -0
  113. package/internal/client/browse.go +456 -0
  114. package/internal/client/browse_test.go +199 -0
  115. package/internal/client/cors_test.go +35 -0
  116. package/internal/client/daemon.go +525 -0
  117. package/internal/client/daemon_test.go +212 -0
  118. package/internal/client/detach_unix.go +19 -0
  119. package/internal/client/detach_windows.go +27 -0
  120. package/internal/client/pair.go +3 -3
  121. package/internal/client/plugins.go +376 -0
  122. package/internal/client/plugins_test.go +223 -0
  123. package/internal/client/proxy.go +374 -0
  124. package/internal/client/proxy_test.go +212 -0
  125. package/internal/client/service.go +1464 -48
  126. package/internal/client/service_test.go +649 -206
  127. package/internal/client/session_events_test.go +130 -0
  128. package/internal/client/wsproxy.go +113 -0
  129. package/internal/client/wsproxy_test.go +102 -0
  130. package/internal/session/delete.go +151 -0
  131. package/internal/session/delete_test.go +103 -0
  132. package/internal/session/session.go +155 -20
  133. package/internal/session/session_append.go +79 -21
  134. package/internal/session/session_test.go +124 -10
  135. package/internal/session/sessionlog.go +572 -6
  136. package/internal/session/sessionlog_read_test.go +410 -0
  137. package/internal/session/title.go +486 -0
  138. package/internal/session/title_test.go +298 -0
  139. package/npm/bin/magic-agent.js +78 -10
  140. package/npm/bin/magic-client.js +100 -0
  141. package/npm/dist/darwin-arm64/magic-agent +0 -0
  142. package/npm/dist/darwin-arm64/magic-client +0 -0
  143. package/npm/dist/darwin-x64/magic-agent +0 -0
  144. package/npm/dist/darwin-x64/magic-client +0 -0
  145. package/npm/dist/linux-arm64/magic-agent +0 -0
  146. package/npm/dist/linux-arm64/magic-client +0 -0
  147. package/npm/dist/linux-x64/magic-agent +0 -0
  148. package/npm/dist/linux-x64/magic-client +0 -0
  149. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  150. package/npm/dist/win32-x64/magic-client.exe +0 -0
  151. package/npm/install.js +36 -13
  152. package/npm/lib/platform.js +27 -5
  153. package/package.json +8 -3
  154. package/client-ui/pc/js/03-chat-message.js +0 -880
  155. package/client-ui/pc/js/06-ask-answer.js +0 -204
  156. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  157. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  158. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  159. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -103,7 +103,7 @@ func TestClaudePermissionModeMapping(t *testing.T) {
103
103
  }
104
104
  }
105
105
 
106
- // TestPermissionSupportOf 能力表:只有 claude / codebuddy / codebuddy-ai 接线。
106
+ // TestPermissionSupportOf 能力表:claude / codebuddy / codebuddy-ai 走 flag,dsh 走环境变量。
107
107
  func TestPermissionSupportOf(t *testing.T) {
108
108
  for _, e := range []string{"claude", "codebuddy", "codebuddy-ai"} {
109
109
  if !PermissionSupported(e) {
@@ -113,8 +113,15 @@ func TestPermissionSupportOf(t *testing.T) {
113
113
  t.Errorf("PermissionSupportOf(%q) = %q", e, got)
114
114
  }
115
115
  }
116
+ // dsh 没有逐调用权限 flag,档位走子进程环境变量(shipped 的 sdk profile 直接读它)。
117
+ if !PermissionSupported("dsh") {
118
+ t.Error("dsh 应支持四档模型(经 DSH_PERMISSION_MODE)")
119
+ }
120
+ if got := PermissionSupportOf("dsh"); got != "env:DSH_PERMISSION_MODE" {
121
+ t.Errorf("PermissionSupportOf(\"dsh\") = %q, want env:DSH_PERMISSION_MODE", got)
122
+ }
116
123
  // 未接线的引擎必须报 none —— CLI 层据此 exit 2,而不是静默忽略。
117
- for _, e := range []string{"trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
124
+ for _, e := range []string{"trae", "llm", "codex", "openclaw", "arkclaw"} {
118
125
  if PermissionSupported(e) {
119
126
  t.Errorf("%s 不应报告支持四档模型", e)
120
127
  }
@@ -124,6 +131,40 @@ func TestPermissionSupportOf(t *testing.T) {
124
131
  }
125
132
  }
126
133
 
134
+ // TestDshPermissionMode 四档 → dsh SandboxMode 的映射,以及**空档位不注入**这条红线。
135
+ //
136
+ // 空档位那条是重点:DefaultPermissionTier 是 full,若 dshPermissionMode 在空串上兜默认值,
137
+ // `magic-agent -e dsh`(未显式传档)就会静默把 dsh 的沙箱整个关掉 —— 这正是本仓库最忌讳的
138
+ // 「静默的安全降级」,故用测试钉住。
139
+ func TestDshPermissionMode(t *testing.T) {
140
+ cases := []struct {
141
+ tier PermissionTier
142
+ mode string
143
+ }{
144
+ {"", ""}, // 空档 = 不注入(用 dsh 自己的默认,保持既有行为)
145
+ {PermissionManual, "read-only"},
146
+ {PermissionAcceptEdits, "workspace-write"},
147
+ {PermissionAuto, "workspace-write"},
148
+ {PermissionFull, "danger-full-access"},
149
+ }
150
+ for _, c := range cases {
151
+ if got := dshPermissionMode(c.tier); got != c.mode {
152
+ t.Errorf("dshPermissionMode(%q) = %q, want %q", c.tier, got, c.mode)
153
+ }
154
+ env := dshPermissionEnv(c.tier)
155
+ if c.mode == "" {
156
+ if env != nil {
157
+ t.Errorf("dshPermissionEnv(%q) 应返回 nil(不注入),得到 %v", c.tier, env)
158
+ }
159
+ continue
160
+ }
161
+ want := []string{"DSH_PERMISSION_MODE=" + c.mode}
162
+ if len(env) != 1 || env[0] != want[0] {
163
+ t.Errorf("dshPermissionEnv(%q) = %v, want %v", c.tier, env, want)
164
+ }
165
+ }
166
+ }
167
+
127
168
  // ── settings 载荷 ─────────────────────────────────────────────
128
169
 
129
170
  // decodeSettings 把 --settings 的内联 JSON 解成嵌套 map 便于断言。
@@ -154,6 +195,13 @@ func dig(m map[string]any, path ...string) any {
154
195
  // 关键点:autoAllowBashIfSandboxed 是第 1/2 档与第 3 档的分水岭。
155
196
  // 该键在 claude 侧的默认值是 true,所以第 1/2 档必须显式写 false ——
156
197
  // 否则沙箱内的 Bash 会被自动放行,第 2 档就退化成了「编辑与命令都不问」。
198
+ //
199
+ // ⚠️ 2026-10-01 反转:第 4 档(PermissionFull / bypassPermissions)下,
200
+ // sandboxSettings **返回 nil** 而不是 `{"enabled": false}`。原因是 claude CLI
201
+ // 在 tools=default + bypassPermissions + `--settings {"sandbox":{"enabled":false}}`
202
+ // 组合下报「Invalid JSON provided to --settings」、exit 0 零输出(实测)。
203
+ // bypassPermissions 与 sandbox 互斥:开了也是空跑、显式写 enabled=false 反而触发 bug。
204
+ // 让外层(agentSettingsPayload)整段不下发即可。
157
205
  func TestSandboxSettingsPerTier(t *testing.T) {
158
206
  cases := []struct {
159
207
  tier PermissionTier
@@ -164,19 +212,16 @@ func TestSandboxSettingsPerTier(t *testing.T) {
164
212
  {PermissionManual, true, false, true},
165
213
  {PermissionAcceptEdits, true, false, true},
166
214
  {PermissionAuto, true, true, true},
167
- {PermissionFull, false, false, false},
168
215
  }
169
216
  for _, c := range cases {
170
217
  sb := sandboxSettings(c.tier, PermissionOptions{}, "claude")
218
+ if sb == nil {
219
+ t.Errorf("%s: sandboxSettings 不应返回 nil", c.tier)
220
+ continue
221
+ }
171
222
  if got := sb["enabled"]; got != c.enabled {
172
223
  t.Errorf("%s: sandbox.enabled = %v, want %v", c.tier, got, c.enabled)
173
224
  }
174
- if c.tier == PermissionFull {
175
- if _, has := sb["autoAllowBashIfSandboxed"]; has {
176
- t.Errorf("full 档不该带 autoAllowBashIfSandboxed")
177
- }
178
- continue
179
- }
180
225
  if got := sb["autoAllowBashIfSandboxed"]; got != c.autoAllow {
181
226
  t.Errorf("%s: sandbox.autoAllowBashIfSandboxed = %v, want %v(这是第1/2档与第3档的分水岭)",
182
227
  c.tier, got, c.autoAllow)
@@ -199,6 +244,11 @@ func TestSandboxSettingsPerTier(t *testing.T) {
199
244
  if got := sb["enabled"]; got != true {
200
245
  t.Errorf("codebuddy auto 档 sandbox.enabled = %v, want true", got)
201
246
  }
247
+
248
+ // 第 4 档:返回 nil,整段 sandbox 不下发。
249
+ if got := sandboxSettings(PermissionFull, PermissionOptions{}, "claude"); got != nil {
250
+ t.Errorf("full 档应返回 nil(实测 2026-10-01:写 enabled=false 触发 claude CLI bug)")
251
+ }
202
252
  }
203
253
 
204
254
  // TestSandboxSettingsOptions 可选项(排除命令 / 网络白名单 / 放宽逃逸舱口)。
@@ -221,9 +271,9 @@ func TestSandboxSettingsOptions(t *testing.T) {
221
271
  t.Errorf("allowUnsandboxedCommands = %v, want true(显式放宽时)", got)
222
272
  }
223
273
 
224
- // 第 4 档无沙箱,可选项一律不生效。
225
- if sbFull := sandboxSettings(PermissionFull, opts, "claude"); len(sbFull) != 1 {
226
- t.Errorf("full 档 sandbox 应只有 enabled 一项,得到 %v", sbFull)
274
+ // 第 4 档:返回 nil,所有可选项一律不生效。
275
+ if sbFull := sandboxSettings(PermissionFull, opts, "claude"); sbFull != nil {
276
+ t.Errorf("full 档 sandbox 应返回 nil(整段不下发),得到 %v", sbFull)
227
277
  }
228
278
  }
229
279
 
@@ -314,17 +364,17 @@ func TestAgentSettingsPayload(t *testing.T) {
314
364
  t.Error("permissions.ask 丢失")
315
365
  }
316
366
 
317
- // ④ 第 4 档:显式关闭沙箱(覆盖用户 settings 里可能开着的 sandbox)。
367
+ // ④ 第 4 档:**不下发 sandbox 整段**(实测 2026-10-01:claude CLI 在
368
+ // tools=default + bypassPermissions + `--settings {"sandbox":{"enabled":false}}`
369
+ // 组合下报「Invalid JSON provided to --settings」、exit 0 零输出)。
370
+ // bypassPermissions 与 sandbox 互斥:开了也是空跑、显式写 enabled=false
371
+ // 反而触发那个 bug。让外层 sandboxSettings 返 nil、外层整段不下发即可。
318
372
  payload, ok = agentSettingsPayload(Request{Tools: ToolsOn, Permission: PermissionFull}, "claude")
319
- if !ok {
320
- t.Fatal("full 档应注入 --settings(显式关闭沙箱)")
373
+ if ok {
374
+ t.Error("full 档应**不**注入 --settings(沙箱整段不下发)")
321
375
  }
322
- s = decodeSettings(t, payload)
323
- if got := dig(s, "sandbox", "enabled"); got != false {
324
- t.Errorf("full 档 sandbox.enabled = %v, want false", got)
325
- }
326
- if _, has := s["autoMode"]; has {
327
- t.Error("full 档不该有 autoMode")
376
+ if payload != "" {
377
+ t.Errorf("full 档 payload 应为空,实际 %q", payload)
328
378
  }
329
379
  }
330
380
 
@@ -374,7 +424,8 @@ func TestClaudeBuildArgsPermissionTiers(t *testing.T) {
374
424
  {PermissionFull, "bypassPermissions"},
375
425
  }
376
426
  for _, c := range cases {
377
- // --tools on
427
+ // --tools on:2026-10-01 必须显式 --tools default(不然 claude CLI 在
428
+ // stream-json 模式下不发 result 行);同时仍带 --permission-mode。
378
429
  args := e.buildArgsBase(Request{Tools: ToolsOn, Permission: c.tier})
379
430
  got, ok := argValue(args, "--permission-mode")
380
431
  if !ok {
@@ -383,8 +434,8 @@ func TestClaudeBuildArgsPermissionTiers(t *testing.T) {
383
434
  if got != c.wantMode {
384
435
  t.Errorf("%s(--tools on): --permission-mode = %q, want %q", c.tier, got, c.wantMode)
385
436
  }
386
- if _, has := argValue(args, "--tools"); has {
387
- t.Errorf("%s(--tools on): 不该传 --tools(on = 引擎默认全工具)", c.tier)
437
+ if got, _ := argValue(args, "--tools"); got != "default" {
438
+ t.Errorf("%s(--tools on): --tools = %q, want default(显式开全部内置)", c.tier, got)
388
439
  }
389
440
  // 改造后不该再出现 --dangerously-skip-permissions(档位统一走 --permission-mode)。
390
441
  for _, a := range args {
@@ -392,9 +443,22 @@ func TestClaudeBuildArgsPermissionTiers(t *testing.T) {
392
443
  t.Errorf("%s: 仍在传 --dangerously-skip-permissions,应统一走 --permission-mode", c.tier)
393
444
  }
394
445
  }
395
- // 沙箱只能经 --settings 进(claude 没有 --sandbox flag)。
396
- if _, has := argValue(args, "--settings"); !has {
397
- t.Errorf("%s: 缺少 --settings(沙箱配置没有 CLI flag,只能从这里进)", c.tier)
446
+ // 第 4 档**不**带 --settings(实测 2026-10-01:claude CLI 在
447
+ // tools=default + bypassPermissions + `{"sandbox":{"enabled":false}}`
448
+ // 组合下报「Invalid JSON provided to --settings」、exit 0 零输出)。
449
+ // 其他档:沙箱只能经 --settings 进(claude 没有 --sandbox flag)。
450
+ hasSettings := false
451
+ if _, has := argValue(args, "--settings"); has {
452
+ hasSettings = true
453
+ }
454
+ if c.tier == PermissionFull {
455
+ if hasSettings {
456
+ t.Errorf("%s(--tools on): full 档不应带 --settings(实测 2026-10-01 触发 claude CLI bug)", c.tier)
457
+ }
458
+ } else {
459
+ if !hasSettings {
460
+ t.Errorf("%s(--tools on): 缺少 --settings(沙箱配置没有 CLI flag,只能从这里进)", c.tier)
461
+ }
398
462
  }
399
463
 
400
464
  // --tools 白名单
@@ -422,22 +486,22 @@ func TestClaudeBuildArgsPermissionTiers(t *testing.T) {
422
486
 
423
487
  // TestClaudeBuildArgsDefaultTierUnchanged 默认档的 argv 与改造前的语义一致。
424
488
  //
425
- // 改造前:--tools on → --dangerously-skip-permissions(无 --settings)。
426
- // 改造后:--tools on → --permission-mode bypassPermissions + --settings{"sandbox":{"enabled":false}}。
427
- // 两者在 claude 官方文档里是**等价**的("Equivalent to --permission-mode bypassPermissions"),
428
- // 且显式关沙箱能覆盖用户 settings 里可能开着的 sandbox.enabled。
489
+ // 2026-10-01 修订:默认档(Permission 零值 = PermissionFull / bypassPermissions)
490
+ // **不**带 --settings。原因是 claude CLI 在 tools=default + bypassPermissions +
491
+ // `{"sandbox":{"enabled":false}}` 组合下报「Invalid JSON provided to --settings」、
492
+ // exit 0 零输出(实测)。bypassPermissions 与 sandbox 互斥,写 enabled=false 反而
493
+ // 触发那个 bug。让 sandboxSettings 在 full 档返 nil、外层整段不下发即可。
429
494
  func TestClaudeBuildArgsDefaultTierUnchanged(t *testing.T) {
430
495
  e := &ClaudeEngine{}
431
496
  args := e.buildArgsBase(Request{Tools: ToolsOn}) // Permission 零值
432
497
  if got, _ := argValue(args, "--permission-mode"); got != "bypassPermissions" {
433
498
  t.Errorf("默认档 --permission-mode = %q, want bypassPermissions", got)
434
499
  }
435
- payload, ok := argValue(args, "--settings")
436
- if !ok {
437
- t.Fatal("默认档应带 --settings(显式关闭沙箱)")
500
+ if got, _ := argValue(args, "--tools"); got != "default" {
501
+ t.Errorf("默认档 --tools = %q, want default(2026-10-01 必须显式 --tools default)", got)
438
502
  }
439
- if got := dig(decodeSettings(t, payload), "sandbox", "enabled"); got != false {
440
- t.Errorf("默认档 sandbox.enabled = %v, want false", got)
503
+ if _, has := argValue(args, "--settings"); has {
504
+ t.Errorf("默认档(full)不应带 --settings(实测触发 claude CLI bug)")
441
505
  }
442
506
  }
443
507
 
@@ -0,0 +1,194 @@
1
+ package agent
2
+
3
+ // repair_models_test.go - 事故还原(--repair-models 的落地逻辑,见 custom_models.go)。
4
+ //
5
+ // 场景来自 2026-09-30 Windows 事故:桌面端 models.json 是**数组**,被反向同步覆盖成
6
+ // `{availableModels, models}` 对象后桌面端解析不了,模型清单整个为空。
7
+ // 这里锁住三件事:
8
+ // 1. 明确的坏形状 → 还原成数组,id 去掉 custom-local: 前缀,且先留 .broken.bak
9
+ // 2. 已经是数组 → 一个字都不动
10
+ // 3. 形状对不上(坏 JSON / 空对象 / models 里混着非 custom-local 条目)→ 一律不动
11
+
12
+ import (
13
+ "bytes"
14
+ "encoding/json"
15
+ "os"
16
+ "path/filepath"
17
+ "strings"
18
+ "testing"
19
+ )
20
+
21
+ // clobberedFixture 造一份「被我们覆盖写坏」的 models.json:顶层对象 + 全 custom-local 条目。
22
+ func clobberedFixture() []byte {
23
+ return []byte(`{
24
+ "availableModels": [
25
+ "custom-local:MiniMax-M3",
26
+ "custom-local:glm-5.3"
27
+ ],
28
+ "models": [
29
+ {
30
+ "id": "custom-local:MiniMax-M3",
31
+ "name": "MiniMax-M3",
32
+ "vendor": "MiniMax",
33
+ "url": "https://api.minimaxi.com/v1/chat/completions",
34
+ "apiKey": "sk-user-own",
35
+ "supportsImages": false
36
+ },
37
+ {
38
+ "id": "custom-local:glm-5.3",
39
+ "name": "glm-5.3",
40
+ "vendor": "Zhipu",
41
+ "url": "https://open.bigmodel.cn/api/paas/v4",
42
+ "apiKey": "sk-user-own-2"
43
+ }
44
+ ]
45
+ }
46
+ `)
47
+ }
48
+
49
+ func TestRepairModelsFile_RestoresArrayFormat(t *testing.T) {
50
+ appHome := t.TempDir()
51
+ path := filepath.Join(appHome, customModelsFileName)
52
+ if err := os.WriteFile(path, clobberedFixture(), 0o600); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+
56
+ repaired, note, err := repairModelsFile(appHome)
57
+ if err != nil {
58
+ t.Fatalf("repair: %v", err)
59
+ }
60
+ if !repaired {
61
+ t.Fatalf("坏形状应被还原,note=%q", note)
62
+ }
63
+
64
+ b, err := os.ReadFile(path)
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ var arr []map[string]any
69
+ if err := json.Unmarshal(b, &arr); err != nil {
70
+ t.Fatalf("还原后应是数组: %v(内容 %s)", err, b)
71
+ }
72
+ if len(arr) != 2 {
73
+ t.Fatalf("条数 = %d, want 2", len(arr))
74
+ }
75
+ for _, m := range arr {
76
+ id, _ := m["id"].(string)
77
+ if id == "" || strings.HasPrefix(id, customModelIDPrefix) {
78
+ t.Errorf("id 必须去掉 %s 前缀,实际 %q", customModelIDPrefix, id)
79
+ }
80
+ }
81
+ if arr[0]["id"] != "MiniMax-M3" {
82
+ t.Errorf("首条 id = %v, want MiniMax-M3", arr[0]["id"])
83
+ }
84
+ // 端点与密钥必须原样保留(丢了就只能手抄)
85
+ if arr[0]["url"] != "https://api.minimaxi.com/v1/chat/completions" || arr[0]["apiKey"] != "sk-user-own" {
86
+ t.Errorf("端点/密钥没保留: %v", arr[0])
87
+ }
88
+
89
+ // 现场必须留备份,且备份是坏的那份原文
90
+ bak, err := os.ReadFile(path + ".broken.bak")
91
+ if err != nil {
92
+ t.Fatalf("应留 .broken.bak: %v", err)
93
+ }
94
+ if !bytes.Equal(bak, clobberedFixture()) {
95
+ t.Error(".broken.bak 应为被写坏的原文")
96
+ }
97
+ }
98
+
99
+ func TestRepairModelsFile_LeavesGoodShapesAlone(t *testing.T) {
100
+ // 桌面端原生数组格式
101
+ native := []byte("[\n {\n \"id\": \"MiniMax-M3\",\n \"url\": \"https://x/v1\"\n }\n]\n")
102
+
103
+ cases := []struct {
104
+ name string
105
+ content []byte
106
+ }{
107
+ {"已是数组(桌面端原生格式)", native},
108
+ {"坏 JSON", []byte("{oops")},
109
+ {"空 JSON 对象", []byte("{}")},
110
+ {"顶层是字符串", []byte(`"nope"`)},
111
+ {"models 里混着非 custom-local 条目", []byte(`{"models":[{"id":"hy3","url":"https://x"}]}`)},
112
+ {"models 是空数组", []byte(`{"models":[],"availableModels":[]}`)},
113
+ }
114
+
115
+ for _, tc := range cases {
116
+ t.Run(tc.name, func(t *testing.T) {
117
+ appHome := t.TempDir()
118
+ path := filepath.Join(appHome, customModelsFileName)
119
+ if err := os.WriteFile(path, tc.content, 0o600); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+
123
+ repaired, _, err := repairModelsFile(appHome)
124
+ if err != nil {
125
+ t.Fatalf("形状对不上应静默不改,不该报错: %v", err)
126
+ }
127
+ if repaired {
128
+ t.Errorf("形状对不上不该动手")
129
+ }
130
+ after, rerr := os.ReadFile(path)
131
+ if rerr != nil {
132
+ t.Fatal(rerr)
133
+ }
134
+ if !bytes.Equal(after, tc.content) {
135
+ t.Errorf("原文件被改写了\nbefore=%s\nafter =%s", tc.content, after)
136
+ }
137
+ if _, serr := os.Stat(path + ".broken.bak"); !os.IsNotExist(serr) {
138
+ t.Errorf("没还原就不该留 .broken.bak")
139
+ }
140
+ })
141
+ }
142
+ }
143
+
144
+ func TestRepairModelsFile_MissingFileIsNoop(t *testing.T) {
145
+ appHome := t.TempDir()
146
+ repaired, note, err := repairModelsFile(appHome)
147
+ if err != nil {
148
+ t.Fatalf("没有文件不该报错: %v", err)
149
+ }
150
+ if repaired {
151
+ t.Error("没有文件时不谈还原")
152
+ }
153
+ if note == "" {
154
+ t.Error("应给出「不存在、无需修复」的说明,不静默")
155
+ }
156
+ }
157
+
158
+ // 幂等:还原过一次之后,再跑一次必须什么都不做(已经是数组)。
159
+ func TestRepairModelsFile_Idempotent(t *testing.T) {
160
+ appHome := t.TempDir()
161
+ path := filepath.Join(appHome, customModelsFileName)
162
+ if err := os.WriteFile(path, clobberedFixture(), 0o600); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ if repaired, _, err := repairModelsFile(appHome); err != nil || !repaired {
166
+ t.Fatalf("首次应还原: repaired=%v err=%v", repaired, err)
167
+ }
168
+ first, err := os.ReadFile(path)
169
+ if err != nil {
170
+ t.Fatal(err)
171
+ }
172
+
173
+ repaired, _, err := repairModelsFile(appHome)
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ if repaired {
178
+ t.Error("第二次不该再动手")
179
+ }
180
+ second, err := os.ReadFile(path)
181
+ if err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if !bytes.Equal(first, second) {
185
+ t.Error("第二次不该改动文件")
186
+ }
187
+ }
188
+
189
+ // 引擎名到桌面端数据目录的映射:不认识的名字必须报错,而不是猜一个目录去写。
190
+ func TestRepairDesktopModels_UnknownEngine(t *testing.T) {
191
+ if _, _, err := RepairDesktopModels("no-such-engine"); err == nil {
192
+ t.Fatal("没有桌面端目录的引擎应报错")
193
+ }
194
+ }
@@ -73,7 +73,10 @@ func runCLIEnv(ctx context.Context, extra []string, bin string, args ...string)
73
73
 
74
74
  // runCLIEnvIn = runCLIIn + runCLIEnv(工作目录 + 追加环境变量)。
75
75
  func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, args ...string) (string, string, error) {
76
- cmd := exec.Command(bin, args...)
76
+ // launchWrap 与流式路径同源:.cmd/.bat 在 Windows 上不能直接 exec,
77
+ // 参数会被 cmd.exe 拆坏(实测含换行的提示词整段丢失)。unix 上原样透传。
78
+ spec := launchWrap(bin, args)
79
+ cmd := exec.Command(spec.bin, spec.args...)
77
80
  if dir != "" {
78
81
  cmd.Dir = dir
79
82
  }
@@ -147,7 +150,8 @@ func waitDrained(ch <-chan struct{}, n int, d time.Duration) {
147
150
  }
148
151
  }
149
152
 
150
- // envWithDefaults 继承当前环境(CLI 依赖 HOME/PATH 等基础变量)。
153
+ // envWithDefaults 继承当前环境(CLI 依赖 HOME/PATH 等基础变量),
154
+ // 父-side 专属项已在 environ() 阶段过滤(参见 envDenylist)。
151
155
  func envWithDefaults() []string {
152
156
  return environ()
153
157
  }
@@ -158,8 +162,14 @@ func newStreamCmd(bin string, args []string) *exec.Cmd {
158
162
  }
159
163
 
160
164
  // newStreamCmdIn 同 newStreamCmd,可指定工作目录(workspace,见 runCLIIn 注释)。
165
+ //
166
+ // ⚠️ 这里的 launchWrap 是 Windows 存活的关键(2026-10-01 实测):
167
+ // npm 装的 claude / codebuddy 是 .cmd 批处理,直接 exec 会让
168
+ // 含换行 / 引号 / & | ^ % 的参数被 cmd.exe 拆坏 → 静默退 0、零输出。
169
+ // unix 上它原样返回,行为不变。见 launch.go。
161
170
  func newStreamCmdIn(dir string, bin string, args []string) *exec.Cmd {
162
- cmd := exec.Command(bin, args...)
171
+ spec := launchWrap(bin, args)
172
+ cmd := exec.Command(spec.bin, spec.args...)
163
173
  if dir != "" {
164
174
  cmd.Dir = dir
165
175
  }
@@ -53,6 +53,8 @@ type Runner struct {
53
53
  }
54
54
 
55
55
  // defaultTimeout 按引擎取默认超时。
56
+ //
57
+ // 2026-10-10 起各引擎统一为 1 小时;未在上面列出的引擎同样回落到 1 小时。
56
58
  func (r *Runner) defaultTimeout() time.Duration {
57
59
  switch r.Engine.(type) {
58
60
  case *ClaudeEngine:
@@ -71,8 +73,12 @@ func (r *Runner) defaultTimeout() time.Duration {
71
73
  return DefaultCBGatewayTimeout
72
74
  case *DshEngine:
73
75
  return DefaultDshTimeout
76
+ case *OpenClawEngine:
77
+ return DefaultOpenClawTimeout
78
+ case *CodexEngine:
79
+ return DefaultCodexTimeout
74
80
  }
75
- return 5 * time.Minute
81
+ return time.Hour
76
82
  }
77
83
 
78
84
  // isRetryable 判断错误是否值得重试。
@@ -252,7 +252,7 @@ func TestRunnerDefaultTimeoutByEngine(t *testing.T) {
252
252
  {&CodeBuddyEngine{}, DefaultCodeBuddyTimeout},
253
253
  {&CodeBuddyAIEngine{}, DefaultCodeBuddyTimeout},
254
254
  {&TraeEngine{}, DefaultTraeTimeout},
255
- {&counterEngine{name: "x"}, 5 * time.Minute},
255
+ {&counterEngine{name: "x"}, time.Hour},
256
256
  }
257
257
  for _, c := range cases {
258
258
  r := &Runner{Engine: c.e}