From 7530e54aecb87a9a48313a3cc215714d7c9dd1b0 Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 29 Sep 2026 11:43:43 +0400 Subject: [PATCH] Shared resource use --- docsgpt/agents/headless_runner.py | 11 +- docsgpt/agents/tool_executor.py | 14 + docsgpt/agents/workflow_agent.py | 4 + docsgpt/agents/workflows/node_agent.py | 3 + docsgpt/agents/workflows/workflow_engine.py | 42 +- .../versions/0039_resource_sponsors.py | 40 ++ .../api/answer/services/stream_processor.py | 41 +- docsgpt/api/user/agents/routes.py | 20 + docsgpt/api/user/resource_access.py | 217 +++++++++ docsgpt/api/user/workflows/routes.py | 54 ++- docsgpt/services/search_service.py | 11 +- docsgpt/storage/db/models.py | 5 + docsgpt/storage/db/repositories/agents.py | 10 +- docsgpt/storage/db/repositories/workflows.py | 10 +- frontend/DESIGN.md | 6 +- frontend/src/agents/NewAgent.tsx | 27 +- .../SponsoredResourcesNotice.test.tsx | 103 ++++ .../components/SponsoredResourcesNotice.tsx | 91 ++++ frontend/src/agents/types/index.ts | 14 + frontend/src/locale/de.json | 22 +- frontend/src/locale/en.json | 22 +- frontend/src/locale/es.json | 22 +- frontend/src/locale/jp.json | 31 +- frontend/src/locale/ru.json | 30 +- frontend/src/locale/zh-TW.json | 31 +- frontend/src/locale/zh.json | 31 +- frontend/src/settings/Teams.test.tsx | 2 +- frontend/src/settings/Teams.tsx | 16 +- frontend/src/teams/ShareToTeamModal.test.tsx | 6 +- frontend/src/teams/ShareToTeamModal.tsx | 5 +- tests/api/test_agent_team_sharing.py | 3 + tests/api/user/test_resource_sponsors.py | 459 ++++++++++++++++++ tests/services/test_search_service.py | 18 +- tests/tracing/test_entry_points.py | 2 +- 34 files changed, 1322 insertions(+), 101 deletions(-) create mode 100644 docsgpt/alembic/versions/0039_resource_sponsors.py create mode 100644 frontend/src/agents/components/SponsoredResourcesNotice.test.tsx create mode 100644 frontend/src/agents/components/SponsoredResourcesNotice.tsx create mode 100644 tests/api/user/test_resource_sponsors.py diff --git a/docsgpt/agents/headless_runner.py b/docsgpt/agents/headless_runner.py index 5e1413d3..cddf693e 100644 --- a/docsgpt/agents/headless_runner.py +++ b/docsgpt/agents/headless_runner.py @@ -18,6 +18,7 @@ from docsgpt.api.answer.services.stream_processor import ( authorized_prompt_id, get_prompt, ) +from docsgpt.api.user.resource_access import ref_principal from docsgpt.core.settings import settings from docsgpt.quotas.service import QuotaExceededError, QuotaService from docsgpt.retriever.retriever_creator import RetrieverCreator @@ -151,7 +152,13 @@ def _run_agent_headless( source_active: Any = {} if source_id: with db_readonly() as conn: - src_row = SourcesRepository(conn).get(str(source_id), owner) + # Owned or team-shared to the owner, else attached by an editor + # who still qualifies; read unscoped once authorized. + src_row = ( + SourcesRepository(conn).get_by_id(str(source_id)) + if ref_principal(conn, "agent", agent_config, "source", str(source_id)) + else None + ) if src_row: source_active = str(src_row["id"]) retriever_kind = src_row.get("retriever", retriever_kind) @@ -161,7 +168,7 @@ def _run_agent_headless( chunks = 6 if raw_chunks in (None, "") else int(raw_chunks) # Runs as the owner: a prompt they can no longer use (revoked grant, # deleted) falls back to the default instead of rendering anyway. - prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner) + prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner, agent_config) user_api_key = agent_config.get("key") agent_id = _resolve_agent_id(agent_config) agent_type = agent_config.get("agent_type", "classic") diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py index a8e043c4..dd9e6f0b 100644 --- a/docsgpt/agents/tool_executor.py +++ b/docsgpt/agents/tool_executor.py @@ -505,6 +505,9 @@ class ToolExecutor: # get_tools() resolves EXACTLY these ids — builtin synthetic ids and # user_tools rows alike — with no defaults mixed in. None = unscoped. self.allowed_tool_ids: Optional[List[str]] = None + # Tool id -> the user to resolve it as, for a workflow node's tools + # sponsored by an editor (see resource_access.active_sponsor). + self.tool_principals: Dict[str, str] = {} self.conversation_id: Optional[str] = None # Set by the workflow engine for agent nodes so run-scoped tools # (artifact_generator / code_executor) address artifacts by the @@ -567,6 +570,8 @@ class ToolExecutor: tools: List[Dict] = [] for tid in tool_ids: row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo) + if row is None and str(tid) in self.tool_principals: + row = resolve_tool_by_id(tid, self.tool_principals[str(tid)], user_tools_repo=tools_repo) if row is None: logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid) continue @@ -588,6 +593,15 @@ class ToolExecutor: tools: List[Dict] = [] for tid in tool_ids: row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo) + if row is None: + # A tool the owner can't use runs as the editor who + # attached it, while they still qualify. + # Lazy: docsgpt.api's package import pulls in every route module. + from docsgpt.api.user.resource_access import active_sponsor + + sponsor = active_sponsor(conn, "agent", agent_data, "tool", str(tid)) + if sponsor: + row = resolve_tool_by_id(tid, sponsor, user_tools_repo=tools_repo) if row is None: continue # Workflow-only builtins (read_document) never resolve for a diff --git a/docsgpt/agents/workflow_agent.py b/docsgpt/agents/workflow_agent.py index 2c08bc0c..010f7287 100644 --- a/docsgpt/agents/workflow_agent.py +++ b/docsgpt/agents/workflow_agent.py @@ -45,6 +45,7 @@ class WorkflowAgent(BaseAgent): ): super().__init__(*args, **kwargs) self.workflow_id = workflow_id + self.workflow_row: Optional[Dict[str, Any]] = None self.workflow_owner = workflow_owner self._workflow_data = workflow self._engine: Optional[WorkflowEngine] = None @@ -195,6 +196,9 @@ class WorkflowAgent(BaseAgent): if workflow_row is None: logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}") return None + # Node tools/sources the owner can't use resolve through its + # ``resource_sponsors`` (see WorkflowEngine). + self.workflow_row = workflow_row pg_workflow_id = str(workflow_row["id"]) graph_version = workflow_row.get("current_graph_version", 1) try: diff --git a/docsgpt/agents/workflows/node_agent.py b/docsgpt/agents/workflows/node_agent.py index 17fa17a8..45215317 100644 --- a/docsgpt/agents/workflows/node_agent.py +++ b/docsgpt/agents/workflows/node_agent.py @@ -19,6 +19,7 @@ class _WorkflowNodeMixin: model_id: str, api_key: str, tool_ids: Optional[List[str]] = None, + tool_principals: Optional[Dict[str, str]] = None, **kwargs, ): super().__init__( @@ -34,6 +35,8 @@ class _WorkflowNodeMixin: # (Artifact / Code Executor / Read Document) and ``user_tools`` rows # alike, and an empty list means the node's LLM gets no tools. self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])] + # Tools the owner can't use resolve as the editor who attached them. + self.tool_executor.tool_principals = dict(tool_principals or {}) class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent): diff --git a/docsgpt/agents/workflows/workflow_engine.py b/docsgpt/agents/workflows/workflow_engine.py index e7cf437a..22017726 100644 --- a/docsgpt/agents/workflows/workflow_engine.py +++ b/docsgpt/agents/workflows/workflow_engine.py @@ -405,6 +405,7 @@ class WorkflowEngine: "model_user_id": getattr(self.agent, "model_user_id", None), "api_key": node_api_key, "tool_ids": node_config.tools, + "tool_principals": self._node_tool_principals(node_config.tools), "prompt": node_prompt, "chat_history": self.agent.chat_history, "decoded_token": self.agent.decoded_token, @@ -1321,6 +1322,36 @@ class WorkflowEngine: docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None return docs, docs_together + def _node_tool_principals(self, tool_ids) -> Dict[str, str]: + """Node tool id -> the editor to resolve it as, for sponsored tools. + + Only tools with a live sponsor on the workflow appear; the executor + still tries the owner first. + + Args: + tool_ids: The node's configured tool ids. + + Returns: + dict: ``tool_id -> sponsor`` user id. + """ + workflow_row = getattr(self.agent, "workflow_row", None) + if not tool_ids or not workflow_row or not workflow_row.get("resource_sponsors"): + return {} + from docsgpt.api.user.resource_access import active_sponsor + from docsgpt.storage.db.session import db_readonly + + principals: Dict[str, str] = {} + try: + with db_readonly() as conn: + for tid in tool_ids: + sponsor = active_sponsor(conn, "workflow", workflow_row, "tool", str(tid)) + if sponsor: + principals[str(tid)] = sponsor + except Exception: + logger.exception("Workflow node tool sponsor lookup failed; using the owner only.") + return {} + return principals + def _authorized_node_sources(self, sources) -> list: """Filter a node's configured source ids to those its owner may read. @@ -1329,7 +1360,9 @@ class WorkflowEngine: tenant's source id and the retriever — which filters only on ``source_id`` — handed the documents back. Gate on the workflow owner (not the runner): a shared workflow legitimately reads its owner's - sources, exactly like a shared agent does. + sources, exactly like a shared agent does. A source the owner can't + read still passes while the editor who attached it (its sponsor) + qualifies. Args: sources: Source ids from the stored node config. @@ -1348,14 +1381,19 @@ class WorkflowEngine: logger.warning("Workflow node sources dropped: no owner to authorize.") return [] + from docsgpt.api.user.resource_access import active_sponsor from docsgpt.api.user.team_sharing import can_access from docsgpt.storage.db.session import db_readonly + workflow_row = getattr(self.agent, "workflow_row", None) allowed = [] try: with db_readonly() as conn: for sid in ids: - if sid and can_access(conn, "source", str(sid), owner): + if sid and ( + can_access(conn, "source", str(sid), owner) + or active_sponsor(conn, "workflow", workflow_row, "source", str(sid)) + ): allowed.append(sid) else: logger.warning( diff --git a/docsgpt/alembic/versions/0039_resource_sponsors.py b/docsgpt/alembic/versions/0039_resource_sponsors.py new file mode 100644 index 00000000..2fc4f5df --- /dev/null +++ b/docsgpt/alembic/versions/0039_resource_sponsors.py @@ -0,0 +1,40 @@ +"""0039 resource sponsors — who vouches for a saved resource the owner can't use. + +An agent (and a workflow) runs as its owner, so every source, prompt and tool +it references is authorized against the owner. A team editor who attaches +their own private tool, prompt or source would have it dropped at run time. +``resource_sponsors`` records the editor who attached such a resource, keyed +``":"`` (e.g. ``{"tool:": "bob"}``). At run time the resource +is authorized as that sponsor, provided they can still edit the agent and +still use the resource (``docsgpt/api/user/resource_access.py``). + +An empty map means today's behaviour: owner-only authorization. + +Idempotent both ways. + +Revision ID: 0039_resource_sponsors +Revises: 0038_resource_access_settings +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0039_resource_sponsors" +down_revision: Union[str, None] = "0038_resource_access_settings" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + for table in ("agents", "workflows"): + op.execute( + f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS " + "resource_sponsors JSONB NOT NULL DEFAULT '{}'::jsonb;" + ) + + +def downgrade() -> None: + for table in ("agents", "workflows"): + op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS resource_sponsors;") diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py index c72b1055..b453aff0 100644 --- a/docsgpt/api/answer/services/stream_processor.py +++ b/docsgpt/api/answer/services/stream_processor.py @@ -112,16 +112,19 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str: _PROMPT_PRESETS_WITHOUT_ROW = ("reduce",) -def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any: - """``prompt_id`` if ``principal`` may use it, else ``"default"``. +def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Optional[dict] = None) -> Any: + """``prompt_id`` if ``principal`` (or the agent's sponsor) may use it, else ``"default"``. Presets pass through. A custom prompt must be owned by ``principal`` or - reach them through a team grant with ``use`` (checked live); a revoked, - deleted or foreign prompt falls back to the default prompt. + reach them through a team grant with ``use`` (checked live). On an agent + run, a prompt the owner can't use still renders while the editor who + attached it (its sponsor) qualifies. A revoked, deleted or foreign prompt + falls back to the default prompt. Args: prompt_id: The configured prompt (preset name, UUID or legacy id). principal: The agent owner for an agent run, else the caller. + agent: The agent row on an agent run, for its ``resource_sponsors``. Returns: The prompt id to render. @@ -131,20 +134,38 @@ def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any: pid = str(prompt_id) if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW: return prompt_id - from docsgpt.api.user.resource_access import resolve + from docsgpt.api.user.resource_access import active_sponsor, resolve try: with db_readonly() as conn: ra = resolve(conn, "prompt", pid, principal) if principal else None + usable = ra is not None and ra.can("use") + if not usable and agent and agent.get("id"): + usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None except Exception: logger.exception("Prompt access check failed for %s", pid) - ra = None - if ra is not None and ra.can("use"): + usable = False + if usable: return prompt_id logger.info("prompt %s not usable by %s; using the default prompt", pid, principal) return "default" +def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any) -> Optional[dict]: + """The source row an agent may retrieve from, or None. + + Authorized as the owner (owned or team-shared to them), else as the + editor who attached it while they still qualify. Read unscoped once + authorized: an owner-scoped read misses a team-shared source. + """ + from docsgpt.api.user.resource_access import ref_principal + + if not ref_principal(conn, "agent", agent, "source", str(source_id)): + logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id) + return None + return sources_repo.get_by_id(str(source_id)) + + def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]: """The owner id to write a wiki source as, when ``caller`` may edit it.""" from docsgpt.api.user.resource_access import resolve @@ -720,13 +741,12 @@ class StreamProcessor: # the legacy ``data["source"]`` slot. sources_list: list = [] seen: set = set() - owner = agent.get("user_id") primary_id = agent.get("source_id") # ``sources`` row may have NULL ``retriever``/``chunks`` — # fall back to the agent's value (``dict.get`` returns None # even when the key exists with value None). if primary_id: - source_doc = sources_repo.get(str(primary_id), owner) + source_doc = _agent_source_doc(conn, sources_repo, agent, primary_id) if source_doc: sid = str(source_doc["id"]) data["source"] = sid @@ -759,7 +779,7 @@ class StreamProcessor: for sid_raw in agent.get("extra_source_ids") or []: if not sid_raw: continue - source_doc = sources_repo.get(str(sid_raw), owner) + source_doc = _agent_source_doc(conn, sources_repo, agent, sid_raw) if not source_doc: continue sid = str(source_doc["id"]) @@ -983,6 +1003,7 @@ class StreamProcessor: "prompt_id": authorized_prompt_id( self._agent_data.get("prompt_id", "default"), self._agent_data.get("user"), + self._agent_data, ), "agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME), "user_api_key": effective_key, diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py index 6b9daf45..1711b039 100644 --- a/docsgpt/api/user/agents/routes.py +++ b/docsgpt/api/user/agents/routes.py @@ -28,11 +28,14 @@ from docsgpt.core.settings import settings from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.api.user.resource_access import ( AccessDenied, + agent_refs, delete_settings, payload_for, require, resolve, settings_many, + sponsor_details, + sponsors_after_save, ) from docsgpt.api.user.team_sharing import ( can_access, @@ -536,12 +539,16 @@ class GetAgent(Resource): try: user = decoded_token["sub"] agent = None + sponsored: list = [] with db_readonly() as conn: # Anyone who can see the agent reads it (a viewer needs it to # chat); what they get back is trimmed by their actions. ra = resolve(conn, "agent", agent_id, user) if ra is not None: agent = AgentsRepository(conn).get_by_id(ra.resource_id) + # Edit-page detail: who vouches for resources the owner can't use. + if agent and ra.can("view"): + sponsored = sponsor_details(conn, "agent", agent) if not agent: return {"status": "Not found"}, 404 is_owner = ra.access == "owner" @@ -552,6 +559,7 @@ class GetAgent(Resource): resolve_names=True, access=ra.payload(), ) + data["resource_sponsors"] = sponsored return make_response(jsonify(data), 200) except Exception as e: current_app.logger.error(f"Agent fetch error: {e}", exc_info=True) @@ -1383,6 +1391,18 @@ class UpdateAgent(Resource): 403, ) + # A resource the owner can't use runs as the editor who + # attached it (its sponsor); record who that is. + after_save = dict(existing_agent) + for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"): + if ref_field in update_fields: + after_save[ref_field] = update_fields[ref_field] + sponsors = sponsors_after_save( + conn, "agent", existing_agent, owner_id, user, agent_refs(after_save) + ) + if sponsors != (existing_agent.get("resource_sponsors") or {}): + update_fields["resource_sponsors"] = sponsors + # Guardrails and the pooled quota are policy: an unchanged # value re-sent by a full-form save is fine, a change needs # ``edit_policy``. diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py index 574e9487..35a19322 100644 --- a/docsgpt/api/user/resource_access.py +++ b/docsgpt/api/user/resource_access.py @@ -21,6 +21,7 @@ revoked grant or membership denies on the next request. from __future__ import annotations import json +import logging from dataclasses import dataclass, field from typing import Iterable, Optional @@ -36,6 +37,8 @@ from docsgpt.storage.db.repositories.team_resource_grants import ( from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository from docsgpt.storage.db.repositories.user_tools import UserToolsRepository +logger = logging.getLogger(__name__) + RESOURCE_TYPES = ("agent", "source", "tool", "prompt") # Weakest role that may perform each action by default. ``owner`` rows are @@ -332,3 +335,217 @@ def require( if not ra.can(action): raise AccessDenied(403, "Your access to this item doesn't allow that") return ra + + +# --- Resource sponsors ------------------------------------------------------ +# +# An agent (or workflow) runs as its owner, so a source, prompt or tool it +# references is authorized against the owner. When a team editor attaches one +# the owner can't use, the editor becomes its *sponsor*: the holder row's +# ``resource_sponsors`` maps ``":"`` to the editor's id, and at run +# time the resource is authorized as the sponsor while they can still edit the +# holder and still use the resource. A tool still runs with its own row's +# credentials (the tool owner's), whoever the principal is. + +# Action a principal needs on a referenced resource for a holder to run it. +REF_USE_ACTION = {"source": "use", "prompt": "use", "tool": "use_in_own"} + + +def sponsor_key(resource_type: str, resource_id: str) -> str: + """The ``resource_sponsors`` key for one referenced resource.""" + return f"{resource_type}:{resource_id}" + + +def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool: + """Whether ``user_id`` may have ``resource_id`` run inside something they hold. + + Args: + conn: Open database connection. + resource_type: ``source``, ``prompt`` or ``tool``. + resource_id: The referenced id. + user_id: The would-be principal. + + Returns: + True when the user owns the resource or a team grant gives them + ``use`` (``use_in_own`` for a tool). + """ + if not user_id or not resource_id: + return False + ra = resolve(conn, resource_type, str(resource_id), user_id) + return ra is not None and ra.can(REF_USE_ACTION[resource_type]) + + +def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: str) -> bool: + """Whether ``user_id`` may still edit the agent or workflow ``holder``. + + A workflow is edited through an agent of its owner that uses it, so the + check is ``edit`` on any such agent (mirrors the workflow routes). + """ + if holder_type == "agent": + ra = resolve(conn, "agent", str(holder["id"]), user_id) + return ra is not None and ra.can("edit") + if holder_type == "workflow": + agent_ids = conn.execute( + text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"), + {"wid": str(holder["id"]), "owner": holder.get("user_id")}, + ).scalars().all() + for agent_id in agent_ids: + ra = resolve(conn, "agent", str(agent_id), user_id) + if ra is not None and ra.can("edit"): + return True + return False + raise ValueError(f"Unknown sponsor holder type: {holder_type}") + + +def active_sponsor( + conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str +) -> Optional[str]: + """The sponsor a holder may run ``resource_id`` as, checked live. + + Args: + conn: Open database connection. + holder_type: ``agent`` or ``workflow``. + holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``). + resource_type: ``source``, ``prompt`` or ``tool``. + resource_id: The referenced id. + + Returns: + The sponsor's id when one is recorded, still edits the holder and + can still use the resource; else None. + """ + if not holder or not resource_id: + return None + sponsor = (holder.get("resource_sponsors") or {}).get(sponsor_key(resource_type, str(resource_id))) + if not sponsor or sponsor == holder.get("user_id"): + return None + try: + if not _holder_editable_by(conn, holder_type, holder, sponsor): + return None + return sponsor if can_use_ref(conn, resource_type, str(resource_id), sponsor) else None + except Exception: + logger.exception("Sponsor check failed for %s %s", resource_type, resource_id) + return None + + +def ref_principal( + conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str +) -> Optional[str]: + """The user a holder's referenced resource is authorized as, or None. + + The owner when they may use it (the default), else a live sponsor. + """ + if not holder: + return None + owner = holder.get("user_id") + if can_use_ref(conn, resource_type, str(resource_id), owner): + return owner + return active_sponsor(conn, holder_type, holder, resource_type, resource_id) + + +def _sponsorable(resource_type: str, resource_id: str) -> bool: + """Only real rows need a principal: skip presets and builtin tool ids.""" + rid = str(resource_id or "") + if not looks_like_uuid(rid): + return False + if resource_type == "tool": + # Lazy: default_tools imports this module lazily too. + from docsgpt.agents.default_tools import is_synthesized_tool_id + + return not is_synthesized_tool_id(rid) + return True + + +def agent_refs(agent: dict) -> list[tuple[str, str]]: + """The ``(type, id)`` resources an agent row references.""" + refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s] + if agent.get("prompt_id"): + refs.append(("prompt", str(agent["prompt_id"]))) + refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t) + return refs + + +def sponsors_after_save( + conn: Connection, + holder_type: str, + holder: Optional[dict], + owner_id: str, + caller: str, + refs: Iterable[tuple[str, str]], +) -> dict[str, str]: + """The ``resource_sponsors`` map to store after ``caller`` saves ``refs``. + + Per referenced resource the owner can't use: a recorded sponsor who still + qualifies is kept; otherwise the caller takes it over when they can use it + (a new attachment, or one whose sponsor lost access); otherwise the old + record is kept so the editor can show who added it. Resources the owner + can use, presets and builtin tools need no sponsor. Removed refs drop out. + + Args: + conn: Open database connection. + holder_type: ``agent`` or ``workflow``. + holder: The holder row before the save (None when creating it). + owner_id: The holder's owner. + caller: The user saving. + refs: Every ``(type, id)`` the holder references after the save. + + Returns: + dict: ``":" -> user_id``. + """ + previous = (holder or {}).get("resource_sponsors") or {} + out: dict[str, str] = {} + for resource_type, resource_id in refs: + key = sponsor_key(resource_type, resource_id) + if key in out or not _sponsorable(resource_type, resource_id): + continue + if can_use_ref(conn, resource_type, resource_id, owner_id): + continue + if active_sponsor(conn, holder_type, holder, resource_type, resource_id): + out[key] = previous[key] + elif caller != owner_id and can_use_ref(conn, resource_type, resource_id, caller): + out[key] = caller + elif previous.get(key): + out[key] = previous[key] + return out + + +def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]: + """The holder's sponsored resources for its edit page. + + Args: + conn: Open database connection. + holder_type: ``agent`` or ``workflow``. + holder: The holder row. + + Returns: + list: ``{type, id, user_id, label, active}`` per sponsored resource; + ``label`` is the sponsor's email when on file, ``active`` whether it + runs (the sponsor still edits the holder and can use the resource). + """ + sponsors = holder.get("resource_sponsors") or {} + if not sponsors: + return [] + user_ids = sorted({u for u in sponsors.values() if u}) + labels = dict( + conn.execute( + text( + "SELECT user_id, email FROM users WHERE user_id = ANY(:ids) " + "AND email IS NOT NULL AND email <> ''" + ), + {"ids": user_ids}, + ).fetchall() + ) if user_ids else {} + out = [] + for key, user_id in sponsors.items(): + resource_type, _, resource_id = key.partition(":") + if resource_type not in REF_USE_ACTION or not resource_id: + continue + out.append( + { + "type": resource_type, + "id": resource_id, + "user_id": user_id, + "label": labels.get(user_id) or user_id, + "active": active_sponsor(conn, holder_type, holder, resource_type, resource_id) is not None, + } + ) + return out diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py index 3faad82e..1e48899a 100644 --- a/docsgpt/api/user/workflows/routes.py +++ b/docsgpt/api/user/workflows/routes.py @@ -1,6 +1,6 @@ """Workflow management routes.""" -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Tuple from flask import current_app, jsonify, make_response, request from flask_restx import Namespace, Resource @@ -10,7 +10,12 @@ from docsgpt.agents.workflows.cel_evaluator import ( CelEvaluationError, validate_cel_expression, ) -from docsgpt.api.user.resource_access import AccessDenied, resolve +from docsgpt.api.user.resource_access import ( + AccessDenied, + resolve, + sponsor_details, + sponsors_after_save, +) from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository @@ -95,6 +100,30 @@ def _workflow_access(conn, workflow_id: str, user_id: str, action: str): raise AccessDenied(403, "Your access to this item doesn't allow that") +def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]: + """The ``(type, id)`` tools and sources a workflow's agent nodes reference. + + Args: + nodes: Nodes as the builder sends them (config under ``data`` or + ``data.config``, like the engine reads it). + + Returns: + list: ``("tool", id)`` and ``("source", id)`` pairs. + """ + refs: List[Tuple[str, str]] = [] + for node in nodes or []: + if not isinstance(node, dict) or node.get("type") != "agent": + continue + data = node.get("data") or {} + cfg = data.get("config") if isinstance(data.get("config"), dict) else data + for resource_type, key in (("tool", "tools"), ("source", "sources")): + values = cfg.get(key) or [] + if isinstance(values, (str, int)): + values = [values] + refs.extend((resource_type, str(v)) for v in values if v) + return refs + + def _denied(err: AccessDenied): """403/404 in this module's ``error`` shape, plus the shared ``message`` key.""" return make_response( @@ -567,6 +596,7 @@ class WorkflowDetail(Resource): edges = WorkflowEdgesRepository(conn).find_by_version( pg_workflow_id, graph_version, ) + sponsored = sponsor_details(conn, "workflow", workflow) except Exception as err: return _workflow_error_response("Failed to fetch workflow", err) @@ -575,6 +605,7 @@ class WorkflowDetail(Resource): "workflow": serialize_workflow(workflow), "nodes": [serialize_node(n) for n in nodes], "edges": [serialize_edge(e) for e in edges], + "resource_sponsors": sponsored, } ) @@ -614,14 +645,19 @@ class WorkflowDetail(Resource): conn, pg_workflow_id, next_graph_version, nodes_data, edges_data, ) - repo.update( - pg_workflow_id, acting, - { - "name": name, - "description": description, - "current_graph_version": next_graph_version, - }, + workflow_fields = { + "name": name, + "description": description, + "current_graph_version": next_graph_version, + } + # A node tool/source the owner can't use runs as the editor + # who attached it (its sponsor); record who that is. + sponsors = sponsors_after_save( + conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data) ) + if sponsors != (workflow.get("resource_sponsors") or {}): + workflow_fields["resource_sponsors"] = sponsors + repo.update(pg_workflow_id, acting, workflow_fields) WorkflowNodesRepository(conn).delete_other_versions( pg_workflow_id, next_graph_version, ) diff --git a/docsgpt/services/search_service.py b/docsgpt/services/search_service.py index 68756c91..3fd55e4e 100644 --- a/docsgpt/services/search_service.py +++ b/docsgpt/services/search_service.py @@ -48,7 +48,7 @@ def _collect_source_ids(agent: Dict[str, Any]) -> List[str]: def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -> List[str]: - """Drop source ids the agent's owner may not read. + """Drop source ids the agent may not read. ``_collect_source_ids`` trusts whatever the agent row carries, and this service searches those ids directly. That made it the second half of a @@ -64,20 +64,23 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) - agent: The agent row resolved from the API key. source_ids: Ids extracted from that row. + A source the owner can't read still searches while the editor who + attached it (its sponsor) can edit the agent and read the source. + Returns: - list: The subset the agent's owner may read. + list: The subset the agent's owner (or a live sponsor) may read. """ owner = agent.get("user_id") if not owner: logger.warning("Agent %s has no owner; refusing to search its sources.", agent.get("id")) return [] - from docsgpt.api.user.team_sharing import can_access + from docsgpt.api.user.resource_access import ref_principal allowed = [] for sid in source_ids: try: - permitted = can_access(conn, "source", str(sid), owner) + permitted = ref_principal(conn, "agent", agent, "source", str(sid)) is not None except Exception: # Fail closed, matching the answer path. logger.warning("Access check failed for source %s; dropping it.", sid) diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index 1e6c3866..34841da6 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -400,6 +400,9 @@ agents_table = Table( # Per-agent behavior contract (AgentConfig — guardrails today). Empty # ``{}`` parses to guardrails-disabled. Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")), + # ``":" -> user_id`` of the editor vouching for a referenced + # resource the owner can't use (migration 0039, see resource_access.py). + Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")), Column("default_model_id", Text), Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")), Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")), @@ -958,6 +961,8 @@ workflows_table = Table( Column("name", Text, nullable=False), Column("description", Text), Column("current_graph_version", Integer, nullable=False, server_default="1"), + # Same shape as ``agents.resource_sponsors``, for node tools and sources. + Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("legacy_mongo_id", Text), diff --git a/docsgpt/storage/db/repositories/agents.py b/docsgpt/storage/db/repositories/agents.py index 2e8163dc..78c2bb80 100644 --- a/docsgpt/storage/db/repositories/agents.py +++ b/docsgpt/storage/db/repositories/agents.py @@ -50,7 +50,7 @@ class AgentsRepository: for col, val in kwargs.items(): if col not in _ALLOWED or val is None: continue - if col in ("tools", "json_schema", "models", "shared_metadata", "config"): + if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"): # JSONB columns: pass the Python object directly. SQLAlchemy # Core's JSONB type processor json.dumps it once during # bind; pre-serialising would double-encode and the value @@ -234,7 +234,7 @@ class AgentsRepository: allowed = { "name", "description", "agent_type", "status", "key", "slug", "source_id", "chunks", "retriever", "prompt_id", "tools", "json_schema", "models", - "config", + "config", "resource_sponsors", "default_model_id", "folder_id", "workflow_id", "extra_source_ids", "image", "limited_token_mode", "token_limit", @@ -248,7 +248,7 @@ class AgentsRepository: return False values: dict = {} for col, val in filtered.items(): - if col in ("tools", "json_schema", "models", "shared_metadata", "config"): + if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"): values[col] = val elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"): values[col] = str(val) if val else None @@ -294,7 +294,7 @@ class AgentsRepository: allowed = { "name", "description", "agent_type", "status", "key", "slug", "source_id", "chunks", "retriever", "prompt_id", "tools", "json_schema", "models", - "config", + "config", "resource_sponsors", "default_model_id", "folder_id", "workflow_id", "extra_source_ids", "image", "limited_token_mode", "token_limit", @@ -309,7 +309,7 @@ class AgentsRepository: values: dict = {} for col, val in filtered.items(): - if col in ("tools", "json_schema", "models", "shared_metadata", "config"): + if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"): # See note in create(): JSONB columns receive Python # objects, the type processor handles serialisation. values[col] = val diff --git a/docsgpt/storage/db/repositories/workflows.py b/docsgpt/storage/db/repositories/workflows.py index b27b45e4..8f3563bf 100644 --- a/docsgpt/storage/db/repositories/workflows.py +++ b/docsgpt/storage/db/repositories/workflows.py @@ -9,6 +9,7 @@ Covers CRUD on workflow metadata: from __future__ import annotations import copy +import json from typing import Optional from sqlalchemy import Connection, text @@ -85,12 +86,17 @@ class WorkflowsRepository: return [row_to_dict(r) for r in result.fetchall()] def update(self, workflow_id: str, user_id: str, fields: dict) -> bool: - allowed = {"name", "description", "current_graph_version"} + allowed = {"name", "description", "current_graph_version", "resource_sponsors"} filtered = {k: v for k, v in fields.items() if k in allowed} if not filtered: return False - set_parts = [f"{col} = :{col}" for col in filtered] + set_parts = [ + f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}" + for col in filtered + ] + if "resource_sponsors" in filtered: + filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {}) set_parts.append("updated_at = now()") params = {**filtered, "id": workflow_id, "user_id": user_id} diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 82c412cb..b2c8b657 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -983,7 +983,11 @@ the current page size, so picking a bigger size never hides the way back ("Page 1 of 1", chevrons off). Counts and numbers in the UI format on the app language: `formatCount` (`utils/dateTimeUtils`, `Intl` on `intlLocale()`), never `toLocaleString()` or a raw `{{count}}`; plural keys get the number as `count` -and the formatted text as its own param. The one exception is a headline +and the formatted text as `formatted` (`{{formatted}} members`), a key with no +plural passes only `formatted`, and a count is never baked into the key name +(`memberCountOne`). `jp` and `zhTW` aren't language tags i18next knows, so it +plurals them by English rules: every plural key there has an `_one` form too +(the same text as `_other`), or a count of 1 falls back to English. The one exception is a headline total that can reach tens of thousands (the chunk count in the Chunks byline and embedded toolbar): it may use `abbreviateCount` (`components/chunkUtils`, `Intl` compact notation: "12K", "12 тыс.", "1.2万"; grouped digits where the diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index fc82c0e3..d5ef020f 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -91,7 +91,8 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice'; import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar'; import AgentPreviewSheet from './components/AgentPreviewSheet'; import SectionShell from '../navigation/SectionShell'; -import { Agent, ToolSummary } from './types'; +import SponsoredResourcesNotice from './components/SponsoredResourcesNotice'; +import { Agent, ResourceSponsor, ToolSummary } from './types'; import WorkflowBuilder from './workflow/WorkflowBuilder'; import type { Model } from '../models/types'; @@ -282,6 +283,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { [agent.source_details, sourceDocs, t], ); + // Name of a tool/source/prompt that runs with an editor's access, from the + // same owner-agnostic details the pickers show. + const resolveSponsoredName = useCallback( + (sponsor: ResourceSponsor): string => { + if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id); + if (sponsor.type === 'prompt') { + return ( + prompts.find((prompt) => prompt.id === sponsor.id)?.name || + agent.prompt_name || + t('agents.form.sponsors.unknownItem') + ); + } + const tool = selectedTools.find((item) => item.id === sponsor.id); + return tool + ? getToolDisplayName(tool) + : t('agents.form.sponsors.unknownItem'); + }, + [agent.prompt_name, prompts, resolveSourceLabel, selectedTools, t], + ); + const sourceItems = useMemo(() => { const items = toSourcePickerItems( sourceDocs, @@ -1185,6 +1206,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { {t('agents.form.buttons.add')} + diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx new file mode 100644 index 00000000..fa3a510f --- /dev/null +++ b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx @@ -0,0 +1,103 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import type { Agent, ResourceSponsor } from '../types'; +import SponsoredResourcesNotice from './SponsoredResourcesNotice'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const baseAgent: Agent = { + name: 'A', + description: 'd', + image: '', + source: '', + chunks: '6', + retriever: '', + prompt_id: 'default', + tools: [], + agent_type: 'classic', + status: 'published', +}; + +const sponsor = (over: Partial): ResourceSponsor => ({ + type: 'tool', + id: 't1', + user_id: 'bob', + label: 'bob@example.com', + active: true, + ...over, +}); + +describe('SponsoredResourcesNotice', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (agent: Agent) => { + await act(async () => { + root.render( + `name-${s.id}`} + />, + ); + }); + }; + + const alerts = () => + Array.from(container.querySelectorAll('[data-slot="alert"]')); + + it('renders nothing for the owner with no sponsored items', async () => { + await render(baseAgent); + expect(container.innerHTML).toBe(''); + }); + + it('tells an editor their own items run with their access', async () => { + await render({ ...baseAgent, access: 'editor', allowed_actions: ['edit'] }); + expect(alerts()).toHaveLength(1); + expect(container.textContent).toContain('agents.form.sponsors.attachNote'); + expect(container.textContent).not.toContain('publicLinkNote'); + }); + + it('adds the public-link warning when the agent has a link', async () => { + await render({ + ...baseAgent, + shared: true, + access: 'editor', + allowed_actions: ['edit'], + }); + expect(container.textContent).toContain( + 'agents.form.sponsors.publicLinkNote', + ); + }); + + it('does not show the attach note to a viewer', async () => { + await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] }); + expect(container.innerHTML).toBe(''); + }); + + it('splits running and no-longer-running items', async () => { + await render({ + ...baseAgent, + resource_sponsors: [ + sponsor({ id: 't1' }), + sponsor({ id: 's1', type: 'source', active: false }), + ], + }); + const [running, stopped] = alerts(); + expect(running.getAttribute('role')).toBe('note'); + expect(running.textContent).toContain('agents.form.sponsors.addedBy'); + expect(stopped.getAttribute('data-variant')).toBe('warning'); + expect(stopped.textContent).toContain('agents.form.sponsors.unavailable'); + }); +}); diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.tsx new file mode 100644 index 00000000..79d8fd26 --- /dev/null +++ b/frontend/src/agents/components/SponsoredResourcesNotice.tsx @@ -0,0 +1,91 @@ +import { Info, TriangleAlert, UserRound } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; + +import { can, isOwner } from '../../utils/accessUtils'; +import type { Agent, ResourceSponsor } from '../types'; + +type SponsoredResourcesNoticeProps = { + agent: Agent; + /** Display name of a sponsored tool, source or prompt. */ + resolveName: (sponsor: ResourceSponsor) => string; +}; + +/** Sponsored items grouped by the person who added them, in first-seen order. */ +function groupByPerson(sponsors: ResourceSponsor[]) { + const groups = new Map(); + for (const sponsor of sponsors) { + const key = sponsor.label || sponsor.user_id; + groups.set(key, [...(groups.get(key) ?? []), sponsor]); + } + return Array.from(groups.entries()); +} + +/** + * Tools, sources and prompts on the agent that run with an editor's access + * rather than the owner's (`resource_sponsors` from GET /api/get_agent). + * + * An editor sees up front that what they attach runs with their access for + * everyone who uses the agent. Everyone who may view the config sees who + * added what, and which items no longer run because that person lost access. + */ +export default function SponsoredResourcesNotice({ + agent, + resolveName, +}: SponsoredResourcesNoticeProps) { + const { t } = useTranslation(); + const sponsors = agent.resource_sponsors ?? []; + const showAttachNote = !isOwner(agent) && can(agent, 'edit'); + const active = groupByPerson(sponsors.filter((s) => s.active)); + const inactive = groupByPerson(sponsors.filter((s) => !s.active)); + + if (!showAttachNote && sponsors.length === 0) return null; + + const names = (items: ResourceSponsor[]) => + items.map((item) => resolveName(item)).join(', '); + + return ( +
+ {showAttachNote && ( + + + + {t('agents.form.sponsors.attachNote')} + {agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''} + + + )} + {active.length > 0 && ( + + + + {active.map(([person, items]) => ( +

+ {t('agents.form.sponsors.addedBy', { + person, + names: names(items), + })} +

+ ))} +
+
+ )} + {inactive.length > 0 && ( + + + + {inactive.map(([person, items]) => ( +

+ {t('agents.form.sponsors.unavailable', { + person, + names: names(items), + })} +

+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 8fa98a32..8b9cd82b 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -6,6 +6,17 @@ export type ToolSummary = { display_name: string; }; +/** A tool, source or prompt that runs with the editor's access who added it. */ +export type ResourceSponsor = { + type: 'tool' | 'source' | 'prompt'; + id: string; + user_id: string; + /** The person's email when on file, else their user id. */ + label: string; + /** False once that person can no longer edit the agent or use the item. */ + active: boolean; +}; + export type Agent = { id?: string; name: string; @@ -41,6 +52,9 @@ export type Agent = { // names for resources the caller themselves owns). prompt_name?: string | null; source_details?: { id: string; name: string | null }[]; + // Resources the owner can't use that run as the editor who attached them + // (GET /api/get_agent, callers who may view the config). + resource_sponsors?: ResourceSponsor[]; created_at?: string; updated_at?: string; last_used_at?: string; diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index fe774f70..4232dde4 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -676,9 +676,10 @@ "createTeamError": "Team konnte nicht erstellt werden.", "noTeams": "Noch keine Teams.", "noDescription": "Keine Beschreibung", - "memberCountOne": "{{count}} Mitglied", - "memberCountOther": "{{count}} Mitglieder", - "sharedCount": "{{count}} geteilt", + "memberCount_one": "{{formatted}} Mitglied", + "memberCount_other": "{{formatted}} Mitglieder", + "sharedCount_one": "{{formatted}} geteilt", + "sharedCount_other": "{{formatted}} geteilt", "loadError": "Teams konnten nicht geladen werden.", "roleAdmin": "Admin", "roleMember": "Mitglied", @@ -746,8 +747,9 @@ "viaTeam": "über {{team}}", "removeAccess": "Zugriff entfernen", "access": "Zugriff", - "showAll": "Alle {{count}} anzeigen", - "andMore": "und {{count}} weitere", + "showAll": "Alle {{formatted}} anzeigen", + "andMore_one": "und {{formatted}} weitere", + "andMore_other": "und {{formatted}} weitere", "back": "Zurück", "allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}", "searchAccess": "Personen und Teams suchen…", @@ -887,7 +889,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} Bearbeiter", + "badgeWithEditors_one": "{{level}} · +{{formatted}} Bearbeiter", + "badgeWithEditors_other": "{{level}} · +{{formatted}} Bearbeiter", "meta": "{{type}} · {{owner}}", "filterLabel": "Geteilte Ressourcen filtern", "filter": { @@ -2007,6 +2010,13 @@ }, "status": { "published": "Veröffentlicht" + }, + "sponsors": { + "attachNote": "Tools, Quellen und Prompts, die du aus deiner eigenen Bibliothek hinzufügst, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff.", + "publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.", + "addedBy": "Hinzugefügt von {{person}}: {{names}}", + "unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.", + "unknownItem": "Unbenanntes Element" } }, "logs": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9a6f4768..1d651430 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -682,9 +682,10 @@ "createTeamError": "Could not create team.", "noTeams": "No teams yet.", "noDescription": "No description", - "memberCountOne": "{{count}} member", - "memberCountOther": "{{count}} members", - "sharedCount": "{{count}} shared", + "memberCount_one": "{{formatted}} member", + "memberCount_other": "{{formatted}} members", + "sharedCount_one": "{{formatted}} shared", + "sharedCount_other": "{{formatted}} shared", "loadError": "Failed to load teams.", "roleAdmin": "admin", "roleMember": "member", @@ -752,8 +753,9 @@ "viaTeam": "via {{team}}", "removeAccess": "Remove access", "access": "Access", - "showAll": "Show all {{count}}", - "andMore": "and {{count}} more", + "showAll": "Show all {{formatted}}", + "andMore_one": "and {{formatted}} more", + "andMore_other": "and {{formatted}} more", "back": "Back", "allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}", "searchAccess": "Search people and teams…", @@ -893,7 +895,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} Editor", + "badgeWithEditors_one": "{{level}} · +{{formatted}} editor", + "badgeWithEditors_other": "{{level}} · +{{formatted}} editors", "meta": "{{type}} · {{owner}}", "filterLabel": "Filter shared resources", "filter": { @@ -2025,6 +2028,13 @@ }, "status": { "published": "Published" + }, + "sponsors": { + "attachNote": "Tools, sources and prompts you add from your own library run with your access for everyone who uses this agent.", + "publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.", + "addedBy": "Added by {{person}}: {{names}}", + "unavailable": "Not running: {{names}}. {{person}} added them and no longer has access. Remove them or choose ones the owner can use.", + "unknownItem": "Unnamed item" } }, "logs": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 4d539806..fec0c542 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -676,9 +676,10 @@ "createTeamError": "No se pudo crear el equipo.", "noTeams": "Aún no hay equipos.", "noDescription": "Sin descripción", - "memberCountOne": "{{count}} miembro", - "memberCountOther": "{{count}} miembros", - "sharedCount": "{{count}} compartidos", + "memberCount_one": "{{formatted}} miembro", + "memberCount_other": "{{formatted}} miembros", + "sharedCount_one": "{{formatted}} compartido", + "sharedCount_other": "{{formatted}} compartidos", "loadError": "No se pudieron cargar los equipos.", "roleAdmin": "admin", "roleMember": "miembro", @@ -746,8 +747,9 @@ "viaTeam": "vía {{team}}", "removeAccess": "Quitar acceso", "access": "Acceso", - "showAll": "Ver los {{count}}", - "andMore": "y {{count}} más", + "showAll": "Ver los {{formatted}}", + "andMore_one": "y {{formatted}} más", + "andMore_other": "y {{formatted}} más", "back": "Atrás", "allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}", "searchAccess": "Buscar personas y equipos…", @@ -887,7 +889,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} Editor", + "badgeWithEditors_one": "{{level}} · +{{formatted}} editor", + "badgeWithEditors_other": "{{level}} · +{{formatted}} editores", "meta": "{{type}} · {{owner}}", "filterLabel": "Filtrar recursos compartidos", "filter": { @@ -2007,6 +2010,13 @@ }, "status": { "published": "Publicado" + }, + "sponsors": { + "attachNote": "Las herramientas, fuentes y prompts que añadas desde tu propia biblioteca se ejecutan con tu acceso para todos los que usen este agente.", + "publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.", + "addedBy": "Añadido por {{person}}: {{names}}", + "unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no tiene acceso. Quítalos o elige otros que el propietario pueda usar.", + "unknownItem": "Elemento sin nombre" } }, "logs": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index b2d5ac3a..e065eb0d 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -176,6 +176,7 @@ } }, "badge": "リビングWiki", + "byline_one": "{{pages}} ページ · {{tokens}} トークン", "byline_other": "{{pages}} ページ · {{tokens}} トークン", "explainer": "エージェントは作業しながらこれらのページを読み、書き換えます。", "explainerEditable": "エージェントは作業しながらこれらのページを読み、書き換えます。このソースを使うエージェントは誰でも編集できます。", @@ -234,6 +235,7 @@ "byConnections": "(接続数順)", "typeFilter": "種類で絞り込む", "otherTypes": "その他: {{types}}", + "otherTypesMore_one": "他{{formatted}}件", "otherTypesMore_other": "他{{formatted}}件", "untyped": "種類なし", "loadFailed": "グラフを読み込めませんでした。", @@ -245,6 +247,7 @@ "showMore": "もっと見る", "showLess": "表示を減らす", "relationships": "関係", + "relationshipsCapped_one": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。", "relationshipsCapped_other": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。", "noRelationships": "このエンティティには関係がありません。", "relatedTo": "関連", @@ -253,8 +256,11 @@ "sourceChunks": "ソースチャンク", "showInGraph": "グラフで表示", "allTypes": "すべての種類", + "chunkCount_one": "{{formatted}}件のチャンク", "chunkCount_other": "{{formatted}}件のチャンク", + "entityCount_one": "{{formatted}}件のエンティティ", "entityCount_other": "{{formatted}}件のエンティティ", + "relationshipCount_one": "{{formatted}}件の関係", "relationshipCount_other": "{{formatted}}件の関係", "chunk": "チャンク", "chunkMeta": "{{file}} · {{tokens}} トークン", @@ -420,9 +426,11 @@ "chunkTitleHint": "回答がこのチャンクを引用するときの名前です。", "files": "ファイル", "filesLoadError": "ファイルを読み込めませんでした", + "filesByline_one": "{{files}} ファイル · {{tokens}} トークン", "filesByline_other": "{{files}} ファイル · {{tokens}} トークン", "filterFiles": "ファイルを絞り込む", "searchChunks": "チャンクを検索", + "chunkCount_one": "{{formatted}} 件のチャンク", "chunkCount_other": "{{formatted}} 件のチャンク", "editor": { "write": "編集", @@ -667,9 +675,10 @@ "createTeamError": "チームを作成できませんでした。", "noTeams": "チームはまだありません。", "noDescription": "説明なし", - "memberCountOne": "{{count}}人のメンバー", - "memberCountOther": "{{count}}人のメンバー", - "sharedCount": "{{count}}件の共有", + "memberCount_one": "{{formatted}}人のメンバー", + "memberCount_other": "{{formatted}}人のメンバー", + "sharedCount_one": "{{formatted}}件の共有", + "sharedCount_other": "{{formatted}}件の共有", "loadError": "チームの読み込みに失敗しました。", "roleAdmin": "管理者", "roleMember": "メンバー", @@ -737,8 +746,9 @@ "viaTeam": "{{team}}経由", "removeAccess": "アクセス権を削除", "access": "アクセス", - "showAll": "すべて表示({{count}})", - "andMore": "ほか {{count}} 件", + "showAll": "すべて表示({{formatted}})", + "andMore_one": "ほか {{formatted}} 件", + "andMore_other": "ほか {{formatted}} 件", "back": "戻る", "allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}", "searchAccess": "ユーザーとチームを検索…", @@ -878,7 +888,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} 編集者", + "badgeWithEditors_one": "{{level}} · +{{formatted}} 編集者", + "badgeWithEditors_other": "{{level}} · +{{formatted}} 編集者", "meta": "{{type}} · {{owner}}", "filterLabel": "共有リソースを絞り込む", "filter": { @@ -1994,6 +2005,13 @@ }, "status": { "published": "公開済み" + }, + "sponsors": { + "attachNote": "自分のライブラリから追加したツール、ソース、プロンプトは、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。", + "publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。", + "addedBy": "{{person}} が追加: {{names}}", + "unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。", + "unknownItem": "名前のない項目" } }, "logs": { @@ -2455,6 +2473,7 @@ "empty": "ToDo はまだありません" }, "note": { + "lines_one": "{{count}} 行", "lines_other": "{{count}} 行", "empty": "空のメモ" }, diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 1949563a..85b1af45 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -718,9 +718,14 @@ "createTeamError": "Не удалось создать команду.", "noTeams": "Команд пока нет.", "noDescription": "Без описания", - "memberCountOne": "{{count}} участник", - "memberCountOther": "Участников: {{count}}", - "sharedCount": "Общих ресурсов: {{count}}", + "memberCount_one": "{{formatted}} участник", + "memberCount_few": "{{formatted}} участника", + "memberCount_many": "{{formatted}} участников", + "memberCount_other": "{{formatted}} участника", + "sharedCount_one": "{{formatted}} общий ресурс", + "sharedCount_few": "{{formatted}} общих ресурса", + "sharedCount_many": "{{formatted}} общих ресурсов", + "sharedCount_other": "{{formatted}} общих ресурса", "loadError": "Не удалось загрузить команды.", "roleAdmin": "администратор", "roleMember": "участник", @@ -788,8 +793,11 @@ "viaTeam": "через {{team}}", "removeAccess": "Убрать доступ", "access": "Доступ", - "showAll": "Показать все ({{count}})", - "andMore": "и ещё {{count}}", + "showAll": "Показать все ({{formatted}})", + "andMore_one": "и ещё {{formatted}}", + "andMore_few": "и ещё {{formatted}}", + "andMore_many": "и ещё {{formatted}}", + "andMore_other": "и ещё {{formatted}}", "back": "Назад", "allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}", "searchAccess": "Поиск людей и команд…", @@ -929,7 +937,10 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} ред.", + "badgeWithEditors_one": "{{level}} · +{{formatted}} редактор", + "badgeWithEditors_few": "{{level}} · +{{formatted}} редактора", + "badgeWithEditors_many": "{{level}} · +{{formatted}} редакторов", + "badgeWithEditors_other": "{{level}} · +{{formatted}} редактора", "meta": "{{type}} · {{owner}}", "filterLabel": "Фильтр общих ресурсов", "filter": { @@ -2071,6 +2082,13 @@ }, "status": { "published": "Опубликован" + }, + "sponsors": { + "attachNote": "Инструменты, источники и промпты, которые вы добавляете из своей библиотеки, работают с вашим доступом для всех, кто пользуется этим агентом.", + "publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.", + "addedBy": "Добавил(а) {{person}}: {{names}}", + "unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.", + "unknownItem": "Элемент без названия" } }, "logs": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index f89e7372..fc735107 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -176,6 +176,7 @@ } }, "badge": "動態 Wiki", + "byline_one": "{{pages}} 個頁面 · {{tokens}} 個 token", "byline_other": "{{pages}} 個頁面 · {{tokens}} 個 token", "explainer": "代理在工作時會閱讀並改寫這些頁面。", "explainerEditable": "代理在工作時會閱讀並改寫這些頁面;任何使用此來源的代理都可以編輯它。", @@ -234,6 +235,7 @@ "byConnections": "(依連結數)", "typeFilter": "依類型篩選", "otherTypes": "其他:{{types}}", + "otherTypesMore_one": "另外 {{formatted}} 個", "otherTypesMore_other": "另外 {{formatted}} 個", "untyped": "無類型", "loadFailed": "無法載入圖譜。", @@ -245,6 +247,7 @@ "showMore": "顯示更多", "showLess": "收合", "relationships": "關係", + "relationshipsCapped_one": "顯示 {{total}} 個關係中最強的 {{shown}} 個。", "relationshipsCapped_other": "顯示 {{total}} 個關係中最強的 {{shown}} 個。", "noRelationships": "此實體沒有關係。", "relatedTo": "相關", @@ -253,8 +256,11 @@ "sourceChunks": "來源區塊", "showInGraph": "在圖譜中顯示", "allTypes": "所有類型", + "chunkCount_one": "{{formatted}} 個區塊", "chunkCount_other": "{{formatted}} 個區塊", + "entityCount_one": "{{formatted}} 個實體", "entityCount_other": "{{formatted}} 個實體", + "relationshipCount_one": "{{formatted}} 個關係", "relationshipCount_other": "{{formatted}} 個關係", "chunk": "區塊", "chunkMeta": "{{file}} · {{tokens}} Token", @@ -420,9 +426,11 @@ "chunkTitleHint": "回答引用此文本塊時使用的名稱。", "files": "檔案", "filesLoadError": "無法載入檔案", + "filesByline_one": "{{files}} 個檔案 · {{tokens}} Token", "filesByline_other": "{{files}} 個檔案 · {{tokens}} Token", "filterFiles": "篩選檔案", "searchChunks": "搜尋文本塊", + "chunkCount_one": "{{formatted}} 個文本塊", "chunkCount_other": "{{formatted}} 個文本塊", "editor": { "write": "編輯", @@ -667,9 +675,10 @@ "createTeamError": "無法建立團隊。", "noTeams": "尚無團隊。", "noDescription": "無描述", - "memberCountOne": "{{count}} 位成員", - "memberCountOther": "{{count}} 位成員", - "sharedCount": "已分享 {{count}} 項", + "memberCount_one": "{{formatted}} 位成員", + "memberCount_other": "{{formatted}} 位成員", + "sharedCount_one": "已分享 {{formatted}} 項", + "sharedCount_other": "已分享 {{formatted}} 項", "loadError": "載入團隊失敗。", "roleAdmin": "管理員", "roleMember": "成員", @@ -737,8 +746,9 @@ "viaTeam": "透過 {{team}}", "removeAccess": "移除存取權", "access": "存取權", - "showAll": "顯示全部 {{count}} 個", - "andMore": "還有 {{count}} 個", + "showAll": "顯示全部 {{formatted}} 個", + "andMore_one": "還有 {{formatted}} 個", + "andMore_other": "還有 {{formatted}} 個", "back": "返回", "allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}", "searchAccess": "搜尋人員和團隊…", @@ -878,7 +888,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} 編輯者", + "badgeWithEditors_one": "{{level}} · +{{formatted}} 編輯者", + "badgeWithEditors_other": "{{level}} · +{{formatted}} 編輯者", "meta": "{{type}} · {{owner}}", "filterLabel": "篩選共用資源", "filter": { @@ -1994,6 +2005,13 @@ }, "status": { "published": "已發佈" + }, + "sponsors": { + "attachNote": "你從自己的資料庫新增的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。", + "publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。", + "addedBy": "由 {{person}} 新增:{{names}}", + "unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。", + "unknownItem": "未命名項目" } }, "logs": { @@ -2455,6 +2473,7 @@ "empty": "尚無待辦事項" }, "note": { + "lines_one": "{{count}} 行", "lines_other": "{{count}} 行", "empty": "空白筆記" }, diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 8163533b..393cb5d2 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -176,6 +176,7 @@ } }, "badge": "动态 Wiki", + "byline_one": "{{pages}} 个页面 · {{tokens}} 个 token", "byline_other": "{{pages}} 个页面 · {{tokens}} 个 token", "explainer": "代理在工作时会阅读并改写这些页面。", "explainerEditable": "代理在工作时会阅读并改写这些页面;任何使用此来源的代理都可以编辑它。", @@ -234,6 +235,7 @@ "byConnections": "(按连接数)", "typeFilter": "按类型筛选", "otherTypes": "其他:{{types}}", + "otherTypesMore_one": "另外 {{formatted}} 个", "otherTypesMore_other": "另外 {{formatted}} 个", "untyped": "无类型", "loadFailed": "无法加载图谱。", @@ -245,6 +247,7 @@ "showMore": "显示更多", "showLess": "收起", "relationships": "关系", + "relationshipsCapped_one": "显示 {{total}} 个关系中最强的 {{shown}} 个。", "relationshipsCapped_other": "显示 {{total}} 个关系中最强的 {{shown}} 个。", "noRelationships": "此实体没有关系。", "relatedTo": "相关", @@ -253,8 +256,11 @@ "sourceChunks": "来源分块", "showInGraph": "在图谱中显示", "allTypes": "所有类型", + "chunkCount_one": "{{formatted}} 个分块", "chunkCount_other": "{{formatted}} 个分块", + "entityCount_one": "{{formatted}} 个实体", "entityCount_other": "{{formatted}} 个实体", + "relationshipCount_one": "{{formatted}} 个关系", "relationshipCount_other": "{{formatted}} 个关系", "chunk": "分块", "chunkMeta": "{{file}} · {{tokens}} 个令牌", @@ -420,9 +426,11 @@ "chunkTitleHint": "回答引用此文本块时使用的名称。", "files": "文件", "filesLoadError": "无法加载文件", + "filesByline_one": "{{files}} 个文件 · {{tokens}} 个令牌", "filesByline_other": "{{files}} 个文件 · {{tokens}} 个令牌", "filterFiles": "筛选文件", "searchChunks": "搜索文本块", + "chunkCount_one": "{{formatted}} 个文本块", "chunkCount_other": "{{formatted}} 个文本块", "editor": { "write": "编辑", @@ -667,9 +675,10 @@ "createTeamError": "无法创建团队。", "noTeams": "暂无团队。", "noDescription": "无描述", - "memberCountOne": "{{count}} 名成员", - "memberCountOther": "{{count}} 名成员", - "sharedCount": "{{count}} 项共享", + "memberCount_one": "{{formatted}} 名成员", + "memberCount_other": "{{formatted}} 名成员", + "sharedCount_one": "{{formatted}} 项共享", + "sharedCount_other": "{{formatted}} 项共享", "loadError": "加载团队失败。", "roleAdmin": "管理员", "roleMember": "成员", @@ -737,8 +746,9 @@ "viaTeam": "通过 {{team}}", "removeAccess": "移除访问权限", "access": "访问权限", - "showAll": "显示全部 {{count}} 个", - "andMore": "还有 {{count}} 个", + "showAll": "显示全部 {{formatted}} 个", + "andMore_one": "还有 {{formatted}} 个", + "andMore_other": "还有 {{formatted}} 个", "back": "返回", "allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}", "searchAccess": "搜索人员和团队…", @@ -878,7 +888,8 @@ } }, "sharedList": { - "badgeWithEditors": "{{level}} · +{{count}} 编辑者", + "badgeWithEditors_one": "{{level}} · +{{formatted}} 编辑者", + "badgeWithEditors_other": "{{level}} · +{{formatted}} 编辑者", "meta": "{{type}} · {{owner}}", "filterLabel": "筛选共享资源", "filter": { @@ -1994,6 +2005,13 @@ }, "status": { "published": "已发布" + }, + "sponsors": { + "attachNote": "你从自己的资料库添加的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。", + "publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。", + "addedBy": "由 {{person}} 添加:{{names}}", + "unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。", + "unknownItem": "未命名项目" } }, "logs": { @@ -2455,6 +2473,7 @@ "empty": "暂无待办事项" }, "note": { + "lines_one": "{{count}} 行", "lines_other": "{{count}} 行", "empty": "空笔记" }, diff --git a/frontend/src/settings/Teams.test.tsx b/frontend/src/settings/Teams.test.tsx index b9d4ed2c..218403a7 100644 --- a/frontend/src/settings/Teams.test.tsx +++ b/frontend/src/settings/Teams.test.tsx @@ -223,7 +223,7 @@ describe('Teams page', () => { expect(rows).toHaveLength(2); expect(rows[0].textContent).toContain('Key Accounts'); expect(rows[0].textContent).toContain( - 'settings.teams.sharedList.badgeWithEditors(level=viewer,count=#1)', + 'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1,formatted=#1)', ); expect(rows[0].textContent).toContain( 'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)', diff --git a/frontend/src/settings/Teams.tsx b/frontend/src/settings/Teams.tsx index b897d190..ddc8f203 100644 --- a/frontend/src/settings/Teams.tsx +++ b/frontend/src/settings/Teams.tsx @@ -697,7 +697,8 @@ export default function Teams() { ? t('settings.teams.sharedList.badgeWithEditors', { interpolation: { escapeValue: false }, level, - count: formatCount(memberEditors), + count: memberEditors, + formatted: formatCount(memberEditors), }) : level; } @@ -863,17 +864,16 @@ export default function Teams() { - {t( - (team.member_count ?? 0) === 1 - ? 'settings.teams.memberCountOne' - : 'settings.teams.memberCountOther', - { count: formatCount(team.member_count ?? 0) }, - )} + {t('settings.teams.memberCount', { + count: team.member_count ?? 0, + formatted: formatCount(team.member_count ?? 0), + })} · {t('settings.teams.sharedCount', { - count: formatCount(team.shared_count ?? 0), + count: team.shared_count ?? 0, + formatted: formatCount(team.shared_count ?? 0), })} diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx index ef6d30ac..e2c4fb77 100644 --- a/frontend/src/teams/ShareToTeamModal.test.tsx +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -259,10 +259,12 @@ describe('ShareToTeamModal', () => { expect(rows).toHaveLength(4); // Most recent first: user-7, user-6, user-5. expect(rows[1].textContent).toContain('user-7'); - expect(text()).toContain('settings.teams.share.andMore(count=#5)'); + expect(text()).toContain( + 'settings.teams.share.andMore(count=5,formatted=#5)', + ); const showAll = buttonByText('settings.teams.share.showAll'); expect(showAll?.textContent).toContain( - 'settings.teams.share.showAll(count=#8)', + 'settings.teams.share.showAll(formatted=#8)', ); expect(showAll?.getAttribute('data-variant')).toBe('link'); expect(body().querySelector('.max-h-72')).toBeNull(); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index ac875b3c..6ead1a0f 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -934,7 +934,7 @@ export default function ShareToTeamModal({ onClick={openAllStep} > {t('settings.teams.share.showAll', { - count: formatCount(shares.length), + formatted: formatCount(shares.length), })} @@ -948,7 +948,8 @@ export default function ShareToTeamModal({ {isLongList && (

{t('settings.teams.share.andMore', { - count: formatCount(shares.length - previewShares.length), + count: shares.length - previewShares.length, + formatted: formatCount(shares.length - previewShares.length), })}

)} diff --git a/tests/api/test_agent_team_sharing.py b/tests/api/test_agent_team_sharing.py index 624bfcc0..9a264ca4 100644 --- a/tests/api/test_agent_team_sharing.py +++ b/tests/api/test_agent_team_sharing.py @@ -195,6 +195,9 @@ def _update_patches(sub, repo, team_access, can_access_mock): patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo), *_access_patches(sub, repo, team_access), patch("docsgpt.api.user.agents.routes.can_access", can_access_mock), + # Sponsor bookkeeping queries the (mocked) connection; covered with a + # real database in tests/api/user/test_resource_sponsors.py. + patch("docsgpt.api.user.agents.routes.sponsors_after_save", return_value={}), ] diff --git a/tests/api/user/test_resource_sponsors.py b/tests/api/user/test_resource_sponsors.py new file mode 100644 index 00000000..7dff59f6 --- /dev/null +++ b/tests/api/user/test_resource_sponsors.py @@ -0,0 +1,459 @@ +"""Resource sponsors: an editor's own tool/prompt/source runs inside the owner's agent. + +An agent runs as its owner, so a resource the owner can't use used to be +dropped at run time even though an editor attached it. The editor who +attaches it becomes its sponsor (``resource_sponsors``); the resource then +runs while the sponsor can still edit the agent and still use the resource. +Uses real repositories on ``pg_conn``. +""" + +from __future__ import annotations + +import uuid +from contextlib import ExitStack, contextmanager +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from flask import Flask + +from docsgpt.api.user.resource_access import ( + active_sponsor, + agent_refs, + ref_principal, + set_settings, + sponsor_key, + sponsors_after_save, +) +from docsgpt.storage.db.repositories.agents import AgentsRepository +from docsgpt.storage.db.repositories.prompts import PromptsRepository +from docsgpt.storage.db.repositories.sources import SourcesRepository +from docsgpt.storage.db.repositories.team_members import TeamMembersRepository +from docsgpt.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, +) +from docsgpt.storage.db.repositories.teams import TeamsRepository +from docsgpt.storage.db.repositories.user_tools import UserToolsRepository +from docsgpt.storage.db.repositories.workflows import WorkflowsRepository + +OWNER, EDITOR, VIEWER, OTHER = "sp-owner", "sp-editor", "sp-viewer", "sp-other" + +_DB_MODULES = ( + "docsgpt.api.user.agents.routes", + "docsgpt.api.user.workflows.routes", + "docsgpt.api.user.base", + "docsgpt.agents.tool_executor", + "docsgpt.api.answer.services.stream_processor", + "docsgpt.agents.workflows.workflow_engine", + "docsgpt.storage.db.session", +) + + +@pytest.fixture +def app(): + return Flask(__name__) + + +@contextmanager +def _patch_db(conn): + import importlib + + @contextmanager + def _yield(): + yield conn + + with ExitStack() as stack: + for mod_name in _DB_MODULES: + mod = importlib.import_module(mod_name) + for attr in ("db_session", "db_readonly"): + if hasattr(mod, attr): + stack.enter_context(patch(f"{mod_name}.{attr}", _yield)) + yield + + +def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()): + kwargs = {"method": method.upper()} + if json is not None: + kwargs["json"] = json + with _patch_db(conn), app.test_request_context(path, **kwargs): + from flask import request + + request.decoded_token = {"sub": user} + return getattr(resource_cls(), method.lower())(*args) + + +def _status(resp) -> int: + return resp[1] if isinstance(resp, tuple) else resp.status_code + + +def _share_agent(conn, agent_id): + """Share the agent with a team: EDITOR as editor, VIEWER as viewer.""" + team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", OWNER) + tid = str(team["id"]) + for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")): + TeamMembersRepository(conn).add_member(tid, member) + TeamResourceGrantsRepository(conn).grant( + tid, "agent", agent_id, OWNER, OWNER, access_level=level, target_user_id=member + ) + return tid + + +def _agent(conn, **extra): + extra.setdefault("agent_type", "classic") + extra.setdefault("chunks", 6) + row = AgentsRepository(conn).create( + OWNER, "Shared", "published", description="d", key=f"k-{uuid.uuid4().hex}", **extra + ) + agent_id = str(row["id"]) + team_id = _share_agent(conn, agent_id) + return agent_id, team_id + + +def _row(conn, agent_id): + return AgentsRepository(conn).get_by_id(agent_id) + + +def _editor_resources(conn): + tool = str(UserToolsRepository(conn).create(EDITOR, "api_tool")["id"]) + prompt = str(PromptsRepository(conn).create(EDITOR, "mine", "Editor prompt")["id"]) + source = str(SourcesRepository(conn).create("editor-src", user_id=EDITOR)["id"]) + return tool, prompt, source + + +def _put(app, conn, agent_id, user, body): + from docsgpt.api.user.agents.routes import UpdateAgent + + return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}", user, + json=body, args=(agent_id,)) + + +# --------------------------------------------------------------------------- +# sponsors_after_save +# --------------------------------------------------------------------------- + + +class TestSponsorsAfterSave: + def test_owner_usable_refs_need_no_sponsor(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"]) + out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, [("tool", tool)]) + assert out == {} + + def test_editor_resources_are_sponsored_by_editor(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, prompt, source = _editor_resources(pg_conn) + refs = [("tool", tool), ("prompt", prompt), ("source", source)] + out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs) + assert out == {sponsor_key(t, i): EDITOR for t, i in refs} + + def test_builtin_tool_and_preset_ids_are_skipped(self, pg_conn): + from docsgpt.agents.default_tools import loaded_builtin_agent_tools, synthesize_builtin_agent_tool + + agent_id, _ = _agent(pg_conn) + builtin = next(iter(loaded_builtin_agent_tools()), None) + refs = [("prompt", "default")] + if builtin: + refs.append(("tool", str(synthesize_builtin_agent_tool(builtin)["id"]))) + assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs) == {} + + def test_owner_save_keeps_editor_sponsor(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)]) + assert out == {sponsor_key("tool", tool): EDITOR} + + def test_removed_ref_drops_out(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, []) == {} + + def test_another_editor_takes_over_when_sponsor_lost_access(self, pg_conn): + agent_id, team_id = _agent(pg_conn) + # A tool shared with the whole team: both editors may use it in their agents. + tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"]) + TeamMembersRepository(pg_conn).add_member(team_id, OTHER) + TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OTHER, OTHER) + TeamResourceGrantsRepository(pg_conn).grant( + team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=OTHER + ) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR) + + out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OTHER, [("tool", tool)]) + assert out == {sponsor_key("tool", tool): OTHER} + + def test_dead_sponsor_record_kept_when_nobody_qualifies(self, pg_conn): + agent_id, team_id = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR) + out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)]) + assert out == {sponsor_key("tool", tool): EDITOR} + + +# --------------------------------------------------------------------------- +# active_sponsor / ref_principal (live checks) +# --------------------------------------------------------------------------- + + +class TestActiveSponsor: + def _sponsored(self, pg_conn): + agent_id, team_id = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + return agent_id, team_id, tool + + def test_live_sponsor_is_principal(self, pg_conn): + agent_id, _, tool = self._sponsored(pg_conn) + agent = _row(pg_conn, agent_id) + assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == EDITOR + assert ref_principal(pg_conn, "agent", agent, "tool", tool) == EDITOR + + def test_owner_wins_over_sponsor(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"]) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == OWNER + + def test_sponsor_removed_from_agent_stops(self, pg_conn): + agent_id, team_id, tool = self._sponsored(pg_conn) + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR) + assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None + + def test_sponsor_demoted_to_viewer_stops(self, pg_conn): + agent_id, team_id, tool = self._sponsored(pg_conn) + TeamResourceGrantsRepository(pg_conn).grant( + team_id, "agent", agent_id, OWNER, OWNER, access_level="viewer", target_user_id=EDITOR + ) + assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None + + def test_sponsor_losing_the_resource_stops(self, pg_conn): + agent_id, team_id = _agent(pg_conn) + tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"]) + TeamResourceGrantsRepository(pg_conn).grant( + team_id, "tool", tool, OTHER, OTHER, target_user_id=EDITOR + ) + AgentsRepository(pg_conn).update_by_id( + agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}} + ) + assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == EDITOR + set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OTHER) + assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None + + def test_no_record_no_sponsor(self, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None + + +# --------------------------------------------------------------------------- +# update_agent / get_agent +# --------------------------------------------------------------------------- + + +class TestAgentRoutes: + def test_editor_attaching_own_resources_records_sponsor(self, app, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, prompt, source = _editor_resources(pg_conn) + resp = _put(app, pg_conn, agent_id, EDITOR, + {"tools": [tool], "prompt_id": prompt, "source": source}) + assert _status(resp) == 200 + row = _row(pg_conn, agent_id) + assert row["resource_sponsors"] == { + sponsor_key("tool", tool): EDITOR, + sponsor_key("prompt", prompt): EDITOR, + sponsor_key("source", source): EDITOR, + } + assert set(agent_refs(row)) >= {("tool", tool), ("prompt", prompt), ("source", source)} + + def test_owner_save_does_not_wipe_sponsors(self, app, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200 + assert _status(_put(app, pg_conn, agent_id, OWNER, {"name": "Renamed", "tools": [tool]})) == 200 + assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR} + + def test_detaching_clears_sponsor(self, app, pg_conn): + agent_id, _ = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200 + assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": []})) == 200 + assert _row(pg_conn, agent_id)["resource_sponsors"] == {} + + def test_get_agent_lists_sponsors_for_editors_only(self, app, pg_conn): + from docsgpt.api.user.agents.routes import GetAgent + from docsgpt.storage.db.repositories.users import UsersRepository + + agent_id, team_id = _agent(pg_conn) + tool, _, _ = _editor_resources(pg_conn) + UsersRepository(pg_conn).upsert(EDITOR, email="bob@example.com") + assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200 + + path = f"/api/get_agent?id={agent_id}" + owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json() + assert owner_view["resource_sponsors"] == [ + {"type": "tool", "id": tool, "user_id": EDITOR, "label": "bob@example.com", "active": True} + ] + viewer_view = _call(app, pg_conn, GetAgent, "get", path, VIEWER).get_json() + assert viewer_view["resource_sponsors"] == [] + + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR) + owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json() + assert owner_view["resource_sponsors"][0]["active"] is False + + +# --------------------------------------------------------------------------- +# run time +# --------------------------------------------------------------------------- + + +class TestRunTime: + def _sponsored_agent(self, app, pg_conn): + agent_id, team_id = _agent(pg_conn) + tool, prompt, source = _editor_resources(pg_conn) + resp = _put(app, pg_conn, agent_id, EDITOR, + {"tools": [tool], "prompt_id": prompt, "source": source}) + assert _status(resp) == 200 + return _row(pg_conn, agent_id), team_id, tool, prompt, source + + def test_agent_key_toolset_includes_sponsored_tool(self, app, pg_conn): + from docsgpt.agents.tool_executor import ToolExecutor + + agent, team_id, tool, _, _ = self._sponsored_agent(app, pg_conn) + with _patch_db(pg_conn): + tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"]) + assert tool in tools + # The row is the tool owner's, so its credentials decrypt as the editor. + assert tools[tool]["user_id"] == EDITOR + + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR) + with _patch_db(pg_conn): + tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"]) + assert tool not in tools + + def test_sponsored_prompt_renders(self, app, pg_conn): + from docsgpt.api.answer.services.stream_processor import authorized_prompt_id + + agent, team_id, _, prompt, _ = self._sponsored_agent(app, pg_conn) + with _patch_db(pg_conn): + assert authorized_prompt_id(prompt, OWNER, agent) == prompt + assert authorized_prompt_id(prompt, OWNER) == "default" + TeamResourceGrantsRepository(pg_conn).revoke( + team_id, "agent", str(agent["id"]), target_user_id=EDITOR + ) + assert authorized_prompt_id(prompt, OWNER, agent) == "default" + + def test_agent_sources_include_sponsored_and_team_shared(self, app, pg_conn): + from docsgpt.api.answer.services.stream_processor import StreamProcessor + + agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn) + # A source shared with the owner by a team (not owned): used to be + # dropped by an owner-scoped read. + shared = str(SourcesRepository(pg_conn).create("shared-src", user_id=OTHER)["id"]) + if not TeamMembersRepository(pg_conn).is_member(OWNER, team_id): + TeamMembersRepository(pg_conn).add_member(team_id, OWNER) + TeamResourceGrantsRepository(pg_conn).grant(team_id, "source", shared, OTHER, OTHER) + AgentsRepository(pg_conn).update_by_id(str(agent["id"]), {"extra_source_ids": [shared]}) + + processor = StreamProcessor.__new__(StreamProcessor) + with _patch_db(pg_conn): + data = processor._get_data_from_api_key(agent["key"]) + assert [s["id"] for s in data["sources"]] == [source, shared] + + def test_search_service_authorizes_sponsored_source(self, app, pg_conn): + from docsgpt.services.search_service import _authorized_source_ids + + agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn) + stranger_src = str(SourcesRepository(pg_conn).create("x", user_id="sp-stranger")["id"]) + assert _authorized_source_ids(pg_conn, agent, [source, stranger_src]) == [source] + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR) + assert _authorized_source_ids(pg_conn, agent, [source]) == [] + + +# --------------------------------------------------------------------------- +# workflows +# --------------------------------------------------------------------------- + + +def _wf_body(tool=None, source=None): + agent_cfg = {"agent_type": "classic", "system_prompt": "s", "tools": [tool] if tool else [], + "sources": [source] if source else []} + return { + "name": "WF", + "description": "d", + "nodes": [ + {"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}}, + {"id": "a1", "type": "agent", "title": "A", "position": {"x": 50, "y": 0}, + "data": {"config": agent_cfg}}, + {"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}}, + ], + "edges": [ + {"id": "e1", "source": "start1", "target": "a1"}, + {"id": "e2", "source": "a1", "target": "end1"}, + ], + } + + +class TestWorkflows: + def _setup(self, pg_conn): + wf = WorkflowsRepository(pg_conn).create(OWNER, "wf") + _, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"])) + return str(wf["id"]), team_id + + def _put(self, app, pg_conn, wid, user, body): + from docsgpt.api.user.workflows.routes import WorkflowDetail + + return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user, + json=body, args=(wid,)) + + def test_editor_node_resources_are_sponsored(self, app, pg_conn): + wid, _ = self._setup(pg_conn) + tool, _, source = _editor_resources(pg_conn) + resp = self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source)) + assert _status(resp) == 200, resp.get_json() + row = WorkflowsRepository(pg_conn).get_by_id(wid) + assert row["resource_sponsors"] == { + sponsor_key("tool", tool): EDITOR, + sponsor_key("source", source): EDITOR, + } + # Removing the node's refs clears them. + assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body())) == 200 + assert WorkflowsRepository(pg_conn).get_by_id(wid)["resource_sponsors"] == {} + + def test_engine_resolves_sponsored_node_refs(self, app, pg_conn): + from docsgpt.agents.workflows.workflow_engine import WorkflowEngine + + wid, team_id = self._setup(pg_conn) + tool, _, source = _editor_resources(pg_conn) + assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))) == 200 + + engine = WorkflowEngine.__new__(WorkflowEngine) + engine.agent = SimpleNamespace( + workflow_row=WorkflowsRepository(pg_conn).get_by_id(wid), + _resolve_owner_id=lambda: OWNER, + user=OWNER, + decoded_token={"sub": OWNER}, + ) + with _patch_db(pg_conn): + assert engine._node_tool_principals([tool]) == {tool: EDITOR} + assert engine._authorized_node_sources([source]) == [source] + agent_id = str(pg_conn.exec_driver_sql( + f"SELECT id FROM agents WHERE workflow_id = '{wid}'" + ).scalar()) + TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR) + assert engine._node_tool_principals([tool]) == {} + assert engine._authorized_node_sources([source]) == [] diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index a1cbe6f7..d51532e4 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -31,7 +31,7 @@ def _fake_db_readonly(agent_data): yield MagicMock() with patch( - "docsgpt.api.user.team_sharing.can_access", return_value=True + "docsgpt.api.user.resource_access.ref_principal", return_value="owner" ), patch( "docsgpt.services.search_service.db_readonly", _yield_conn ), patch( @@ -254,24 +254,24 @@ class TestSourceAuthorization: return {"id": "agent-1", "user_id": "owner", "extra_source_ids": [], **kw} def test_readable_sources_pass_through(self, monkeypatch): - import docsgpt.api.user.team_sharing as ts + import docsgpt.api.user.resource_access as ra - monkeypatch.setattr(ts, "can_access", lambda *a, **k: True) + monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner") agent = self._agent(extra_source_ids=["s1", "s2"]) assert _authorized_source_ids(None, agent, ["s1", "s2"]) == ["s1", "s2"] def test_foreign_source_is_dropped(self, monkeypatch): - import docsgpt.api.user.team_sharing as ts + import docsgpt.api.user.resource_access as ra - monkeypatch.setattr(ts, "can_access", lambda conn, k, sid, u: sid == "mine") + monkeypatch.setattr(ra, "ref_principal", lambda conn, h, agent, k, sid: "owner" if sid == "mine" else None) agent = self._agent() assert _authorized_source_ids(None, agent, ["mine", "theirs"]) == ["mine"] def test_team_shared_source_is_kept(self, monkeypatch): """A grant is legitimate access; only unreadable ids are dropped.""" - import docsgpt.api.user.team_sharing as ts + import docsgpt.api.user.resource_access as ra - monkeypatch.setattr(ts, "can_access", lambda *a, **k: True) + monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner") agent = self._agent(user_id="grantee") assert _authorized_source_ids(None, agent, ["shared"]) == ["shared"] @@ -280,12 +280,12 @@ class TestSourceAuthorization: assert _authorized_source_ids(None, agent, ["s1"]) == [] def test_check_failure_fails_closed(self, monkeypatch): - import docsgpt.api.user.team_sharing as ts + import docsgpt.api.user.resource_access as ra def _boom(*a, **k): raise RuntimeError("db down") - monkeypatch.setattr(ts, "can_access", _boom) + monkeypatch.setattr(ra, "ref_principal", _boom) assert _authorized_source_ids(None, self._agent(), ["s1"]) == [] diff --git a/tests/tracing/test_entry_points.py b/tests/tracing/test_entry_points.py index 5a613892..218432d2 100644 --- a/tests/tracing/test_entry_points.py +++ b/tests/tracing/test_entry_points.py @@ -149,7 +149,7 @@ def _search_env(agent): store = MagicMock() store.search.return_value = [{"text": "hit", "metadata": {"title": "T", "source": "s"}}] - with patch("docsgpt.api.user.team_sharing.can_access", return_value=True), \ + with patch("docsgpt.api.user.resource_access.ref_principal", return_value="owner"), \ patch("docsgpt.services.search_service.db_readonly", _conn), \ patch("docsgpt.services.search_service.AgentsRepository", return_value=repo), \ patch(