mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
667fc85d54
add new field type to Channel struct config.channels refactor to channel_list update config version to 3 update the docs
544 lines
16 KiB
Go
544 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
|
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/config", h.handleGetConfig)
|
|
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
|
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
|
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
|
}
|
|
|
|
func (h *Handler) applyRuntimeLogLevel() {
|
|
if h.debug {
|
|
logger.SetLevel(logger.DEBUG)
|
|
return
|
|
}
|
|
logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
|
|
}
|
|
|
|
// handleGetConfig returns the complete system configuration.
|
|
//
|
|
// GET /api/config
|
|
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(cfg); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleUpdateConfig updates the complete system configuration.
|
|
//
|
|
// PUT /api/config
|
|
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
var cfg config.Config
|
|
if err = json.Unmarshal(body, &cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
var raw map[string]any
|
|
if err = json.Unmarshal(body, &raw); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if execAllowRemoteOmitted(body) {
|
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
|
}
|
|
|
|
// Load existing config and copy security credentials before validation,
|
|
// so that security-managed fields (e.g. pico token) are available.
|
|
err = cfg.SecurityCopyFrom(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
applyConfigSecretsFromMap(&cfg, raw)
|
|
|
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "validation_error",
|
|
"errors": errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Refresh cached pico token in case user changed it.
|
|
refreshPicoToken(&cfg)
|
|
h.applyRuntimeLogLevel()
|
|
logger.Infof("configuration updated successfully")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func execAllowRemoteOmitted(body []byte) bool {
|
|
var raw struct {
|
|
Tools *struct {
|
|
Exec *struct {
|
|
AllowRemote *bool `json:"allow_remote"`
|
|
} `json:"exec"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return false
|
|
}
|
|
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
|
|
}
|
|
|
|
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
|
|
// Only the fields present in the request body will be updated; all other fields remain unchanged.
|
|
//
|
|
// PATCH /api/config
|
|
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
// Validate the patch is valid JSON
|
|
var patch map[string]any
|
|
if err = json.Unmarshal(patchBody, &patch); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Load existing config and marshal to a map for merging
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
existing, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var base map[string]any
|
|
if err = json.Unmarshal(existing, &base); err != nil {
|
|
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Recursively merge patch into base
|
|
mergeMap(base, patch)
|
|
|
|
// Convert merged map back to Config struct
|
|
merged, err := json.Marshal(base)
|
|
if err != nil {
|
|
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var newCfg config.Config
|
|
if err = json.Unmarshal(merged, &newCfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
|
// because private fields are lost during JSON round-trip.
|
|
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
applyConfigSecretsFromMap(&newCfg, base)
|
|
|
|
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "validation_error",
|
|
"errors": errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Refresh cached pico token in case user changed it.
|
|
refreshPicoToken(&newCfg)
|
|
h.applyRuntimeLogLevel()
|
|
logger.Infof("configuration updated successfully")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
|
|
//
|
|
// POST /api/config/test-command-patterns
|
|
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
var req struct {
|
|
AllowPatterns []string `json:"allow_patterns"`
|
|
DenyPatterns []string `json:"deny_patterns"`
|
|
Command string `json:"command"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(req.Command))
|
|
|
|
type result struct {
|
|
Allowed bool `json:"allowed"`
|
|
Blocked bool `json:"blocked"`
|
|
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
|
|
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
|
|
}
|
|
|
|
resp := result{Allowed: false, Blocked: false}
|
|
|
|
// Check whitelist first
|
|
for _, pattern := range req.AllowPatterns {
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
continue // skip invalid patterns
|
|
}
|
|
if re.MatchString(lower) {
|
|
resp.Allowed = true
|
|
resp.MatchedWhitelist = &pattern
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check blacklist
|
|
for _, pattern := range req.DenyPatterns {
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if re.MatchString(lower) {
|
|
resp.Blocked = true
|
|
resp.MatchedBlacklist = &pattern
|
|
break
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// validateConfig checks the config for common errors before saving.
|
|
// Returns a list of human-readable error strings; empty means valid.
|
|
func validateConfig(cfg *config.Config) []string {
|
|
var errs []string
|
|
|
|
// Validate model_list entries
|
|
if err := cfg.ValidateModelList(); 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))
|
|
}
|
|
|
|
// Pico channel: token required when enabled
|
|
{
|
|
bc := cfg.Channels.GetByType(config.ChannelPico)
|
|
if bc != nil && bc.Enabled {
|
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
|
if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" {
|
|
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Telegram: token required when enabled
|
|
{
|
|
bc := cfg.Channels.GetByType(config.ChannelTelegram)
|
|
if bc != nil && bc.Enabled {
|
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
|
if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" {
|
|
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Discord: token required when enabled
|
|
{
|
|
bc := cfg.Channels.GetByType(config.ChannelDiscord)
|
|
if bc != nil && bc.Enabled {
|
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
|
if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" {
|
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
bc := cfg.Channels.GetByType(config.ChannelWeCom)
|
|
if bc != nil && bc.Enabled {
|
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
|
if c, ok := decoded.(*config.WeComSettings); ok {
|
|
if c.BotID == "" {
|
|
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
|
|
}
|
|
if c.Secret.String() == "" {
|
|
errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if cfg.Tools.Exec.Enabled {
|
|
if cfg.Tools.Exec.EnableDenyPatterns {
|
|
errs = append(
|
|
errs,
|
|
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
|
|
}
|
|
errs = append(
|
|
errs,
|
|
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func validateRegexPatterns(field string, patterns []string) []string {
|
|
var errs []string
|
|
for index, pattern := range patterns {
|
|
if _, err := regexp.Compile(pattern); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
|
|
// - If a key in src has a null value, it is deleted from dst.
|
|
// - If both dst and src have a nested object for the same key, merge recursively.
|
|
// - Otherwise the value from src overwrites dst.
|
|
func mergeMap(dst, src map[string]any) {
|
|
for key, srcVal := range src {
|
|
if srcVal == nil {
|
|
delete(dst, key)
|
|
continue
|
|
}
|
|
srcMap, srcIsMap := srcVal.(map[string]any)
|
|
dstMap, dstIsMap := dst[key].(map[string]any)
|
|
if srcIsMap && dstIsMap {
|
|
mergeMap(dstMap, srcMap)
|
|
} else {
|
|
dst[key] = srcVal
|
|
}
|
|
}
|
|
}
|
|
|
|
func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
|
raw, exists := value[key]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
m, isMap := raw.(map[string]any)
|
|
return m, isMap
|
|
}
|
|
|
|
func getSecretString(m map[string]any, key string) (string, bool) {
|
|
if raw, exists := m[key]; exists {
|
|
s, isString := raw.(string)
|
|
if isString {
|
|
return s, true
|
|
}
|
|
}
|
|
if raw, exists := m["_"+key]; exists {
|
|
s, isString := raw.(string)
|
|
if isString {
|
|
return s, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
|
|
channelsMap, hasChannels := asMapField(raw, "channel_list")
|
|
if !hasChannels {
|
|
return
|
|
}
|
|
|
|
for chName, chData := range channelsMap {
|
|
chMap, ok := chData.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
bc := cfg.Channels.Get(chName)
|
|
if bc == nil {
|
|
continue
|
|
}
|
|
decoded, err := bc.GetDecoded()
|
|
if err != nil || decoded == nil {
|
|
continue
|
|
}
|
|
rv := reflect.ValueOf(decoded)
|
|
if rv.Kind() == reflect.Ptr {
|
|
rv = rv.Elem()
|
|
}
|
|
if rv.Kind() != reflect.Struct {
|
|
continue
|
|
}
|
|
// Channel-specific settings live under the "settings" key in the raw map
|
|
settingsMap := chMap
|
|
if sm, hasSettings := asMapField(chMap, "settings"); hasSettings {
|
|
settingsMap = sm
|
|
}
|
|
applySecureStringsToStruct(rv, settingsMap)
|
|
}
|
|
|
|
// Handle tools secrets
|
|
tools, hasTools := asMapField(raw, "tools")
|
|
if hasTools {
|
|
skills, hasSkills := asMapField(tools, "skills")
|
|
if hasSkills {
|
|
if github, hasGithub := asMapField(skills, "github"); hasGithub {
|
|
if token, hasToken := getSecretString(github, "token"); hasToken {
|
|
cfg.Tools.Skills.Github.Token.Set(token)
|
|
}
|
|
}
|
|
registries, hasRegistries := asMapField(skills, "registries")
|
|
if hasRegistries {
|
|
if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub {
|
|
if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken {
|
|
cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// applySecureStringsToStruct walks a struct and applies SecureString fields
|
|
// from the matching keys in rawMap. It recurses into nested maps and slices.
|
|
func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) {
|
|
rt := rv.Type()
|
|
for jsonKey, rawVal := range rawMap {
|
|
for i := range rt.NumField() {
|
|
f := rt.Field(i)
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
tag := f.Tag.Get("json")
|
|
name := strings.Split(tag, ",")[0]
|
|
if name != jsonKey {
|
|
continue
|
|
}
|
|
sf := rv.Field(i)
|
|
if !sf.CanSet() {
|
|
continue
|
|
}
|
|
// Direct SecureString field
|
|
if s, ok := rawVal.(string); ok {
|
|
if f.Type == reflect.TypeOf(config.SecureString{}) {
|
|
sf.Set(reflect.ValueOf(*config.NewSecureString(s)))
|
|
} else if f.Type == reflect.TypeOf(&config.SecureString{}) {
|
|
sf.Set(reflect.ValueOf(config.NewSecureString(s)))
|
|
}
|
|
continue
|
|
}
|
|
// Recurse into nested struct
|
|
if sf.Kind() == reflect.Struct {
|
|
if nested, ok := rawVal.(map[string]any); ok {
|
|
applySecureStringsToStruct(sf, nested)
|
|
}
|
|
continue
|
|
}
|
|
// Recurse into map fields (e.g., map[string]SomeStruct)
|
|
if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct {
|
|
if nestedMap, ok := rawVal.(map[string]any); ok {
|
|
for mapKey, mapVal := range nestedMap {
|
|
nested, ok := mapVal.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
elemType := sf.Type().Elem()
|
|
// Get existing element or create a new zero value
|
|
var elem reflect.Value
|
|
existing := sf.MapIndex(reflect.ValueOf(mapKey))
|
|
if existing.IsValid() {
|
|
if existing.Kind() == reflect.Interface {
|
|
existing = existing.Elem()
|
|
}
|
|
if existing.Kind() == reflect.Ptr && !existing.IsNil() {
|
|
elem = reflect.New(elemType)
|
|
elem.Elem().Set(existing.Elem())
|
|
} else if existing.Kind() == reflect.Struct {
|
|
elem = reflect.New(elemType)
|
|
elem.Elem().Set(existing)
|
|
}
|
|
}
|
|
if !elem.IsValid() {
|
|
elem = reflect.New(elemType)
|
|
}
|
|
applySecureStringsToStruct(elem.Elem(), nested)
|
|
sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem())
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// Recurse into slice elements that are structs
|
|
if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct {
|
|
if sliceRaw, ok := rawVal.([]any); ok {
|
|
for idx, elemRaw := range sliceRaw {
|
|
if nested, ok := elemRaw.(map[string]any); ok {
|
|
if idx < sf.Len() {
|
|
applySecureStringsToStruct(sf.Index(idx), nested)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|