refactor: merge file writers into config permissions + agent hard delete

Migration 000023:
- Migrate group_file_writers data into agent_config_permissions (config_type='file_writer')
- Add FK CASCADE/SET NULL on 16 tables for agent hard delete
- Clean up soft-deleted zombie agents
- ALTER scope VARCHAR(100) → VARCHAR(255)
- Partial unique index on agent_key (allows reuse after delete)

Store layer:
- Remove GroupWriterCache, GroupFileWriterData, 5 AgentStore methods
- Add CheckFileWriterPermission (fail-open), ListFileWriters (cached)
- Add scope filter to ConfigPermissionStore.List()

Tools: Replace GroupWriterCache with ConfigPermissionStore in read_file,
write_file, edit, cron, context_file_interceptor

Agent loop: buildGroupWriterPrompt uses ListFileWriters + metadata parsing

Channels: Telegram commands rewritten to use configPermStore.Grant/Revoke/List
HTTP API: Writer endpoints rewired to configPermStore (same response shapes)

Hard delete: agents.go DELETE FROM instead of soft delete
This commit is contained in:
viettranx committed 2026-03-19 13:35:57 +07:00
1 parent 68a4ee39b4
commit 6595c0185a
35 files changed
+519 -436

No files matched your search

+1 -1
View File
@@ -436,7 +436,7 @@ func runGateway() {
instanceLoader = channels.NewInstanceLoader(pgStores.ChannelInstances, pgStores.Agents, channelMgr, msgBus, pgStores.Pairing)
instanceLoader.SetProviderRegistry(providerRegistry)
instanceLoader.SetPendingCompactionConfig(cfg.Channels.PendingCompaction)
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStores(pgStores.Agents, pgStores.Teams, pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStores(pgStores.Agents, pgStores.ConfigPermissions, pgStores.Teams, pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory)
+13 -10
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"log/slog"
"strings"
@@ -9,15 +10,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// buildEnsureUserFiles creates the per-user file seeding callback.
// Seeds per-user context files on first chat (new user profile).
func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.EnsureUserFilesFunc {
func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserFilesFunc {
return func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (string, error) {
isNew, effectiveWs, err := as.GetOrCreateUserProfile(ctx, agentID, userID, workspace, channel)
if err != nil {
@@ -28,7 +27,7 @@ func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.Ens
}
// Auto-add first group member as a file writer (bootstrap the allowlist).
if strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:") {
if configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
senderID := store.SenderIDFromContext(ctx)
if senderID != "" {
parts := strings.SplitN(senderID, "|", 2)
@@ -37,14 +36,18 @@ func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.Ens
if len(parts) > 1 {
senderUsername = parts[1]
}
if addErr := as.AddGroupFileWriter(ctx, agentID, userID, numericID, "", senderUsername); addErr != nil {
meta, _ := json.Marshal(map[string]string{"displayName": "", "username": senderUsername})
if addErr := configPermStore.Grant(ctx, &store.ConfigPermission{
AgentID: agentID,
Scope: userID,
ConfigType: "file_writer",
UserID: numericID,
Permission: "allow",
Metadata: meta,
}); addErr != nil {
slog.Warn("failed to auto-add group file writer", "error", addErr, "sender", numericID, "group", userID)
} else if msgBus != nil {
msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: userID},
})
}
// No bus broadcast needed — Grant already invalidates cache
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ import (
// registerConfigChannels registers config-based channels as fallback when no DB instances are loaded.
func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus, pgStores *store.Stores, instanceLoader *channels.InstanceLoader) {
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil {
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil, nil)
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil, nil, nil)
if err != nil {
slog.Error("failed to initialize telegram channel", "error", err)
} else {
+1 -1
View File
@@ -53,7 +53,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
}
if stores != nil && stores.ChannelInstances != nil {
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.Contacts, token, msgBus)
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus)
}
if stores != nil && stores.Providers != nil {
+8 -32
View File
@@ -49,7 +49,7 @@ func wireExtras(
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
// 1. Build cache instances (in-memory or Redis depending on build tags)
agentCtxCache, userCtxCache, gwCache := makeCaches(redisClient)
agentCtxCache, userCtxCache := makeCaches(redisClient)
// 1a. Context file interceptor (created before resolver so callbacks can reference it)
var contextFileInterceptor *tools.ContextFileInterceptor
@@ -57,12 +57,6 @@ func wireExtras(
contextFileInterceptor = tools.NewContextFileInterceptor(stores.Agents, workspace, agentCtxCache, userCtxCache)
}
// 1b. Group writer cache (wraps ListGroupFileWriters with TTL cache)
var groupWriterCache *store.GroupWriterCache
if stores.Agents != nil {
groupWriterCache = store.NewGroupWriterCache(stores.Agents, gwCache)
}
// 1c. Persistent media storage for cross-turn image/document access
mediaStore, err := media.NewStore(filepath.Join(workspace, ".media"))
if err != nil {
@@ -96,7 +90,7 @@ func wireExtras(
// 2. User seeding callback: seeds per-user context files on first chat
var ensureUserFiles agent.EnsureUserFilesFunc
if stores.Agents != nil {
ensureUserFiles = buildEnsureUserFiles(stores.Agents, msgBus)
ensureUserFiles = buildEnsureUserFiles(stores.Agents, stores.ConfigPermissions)
}
// 3. Context file loader callback: loads per-user context files dynamically
@@ -159,7 +153,7 @@ func wireExtras(
BuiltinToolStore: stores.BuiltinTools,
MCPStore: stores.MCP,
MCPPool: mcpPool,
GroupWriterCache: groupWriterCache,
ConfigPermStore: stores.ConfigPermissions,
MediaStore: mediaStore,
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
@@ -222,17 +216,17 @@ func wireExtras(
}
}
// Wire group writer cache for permission checks
if groupWriterCache != nil {
// Wire config perm store for file writer permission checks
if stores.ConfigPermissions != nil {
for _, toolName := range []string{"read_file", "write_file", "edit", "cron"} {
if t, ok := toolsReg.Get(toolName); ok {
if gwa, ok := t.(tools.GroupWriterAware); ok {
gwa.SetGroupWriterCache(groupWriterCache)
if cpa, ok := t.(tools.ConfigPermAware); ok {
cpa.SetConfigPermStore(stores.ConfigPermissions)
}
}
}
if contextFileInterceptor != nil {
contextFileInterceptor.SetGroupWriterCache(groupWriterCache)
contextFileInterceptor.SetConfigPermStore(stores.ConfigPermissions)
}
}
@@ -475,24 +469,6 @@ func wireExtras(
}
})
// Group writer cache: invalidate on writer list changes
if groupWriterCache != nil {
msgBus.Subscribe(bus.TopicCacheGroupFileWriters, func(event bus.Event) {
if event.Name != protocol.EventCacheInvalidate {
return
}
payload, ok := event.Payload.(bus.CacheInvalidatePayload)
if !ok || payload.Kind != bus.CacheKindGroupFileWriters {
return
}
if payload.Key != "" {
groupWriterCache.Invalidate(payload.Key)
} else {
groupWriterCache.InvalidateAll()
}
})
}
// Provider cache: re-register ACP providers on create/update/delete
msgBus.Subscribe(bus.TopicCacheProvider, func(event bus.Event) {
if event.Name != protocol.EventCacheInvalidate {
+2 -5
View File
@@ -33,19 +33,16 @@ func initRedisClient(cfg *config.Config) any {
func makeCaches(raw any) (
agentCtxCache cache.Cache[[]store.AgentContextFileData],
userCtxCache cache.Cache[[]store.AgentContextFileData],
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
) {
client, _ := raw.(*redis.Client)
if client == nil {
slog.Info("cache backend: in-memory (Redis not connected)")
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
cache.NewInMemoryCache[[]store.AgentContextFileData]()
}
slog.Info("cache backend: redis")
return cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:agent"),
cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:user"),
cache.NewRedisCache[[]store.GroupFileWriterData](client, "grp:writers")
cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:user")
}
// shutdownRedis closes the Redis client connection.
+1 -3
View File
@@ -18,12 +18,10 @@ func initRedisClient(_ *config.Config) any { return nil }
func makeCaches(_ any) (
agentCtxCache cache.Cache[[]store.AgentContextFileData],
userCtxCache cache.Cache[[]store.AgentContextFileData],
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
) {
slog.Info("cache backend: in-memory")
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
cache.NewInMemoryCache[[]store.AgentContextFileData]()
}
// shutdownRedis is a no-op when built without the "redis" tag.
+14 -7
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
@@ -124,7 +125,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
}
// Group writer restrictions: filter context files + inject prompt
if l.groupWriterCache != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
if l.configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
senderID := store.SenderIDFromContext(ctx)
writerPrompt, filtered := l.buildGroupWriterPrompt(ctx, userID, senderID, contextFiles)
contextFiles = filtered
@@ -560,7 +561,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
// buildGroupWriterPrompt builds the system prompt section for group file writer restrictions.
// For non-writers: injects refusal instructions + removes SOUL.md/AGENTS.md from context files.
func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID string, files []bootstrap.ContextFile) (string, []bootstrap.ContextFile) {
writers, err := l.groupWriterCache.ListWriters(ctx, l.agentUUID, groupID)
writers, err := l.configPermStore.ListFileWriters(ctx, l.agentUUID, groupID)
if err != nil || len(writers) == 0 {
return "", files // fail-open
}
@@ -585,13 +586,19 @@ func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID str
}
}
// Build writer display names
// Build writer display names from metadata JSON
type fwMeta struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
}
var names []string
for _, w := range writers {
if w.Username != nil && *w.Username != "" {
names = append(names, "@"+*w.Username)
} else if w.DisplayName != nil && *w.DisplayName != "" {
names = append(names, *w.DisplayName)
var meta fwMeta
_ = json.Unmarshal(w.Metadata, &meta)
if meta.Username != "" {
names = append(names, "@"+meta.Username)
} else if meta.DisplayName != "" {
names = append(names, meta.DisplayName)
}
}
+5 -5
View File
@@ -119,8 +119,8 @@ type Loop struct {
skillEvolve bool
skillNudgeInterval int // nudge every N tool calls (0 = disabled, 15 = default)
// Group writer cache for system prompt injection
groupWriterCache *store.GroupWriterCache
// Config permission store for group file writer checks
configPermStore store.ConfigPermissionStore
// Team store for cross-session pending task detection
teamStore store.TeamStore
@@ -236,8 +236,8 @@ type LoopConfig struct {
SkillEvolve bool
SkillNudgeInterval int // 0 = disabled, 15 = default
// Group writer cache for system prompt injection
GroupWriterCache *store.GroupWriterCache
// Config permission store for group file writer checks
ConfigPermStore store.ConfigPermissionStore
// Team store for cross-session pending task detection
TeamStore store.TeamStore
@@ -335,7 +335,7 @@ func NewLoop(cfg LoopConfig) *Loop {
selfEvolve: cfg.SelfEvolve,
skillEvolve: cfg.SkillEvolve,
skillNudgeInterval: cfg.SkillNudgeInterval,
groupWriterCache: cfg.GroupWriterCache,
configPermStore: cfg.ConfigPermStore,
teamStore: cfg.TeamStore,
secureCLIStore: cfg.SecureCLIStore,
mediaStore: cfg.MediaStore,
+3 -3
View File
@@ -75,8 +75,8 @@ type ResolverDeps struct {
// Skill access store — for per-agent skill visibility filtering
SkillAccessStore store.SkillAccessStore
// Group file writer cache
GroupWriterCache *store.GroupWriterCache
// Config permission store for group file writer checks
ConfigPermStore store.ConfigPermissionStore
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
@@ -349,7 +349,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SkillNudgeInterval: ag.ParseSkillNudgeInterval(),
WorkspaceSharing: ag.ParseWorkspaceSharing(),
ShellDenyGroups: ag.ParseShellDenyGroups(),
GroupWriterCache: deps.GroupWriterCache,
ConfigPermStore: deps.ConfigPermStore,
TeamStore: deps.TeamStore,
SecureCLIStore: deps.SecureCLIStore,
MediaStore: deps.MediaStore,
-15
View File
@@ -87,21 +87,6 @@ func (s *seedStubStore) SetUserOverride(_ context.Context, _ *store.UserAgentOve
func (s *seedStubStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
return false, "", nil
}
func (s *seedStubStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *seedStubStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
return nil
}
func (s *seedStubStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
return nil, nil
}
func (s *seedStubStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
return nil, nil
}
func (s *seedStubStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
return nil, nil
}
-2
View File
@@ -61,7 +61,6 @@ const (
CacheKindBuiltinTools = "builtin_tools"
CacheKindTeam = "team"
CacheKindUserWorkspace = "user_workspace"
CacheKindGroupFileWriters = "group_file_writers"
CacheKindSkillGrants = "skill_grants"
CacheKindMCP = "mcp"
CacheKindProvider = "provider"
@@ -81,7 +80,6 @@ const (
TopicCacheTeam = "cache:team"
TopicCacheUserWorkspace = "cache:user_workspace"
TopicCacheChannelInstances = "cache:channel_instances"
TopicCacheGroupFileWriters = "cache:group_file_writers"
TopicCacheSkillGrants = "cache:skill_grants"
TopicCacheMCP = "cache:mcp"
TopicCacheProvider = "cache:provider"
+16 -13
View File
@@ -25,8 +25,9 @@ type Channel struct {
config config.TelegramConfig
httpClient *http.Client
pairingService store.PairingStore
agentStore store.AgentStore // for group file writer management (nil if not configured)
teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
agentStore store.AgentStore // for agent key lookup (nil if not configured)
configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured)
teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
placeholders sync.Map // localKey string → messageID int
stopThinking sync.Map // localKey string → *thinkingCancel
typingCtrls sync.Map // localKey string → *typing.Controller
@@ -54,8 +55,9 @@ func (c *thinkingCancel) Cancel() {
// New creates a new Telegram channel from config.
// pairingSvc is optional (nil = fall back to allowlist only).
// agentStore is optional (nil = group file writer commands disabled).
// configPermStore is optional (nil = group file writer commands disabled).
// teamStore is optional (nil = /tasks, /task_detail commands disabled).
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (*Channel, error) {
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (*Channel, error) {
var opts []telego.BotOption
if cfg.APIServer != "" {
@@ -96,16 +98,17 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
}
return &Channel{
BaseChannel: base,
bot: bot,
config: cfg,
httpClient: httpClient,
pairingService: pairingSvc,
agentStore: agentStore,
teamStore: teamStore,
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
historyLimit: historyLimit,
requireMention: requireMention,
BaseChannel: base,
bot: bot,
config: cfg,
httpClient: httpClient,
pairingService: pairingSvc,
agentStore: agentStore,
configPermStore: configPermStore,
teamStore: teamStore,
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
historyLimit: historyLimit,
requireMention: requireMention,
}, nil
}
+2 -2
View File
@@ -93,12 +93,12 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message,
case "/reset":
// In group chats, only file writers can reset conversation history.
if isGroup && c.agentStore != nil {
if isGroup && c.configPermStore != nil {
agentID, err := c.resolveAgentUUID(ctx)
if err == nil {
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
senderNumericID := strings.SplitN(senderID, "|", 2)[0]
isWriter, err := c.agentStore.IsGroupFileWriter(ctx, agentID, groupID, senderNumericID)
isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, groupID, "file_writer", senderNumericID)
if err != nil {
slog.Warn("security.reset_writer_check_failed", "error", err, "sender", senderNumericID)
// fail-open: allow reset if DB check fails
+28 -21
View File
@@ -2,6 +2,7 @@ package telegram
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
@@ -9,8 +10,7 @@ import (
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleWriterCommand handles /addwriter and /removewriter commands.
@@ -29,7 +29,7 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
return
}
if c.agentStore == nil {
if c.configPermStore == nil {
send("File writer management is not available.")
return
}
@@ -45,7 +45,7 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
senderNumericID := strings.SplitN(senderID, "|", 2)[0]
// Check if sender is an existing writer (only writers can manage the list)
isWriter, err := c.agentStore.IsGroupFileWriter(ctx, agentID, groupID, senderNumericID)
isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, groupID, "file_writer", senderNumericID)
if err != nil {
slog.Warn("writer check failed", "error", err, "sender", senderNumericID)
send("Failed to check permissions. Please try again.")
@@ -75,34 +75,34 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
switch action {
case "add":
if err := c.agentStore.AddGroupFileWriter(ctx, agentID, groupID, targetID, targetUser.FirstName, targetUser.Username); err != nil {
meta, _ := json.Marshal(map[string]string{"displayName": targetUser.FirstName, "username": targetUser.Username})
if err := c.configPermStore.Grant(ctx, &store.ConfigPermission{
AgentID: agentID,
Scope: groupID,
ConfigType: "file_writer",
UserID: targetID,
Permission: "allow",
Metadata: meta,
}); err != nil {
slog.Warn("add writer failed", "error", err, "target", targetID)
send("Failed to add writer. Please try again.")
return
}
send(fmt.Sprintf("Added %s as a file writer.", targetName))
c.Bus().Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: groupID},
})
case "remove":
// Prevent removing the last writer
writers, _ := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
writers, _ := c.configPermStore.List(ctx, agentID, "file_writer", groupID)
if len(writers) <= 1 {
send("Cannot remove the last file writer.")
return
}
if err := c.agentStore.RemoveGroupFileWriter(ctx, agentID, groupID, targetID); err != nil {
if err := c.configPermStore.Revoke(ctx, agentID, groupID, "file_writer", targetID); err != nil {
slog.Warn("remove writer failed", "error", err, "target", targetID)
send("Failed to remove writer. Please try again.")
return
}
send(fmt.Sprintf("Removed %s from file writers.", targetName))
c.Bus().Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: groupID},
})
}
}
@@ -121,7 +121,7 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
return
}
if c.agentStore == nil {
if c.configPermStore == nil {
send("File writer management is not available.")
return
}
@@ -135,7 +135,7 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
writers, err := c.configPermStore.List(ctx, agentID, "file_writer", groupID)
if err != nil {
slog.Warn("list writers failed", "error", err)
send("Failed to list writers. Please try again.")
@@ -147,14 +147,21 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
return
}
type fwMeta struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("File writers for this group (%d):\n", len(writers)))
for i, w := range writers {
var meta fwMeta
_ = json.Unmarshal(w.Metadata, &meta)
label := w.UserID
if w.Username != nil && *w.Username != "" {
label = "@" + *w.Username
} else if w.DisplayName != nil && *w.DisplayName != "" {
label = *w.DisplayName
if meta.Username != "" {
label = "@" + meta.Username
} else if meta.DisplayName != "" {
label = meta.DisplayName
}
sb.WriteString(fmt.Sprintf("%d. %s (ID: %s)\n", i+1, label, w.UserID))
}
+6 -6
View File
@@ -40,19 +40,19 @@ type telegramInstanceConfig struct {
// Factory creates a Telegram channel from DB instance data (no extra stores).
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil, nil)
return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil, nil, nil)
}
// FactoryWithStores returns a ChannelFactory that includes agent, team, and pending message stores.
func FactoryWithStores(agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) channels.ChannelFactory {
// FactoryWithStores returns a ChannelFactory that includes agent, configPerm, team, and pending message stores.
func FactoryWithStores(agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) channels.ChannelFactory {
return func(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, teamStore, pendingStore)
return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, configPermStore, teamStore, pendingStore)
}
}
func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (channels.Channel, error) {
msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (channels.Channel, error) {
var c telegramCreds
if len(creds) > 0 {
@@ -107,7 +107,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
tgCfg.GroupPolicy = "pairing"
}
ch, err := New(tgCfg, msgBus, pairingSvc, agentStore, teamStore, pendingStore)
ch, err := New(tgCfg, msgBus, pairingSvc, agentStore, configPermStore, teamStore, pendingStore)
if err != nil {
return nil, err
}
@@ -80,21 +80,6 @@ func (s *createCaptureStore) SetUserOverride(_ context.Context, _ *store.UserAge
func (s *createCaptureStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
return false, "", nil
}
func (s *createCaptureStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *createCaptureStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
return nil
}
func (s *createCaptureStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
return nil, nil
}
func (s *createCaptureStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
return nil, nil
}
func (s *createCaptureStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
return nil, nil
}
@@ -48,7 +48,7 @@ func (m *ConfigPermissionsMethods) handleList(ctx context.Context, client *gatew
return
}
perms, err := m.permStore.List(ctx, agentUUID, params.ConfigType)
perms, err := m.permStore.List(ctx, agentUUID, params.ConfigType, "")
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("list", err)))
return
+71 -16
View File
@@ -17,16 +17,17 @@ import (
// ChannelInstancesHandler handles channel instance CRUD endpoints.
type ChannelInstancesHandler struct {
store store.ChannelInstanceStore
agentStore store.AgentStore
contactStore store.ContactStore
token string
msgBus *bus.MessageBus
store store.ChannelInstanceStore
agentStore store.AgentStore
configPermStore store.ConfigPermissionStore
contactStore store.ContactStore
token string
msgBus *bus.MessageBus
}
// NewChannelInstancesHandler creates a handler for channel instance management endpoints.
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
return &ChannelInstancesHandler{store: s, agentStore: agentStore, contactStore: contactStore, token: token, msgBus: msgBus}
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, token: token, msgBus: msgBus}
}
// RegisterRoutes registers all channel instance routes on the given mux.
@@ -44,7 +45,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
}
// Group file writers (nested under channel instances)
if h.agentStore != nil {
if h.configPermStore != nil {
mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups))
mux.HandleFunc("GET /v1/channels/instances/{id}/writers", h.auth(h.handleListWriters))
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.auth(h.handleAddWriter))
@@ -303,15 +304,27 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h
if !ok {
return
}
groups, err := h.agentStore.ListGroupFileWriterGroups(r.Context(), agentID)
perms, err := h.configPermStore.List(r.Context(), agentID, "file_writer", "")
if err != nil {
slog.Error("channel_instances.writer_groups", "error", err)
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")})
return
}
if groups == nil {
groups = []store.GroupWriterGroupInfo{}
// Group by scope
counts := make(map[string]int)
for _, p := range perms {
if p.Permission == "allow" {
counts[p.Scope]++
}
}
type groupInfo struct {
GroupID string `json:"group_id"`
WriterCount int `json:"writer_count"`
}
groups := make([]groupInfo, 0, len(counts))
for scope, count := range counts {
groups = append(groups, groupInfo{GroupID: scope, WriterCount: count})
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
@@ -327,14 +340,36 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")})
return
}
writers, err := h.agentStore.ListGroupFileWriters(r.Context(), agentID, groupID)
perms, err := h.configPermStore.List(r.Context(), agentID, "file_writer", groupID)
if err != nil {
slog.Error("channel_instances.list_writers", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")})
return
}
if writers == nil {
writers = []store.GroupFileWriterData{}
type writerData struct {
UserID string `json:"user_id"`
DisplayName *string `json:"display_name,omitempty"`
Username *string `json:"username,omitempty"`
}
writers := make([]writerData, 0, len(perms))
for _, p := range perms {
if p.Permission != "allow" {
continue
}
wd := writerData{UserID: p.UserID}
var meta struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
}
if json.Unmarshal(p.Metadata, &meta) == nil {
if meta.DisplayName != "" {
wd.DisplayName = &meta.DisplayName
}
if meta.Username != "" {
wd.Username = &meta.Username
}
}
writers = append(writers, wd)
}
writeJSON(w, http.StatusOK, map[string]any{"writers": writers})
}
@@ -359,7 +394,15 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")})
return
}
if err := h.agentStore.AddGroupFileWriter(r.Context(), agentID, body.GroupID, body.UserID, body.DisplayName, body.Username); err != nil {
meta, _ := json.Marshal(map[string]string{"displayName": body.DisplayName, "username": body.Username})
if err := h.configPermStore.Grant(r.Context(), &store.ConfigPermission{
AgentID: agentID,
Scope: body.GroupID,
ConfigType: "file_writer",
UserID: body.UserID,
Permission: "allow",
Metadata: meta,
}); err != nil {
slog.Error("channel_instances.add_writer", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())})
return
@@ -379,7 +422,19 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")})
return
}
if err := h.agentStore.RemoveGroupFileWriter(r.Context(), agentID, groupID, userID); err != nil {
// Prevent removing the last writer (same guard as Telegram /removewriter)
writers, _ := h.configPermStore.List(r.Context(), agentID, "file_writer", groupID)
allowCount := 0
for _, p := range writers {
if p.Permission == "allow" {
allowCount++
}
}
if allowCount <= 1 {
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot remove the last file writer"})
return
}
if err := h.configPermStore.Revoke(r.Context(), agentID, groupID, "file_writer", userID); err != nil {
slog.Error("channel_instances.remove_writer", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())})
return
-18
View File
@@ -313,12 +313,6 @@ type AgentStore interface {
ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error
// Group file writers (allowlist for protected file edits in group chats)
IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error)
AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error
RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error
ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]GroupFileWriterData, error)
ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]GroupWriterGroupInfo, error)
}
// UserInstanceData represents a user instance for a predefined agent.
@@ -330,15 +324,3 @@ type UserInstanceData struct {
Metadata map[string]string `json:"metadata,omitempty"`
}
// GroupFileWriterData represents a group file writer entry.
type GroupFileWriterData struct {
UserID string `json:"user_id"`
DisplayName *string `json:"display_name,omitempty"`
Username *string `json:"username,omitempty"`
}
// GroupWriterGroupInfo represents a group that has writers configured.
type GroupWriterGroupInfo struct {
GroupID string `json:"group_id"`
WriterCount int `json:"writer_count"`
}
+38 -2
View File
@@ -3,6 +3,8 @@ package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -13,7 +15,7 @@ type ConfigPermission struct {
ID uuid.UUID `json:"id"`
AgentID uuid.UUID `json:"agentId"`
Scope string `json:"scope"` // "agent" | "group:telegram:-100456" | "group:*" | "*"
ConfigType string `json:"configType"` // "heartbeat" | "cron" | "context_files" | "*"
ConfigType string `json:"configType"` // "heartbeat" | "cron" | "context_files" | "file_writer" | "*"
UserID string `json:"userId"`
Permission string `json:"permission"` // "allow" | "deny"
GrantedBy *string `json:"grantedBy,omitempty"`
@@ -30,5 +32,39 @@ type ConfigPermissionStore interface {
Grant(ctx context.Context, perm *ConfigPermission) error
Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error
List(ctx context.Context, agentID uuid.UUID, configType string) ([]ConfigPermission, error)
// List returns permissions for agentID+configType. If scope != "" only rows with that scope are returned.
List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]ConfigPermission, error)
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope (hot-path).
ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]ConfigPermission, error)
}
// CheckFileWriterPermission returns an error if the caller is in a group context
// and is not a file writer. Returns nil if write is allowed.
// Fail-open: returns nil on DB errors or missing context (cron, subagent).
// Replaces the deleted CheckGroupWritePermission / GroupWriterCache.
func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionStore) error {
if permStore == nil {
return nil
}
userID := UserIDFromContext(ctx)
if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") {
return nil // not a group context
}
agentID := AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return nil // no agent context
}
senderID := SenderIDFromContext(ctx)
if senderID == "" {
return nil // system context (cron, subagent)
}
numericID := strings.SplitN(senderID, "|", 2)[0]
allowed, err := permStore.CheckPermission(ctx, agentID, userID, "file_writer", numericID)
if err != nil {
return nil // fail-open
}
if !allowed {
return fmt.Errorf("permission denied: only file writers can modify files in this group. Use /addwriter to get write access")
}
return nil
}
-103
View File
@@ -1,103 +0,0 @@
package store
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cache"
)
const groupWriterCacheTTL = 5 * time.Minute
// GroupWriterCache wraps AgentStore.ListGroupFileWriters with a TTL cache.
// Used by tools and agent loop to check group write permissions without repeated DB queries.
type GroupWriterCache struct {
agentStore AgentStore
cache cache.Cache[[]GroupFileWriterData]
}
// NewGroupWriterCache creates a new cache backed by the given agent store.
// The cache implementation is injected (in-memory or Redis) so callers control the backend.
func NewGroupWriterCache(as AgentStore, c cache.Cache[[]GroupFileWriterData]) *GroupWriterCache {
return &GroupWriterCache{
agentStore: as,
cache: c,
}
}
func (c *GroupWriterCache) cacheKey(agentID uuid.UUID, groupID string) string {
return agentID.String() + ":" + groupID
}
// ListWriters returns cached writers, falling back to DB on miss/expiry.
func (c *GroupWriterCache) ListWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]GroupFileWriterData, error) {
key := c.cacheKey(agentID, groupID)
if writers, ok := c.cache.Get(ctx, key); ok {
return writers, nil
}
writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
if err != nil {
return nil, err
}
c.cache.Set(ctx, key, writers, groupWriterCacheTTL)
return writers, nil
}
// IsWriter checks if senderNumericID is in the cached writer list.
func (c *GroupWriterCache) IsWriter(ctx context.Context, agentID uuid.UUID, groupID, senderNumericID string) (bool, error) {
writers, err := c.ListWriters(ctx, agentID, groupID)
if err != nil {
return false, err
}
for _, w := range writers {
if w.UserID == senderNumericID {
return true, nil
}
}
return false, nil
}
// Invalidate clears cache entries matching the given groupID.
func (c *GroupWriterCache) Invalidate(groupID string) {
// DeleteByPrefix can't match suffix, so we use a sentinel prefix approach:
// keys are "agentUUID:groupID" — walk via Clear is not viable, so we
// store a reverse suffix index would add complexity. Instead, clear all
// and let the next access re-populate. Invalidate is called rarely (writer list changes).
c.cache.Clear(context.Background())
}
// InvalidateAll clears all cached entries.
func (c *GroupWriterCache) InvalidateAll() {
c.cache.Clear(context.Background())
}
// CheckGroupWritePermission returns an error if the caller is in a group context
// and is not a file writer. Returns nil if write is allowed.
// Fail-open: returns nil on DB errors or missing context (cron, subagent).
func CheckGroupWritePermission(ctx context.Context, cache *GroupWriterCache) error {
userID := UserIDFromContext(ctx)
if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") {
return nil // not a group context
}
agentID := AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return nil // no agent context
}
senderID := SenderIDFromContext(ctx)
if senderID == "" {
return nil // system context (cron, subagent)
}
numericID := strings.SplitN(senderID, "|", 2)[0]
isWriter, err := cache.IsWriter(ctx, agentID, userID, numericID)
if err != nil {
return nil // fail-open
}
if !isWriter {
return fmt.Errorf("permission denied: only file writers can modify files in this group. Use /addwriter to get write access")
}
return nil
}
+1 -2
View File
@@ -183,8 +183,7 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
}
func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
// Soft delete
_, err := s.db.ExecContext(ctx, "UPDATE agents SET deleted_at = NOW() WHERE id = $1", id)
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id)
return err
}
+73 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"strconv"
"strings"
"sync"
"time"
@@ -27,23 +28,39 @@ type permRow struct {
Permission string
}
// fwCacheEntry holds cached file_writer ConfigPermission rows for a scope.
type fwCacheEntry struct {
rows []store.ConfigPermission
fetched time.Time
}
// PGConfigPermissionStore implements store.ConfigPermissionStore backed by Postgres.
// Includes a TTL cache for CheckPermission to avoid per-request DB queries.
type PGConfigPermissionStore struct {
db *sql.DB
mu sync.RWMutex
cache map[string]permCacheEntry // key: "agentID:userID"
db *sql.DB
mu sync.RWMutex
cache map[string]permCacheEntry // key: "agentID:userID"
fwMu sync.RWMutex
fwCache map[string]fwCacheEntry // key: "agentID:scope"
}
func NewPGConfigPermissionStore(db *sql.DB) *PGConfigPermissionStore {
return &PGConfigPermissionStore{db: db, cache: make(map[string]permCacheEntry)}
return &PGConfigPermissionStore{
db: db,
cache: make(map[string]permCacheEntry),
fwCache: make(map[string]fwCacheEntry),
}
}
// InvalidateCache clears all cached permission entries.
func (s *PGConfigPermissionStore) InvalidateCache() {
s.mu.Lock()
defer s.mu.Unlock()
s.cache = make(map[string]permCacheEntry)
s.mu.Unlock()
s.fwMu.Lock()
s.fwCache = make(map[string]fwCacheEntry)
s.fwMu.Unlock()
}
// CheckPermission evaluates deny-first, allow-second permission with Go-level wildcard matching.
@@ -155,14 +172,18 @@ func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID,
return err
}
func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType string) ([]store.ConfigPermission, error) {
func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions WHERE agent_id = $1`
args := []any{agentID}
if configType != "" {
query += ` AND config_type = $2`
args = append(args, configType)
query += ` AND config_type = $` + itoa(len(args))
}
if scope != "" {
args = append(args, scope)
query += ` AND scope = $` + itoa(len(args))
}
query += ` ORDER BY created_at`
@@ -172,6 +193,46 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
}
defer rows.Close()
return scanConfigPermissions(rows)
}
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope.
// Hot-path: called during system prompt injection for every group message.
func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
cacheKey := agentID.String() + ":" + scope
s.fwMu.RLock()
if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
s.fwMu.RUnlock()
return entry.rows, nil
}
s.fwMu.RUnlock()
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions
WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'
ORDER BY created_at`,
agentID, scope,
)
if err != nil {
return nil, err
}
defer rows.Close()
perms, err := scanConfigPermissions(rows)
if err != nil {
return nil, err
}
s.fwMu.Lock()
s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()}
s.fwMu.Unlock()
return perms, nil
}
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
var perms []store.ConfigPermission
for rows.Next() {
var p store.ConfigPermission
@@ -189,3 +250,8 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
}
return perms, nil
}
// itoa converts an int to its decimal string representation.
func itoa(n int) string {
return strconv.Itoa(n)
}
-85
View File
@@ -1,85 +0,0 @@
package pg
import (
"context"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *PGAgentStore) IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3)`,
agentID, groupID, userID,
).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func (s *PGAgentStore) AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO group_file_writers (agent_id, group_id, user_id, display_name, username)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''))
ON CONFLICT (agent_id, group_id, user_id) DO UPDATE SET display_name=NULLIF($4,''), username=NULLIF($5,'')`,
agentID, groupID, userID, displayName, username,
)
return err
}
func (s *PGAgentStore) RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3`,
agentID, groupID, userID,
)
return err
}
func (s *PGAgentStore) ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT group_id, COUNT(*) as writer_count FROM group_file_writers WHERE agent_id=$1 GROUP BY group_id ORDER BY group_id`,
agentID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []store.GroupWriterGroupInfo
for rows.Next() {
var g store.GroupWriterGroupInfo
if err := rows.Scan(&g.GroupID, &g.WriterCount); err != nil {
return nil, err
}
groups = append(groups, g)
}
return groups, rows.Err()
}
func (s *PGAgentStore) ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]store.GroupFileWriterData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT user_id, display_name, username FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 ORDER BY created_at`,
agentID, groupID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var writers []store.GroupFileWriterData
for rows.Next() {
var w store.GroupFileWriterData
if err := rows.Scan(&w.UserID, &w.DisplayName, &w.Username); err != nil {
return nil, err
}
writers = append(writers, w)
}
if err := rows.Err(); err != nil {
return nil, err
}
return writers, nil
}
+9 -15
View File
@@ -78,7 +78,7 @@ type ContextFileInterceptor struct {
agentCache cache.Cache[[]store.AgentContextFileData] // agent-level files, keyed by agentID.String()
userCache cache.Cache[[]store.AgentContextFileData] // user-level files, keyed by "agentID:userID"
ttl time.Duration
groupWriterCache *store.GroupWriterCache // nil = use direct DB call (backward compat)
permStore store.ConfigPermissionStore // nil = no group write restriction
}
// NewContextFileInterceptor creates an interceptor backed by the given agent store.
@@ -97,9 +97,9 @@ func NewContextFileInterceptor(
}
}
// SetGroupWriterCache sets the shared cache for group writer permission checks (defense-in-depth).
func (b *ContextFileInterceptor) SetGroupWriterCache(c *store.GroupWriterCache) {
b.groupWriterCache = c
// SetConfigPermStore sets the config permission store for group writer permission checks.
func (b *ContextFileInterceptor) SetConfigPermStore(s store.ConfigPermissionStore) {
b.permStore = s
}
// ReadFile attempts to read a context file from the DB (with cache).
@@ -210,24 +210,18 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
}
if !skipCheck {
senderID := store.SenderIDFromContext(ctx)
if senderID != "" {
if senderID != "" && b.permStore != nil {
numericID := strings.SplitN(senderID, "|", 2)[0]
var isWriter bool
var err error
if b.groupWriterCache != nil {
isWriter, err = b.groupWriterCache.IsWriter(ctx, agentID, userID, numericID)
} else {
isWriter, err = b.agentStore.IsGroupFileWriter(ctx, agentID, userID, numericID)
}
allowed, err := b.permStore.CheckPermission(ctx, agentID, userID, "file_writer", numericID)
if err != nil {
slog.Warn("security.group_file_writer_check_failed",
"error", err, "sender", numericID, "file", fileName, "group", userID)
// fail open: allow write if DB check fails
} else if !isWriter {
// fail open: allow write if check fails
} else if !allowed {
return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. Ask a group file writer to add you with /addwriter", fileName)
}
}
// senderID empty = system context (cron, subagent) → fail open
// senderID empty or no permStore = system context (cron, subagent) → fail open
}
}
@@ -69,21 +69,6 @@ func (s *stubAgentStore) SetUserOverride(_ context.Context, _ *store.UserAgentOv
func (s *stubAgentStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
return false, "", nil
}
func (s *stubAgentStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *stubAgentStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
return nil
}
func (s *stubAgentStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *stubAgentStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
return nil, nil
}
func (s *stubAgentStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
return nil, nil
}
func (s *stubAgentStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
return nil, nil
}
+7 -7
View File
@@ -12,17 +12,17 @@ import (
// CronTool lets agents manage Gateway cron jobs.
// Matching OpenClaw src/agents/tools/cron-tool.ts.
type CronTool struct {
cronStore store.CronStore
groupWriterCache *store.GroupWriterCache // nil = no group restriction
cronStore store.CronStore
permStore store.ConfigPermissionStore // nil = no group restriction
}
func NewCronTool(cronStore store.CronStore) *CronTool {
return &CronTool{cronStore: cronStore}
}
// SetGroupWriterCache enables group cron mutation restriction.
func (t *CronTool) SetGroupWriterCache(c *store.GroupWriterCache) {
t.groupWriterCache = c
// SetConfigPermStore enables group cron mutation restriction.
func (t *CronTool) SetConfigPermStore(s store.ConfigPermissionStore) {
t.permStore = s
}
func (t *CronTool) Name() string { return "cron" }
@@ -117,8 +117,8 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result {
}
// Group write permission check for mutation actions
if t.groupWriterCache != nil && (action == "add" || action == "update" || action == "remove") {
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
if t.permStore != nil && (action == "add" || action == "update" || action == "remove") {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult("permission denied: only file writers can manage cron jobs in group chats")
}
}
+6 -6
View File
@@ -20,7 +20,7 @@ type EditTool struct {
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor
memIntc *MemoryInterceptor
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
permStore store.ConfigPermissionStore // nil = no group write restriction
}
// DenyPaths adds path prefixes that edit must reject.
@@ -36,9 +36,9 @@ func (t *EditTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
t.memIntc = intc
}
// SetGroupWriterCache enables group write permission checks.
func (t *EditTool) SetGroupWriterCache(c *store.GroupWriterCache) {
t.groupWriterCache = c
// SetConfigPermStore enables group write permission checks.
func (t *EditTool) SetConfigPermStore(s store.ConfigPermissionStore) {
t.permStore = s
}
func NewEditTool(workspace string, restrict bool) *EditTool {
@@ -98,8 +98,8 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
}
// Group write permission check
if t.groupWriterCache != nil {
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
if t.permStore != nil {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult(err.Error())
}
}
+6 -6
View File
@@ -30,7 +30,7 @@ type ReadFileTool struct {
sandboxMgr sandbox.Manager // nil = direct host access
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
groupWriterCache *store.GroupWriterCache // nil = no group read restriction
permStore store.ConfigPermissionStore // nil = no group read restriction
}
// SetContextFileInterceptor enables virtual FS routing for context files.
@@ -43,9 +43,9 @@ func (t *ReadFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
t.memIntc = intc
}
// SetGroupWriterCache enables group read restriction for SOUL.md/AGENTS.md.
func (t *ReadFileTool) SetGroupWriterCache(c *store.GroupWriterCache) {
t.groupWriterCache = c
// SetConfigPermStore enables group read restriction for SOUL.md/AGENTS.md.
func (t *ReadFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
t.permStore = s
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
@@ -92,10 +92,10 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
}
// Group read restriction: block non-writers from reading SOUL.md/AGENTS.md
if t.groupWriterCache != nil {
if t.permStore != nil {
base := filepath.Base(path)
if base == bootstrap.SoulFile || base == bootstrap.AgentsFile {
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult(fmt.Sprintf("permission denied: %s is restricted in this group", base))
}
}
+7 -7
View File
@@ -19,8 +19,8 @@ type WriteFileTool struct {
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
permStore store.ConfigPermissionStore // nil = no group write restriction
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
}
// DenyPaths adds path prefixes that write_file must reject.
@@ -38,9 +38,9 @@ func (t *WriteFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
t.memIntc = intc
}
// SetGroupWriterCache enables group write permission checks.
func (t *WriteFileTool) SetGroupWriterCache(c *store.GroupWriterCache) {
t.groupWriterCache = c
// SetConfigPermStore enables group write permission checks.
func (t *WriteFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
t.permStore = s
}
// SetWorkspaceInterceptor enables team workspace validation and event broadcasting.
@@ -103,8 +103,8 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
}
// Group write permission check
if t.groupWriterCache != nil {
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
if t.permStore != nil {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult(err.Error())
}
}
+3 -3
View File
@@ -50,9 +50,9 @@ type InterceptorAware interface {
SetMemoryInterceptor(*MemoryInterceptor)
}
// GroupWriterAware tools receive a GroupWriterCache for group permission checks.
type GroupWriterAware interface {
SetGroupWriterCache(*store.GroupWriterCache)
// ConfigPermAware tools receive a ConfigPermissionStore for group permission checks.
type ConfigPermAware interface {
SetConfigPermStore(store.ConfigPermissionStore)
}
// WorkspaceInterceptorAware tools can receive a WorkspaceInterceptor for team workspace validation.
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 22
const RequiredSchemaVersion uint = 23
@@ -0,0 +1,98 @@
-- ============================================================
-- Reverse Part B: restore all FK constraints to NO ACTION
-- ============================================================
ALTER TABLE sessions DROP CONSTRAINT sessions_agent_id_fkey;
ALTER TABLE sessions ADD CONSTRAINT sessions_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE cron_jobs DROP CONSTRAINT cron_jobs_agent_id_fkey;
ALTER TABLE cron_jobs ADD CONSTRAINT cron_jobs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE heartbeat_run_logs DROP CONSTRAINT heartbeat_run_logs_agent_id_fkey;
ALTER TABLE heartbeat_run_logs ADD CONSTRAINT heartbeat_run_logs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE agent_teams DROP CONSTRAINT agent_teams_lead_agent_id_fkey;
ALTER TABLE agent_teams ADD CONSTRAINT agent_teams_lead_agent_id_fkey
FOREIGN KEY (lead_agent_id) REFERENCES agents(id);
ALTER TABLE team_messages DROP CONSTRAINT team_messages_from_agent_id_fkey;
ALTER TABLE team_messages ADD CONSTRAINT team_messages_from_agent_id_fkey
FOREIGN KEY (from_agent_id) REFERENCES agents(id);
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_source_agent_id_fkey;
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_source_agent_id_fkey
FOREIGN KEY (source_agent_id) REFERENCES agents(id);
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_target_agent_id_fkey;
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_target_agent_id_fkey
FOREIGN KEY (target_agent_id) REFERENCES agents(id);
ALTER TABLE team_workspace_files DROP CONSTRAINT team_workspace_files_uploaded_by_fkey;
ALTER TABLE team_workspace_files ADD CONSTRAINT team_workspace_files_uploaded_by_fkey
FOREIGN KEY (uploaded_by) REFERENCES agents(id);
ALTER TABLE team_workspace_file_versions DROP CONSTRAINT team_workspace_file_versions_uploaded_by_fkey;
ALTER TABLE team_workspace_file_versions ADD CONSTRAINT team_workspace_file_versions_uploaded_by_fkey
FOREIGN KEY (uploaded_by) REFERENCES agents(id);
ALTER TABLE team_workspace_comments DROP CONSTRAINT team_workspace_comments_agent_id_fkey;
ALTER TABLE team_workspace_comments ADD CONSTRAINT team_workspace_comments_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE cron_run_logs DROP CONSTRAINT cron_run_logs_agent_id_fkey;
ALTER TABLE cron_run_logs ADD CONSTRAINT cron_run_logs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_owner_agent_id_fkey;
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_owner_agent_id_fkey
FOREIGN KEY (owner_agent_id) REFERENCES agents(id);
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_created_by_agent_id_fkey;
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_created_by_agent_id_fkey
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id);
ALTER TABLE team_messages DROP CONSTRAINT team_messages_to_agent_id_fkey;
ALTER TABLE team_messages ADD CONSTRAINT team_messages_to_agent_id_fkey
FOREIGN KEY (to_agent_id) REFERENCES agents(id);
ALTER TABLE team_task_comments DROP CONSTRAINT team_task_comments_agent_id_fkey;
ALTER TABLE team_task_comments ADD CONSTRAINT team_task_comments_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id);
ALTER TABLE team_task_attachments DROP CONSTRAINT team_task_attachments_added_by_fkey;
ALTER TABLE team_task_attachments ADD CONSTRAINT team_task_attachments_added_by_fkey
FOREIGN KEY (added_by) REFERENCES agents(id);
-- Restore agent_key unique constraint
DROP INDEX IF EXISTS idx_agents_agent_key_active;
ALTER TABLE agents ADD CONSTRAINT agents_agent_key_key UNIQUE (agent_key);
-- ============================================================
-- Reverse Part A: restore group_file_writers
-- ============================================================
CREATE TABLE group_file_writers (
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
group_id VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
username VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (agent_id, group_id, user_id)
);
-- Migrate data back from config permissions
INSERT INTO group_file_writers (agent_id, group_id, user_id, display_name, username, created_at)
SELECT agent_id, scope, user_id,
metadata->>'displayName', metadata->>'username',
created_at
FROM agent_config_permissions
WHERE config_type = 'file_writer' AND permission = 'allow';
DELETE FROM agent_config_permissions WHERE config_type = 'file_writer';
-- Restore scope column size
ALTER TABLE agent_config_permissions ALTER COLUMN scope TYPE VARCHAR(100);
@@ -0,0 +1,97 @@
-- ============================================================
-- Part A: File Writer → Config Permissions merge
-- ============================================================
-- Widen scope column to match group_file_writers.group_id size
ALTER TABLE agent_config_permissions ALTER COLUMN scope TYPE VARCHAR(255);
-- Migrate group_file_writers → agent_config_permissions
INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, metadata, created_at)
SELECT agent_id, group_id, 'file_writer', user_id, 'allow',
jsonb_build_object(
'displayName', COALESCE(display_name, ''),
'username', COALESCE(username, '')
),
created_at
FROM group_file_writers
ON CONFLICT (agent_id, scope, config_type, user_id) DO NOTHING;
DROP TABLE group_file_writers;
-- ============================================================
-- Part B: Agent hard delete — FK cascade constraints
-- ============================================================
-- Allow soft-deleted agents to reuse agent_key
ALTER TABLE agents DROP CONSTRAINT agents_agent_key_key;
CREATE UNIQUE INDEX idx_agents_agent_key_active ON agents(agent_key) WHERE deleted_at IS NULL;
-- CASCADE: data belongs to agent, delete with it
ALTER TABLE sessions DROP CONSTRAINT sessions_agent_id_fkey;
ALTER TABLE sessions ADD CONSTRAINT sessions_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE cron_jobs DROP CONSTRAINT cron_jobs_agent_id_fkey;
ALTER TABLE cron_jobs ADD CONSTRAINT cron_jobs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE heartbeat_run_logs DROP CONSTRAINT heartbeat_run_logs_agent_id_fkey;
ALTER TABLE heartbeat_run_logs ADD CONSTRAINT heartbeat_run_logs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE agent_teams DROP CONSTRAINT agent_teams_lead_agent_id_fkey;
ALTER TABLE agent_teams ADD CONSTRAINT agent_teams_lead_agent_id_fkey
FOREIGN KEY (lead_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE team_messages DROP CONSTRAINT team_messages_from_agent_id_fkey;
ALTER TABLE team_messages ADD CONSTRAINT team_messages_from_agent_id_fkey
FOREIGN KEY (from_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_source_agent_id_fkey;
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_source_agent_id_fkey
FOREIGN KEY (source_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_target_agent_id_fkey;
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_target_agent_id_fkey
FOREIGN KEY (target_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE team_workspace_files DROP CONSTRAINT team_workspace_files_uploaded_by_fkey;
ALTER TABLE team_workspace_files ADD CONSTRAINT team_workspace_files_uploaded_by_fkey
FOREIGN KEY (uploaded_by) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE team_workspace_file_versions DROP CONSTRAINT team_workspace_file_versions_uploaded_by_fkey;
ALTER TABLE team_workspace_file_versions ADD CONSTRAINT team_workspace_file_versions_uploaded_by_fkey
FOREIGN KEY (uploaded_by) REFERENCES agents(id) ON DELETE CASCADE;
ALTER TABLE team_workspace_comments DROP CONSTRAINT team_workspace_comments_agent_id_fkey;
ALTER TABLE team_workspace_comments ADD CONSTRAINT team_workspace_comments_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
-- SET NULL: keep row, clear reference
ALTER TABLE cron_run_logs DROP CONSTRAINT cron_run_logs_agent_id_fkey;
ALTER TABLE cron_run_logs ADD CONSTRAINT cron_run_logs_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_owner_agent_id_fkey;
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_owner_agent_id_fkey
FOREIGN KEY (owner_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_created_by_agent_id_fkey;
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_created_by_agent_id_fkey
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
ALTER TABLE team_messages DROP CONSTRAINT team_messages_to_agent_id_fkey;
ALTER TABLE team_messages ADD CONSTRAINT team_messages_to_agent_id_fkey
FOREIGN KEY (to_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
ALTER TABLE team_task_comments DROP CONSTRAINT team_task_comments_agent_id_fkey;
ALTER TABLE team_task_comments ADD CONSTRAINT team_task_comments_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
ALTER TABLE team_task_attachments DROP CONSTRAINT team_task_attachments_added_by_fkey;
ALTER TABLE team_task_attachments ADD CONSTRAINT team_task_attachments_added_by_fkey
FOREIGN KEY (added_by) REFERENCES agents(id) ON DELETE SET NULL;
-- Clean up previously soft-deleted agents (zombie rows)
-- Placed after CASCADE constraints so related data is auto-cleaned
DELETE FROM agents WHERE deleted_at IS NOT NULL;