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.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /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
|
|
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", "
|
|
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");
|
|
226
|
-
t.Errorf("full 档 sandbox
|
|
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
|
|
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
|
|
320
|
-
t.
|
|
373
|
+
if ok {
|
|
374
|
+
t.Error("full 档应**不**注入 --settings(沙箱整段不下发)")
|
|
321
375
|
}
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
387
|
-
t.Errorf("%s(--tools on):
|
|
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
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
440
|
-
t.Errorf("
|
|
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
|
+
}
|
package/internal/agent/runcmd.go
CHANGED
|
@@ -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
|
|
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
|
-
|
|
171
|
+
spec := launchWrap(bin, args)
|
|
172
|
+
cmd := exec.Command(spec.bin, spec.args...)
|
|
163
173
|
if dir != "" {
|
|
164
174
|
cmd.Dir = dir
|
|
165
175
|
}
|
package/internal/agent/runner.go
CHANGED
|
@@ -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
|
|
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"},
|
|
255
|
+
{&counterEngine{name: "x"}, time.Hour},
|
|
256
256
|
}
|
|
257
257
|
for _, c := range cases {
|
|
258
258
|
r := &Runner{Engine: c.e}
|