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 ( -
+
{error && (
diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 6f4a4693..018f3c16 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -276,6 +276,18 @@ "selectToolsDeny": "Select tools to deny...", "selectToolsAlsoAllow": "Select additional tools..." }, + "workspaceSharing": { + "title": "Workspace Sharing", + "description": "Control workspace file isolation between users", + "sharedDm": "Share in DMs", + "sharedDmTip": "All DM users share the same workspace directory instead of isolated per-user folders", + "sharedGroup": "Share in Groups", + "sharedGroupTip": "All members in a group chat share the same workspace directory", + "sharedUsers": "Shared Users", + "sharedUsersTip": "Specific user IDs that always share workspace regardless of DM/Group settings. Format: channel:userId (e.g. telegram:386246614)", + "userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)", + "warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory and context files remain isolated per user." + }, "compaction": { "title": "Compaction", "description": "Context window compaction and memory flush settings", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 50813be5..a36bf423 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -276,6 +276,18 @@ "selectToolsDeny": "Chọn công cụ để từ chối...", "selectToolsAlsoAllow": "Chọn công cụ bổ sung..." }, + "workspaceSharing": { + "title": "Chia sẻ Workspace", + "description": "Kiểm soát cách cô lập file workspace giữa các người dùng", + "sharedDm": "Chia sẻ trong DM", + "sharedDmTip": "Tất cả người dùng DM dùng chung thư mục workspace thay vì thư mục riêng", + "sharedGroup": "Chia sẻ trong Group", + "sharedGroupTip": "Tất cả thành viên trong group chat dùng chung thư mục workspace", + "sharedUsers": "User được chia sẻ", + "sharedUsersTip": "Danh sách user ID cụ thể luôn dùng chung workspace. Định dạng: channel:userId (vd: telegram:386246614)", + "userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)", + "warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Bộ nhớ và file ngữ cảnh vẫn được cô lập theo từng người dùng." + }, "compaction": { "title": "Nén ngữ cảnh", "description": "Cài đặt nén cửa sổ ngữ cảnh và ghi nhớ trước nén", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index e90658da..68d110fc 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -276,6 +276,18 @@ "selectToolsDeny": "选择要拒绝的工具...", "selectToolsAlsoAllow": "选择附加工具..." }, + "workspaceSharing": { + "title": "工作区共享", + "description": "控制用户之间的工作区文件隔离", + "sharedDm": "在私聊中共享", + "sharedDmTip": "启用后,所有私聊用户共享同一工作区目录", + "sharedGroup": "在群组中共享", + "sharedGroupTip": "启用后,群聊中的所有成员共享同一工作区目录", + "sharedUsers": "共享用户", + "sharedUsersTip": "始终共享工作区的特定用户ID。格式:channel:userId(例如 telegram:386246614)", + "userIdPlaceholder": "输入用户ID(例如 telegram:386246614)", + "warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆和上下文文件仍按用户隔离。" + }, "compaction": { "title": "压缩", "description": "上下文窗口压缩和压缩前记忆设置", diff --git a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx index 37a8aca6..953609b3 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx @@ -11,6 +11,7 @@ import type { SandboxConfig, MemoryConfig, QualityGateConfig, + WorkspaceSharingConfig, } from "@/types/agent"; import { SubagentsSection, @@ -22,6 +23,7 @@ import { OtherConfigSection, QualityGatesSection, ThinkingSection, + WorkspaceSharingSection, } from "./config-sections"; interface AgentConfigTabProps { @@ -52,7 +54,10 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { const otherObj = (agent.other_config ?? {}) as Record; const initialGates = (Array.isArray(otherObj.quality_gates) ? otherObj.quality_gates : []) as QualityGateConfig[]; const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off"); - const { quality_gates: _qg, thinking_level: _tl, ...otherWithoutManaged } = otherObj; + const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig; + const { workspace_sharing: _ws, quality_gates: _qg, thinking_level: _tl, ...otherWithoutManaged } = otherObj; + + const [wsSharing, setWsSharing] = useState(initialWsSharing); const [qgEnabled, setQgEnabled] = useState(initialGates.length > 0); const [qualityGates, setQualityGates] = useState(initialGates); @@ -95,6 +100,9 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { if (thinkingLevel && thinkingLevel !== "off") { otherBase.thinking_level = thinkingLevel; } + if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0) { + otherBase.workspace_sharing = wsSharing; + } updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {}; await onUpdate(updates); setSaved(true); @@ -108,6 +116,12 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { return (
+ {/* Workspace & Security */} + + {/* Core */} void; +} + +export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.workspaceSharing"; + const [contactSearch, setContactSearch] = useState(""); + + // Fetch contacts for the combobox + const { contacts } = useContacts({ search: contactSearch, limit: 20 }); + + // Build combobox options from contacts, excluding already-added users + const existingUsers = value.shared_users ?? []; + const contactOptions = useMemo(() => { + const existing = new Set(existingUsers); + return contacts + .filter((c) => c.user_id && !existing.has(c.user_id)) + .map((c) => ({ + value: c.user_id!, + label: c.display_name + ? `${c.display_name} (${c.user_id})` + : c.user_id!, + })); + }, [contacts, existingUsers]); + + const addUser = (userId: string) => { + const trimmed = userId.trim(); + if (!trimmed) return; + if (existingUsers.includes(trimmed)) return; + if (existingUsers.length >= MAX_SHARED_USERS) return; + onChange({ ...value, shared_users: [...existingUsers, trimmed] }); + setContactSearch(""); + }; + + const removeUser = (idx: number) => { + const updated = existingUsers.filter((_, i) => i !== idx); + onChange({ ...value, shared_users: updated.length > 0 ? updated : undefined }); + }; + + const isActive = !!value.shared_dm || !!value.shared_group || existingUsers.length > 0; + + return ( +
+ {/* Header — prominent with shield icon */} +
+
+ +
+
+

{t(`${s}.title`)}

+

{t(`${s}.description`)}

+
+
+ + {/* Content card with accent border when active */} +
+ {/* Security warning */} + + + + {t(`${s}.warning`)} + + + + {/* DM / Group sharing toggles */} +
+
+ {t(`${s}.sharedDm`)} + onChange({ ...value, shared_dm: v })} + /> +
+
+ {t(`${s}.sharedGroup`)} + onChange({ ...value, shared_group: v })} + /> +
+
+ + {/* Shared users allowlist with contact search */} +
+ {t(`${s}.sharedUsers`)} + {existingUsers.length > 0 && ( +
+ {existingUsers.map((u, i) => ( + + {u} + + + ))} +
+ )} +
+ setContactSearch(val)} + options={contactOptions} + placeholder={t(`${s}.userIdPlaceholder`)} + className="flex-1" + /> + +
+
+
+
+ ); +} diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index 56f3125a..3742a39e 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -77,6 +77,12 @@ export interface QualityGateConfig { timeout_seconds?: number; } +export interface WorkspaceSharingConfig { + shared_dm?: boolean; + shared_group?: boolean; + shared_users?: string[]; +} + export interface AgentData { id: string; agent_key: string;