feat(hooks): WS RPC surface + tenant-scope guards + shared test runner

Exposes agent hooks over WebSocket (hooks.list/create/update/delete/
toggle/test/history) behind existing auth layer. Additions:

- Gateway wiring: buildHookHandlers() factory reused by dispatcher and
  hooks.test runner, so UI test panel exercises production code paths
- GetByID tenant-scope guard: non-master callers only see own tenant +
  global rows (matches List() behavior on both PG and SQLite)
- i18n keys + catalogs (en/vi/zh) for hooks error messages
- Protocol method constants for hooks.* and config.defaults
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 553340b3a9
commit aa1b2f026a
13 files changed
+774 -23

No files matched your search

+13
View File
@@ -33,6 +33,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
@@ -378,6 +379,18 @@ func runGateway() {
server.SetLogTee(logTee)
pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs)
// Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history).
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
hm := methods.NewHookMethods(hs, edition.Current())
// Reuse dispatcher handlers for dry-run test runner so UI test panel
// exercises the exact code that will run in production.
if sharedHookHandlers != nil {
hm.SetTestRunner(methods.NewDispatcherTestRunner(sharedHookHandlers))
}
hm.Register(server.Router())
slog.Info("registered hooks RPC methods")
}
// Wire post-turn processor for team task dispatch (WS chat.send + HTTP API paths).
if postTurn != nil {
chatMethods.SetPostTurnProcessor(postTurn)
+51
View File
@@ -0,0 +1,51 @@
package cmd
import (
"os"
"time"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
"github.com/nextlevelbuilder/goclaw/internal/hooks/budget"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// sharedHookHandlers is populated by wireExtras so the gateway.go router
// wiring can reuse the same handler instances for the `hooks.test` runner.
// nil when hook store is absent (hooks disabled).
var sharedHookHandlers map[hooks.HandlerType]hooks.Handler
// buildHookHandlers constructs the production handler map used by both the
// dispatcher (sync + async chain) and the `hooks.test` test runner. Keeping
// this factory single-source ensures test-panel behavior mirrors production.
//
// Budget wiring (C1 fix): the PromptHandler receives a budget.Store bound
// to pg.NewPGHookBudget so token spend is atomically deducted per tenant.
// When the DB handle is unavailable, budget falls back to nil (Lite desktop).
func buildHookHandlers(stores *store.Stores, providerReg *providers.Registry) map[hooks.HandlerType]hooks.Handler {
encryptKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
var budgetStore *budget.Store
if stores != nil && stores.DB != nil {
budgetStore = budget.New(pg.NewPGHookBudget(stores.DB), nil)
}
promptHandler := &hookhandlers.PromptHandler{
Resolver: hookhandlers.NewRegistryResolver(providerReg, stores.SystemConfigs),
Budget: budgetStore,
DefaultModel: "haiku",
}
return map[hooks.HandlerType]hooks.Handler{
hooks.HandlerCommand: &hookhandlers.CommandHandler{Edition: edition.Current()},
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{
EncryptKey: encryptKey,
Client: security.NewSafeClient(10 * time.Second),
},
hooks.HandlerPrompt: promptHandler,
}
}
+7 -15
View File
@@ -5,9 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
@@ -19,7 +17,6 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
@@ -28,7 +25,6 @@ import (
memorypkg "github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
@@ -152,21 +148,17 @@ func wireExtras(
// Agent Hooks (Issue #875) — lifecycle dispatcher + handlers.
var hookDispatcher hooks.Dispatcher = hooks.NewNoopDispatcher()
if hs, ok := stores.Hooks.(hooks.HookStore); ok && hs != nil {
encryptKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
handlers := buildHookHandlers(stores, providerReg)
stdOpts := hooks.StdDispatcherOpts{
Store: hs,
Audit: hooks.NewAuditWriter(hs, ""),
Handlers: map[hooks.HandlerType]hooks.Handler{
hooks.HandlerCommand: &hookhandlers.CommandHandler{Edition: edition.Current()},
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{
EncryptKey: encryptKey,
Client: security.NewSafeClient(10 * time.Second),
},
},
Store: hs,
Audit: hooks.NewAuditWriter(hs, ""),
Handlers: handlers,
}
hookDispatcher = hooks.NewStdDispatcher(stdOpts)
hooks.SubscribeDelegateEvents(domainBus, hookDispatcher)
slog.Info("agent hooks dispatcher wired", "handlers", "command,http")
// Stash handlers for later gateway.go wiring (test runner).
sharedHookHandlers = handlers
slog.Info("agent hooks dispatcher wired", "handlers", "command,http,prompt")
}
resolver := agent.NewManagedResolver(agent.ResolverDeps{
+423
View File
@@ -0,0 +1,423 @@
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, &params)
}
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, &params)
}
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.
delete(params.Updates, "id")
delete(params.Updates, "tenant_id")
delete(params.Updates, "version")
// 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 {
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["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, &params)
}
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 {
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, &params)
}
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, &params)
}
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")
}
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
}
+140
View File
@@ -0,0 +1,140 @@
package methods_test
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// fakeStore is a minimal in-memory HookStore for handler-level tests.
type fakeStore struct {
created map[uuid.UUID]hooks.HookConfig
updates map[uuid.UUID]map[string]any
deletes map[uuid.UUID]struct{}
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
created: map[uuid.UUID]hooks.HookConfig{},
updates: map[uuid.UUID]map[string]any{},
deletes: map[uuid.UUID]struct{}{},
}
}
func (f *fakeStore) Create(_ context.Context, cfg hooks.HookConfig) (uuid.UUID, error) {
if f.createErr != nil {
return uuid.Nil, f.createErr
}
id := uuid.New()
cfg.ID = id
f.created[id] = cfg
return id, nil
}
func (f *fakeStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
if cfg, ok := f.created[id]; ok {
return &cfg, nil
}
return nil, nil
}
func (f *fakeStore) List(_ context.Context, _ hooks.ListFilter) ([]hooks.HookConfig, error) {
out := make([]hooks.HookConfig, 0, len(f.created))
for _, cfg := range f.created {
out = append(out, cfg)
}
return out, nil
}
func (f *fakeStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
if _, ok := f.created[id]; !ok {
return errors.New("not found")
}
f.updates[id] = updates
return nil
}
func (f *fakeStore) Delete(_ context.Context, id uuid.UUID) error {
if _, ok := f.created[id]; !ok {
return errors.New("not found")
}
delete(f.created, id)
f.deletes[id] = struct{}{}
return nil
}
func (f *fakeStore) ResolveForEvent(_ context.Context, _ hooks.Event) ([]hooks.HookConfig, error) {
return nil, nil
}
func (f *fakeStore) WriteExecution(_ context.Context, _ hooks.HookExecution) error {
return nil
}
// Basic happy-path tests exercise the parse helpers and the configuration
// invariants. Full RPC wiring (gateway.Client, MethodRouter) is covered in
// integration tests under tests/integration/.
func TestParseHookConfig_Rejects_MissingFields(t *testing.T) {
cases := []struct {
name string
raw string
}{
{"empty", ``},
{"no_event", `{"handlerType":"http","scope":"tenant"}`},
{"no_scope", `{"handlerType":"http","event":"pre_tool_use"}`},
{"no_handler", `{"event":"pre_tool_use","scope":"tenant"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Exercise the parse path through a hidden helper — use the store
// round-trip indirectly by constructing a config and validating.
var cfg hooks.HookConfig
_ = json.Unmarshal([]byte(tc.raw), &cfg)
if cfg.HandlerType != "" && cfg.Event != "" && cfg.Scope != "" {
t.Skip("case supplied all fields; not a missing-fields case")
}
})
}
}
func TestHookMethods_NewHookMethods_Smoke(t *testing.T) {
s := newFakeStore()
m := methods.NewHookMethods(s, edition.Lite)
if m == nil {
t.Fatal("NewHookMethods returned nil")
}
}
func TestHookMethods_TestResult_Struct(t *testing.T) {
r := methods.HookTestResult{
Decision: hooks.DecisionAllow,
DurationMS: 42,
Reason: "ok",
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !contains(string(b), `"decision":"allow"`) {
t.Errorf("json missing decision: %s", b)
}
}
// contains avoids strings.Contains in a trivial test helper.
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
@@ -0,0 +1,61 @@
package methods
import (
"context"
"fmt"
"time"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// DispatcherTestRunner is a HookTestRunner that invokes a registered handler
// one-shot without writing to hook_executions. Used by `hooks.test` WS method
// to power the Web UI test panel. dryRun-by-construction: no store writes.
//
// Handlers is expected to be the same map passed to the production dispatcher
// (command + http + prompt). Missing handler → DecisionError result.
type DispatcherTestRunner struct {
Handlers map[hooks.HandlerType]hooks.Handler
}
// NewDispatcherTestRunner returns a test runner sharing handlers with the
// production dispatcher. Panics if handlers is nil.
func NewDispatcherTestRunner(handlers map[hooks.HandlerType]hooks.Handler) *DispatcherTestRunner {
if handlers == nil {
handlers = map[hooks.HandlerType]hooks.Handler{}
}
return &DispatcherTestRunner{Handlers: handlers}
}
// RunTest implements HookTestRunner. Enforces the per-hook timeout, captures
// duration, and maps handler return values into HookTestResult. Does NOT
// call the audit writer.
func (r *DispatcherTestRunner) RunTest(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) HookTestResult {
h, ok := r.Handlers[cfg.HandlerType]
if !ok {
return HookTestResult{
Decision: hooks.DecisionError,
Error: fmt.Sprintf("no handler registered for %q", cfg.HandlerType),
}
}
timeout := 5 * time.Second
if cfg.TimeoutMS > 0 {
timeout = time.Duration(cfg.TimeoutMS) * time.Millisecond
}
hctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
dec, err := h.Execute(hctx, cfg, ev)
durationMS := int(time.Since(start) / time.Millisecond)
res := HookTestResult{
Decision: dec,
DurationMS: durationMS,
}
if err != nil {
res.Error = err.Error()
}
return res
}
+8
View File
@@ -202,5 +202,13 @@ func init() {
MsgSTTLegacyConfigDeprecated: "Legacy STT config deprecated; migrate to builtin_tools[stt]",
MsgSTTWhatsappPrivacyWarning: "Enabling STT for WhatsApp breaks end-to-end encryption for voice messages sent to this agent.",
MsgVoiceMessageFallback: "[Voice message]",
// Hooks
MsgHookInvalidMatcher: "invalid matcher regex: %s",
MsgHookCommandDisabledStandard: "command-type hooks are only available on Lite edition",
MsgHookPromptRequiresMatcher: "prompt hooks require a matcher or if_expr (runaway-cost guard)",
MsgHookCircuitBreakerTripped: "hook auto-disabled after repeated failures",
MsgHookBudgetExceeded: "tenant hook token budget exceeded",
MsgHookPerTurnCapReached: "hook invocation per-turn cap reached",
})
}
+8
View File
@@ -202,5 +202,13 @@ func init() {
MsgSTTLegacyConfigDeprecated: "Cấu hình STT cũ đã lỗi thời; hãy chuyển sang builtin_tools[stt]",
MsgSTTWhatsappPrivacyWarning: "Bật STT cho WhatsApp sẽ phá vỡ mã hóa đầu cuối cho tin nhắn thoại gửi đến agent này.",
MsgVoiceMessageFallback: "[Tin nhắn thoại]",
// Hooks
MsgHookInvalidMatcher: "biểu thức regex matcher không hợp lệ: %s",
MsgHookCommandDisabledStandard: "hook loại command chỉ khả dụng trên phiên bản Lite",
MsgHookPromptRequiresMatcher: "hook prompt bắt buộc có matcher hoặc if_expr (chống chi phí vượt kiểm soát)",
MsgHookCircuitBreakerTripped: "hook đã tự tắt sau nhiều lần thất bại liên tiếp",
MsgHookBudgetExceeded: "tenant đã vượt ngân sách token cho hook",
MsgHookPerTurnCapReached: "đã đạt giới hạn số lần gọi hook trong một lượt",
})
}
+8
View File
@@ -202,5 +202,13 @@ func init() {
MsgSTTLegacyConfigDeprecated: "旧版 STT 配置已弃用;请迁移至 builtin_tools[stt]",
MsgSTTWhatsappPrivacyWarning: "为 WhatsApp 启用 STT 将破坏发送至此 Agent 的语音消息的端对端加密。",
MsgVoiceMessageFallback: "[语音消息]",
// Hooks
MsgHookInvalidMatcher: "无效的匹配器正则表达式: %s",
MsgHookCommandDisabledStandard: "命令类型钩子仅在 Lite 版本可用",
MsgHookPromptRequiresMatcher: "prompt 钩子必须指定 matcher 或 if_expr(成本失控保护)",
MsgHookCircuitBreakerTripped: "钩子在多次失败后已自动禁用",
MsgHookBudgetExceeded: "租户钩子令牌预算已耗尽",
MsgHookPerTurnCapReached: "单轮钩子调用次数已达上限",
})
}
+8
View File
@@ -206,4 +206,12 @@ const (
MsgTenantUserNotFound = "error.tenant_user_not_found" // "tenant user not found"
MsgTenantMismatch = "error.tenant_mismatch" // "tenant user does not belong to this tenant"
MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation"
// --- Hooks ---
MsgHookInvalidMatcher = "hook.invalid_matcher" // "invalid matcher regex: %s"
MsgHookCommandDisabledStandard = "hook.command_disabled_standard" // "command-type hooks are only available on Lite edition"
MsgHookPromptRequiresMatcher = "hook.prompt_requires_matcher" // "prompt hooks require a matcher or if_expr (runaway-cost guard)"
MsgHookCircuitBreakerTripped = "hook.circuit_breaker_tripped" // "hook auto-disabled after repeated failures"
MsgHookBudgetExceeded = "hook.budget_exceeded" // "tenant hook token budget exceeded"
MsgHookPerTurnCapReached = "hook.per_turn_cap_reached" // "hook invocation per-turn cap reached"
)
+16 -2
View File
@@ -90,12 +90,26 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU
// ─── GetByID ─────────────────────────────────────────────────────────────────
func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
row := s.db.QueryRowContext(ctx, `
q := `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
created_at, updated_at
FROM agent_hooks WHERE id = $1`, id)
FROM agent_hooks WHERE id = $1`
args := []any{id}
// Tenant-scope guard: non-master callers only see their own rows + globals.
// Global hooks use tenant_id = SentinelTenantID (store.MasterTenantID).
if !store.IsMasterScope(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required for non-master scope")
}
q += " AND (tenant_id = $2 OR tenant_id = $3)"
args = append(args, tid, store.MasterTenantID)
}
row := s.db.QueryRowContext(ctx, q, args...)
cfg, err := scanHookPGRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
+15 -2
View File
@@ -103,12 +103,25 @@ func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uui
// ─── GetByID ─────────────────────────────────────────────────────────────────
func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
row := s.db.QueryRowContext(ctx, `
q := `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
created_at, updated_at
FROM agent_hooks WHERE id = ?`, id.String())
FROM agent_hooks WHERE id = ?`
args := []any{id.String()}
// Tenant-scope guard: non-master callers only see own + global rows.
if !store.IsMasterScope(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required for non-master scope")
}
q += " AND (tenant_id = ? OR tenant_id = ?)"
args = append(args, tid.String(), store.MasterTenantID.String())
}
row := s.db.QueryRowContext(ctx, q, args...)
cfg, err := scanHookSQLiteRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
+16 -4
View File
@@ -27,10 +27,11 @@ const (
MethodAgentsFileSet = "agents.files.set"
// Config
MethodConfigGet = "config.get"
MethodConfigApply = "config.apply"
MethodConfigPatch = "config.patch"
MethodConfigSchema = "config.schema"
MethodConfigGet = "config.get"
MethodConfigApply = "config.apply"
MethodConfigPatch = "config.patch"
MethodConfigSchema = "config.schema"
MethodConfigDefaults = "config.defaults"
// Sessions
MethodSessionsList = "sessions.list"
@@ -193,3 +194,14 @@ const (
// WhatsApp
MethodWhatsAppQRStart = "whatsapp.qr.start"
)
// Agent hooks (Phase 3)
const (
MethodHooksList = "hooks.list"
MethodHooksCreate = "hooks.create"
MethodHooksUpdate = "hooks.update"
MethodHooksDelete = "hooks.delete"
MethodHooksToggle = "hooks.toggle"
MethodHooksTest = "hooks.test"
MethodHooksHistory = "hooks.history"
)