Files
Agent-System-Backend/internal/app/squad_config_test.go
T
weichen 80faf3edbd Resolve squad routes from the run owner and project
Why: F13 - the squad factory read the default account with an empty
project, pairing the run user's model name with another account's
endpoint and credentials; direct and squad paths disagreed on
reasoning split and route contract.
Evidence: app/runtime/squadadapter tests pass (verify-u10b.json);
run-scoped snapshot, project override, profile override and
fail-closed cases added with synthetic keys.
Impact: squad execution now uses the same effective-config rules as
the direct path; default setups unchanged.
Refs: feature/agent-stack-audit-remediation-b1 U10
2026-10-21 16:15:03 +00:00

444 lines
17 KiB
Go

// 小队内核配置的归属测试(F13 / KTD3):内核用的模型、端点、密钥必须整条来自
// 「本次运行」的生效快照,不得按默认账号重新解析——否则一个用户的模型名会配
// 上另一个账号的端点/密钥。全部使用合成密钥与本地假服务。
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// squadRunCtx 是适配器窄端口的最小宿主实现:只有配置作用域(无 Profile 端口,
// 属主只能来自 run)。
type squadRunCtx struct {
run core.Run
eff core.EffectiveSettings
}
func (c *squadRunCtx) Context() context.Context { return context.Background() }
func (c *squadRunCtx) Run() core.Run { return c.run }
func (c *squadRunCtx) Settings() core.EffectiveSettings { return c.eff }
func (c *squadRunCtx) Emit(core.StreamEventType, any) error { return nil }
func (c *squadRunCtx) UpdatePlan(core.PlanSnapshot) error { return nil }
func (c *squadRunCtx) SpawnSubagent(string, string, string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{}, nil
}
func (c *squadRunCtx) AskHITL(core.HITLCard) (core.HITLResponse, error) {
return core.HITLResponse{}, nil
}
func (c *squadRunCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
return core.Artifact{}, nil
}
var _ squadadapter.Ctx = (*squadRunCtx)(nil)
// squadProfileRunCtx 是带画像端口的宿主(runmgr 的 runCtx 形态):画像的
// Owner 与 run.Owner 一致,模型覆盖按直连规则生效。
type squadProfileRunCtx struct {
*squadRunCtx
prof core.AgentProfile
}
func (c *squadProfileRunCtx) Profile() core.AgentProfile { return c.prof }
// squadSecretStub 是受管密钥来源桩(Tavily 池按 owner 查)。
type squadSecretStub map[string]string
func (s squadSecretStub) ActiveSecret(owner, _ string) (string, bool) {
v, ok := s[owner]
return v, ok
}
func squadStore(t *testing.T) (*store.Store, *settings.Service) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "squad.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st, settings.NewService(st)
}
// squadLLMCall 是一次上游请求的观测(只记合成字段)。
type squadLLMCall struct {
Path string
Authorization string
Model string
ReasoningSplit bool
}
func squadLLMServer(t *testing.T) (*httptest.Server, func() []squadLLMCall) {
t.Helper()
var mu sync.Mutex
var calls []squadLLMCall
frame := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return "data: " + string(raw) + "\n\n"
}
body := frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": "ok"}}}}) +
frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}}) +
frame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) +
"data: [DONE]\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var decoded struct {
Model string `json:"model"`
ReasoningSplit bool `json:"reasoning_split"`
}
_ = json.Unmarshal(raw, &decoded)
mu.Lock()
calls = append(calls, squadLLMCall{
Path: r.URL.Path, Authorization: r.Header.Get("Authorization"),
Model: decoded.Model, ReasoningSplit: decoded.ReasoningSplit,
})
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(server.Close)
return server, func() []squadLLMCall {
mu.Lock()
defer mu.Unlock()
return append([]squadLLMCall(nil), calls...)
}
}
// squadCall 驱动装配出的内核流一次,返回终态事件。
func squadCall(t *testing.T, stream agentllm.StreamFunc, model string) []agentllm.Event {
t.Helper()
if stream == nil {
t.Fatal("squad config produced no stream")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var terminal []agentllm.Event
for ev := range stream(ctx, model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, nil, agentllm.StreamOptions{}) {
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
terminal = append(terminal, ev)
}
}
if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil {
t.Fatalf("terminal events = %+v, want one clean message_end", terminal)
}
return terminal
}
func chatEff(model, base, key string) core.EffectiveSettings {
return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"model": model, "baseUrl": base, "apiKey": key,
}}}
}
// 两位用户的端点/密钥/模型各不相同:装配必须整条取自本次运行的快照。
func TestSquadNativeOptionsResolvesRouteFromRunScope(t *testing.T) {
aliceSrv, aliceCalls := squadLLMServer(t)
bobSrv, bobCalls := squadLLMServer(t)
// 默认账号:与两位用户都不同;小队路径不得解析到它。
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for _, tc := range []struct {
owner core.ID
model string
base string
key string
calls func() []squadLLMCall
foreign []string
}{
{"alice", "alice-model", aliceSrv.URL + "/v1", "sk-alice-synthetic", aliceCalls,
[]string{"bob-model", "sk-bob-synthetic", "default-account-model", "sk-default-synthetic"}},
{"bob", "bob-model", bobSrv.URL + "/v1", "sk-bob-synthetic", bobCalls,
[]string{"alice-model", "sk-alice-synthetic", "default-account-model", "sk-default-synthetic"}},
} {
t.Run(string(tc.owner), func(t *testing.T) {
cfg, enabled := factory(&squadRunCtx{
run: core.Run{ID: "run_" + string(tc.owner), Owner: tc.owner},
eff: chatEff(tc.model, tc.base, tc.key),
})
if !enabled || cfg.Client == nil {
t.Fatalf("factory disabled: enabled=%v cfg=%+v", enabled, cfg)
}
if cfg.DefaultModel != tc.model {
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
}
if cfg.Client.BaseURL != tc.base || cfg.Client.APIKey != tc.key {
t.Fatalf("route = %q/%q, want %q/%q", cfg.Client.BaseURL, cfg.Client.APIKey, tc.base, tc.key)
}
for _, foreign := range tc.foreign {
if strings.Contains(cfg.DefaultModel+cfg.Client.BaseURL+cfg.Client.APIKey, foreign) {
t.Fatalf("squad config leaked another account's value %q: model=%q base=%q key=%q",
foreign, cfg.DefaultModel, cfg.Client.BaseURL, cfg.Client.APIKey)
}
}
// 真正发一次请求:模型名、端点与密钥必须成对出现在同一次调用里。
squadCall(t, cfg.Stream, cfg.DefaultModel)
calls := tc.calls()
if len(calls) != 1 {
t.Fatalf("upstream calls = %d, want exactly 1", len(calls))
}
if calls[0].Path != "/v1/chat/completions" {
t.Fatalf("request path = %q, want the run's own endpoint", calls[0].Path)
}
if calls[0].Authorization != "Bearer "+tc.key {
t.Fatalf("authorization = %q, want the run's own key", calls[0].Authorization)
}
if calls[0].Model != tc.model {
t.Fatalf("request model = %q, want %q", calls[0].Model, tc.model)
}
})
}
}
// 受管密钥按属主隔离:画像里的 Owner 决定密钥层归属,不得借默认/系统账号的。
func TestSquadNativeOptionsNeverBorrowsAnotherOwnerSecret(t *testing.T) {
st, svc := squadStore(t)
srv, _ := squadLLMServer(t)
base := srv.URL + "/v1"
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "shared-model", "baseUrl": base,
}}}); err != nil {
t.Fatal(err)
}
for owner, value := range map[string]string{
settings.OwnerSystemRow: "sk-system-synthetic",
"alice": "sk-alice-synthetic",
"bob": "sk-bob-synthetic",
store.OwnerDefault: "sk-default-synthetic",
} {
if _, err := st.AddSecret(owner, "models.chat", "test", value); err != nil {
t.Fatal(err)
}
}
// 与组合根同一套解析规则:属主 → 系统行 → 无。
saved := runtime.SecretLookup
runtime.SecretLookup = func(owner, service string) (string, string, bool) {
if owner == "" {
owner = store.OwnerDefault
}
if v, ok := st.ActiveSecret(owner, service); ok {
return v, "owner", true
}
if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok {
return v, "system", true
}
return "", "", false
}
t.Cleanup(func() { runtime.SecretLookup = saved })
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for _, tc := range []struct {
owner, model, key string
}{
{"alice", "alice-profile-model", "sk-alice-synthetic"},
{"bob", "bob-profile-model", "sk-bob-synthetic"},
// 无自有密钥的租户仍按既有规则回落系统行受管密钥(不是默认账号的)。
{"carol", "shared-model", "sk-system-synthetic"},
} {
t.Run(tc.owner, func(t *testing.T) {
prof := core.AgentProfile{Owner: tc.owner}
if tc.model != "shared-model" {
prof.Model = core.ModelRoute{Chat: tc.model}
}
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
eff: svc.Effective(tc.owner, ""),
},
prof: prof,
})
if !enabled {
t.Fatal("factory disabled for a configured run")
}
if cfg.DefaultModel != tc.model || cfg.Client.APIKey != tc.key {
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.APIKey, tc.model, tc.key)
}
if cfg.Client.BaseURL != base {
t.Fatalf("base = %q, want %q", cfg.Client.BaseURL, base)
}
// 无 Profile 端口时属主只能来自 run.Owner——密钥层不得退化成默认账号。
bare, _ := factory(&squadRunCtx{
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
eff: svc.Effective(tc.owner, ""),
})
if bare.Client.APIKey != tc.key || bare.Client.BaseURL != base {
t.Fatalf("owner from run = %q/%q, want %q/%q", bare.Client.APIKey, bare.Client.BaseURL, tc.key, base)
}
})
}
}
// 项目运行消费的是「用户层 + 项目层」合并后的快照:项目覆盖的键取项目,
// 未覆盖的键仍留用户层,绝不整包换回默认账号。
func TestSquadNativeOptionsHonorsProjectLayerSnapshot(t *testing.T) {
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
projSrv, projCalls := squadLLMServer(t)
userLayer := map[string]any{
"models": map[string]any{"chat": map[string]any{
"model": "user-model", "baseUrl": "https://user.invalid/v1", "apiKey": "sk-user-synthetic",
}},
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "tvly-user-synthetic"}},
"runtime": map[string]any{"codeOrchestration": true, "exec": true},
}
projectLayer := map[string]any{"models": map[string]any{"chat": map[string]any{
"baseUrl": projSrv.URL + "/v1", "apiKey": "sk-project-synthetic",
}}}
eff := core.EffectiveSettings(core.MergeSettings(userLayer, projectLayer))
factory := squadAgentNativeFactory(squadNativeDeps{
set: svc, secrets: squadSecretStub{store.OwnerDefault: "tvly-active-synthetic"},
})
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_proj", Owner: "alice", ProjectID: "proj1"}, eff: eff},
prof: core.AgentProfile{Owner: "alice"},
})
if !enabled {
t.Fatal("factory disabled for a configured project run")
}
if cfg.DefaultModel != "user-model" {
t.Fatalf("model = %q, want the user-layer value %q", cfg.DefaultModel, "user-model")
}
if cfg.Client.BaseURL != projSrv.URL+"/v1" || cfg.Client.APIKey != "sk-project-synthetic" {
t.Fatalf("route = %q/%q, want the project-layer endpoint and key", cfg.Client.BaseURL, cfg.Client.APIKey)
}
// 直接驱动内核客户端(受保护的 cfg.Stream 在无项目上下文端口的桩上按
// 设计 fail-closed,不用于本断言):模型名、端点与密钥成对出现。
squadCall(t, cfg.Client.Stream, cfg.DefaultModel)
calls := projCalls()
if len(calls) != 1 || calls[0].Authorization != "Bearer sk-project-synthetic" || calls[0].Model != "user-model" {
t.Fatalf("upstream calls = %+v, want the project endpoint with the user-layer model", calls)
}
// 项目来源的观察/授权限制不得因为改配置解析而放宽。
if cfg.Search != nil || cfg.Exec != nil || cfg.Fetch != nil || cfg.MCP != nil {
t.Fatalf("project run kept direct providers: search=%v exec=%v fetch=%v mcp=%v", cfg.Search != nil, cfg.Exec != nil, cfg.Fetch != nil, cfg.MCP != nil)
}
if cfg.CodeOrchestration {
t.Fatal("project run kept code orchestration")
}
}
// per-route 规则与直连同源:画像模型覆盖后,reasoning split 与受限 EOF 契约
// 都按「覆盖后这条路由」判定。
func TestSquadNativeOptionsMatchesDirectRouteRules(t *testing.T) {
_, svc := squadStore(t)
// 契约只认规范化后的 https 端点,所以这里用合成端点(不发请求)。
base := "https://model.example.com/v1"
if err := svc.MergeSystem(map[string]any{"models": map[string]any{
"chat": map[string]any{"model": "MiniMax-M1", "baseUrl": base},
"routeContractEOF": map[string]any{
"model": "MiniMax-M1",
"baseUrl": base,
"identitySha256": agentllm.RouteContractIdentity("MiniMax-M1", base),
},
}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
eff := svc.Effective("alice", "")
for _, tc := range []struct {
name string
profile core.AgentProfile
model string
wantSplit bool
wantContract bool
}{
{"settings route", core.AgentProfile{Owner: "alice"}, "MiniMax-M1", true, true},
{"profile keeps reasoning split, drops contract", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "MiniMax-M2"}}, "MiniMax-M2", true, false},
{"profile override to plain model", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "plain-model"}}, "plain-model", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
prof := tc.profile
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_rules", Owner: "alice"}, eff: eff},
prof: prof,
})
if !enabled {
t.Fatal("factory disabled for a configured run")
}
if cfg.DefaultModel != tc.model {
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
}
if cfg.Client.ReasoningSplit != tc.wantSplit {
t.Fatalf("reasoning split = %v, want %v", cfg.Client.ReasoningSplit, tc.wantSplit)
}
if (cfg.Client.RouteContract != nil) != tc.wantContract {
t.Fatalf("route contract = %+v, want attached=%v", cfg.Client.RouteContract, tc.wantContract)
}
// 逐字段等于直连路径同一快照构造出的客户端。
direct := runtime.OpenRouteClient(runtime.ResolveChatModel(prof, eff), eff)
if cfg.Client.BaseURL != direct.BaseURL || cfg.Client.APIKey != direct.APIKey ||
cfg.Client.ReasoningSplit != direct.ReasoningSplit {
t.Fatalf("squad client %+v differs from the direct-path client %+v", cfg.Client, direct)
}
if (cfg.Client.RouteContract == nil) != (direct.RouteContract == nil) {
t.Fatalf("contract decision differs from the direct path: squad=%v direct=%v",
cfg.Client.RouteContract, direct.RouteContract)
}
})
}
}
// 宿主没有 Settings 端口(旧骨架/测试桩)时仍按默认账号快照装配,行为不变。
func TestSquadNativeOptionsFallsBackToDefaultAccountSnapshot(t *testing.T) {
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for name, eff := range map[string]core.EffectiveSettings{
"nil snapshot": nil,
"empty snapshot": {},
"settings without run": {"models": map[string]any{"chat": map[string]any{"model": "snapshot-model", "baseUrl": "https://snapshot.invalid/v1"}}},
} {
t.Run(name, func(t *testing.T) {
cfg, enabled := factory(&squadRunCtx{run: core.Run{ID: "run_default", Owner: store.OwnerDefault}, eff: eff})
if !enabled {
t.Fatal("default-config run stopped working")
}
want := "snapshot-model"
wantBase := "https://snapshot.invalid/v1"
if len(eff) == 0 {
want, wantBase = "default-account-model", "https://default.invalid/v1"
}
if cfg.DefaultModel != want || cfg.Client.BaseURL != wantBase {
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.BaseURL, want, wantBase)
}
})
}
// 没有快照来源也没有默认账号配置时保持未启用(不阻塞产品)。
if _, enabled := squadAgentNativeFactory(squadNativeDeps{})(&squadRunCtx{run: core.Run{ID: "run_none"}}); enabled {
t.Fatal("factory reported enabled without any configuration source")
}
}