mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user