magic-agent 1.0.1 → 1.0.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.
- package/CLIENT-CONTRACT.md +298 -0
- package/README.md +35 -0
- package/client-ui/assets/OFL-Caveat.txt +93 -0
- package/client-ui/assets/caveat.woff2 +0 -0
- package/client-ui/index.html +246 -0
- package/client-ui/pc/app.css +5110 -0
- package/client-ui/pc/assets/damiao-mascot.png +0 -0
- package/client-ui/pc/assets/icon.png +0 -0
- package/client-ui/pc/index.html +154 -0
- package/client-ui/pc/js/00-base.js +52 -0
- package/client-ui/pc/js/03-chat-message.js +880 -0
- package/client-ui/pc/js/04-task-list.js +430 -0
- package/client-ui/pc/js/05-task-list-render.js +812 -0
- package/client-ui/pc/js/06-ask-answer.js +204 -0
- package/client-ui/pc/js/07-chat-stream-render.js +493 -0
- package/client-ui/pc/js/08-ask-event-map.js +171 -0
- package/client-ui/pc/js/09-stream-apply.js +367 -0
- package/client-ui/pc/js/10-markdown.js +430 -0
- package/client-ui/pc/js/18-askbox.js +262 -0
- package/client-ui/pc/js/19-attachments.js +679 -0
- package/client-ui/pc/js/29-msg-variants.js +462 -0
- package/client-ui/pc/js/90-glue.js +212 -0
- package/client-ui/pc/js/91-workbench-stubs.js +134 -0
- package/client-ui/pc/js/92-composer.js +406 -0
- package/client-ui/pc/js/93-rail.js +362 -0
- package/client-ui/pc/vendor/highlight.min.js +1262 -0
- package/client-ui/pc/vendor/marked.min.js +6 -0
- package/client-ui/pc/vendor/purify.min.js +3 -0
- package/client-ui/shared/conversation.css +256 -0
- package/client-ui/shared/conversation.js +1225 -0
- package/client-ui/shared/transport-http.js +123 -0
- package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
- package/client-ui-mobile/assets/caveat.woff2 +0 -0
- package/client-ui-mobile/index.html +173 -0
- package/internal/agent/custom_models.go +151 -5
- package/internal/agent/custom_models_test.go +130 -7
- package/internal/agent/env.go +10 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/cli/ask.go +22 -18
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +21 -0
- package/internal/cli/session.go +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/package.json +4 -1
|
@@ -9,8 +9,11 @@ package agent
|
|
|
9
9
|
// 2. 幂等:无新增时**不落盘**(内容逐字节不变)
|
|
10
10
|
// 3. 只增不改:既有条目与未识别字段原样保留;availableModels 追加不丢
|
|
11
11
|
// 4. 安全兜底:App 缓存缺失 / 条目 disabled → 不建文件、不报错
|
|
12
|
+
// 5. 陌生文件一律不碰:顶层不是 JSON 对象(桌面端是数组)/ 坏 JSON → 逐字节保持原样,
|
|
13
|
+
// 且覆盖写之前一定留 .magic-agent.bak(见 2026-09-30 Windows 事故用例)
|
|
12
14
|
|
|
13
15
|
import (
|
|
16
|
+
"bytes"
|
|
14
17
|
"encoding/json"
|
|
15
18
|
"os"
|
|
16
19
|
"path/filepath"
|
|
@@ -182,11 +185,112 @@ func TestSyncCustomModelsFromApp_IdempotentAndMergeOnly(t *testing.T) {
|
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
// 目标文件顶层不是 JSON 对象(桌面端 WorkBuddy 的 models.json 就是**数组**)时,
|
|
189
|
+
// 必须**一个字都不动**、也不留备份(因为压根没写)。
|
|
190
|
+
//
|
|
191
|
+
// 为什么是关键回归:以前把"解析不进 map"当空文件继续写回 {models, availableModels},
|
|
192
|
+
// 等于把对方的模型清单整体替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
|
|
193
|
+
func TestSyncCustomModelsFromApp_RefusesNonObjectFile(t *testing.T) {
|
|
194
|
+
// 桌面端 models.json 的真实形状:顶层数组,条目是 LanguageModel。
|
|
195
|
+
desktop := []byte(`[
|
|
196
|
+
{
|
|
197
|
+
"id": "MiniMax-M3",
|
|
198
|
+
"name": "MiniMax-M3",
|
|
199
|
+
"vendor": "MiniMax",
|
|
200
|
+
"url": "https://api.minimaxi.com/v1/chat/completions",
|
|
201
|
+
"apiKey": "sk-user-own"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "glm-5.3",
|
|
205
|
+
"name": "glm-5.3",
|
|
206
|
+
"url": "https://open.bigmodel.cn/api/paas/v4",
|
|
207
|
+
"apiKey": "sk-user-own-2"
|
|
208
|
+
}
|
|
209
|
+
]`)
|
|
210
|
+
|
|
211
|
+
cases := []struct {
|
|
212
|
+
name string
|
|
213
|
+
content []byte
|
|
214
|
+
}{
|
|
215
|
+
{"顶层是数组(桌面端格式)", desktop},
|
|
216
|
+
{"坏 JSON", []byte("{oops")},
|
|
217
|
+
{"顶层是字符串", []byte(`"just a string"`)},
|
|
218
|
+
{"顶层是数字", []byte(`42`)},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for _, tc := range cases {
|
|
222
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
223
|
+
dir := t.TempDir()
|
|
224
|
+
acc := filepath.Join(dir, "acc.json")
|
|
225
|
+
writeJSON(t, acc, appCacheFixture())
|
|
226
|
+
cfg := filepath.Join(dir, "config")
|
|
227
|
+
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
231
|
+
if err := os.WriteFile(path, tc.content, 0o600); err != nil {
|
|
232
|
+
t.Fatal(err)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
236
|
+
if err != nil {
|
|
237
|
+
t.Errorf("应静默跳过,不该报错: %v", err)
|
|
238
|
+
}
|
|
239
|
+
if added != 0 {
|
|
240
|
+
t.Errorf("added = %d, want 0", added)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
after, err := os.ReadFile(path)
|
|
244
|
+
if err != nil {
|
|
245
|
+
t.Fatalf("原文件必须还在: %v", err)
|
|
246
|
+
}
|
|
247
|
+
if !bytes.Equal(after, tc.content) {
|
|
248
|
+
t.Errorf("原文件被改写了!\nbefore=%s\nafter =%s", tc.content, after)
|
|
249
|
+
}
|
|
250
|
+
if _, err := os.Stat(path + customModelsBackupSuffix); !os.IsNotExist(err) {
|
|
251
|
+
t.Errorf("没写就不该留备份")
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 真的要覆盖写时,必须先把原文留成 <models.json>.magic-agent.bak(可回滚)。
|
|
258
|
+
func TestSyncCustomModelsFromApp_BacksUpBeforeOverwrite(t *testing.T) {
|
|
186
259
|
dir := t.TempDir()
|
|
260
|
+
acc := filepath.Join(dir, "acc.json")
|
|
261
|
+
writeJSON(t, acc, appCacheFixture())
|
|
187
262
|
cfg := filepath.Join(dir, "config")
|
|
263
|
+
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
264
|
+
t.Fatal(err)
|
|
265
|
+
}
|
|
188
266
|
path := filepath.Join(cfg, customModelsFileName)
|
|
189
267
|
|
|
268
|
+
before := []byte("{\n \"models\": [],\n \"availableModels\": [\"hy3\"]\n}\n")
|
|
269
|
+
if err := os.WriteFile(path, before, 0o600); err != nil {
|
|
270
|
+
t.Fatal(err)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
274
|
+
if err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
if added == 0 {
|
|
278
|
+
t.Fatal("本次应有新增(触发落盘)")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
bak, err := os.ReadFile(path + customModelsBackupSuffix)
|
|
282
|
+
if err != nil {
|
|
283
|
+
t.Fatalf("覆盖写之前应留备份: %v", err)
|
|
284
|
+
}
|
|
285
|
+
if !bytes.Equal(bak, before) {
|
|
286
|
+
t.Errorf("备份内容应为改动前的原文\nwant=%s\ngot =%s", before, bak)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
|
|
291
|
+
dir := t.TempDir()
|
|
292
|
+
cfg := filepath.Join(dir, "config")
|
|
293
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
190
294
|
cases := []struct{ name, acc string }{
|
|
191
295
|
{"acc 路径为空", ""},
|
|
192
296
|
{"acc 文件不存在", filepath.Join(dir, "nope.json")},
|
|
@@ -221,7 +325,17 @@ func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
|
|
|
221
325
|
}
|
|
222
326
|
}
|
|
223
327
|
|
|
224
|
-
|
|
328
|
+
/* 既有 models.json 无法确认为「CLI 自己的格式」时,一律**不碰**,而不是重建。
|
|
329
|
+
*
|
|
330
|
+
* 这条契约是 2026-09-30 改的(Windows 事故)。原行为是「解析失败当空文件,重建」,
|
|
331
|
+
* 理由看起来无害(CLI 自己也这么兜底)。实际后果是把**别人的**文件整体替换掉:
|
|
332
|
+
* 桌面端 WorkBuddy 的 models.json 顶层是数组,数组解析不进 map[string]any,
|
|
333
|
+
* 于是被当成「坏文件」重建 → 里面 61 条官方模型全没了,只剩 8 条 custom-local,
|
|
334
|
+
* 而且没有备份,不可回滚。
|
|
335
|
+
*
|
|
336
|
+
* 宁可少同步一次端点(CLI 会自己报 "has no endpoint url configured"),
|
|
337
|
+
* 也不要毁掉一份不是我们管的配置。 */
|
|
338
|
+
func TestSyncCustomModelsFromApp_CorruptExistingFileLeftUntouched(t *testing.T) {
|
|
225
339
|
dir := t.TempDir()
|
|
226
340
|
acc := filepath.Join(dir, "acc.json")
|
|
227
341
|
writeJSON(t, acc, appCacheFixture())
|
|
@@ -229,16 +343,25 @@ func TestSyncCustomModelsFromApp_CorruptExistingFileRebuilt(t *testing.T) {
|
|
|
229
343
|
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
230
344
|
t.Fatal(err)
|
|
231
345
|
}
|
|
232
|
-
|
|
233
|
-
|
|
346
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
347
|
+
broken := []byte("{oops")
|
|
348
|
+
if err := os.WriteFile(path, broken, 0o600); err != nil {
|
|
234
349
|
t.Fatal(err)
|
|
235
350
|
}
|
|
351
|
+
|
|
236
352
|
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
237
353
|
if err != nil {
|
|
238
|
-
t.Fatalf("
|
|
354
|
+
t.Fatalf("不认识的文件应静默跳过,不该报错: %v", err)
|
|
239
355
|
}
|
|
240
|
-
if added !=
|
|
241
|
-
t.Errorf("added = %d, want
|
|
356
|
+
if added != 0 {
|
|
357
|
+
t.Errorf("added = %d, want 0(不碰未知格式)", added)
|
|
358
|
+
}
|
|
359
|
+
after, rerr := os.ReadFile(path)
|
|
360
|
+
if rerr != nil {
|
|
361
|
+
t.Fatalf("原文件必须还在: %v", rerr)
|
|
362
|
+
}
|
|
363
|
+
if !bytes.Equal(after, broken) {
|
|
364
|
+
t.Errorf("原文件被改写了:%s", after)
|
|
242
365
|
}
|
|
243
366
|
}
|
|
244
367
|
|
package/internal/agent/env.go
CHANGED
|
@@ -80,9 +80,17 @@ func ChildEnvWith(extraEnv []string) []string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// envDenied 判断某环境变量是否不允许传给子 CLI。
|
|
83
|
+
//
|
|
84
|
+
// ⚠️ 大小写不敏感(2026-09-30 修,Windows 事故):环境变量名在 **Windows 上本身
|
|
85
|
+
// 不区分大小写**,父会话用 `server__port` / `Server__Port` 这类大小写写出来时,
|
|
86
|
+
// 原来的逐字符比较会**漏剔除** —— 子 CLI 于是仍读到父进程的监听端口,再起一个服务
|
|
87
|
+
// 撞上 EADDRINUSE,表现为「一进登录界面就卡死 / 嵌套调用永远没结果」。
|
|
88
|
+
// Unix 上变量名区分大小写,折叠大小写只会**更保守**(多剔除几个父会话风格的名字),
|
|
89
|
+
// 不会少剔除,故这里两个平台统一按大写比较。
|
|
83
90
|
func envDenied(name string) bool {
|
|
84
|
-
|
|
91
|
+
upper := strings.ToUpper(name)
|
|
92
|
+
if strings.HasPrefix(upper, serverEnvPrefix) {
|
|
85
93
|
return true
|
|
86
94
|
}
|
|
87
|
-
return envDenylist[
|
|
95
|
+
return envDenylist[upper]
|
|
88
96
|
}
|
|
@@ -86,6 +86,43 @@ func TestEnvironStripsPythonVars(t *testing.T) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Windows 的变量名不区分大小写:父会话若写成混合大小写,也必须被剔除。
|
|
90
|
+
// 2026-09-30 事故:漏剔除 → 子 CLI 仍读到父进程端口 → EADDRINUSE 永久挂起,
|
|
91
|
+
// 表现就是「一进登录界面就上不去」。
|
|
92
|
+
func TestEnvDeniedCaseInsensitive(t *testing.T) {
|
|
93
|
+
cases := map[string]bool{
|
|
94
|
+
"server__port": true,
|
|
95
|
+
"Server__Port": true,
|
|
96
|
+
"server__host": true,
|
|
97
|
+
"codebuddy_session_id": true,
|
|
98
|
+
"Codebuddy_Session_Id": true,
|
|
99
|
+
"pythonhome": true,
|
|
100
|
+
"PATH": false,
|
|
101
|
+
"HOME": false,
|
|
102
|
+
// 仍必须放行:引擎靠它选配置目录(见 codebuddyAccountEnv)
|
|
103
|
+
"codebuddy_config_dir": false,
|
|
104
|
+
}
|
|
105
|
+
for name, want := range cases {
|
|
106
|
+
if got := envDenied(name); got != want {
|
|
107
|
+
t.Errorf("envDenied(%q) = %v, want %v", name, got, want)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 混合大小写的父会话变量在 environ() 里也必须不见。
|
|
113
|
+
func TestEnvironStripsMixedCaseServerPrefix(t *testing.T) {
|
|
114
|
+
t.Setenv("server__port", "58311")
|
|
115
|
+
t.Setenv("Server__Host", "127.0.0.1")
|
|
116
|
+
t.Setenv("codebuddy_session_id", "should-be-stripped")
|
|
117
|
+
|
|
118
|
+
for _, kv := range environ() {
|
|
119
|
+
name, _, _ := strings.Cut(kv, "=")
|
|
120
|
+
if envDenied(name) {
|
|
121
|
+
t.Errorf("被拒绝的变量仍出现在子 CLI 环境里: %s", kv)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
89
126
|
func TestEnvDenied(t *testing.T) {
|
|
90
127
|
cases := map[string]bool{
|
|
91
128
|
"SERVER__PORT": true,
|
|
@@ -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/cli/ask.go
CHANGED
|
@@ -44,24 +44,27 @@ import (
|
|
|
44
44
|
|
|
45
45
|
// askOptions 根命令的全部参数。
|
|
46
46
|
type askOptions struct {
|
|
47
|
-
engine
|
|
48
|
-
model
|
|
49
|
-
system
|
|
50
|
-
prompt
|
|
51
|
-
file
|
|
52
|
-
attach
|
|
53
|
-
tools
|
|
54
|
-
timeout
|
|
55
|
-
retries
|
|
56
|
-
backoff
|
|
57
|
-
output
|
|
58
|
-
verbose
|
|
59
|
-
engines
|
|
60
|
-
contract
|
|
61
|
-
noModels
|
|
62
|
-
login
|
|
63
|
-
|
|
64
|
-
|
|
47
|
+
engine string
|
|
48
|
+
model string
|
|
49
|
+
system string
|
|
50
|
+
prompt string
|
|
51
|
+
file string
|
|
52
|
+
attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
|
|
53
|
+
tools string
|
|
54
|
+
timeout time.Duration
|
|
55
|
+
retries int
|
|
56
|
+
backoff time.Duration
|
|
57
|
+
output string
|
|
58
|
+
verbose bool
|
|
59
|
+
engines bool
|
|
60
|
+
contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
|
|
61
|
+
noModels bool
|
|
62
|
+
login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
|
|
63
|
+
// repairModels 还原被反向同步写坏的桌面端 models.json(--repair-models <engine>):
|
|
64
|
+
// 2026-09-30 Windows 事故的收尾手段,见 internal/agent/custom_models.go 的事故还原一节。
|
|
65
|
+
repairModels string
|
|
66
|
+
events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
|
|
67
|
+
control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
|
|
65
68
|
jsonOut bool
|
|
66
69
|
stream bool
|
|
67
70
|
noThinking bool
|
|
@@ -145,6 +148,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
|
|
|
145
148
|
f.BoolVar(&opts.contract, "contract", false, "输出桌面/移动客户端契约:{\"contractVersion\":N,\"engines\":[...]}(engines 与 --engines 同构,另含 capabilities 静态能力字段)。默认不探测模型(快);要模型写 --no-models=false")
|
|
146
149
|
f.BoolVar(&opts.noModels, "no-models", false, "配合 --engines / --contract:跳过各引擎的模型探测(只列引擎与可用性,不启动 CLI)")
|
|
147
150
|
f.StringVar(&opts.login, "login", "", "拉起指定引擎自己的交互式登录会话(如 codebuddy / codebuddy-ai):自动带上该引擎的账号环境,进去执行 /login 即可;登录态落在该引擎自己的票据上,两个账号互不顶号")
|
|
151
|
+
f.StringVar(&opts.repairModels, "repair-models", "", "还原被覆盖写坏的桌面端 models.json(如 codebuddy / codebuddy-ai):把对象格式改回桌面端的数组格式,官方模型清单不动;只在形状确实是坏的时候才改,且先留 .broken.bak 备份")
|
|
148
152
|
f.BoolVar(&opts.jsonOut, "json", false, "兼容保留:--engines 已默认 JSON,本 flag 不再需要")
|
|
149
153
|
f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
|
|
150
154
|
f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
|
package/internal/cli/login.go
CHANGED
|
@@ -14,6 +14,7 @@ import (
|
|
|
14
14
|
"os"
|
|
15
15
|
"os/exec"
|
|
16
16
|
"strings"
|
|
17
|
+
"sync"
|
|
17
18
|
|
|
18
19
|
"github.com/darren/magic-agent/internal/agent"
|
|
19
20
|
"github.com/spf13/cobra"
|
|
@@ -40,7 +41,11 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
|
|
|
40
41
|
return err
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/* stdio 直通:交互式界面必须拿到真的 TTY。
|
|
45
|
+
⚠️ 这里**刻意不设进程组**(普通引擎调用走 configureProcAttr 自成一组):
|
|
46
|
+
交互式 TUI 必须在**前台进程组**里,否则它从 tty 读输入会被 SIGTTIN 停住 ——
|
|
47
|
+
表现就是「一进登录界面就卡住」。代之以「登记子进程 + 退出前带走」的清理
|
|
48
|
+
(见 loginChild / stopLoginChild),保证它不会变成占着账号的孤儿。 */
|
|
44
49
|
c := exec.Command(bin, args...)
|
|
45
50
|
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
46
51
|
c.Env = agent.ChildEnvWith(extraEnv)
|
|
@@ -49,20 +54,72 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
// 提示走 stderr:stdout 留给子进程的交互界面,不被污染。
|
|
57
|
+
// 第三行是给「上一次登录没成功」的情形:残留的登录态得先清掉,否则会一直登不上。
|
|
52
58
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
53
59
|
"magic-agent: 已按 %s 的账号环境启动 %s\n"+
|
|
54
|
-
" 在里面执行 /login 完成登录(退出后本命令结束)\n"
|
|
60
|
+
" 在里面执行 /login 完成登录(退出后本命令结束)\n"+
|
|
61
|
+
" 若上一次登录没成功、这次进去仍登不上:先执行 /logout 清掉残留登录态,再执行 /login\n",
|
|
62
|
+
eng.Name(), bin)
|
|
55
63
|
|
|
56
|
-
if
|
|
64
|
+
if startErr := c.Start(); startErr != nil {
|
|
65
|
+
return fmt.Errorf("启动 %s 失败: %w", bin, startErr)
|
|
66
|
+
}
|
|
67
|
+
setLoginChild(c)
|
|
68
|
+
defer clearLoginChild()
|
|
69
|
+
|
|
70
|
+
if runErr := c.Wait(); runErr != nil {
|
|
57
71
|
var ee *exec.ExitError
|
|
58
72
|
if errors.As(runErr, &ee) {
|
|
59
73
|
return fmt.Errorf("%s 会话退出码 %d", eng.Name(), ee.ExitCode())
|
|
60
74
|
}
|
|
61
|
-
return fmt.Errorf("
|
|
75
|
+
return fmt.Errorf("%s 会话异常: %w", eng.Name(), runErr)
|
|
62
76
|
}
|
|
63
77
|
return nil
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
/* ── 交互式登录子进程的清理登记 ──────────────────────────────
|
|
81
|
+
*
|
|
82
|
+
* 为什么单独记一笔:登录是**唯一**不走进程组管理的子进程(理由见上面 runLogin 的
|
|
83
|
+
* 注释 —— 交互式 TUI 必须留在前台组里)。因此它既不在会话登记表(--stop 管不到),
|
|
84
|
+
* 也不会被 installSignalStop 的「杀会话」路径带走。一旦 magic-agent 被 SIGTERM
|
|
85
|
+
* (桌面壳的「停止」按钮、`kill <pid>`)终止,这个 CLI 就成孤儿,继续占着该账号的
|
|
86
|
+
* 配置目录 / 端口,下一次 --login 起来会撞上残留状态 —— 用户看到的就是
|
|
87
|
+
* 「登录失败后再也登不上」。所以退出前必须补一刀。
|
|
88
|
+
*/
|
|
89
|
+
var (
|
|
90
|
+
loginMu sync.Mutex
|
|
91
|
+
loginChild *exec.Cmd
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// setLoginChild 登记正在跑的登录子进程。
|
|
95
|
+
func setLoginChild(c *exec.Cmd) {
|
|
96
|
+
loginMu.Lock()
|
|
97
|
+
loginChild = c
|
|
98
|
+
loginMu.Unlock()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// clearLoginChild 注销(登录会话正常结束 / 启动失败时调用)。
|
|
102
|
+
func clearLoginChild() {
|
|
103
|
+
setLoginChild(nil)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* stopLoginChild 杀掉正在跑的登录子进程(若在),返回是否真的动了手。
|
|
107
|
+
*
|
|
108
|
+
* 用**单进程**信号(group=false):它本来就与 magic-agent 同组,负 pid 会把
|
|
109
|
+
* magic-agent 自己一起杀掉。
|
|
110
|
+
*/
|
|
111
|
+
func stopLoginChild() bool {
|
|
112
|
+
loginMu.Lock()
|
|
113
|
+
c := loginChild
|
|
114
|
+
loginChild = nil
|
|
115
|
+
loginMu.Unlock()
|
|
116
|
+
if c == nil || c.Process == nil {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
_ = agent.KillPID(c.Process.Pid, false)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
66
123
|
// loginCapableEngines 列出实现了 LoginRunner 的引擎名(报错提示用)。
|
|
67
124
|
func loginCapableEngines() string {
|
|
68
125
|
var names []string
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// login_test.go - `--login` 的参数校验与「退出前带走登录子进程」的清理契约。
|
|
4
|
+
//
|
|
5
|
+
// 这两件事都是「登录失败后再也登不上」那个用户问题的直接成因:
|
|
6
|
+
// - 参数写错时给一句看得懂的中文(而不是 cobra 的英文裸报错,且退出码要对);
|
|
7
|
+
// - 登录 TUI 必须留在前台进程组里(否则读 tty 被 SIGTTIN 停住),代价是
|
|
8
|
+
// magic-agent 被 SIGTERM / 关终端时必须主动把它带走,不能留成占着账号的孤儿。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"errors"
|
|
12
|
+
"os/exec"
|
|
13
|
+
"runtime"
|
|
14
|
+
"strings"
|
|
15
|
+
"testing"
|
|
16
|
+
"time"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// `--login` 后面没跟引擎名:必须给出中文 usage(点名可用引擎),且算参数错(exit 2)。
|
|
20
|
+
// cobra 在解析阶段就报错(走不到 runLogin),所以这条靠 SetFlagErrorFunc 兜住。
|
|
21
|
+
func TestLoginWithoutEngineNameIsUsageError(t *testing.T) {
|
|
22
|
+
_, _, err := runAskCmd(t, "", "--login")
|
|
23
|
+
if err == nil {
|
|
24
|
+
t.Fatal("--login 缺引擎名应报错")
|
|
25
|
+
}
|
|
26
|
+
msg := err.Error()
|
|
27
|
+
if !strings.Contains(msg, "需要引擎名") {
|
|
28
|
+
t.Errorf("应是中文用法提示,实际 %q", msg)
|
|
29
|
+
}
|
|
30
|
+
/* ⚠️ 不断言「提示里点名了 codebuddy」:包内用例会在 Engines() 首次调用前
|
|
31
|
+
agent.Register 假引擎,触发 registry 的已知脆弱点(内置引擎整体不注册,
|
|
32
|
+
见 agent/engine.go 的守卫注释),那时可用引擎列表本来就是空的。
|
|
33
|
+
这里只锚定稳定契约:中文提示 + usage(exit 2)。 */
|
|
34
|
+
if _, ok := err.(*usageError); !ok {
|
|
35
|
+
t.Errorf("应是 usageError(exit 2),实际 %T", err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 未知引擎名:usage 错误 + 列出可用引擎,不静默启一个空 bin。
|
|
40
|
+
func TestLoginUnknownEngine(t *testing.T) {
|
|
41
|
+
_, _, err := runAskCmd(t, "", "--login", "no-such-engine")
|
|
42
|
+
if err == nil {
|
|
43
|
+
t.Fatal("未知引擎应报错")
|
|
44
|
+
}
|
|
45
|
+
if !strings.Contains(err.Error(), "未知引擎") {
|
|
46
|
+
t.Errorf("错误应点名未知引擎,实际 %q", err.Error())
|
|
47
|
+
}
|
|
48
|
+
if _, ok := err.(*usageError); !ok {
|
|
49
|
+
t.Errorf("应是 usageError(exit 2),实际 %T", err)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 不支持交互式登录的引擎:明确说不必用 --login,而不是硬造一条命令。
|
|
54
|
+
func TestLoginEngineWithoutLoginRunner(t *testing.T) {
|
|
55
|
+
_, _, err := runAskCmd(t, "", "--login", "claude")
|
|
56
|
+
if err == nil {
|
|
57
|
+
t.Fatal("claude 不支持 --login,应报错")
|
|
58
|
+
}
|
|
59
|
+
if !strings.Contains(err.Error(), "不需要 --login") {
|
|
60
|
+
t.Errorf("错误应说明该引擎不需要 --login,实际 %q", err.Error())
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 登录子进程必须能被 stopLoginChild 收掉(进程真死)。
|
|
65
|
+
// 为什么值得测:登录是唯一不进进程组的子进程,孤儿化正是「登录失败后再也登不上」的成因。
|
|
66
|
+
//
|
|
67
|
+
// 判据用 c.Wait()(真回收)而不是 PIDAlive —— 被 SIGKILL 的子进程在没人 Wait 之前
|
|
68
|
+
// 是僵尸,仍然会被 kill(pid, 0) 判成「活着」,拿它当判据会假红。
|
|
69
|
+
func TestStopLoginChildKillsProcess(t *testing.T) {
|
|
70
|
+
if runtime.GOOS == "windows" {
|
|
71
|
+
t.Skip("用 sleep 当靶子,Windows 另论")
|
|
72
|
+
}
|
|
73
|
+
c := exec.Command("sleep", "60")
|
|
74
|
+
if err := c.Start(); err != nil {
|
|
75
|
+
t.Fatalf("起靶子进程失败: %v", err)
|
|
76
|
+
}
|
|
77
|
+
setLoginChild(c)
|
|
78
|
+
|
|
79
|
+
if !stopLoginChild() {
|
|
80
|
+
t.Fatal("stopLoginChild 应报告动了手")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
done := make(chan error, 1)
|
|
84
|
+
go func() { done <- c.Wait() }()
|
|
85
|
+
select {
|
|
86
|
+
case werr := <-done:
|
|
87
|
+
var ee *exec.ExitError
|
|
88
|
+
if !errors.As(werr, &ee) {
|
|
89
|
+
t.Fatalf("靶子应被信号杀掉,实际 err=%v", werr)
|
|
90
|
+
}
|
|
91
|
+
case <-time.After(5 * time.Second):
|
|
92
|
+
t.Fatal("登录子进程未被收掉(5s 内没退出)")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 已经清空:再调一次不应再动手(幂等,避免误杀复用的 pid)。
|
|
96
|
+
if stopLoginChild() {
|
|
97
|
+
t.Error("注销后再调 stopLoginChild 不应再动手")
|
|
98
|
+
}
|
|
99
|
+
}
|