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{