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
444 lines
17 KiB
Go
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")
|
|
}
|
|
}
|