magic-agent 2.6.0 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- 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 +11 -0
- package/package.json +8 -3
|
@@ -8,6 +8,7 @@ import (
|
|
|
8
8
|
"net/http/httptest"
|
|
9
9
|
"os"
|
|
10
10
|
"path/filepath"
|
|
11
|
+
"strings"
|
|
11
12
|
"testing"
|
|
12
13
|
"time"
|
|
13
14
|
)
|
|
@@ -36,7 +37,7 @@ func TestHandleRestartFlagsThenTimer(t *testing.T) {
|
|
|
36
37
|
// 假助手:只记录被拉起过几次、带的是什么可执行文件。
|
|
37
38
|
spawned := make(chan string, 4)
|
|
38
39
|
oldSpawner := restartSpawner
|
|
39
|
-
restartSpawner = func(gotExe string, _ *log.Logger) { spawned <- gotExe }
|
|
40
|
+
restartSpawner = func(gotExe string, _ *log.Logger, _ string) { spawned <- gotExe }
|
|
40
41
|
defer func() { restartSpawner = oldSpawner }()
|
|
41
42
|
// 标志是进程级的:用例前后都重置,别把别的用例立过的 pending 带进来。
|
|
42
43
|
resetRestartPendingForTest()
|
|
@@ -149,3 +150,141 @@ func resetRestartPendingForTest() {
|
|
|
149
150
|
restartPending = false
|
|
150
151
|
restartMu.Unlock()
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
前台 serve 也要能被手机重启(2026-10-26 用户:「通过 magic-test 启动的无法在移动端
|
|
156
|
+
点击重启服务重启」)。
|
|
157
|
+
|
|
158
|
+
根因:`POST /desk/restart` 原来只认 `magic-client start` 写的 client.json,
|
|
159
|
+
而 magic-test 的桥接是 `spawn(bin, ['serve', ...])` 起的**前台** serve,什么都不写 ——
|
|
160
|
+
于是手机点重启一律 409。修法是让 serve 监听成功后自登记(<数据目录>/serve.json),
|
|
161
|
+
hanldeRestart 用 selfDaemonState() 通用认领。
|
|
162
|
+
|
|
163
|
+
这个用例钉住三件事:
|
|
164
|
+
|
|
165
|
+
① 前台 serve 自登记后,POST /desk/restart 会给 200(不再是 409);
|
|
166
|
+
② 拉起的助手带的是**这份**状态文件的路径(否则它会去重启 client.json 里那个实例);
|
|
167
|
+
③ 重启提交给状态文件的令牌是 serve 自己的 —— 手机存的地址靠它继续有效。
|
|
168
|
+
*/
|
|
169
|
+
func TestForegroundServeCanRestartItself(t *testing.T) {
|
|
170
|
+
dir := t.TempDir()
|
|
171
|
+
/* 用假 HOME 把**默认**状态目录挪进临时目录,而不是设 EnvState:
|
|
172
|
+
EnvState(MAGIC_CLIENT_STATE)的语义是「这是 start 起的实例」,设了它
|
|
173
|
+
selfDaemonState 就会优先认它 —— 那是 start 那条路的行为,不是前台 serve 的。 */
|
|
174
|
+
t.Setenv("HOME", dir)
|
|
175
|
+
// 默认位置放一份**别人**的登记:证明前台 serve 认的是自己那份 serve.json,
|
|
176
|
+
// 而不是默认路径那份(认错的话就会去重启别人的服务)。
|
|
177
|
+
defaultState := DefaultStatePath()
|
|
178
|
+
if err := os.MkdirAll(filepath.Dir(defaultState), 0o700); err != nil {
|
|
179
|
+
t.Fatal(err)
|
|
180
|
+
}
|
|
181
|
+
other := &DaemonState{PID: os.Getpid() + 100000, Port: 9100, Token: "other-token"}
|
|
182
|
+
if err := writeDaemonState(defaultState, other); err != nil {
|
|
183
|
+
t.Fatal(err)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
exe, err := os.Executable()
|
|
187
|
+
if err != nil {
|
|
188
|
+
t.Fatal(err)
|
|
189
|
+
}
|
|
190
|
+
selfState := SelfStatePath(dir)
|
|
191
|
+
if _, err := WriteSelfStateAt("", dir, &DaemonState{
|
|
192
|
+
PID: os.Getpid(), Port: 8977, Token: "serve-token", Exe: exe, Lan: true,
|
|
193
|
+
}); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
resetRestartPendingForTest()
|
|
198
|
+
resetRestartHandoffForTest()
|
|
199
|
+
defer resetRestartPendingForTest()
|
|
200
|
+
defer resetRestartHandoffForTest()
|
|
201
|
+
|
|
202
|
+
type spawn struct{ exe, statePath string }
|
|
203
|
+
spawned := make(chan spawn, 1)
|
|
204
|
+
oldSpawner := restartSpawner
|
|
205
|
+
restartSpawner = func(gotExe string, _ *log.Logger, gotState string) {
|
|
206
|
+
spawned <- spawn{gotExe, gotState}
|
|
207
|
+
}
|
|
208
|
+
defer func() { restartSpawner = oldSpawner }()
|
|
209
|
+
oldDelay := restartDelay
|
|
210
|
+
restartDelay = 50 * time.Millisecond
|
|
211
|
+
defer func() { restartDelay = oldDelay }()
|
|
212
|
+
|
|
213
|
+
// 只给 SelfStatePath,不给 StatePath —— 这正是前台 serve 的形态。
|
|
214
|
+
s := &Service{
|
|
215
|
+
log: log.New(io.Discard, "", 0),
|
|
216
|
+
opts: Options{SelfStatePath: selfState},
|
|
217
|
+
}
|
|
218
|
+
rec := httptest.NewRecorder()
|
|
219
|
+
s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
|
|
220
|
+
if rec.Code != http.StatusOK {
|
|
221
|
+
t.Fatalf("前台 serve 点重启 = %d,want 200(body: %s)", rec.Code, rec.Body.String())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
select {
|
|
225
|
+
case got := <-spawned:
|
|
226
|
+
if got.statePath != selfState {
|
|
227
|
+
t.Errorf("助手拿到的状态文件 = %q,want %q(认错就会去重启别的实例)", got.statePath, selfState)
|
|
228
|
+
}
|
|
229
|
+
if got.exe != exe {
|
|
230
|
+
t.Errorf("助手可执行文件 = %q,want %q", got.exe, exe)
|
|
231
|
+
}
|
|
232
|
+
case <-time.After(2 * time.Second):
|
|
233
|
+
t.Fatal("定时器没有拉起助手")
|
|
234
|
+
}
|
|
235
|
+
// 交接里必须是 serve 自己的令牌:手机存的就是这个地址+令牌组合。
|
|
236
|
+
if tok, _ := restartHandoff()["token"].(string); tok != "serve-token" {
|
|
237
|
+
t.Errorf("交接令牌 = %q,want serve-token", tok)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 没有任何自登记时仍然是 409,而且话要说得能照着做(区分「别人的登记」与「一份都没有」)。
|
|
242
|
+
func TestRestartWithoutSelfStateIsRefused(t *testing.T) {
|
|
243
|
+
dir := t.TempDir()
|
|
244
|
+
// 同样只挪 HOME,不设 EnvState(那个代表 start 路线)。
|
|
245
|
+
t.Setenv("HOME", dir)
|
|
246
|
+
resetRestartPendingForTest()
|
|
247
|
+
defer resetRestartPendingForTest()
|
|
248
|
+
|
|
249
|
+
s := &Service{log: log.New(io.Discard, "", 0)}
|
|
250
|
+
rec := httptest.NewRecorder()
|
|
251
|
+
s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
|
|
252
|
+
if rec.Code != http.StatusConflict {
|
|
253
|
+
t.Fatalf("没有状态记录时 = %d,want 409", rec.Code)
|
|
254
|
+
}
|
|
255
|
+
var body map[string]any
|
|
256
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
257
|
+
t.Fatalf("解析响应失败: %v", err)
|
|
258
|
+
}
|
|
259
|
+
msg, _ := body["error"].(string)
|
|
260
|
+
// 必须给出可照做的动作,不能只说「不能自重启」。
|
|
261
|
+
if !strings.Contains(msg, "magic-client restart") {
|
|
262
|
+
t.Errorf("拒绝理由应给出可照做的指引,实际:%q", msg)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// WriteSelfState / RemoveSelfState 的配对语义:新实例接手后,旧实例收尾**不许**删掉它。
|
|
267
|
+
func TestRemoveSelfStateKeepsNewInstance(t *testing.T) {
|
|
268
|
+
dir := t.TempDir()
|
|
269
|
+
path, err := WriteSelfStateAt("", dir, &DaemonState{PID: os.Getpid(), Port: 8977, Token: "t"})
|
|
270
|
+
if err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
// 模拟「新实例已经接管了这份登记」。
|
|
274
|
+
if err := writeDaemonState(path, &DaemonState{PID: os.Getpid() + 99999, Port: 8977}); err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
RemoveSelfState(path)
|
|
278
|
+
if _, err := os.Stat(path); err != nil {
|
|
279
|
+
t.Fatalf("新实例的登记被旧实例删掉了(重启后再点会变 409):%v", err)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 登记还属于自己时才删。
|
|
283
|
+
if err := writeDaemonState(path, &DaemonState{PID: os.Getpid(), Port: 8977}); err != nil {
|
|
284
|
+
t.Fatal(err)
|
|
285
|
+
}
|
|
286
|
+
RemoveSelfState(path)
|
|
287
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
288
|
+
t.Errorf("自己的登记应被删掉,stat err = %v", err)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// scripts_decode_implausible.go - 「解码结果像不像乱码」的判定,**全平台共用一份**。
|
|
4
|
+
//
|
|
5
|
+
// 只保留 !windows 时会怎样:scripts_decode_windows.go 里调用了它却没定义,
|
|
6
|
+
// Windows 目标直接编译不过(`undefined: hasImplausibleRune`)——
|
|
7
|
+
// 2026-10-26 打 2.6.1 时 win32-x64 就是这个错误挂掉的(另外 4 个目标照常出包,
|
|
8
|
+
// 很容易被「完成 4/5」这句糊过去)。
|
|
9
|
+
//
|
|
10
|
+
// 非 Windows 的解码路径本身不走它(UTF-8 解不出就原样保留字节,见
|
|
11
|
+
// scripts_decode_other.go),但 scripts_decode_test.go 不分平台编译、要引用它,
|
|
12
|
+
// 所以两个平台都得有这个符号。判定逻辑只有这一份,不要再往平台文件里抄第二份。
|
|
13
|
+
|
|
14
|
+
import "unicode/utf8"
|
|
15
|
+
|
|
16
|
+
// hasImplausibleRune 报告文本里有没有「控制台输出里不会出现」的字符。
|
|
17
|
+
//
|
|
18
|
+
// 这些字符是「字节编码猜错」的典型产物:GBK 字节被当 UTF-8 解会冒出
|
|
19
|
+
// 西里尔文(һ)、希伯来文、中文全角标点残留等。
|
|
20
|
+
func hasImplausibleRune(s string) bool {
|
|
21
|
+
for _, r := range s {
|
|
22
|
+
switch {
|
|
23
|
+
case r == utf8.RuneError: // 替换符:解码失败过
|
|
24
|
+
return true
|
|
25
|
+
case r < 0x20 && r != '\t' && r != '\n' && r != '\r':
|
|
26
|
+
return true // 控制符
|
|
27
|
+
case r >= 0x80 && r <= 0x9F:
|
|
28
|
+
return true // C1 控制区
|
|
29
|
+
case r >= 0xE000 && r <= 0xF8FF:
|
|
30
|
+
return true // 私用区
|
|
31
|
+
case r >= 0x0370 && r <= 0x04FF:
|
|
32
|
+
return true // 希腊文 + 西里尔文
|
|
33
|
+
case r >= 0x0590 && r <= 0x08FF:
|
|
34
|
+
return true // 希伯来文 / 阿拉伯文 / 叙利亚文 / 塔安文
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
@@ -25,3 +25,7 @@ func decodeScriptBytes(b []byte) string {
|
|
|
25
25
|
func encodeACP(s string, cp uint32) ([]byte, bool) {
|
|
26
26
|
return []byte(s), true
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
// scriptCodePage 非 Windows 平台没有「控制台代码页」:UTF-8(65001)就是它。
|
|
30
|
+
// 单独存在的理由是 scripts_decode_test.go 会引用它(测试文件不分平台编译)。
|
|
31
|
+
func scriptCodePage() uint32 { return 65001 }
|
|
@@ -24,18 +24,23 @@ func TestDecodeScriptBytesKeepsUTF8(t *testing.T) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
func TestDecodeScriptBytesRoundTripCodePage(t *testing.T) {
|
|
27
|
-
if runtime.GOOS != "windows" {
|
|
28
|
-
t.Skip("只有 Windows 才有控制台代码页这回事")
|
|
29
|
-
}
|
|
30
27
|
for _, in := range []string{"第一行", "中文", "hello", "混合 mixed 内容 123"} {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
in := in
|
|
29
|
+
t.Run(in, func(t *testing.T) {
|
|
30
|
+
// 「代码页」只存在于 Windows;unix 上 encodeACP 就是原始字节,往返没有意义。
|
|
31
|
+
// 按用例跳过而不是整函数跳过:非 Windows 编译时才算这个函数(否则 vet 报未定义)。
|
|
32
|
+
if runtime.GOOS != "windows" {
|
|
33
|
+
t.Skip("只有 Windows 才有控制台代码页这回事")
|
|
34
|
+
}
|
|
35
|
+
enc, ok := encodeACP(in, scriptCodePage())
|
|
36
|
+
if !ok {
|
|
37
|
+
t.Fatalf("按代码页编码 %q 失败", in)
|
|
38
|
+
}
|
|
39
|
+
got := decodeScriptBytes(enc)
|
|
40
|
+
if got != in {
|
|
41
|
+
t.Fatalf("代码页往返不对:%q != %q(字节 % x)", got, in, enc)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -93,30 +93,6 @@ func decodeScriptBytes(b []byte) string {
|
|
|
93
93
|
return string(b)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
// hasImplausibleRune 报告文本里有没有「控制台输出里不会出现」的字符。
|
|
97
|
-
//
|
|
98
|
-
// 这些字符是「字节编码猜错」的典型产物:GBK 字节被当 UTF-8 解会冒出
|
|
99
|
-
// 西里尔文(һ)、希伯来文、中文全角标点残留等。
|
|
100
|
-
func hasImplausibleRune(s string) bool {
|
|
101
|
-
for _, r := range s {
|
|
102
|
-
switch {
|
|
103
|
-
case r == utf8.RuneError: // 替换符:解码失败过
|
|
104
|
-
return true
|
|
105
|
-
case r < 0x20 && r != '\t' && r != '\n' && r != '\r':
|
|
106
|
-
return true // 控制符
|
|
107
|
-
case r >= 0x80 && r <= 0x9F:
|
|
108
|
-
return true // C1 控制区
|
|
109
|
-
case r >= 0xE000 && r <= 0xF8FF:
|
|
110
|
-
return true // 私用区
|
|
111
|
-
case r >= 0x0370 && r <= 0x04FF:
|
|
112
|
-
return true // 希腊文 + 西里尔文
|
|
113
|
-
case r >= 0x0590 && r <= 0x08FF:
|
|
114
|
-
return true // 希伯来文 / 阿拉伯文 / 叙利亚文 / 塔安文
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return false
|
|
118
|
-
}
|
|
119
|
-
|
|
120
96
|
// errnoIsZero 判断 syscall 调用的错误码是否为 0(成功)。
|
|
121
97
|
//
|
|
122
98
|
// ⚠️ **不要直接比 err != nil**:Proc.Call 返回的 error **成功时也是非 nil**
|
|
@@ -63,7 +63,20 @@ func writeScript(t *testing.T, name string, lines []string) string {
|
|
|
63
63
|
return path
|
|
64
64
|
}
|
|
65
65
|
path := filepath.Join(dir, name+".sh")
|
|
66
|
-
|
|
66
|
+
/* ⚠️ 每一行都要 `echo ` 前缀 —— 早先这里直接 join(lines) 写进脚本,
|
|
67
|
+
在 Windows 分支(下面那支)是 `echo <line>`,到 POSIX 分支却当成了**命令**:
|
|
68
|
+
`第一行` 会被 shell 当成命令名,退出码 127 → 脚本状态 failed →
|
|
69
|
+
TestScriptsStartRunsAndStreamsLog 在 macOS/Linux 上必然失败。
|
|
70
|
+
空行写 `echo` 本身(输出空行),与批处理的 `echo.` 对齐。 */
|
|
71
|
+
out := make([]string, 0, len(lines))
|
|
72
|
+
for _, l := range lines {
|
|
73
|
+
if strings.TrimSpace(l) == "" {
|
|
74
|
+
out = append(out, "echo")
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
out = append(out, "echo "+l)
|
|
78
|
+
}
|
|
79
|
+
body := "#!/bin/sh\n" + strings.Join(out, "\n") + "\n"
|
|
67
80
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
68
81
|
t.Fatal(err)
|
|
69
82
|
}
|