diff --git a/deps/agent-system-core b/deps/agent-system-core index ba289e7..9150e6f 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit ba289e7b1780a29855aef8477bf1ac49bd563eb2 +Subproject commit 9150e6f90b7ed360467492c6102d971a97c8a59a diff --git a/internal/app/app.go b/internal/app/app.go index 314ad68..ba8db5e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -295,7 +295,12 @@ func Run(opt Options) error { if owner == "" { owner = store.OwnerDefault } - return st.ActiveSecret(owner, service) + if v, ok := st.ActiveSecret(owner, service); ok { + return v, true + } + // P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行 + //(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。 + return st.ActiveSecret(settings.OwnerSystemRow, service) } rag.SecretLookup = func(service, fallback string) string { return runtime.LookupSecret(store.OwnerDefault, service, fallback) @@ -486,6 +491,9 @@ func Run(opt Options) error { logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable)) } } + // P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径 + //(user/project 写入拒绝、生效链剥除)。 + setSvc.SetExtraSchema(pluginHost.SettingsSchema()) // 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。 pipe := rag.NewPipeline(st, setSvc, nil, nil) diff --git a/internal/rag/rag_test.go b/internal/rag/rag_test.go index 8260039..c3ce9f6 100644 --- a/internal/rag/rag_test.go +++ b/internal/rag/rag_test.go @@ -162,8 +162,9 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) { } // 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。 + // P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。 pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil) - if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{ + if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{ "models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}}, }); err != nil { t.Fatal(err) diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 7c476cd..cb31fef 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -527,3 +527,79 @@ func TestStaleKeySessionFallback(t *testing.T) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } } + +// TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403, +// 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。 +func TestSystemSettingsAdminGate(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + hash, err := store.HashPassword("init-pass-01") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil { + t.Fatal(err) + } + keyBoss, err := st.CreateAPIKey("boss", "cli") + if err != nil { + t.Fatal(err) + } + keyDave, err := st.CreateAPIKey("dave", "cli") + if err != nil { + t.Fatal(err) + } + // 初始密码改密门不参与本测试对象(受管拦截语义),先解除。 + if err := st.ClearMustChangePassword("boss"); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("dave"); err != nil { + t.Fatal(err) + } + do := func(key, method, path string, body string) *httptest.ResponseRecorder { + var rd io.Reader + if body != "" { + rd = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, rd) + req.Header.Set("Authorization", "Bearer "+key) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。 + if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden { + t.Fatalf("dave get system: %d", rec.Code) + } + if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden { + t.Fatalf("dave put system: %d", rec.Code) + } + if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest { + t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String()) + } + if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK { + t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String()) + } + + // 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。 + if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK { + t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String()) + } + rec := do(keyBoss, "GET", "/api/settings/system", "") + if rec.Code != http.StatusOK { + t.Fatalf("boss get system: %d", rec.Code) + } + if strings.Contains(rec.Body.String(), "sk-boss-secret") { + t.Fatalf("system read leaked secret key") + } + if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" { + t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", "")) + } +} diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 0b84e36..44a21c8 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -48,12 +48,14 @@ func newTestServer(t *testing.T) (*Server, *store.Store) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) - _ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}}) - b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) + // P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。 + if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { + t.Fatal(err) + } profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) @@ -1010,41 +1012,59 @@ func TestSquadOrchestration(t *testing.T) { t.Fatal("squad deliverable artifact not found") } -// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project) -// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。 +// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统 +// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名 +// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。 func TestModelRoutingOverrideChain(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) - set := func(body map[string]any, path string) { - code, out := doJSON(t, h, "PUT", path, body) - if code != 200 { - t.Fatalf("PUT %s: %d %v", path, code, out) + // user/project 层写受管项 → 400(含点路径与整体子树覆盖)。 + for _, tc := range []struct { + name string + path string + body map[string]any + }{ + {"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}}, + {"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}}, + {"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}}, + {"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}}, + } { + code, out := doJSON(t, h, "PUT", tc.path, tc.body) + if code != http.StatusBadRequest { + t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out) } } - // user 层:model-A。 - set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user") - eff := settings.NewService(st).Effective(store.OwnerDefault, projectID) - if got := eff.GetString("models.chat.model", ""); got != "model-A" { - t.Fatalf("user layer: %q", got) - } - // project 层:model-B(覆盖 user)。 - set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID) - eff = settings.NewService(st).Effective(store.OwnerDefault, projectID) - if got := eff.GetString("models.chat.model", ""); got != "model-B" { - t.Fatalf("project layer should win: %q", got) - } - // 设置 API 呈现两层。 - code, out := doJSON(t, h, "GET", "/api/settings?project="+projectID, nil) + // 系统层(无认证测试档 = 本机管理员)写入生效。 + code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{ + "models": map[string]any{"chat": map[string]any{"model": "model-SYS"}}, + }) if code != 200 { - t.Fatal("get settings failed") + t.Fatalf("system write: %d %v", code, out) + } + if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" { + t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", "")) + } + // 系统 GET 面:schema 携带 scope,系统值脱敏返回。 + code, out = doJSON(t, h, "GET", "/api/settings/system", nil) + if code != 200 { + t.Fatalf("get system: %d %v", code, out) + } + sysView := out + if sysView["system"] == nil || sysView["schema"] == nil { + t.Fatalf("system view malformed: %#v", sysView) + } + // 非受管项仍可走 user 层(回归)。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 { + t.Fatalf("non-managed user write rejected") } - _ = out - // 画像层:model-C 为最终覆盖。关 mock → 发消息 → run 失败信息携带解析结果。 - set(map[string]any{"runtime": map[string]any{"mock": false}}, "/api/settings/user") + // 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 { + t.Fatal("disable mock via system layer failed") + } prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{ "id": "prof_route_c", "name": "路由C", "systemPrompt": "x", "model": map[string]any{"chat": "model-C"}, @@ -1205,18 +1225,27 @@ func TestProjectInbox(t *testing.T) { } } -// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。 +// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、 +// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。 func TestSettingsDottedKeyAndSecretMask(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() + svc := settings.NewService(st) + set := func(body map[string]any) { t.Helper() code, out := doJSON(t, h, "PUT", "/api/settings/user", body) if code != 200 { - t.Fatalf("PUT: %d %v", code, out) + t.Fatalf("PUT user: %d %v", code, out) + } + } + setSys := func(body map[string]any) { + t.Helper() + code, out := doJSON(t, h, "PUT", "/api/settings/system", body) + if code != 200 { + t.Fatalf("PUT system: %d %v", code, out) } } - svc := settings.NewService(st) set(map[string]any{"runtime.entry": "squad"}) if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" { @@ -1232,21 +1261,25 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) { const pool = "tvly-should-not-leak" const modelKey = "sk-should-not-leak" - set(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}}) - set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}}) - set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}}) - set(map[string]any{ - "tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested-wins"}}, - "tools.tavily.apiKeys": "flat-loses", - }) + // P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{ + "tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}, + }); code != http.StatusBadRequest { + t.Fatalf("managed pool write via user should 400, got %d", code) + } + setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}}) + setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}}) + // 掩码回写经系统层:不覆盖已存明文。 + setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}}) eff := svc.Effective(store.OwnerDefault, "") if got := eff.GetString("models.chat.apiKey", ""); got != modelKey { t.Fatalf("mask writeback overwrote key: %q", got) } - if got := eff.GetString("tools.tavily.apiKeys", ""); got != "nested-wins" { - t.Fatalf("nested form should win, got %q", got) + if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool { + t.Fatalf("system pool not effective: %q", got) } + // 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。 code, out := doJSON(t, h, "GET", "/api/settings", nil) if code != 200 { t.Fatalf("GET settings: %d", code) @@ -1255,19 +1288,36 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) { if err != nil { t.Fatal(err) } - for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} { + for _, secret := range []string{pool, modelKey} { if strings.Contains(string(blob), secret) { - t.Fatalf("GET leaked %q", secret) + t.Fatalf("GET /api/settings leaked %q", secret) } } user := out["user"].(map[string]any) - tavily := user["tools"].(map[string]any)["tavily"].(map[string]any) - if tavily["apiKeys"] != settings.MaskPlaceholder { - t.Fatalf("apiKeys mask = %v", tavily["apiKeys"]) + if user["models"] != nil || user["tools"] != nil { + t.Fatalf("managed subtrees exposed in user scope: %#v", user) } - chat := user["models"].(map[string]any)["chat"].(map[string]any) + code, sys := doJSON(t, h, "GET", "/api/settings/system", nil) + if code != 200 { + t.Fatalf("GET system: %d", code) + } + sysBlob, err := json.Marshal(sys) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{pool, modelKey} { + if strings.Contains(string(sysBlob), secret) { + t.Fatalf("GET /api/settings/system leaked %q", secret) + } + } + sysValues := sys["system"].(map[string]any) + tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any) + if tavily["apiKeys"] != settings.MaskPlaceholder { + t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"]) + } + chat := sysValues["models"].(map[string]any)["chat"].(map[string]any) if chat["apiKey"] != settings.MaskPlaceholder { - t.Fatalf("apiKey mask = %v", chat["apiKey"]) + t.Fatalf("system apiKey mask = %v", chat["apiKey"]) } } diff --git a/internal/server/server.go b/internal/server/server.go index 3b70bb6..cbce1bd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -349,6 +349,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser) mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject) + mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem) + mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem) mux.HandleFunc("GET /api/tools", s.listTools) mux.HandleFunc("GET /api/url-preview", s.urlPreview) mux.HandleFunc("POST /api/tools", s.createUserTool) @@ -1439,7 +1441,7 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.set.MergeUser(s.owner(r), values)) + s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) }) } func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { @@ -1448,7 +1450,66 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values)) + s.writeSettingsMerge(w, func() error { + return s.set.MergeProject(s.owner(r), r.PathValue("id"), values) + }) +} + +// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径, +// 其余错误按 writeOKOr 既有语义处理。 +func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) { + err := merge() + if err == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + var mp *settings.ErrManagedPath + if errors.As(err, &mp) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeStoreErr(w, err) +} + +// ---- 系统设置(管理员;P2 受管项的唯一写入面)---- + +// requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档 +//(Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 +// 嵌入式部署的配置播种,不增加安全边界。 +func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + if s.Auth == nil { + return s.owner(r), true + } + return s.requireAdmin(w, r) +} + +func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + schema := append(settings.Schema(), s.plugins.SettingsSchema()...) + sys, err := s.set.SystemScope() + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "schema": schema, + "defaults": settings.Masked(settings.SystemDefaults(), schema), + "system": settings.Masked(sys, schema), + }) +} + +func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + var values map[string]any + if err := json.NewDecoder(r.Body).Decode(&values); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeOKOr(w, s.set.MergeSystem(values)) } // ---- 工具(用户声明式 HTTP 工具注册)---- diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8c839f3..05dfd0a 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -10,12 +10,24 @@ import ( core "agent-system-core" ) -// Scope 层级键:user 与 project: 落库;conversation 经会话画像选择实现。 +// Scope 层级键:user 与 project: 落库;system 是管理员全局层(owner 固定 +// OwnerSystemRow);conversation 经会话画像选择实现。 const ( ScopeUser = "user" ScopeProjectPrefix = "project:" + ScopeSystem = "system" + // OwnerSystemRow 系统层落库行的 owner。空串不是任何账号(账号名非空), + // 租户 API 无法读写该行。 + OwnerSystemRow = "" ) +// ErrManagedPath 表示写入负载触碰系统受管设置项;user/project 层拒绝整笔写入。 +type ErrManagedPath struct{ Paths []string } + +func (e *ErrManagedPath) Error() string { + return "系统受管设置项仅管理员可配置: " + strings.Join(e.Paths, ", ") +} + // SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。 func SystemDefaults() map[string]any { return map[string]any{ @@ -77,30 +89,30 @@ func SystemDefaults() map[string]any { // Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。 func Schema() []core.SettingDesc { return []core.SettingDesc{ - {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"}, - {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, - {Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"}, - {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, - {Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"}, - {Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"}, - {Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"}, - {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"}, - {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, - {Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, - {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"}, - {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"}, + {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"}, + {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem}, + {Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"}, + {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, + {Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"}, + {Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "清理阈值里为模型输出预留的 token 数"}, + {Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "避免贴着上限才清理的余量"}, + {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "标题生成/澄清等轻任务;空则回落主模型"}, + {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem}, + {Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, + {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"}, + {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "不调用真实模型,按脚本回放事件流"}, {Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"}, {Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"}, {Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"}, - {Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"}, - {Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"}, + {Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "0 = 不限;多用户建议 300–1200"}, + {Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"}, {Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"}, {Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"}, {Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"}, @@ -112,8 +124,8 @@ func Schema() []core.SettingDesc { {Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"}, {Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"}, {Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"}, - {Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"}, - {Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Description: "OTLP resource 的 service.name"}, + {Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"}, + {Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"}, {Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun}, {Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun}, {Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"}, @@ -124,8 +136,8 @@ func Schema() []core.SettingDesc { {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot}, {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot}, {Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"}, - {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, - {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, + {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, + {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, {Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"}, {Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"}, {Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"}, @@ -139,6 +151,9 @@ func Schema() []core.SettingDesc { type Service struct { st *store.Store system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间 + // extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入); + // 受管路径计算同时覆盖内置 schema 与插件声明。 + extraSchema []core.SettingDesc } func NewService(st *store.Store) *Service { return &Service{st: st} } @@ -146,30 +161,62 @@ func NewService(st *store.Store) *Service { return &Service{st: st} } // SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。 func (s *Service) SetSystemOverride(values map[string]any) { s.system = values } -// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。 +// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。 +func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = descs } + +// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。 +func (s *Service) ManagedSettingPaths() []string { + paths := core.ManagedPaths(Schema()) + if len(s.extraSchema) > 0 { + paths = append(paths, core.ManagedPaths(s.extraSchema)...) + } + return paths +} + +// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库) +// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。 // owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。 func (s *Service) Effective(owner, projectID string) core.EffectiveSettings { eff := core.MergeSettings(SystemDefaults(), map[string]any{}) if s.system != nil { eff = core.MergeSettings(eff, s.system) } + if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil { + eff = core.MergeSettings(eff, sysdb) + } + managed := s.ManagedSettingPaths() if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil { - eff = core.MergeSettings(eff, user) + eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(user), managed)) } if projectID != "" { if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil { - eff = core.MergeSettings(eff, proj) + eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(proj), managed)) } } return eff } func (s *Service) UserScope(owner string) (map[string]any, error) { - return s.st.GetSettingsScope(owner, ScopeUser) + return s.readScopeStripped(owner, ScopeUser) } func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) { - return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID) + return s.readScopeStripped(owner, ScopeProjectPrefix+projectID) +} + +// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页 +// 回显为"当前值"(存量值保留在库中作为迁移依据,P8 处置)。 +func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error) { + m, err := s.st.GetSettingsScope(owner, scope) + if err != nil { + return nil, err + } + return core.StripManagedSettings(normalizeKeys(m), s.ManagedSettingPaths()), nil +} + +// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。 +func (s *Service) SystemScope() (map[string]any, error) { + return s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem) } func (s *Service) MergeUser(owner string, values map[string]any) error { @@ -180,10 +227,21 @@ func (s *Service) MergeProject(owner, projectID string, values map[string]any) e return s.mergeScope(owner, ScopeProjectPrefix+projectID, values) } +// MergeSystem 写管理员系统层(仅系统管理 API 调用;不做受管拦截——系统层 +// 就是受管项的权威写入面;非受管项写入形成全局默认覆盖,用户层仍可再覆盖)。 +func (s *Service) MergeSystem(values map[string]any) error { + return s.mergeScope(OwnerSystemRow, ScopeSystem, values) +} + // mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。 -// 掩码占位符视为未修改,不覆盖已存密钥。 +// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。 func (s *Service) mergeScope(owner, scope string, values map[string]any) error { incoming := prepareWrite(values) + if scope != ScopeSystem { + if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { + return &ErrManagedPath{Paths: hits} + } + } if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { return core.MergeSettings(normalizeKeys(cur), incoming), nil }); err != nil { diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 2fd2a20..8ab0654 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -1,6 +1,7 @@ package settings import ( + "errors" "path/filepath" "reflect" "strings" @@ -180,8 +181,9 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) { if eff.GetString("runtime.entry", "") != "squad" { t.Fatalf("entry = %q", eff.GetString("runtime.entry", "")) } - if eff.GetString("tools.tavily.apiKeys", "") != "nested" { - t.Fatalf("nested key lost: %q", eff.GetString("tools.tavily.apiKeys", "")) + // P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。 + if got := eff.GetString("tools.tavily.apiKeys", ""); got != "" { + t.Fatalf("managed key leaked from user scope: %q", got) } if eff.GetNumber("context.maxHistoryMessages", 0) != 30 { t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0)) @@ -204,14 +206,27 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) { if _, ok := raw["runtime.entry"]; ok { t.Fatalf("dotted key persisted: %#v", raw) } - if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); err != nil { + // P2 受管:user 层写受管路径(嵌套与点路径形式)都整笔拒绝。 + var mp *ErrManagedPath + if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); !errors.As(err, &mp) { + t.Fatalf("nested managed write not rejected: %v", err) + } + if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": "sk-real"}); !errors.As(err, &mp) { + t.Fatalf("dotted managed write not rejected: %v", err) + } + if err := svc.MergeUser("local", map[string]any{"models": "smash"}); !errors.As(err, &mp) { + t.Fatalf("ancestor scalar overwrite not rejected: %v", err) + } + // 受管项只能经系统层写入并生效。 + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-sys"}}}); err != nil { t.Fatal(err) } - if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": MaskPlaceholder}); err != nil { - t.Fatal(err) + if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-sys" { + t.Fatalf("system-layer key not effective: %q", got) } - if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-real" { - t.Fatalf("mask writeback overwrote key: %q", got) + // 读面剥离:UserScope 不回显受管项的存量值。 + if raw2, _ := svc.UserScope("local"); raw2["models"] != nil { + t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"]) } }