mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-16 14:17:01 +00:00
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop - Updated SQLite schema v19 with final consolidated migration - Removed obsolete schema rebuild files (v21, v23) - Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go) - Updated integration tests to use new table names (hooks, hook_agents) - Updated TypeScript protocol, UI components, and i18n strings - Updated gateway methods to reflect schema changes Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
469 lines
16 KiB
Go
469 lines
16 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// HookMethods handles hooks.* RPC methods: list/create/update/delete/toggle/test/history.
|
|
// Test handler is an optional dependency; when nil, hooks.test returns a stub error.
|
|
type HookMethods struct {
|
|
store hooks.HookStore
|
|
edition edition.Edition
|
|
// TestRunner executes a HookConfig + sample Event without writing audit.
|
|
// Typically wraps the dispatcher's handlers map for dry-run semantics.
|
|
TestRunner HookTestRunner
|
|
}
|
|
|
|
// HookTestRunner runs a hook in dry-run mode. Implementations MUST NOT write
|
|
// to hook_executions; the WS layer returns the result directly to the caller.
|
|
type HookTestRunner interface {
|
|
RunTest(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) HookTestResult
|
|
}
|
|
|
|
// HookTestResult is the dry-run output surfaced to the Test panel UI.
|
|
type HookTestResult struct {
|
|
Decision hooks.Decision `json:"decision"`
|
|
Reason string `json:"reason,omitempty"`
|
|
DurationMS int `json:"durationMs"`
|
|
Stdout string `json:"stdout,omitempty"`
|
|
Stderr string `json:"stderr,omitempty"`
|
|
StatusCode int `json:"statusCode,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
UpdatedInput map[string]any `json:"updatedInput,omitempty"`
|
|
}
|
|
|
|
// NewHookMethods wires the methods with their store + edition context.
|
|
// Call SetTestRunner after construction to enable the `hooks.test` method.
|
|
func NewHookMethods(s hooks.HookStore, ed edition.Edition) *HookMethods {
|
|
return &HookMethods{store: s, edition: ed}
|
|
}
|
|
|
|
// SetTestRunner attaches a runner for the `hooks.test` dry-run method.
|
|
// When unset, hooks.test returns an informative error.
|
|
func (m *HookMethods) SetTestRunner(r HookTestRunner) {
|
|
m.TestRunner = r
|
|
}
|
|
|
|
func (m *HookMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodHooksList, m.requireViewer(m.handleList))
|
|
router.Register(protocol.MethodHooksCreate, m.requireAdmin(m.handleCreate))
|
|
router.Register(protocol.MethodHooksUpdate, m.requireAdmin(m.handleUpdate))
|
|
router.Register(protocol.MethodHooksDelete, m.requireAdmin(m.handleDelete))
|
|
router.Register(protocol.MethodHooksToggle, m.requireAdmin(m.handleToggle))
|
|
router.Register(protocol.MethodHooksTest, m.requireOperator(m.handleTest))
|
|
router.Register(protocol.MethodHooksHistory, m.requireViewer(m.handleHistory))
|
|
}
|
|
|
|
// ── RBAC middleware ────────────────────────────────────────────────────────
|
|
|
|
func (m *HookMethods) requireViewer(next gateway.MethodHandler) gateway.MethodHandler {
|
|
return m.requireMinRole(permissions.RoleViewer, next)
|
|
}
|
|
func (m *HookMethods) requireOperator(next gateway.MethodHandler) gateway.MethodHandler {
|
|
return m.requireMinRole(permissions.RoleOperator, next)
|
|
}
|
|
func (m *HookMethods) requireAdmin(next gateway.MethodHandler) gateway.MethodHandler {
|
|
return m.requireMinRole(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
func (m *HookMethods) requireMinRole(min permissions.Role, next gateway.MethodHandler) gateway.MethodHandler {
|
|
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
if !permissions.HasMinRole(client.Role(), min) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgPermissionDenied, req.Method)))
|
|
return
|
|
}
|
|
next(ctx, client, req)
|
|
}
|
|
}
|
|
|
|
// ── Handlers ───────────────────────────────────────────────────────────────
|
|
|
|
func (m *HookMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params struct {
|
|
Event string `json:"event"`
|
|
Scope string `json:"scope"`
|
|
AgentID string `json:"agentId"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if req.Params != nil {
|
|
_ = json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
filter := hooks.ListFilter{Enabled: params.Enabled}
|
|
if params.Event != "" {
|
|
ev := hooks.HookEvent(params.Event)
|
|
filter.Event = &ev
|
|
}
|
|
if params.Scope != "" {
|
|
sc := hooks.Scope(params.Scope)
|
|
filter.Scope = &sc
|
|
}
|
|
if params.AgentID != "" {
|
|
if id, err := uuid.Parse(params.AgentID); err == nil {
|
|
filter.AgentID = &id
|
|
}
|
|
}
|
|
|
|
list, err := m.store.List(ctx, filter)
|
|
if err != nil {
|
|
locale := store.LocaleFromContext(ctx)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFailedToList, "hooks")+": "+err.Error()))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"hooks": list}))
|
|
}
|
|
|
|
func (m *HookMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
cfg, err := parseHookConfigParams(req.Params)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
|
|
return
|
|
}
|
|
|
|
// Master-scope guard for global hooks.
|
|
if cfg.Scope == hooks.ScopeGlobal && !store.IsMasterScope(ctx) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgMasterScopeRequired)))
|
|
return
|
|
}
|
|
// For tenant/agent scope, fill TenantID from ctx if not provided.
|
|
if cfg.Scope != hooks.ScopeGlobal && cfg.TenantID == uuid.Nil {
|
|
cfg.TenantID = store.TenantIDFromContext(ctx)
|
|
}
|
|
if cfg.Scope == hooks.ScopeGlobal {
|
|
cfg.TenantID = hooks.SentinelTenantID
|
|
}
|
|
|
|
if err := cfg.Validate(m.edition); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
|
|
if uid := store.UserIDFromContext(ctx); uid != "" {
|
|
if parsed, err := uuid.Parse(uid); err == nil {
|
|
cfg.CreatedBy = &parsed
|
|
}
|
|
}
|
|
|
|
id, err := m.store.Create(ctx, *cfg)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFailedToCreate, "hook", err.Error())))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"hookId": id.String()}))
|
|
}
|
|
|
|
func (m *HookMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
HookID string `json:"hookId"`
|
|
Updates map[string]any `json:"updates"`
|
|
}
|
|
if req.Params != nil {
|
|
_ = json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
id, err := uuid.Parse(params.HookID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidID, "hook")))
|
|
return
|
|
}
|
|
if len(params.Updates) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidUpdates)))
|
|
return
|
|
}
|
|
// Strip protected columns before any validation so callers cannot bypass
|
|
// the scope/edition/matcher/timeout gate by sneaking them into updates.
|
|
// `source` strip closes the C2 forge: without it a tenant admin could
|
|
// PATCH {"source":"builtin"} on an existing UI row to escalate it into a
|
|
// builtin-tier hook (which the dispatcher allows to mutate event input).
|
|
// `created_by` strip prevents lying about provenance.
|
|
delete(params.Updates, "id")
|
|
delete(params.Updates, "tenant_id")
|
|
delete(params.Updates, "version")
|
|
delete(params.Updates, "source")
|
|
delete(params.Updates, "created_by")
|
|
|
|
// Re-validate the merged config: fetch current → apply patch → Validate.
|
|
// Without this, admin-role callers could bypass edition gate, timeout
|
|
// bounds, and prompt matcher/template invariants (C3 fix).
|
|
current, err := m.store.GetByID(ctx, id)
|
|
if err != nil || current == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgNotFound, "hook", params.HookID)))
|
|
return
|
|
}
|
|
merged := applyHookPatch(*current, params.Updates)
|
|
if verr := merged.Validate(m.edition); verr != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, verr.Error()))
|
|
return
|
|
}
|
|
|
|
if err := m.store.Update(ctx, id, params.Updates); err != nil {
|
|
if errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgHookBuiltinReadOnly)))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFailedToUpdate, "hook", err.Error())))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"hookId": id.String()}))
|
|
}
|
|
|
|
// applyHookPatch produces the post-update config by overlaying the DB-column
|
|
// patch values onto cur. Only columns we actually expose via the form are
|
|
// recognized; unknown keys are tolerated (store layer will reject invalid
|
|
// columns). Used pre-validation so the Validate() contract runs on the
|
|
// full, merged config exactly as it will exist after Update commits.
|
|
func applyHookPatch(cur hooks.HookConfig, p map[string]any) hooks.HookConfig {
|
|
if v, ok := p["name"].(string); ok {
|
|
cur.Name = v
|
|
}
|
|
if v, ok := p["agent_ids"]; ok {
|
|
if arr, ok := v.([]any); ok {
|
|
var ids []uuid.UUID
|
|
for _, item := range arr {
|
|
if s, ok := item.(string); ok {
|
|
if id, err := uuid.Parse(s); err == nil {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
}
|
|
cur.AgentIDs = ids
|
|
}
|
|
}
|
|
if v, ok := p["event"].(string); ok && v != "" {
|
|
cur.Event = hooks.HookEvent(v)
|
|
}
|
|
if v, ok := p["scope"].(string); ok && v != "" {
|
|
cur.Scope = hooks.Scope(v)
|
|
}
|
|
if v, ok := p["handler_type"].(string); ok && v != "" {
|
|
cur.HandlerType = hooks.HandlerType(v)
|
|
}
|
|
if v, ok := p["matcher"].(string); ok {
|
|
cur.Matcher = v
|
|
}
|
|
if v, ok := p["if_expr"].(string); ok {
|
|
cur.IfExpr = v
|
|
}
|
|
if v, ok := p["timeout_ms"].(float64); ok {
|
|
cur.TimeoutMS = int(v)
|
|
}
|
|
if v, ok := p["timeout_ms"].(int); ok {
|
|
cur.TimeoutMS = v
|
|
}
|
|
if v, ok := p["on_timeout"].(string); ok && v != "" {
|
|
cur.OnTimeout = hooks.Decision(v)
|
|
}
|
|
if v, ok := p["priority"].(float64); ok {
|
|
cur.Priority = int(v)
|
|
}
|
|
if v, ok := p["priority"].(int); ok {
|
|
cur.Priority = v
|
|
}
|
|
if v, ok := p["enabled"].(bool); ok {
|
|
cur.Enabled = v
|
|
}
|
|
if v, ok := p["config"].(map[string]any); ok {
|
|
cur.Config = v
|
|
}
|
|
if v, ok := p["metadata"].(map[string]any); ok {
|
|
cur.Metadata = v
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func (m *HookMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
HookID string `json:"hookId"`
|
|
}
|
|
if req.Params != nil {
|
|
_ = json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
id, err := uuid.Parse(params.HookID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidID, "hook")))
|
|
return
|
|
}
|
|
if err := m.store.Delete(ctx, id); err != nil {
|
|
if errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgHookBuiltinReadOnly)))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFailedToDelete, "hook", err.Error())))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"hookId": id.String()}))
|
|
}
|
|
|
|
func (m *HookMethods) handleToggle(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
HookID string `json:"hookId"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if req.Params != nil {
|
|
_ = json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
id, err := uuid.Parse(params.HookID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidID, "hook")))
|
|
return
|
|
}
|
|
if err := m.store.Update(ctx, id, map[string]any{"enabled": params.Enabled}); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFailedToUpdate, "hook", err.Error())))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"hookId": id.String(),
|
|
"enabled": params.Enabled,
|
|
}))
|
|
}
|
|
|
|
func (m *HookMethods) handleTest(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if m.TestRunner == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidRequest, "test runner not configured")))
|
|
return
|
|
}
|
|
var params struct {
|
|
Config json.RawMessage `json:"config"`
|
|
SampleEvent json.RawMessage `json:"sampleEvent"`
|
|
}
|
|
if req.Params != nil {
|
|
_ = json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
cfg, err := parseHookConfigParams(params.Config)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
|
|
return
|
|
}
|
|
if cfg.Scope != hooks.ScopeGlobal && cfg.TenantID == uuid.Nil {
|
|
cfg.TenantID = store.TenantIDFromContext(ctx)
|
|
}
|
|
if cfg.Scope == hooks.ScopeGlobal {
|
|
cfg.TenantID = hooks.SentinelTenantID
|
|
}
|
|
if err := cfg.Validate(m.edition); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
|
|
ev, err := parseTestEventParams(params.SampleEvent, cfg)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
|
|
return
|
|
}
|
|
|
|
result := m.TestRunner.RunTest(ctx, *cfg, ev)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"result": result}))
|
|
}
|
|
|
|
func (m *HookMethods) handleHistory(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
// Phase 3 MVP stub — history reads are backed by hook_executions. Since
|
|
// HookStore doesn't yet expose a paginated read API, we return an empty
|
|
// list and a note so the UI can render a "not yet available" state while
|
|
// Phase 4 wires the paginated reader.
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"executions": []any{},
|
|
"nextCursor": "",
|
|
"note": "history pagination lands in phase 4",
|
|
}))
|
|
_ = ctx // reserved for future reader
|
|
}
|
|
|
|
// ── param helpers ─────────────────────────────────────────────────────────
|
|
|
|
func parseHookConfigParams(raw json.RawMessage) (*hooks.HookConfig, error) {
|
|
if len(raw) == 0 {
|
|
return nil, errors.New("missing config payload")
|
|
}
|
|
var cfg hooks.HookConfig
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
if cfg.Metadata == nil {
|
|
cfg.Metadata = map[string]any{}
|
|
}
|
|
if cfg.Config == nil {
|
|
cfg.Config = map[string]any{}
|
|
}
|
|
if cfg.HandlerType == "" || cfg.Event == "" || cfg.Scope == "" {
|
|
return nil, errors.New("handler_type, event, and scope are required")
|
|
}
|
|
// Strip caller-controlled identity / provenance fields. The dispatcher's
|
|
// source-tier gate trusts `Source == "builtin"` to permit input mutation;
|
|
// allowing a tenant admin to forge that here would let any script bypass
|
|
// the readonly capability tier and rewrite event input. ID stripping keeps
|
|
// the H9 cfg.ID-honor path (Phase 03) restricted to internal seeders only.
|
|
cfg.Source = ""
|
|
cfg.ID = uuid.Nil
|
|
cfg.CreatedBy = nil
|
|
cfg.Version = 0
|
|
// Backward compat: singular agent_id → single-element AgentIDs.
|
|
if len(cfg.AgentIDs) == 0 && cfg.AgentID != nil && *cfg.AgentID != uuid.Nil {
|
|
cfg.AgentIDs = []uuid.UUID{*cfg.AgentID}
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func parseTestEventParams(raw json.RawMessage, cfg *hooks.HookConfig) (hooks.Event, error) {
|
|
ev := hooks.Event{
|
|
EventID: fmt.Sprintf("test-%d", time.Now().UnixNano()),
|
|
TenantID: cfg.TenantID,
|
|
HookEvent: cfg.Event,
|
|
}
|
|
if cfg.AgentID != nil {
|
|
ev.AgentID = *cfg.AgentID
|
|
}
|
|
if len(raw) == 0 {
|
|
return ev, nil
|
|
}
|
|
var sample struct {
|
|
ToolName string `json:"toolName"`
|
|
ToolInput map[string]any `json:"toolInput"`
|
|
RawInput string `json:"rawInput"`
|
|
}
|
|
if err := json.Unmarshal(raw, &sample); err != nil {
|
|
return ev, fmt.Errorf("invalid sampleEvent: %w", err)
|
|
}
|
|
ev.ToolName = sample.ToolName
|
|
ev.ToolInput = sample.ToolInput
|
|
ev.RawInput = sample.RawInput
|
|
return ev, nil
|
|
}
|