feat: add request-scoped context policies (#2914)

* feat: add request-scoped context policies

Add named turn profiles under agents.defaults so callers can opt into
per-request context and tool policies without changing default chat behavior.

Profiles can disable history, system context, skill prompts, or tools, and can
limit skills/tools with allow lists. Wire profile selection through Pico message
payloads, agent turn execution, Web chat selection, and Web visual config.

Reject invalid turn profiles before saving config through Web APIs and document
the new request context policy behavior.

* fix: address turn profile review blockers

* feat: simplify request context policy config

* fix: suppress tool prompt when turn tools are disabled

* fix: enforce turn profile tool restrictions
This commit is contained in:
lxowalle
2026-05-22 10:06:40 +08:00
committed by GitHub
parent 5bbebb5fc8
commit 2992eccbf0
39 changed files with 3150 additions and 162 deletions
+4
View File
@@ -312,6 +312,10 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, err.Error())
}
if err := cfg.ValidateTurnProfile(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
+218 -25
View File
@@ -13,6 +13,95 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
func TestHandlePatchConfig_PreservesTurnProfile(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Agents.Defaults.TurnProfile = config.TurnProfileConfig{
Enabled: true,
History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
Tools: config.TurnProfileBlock{
Mode: config.TurnProfileModeCustom,
Allow: []string{"web_search", "web_fetch"},
},
}
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"max_tokens": 1234
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig(updated) error = %v", err)
}
profile := updated.Agents.Defaults.TurnProfile
if profile.Tools.Mode != config.TurnProfileModeCustom ||
strings.Join(profile.Tools.Allow, ",") != "web_search,web_fetch" {
t.Fatalf("profile tools = %#v, want custom web_search/web_fetch", profile.Tools)
}
}
func TestHandlePatchConfig_RejectsInvalidTurnProfile(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"turn_profile": {
"enabled": true,
"history": { "mode": "custom" }
}
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !strings.Contains(rec.Body.String(), "history.mode custom is not supported") {
t.Fatalf("body=%s, want turn profile validation error", rec.Body.String())
}
if _, err := config.LoadConfig(configPath); err != nil {
t.Fatalf("LoadConfig() after rejected patch error = %v", err)
}
}
func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
t.Helper()
@@ -35,7 +124,13 @@ func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"%s /api/config status = %d, want %d, body=%s",
method,
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
if got := logger.GetLevel(); got != want {
t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
@@ -141,10 +236,18 @@ func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) {
t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String())
t.Fatalf(
"expected validation error mentioning custom_deny_patterns, body=%s",
rec.Body.String(),
)
}
}
@@ -200,7 +303,12 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -264,7 +372,12 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -277,7 +390,10 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
picoChannel.AllowFrom[0] != "ou_a" ||
picoChannel.AllowFrom[1] != "ou_b" ||
picoChannel.AllowFrom[2] != "ou_c" {
t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
t.Fatalf(
"pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]",
picoChannel.AllowFrom,
)
}
if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
picoChannel.GroupTrigger.Prefixes[0] != "/" ||
@@ -346,7 +462,12 @@ func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -420,7 +541,11 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
req := httptest.NewRequest(
http.MethodPatch,
"/api/config",
bytes.NewBufferString(tt.body),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -439,8 +564,12 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
telegramChannel := cfg.Channels[config.ChannelTelegram]
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
if len(telegramChannel.AllowFrom) != 1 ||
telegramChannel.AllowFrom[0] != "existing-user" {
t.Fatalf(
"telegram allow_from = %#v, want unchanged [\"existing-user\"]",
telegramChannel.AllowFrom,
)
}
})
}
@@ -471,10 +600,18 @@ func TestHandlePatchConfig_RejectsNegativeStreamingDeliveryValues(t *testing.T)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !strings.Contains(rec.Body.String(), "streaming.throttle_seconds") {
t.Fatalf("response body = %q, want streaming.throttle_seconds validation error", rec.Body.String())
t.Fatalf(
"response body = %q, want streaming.throttle_seconds validation error",
rec.Body.String(),
)
}
}
@@ -520,7 +657,12 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err = config.LoadConfig(configPath)
@@ -537,7 +679,10 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if strings.Contains(string(configData), `"allow_from": [""]`) {
t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
t.Fatalf(
"config file should not contain empty-string allow_from item: %s",
string(configData),
)
}
}
@@ -575,7 +720,12 @@ func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err = config.LoadConfig(configPath)
@@ -696,7 +846,12 @@ func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PUT /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
}
@@ -719,7 +874,12 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
}
@@ -778,7 +938,12 @@ func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
if got := logger.GetLevel(); got != logger.DEBUG {
t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
@@ -808,7 +973,12 @@ func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -852,7 +1022,12 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -875,7 +1050,10 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if strings.Contains(string(rawConfig), "_auth_token") {
t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig))
t.Fatalf(
"config.json should not persist _auth_token shadow field, got:\n%s",
string(rawConfig),
)
}
}
@@ -911,7 +1089,11 @@ func testCommandPatterns(t *testing.T, configPath string, body string) *httptest
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
req := httptest.NewRequest(
http.MethodPost,
"/api/config/test-command-patterns",
bytes.NewBufferString(body),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
@@ -954,7 +1136,10 @@ func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
t.Fatalf(
"expected allowed=false when blacklist matches but not whitelist, body=%s",
rec.Body.String(),
)
}
}
@@ -1028,7 +1213,10 @@ func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
t.Fatalf(
"expected allowed=true, invalid pattern skipped and valid one matched, body=%s",
rec.Body.String(),
)
}
}
@@ -1068,7 +1256,12 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
}