mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 22:11:49 +00:00
9115169c03
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.
238 lines
7.9 KiB
Go
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, ¶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.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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|