diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e4b4e9aa..6ecf4b68 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -103,7 +103,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) l.userWorkspaces.Store(req.UserID, ws) cachedWs = ws } - effectiveWorkspace := filepath.Join(cachedWs.(string), sanitizePathSegment(req.UserID)) + effectiveWorkspace := cachedWs.(string) + if !l.shouldShareWorkspace(req.UserID, req.PeerKind) { + effectiveWorkspace = filepath.Join(effectiveWorkspace, sanitizePathSegment(req.UserID)) + } if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil { slog.Warn("failed to create user workspace directory", "workspace", effectiveWorkspace, "user", req.UserID, "error", err) } diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index d961a33d..d2070925 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -66,11 +66,15 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s // Per-user workspace: show the user's subdirectory in the system prompt. // Uses cached workspace from user_agent_profiles (includes channel isolation). + // When workspace sharing is enabled, show the base workspace without user subfolder. promptWorkspace := l.workspace if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" { if cachedWs, ok := l.userWorkspaces.Load(userID); ok { - promptWorkspace = filepath.Join(cachedWs.(string), sanitizePathSegment(userID)) - } else { + promptWorkspace = cachedWs.(string) + if !l.shouldShareWorkspace(userID, peerKind) { + promptWorkspace = filepath.Join(promptWorkspace, sanitizePathSegment(userID)) + } + } else if !l.shouldShareWorkspace(userID, peerKind) { promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID)) } } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 0a739af7..ab9d5c98 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -46,7 +46,8 @@ type Loop struct { contextWindow int maxIterations int maxToolCalls int - workspace string + workspace string + workspaceSharing *store.WorkspaceSharingConfig // Per-agent overrides from DB (nil = use global defaults) restrictToWs *bool @@ -153,7 +154,8 @@ type LoopConfig struct { ContextWindow int MaxIterations int MaxToolCalls int - Workspace string + Workspace string + WorkspaceSharing *store.WorkspaceSharingConfig // Per-agent DB overrides (nil = use global defaults) RestrictToWs *bool @@ -262,6 +264,7 @@ func NewLoop(cfg LoopConfig) *Loop { maxIterations: cfg.MaxIterations, maxToolCalls: cfg.MaxToolCalls, workspace: cfg.Workspace, + workspaceSharing: cfg.WorkspaceSharing, restrictToWs: cfg.RestrictToWs, subagentsCfg: cfg.SubagentsCfg, memoryCfg: cfg.MemoryCfg, diff --git a/internal/agent/loop_utils.go b/internal/agent/loop_utils.go index cf365b65..3663418f 100644 --- a/internal/agent/loop_utils.go +++ b/internal/agent/loop_utils.go @@ -39,6 +39,27 @@ func (l *Loop) scanWebToolResult(toolName string, result *tools.Result) { } } +// shouldShareWorkspace checks if the given user should share the base workspace +// directory (skip per-user subfolder isolation) based on workspace_sharing config. +func (l *Loop) shouldShareWorkspace(userID, peerKind string) bool { + ws := l.workspaceSharing + if ws == nil { + return false + } + for _, u := range ws.SharedUsers { + if u == userID { + return true + } + } + switch peerKind { + case "direct": + return ws.SharedDM + case "group": + return ws.SharedGroup + } + return false +} + // InvalidateUserWorkspace clears the cached workspace for a user, // forcing the next request to re-read from user_agent_profiles. func (l *Loop) InvalidateUserWorkspace(userID string) { diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 1177423d..6257f8d9 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -386,6 +386,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { BuiltinToolSettings: builtinSettings, ThinkingLevel: ag.ParseThinkingLevel(), SelfEvolve: ag.ParseSelfEvolve(), + WorkspaceSharing: ag.ParseWorkspaceSharing(), GroupWriterCache: deps.GroupWriterCache, TeamStore: deps.TeamStore, MediaStore: deps.MediaStore, diff --git a/internal/agent/workspace_sharing_test.go b/internal/agent/workspace_sharing_test.go new file mode 100644 index 00000000..59ec9883 --- /dev/null +++ b/internal/agent/workspace_sharing_test.go @@ -0,0 +1,97 @@ +package agent + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestShouldShareWorkspace_NilConfig(t *testing.T) { + l := &Loop{workspaceSharing: nil} + if l.shouldShareWorkspace("user1", "direct") { + t.Error("nil config should never share") + } +} + +func TestShouldShareWorkspace_SharedDM(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true}} + + if !l.shouldShareWorkspace("user1", "direct") { + t.Error("shared_dm=true should share for direct peer") + } + if l.shouldShareWorkspace("user1", "group") { + t.Error("shared_dm=true should NOT share for group peer") + } +} + +func TestShouldShareWorkspace_SharedGroup(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedGroup: true}} + + if !l.shouldShareWorkspace("group:telegram:-100", "group") { + t.Error("shared_group=true should share for group peer") + } + if l.shouldShareWorkspace("user1", "direct") { + t.Error("shared_group=true should NOT share for direct peer") + } +} + +func TestShouldShareWorkspace_SharedUsers(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ + SharedUsers: []string{"telegram:386246614", "group:telegram:-100"}, + }} + + if !l.shouldShareWorkspace("telegram:386246614", "direct") { + t.Error("user in shared_users should share regardless of peerKind") + } + if !l.shouldShareWorkspace("group:telegram:-100", "group") { + t.Error("group in shared_users should share") + } + if l.shouldShareWorkspace("unknown-user", "direct") { + t.Error("user NOT in shared_users should not share") + } +} + +func TestShouldShareWorkspace_SharedUsersTakesPriority(t *testing.T) { + // shared_dm=false, shared_group=false, but user is in shared_users + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ + SharedDM: false, + SharedGroup: false, + SharedUsers: []string{"special-user"}, + }} + + if !l.shouldShareWorkspace("special-user", "direct") { + t.Error("shared_users should override shared_dm=false") + } + if l.shouldShareWorkspace("other-user", "direct") { + t.Error("non-listed user should not share when shared_dm=false") + } +} + +func TestShouldShareWorkspace_UnknownPeerKind(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true, SharedGroup: true}} + + if l.shouldShareWorkspace("user1", "unknown") { + t.Error("unknown peerKind should default to not sharing") + } + if l.shouldShareWorkspace("user1", "") { + t.Error("empty peerKind should default to not sharing") + } +} + +func TestShouldShareWorkspace_BothEnabled(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ + SharedDM: true, + SharedGroup: true, + SharedUsers: []string{"extra-user"}, + }} + + if !l.shouldShareWorkspace("user1", "direct") { + t.Error("should share DM") + } + if !l.shouldShareWorkspace("group:tg:-100", "group") { + t.Error("should share group") + } + if !l.shouldShareWorkspace("extra-user", "direct") { + t.Error("should share listed user") + } +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index d7a6473a..17fed3a0 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -154,6 +154,33 @@ func (a *AgentData) ParseSelfEvolve() bool { return cfg.SelfEvolve } +// WorkspaceSharingConfig controls per-user workspace isolation. +// When shared_dm/shared_group is true, users share the base workspace directory +// instead of each getting an isolated subfolder. +type WorkspaceSharingConfig struct { + SharedDM bool `json:"shared_dm"` + SharedGroup bool `json:"shared_group"` + SharedUsers []string `json:"shared_users,omitempty"` +} + +// ParseWorkspaceSharing extracts workspace_sharing from other_config JSONB. +// Returns nil if not configured or all fields are default (isolation enabled). +func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig { + if len(a.OtherConfig) == 0 { + return nil + } + var cfg struct { + WS *WorkspaceSharingConfig `json:"workspace_sharing"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.WS == nil { + return nil + } + if !cfg.WS.SharedDM && !cfg.WS.SharedGroup && len(cfg.WS.SharedUsers) == 0 { + return nil + } + return cfg.WS +} + // AgentShareData represents an agent share grant. type AgentShareData struct { BaseModel diff --git a/ui/web/src/components/shared/sticky-save-bar.tsx b/ui/web/src/components/shared/sticky-save-bar.tsx index 9def77c9..aba95566 100644 --- a/ui/web/src/components/shared/sticky-save-bar.tsx +++ b/ui/web/src/components/shared/sticky-save-bar.tsx @@ -22,7 +22,7 @@ export function StickySaveBar({ savedLabel = "Saved", }: StickySaveBarProps) { return ( -
{t(`${s}.description`)}
+