mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-05 06:16:54 +00:00
Squash merge PR #112 after resolving the project changelog conflict with current dev. Local checks passed: config gateway tests, provider/http/tools deny-pattern tests, go build ./..., and go build -tags sqliteonly ./.... PR CI run 26704832350 passed release-versioning, go, and web.
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
|
|
"github.com/titanous/json5"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// ConfigMethods handles config.get, config.apply, config.patch, config.schema.
|
|
// Matching TS src/gateway/server-methods/config.ts.
|
|
type ConfigMethods struct {
|
|
cfg *config.Config
|
|
cfgPath string
|
|
secretsStore store.ConfigSecretsStore
|
|
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
|
|
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
|
|
}
|
|
|
|
func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
|
|
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, secretsStore: secretsStore, eventBus: eventBus}
|
|
}
|
|
|
|
// SetSystemConfigSync sets a callback to sync config to system_configs after save.
|
|
// The callback receives the final resolved config (with secrets + env applied).
|
|
func (m *ConfigMethods) SetSystemConfigSync(fn func(ctx context.Context, cfg *config.Config)) {
|
|
m.syncFn = fn
|
|
}
|
|
|
|
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodConfigGet, m.requireMasterScope(m.requireOwner(m.handleGet)))
|
|
router.Register(protocol.MethodConfigApply, m.requireMasterScope(m.requireOwner(m.handleApply)))
|
|
router.Register(protocol.MethodConfigPatch, m.requireMasterScope(m.requireOwner(m.handlePatch)))
|
|
router.Register(protocol.MethodConfigSchema, m.requireMasterScope(m.requireOwner(m.handleSchema)))
|
|
// config.defaults is read-only + secret-free (Go consts + agents.defaults overlay),
|
|
// so it only needs requireMasterScope — owner gating would spam auth errors for
|
|
// operators viewing agent detail pages.
|
|
router.Register(protocol.MethodConfigDefaults, m.requireMasterScope(m.handleDefaults))
|
|
}
|
|
|
|
// requireOwner wraps a handler to only allow owner-role users.
|
|
func (m *ConfigMethods) requireOwner(next gateway.MethodHandler) gateway.MethodHandler {
|
|
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
if !client.IsOwner() {
|
|
locale := store.LocaleFromContext(ctx)
|
|
client.SendResponse(protocol.NewErrorResponse(
|
|
req.ID, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
|
|
))
|
|
return
|
|
}
|
|
next(ctx, client, req)
|
|
}
|
|
}
|
|
|
|
// requireMasterScope rejects config.* calls when the caller's ctx is scoped to
|
|
// a non-master tenant. System owner callers (bypass-all) are allowed through.
|
|
//
|
|
// Background: config.* mutates the master in-memory *config.Config and the
|
|
// on-disk config.json. A non-master tenant admin calling config.patch would
|
|
// corrupt master state + leak master config to other tenants. This guard keeps
|
|
// config.* strictly master-scoped until a tenant-aware refactor lands.
|
|
//
|
|
// Shares the predicate with store.IsMasterScope so HTTP and WS layers can't
|
|
// drift — same rule, one source of truth.
|
|
func (m *ConfigMethods) requireMasterScope(next gateway.MethodHandler) gateway.MethodHandler {
|
|
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
if !store.IsMasterScope(ctx) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
client.SendResponse(protocol.NewErrorResponse(
|
|
req.ID,
|
|
protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgConfigMasterScopeOnly),
|
|
))
|
|
return
|
|
}
|
|
next(ctx, client, req)
|
|
}
|
|
}
|
|
|
|
func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"config": m.cfg.MaskedCopy(),
|
|
"hash": m.cfg.Hash(),
|
|
"path": m.cfgPath,
|
|
}))
|
|
}
|
|
|
|
// handleApply replaces the entire config with the provided JSON5 raw content.
|
|
// Matching TS config.apply (src/gateway/server-methods/config.ts:435-486).
|
|
func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Raw string `json:"raw"`
|
|
BaseHash string `json:"baseHash"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Raw == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawConfigRequired)))
|
|
return
|
|
}
|
|
|
|
// Optimistic concurrency: validate hash if provided
|
|
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
|
|
return
|
|
}
|
|
|
|
// Parse the new config
|
|
newCfg := config.Default()
|
|
if err := json5.Unmarshal([]byte(params.Raw), newCfg); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
|
|
return
|
|
}
|
|
|
|
// Extract secrets → save to config_secrets table, strip all from file
|
|
m.saveSecretsToStore(ctx, newCfg)
|
|
newCfg.StripSecrets()
|
|
|
|
// Save to disk
|
|
if err := config.Save(m.cfgPath, newCfg); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
|
|
return
|
|
}
|
|
|
|
// Update in-memory config and restore secrets
|
|
m.cfg.ReplaceFrom(newCfg)
|
|
if m.secretsStore != nil {
|
|
if secrets, err := m.secretsStore.GetAll(ctx); err == nil {
|
|
m.cfg.ApplyDBSecrets(secrets)
|
|
}
|
|
}
|
|
m.cfg.ApplyEnvOverrides()
|
|
m.syncToSystemConfigs(ctx)
|
|
m.broadcastChanged()
|
|
emitAudit(m.eventBus, client, "config.applied", "config", "gateway")
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
"path": m.cfgPath,
|
|
"config": m.cfg.MaskedCopy(),
|
|
"hash": m.cfg.Hash(),
|
|
"restart": false,
|
|
}))
|
|
}
|
|
|
|
// handlePatch merges a partial config update into the current config.
|
|
// Matching TS config.patch (src/gateway/server-methods/config.ts:321-434).
|
|
func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Raw string `json:"raw"`
|
|
BaseHash string `json:"baseHash"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Raw == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawPatchRequired)))
|
|
return
|
|
}
|
|
|
|
// Optimistic concurrency
|
|
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
|
|
return
|
|
}
|
|
|
|
// Start from a locked current-config snapshot as base.
|
|
merged := m.cfg.Clone()
|
|
|
|
// Apply patch on top
|
|
if err := json5.Unmarshal([]byte(params.Raw), merged); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
|
|
return
|
|
}
|
|
|
|
// Extract secrets → save to config_secrets table, strip all from file
|
|
m.saveSecretsToStore(ctx, merged)
|
|
merged.StripSecrets()
|
|
|
|
// Save to disk
|
|
if err := config.Save(m.cfgPath, merged); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
|
|
return
|
|
}
|
|
|
|
// Update in-memory config and restore secrets
|
|
m.cfg.ReplaceFrom(merged)
|
|
if m.secretsStore != nil {
|
|
if secrets, err := m.secretsStore.GetAll(ctx); err == nil {
|
|
m.cfg.ApplyDBSecrets(secrets)
|
|
}
|
|
}
|
|
m.cfg.ApplyEnvOverrides()
|
|
m.syncToSystemConfigs(ctx)
|
|
m.broadcastChanged()
|
|
emitAudit(m.eventBus, client, "config.patched", "config", "gateway")
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"ok": true,
|
|
"path": m.cfgPath,
|
|
"config": m.cfg.MaskedCopy(),
|
|
"hash": m.cfg.Hash(),
|
|
"restart": false,
|
|
}))
|
|
}
|
|
|
|
// syncToSystemConfigs syncs the resolved config to system_configs table for the given tenant.
|
|
func (m *ConfigMethods) syncToSystemConfigs(ctx context.Context) {
|
|
if m.syncFn != nil {
|
|
m.syncFn(ctx, m.cfg)
|
|
}
|
|
}
|
|
|
|
// broadcastChanged notifies subscribers that config has been updated.
|
|
func (m *ConfigMethods) broadcastChanged() {
|
|
if m.eventBus != nil {
|
|
m.eventBus.Broadcast(bus.Event{Name: bus.TopicConfigChanged, Payload: m.cfg})
|
|
}
|
|
}
|
|
|
|
// handleSchema returns the config JSON schema for UI form generation.
|
|
// Matching TS config.schema (src/gateway/server-methods/config.ts:276-289).
|
|
func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"agents": map[string]any{
|
|
"type": "object",
|
|
"description": "Agent configuration (defaults + per-agent overrides)",
|
|
},
|
|
"channels": map[string]any{
|
|
"type": "object",
|
|
"description": "Channel configuration (telegram, discord, slack, etc.)",
|
|
},
|
|
"providers": map[string]any{
|
|
"type": "object",
|
|
"description": "AI provider API keys and settings",
|
|
},
|
|
"gateway": map[string]any{
|
|
"type": "object",
|
|
"description": "Gateway server settings (host, port, token)",
|
|
},
|
|
"tools": map[string]any{
|
|
"type": "object",
|
|
"description": "Tool configuration (browser, exec, web search)",
|
|
},
|
|
"skills": map[string]any{
|
|
"type": "object",
|
|
"description": "Skill storage and upload settings",
|
|
"properties": map[string]any{
|
|
"max_upload_size_mb": map[string]any{
|
|
"type": "integer",
|
|
"minimum": config.MinSkillMaxUploadSizeMB,
|
|
"maximum": config.MaxSkillMaxUploadSizeMB,
|
|
"default": config.DefaultSkillMaxUploadSizeMB,
|
|
"description": "Maximum skill ZIP upload size in MB",
|
|
},
|
|
"slash_commands": map[string]any{
|
|
"type": "object",
|
|
"description": "Explicit slash command skill activation settings",
|
|
"properties": map[string]any{
|
|
"enabled": map[string]any{
|
|
"type": "boolean",
|
|
"default": true,
|
|
"description": "Enable slash command detection in user prompts",
|
|
},
|
|
"suggest_not_found": map[string]any{
|
|
"type": "boolean",
|
|
"default": true,
|
|
"description": "Suggest similar skills when a requested skill is not found",
|
|
},
|
|
"partial_matching": map[string]any{
|
|
"type": "boolean",
|
|
"default": false,
|
|
"description": "Allow unique skill slug/name prefixes",
|
|
},
|
|
"prefix": map[string]any{
|
|
"type": "string",
|
|
"default": config.DefaultSkillSlashCommandPrefix,
|
|
"description": "Single-character slash command prefix",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"sessions": map[string]any{
|
|
"type": "object",
|
|
"description": "Session storage configuration",
|
|
},
|
|
},
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"json": schema,
|
|
}))
|
|
}
|
|
|
|
// saveSecretsToStore extracts non-LLM/non-channel secrets from the config
|
|
// and persists them to the config_secrets table.
|
|
func (m *ConfigMethods) saveSecretsToStore(ctx context.Context, cfg *config.Config) {
|
|
if m.secretsStore == nil {
|
|
return
|
|
}
|
|
|
|
secrets := cfg.ExtractDBSecrets()
|
|
for key, value := range secrets {
|
|
if err := m.secretsStore.Set(ctx, key, value); err != nil {
|
|
slog.Warn("failed to save config secret", "key", key, "error", err)
|
|
}
|
|
}
|
|
}
|