magic-agent 2.4.3 → 2.6.0
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 +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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/package.json +8 -2
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"os"
|
|
9
|
+
"os/exec"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"runtime"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
/* 脚本(发现页的「脚本」):
|
|
20
|
+
* ① 客户端只能登记「脚本文件路径 + 参数」,**不能传任意命令行**;
|
|
21
|
+
* ② 扩展名走白名单,.exe / .txt 一律拒绝;
|
|
22
|
+
* ③ start 真把脚本跑起来,日志逐行到达,状态如实(done / failed / stopped);
|
|
23
|
+
* ④ stop 杀掉长跑脚本(整组),重复 start / 运行中 delete 都报 409。 */
|
|
24
|
+
|
|
25
|
+
/* scriptSvc 起一个脚本服务:数据目录指到临时目录,不碰用户真实的 ~/.magic-agent/scripts。 */
|
|
26
|
+
func scriptSvc(t *testing.T) *Service {
|
|
27
|
+
t.Helper()
|
|
28
|
+
dir := t.TempDir()
|
|
29
|
+
t.Setenv(envScriptsDir, dir)
|
|
30
|
+
return &Service{opts: Options{}}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* writeScript 造一个能跑的脚本桩,返回路径。
|
|
34
|
+
*
|
|
35
|
+
* Windows 上 exec 不了 shebang 脚本(CreateProcess 不认 #!),改发 .cmd
|
|
36
|
+
* (走 agent.WrapLaunch 的 PowerShell 包装);unix 上发 .sh。
|
|
37
|
+
* 两条路的**语义逐条对齐**:同样的行、同样的顺序。
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ Windows 上脚本文件**按 GBK 存**(中文 Windows 的记事本默认那样),
|
|
40
|
+
* 且脚本输出走控制台代码页(936)→ 管道里是 GBK 字节。
|
|
41
|
+
* 这与服务端 scripts_decode_windows.go 的解码口径正好对上:
|
|
42
|
+
* 写 GBK、读 GBK,中文能正确还原。
|
|
43
|
+
* (写成 UTF-8 的话,cmd.exe 会拿代码页去解释脚本里的字节 → 乱码命令。)
|
|
44
|
+
*/
|
|
45
|
+
func writeScript(t *testing.T, name string, lines []string) string {
|
|
46
|
+
t.Helper()
|
|
47
|
+
dir := t.TempDir()
|
|
48
|
+
if runtime.GOOS == "windows" {
|
|
49
|
+
path := filepath.Join(dir, name+".cmd")
|
|
50
|
+
out := []string{"@echo off"}
|
|
51
|
+
for _, l := range lines {
|
|
52
|
+
// 批处理里 echo 空行要写成 "echo.",否则会被跳过。
|
|
53
|
+
if strings.TrimSpace(l) == "" {
|
|
54
|
+
out = append(out, "echo.")
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
out = append(out, "echo "+l)
|
|
58
|
+
}
|
|
59
|
+
body := strings.Join(out, "\r\n") + "\r\n"
|
|
60
|
+
if err := os.WriteFile(path, encodeWindowsScript(body), 0o755); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
return path
|
|
64
|
+
}
|
|
65
|
+
path := filepath.Join(dir, name+".sh")
|
|
66
|
+
body := "#!/bin/sh\n" + strings.Join(lines, "\n") + "\n"
|
|
67
|
+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
return path
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// encodeWindowsScript 把脚本内容按**当前控制台代码页**编码。
|
|
74
|
+
//
|
|
75
|
+
// Windows 上 cmd.exe 按代码页(936=GBK)解释脚本文件里的字节 —— 文件是 UTF-8
|
|
76
|
+
// 的话,「echo 中文」这一行会被拆成乱码命令,直接报「不是内部或外部命令」。
|
|
77
|
+
// 所以测试桩必须按代码页存,才和真实用户脚本一致。
|
|
78
|
+
func encodeWindowsScript(s string) []byte {
|
|
79
|
+
cp := scriptCodePage()
|
|
80
|
+
if b, ok := encodeACP(s, cp); ok {
|
|
81
|
+
return b
|
|
82
|
+
}
|
|
83
|
+
return []byte(s)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// writeLongScript 造一个「一直跑」的脚本(等 stop 才退出)。
|
|
87
|
+
func writeLongScript(t *testing.T) string {
|
|
88
|
+
t.Helper()
|
|
89
|
+
dir := t.TempDir()
|
|
90
|
+
if runtime.GOOS == "windows" {
|
|
91
|
+
path := filepath.Join(dir, "long.cmd")
|
|
92
|
+
body := "@echo off\r\n" +
|
|
93
|
+
"echo started\r\n" +
|
|
94
|
+
":loop\r\n" +
|
|
95
|
+
"ping -n 2 127.0.0.1 >nul\r\n" +
|
|
96
|
+
"goto loop\r\n"
|
|
97
|
+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
return path
|
|
101
|
+
}
|
|
102
|
+
path := filepath.Join(dir, "long.sh")
|
|
103
|
+
body := "#!/bin/sh\n" +
|
|
104
|
+
"echo started\n" +
|
|
105
|
+
"while true; do sleep 1; done\n"
|
|
106
|
+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
return path
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// addScript 登记一个脚本,返回 id。
|
|
113
|
+
func addScript(t *testing.T, s *Service, path string, args ...string) string {
|
|
114
|
+
t.Helper()
|
|
115
|
+
rec := httptest.NewRecorder()
|
|
116
|
+
body, _ := json.Marshal(map[string]any{"name": "测试脚本", "path": path, "args": args})
|
|
117
|
+
req := httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body)))
|
|
118
|
+
s.handleScripts(rec, req)
|
|
119
|
+
if rec.Code != http.StatusOK {
|
|
120
|
+
t.Fatalf("登记脚本失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
121
|
+
}
|
|
122
|
+
var item struct {
|
|
123
|
+
ID string `json:"id"`
|
|
124
|
+
}
|
|
125
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &item); err != nil {
|
|
126
|
+
t.Fatalf("响应不是合法 JSON:%v", err)
|
|
127
|
+
}
|
|
128
|
+
if item.ID == "" {
|
|
129
|
+
t.Fatal("登记成功但没给 id")
|
|
130
|
+
}
|
|
131
|
+
return item.ID
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// addScriptDef 登记一个脚本(完整字段),返回 id。
|
|
135
|
+
//
|
|
136
|
+
// 与 addScript 的区别:能带解释器与环境变量 —— 这两个字段是
|
|
137
|
+
// 「白名单里没有 .exe 时 Python/Node 脚本怎么跑」的落地点。
|
|
138
|
+
func addScriptDef(t *testing.T, s *Service, def map[string]any) string {
|
|
139
|
+
t.Helper()
|
|
140
|
+
rec := httptest.NewRecorder()
|
|
141
|
+
body, _ := json.Marshal(def)
|
|
142
|
+
req := httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body)))
|
|
143
|
+
s.handleScripts(rec, req)
|
|
144
|
+
if rec.Code != http.StatusOK {
|
|
145
|
+
t.Fatalf("登记脚本失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
146
|
+
}
|
|
147
|
+
var item struct {
|
|
148
|
+
ID string `json:"id"`
|
|
149
|
+
}
|
|
150
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &item); err != nil {
|
|
151
|
+
t.Fatalf("响应不是合法 JSON:%v", err)
|
|
152
|
+
}
|
|
153
|
+
if item.ID == "" {
|
|
154
|
+
t.Fatal("登记成功但没给 id")
|
|
155
|
+
}
|
|
156
|
+
return item.ID
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// absInterpreter 找一个**可用的解释器绝对路径**(跨平台)。
|
|
160
|
+
//
|
|
161
|
+
// Windows 上用 PowerShell(.ps1 可以直接当参数传给它),
|
|
162
|
+
// unix 上用 sh(.sh 同理)。
|
|
163
|
+
//
|
|
164
|
+
// ⚠️ 不能用 .cmd 当解释器:Windows 的 CreateProcess **不认批处理**,
|
|
165
|
+
//
|
|
166
|
+
// exec 直接跑 .cmd 会报「不是有效的 Win32 应用程序」。
|
|
167
|
+
func absInterpreter(t *testing.T) string {
|
|
168
|
+
t.Helper()
|
|
169
|
+
name := "sh"
|
|
170
|
+
if runtime.GOOS == "windows" {
|
|
171
|
+
name = "powershell.exe"
|
|
172
|
+
}
|
|
173
|
+
p, err := exec.LookPath(name)
|
|
174
|
+
if err != nil {
|
|
175
|
+
t.Skipf("这台机器上找不到 %s,跳过解释器测试", name)
|
|
176
|
+
}
|
|
177
|
+
abs, err := filepath.Abs(p)
|
|
178
|
+
if err != nil {
|
|
179
|
+
t.Fatal(err)
|
|
180
|
+
}
|
|
181
|
+
return abs
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ─────────────── 清单 ─────────────── */
|
|
185
|
+
|
|
186
|
+
func TestScriptsAddListUpdateRemove(t *testing.T) {
|
|
187
|
+
s := scriptSvc(t)
|
|
188
|
+
path := writeScript(t, "hello", []string{"hi"})
|
|
189
|
+
id := addScript(t, s, path)
|
|
190
|
+
|
|
191
|
+
// 清单里能看到它,路径与参数原样带出去。
|
|
192
|
+
rec := httptest.NewRecorder()
|
|
193
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodGet, "/desk/scripts", nil))
|
|
194
|
+
if rec.Code != http.StatusOK {
|
|
195
|
+
t.Fatalf("读清单失败:HTTP %d", rec.Code)
|
|
196
|
+
}
|
|
197
|
+
var body struct {
|
|
198
|
+
Scripts []ScriptItem `json:"scripts"`
|
|
199
|
+
}
|
|
200
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
if len(body.Scripts) != 1 {
|
|
204
|
+
t.Fatalf("脚本数 = %d,期望 1", len(body.Scripts))
|
|
205
|
+
}
|
|
206
|
+
if body.Scripts[0].Path != path {
|
|
207
|
+
t.Fatalf("路径 = %q,期望 %q", body.Scripts[0].Path, path)
|
|
208
|
+
}
|
|
209
|
+
if body.Scripts[0].State.State != "stopped" {
|
|
210
|
+
t.Fatalf("初始状态 = %q,期望 stopped", body.Scripts[0].State.State)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 改:换名字与参数。
|
|
214
|
+
rec = httptest.NewRecorder()
|
|
215
|
+
upd, _ := json.Marshal(map[string]any{"name": "改名了", "path": path, "args": []string{"--x"}})
|
|
216
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPut, "/desk/scripts/"+id, strings.NewReader(string(upd))))
|
|
217
|
+
if rec.Code != http.StatusOK {
|
|
218
|
+
t.Fatalf("改脚本失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
219
|
+
}
|
|
220
|
+
item, ok := s.scriptMgr().get(id)
|
|
221
|
+
if !ok {
|
|
222
|
+
t.Fatal("改完就找不到了")
|
|
223
|
+
}
|
|
224
|
+
if item.Name != "改名了" || len(item.Args) != 1 || item.Args[0] != "--x" {
|
|
225
|
+
t.Fatalf("改完的内容不对:%+v", item.ScriptDef)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 删。
|
|
229
|
+
rec = httptest.NewRecorder()
|
|
230
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodDelete, "/desk/scripts/"+id, nil))
|
|
231
|
+
if rec.Code != http.StatusOK {
|
|
232
|
+
t.Fatalf("删脚本失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
233
|
+
}
|
|
234
|
+
if _, ok := s.scriptMgr().get(id); ok {
|
|
235
|
+
t.Fatal("删完还在")
|
|
236
|
+
}
|
|
237
|
+
// 落盘日志也要跟着删 —— 界面上写的是「配置与日志都会被删掉」,
|
|
238
|
+
// 留着文件就是话说了不算。
|
|
239
|
+
if p := s.scriptMgr().logPath(id); p != "" {
|
|
240
|
+
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
241
|
+
t.Fatalf("删完日志文件还在:%s", p)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ─────────────── 校验:白名单是安全落地点 ─────────────── */
|
|
247
|
+
|
|
248
|
+
func TestScriptsRejectNonScriptFiles(t *testing.T) {
|
|
249
|
+
s := scriptSvc(t)
|
|
250
|
+
dir := t.TempDir()
|
|
251
|
+
|
|
252
|
+
// .txt —— 不是脚本
|
|
253
|
+
txt := filepath.Join(dir, "notes.txt")
|
|
254
|
+
if err := os.WriteFile(txt, []byte("hello"), 0o644); err != nil {
|
|
255
|
+
t.Fatal(err)
|
|
256
|
+
}
|
|
257
|
+
// .exe —— 可执行文件不是脚本(允许它就等于允许跑任意二进制)
|
|
258
|
+
exe := filepath.Join(dir, "tool.exe")
|
|
259
|
+
if err := os.WriteFile(exe, []byte("MZ"), 0o755); err != nil {
|
|
260
|
+
t.Fatal(err)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for _, p := range []string{txt, exe} {
|
|
264
|
+
rec := httptest.NewRecorder()
|
|
265
|
+
body, _ := json.Marshal(map[string]any{"name": "x", "path": p})
|
|
266
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
267
|
+
if rec.Code == http.StatusOK {
|
|
268
|
+
t.Fatalf("扩展名不在白名单内的文件被接受了:%s", p)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 不存在的文件 → 拒绝(不能登记一个跑不起来的东西然后假装能跑)。
|
|
273
|
+
missing := filepath.Join(dir, "nope.sh")
|
|
274
|
+
rec := httptest.NewRecorder()
|
|
275
|
+
body, _ := json.Marshal(map[string]any{"name": "x", "path": missing})
|
|
276
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
277
|
+
if rec.Code == http.StatusOK {
|
|
278
|
+
t.Fatal("不存在的文件被接受了")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 相对路径 → 拒绝(服务端不猜工作目录)。
|
|
282
|
+
rec = httptest.NewRecorder()
|
|
283
|
+
body, _ = json.Marshal(map[string]any{"name": "x", "path": "rel.sh"})
|
|
284
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
285
|
+
if rec.Code == http.StatusOK {
|
|
286
|
+
t.Fatal("相对路径被接受了")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 空路径 → 拒绝。
|
|
290
|
+
rec = httptest.NewRecorder()
|
|
291
|
+
body, _ = json.Marshal(map[string]any{"name": "x", "path": ""})
|
|
292
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
293
|
+
if rec.Code == http.StatusOK {
|
|
294
|
+
t.Fatal("空路径被接受了")
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ─────────────── 启停与日志 ─────────────── */
|
|
299
|
+
|
|
300
|
+
// waitState 等脚本进入某个状态(最多 d)。
|
|
301
|
+
func waitState(t *testing.T, s *Service, id string, want string, d time.Duration) ScriptState {
|
|
302
|
+
t.Helper()
|
|
303
|
+
deadline := time.Now().Add(d)
|
|
304
|
+
var last ScriptState
|
|
305
|
+
for time.Now().Before(deadline) {
|
|
306
|
+
item, ok := s.scriptMgr().get(id)
|
|
307
|
+
if !ok {
|
|
308
|
+
t.Fatalf("脚本 %s 没了", id)
|
|
309
|
+
}
|
|
310
|
+
last = item.State
|
|
311
|
+
if last.State == want {
|
|
312
|
+
return last
|
|
313
|
+
}
|
|
314
|
+
time.Sleep(30 * time.Millisecond)
|
|
315
|
+
}
|
|
316
|
+
t.Fatalf("脚本 %s 的状态 = %q,期望 %q(等了 %s)", id, last.State, want, d)
|
|
317
|
+
return last
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func TestScriptsStartRunsAndStreamsLog(t *testing.T) {
|
|
321
|
+
s := scriptSvc(t)
|
|
322
|
+
path := writeScript(t, "hello", []string{"第一行", "第二行"})
|
|
323
|
+
id := addScript(t, s, path)
|
|
324
|
+
|
|
325
|
+
rec := httptest.NewRecorder()
|
|
326
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
327
|
+
if rec.Code != http.StatusOK {
|
|
328
|
+
t.Fatalf("启动失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
329
|
+
}
|
|
330
|
+
var item ScriptItem
|
|
331
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &item); err != nil {
|
|
332
|
+
t.Fatal(err)
|
|
333
|
+
}
|
|
334
|
+
if item.State.State != "running" {
|
|
335
|
+
t.Fatalf("刚启动的状态 = %q,期望 running", item.State.State)
|
|
336
|
+
}
|
|
337
|
+
if item.State.PID <= 0 {
|
|
338
|
+
t.Fatal("启动后没有 pid")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 跑完 → done(退出码 0)。
|
|
342
|
+
waitState(t, s, id, "done", 20*time.Second)
|
|
343
|
+
|
|
344
|
+
// 日志里能看到那两行。
|
|
345
|
+
lines, _, ok := s.scriptMgr().logLines(id, 0)
|
|
346
|
+
if !ok {
|
|
347
|
+
t.Fatal("读日志失败")
|
|
348
|
+
}
|
|
349
|
+
joined := scriptLinesText(lines)
|
|
350
|
+
if !strings.Contains(joined, "第一行") || !strings.Contains(joined, "第二行") {
|
|
351
|
+
t.Fatalf("日志里没有脚本输出:%q", joined)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func TestScriptsFailedExitIsReportedAsFailed(t *testing.T) {
|
|
356
|
+
s := scriptSvc(t)
|
|
357
|
+
// 退出码非 0 的脚本(unix 用 exit 3;Windows 批处理 exit /b 3)。
|
|
358
|
+
dir := t.TempDir()
|
|
359
|
+
var path string
|
|
360
|
+
if runtime.GOOS == "windows" {
|
|
361
|
+
path = filepath.Join(dir, "bad.cmd")
|
|
362
|
+
body := "@echo off\r\necho 准备失败\r\nexit /b 3\r\n"
|
|
363
|
+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
364
|
+
t.Fatal(err)
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
path = filepath.Join(dir, "bad.sh")
|
|
368
|
+
body := "#!/bin/sh\necho 准备失败\nexit 3\n"
|
|
369
|
+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
370
|
+
t.Fatal(err)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
id := addScript(t, s, path)
|
|
374
|
+
|
|
375
|
+
rec := httptest.NewRecorder()
|
|
376
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
377
|
+
if rec.Code != http.StatusOK {
|
|
378
|
+
t.Fatalf("启动失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
379
|
+
}
|
|
380
|
+
st := waitState(t, s, id, "failed", 20*time.Second)
|
|
381
|
+
if st.ExitCode != 3 {
|
|
382
|
+
t.Fatalf("退出码 = %d,期望 3", st.ExitCode)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func TestScriptsStopKillsLongRunning(t *testing.T) {
|
|
387
|
+
s := scriptSvc(t)
|
|
388
|
+
path := writeLongScript(t)
|
|
389
|
+
id := addScript(t, s, path)
|
|
390
|
+
|
|
391
|
+
rec := httptest.NewRecorder()
|
|
392
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
393
|
+
if rec.Code != http.StatusOK {
|
|
394
|
+
t.Fatalf("启动失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
395
|
+
}
|
|
396
|
+
item, _ := s.scriptMgr().get(id)
|
|
397
|
+
pid := item.State.PID
|
|
398
|
+
if pid <= 0 {
|
|
399
|
+
t.Fatal("没有 pid")
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 等它真的起来了(输出第一行)再停 —— 否则可能停了个还没 spawn 的。
|
|
403
|
+
waitForLog(t, s, id, "started", 20*time.Second)
|
|
404
|
+
|
|
405
|
+
rec = httptest.NewRecorder()
|
|
406
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/stop", nil))
|
|
407
|
+
if rec.Code != http.StatusOK {
|
|
408
|
+
t.Fatalf("停止失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 状态必须是 stopped(**不是 failed**):用户是自己点的停止,不是脚本出错。
|
|
412
|
+
st := waitState(t, s, id, "stopped", 20*time.Second)
|
|
413
|
+
if st.ExitCode == 3 {
|
|
414
|
+
t.Fatalf("主动停止不该记成失败(退出码 %d)", st.ExitCode)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 进程真的没了。
|
|
418
|
+
deadline := time.Now().Add(10 * time.Second)
|
|
419
|
+
for time.Now().Before(deadline) {
|
|
420
|
+
if !pidAlive(pid) {
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
time.Sleep(100 * time.Millisecond)
|
|
424
|
+
}
|
|
425
|
+
t.Fatalf("pid %d 还活着 —— 停止没把进程带走", pid)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
func TestScriptsRejectDoubleStartAndDeleteWhileRunning(t *testing.T) {
|
|
429
|
+
s := scriptSvc(t)
|
|
430
|
+
path := writeLongScript(t)
|
|
431
|
+
id := addScript(t, s, path)
|
|
432
|
+
|
|
433
|
+
rec := httptest.NewRecorder()
|
|
434
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
435
|
+
if rec.Code != http.StatusOK {
|
|
436
|
+
t.Fatalf("启动失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
437
|
+
}
|
|
438
|
+
waitForLog(t, s, id, "started", 20*time.Second)
|
|
439
|
+
|
|
440
|
+
// 重复启动 → 409(不能一个脚本跑两份)。
|
|
441
|
+
rec = httptest.NewRecorder()
|
|
442
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
443
|
+
if rec.Code != http.StatusConflict {
|
|
444
|
+
t.Fatalf("重复启动的 HTTP 码 = %d,期望 409", rec.Code)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 运行中删除 → 409(先让用户停掉)。
|
|
448
|
+
rec = httptest.NewRecorder()
|
|
449
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodDelete, "/desk/scripts/"+id, nil))
|
|
450
|
+
if rec.Code != http.StatusConflict {
|
|
451
|
+
t.Fatalf("运行中删除的 HTTP 码 = %d,期望 409", rec.Code)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 停掉之后就能删了。
|
|
455
|
+
rec = httptest.NewRecorder()
|
|
456
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/stop", nil))
|
|
457
|
+
if rec.Code != http.StatusOK {
|
|
458
|
+
t.Fatalf("停止失败:HTTP %d", rec.Code)
|
|
459
|
+
}
|
|
460
|
+
waitState(t, s, id, "stopped", 20*time.Second)
|
|
461
|
+
|
|
462
|
+
rec = httptest.NewRecorder()
|
|
463
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodDelete, "/desk/scripts/"+id, nil))
|
|
464
|
+
if rec.Code != http.StatusOK {
|
|
465
|
+
t.Fatalf("停止后删除的 HTTP 码 = %d,期望 200(%s)", rec.Code, rec.Body.String())
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* ─────────────── 日志增量 ─────────────── */
|
|
470
|
+
|
|
471
|
+
func TestScriptsLogAfterReturnsOnlyNewLines(t *testing.T) {
|
|
472
|
+
s := scriptSvc(t)
|
|
473
|
+
path := writeScript(t, "hello", []string{"一", "二", "三"})
|
|
474
|
+
id := addScript(t, s, path)
|
|
475
|
+
|
|
476
|
+
rec := httptest.NewRecorder()
|
|
477
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
478
|
+
if rec.Code != http.StatusOK {
|
|
479
|
+
t.Fatalf("启动失败:HTTP %d", rec.Code)
|
|
480
|
+
}
|
|
481
|
+
waitState(t, s, id, "done", 20*time.Second)
|
|
482
|
+
|
|
483
|
+
all, _, _ := s.scriptMgr().logLines(id, 0)
|
|
484
|
+
if len(all) < 3 {
|
|
485
|
+
t.Fatalf("日志行数 = %d,期望 ≥3", len(all))
|
|
486
|
+
}
|
|
487
|
+
// after = 第 2 行的 seq → 只应拿到第 3 行。
|
|
488
|
+
tail, _, _ := s.scriptMgr().logLines(id, all[1].Seq)
|
|
489
|
+
if len(tail) != len(all)-2 {
|
|
490
|
+
t.Fatalf("增量行数 = %d,期望 %d", len(tail), len(all)-2)
|
|
491
|
+
}
|
|
492
|
+
for _, l := range tail {
|
|
493
|
+
if l.Seq <= all[1].Seq {
|
|
494
|
+
t.Fatalf("增量里混进了旧行:seq %d <= %d", l.Seq, all[1].Seq)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* ─────────────── 解释器与环境变量 ─────────────── */
|
|
500
|
+
|
|
501
|
+
// writeInterpretedScript 造一个「要靠解释器跑」的脚本桩,返回路径。
|
|
502
|
+
//
|
|
503
|
+
// Windows 用 .ps1(配 powershell.exe),unix 用 .sh(配 sh)。
|
|
504
|
+
//
|
|
505
|
+
// ⚠️ unix 侧**故意不写 shebang、也不给执行位** —— 直接 exec 它必然失败
|
|
506
|
+
//
|
|
507
|
+
// (格式错 / 权限错),只有「由解释器打开」才跑得起来。
|
|
508
|
+
// 这样「脚本跑起来了」就**证明**走的是解释器那条路,
|
|
509
|
+
// 而不是碰巧直接 exec 也能过。
|
|
510
|
+
//
|
|
511
|
+
// ⚠️ 文件内容只用 ASCII:Windows 上 PowerShell 5.1 读无 BOM 的 .ps1
|
|
512
|
+
//
|
|
513
|
+
// 按 ANSI(系统代码页)解释,而 unix 侧走 UTF-8 —— 掺中文会让
|
|
514
|
+
// 两边行为不一致,测的就不是「解释器有没有生效」而是「编码对不对」。
|
|
515
|
+
func writeInterpretedScript(t *testing.T, body string) string {
|
|
516
|
+
t.Helper()
|
|
517
|
+
dir := t.TempDir()
|
|
518
|
+
name := "interp.sh"
|
|
519
|
+
mode := os.FileMode(0o644)
|
|
520
|
+
if runtime.GOOS == "windows" {
|
|
521
|
+
name = "interp.ps1"
|
|
522
|
+
mode = 0o755
|
|
523
|
+
}
|
|
524
|
+
path := filepath.Join(dir, name)
|
|
525
|
+
// unix 侧 0644 = 没有执行位 → 直接 exec 会被拒,
|
|
526
|
+
// 只能由解释器打开 → 「跑起来了」就证明走了解释器那条路。
|
|
527
|
+
if err := os.WriteFile(path, []byte(body+"\n"), mode); err != nil {
|
|
528
|
+
t.Fatal(err)
|
|
529
|
+
}
|
|
530
|
+
return path
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// interpEchoBody 造一段「打印收到的参数、自身路径与某个环境变量」的脚本体。
|
|
534
|
+
//
|
|
535
|
+
// ⚠️ 参数下标两个平台不一样,**这是解释器自己的约定,不是我们的**:
|
|
536
|
+
//
|
|
537
|
+
// - unix `sh script.sh A` → $0=脚本路径、$1=A
|
|
538
|
+
// - win `powershell.exe script.ps1 A` → $args[0]=A(脚本路径被 PowerShell
|
|
539
|
+
// 自己吃掉了,不进 $args)
|
|
540
|
+
//
|
|
541
|
+
// 所以这里按平台取「第一个用户参数」,两边能断言到同一个值。
|
|
542
|
+
func interpEchoBody(envKey string) string {
|
|
543
|
+
if runtime.GOOS == "windows" {
|
|
544
|
+
return "Write-Output (\"ARG1=\" + $args[0])\n" +
|
|
545
|
+
"Write-Output (\"SELF=\" + $PSCommandPath)\n" +
|
|
546
|
+
"Write-Output (\"ENV=\" + $env:" + envKey + ")"
|
|
547
|
+
}
|
|
548
|
+
return "echo \"ARG1=$1\"\n" +
|
|
549
|
+
"echo \"SELF=$0\"\n" +
|
|
550
|
+
"echo \"ENV=$" + envKey + "\""
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
func TestScriptsInterpreterAndEnv(t *testing.T) {
|
|
554
|
+
s := scriptSvc(t)
|
|
555
|
+
interp := absInterpreter(t)
|
|
556
|
+
const envKey = "MAGIC_SCRIPT_TEST_ENV"
|
|
557
|
+
path := writeInterpretedScript(t, interpEchoBody(envKey))
|
|
558
|
+
|
|
559
|
+
id := addScriptDef(t, s, map[string]any{
|
|
560
|
+
"name": "解释器脚本",
|
|
561
|
+
"path": path,
|
|
562
|
+
"args": []string{"arg-one"},
|
|
563
|
+
"interpreter": interp,
|
|
564
|
+
"env": []string{envKey + "=42"},
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// 定义要原样回显(前端详情页要显示这两项)。
|
|
568
|
+
item, ok := s.scriptMgr().get(id)
|
|
569
|
+
if !ok {
|
|
570
|
+
t.Fatal("登记完就找不到了")
|
|
571
|
+
}
|
|
572
|
+
if item.Interpreter != interp {
|
|
573
|
+
t.Fatalf("解释器没落库:%q", item.Interpreter)
|
|
574
|
+
}
|
|
575
|
+
if len(item.Env) != 1 || item.Env[0] != envKey+"=42" {
|
|
576
|
+
t.Fatalf("环境变量没落库:%v", item.Env)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
rec := httptest.NewRecorder()
|
|
580
|
+
s.handleScriptSub(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts/"+id+"/start", nil))
|
|
581
|
+
if rec.Code != http.StatusOK {
|
|
582
|
+
t.Fatalf("启动失败:HTTP %d(%s)", rec.Code, rec.Body.String())
|
|
583
|
+
}
|
|
584
|
+
waitState(t, s, id, "done", 30*time.Second)
|
|
585
|
+
|
|
586
|
+
lines, _, _ := s.scriptMgr().logLines(id, 0)
|
|
587
|
+
joined := scriptLinesText(lines)
|
|
588
|
+
/* 用户给的参数要原样到达脚本 → 说明走的是「解释器 + 脚本路径 + 参数」这条路。
|
|
589
|
+
⚠️ 用 ASCII 断言:这一步测的是「参数有没有传对」,
|
|
590
|
+
编码对不对是 scripts_decode_test.go 的活儿,别把两件事混在一个断言里。 */
|
|
591
|
+
if !strings.Contains(joined, "ARG1=arg-one") {
|
|
592
|
+
t.Fatalf("日志里没有脚本收到的参数(期望 ARG1=arg-one):\n%s", joined)
|
|
593
|
+
}
|
|
594
|
+
// 脚本自己的路径要能被解释器看到(unix $0 / win $PSCommandPath)。
|
|
595
|
+
//
|
|
596
|
+
// ⚠️ 只查**末级文件名在不在**:Windows 上 t.TempDir() 给的是 8.3 短路径
|
|
597
|
+
//(C:\Users\ADMINI~1\...),而 PowerShell 报的是长路径
|
|
598
|
+
//(C:\Users\Administrator\...)—— 同一处,形态不同。
|
|
599
|
+
// 比全路径会得到一个假失败。
|
|
600
|
+
if !strings.Contains(joined, "SELF=") || !strings.Contains(joined, filepath.Base(path)) {
|
|
601
|
+
t.Fatalf("日志里没有脚本自身路径(期望含 %s):\n%s", filepath.Base(path), joined)
|
|
602
|
+
}
|
|
603
|
+
// 环境变量要真的到了子进程。
|
|
604
|
+
if !strings.Contains(joined, "ENV=42") {
|
|
605
|
+
t.Fatalf("子进程没拿到环境变量:\n%s", joined)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
func TestScriptsRejectBadInterpreterAndEnv(t *testing.T) {
|
|
610
|
+
s := scriptSvc(t)
|
|
611
|
+
dir := t.TempDir()
|
|
612
|
+
interp := absInterpreter(t)
|
|
613
|
+
|
|
614
|
+
// 一个存在的脚本文件(.sh / .ps1),用来测「只有解释器或 env 出错」的场景。
|
|
615
|
+
var path string
|
|
616
|
+
if runtime.GOOS == "windows" {
|
|
617
|
+
path = filepath.Join(dir, "ok.ps1")
|
|
618
|
+
if err := os.WriteFile(path, []byte("Write-Output 1\n"), 0o755); err != nil {
|
|
619
|
+
t.Fatal(err)
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
path = filepath.Join(dir, "ok.sh")
|
|
623
|
+
if err := os.WriteFile(path, []byte("#!/bin/sh\necho 1\n"), 0o755); err != nil {
|
|
624
|
+
t.Fatal(err)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
cases := []struct {
|
|
629
|
+
what string
|
|
630
|
+
def map[string]any
|
|
631
|
+
}{
|
|
632
|
+
{"解释器不存在", map[string]any{
|
|
633
|
+
"name": "x", "path": path,
|
|
634
|
+
"interpreter": filepath.Join(dir, "没有这个解释器.exe"),
|
|
635
|
+
}},
|
|
636
|
+
{"解释器是相对路径", map[string]any{
|
|
637
|
+
"name": "x", "path": path, "interpreter": "python.exe",
|
|
638
|
+
}},
|
|
639
|
+
{"解释器是目录", map[string]any{
|
|
640
|
+
"name": "x", "path": path, "interpreter": dir,
|
|
641
|
+
}},
|
|
642
|
+
{"环境变量不是 KEY=VALUE", map[string]any{
|
|
643
|
+
"name": "x", "path": path, "env": []string{"没有等号"},
|
|
644
|
+
}},
|
|
645
|
+
{"环境变量名里有空格", map[string]any{
|
|
646
|
+
"name": "x", "path": path, "env": []string{"BAD KEY=1"},
|
|
647
|
+
}},
|
|
648
|
+
}
|
|
649
|
+
for _, c := range cases {
|
|
650
|
+
rec := httptest.NewRecorder()
|
|
651
|
+
body, _ := json.Marshal(c.def)
|
|
652
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
653
|
+
if rec.Code == http.StatusOK {
|
|
654
|
+
t.Fatalf("%s:被接受了(应该拒绝)", c.what)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 环境变量条数上限。
|
|
659
|
+
tooMany := make([]string, scriptMaxEnv+1)
|
|
660
|
+
for i := range tooMany {
|
|
661
|
+
tooMany[i] = fmt.Sprintf("K%d=1", i)
|
|
662
|
+
}
|
|
663
|
+
rec := httptest.NewRecorder()
|
|
664
|
+
body, _ := json.Marshal(map[string]any{"name": "x", "path": path, "env": tooMany})
|
|
665
|
+
s.handleScripts(rec, httptest.NewRequest(http.MethodPost, "/desk/scripts", strings.NewReader(string(body))))
|
|
666
|
+
if rec.Code == http.StatusOK {
|
|
667
|
+
t.Fatalf("环境变量超上限(%d 条):被接受了", len(tooMany))
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 正常解释器不该被上面的坏用例带偏(确认 interp 本身合法)。
|
|
671
|
+
id := addScriptDef(t, s, map[string]any{
|
|
672
|
+
"name": "正常", "path": path, "interpreter": interp,
|
|
673
|
+
})
|
|
674
|
+
if id == "" {
|
|
675
|
+
t.Fatal("合法解释器没登记上")
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/* ─────────────── 持久化 ─────────────── */
|
|
680
|
+
|
|
681
|
+
func TestScriptsPersistAcrossRestart(t *testing.T) {
|
|
682
|
+
dir := t.TempDir()
|
|
683
|
+
t.Setenv(envScriptsDir, dir)
|
|
684
|
+
|
|
685
|
+
path := writeScript(t, "hello", []string{"你好"})
|
|
686
|
+
// 第一个服务:登记脚本。
|
|
687
|
+
s1 := &Service{opts: Options{}}
|
|
688
|
+
id := addScript(t, s1, path)
|
|
689
|
+
|
|
690
|
+
// 第二个服务(模拟重启):清单要还在。
|
|
691
|
+
s2 := &Service{opts: Options{}}
|
|
692
|
+
if _, ok := s2.scriptMgr().get(id); !ok {
|
|
693
|
+
t.Fatal("重启后脚本清单没了 —— 没落盘")
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* ─────────────── 辅助 ─────────────── */
|
|
698
|
+
|
|
699
|
+
func scriptLinesText(lines []scriptLine) string {
|
|
700
|
+
var b strings.Builder
|
|
701
|
+
for _, l := range lines {
|
|
702
|
+
b.WriteString(l.Text)
|
|
703
|
+
b.WriteString("\n")
|
|
704
|
+
}
|
|
705
|
+
return b.String()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// waitForLog 等日志里出现某段文字。
|
|
709
|
+
func waitForLog(t *testing.T, s *Service, id, want string, d time.Duration) {
|
|
710
|
+
t.Helper()
|
|
711
|
+
deadline := time.Now().Add(d)
|
|
712
|
+
for time.Now().Before(deadline) {
|
|
713
|
+
lines, _, _ := s.scriptMgr().logLines(id, 0)
|
|
714
|
+
if strings.Contains(scriptLinesText(lines), want) {
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
time.Sleep(30 * time.Millisecond)
|
|
718
|
+
}
|
|
719
|
+
t.Fatalf("等了 %s 也没等到日志里有 %q", d, want)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// pidAlive 探活(用 agent.PIDAlive:Windows 走 OpenProcess 内核调用,
|
|
723
|
+
// unix 走 signal 0 —— 平台差异已经在那一层处理好了)。
|
|
724
|
+
func pidAlive(pid int) bool {
|
|
725
|
+
return agent.PIDAlive(pid)
|
|
726
|
+
}
|