Files
goclaw/internal/gateway/methods/config.go
T
viettranx 9115169c03 feat: expand audit logging via pub/sub event pattern
Replace direct ActivityStore injection with event-driven audit system.
Handlers emit audit events via msgBus.Broadcast(), a single subscriber
with buffered channel persists to activity_logs table.

Coverage expanded from 3 agent CRUD actions to ~65 audit points across
all HTTP handlers and WebSocket RPC methods including agents, providers,
skills, MCP servers, cron, sessions, teams, pairing, and more.
2026-03-12 18:34:56 +07:00

238 lines
7.9 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
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}
}
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodConfigGet, m.handleGet)
router.Register(protocol.MethodConfigApply, m.handleApply)
router.Register(protocol.MethodConfigPatch, m.handlePatch)
router.Register(protocol.MethodConfigSchema, m.handleSchema)
}
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, &params)
}
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.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, &params)
}
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
}
// Merge strategy: serialize current -> deserialize patch on top -> save
currentJSON, err := json.Marshal(m.cfg)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to serialize current config")))
return
}
// Start from current config as base
merged := config.Default()
if err := json.Unmarshal(currentJSON, merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to clone config")))
return
}
// 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.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,
}))
}
// 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)",
},
"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)
}
}
}