magic-agent 1.0.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/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- 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/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// permission_test.go - 四档权限模型(permission.go)的单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖三件事:
|
|
6
|
+
// 1. 档位解析(含别名与数字档位);
|
|
7
|
+
// 2. settings 载荷组装(sandbox / autoMode / permissions / env 的合并与取值);
|
|
8
|
+
// 3. 各档位落到 claude / codebuddy 的 argv 上到底长什么样(参数映射的最终证据)。
|
|
9
|
+
//
|
|
10
|
+
// 最后再补一个假 CLI 端到端:证明这些参数真的被送进了子进程,而不只是
|
|
11
|
+
// buildArgsBase 的返回值好看。
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"context"
|
|
15
|
+
"encoding/json"
|
|
16
|
+
"os"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
"strings"
|
|
19
|
+
"testing"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// ── 档位解析 ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
func TestParsePermissionTier(t *testing.T) {
|
|
25
|
+
cases := []struct {
|
|
26
|
+
in string
|
|
27
|
+
want PermissionTier
|
|
28
|
+
ok bool
|
|
29
|
+
}{
|
|
30
|
+
// 规范名
|
|
31
|
+
{"manual", PermissionManual, true},
|
|
32
|
+
{"accept-edits", PermissionAcceptEdits, true},
|
|
33
|
+
{"auto", PermissionAuto, true},
|
|
34
|
+
{"full", PermissionFull, true},
|
|
35
|
+
// 大小写 / 分隔符不敏感
|
|
36
|
+
{"MANUAL", PermissionManual, true},
|
|
37
|
+
{"Accept_Edits", PermissionAcceptEdits, true},
|
|
38
|
+
{"acceptEdits", PermissionAcceptEdits, true},
|
|
39
|
+
{" accept-edits ", PermissionAcceptEdits, true},
|
|
40
|
+
// 别名
|
|
41
|
+
{"default", PermissionManual, true}, // 与 claude 自己的命名一致
|
|
42
|
+
{"ask", PermissionManual, true},
|
|
43
|
+
{"edits", PermissionAcceptEdits, true},
|
|
44
|
+
{"guardian", PermissionAuto, true},
|
|
45
|
+
{"bypass", PermissionFull, true},
|
|
46
|
+
{"bypassPermissions", PermissionFull, true},
|
|
47
|
+
{"yolo", PermissionFull, true},
|
|
48
|
+
// 数字档位
|
|
49
|
+
{"1", PermissionManual, true},
|
|
50
|
+
{"2", PermissionAcceptEdits, true},
|
|
51
|
+
{"3", PermissionAuto, true},
|
|
52
|
+
{"4", PermissionFull, true},
|
|
53
|
+
// 非法
|
|
54
|
+
{"", "", false},
|
|
55
|
+
{"plan", "", false}, // plan 是正交的「阶段档」,不是四档之一
|
|
56
|
+
{"dontAsk", "", false}, // dontAsk 是正交的「锁定档」
|
|
57
|
+
{"nope", "", false},
|
|
58
|
+
}
|
|
59
|
+
for _, c := range cases {
|
|
60
|
+
got, ok := ParsePermissionTier(c.in)
|
|
61
|
+
if ok != c.ok {
|
|
62
|
+
t.Errorf("ParsePermissionTier(%q) ok = %v, want %v", c.in, ok, c.ok)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if ok && got != c.want {
|
|
66
|
+
t.Errorf("ParsePermissionTier(%q) = %q, want %q", c.in, got, c.want)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// TestDefaultPermissionTierIsFull 默认档必须是 full。
|
|
72
|
+
//
|
|
73
|
+
// 这是**刻意**的设计:改造前 claude/codebuddy 在 --tools 非 off 时恒传
|
|
74
|
+
// --dangerously-skip-permissions / -y(= 第 4 档)。默认值一旦改动,既有调用方
|
|
75
|
+
// 的 agent 会突然开始弹审批 / 被沙箱拦 —— 那是静默的行为变更,不能发生。
|
|
76
|
+
func TestDefaultPermissionTierIsFull(t *testing.T) {
|
|
77
|
+
if DefaultPermissionTier != PermissionFull {
|
|
78
|
+
t.Fatalf("DefaultPermissionTier = %q, want %q(改默认档等于静默变更既有行为)",
|
|
79
|
+
DefaultPermissionTier, PermissionFull)
|
|
80
|
+
}
|
|
81
|
+
if got := permissionOrDefault(""); got != PermissionFull {
|
|
82
|
+
t.Errorf("permissionOrDefault(\"\") = %q, want %q", got, PermissionFull)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// TestClaudePermissionModeMapping 档位 → --permission-mode 取值的映射。
|
|
87
|
+
// 取值必须是 CLI 认的字面量(camelCase 的 acceptEdits 尤其容易写错)。
|
|
88
|
+
func TestClaudePermissionModeMapping(t *testing.T) {
|
|
89
|
+
cases := map[PermissionTier]string{
|
|
90
|
+
PermissionManual: "default",
|
|
91
|
+
PermissionAcceptEdits: "acceptEdits",
|
|
92
|
+
PermissionAuto: "auto",
|
|
93
|
+
PermissionFull: "bypassPermissions",
|
|
94
|
+
}
|
|
95
|
+
for tier, want := range cases {
|
|
96
|
+
if got := claudePermissionMode(tier); got != want {
|
|
97
|
+
t.Errorf("claudePermissionMode(%q) = %q, want %q", tier, got, want)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 空串 → 默认档
|
|
101
|
+
if got := claudePermissionMode(""); got != "bypassPermissions" {
|
|
102
|
+
t.Errorf("claudePermissionMode(\"\") = %q, want bypassPermissions", got)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// TestPermissionSupportOf 能力表:只有 claude / codebuddy / codebuddy-ai 接线。
|
|
107
|
+
func TestPermissionSupportOf(t *testing.T) {
|
|
108
|
+
for _, e := range []string{"claude", "codebuddy", "codebuddy-ai"} {
|
|
109
|
+
if !PermissionSupported(e) {
|
|
110
|
+
t.Errorf("%s 应支持四档模型", e)
|
|
111
|
+
}
|
|
112
|
+
if got := PermissionSupportOf(e); got != "flag:--permission-mode" {
|
|
113
|
+
t.Errorf("PermissionSupportOf(%q) = %q", e, got)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 未接线的引擎必须报 none —— CLI 层据此 exit 2,而不是静默忽略。
|
|
117
|
+
for _, e := range []string{"trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
|
|
118
|
+
if PermissionSupported(e) {
|
|
119
|
+
t.Errorf("%s 不应报告支持四档模型", e)
|
|
120
|
+
}
|
|
121
|
+
if got := PermissionSupportOf(e); got != "none" {
|
|
122
|
+
t.Errorf("PermissionSupportOf(%q) = %q, want none", e, got)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── settings 载荷 ─────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
// decodeSettings 把 --settings 的内联 JSON 解成嵌套 map 便于断言。
|
|
130
|
+
func decodeSettings(t *testing.T, payload string) map[string]any {
|
|
131
|
+
t.Helper()
|
|
132
|
+
var out map[string]any
|
|
133
|
+
if err := json.Unmarshal([]byte(payload), &out); err != nil {
|
|
134
|
+
t.Fatalf("settings payload 不是合法 JSON: %v\n%s", err, payload)
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// dig 按路径取值(不存在返回 nil)。
|
|
140
|
+
func dig(m map[string]any, path ...string) any {
|
|
141
|
+
var cur any = m
|
|
142
|
+
for _, k := range path {
|
|
143
|
+
mm, ok := cur.(map[string]any)
|
|
144
|
+
if !ok {
|
|
145
|
+
return nil
|
|
146
|
+
}
|
|
147
|
+
cur = mm[k]
|
|
148
|
+
}
|
|
149
|
+
return cur
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// TestSandboxSettingsPerTier 逐档断言 sandbox 子对象。
|
|
153
|
+
//
|
|
154
|
+
// 关键点:autoAllowBashIfSandboxed 是第 1/2 档与第 3 档的分水岭。
|
|
155
|
+
// 该键在 claude 侧的默认值是 true,所以第 1/2 档必须显式写 false ——
|
|
156
|
+
// 否则沙箱内的 Bash 会被自动放行,第 2 档就退化成了「编辑与命令都不问」。
|
|
157
|
+
func TestSandboxSettingsPerTier(t *testing.T) {
|
|
158
|
+
cases := []struct {
|
|
159
|
+
tier PermissionTier
|
|
160
|
+
enabled bool
|
|
161
|
+
autoAllow bool
|
|
162
|
+
wantKeySet bool // 非 full 档应带 failIfUnavailable
|
|
163
|
+
}{
|
|
164
|
+
{PermissionManual, true, false, true},
|
|
165
|
+
{PermissionAcceptEdits, true, false, true},
|
|
166
|
+
{PermissionAuto, true, true, true},
|
|
167
|
+
{PermissionFull, false, false, false},
|
|
168
|
+
}
|
|
169
|
+
for _, c := range cases {
|
|
170
|
+
sb := sandboxSettings(c.tier, PermissionOptions{}, "claude")
|
|
171
|
+
if got := sb["enabled"]; got != c.enabled {
|
|
172
|
+
t.Errorf("%s: sandbox.enabled = %v, want %v", c.tier, got, c.enabled)
|
|
173
|
+
}
|
|
174
|
+
if c.tier == PermissionFull {
|
|
175
|
+
if _, has := sb["autoAllowBashIfSandboxed"]; has {
|
|
176
|
+
t.Errorf("full 档不该带 autoAllowBashIfSandboxed")
|
|
177
|
+
}
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if got := sb["autoAllowBashIfSandboxed"]; got != c.autoAllow {
|
|
181
|
+
t.Errorf("%s: sandbox.autoAllowBashIfSandboxed = %v, want %v(这是第1/2档与第3档的分水岭)",
|
|
182
|
+
c.tier, got, c.autoAllow)
|
|
183
|
+
}
|
|
184
|
+
// 严格沙箱:默认关闭逃逸舱口。
|
|
185
|
+
if got := sb["allowUnsandboxedCommands"]; got != false {
|
|
186
|
+
t.Errorf("%s: sandbox.allowUnsandboxedCommands = %v, want false", c.tier, got)
|
|
187
|
+
}
|
|
188
|
+
// 沙箱起不来就报错,不静默降级成不沙箱运行。
|
|
189
|
+
if got := sb["failIfUnavailable"]; got != true {
|
|
190
|
+
t.Errorf("%s: sandbox.failIfUnavailable = %v, want true", c.tier, got)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// codebuddy 文档未列 failIfUnavailable,不能注入未知键。
|
|
195
|
+
sb := sandboxSettings(PermissionAuto, PermissionOptions{}, "codebuddy")
|
|
196
|
+
if _, has := sb["failIfUnavailable"]; has {
|
|
197
|
+
t.Error("codebuddy 不该注入 failIfUnavailable(其文档未列该键)")
|
|
198
|
+
}
|
|
199
|
+
if got := sb["enabled"]; got != true {
|
|
200
|
+
t.Errorf("codebuddy auto 档 sandbox.enabled = %v, want true", got)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// TestSandboxSettingsOptions 可选项(排除命令 / 网络白名单 / 放宽逃逸舱口)。
|
|
205
|
+
func TestSandboxSettingsOptions(t *testing.T) {
|
|
206
|
+
opts := PermissionOptions{
|
|
207
|
+
ExcludedCommands: []string{" docker ", "watchman", "docker", ""}, // 含重复与空白
|
|
208
|
+
AllowedDomains: []string{"api.example.com", "*.corp.example.com"},
|
|
209
|
+
AllowUnsandboxedCommands: true,
|
|
210
|
+
}
|
|
211
|
+
sb := sandboxSettings(PermissionAuto, opts, "claude")
|
|
212
|
+
|
|
213
|
+
ex, _ := sb["excludedCommands"].([]string)
|
|
214
|
+
if strings.Join(ex, ",") != "docker,watchman" {
|
|
215
|
+
t.Errorf("excludedCommands = %v, want [docker watchman](去重去空白)", ex)
|
|
216
|
+
}
|
|
217
|
+
if got := dig(map[string]any{"s": sb}, "s", "network", "allowedDomains"); got == nil {
|
|
218
|
+
t.Error("allowedDomains 未注入")
|
|
219
|
+
}
|
|
220
|
+
if got := sb["allowUnsandboxedCommands"]; got != true {
|
|
221
|
+
t.Errorf("allowUnsandboxedCommands = %v, want true(显式放宽时)", got)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 第 4 档无沙箱,可选项一律不生效。
|
|
225
|
+
if sbFull := sandboxSettings(PermissionFull, opts, "claude"); len(sbFull) != 1 {
|
|
226
|
+
t.Errorf("full 档 sandbox 应只有 enabled 一项,得到 %v", sbFull)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// TestAutoModeSettingsOnlyForAutoTier 只有第 3 档注入 autoMode,且必须带 $defaults。
|
|
231
|
+
func TestAutoModeSettingsOnlyForAutoTier(t *testing.T) {
|
|
232
|
+
opts := PermissionOptions{AutoModeEnvironment: []string{"Source control: github.example.com/acme-corp"}}
|
|
233
|
+
|
|
234
|
+
if got := autoModeSettings(PermissionManual, opts); got != nil {
|
|
235
|
+
t.Errorf("manual 档不该有 autoMode,得到 %v", got)
|
|
236
|
+
}
|
|
237
|
+
if got := autoModeSettings(PermissionAcceptEdits, opts); got != nil {
|
|
238
|
+
t.Errorf("accept-edits 档不该有 autoMode,得到 %v", got)
|
|
239
|
+
}
|
|
240
|
+
if got := autoModeSettings(PermissionFull, opts); got != nil {
|
|
241
|
+
t.Errorf("full 档不该有 autoMode,得到 %v", got)
|
|
242
|
+
}
|
|
243
|
+
// 第 3 档但没给自定义边界 → 不注入(用引擎内建默认即可)。
|
|
244
|
+
if got := autoModeSettings(PermissionAuto, PermissionOptions{}); got != nil {
|
|
245
|
+
t.Errorf("auto 档无自定义边界时不该注入 autoMode,得到 %v", got)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
am := autoModeSettings(PermissionAuto, opts)
|
|
249
|
+
env, _ := am["environment"].([]string)
|
|
250
|
+
if len(env) != 2 || env[0] != "$defaults" {
|
|
251
|
+
t.Fatalf("autoMode.environment = %v, want [$defaults, <自定义>];"+
|
|
252
|
+
"漏掉 $defaults 等于**完整替换**内建规则,会丢掉内建安全规则", env)
|
|
253
|
+
}
|
|
254
|
+
if env[1] != "Source control: github.example.com/acme-corp" {
|
|
255
|
+
t.Errorf("自定义条目丢失: %v", env)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// TestAgentSettingsPayload 端到端组装:env / sandbox / autoMode / permissions
|
|
260
|
+
// 必须合并进**同一份** JSON(--settings 不是可重复 flag,分两次传会互相覆盖)。
|
|
261
|
+
func TestAgentSettingsPayload(t *testing.T) {
|
|
262
|
+
// ① 工具关闭 + 无 MaxTokens → 不注入(纯 chat 不需要沙箱,
|
|
263
|
+
// 而 failIfUnavailable 反而可能让一次纯 chat 因沙箱起不来而失败)。
|
|
264
|
+
if _, ok := agentSettingsPayload(Request{Tools: ToolsOff}, "claude"); ok {
|
|
265
|
+
t.Error("tools off 且无 MaxTokens 时不该注入 --settings")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ② 工具关闭 + MaxTokens → 只注入 env,不带 sandbox。
|
|
269
|
+
payload, ok := agentSettingsPayload(Request{Tools: ToolsOff, MaxTokens: 8000}, "claude")
|
|
270
|
+
if !ok {
|
|
271
|
+
t.Fatal("tools off + MaxTokens 应注入 --settings")
|
|
272
|
+
}
|
|
273
|
+
s := decodeSettings(t, payload)
|
|
274
|
+
if got := dig(s, "env", "CLAUDE_CODE_MAX_OUTPUT_TOKENS"); got != "8000" {
|
|
275
|
+
t.Errorf("env 注入错误: %v", got)
|
|
276
|
+
}
|
|
277
|
+
if _, has := s["sandbox"]; has {
|
|
278
|
+
t.Error("tools off 时不该注入 sandbox")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ③ 第 3 档 + MaxTokens + 全部可选项 → 四段齐全,且共用一份 JSON。
|
|
282
|
+
req := Request{
|
|
283
|
+
Tools: ToolsOn,
|
|
284
|
+
MaxTokens: 16000,
|
|
285
|
+
Permission: PermissionAuto,
|
|
286
|
+
PermissionOptions: PermissionOptions{
|
|
287
|
+
ExcludedCommands: []string{"docker"},
|
|
288
|
+
AutoModeEnvironment: []string{"Org: acme"},
|
|
289
|
+
Deny: []string{"Bash(rm -rf *)"},
|
|
290
|
+
Ask: []string{"Bash(git push *)"},
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
payload, ok = agentSettingsPayload(req, "claude")
|
|
294
|
+
if !ok {
|
|
295
|
+
t.Fatal("auto 档应注入 --settings")
|
|
296
|
+
}
|
|
297
|
+
s = decodeSettings(t, payload)
|
|
298
|
+
if got := dig(s, "env", "CLAUDE_CODE_MAX_OUTPUT_TOKENS"); got != "16000" {
|
|
299
|
+
t.Errorf("env 丢失(说明被 sandbox 覆盖了?): %v", got)
|
|
300
|
+
}
|
|
301
|
+
if got := dig(s, "sandbox", "enabled"); got != true {
|
|
302
|
+
t.Errorf("sandbox.enabled = %v, want true", got)
|
|
303
|
+
}
|
|
304
|
+
if got := dig(s, "sandbox", "autoAllowBashIfSandboxed"); got != true {
|
|
305
|
+
t.Errorf("auto 档 autoAllowBashIfSandboxed = %v, want true", got)
|
|
306
|
+
}
|
|
307
|
+
if got := dig(s, "autoMode", "environment"); got == nil {
|
|
308
|
+
t.Error("autoMode 丢失")
|
|
309
|
+
}
|
|
310
|
+
if got := dig(s, "permissions", "deny"); got == nil {
|
|
311
|
+
t.Error("permissions.deny 丢失")
|
|
312
|
+
}
|
|
313
|
+
if got := dig(s, "permissions", "ask"); got == nil {
|
|
314
|
+
t.Error("permissions.ask 丢失")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ④ 第 4 档:显式关闭沙箱(覆盖用户 settings 里可能开着的 sandbox)。
|
|
318
|
+
payload, ok = agentSettingsPayload(Request{Tools: ToolsOn, Permission: PermissionFull}, "claude")
|
|
319
|
+
if !ok {
|
|
320
|
+
t.Fatal("full 档应注入 --settings(显式关闭沙箱)")
|
|
321
|
+
}
|
|
322
|
+
s = decodeSettings(t, payload)
|
|
323
|
+
if got := dig(s, "sandbox", "enabled"); got != false {
|
|
324
|
+
t.Errorf("full 档 sandbox.enabled = %v, want false", got)
|
|
325
|
+
}
|
|
326
|
+
if _, has := s["autoMode"]; has {
|
|
327
|
+
t.Error("full 档不该有 autoMode")
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// TestAgentSettingsPayloadDeterministic 同一组选项必须产出同一份 JSON。
|
|
332
|
+
// (否则参数断言类测试会随机翻车,调用方的 diff 也会无意义地抖。)
|
|
333
|
+
func TestAgentSettingsPayloadDeterministic(t *testing.T) {
|
|
334
|
+
req := Request{
|
|
335
|
+
Tools: ToolsOn,
|
|
336
|
+
Permission: PermissionManual,
|
|
337
|
+
PermissionOptions: PermissionOptions{
|
|
338
|
+
ExcludedCommands: []string{"docker", "git", "docker"},
|
|
339
|
+
AllowedDomains: []string{"b.example.com", "a.example.com"},
|
|
340
|
+
Deny: []string{"Bash(rm -rf *)", "Bash(rm -rf *)"},
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
first, _ := agentSettingsPayload(req, "claude")
|
|
344
|
+
for i := 0; i < 5; i++ {
|
|
345
|
+
got, _ := agentSettingsPayload(req, "claude")
|
|
346
|
+
if got != first {
|
|
347
|
+
t.Fatalf("载荷不稳定:\n1: %s\n%d: %s", first, i, got)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── argv 映射(四档的最终证据) ────────────────────────────────
|
|
353
|
+
|
|
354
|
+
// argValue 取 argv 中某个 flag 的取值;flag 不存在时返回 ("", false)。
|
|
355
|
+
func argValue(args []string, flag string) (string, bool) {
|
|
356
|
+
for i, a := range args {
|
|
357
|
+
if a == flag && i+1 < len(args) {
|
|
358
|
+
return args[i+1], true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return "", false
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// TestClaudeBuildArgsPermissionTiers 逐档断言 claude 的真实 argv。
|
|
365
|
+
func TestClaudeBuildArgsPermissionTiers(t *testing.T) {
|
|
366
|
+
e := &ClaudeEngine{}
|
|
367
|
+
cases := []struct {
|
|
368
|
+
tier PermissionTier
|
|
369
|
+
wantMode string
|
|
370
|
+
}{
|
|
371
|
+
{PermissionManual, "default"},
|
|
372
|
+
{PermissionAcceptEdits, "acceptEdits"},
|
|
373
|
+
{PermissionAuto, "auto"},
|
|
374
|
+
{PermissionFull, "bypassPermissions"},
|
|
375
|
+
}
|
|
376
|
+
for _, c := range cases {
|
|
377
|
+
// --tools on
|
|
378
|
+
args := e.buildArgsBase(Request{Tools: ToolsOn, Permission: c.tier})
|
|
379
|
+
got, ok := argValue(args, "--permission-mode")
|
|
380
|
+
if !ok {
|
|
381
|
+
t.Fatalf("%s(--tools on): argv 里没有 --permission-mode: %v", c.tier, args)
|
|
382
|
+
}
|
|
383
|
+
if got != c.wantMode {
|
|
384
|
+
t.Errorf("%s(--tools on): --permission-mode = %q, want %q", c.tier, got, c.wantMode)
|
|
385
|
+
}
|
|
386
|
+
if _, has := argValue(args, "--tools"); has {
|
|
387
|
+
t.Errorf("%s(--tools on): 不该传 --tools(on = 引擎默认全工具)", c.tier)
|
|
388
|
+
}
|
|
389
|
+
// 改造后不该再出现 --dangerously-skip-permissions(档位统一走 --permission-mode)。
|
|
390
|
+
for _, a := range args {
|
|
391
|
+
if a == "--dangerously-skip-permissions" {
|
|
392
|
+
t.Errorf("%s: 仍在传 --dangerously-skip-permissions,应统一走 --permission-mode", c.tier)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// 沙箱只能经 --settings 进(claude 没有 --sandbox flag)。
|
|
396
|
+
if _, has := argValue(args, "--settings"); !has {
|
|
397
|
+
t.Errorf("%s: 缺少 --settings(沙箱配置没有 CLI flag,只能从这里进)", c.tier)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --tools 白名单
|
|
401
|
+
wargs := e.buildArgsBase(Request{Tools: ToolsAllowlist([]string{"Bash", "Read"}), Permission: c.tier})
|
|
402
|
+
if got, _ := argValue(wargs, "--tools"); got != "Bash,Read" {
|
|
403
|
+
t.Errorf("%s(白名单): --tools = %q, want Bash,Read", c.tier, got)
|
|
404
|
+
}
|
|
405
|
+
if got, _ := argValue(wargs, "--permission-mode"); got != c.wantMode {
|
|
406
|
+
t.Errorf("%s(白名单): --permission-mode = %q, want %q", c.tier, got, c.wantMode)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --tools off:不传档位、不传 settings(无工具调用,沙箱无意义)。
|
|
411
|
+
offArgs := e.buildArgsBase(Request{Tools: ToolsOff, Permission: PermissionManual})
|
|
412
|
+
if _, has := argValue(offArgs, "--permission-mode"); has {
|
|
413
|
+
t.Errorf("--tools off 不该传 --permission-mode: %v", offArgs)
|
|
414
|
+
}
|
|
415
|
+
if _, has := argValue(offArgs, "--settings"); has {
|
|
416
|
+
t.Errorf("--tools off 且无 MaxTokens 不该传 --settings: %v", offArgs)
|
|
417
|
+
}
|
|
418
|
+
if got, _ := argValue(offArgs, "--tools"); got != "" {
|
|
419
|
+
t.Errorf("--tools off 应传空串,得到 %q", got)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// TestClaudeBuildArgsDefaultTierUnchanged 默认档的 argv 与改造前的语义一致。
|
|
424
|
+
//
|
|
425
|
+
// 改造前:--tools on → --dangerously-skip-permissions(无 --settings)。
|
|
426
|
+
// 改造后:--tools on → --permission-mode bypassPermissions + --settings{"sandbox":{"enabled":false}}。
|
|
427
|
+
// 两者在 claude 官方文档里是**等价**的("Equivalent to --permission-mode bypassPermissions"),
|
|
428
|
+
// 且显式关沙箱能覆盖用户 settings 里可能开着的 sandbox.enabled。
|
|
429
|
+
func TestClaudeBuildArgsDefaultTierUnchanged(t *testing.T) {
|
|
430
|
+
e := &ClaudeEngine{}
|
|
431
|
+
args := e.buildArgsBase(Request{Tools: ToolsOn}) // Permission 零值
|
|
432
|
+
if got, _ := argValue(args, "--permission-mode"); got != "bypassPermissions" {
|
|
433
|
+
t.Errorf("默认档 --permission-mode = %q, want bypassPermissions", got)
|
|
434
|
+
}
|
|
435
|
+
payload, ok := argValue(args, "--settings")
|
|
436
|
+
if !ok {
|
|
437
|
+
t.Fatal("默认档应带 --settings(显式关闭沙箱)")
|
|
438
|
+
}
|
|
439
|
+
if got := dig(decodeSettings(t, payload), "sandbox", "enabled"); got != false {
|
|
440
|
+
t.Errorf("默认档 sandbox.enabled = %v, want false", got)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// TestCodeBuddyBuildArgsPermissionTiers codebuddy 与 claude 同族,档位取值一致。
|
|
445
|
+
func TestCodeBuddyBuildArgsPermissionTiers(t *testing.T) {
|
|
446
|
+
e := &CodeBuddyEngine{}
|
|
447
|
+
args := e.buildArgsBase(Request{Tools: ToolsOn, Permission: PermissionAuto})
|
|
448
|
+
if got, ok := argValue(args, "--permission-mode"); !ok || got != "auto" {
|
|
449
|
+
t.Errorf("codebuddy --permission-mode = %q (ok=%v), want auto", got, ok)
|
|
450
|
+
}
|
|
451
|
+
// 改造后不该再出现 -y。
|
|
452
|
+
for _, a := range args {
|
|
453
|
+
if a == "-y" {
|
|
454
|
+
t.Error("codebuddy 仍在传 -y,应统一走 --permission-mode")
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
payload, ok := argValue(args, "--settings")
|
|
458
|
+
if !ok {
|
|
459
|
+
t.Fatal("codebuddy auto 档应带 --settings")
|
|
460
|
+
}
|
|
461
|
+
s := decodeSettings(t, payload)
|
|
462
|
+
if got := dig(s, "sandbox", "enabled"); got != true {
|
|
463
|
+
t.Errorf("codebuddy auto 档 sandbox.enabled = %v, want true", got)
|
|
464
|
+
}
|
|
465
|
+
if got := dig(s, "sandbox", "autoAllowBashIfSandboxed"); got != true {
|
|
466
|
+
t.Errorf("codebuddy auto 档 autoAllowBashIfSandboxed = %v, want true", got)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── 端到端:参数真的进了子进程 ────────────────────────────────
|
|
471
|
+
|
|
472
|
+
// TestClaudePermissionEndToEnd 假 CLI 落 argv 取证。
|
|
473
|
+
//
|
|
474
|
+
// buildArgsBase 的返回值好看不等于子进程真的收到 —— spawn 路径上还有
|
|
475
|
+
// runCLIIn / streamJSONArgs 等环节会改参数。这里让假 CLI 把 "$@" 原样落盘,
|
|
476
|
+
// 再断言落盘内容里确实有档位与沙箱载荷。
|
|
477
|
+
func TestClaudePermissionEndToEnd(t *testing.T) {
|
|
478
|
+
dir := t.TempDir()
|
|
479
|
+
out := filepath.Join(dir, "argv.txt")
|
|
480
|
+
|
|
481
|
+
script := "#!/bin/sh\n" +
|
|
482
|
+
"for a in \"$@\"; do printf '%s\\n' \"$a\" >> \"" + out + "\"; done\n" +
|
|
483
|
+
"echo '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"session_id\":\"s-1\",\"model\":\"m\"}'\n"
|
|
484
|
+
cli := writeFakeCLI(t, "claude", script)
|
|
485
|
+
|
|
486
|
+
e := &ClaudeEngine{BinPath: cli}
|
|
487
|
+
req := Request{
|
|
488
|
+
Engine: "claude",
|
|
489
|
+
Tools: ToolsOn,
|
|
490
|
+
Permission: PermissionAcceptEdits,
|
|
491
|
+
PermissionOptions: PermissionOptions{
|
|
492
|
+
Ask: []string{"Bash(git push *)"},
|
|
493
|
+
},
|
|
494
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
495
|
+
}
|
|
496
|
+
if _, err := e.Complete(context.Background(), req); err != nil {
|
|
497
|
+
t.Fatalf("Complete 失败: %v", err)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
raw, err := os.ReadFile(out)
|
|
501
|
+
if err != nil {
|
|
502
|
+
t.Fatalf("假 CLI 未落盘 argv: %v", err)
|
|
503
|
+
}
|
|
504
|
+
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
505
|
+
got, ok := argValue(lines, "--permission-mode")
|
|
506
|
+
if !ok {
|
|
507
|
+
t.Fatalf("子进程 argv 里没有 --permission-mode:\n%s", raw)
|
|
508
|
+
}
|
|
509
|
+
if got != "acceptEdits" {
|
|
510
|
+
t.Errorf("子进程收到 --permission-mode %q, want acceptEdits", got)
|
|
511
|
+
}
|
|
512
|
+
payload, ok := argValue(lines, "--settings")
|
|
513
|
+
if !ok {
|
|
514
|
+
t.Fatalf("子进程 argv 里没有 --settings:\n%s", raw)
|
|
515
|
+
}
|
|
516
|
+
s := decodeSettings(t, payload)
|
|
517
|
+
if v := dig(s, "sandbox", "autoAllowBashIfSandboxed"); v != false {
|
|
518
|
+
t.Errorf("第 2 档 autoAllowBashIfSandboxed = %v, want false(命令仍须确认)", v)
|
|
519
|
+
}
|
|
520
|
+
if v := dig(s, "sandbox", "enabled"); v != true {
|
|
521
|
+
t.Errorf("第 2 档 sandbox.enabled = %v, want true", v)
|
|
522
|
+
}
|
|
523
|
+
if v := dig(s, "permissions", "ask"); v == nil {
|
|
524
|
+
t.Error("permissions.ask 未进子进程")
|
|
525
|
+
}
|
|
526
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// prompt.go - 多轮消息扁平化为单条 prompt 的公共逻辑。
|
|
4
|
+
//
|
|
5
|
+
// CLI 后端(claude -p / codebuddy --print / trae -p)本质上只接受一条
|
|
6
|
+
// prompt 字符串。当调用方给出 system prompt + 多轮 messages 时,按各角色
|
|
7
|
+
// 打标签拼接;末尾若是 assistant 消息,补一条「请继续」的 user 轮,
|
|
8
|
+
// 保证最终话语权交给模型。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"fmt"
|
|
12
|
+
"strings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// noToolSuffix 追加到 system prompt 末尾的公共约束:禁止 agent loop /
|
|
16
|
+
// 工具调用,强制一次性输出最终答案。codebuddy/trae 实测不加这个会输出
|
|
17
|
+
// agent trace("I'll explore..." + <tool_calls>...),下游无法解析。
|
|
18
|
+
// claude 引擎默认用 --tools "" 关工具,不追加该后缀。
|
|
19
|
+
//
|
|
20
|
+
// 第 4 条针对「伪造工具返回」:实测(2026-09-15)off 模式下约 1/6 概率,
|
|
21
|
+
// 模型被要求「输出工具抓取到的内容」但没有工具时,会凭空编造一份看似
|
|
22
|
+
// 真实的返回体(如 httpbin JSON),甚至把 prompt 里 URL 携带的随机串填
|
|
23
|
+
// 进去冒充真实请求结果。仅禁「调用工具」堵不住这种编造,必须显式禁止。
|
|
24
|
+
const noToolSuffix = "\n\n[约束]\n- 严禁使用任何工具(function call / tool call / agent loop)。\n- 严禁伪造工具返回结果:不得编造网页/搜索/命令/文件的输出内容,不得假装调用过工具或声称已获取外部数据。若没有工具可用,就直接说明无法获取,不要用回忆或猜测填充成「工具返回」。\n- 直接以纯文本 markdown 一次性输出最终答案,不要输出思考过程、不要解释、不要分析。\n- 不要以 \"I'll ...\" / \"Let me ...\" / \"I need to ...\" 等执行意图开头。\n- 第一行必须是答案正文。"
|
|
25
|
+
|
|
26
|
+
// FlattenPrompt 把 system prompt + messages 扁平化为一条 prompt。
|
|
27
|
+
// appendNoTool 为 true 时在 system 部分后追加 noToolSuffix。
|
|
28
|
+
func FlattenPrompt(systemPrompt string, messages []Message, appendNoTool bool) string {
|
|
29
|
+
var sb strings.Builder
|
|
30
|
+
if systemPrompt != "" {
|
|
31
|
+
sb.WriteString("【系统指令】\n")
|
|
32
|
+
sb.WriteString(systemPrompt)
|
|
33
|
+
if appendNoTool {
|
|
34
|
+
sb.WriteString(noToolSuffix)
|
|
35
|
+
}
|
|
36
|
+
sb.WriteString("\n\n")
|
|
37
|
+
}
|
|
38
|
+
for _, m := range messages {
|
|
39
|
+
switch m.Role {
|
|
40
|
+
case "system":
|
|
41
|
+
sb.WriteString("【系统】\n")
|
|
42
|
+
case "user":
|
|
43
|
+
sb.WriteString("【用户】\n")
|
|
44
|
+
case "assistant":
|
|
45
|
+
sb.WriteString("【助手】\n")
|
|
46
|
+
default:
|
|
47
|
+
sb.WriteString(fmt.Sprintf("【%s】\n", m.Role))
|
|
48
|
+
}
|
|
49
|
+
sb.WriteString(m.Content)
|
|
50
|
+
sb.WriteString("\n\n")
|
|
51
|
+
}
|
|
52
|
+
// 末尾是 assistant 时补 user 轮,让 CLI 收到明确的「最终输入」。
|
|
53
|
+
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
|
|
54
|
+
sb.WriteString("【用户】\n请继续。\n")
|
|
55
|
+
}
|
|
56
|
+
return strings.TrimSpace(sb.String())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// stripModelPrefix 剥掉 "engine/model" 形式的前缀,返回裸模型名。
|
|
60
|
+
func stripModelPrefix(model string) string {
|
|
61
|
+
if i := strings.Index(model, "/"); i >= 0 {
|
|
62
|
+
return model[i+1:]
|
|
63
|
+
}
|
|
64
|
+
return model
|
|
65
|
+
}
|