From 6595c0185a9ab905a1fecee8423f100db3d04023 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 19 Mar 2026 13:35:10 +0700 Subject: [PATCH] refactor: merge file writers into config permissions + agent hard delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/gateway.go | 2 +- cmd/gateway_callbacks.go | 23 ++-- cmd/gateway_channels_setup.go | 2 +- cmd/gateway_http_handlers.go | 2 +- cmd/gateway_managed.go | 40 ++----- cmd/gateway_redis.go | 7 +- cmd/gateway_redis_noop.go | 4 +- internal/agent/loop_history.go | 21 ++-- internal/agent/loop_types.go | 10 +- internal/agent/resolver.go | 6 +- internal/bootstrap/seed_store_test.go | 15 --- internal/bus/types.go | 2 - internal/channels/telegram/channel.go | 29 ++--- internal/channels/telegram/commands.go | 4 +- .../channels/telegram/commands_writers.go | 49 +++++---- internal/channels/telegram/factory.go | 12 +- .../methods/agents_create_owner_test.go | 15 --- .../gateway/methods/config_permissions.go | 2 +- internal/http/channel_instances.go | 87 ++++++++++++--- internal/store/agent_store.go | 18 --- internal/store/config_permission_store.go | 40 ++++++- internal/store/group_writer_cache.go | 103 ------------------ internal/store/pg/agents.go | 3 +- internal/store/pg/config_permissions.go | 80 ++++++++++++-- internal/store/pg/group_file_writers.go | 85 --------------- internal/tools/context_file_interceptor.go | 24 ++-- .../tools/context_file_interceptor_test.go | 15 --- internal/tools/cron.go | 14 +-- internal/tools/edit.go | 12 +- internal/tools/filesystem.go | 12 +- internal/tools/filesystem_write.go | 14 +-- internal/tools/types.go | 6 +- internal/upgrade/version.go | 2 +- ...hard_delete_and_file_writer_merge.down.sql | 98 +++++++++++++++++ ...t_hard_delete_and_file_writer_merge.up.sql | 97 +++++++++++++++++ 35 files changed, 519 insertions(+), 436 deletions(-) delete mode 100644 internal/store/group_writer_cache.go delete mode 100644 internal/store/pg/group_file_writers.go create mode 100644 migrations/000023_agent_hard_delete_and_file_writer_merge.down.sql create mode 100644 migrations/000023_agent_hard_delete_and_file_writer_merge.up.sql diff --git a/cmd/gateway.go b/cmd/gateway.go index efbcb68d..01412ccf 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/cmd/gateway_callbacks.go b/cmd/gateway_callbacks.go index d81c480b..30da4f66 100644 --- a/cmd/gateway_callbacks.go +++ b/cmd/gateway_callbacks.go @@ -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 } } diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index e2649f05..589ee79a 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -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 { diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 735027f5..66f9ce24 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -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 { diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 9da01165..44496eeb 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -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 { diff --git a/cmd/gateway_redis.go b/cmd/gateway_redis.go index b230b5c6..728febe3 100644 --- a/cmd/gateway_redis.go +++ b/cmd/gateway_redis.go @@ -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. diff --git a/cmd/gateway_redis_noop.go b/cmd/gateway_redis_noop.go index 9cd472e1..a80d4805 100644 --- a/cmd/gateway_redis_noop.go +++ b/cmd/gateway_redis_noop.go @@ -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. diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 6805d1d0..a6a2947e 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -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) } } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 3e11588c..a56a2ab4 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 7789e394..84fae343 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -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, diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index 9ba4a9f5..61f32378 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -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 } diff --git a/internal/bus/types.go b/internal/bus/types.go index 20ad0dda..6cf66205 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -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" diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index f704aad0..6fb95e42 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -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 } diff --git a/internal/channels/telegram/commands.go b/internal/channels/telegram/commands.go index 4cbb3aa2..372b5eb0 100644 --- a/internal/channels/telegram/commands.go +++ b/internal/channels/telegram/commands.go @@ -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 diff --git a/internal/channels/telegram/commands_writers.go b/internal/channels/telegram/commands_writers.go index 526565cc..a0cde200 100644 --- a/internal/channels/telegram/commands_writers.go +++ b/internal/channels/telegram/commands_writers.go @@ -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)) } diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index 82cfa62e..bcbd9a9a 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -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 } diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 78f0c87b..1928d4d5 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -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 } diff --git a/internal/gateway/methods/config_permissions.go b/internal/gateway/methods/config_permissions.go index db20ca24..7d1b89c9 100644 --- a/internal/gateway/methods/config_permissions.go +++ b/internal/gateway/methods/config_permissions.go @@ -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 diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ef399d95..b670d292 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -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 diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 74ebb38d..da1576a9 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -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"` -} diff --git a/internal/store/config_permission_store.go b/internal/store/config_permission_store.go index be4c9ca3..eb0a45c7 100644 --- a/internal/store/config_permission_store.go +++ b/internal/store/config_permission_store.go @@ -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 } diff --git a/internal/store/group_writer_cache.go b/internal/store/group_writer_cache.go deleted file mode 100644 index 0fcdf62b..00000000 --- a/internal/store/group_writer_cache.go +++ /dev/null @@ -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 -} diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 2be69a50..2d24c00e 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -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 } diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go index 8b5c5173..042199bb 100644 --- a/internal/store/pg/config_permissions.go +++ b/internal/store/pg/config_permissions.go @@ -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) +} diff --git a/internal/store/pg/group_file_writers.go b/internal/store/pg/group_file_writers.go deleted file mode 100644 index 321ccdf7..00000000 --- a/internal/store/pg/group_file_writers.go +++ /dev/null @@ -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 -} diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 0cb5cbd7..37154760 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -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 } } diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 8af0fa74..21abf50e 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -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 } diff --git a/internal/tools/cron.go b/internal/tools/cron.go index a5a179fd..75059228 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -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") } } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index ddbff5db..9cd0414e 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -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()) } } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index f52e17a4..e71bbb3a 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -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)) } } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index 60794f57..0f80df9b 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -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()) } } diff --git a/internal/tools/types.go b/internal/tools/types.go index b177b265..b291f16e 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -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. diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 148ee80e..1048ae52 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -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 diff --git a/migrations/000023_agent_hard_delete_and_file_writer_merge.down.sql b/migrations/000023_agent_hard_delete_and_file_writer_merge.down.sql new file mode 100644 index 00000000..8e9da20a --- /dev/null +++ b/migrations/000023_agent_hard_delete_and_file_writer_merge.down.sql @@ -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); diff --git a/migrations/000023_agent_hard_delete_and_file_writer_merge.up.sql b/migrations/000023_agent_hard_delete_and_file_writer_merge.up.sql new file mode 100644 index 00000000..b3b7e17f --- /dev/null +++ b/migrations/000023_agent_hard_delete_and_file_writer_merge.up.sql @@ -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;