MCP rollback for connectors

This commit is contained in:
Pavel committed 2026-09-29 14:48:18 +04:00
1 parent 0622883ce4
commit 68c1e12b6f
29 files changed
+650 -160

No files matched your search

+4
View File
@@ -158,6 +158,10 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/pnpm-debug.log*
frontend/lerna-debug.log*
# tsc build output (composite tsconfig.node.json); Vite would load vite.config.js before .ts
frontend/*.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
# Keep frontend utility helpers tracked (overrides global lib/ ignore)
!frontend/src/lib/
+42 -28
View File
@@ -25,7 +25,7 @@ from docsgpt.core.json_schema_utils import (
normalize_json_schema_payload,
)
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.api.user.resource_access import (
AccessDenied,
agent_refs,
@@ -221,17 +221,17 @@ def _denied(err: AccessDenied):
return make_response(jsonify({"success": False, "message": err.message}), err.status)
def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
"""Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
def _tool_attachable(conn, tool_id: str, caller: str) -> bool:
"""Whether ``caller`` may newly attach ``tool_id`` to an agent.
Builtin synthetic ids belong to no one. Otherwise the tool must be the
agent owner's (it runs with the owner's credentials) or reach the caller
with ``use_in_own``.
Builtin synthetic ids belong to no one. Otherwise the caller must own the
tool or reach it with ``use_in_own``. The agent owner owning it is not
enough: an editor could otherwise wire the owner's private tool (run with
the owner's credentials) into an agent the editor controls.
Args:
conn: Open database connection.
tool_id: The tool id being attached.
owner_id: The agent's owner.
caller: The user making the change.
Returns:
@@ -240,29 +240,26 @@ def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
tid = str(tool_id)
if is_synthesized_tool_id(tid):
return True
if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
return True
ra = resolve(conn, "tool", tid, caller)
return ra is not None and ra.can("use_in_own")
def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
"""Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
def _ref_attachable(conn, resource_type: str, resource_id: str, caller: str) -> bool:
"""Whether ``caller`` may newly reference a source/prompt from an agent.
Like tools, the caller's own access counts, not the agent owner's.
Args:
conn: Open database connection.
resource_type: ``source`` or ``prompt``.
resource_id: The referenced id.
owner_id: The agent's owner.
caller: The user making the change.
Returns:
True when the agent owner owns it or the caller can ``use`` it.
True when the caller owns it or a team grant reaches them.
"""
if not resource_id:
return True
if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
return True
return can_access(conn, resource_type, str(resource_id), caller)
@@ -825,11 +822,11 @@ class CreateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
extra_source_ids.append(src)
extra_source_ids.append(canonical_uuid(src))
else:
source_value = data.get("source", "")
if source_value and source_value != "default" and looks_like_uuid(source_value):
source_id_resolved = source_value
source_id_resolved = canonical_uuid(source_value)
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
@@ -854,13 +851,17 @@ class CreateAgent(Resource):
# Tools run with the agent owner's credentials: attach only your
# own, or ones a team lets you use in your agents.
for tid in data.get("tools") or []:
if not _tool_attachable(conn, tid, user, user):
if not _tool_attachable(conn, tid, user):
return make_response(
jsonify({"success": False, "message": "Tool not accessible"}),
403,
)
build_data = dict(data)
if isinstance(data.get("tools"), list):
build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
if looks_like_uuid(data.get("prompt_id")):
build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
build_data["source_id"] = source_id_resolved
@@ -1087,7 +1088,7 @@ class UpdateAgent(Resource):
if not source_id or source_id == "default":
update_fields["source_id"] = None
elif looks_like_uuid(source_id):
update_fields["source_id"] = source_id
update_fields["source_id"] = canonical_uuid(source_id)
else:
return _reject(
f"Invalid source ID format: {source_id}", user, field
@@ -1102,7 +1103,7 @@ class UpdateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
valid.append(src)
valid.append(canonical_uuid(src))
else:
return _reject(
f"Invalid source ID in list: {src}", user, field
@@ -1130,7 +1131,7 @@ class UpdateAgent(Resource):
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return _reject("Tools must be a list", user, field)
update_fields["tools"] = tools_list
update_fields["tools"] = [canonical_uuid(t) for t in tools_list]
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
@@ -1250,13 +1251,25 @@ class UpdateAgent(Resource):
)
if wf_err:
return wf_err
# Only the owner may point the agent at a different
# workflow: editing rights on this agent extend to
# the graph it uses, so swapping in the workflow of
# another of the owner's agents would hand that
# graph to the editor.
current_workflow = existing_agent.get("workflow_id")
if is_team_editor and pg_workflow_id != (
str(current_workflow) if current_workflow else None
):
return _denied(
AccessDenied(403, "Only the owner can change this agent's workflow")
)
update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
update_fields["prompt_id"] = None
elif looks_like_uuid(value):
update_fields["prompt_id"] = value
update_fields["prompt_id"] = canonical_uuid(value)
else:
return _reject(f"Invalid prompt_id: {value}", user, field)
elif field == "allow_system_prompt_override":
@@ -1358,7 +1371,7 @@ class UpdateAgent(Resource):
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
if not _ref_attachable(conn, "source", sid, owner_id, user):
if not _ref_attachable(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
@@ -1366,16 +1379,17 @@ class UpdateAgent(Resource):
if (
new_prompt_id
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
and not _ref_attachable(conn, "prompt", new_prompt_id, user)
):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}), 403
)
# Tools run with the OWNER's credentials (the agent-key path
# resolves and decrypts them as the owner), so a newly attached
# tool must be the owner's or reach the caller with
# ``use_in_own``. Tools already on the agent stay. Builtin
# synthetic ids belong to no one and are always allowed.
# tool must be the caller's own or reach them with
# ``use_in_own`` -- the owner owning it is not enough. Tools
# already on the agent stay. Builtin synthetic ids belong to no
# one and are always allowed.
if "tools" in update_fields:
existing_tools = {
str(t) for t in (existing_agent.get("tools") or [])
@@ -1383,7 +1397,7 @@ class UpdateAgent(Resource):
for tid in update_fields["tools"] or []:
if str(tid) in existing_tools:
continue
if not _tool_attachable(conn, tid, owner_id, user):
if not _tool_attachable(conn, tid, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
+13 -6
View File
@@ -27,7 +27,7 @@ from typing import Iterable, Optional
from sqlalchemy import Connection, text
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
@@ -73,7 +73,7 @@ ACTIONS: dict[str, dict[str, str]] = {
"use": "viewer", # see it and run it inside the owner's shared agents
"use_in_own": "viewer", # add it to my own agents and chats
"edit": "editor", # name, action descriptions, parameters, approval
"edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
"edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
@@ -186,14 +186,18 @@ def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
"""The resource's switches, defaults filled in."""
return settings_many(conn, resource_type, [resource_id])[resource_id]
rid = canonical_uuid(str(resource_id))
return settings_many(conn, resource_type, [rid])[rid]
def settings_many(
conn: Connection, resource_type: str, resource_ids: Iterable[str]
) -> dict[str, dict[str, bool]]:
"""``resource_id -> switches`` for many resources in one query."""
ids = [str(r) for r in resource_ids]
"""``resource_id -> switches`` for many resources in one query.
Keys are canonical (lowercase) UUIDs, the form Postgres returns.
"""
ids = [canonical_uuid(str(r)) for r in resource_ids]
out = {rid: default_settings(resource_type) for rid in ids}
uuids = [rid for rid in ids if looks_like_uuid(rid)]
if not uuids:
@@ -298,7 +302,10 @@ def resolve(
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None or not resource_id or not user_id:
return None
owned = repo_cls(conn).get_any(str(resource_id), user_id)
# Postgres matches any casing but returns lowercase; canonicalise so the
# switch lookup (keyed by the returned id) can't miss.
resource_id = canonical_uuid(str(resource_id))
owned = repo_cls(conn).get_any(resource_id, user_id)
if owned is not None:
rid = str(owned.get("id") or resource_id)
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
+29 -53
View File
@@ -13,6 +13,7 @@ from docsgpt.api.user.tools.routes import (
_CREDENTIALS_FOR_NEW_SERVER,
_MCP_CREDENTIAL_AUTH_TYPES,
_mcp_host_changed,
check_oauth_mcp_owner_only,
denied_response,
transform_actions,
)
@@ -83,9 +84,6 @@ def _validate_mcp_server_url(config: dict) -> None:
raise ValueError(f"Invalid server URL: {exc}") from exc
_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
def _existing_mcp_context(tool_id, user, config):
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
@@ -93,14 +91,16 @@ def _existing_mcp_context(tool_id, user, config):
the caller needs ``edit_credentials`` on that tool and everything runs as
its owner. Stored secrets are write-only, so an empty secret field reuses
the stored one while the host is unchanged; a new host never inherits them.
A server that is or would become OAuth is the owner's alone (its tokens
are the owner's sign-in).
Returns:
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
response (404 / 400) to return as is.
Raises:
AccessDenied: the caller can't see the tool (404) or can't change
its credentials (403).
AccessDenied: the caller can't see the tool (404), can't change its
credentials (403), or isn't the owner of an OAuth server (403).
"""
auth_credentials = _extract_auth_credentials(config)
if not tool_id:
@@ -113,6 +113,7 @@ def _existing_mcp_context(tool_id, user, config):
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
)
existing_config = existing_doc.get("config") or {}
check_oauth_mcp_owner_only(ra, existing_config, config)
moved = _mcp_host_changed(config, existing_config)
auth_type = config.get("auth_type", "none")
new_secret_keys = set(auth_credentials) - {"api_key_header"}
@@ -170,13 +171,7 @@ class TestMCPServerConfig(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
_existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
if not is_owner and config.get("auth_type") == "oauth":
# An OAuth flow would store tokens under the editor's account
# (and its popup event goes to that account), not the owner's.
return make_response(
jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
)
_existing_doc, owner_id, _is_owner, _moved, auth_credentials = ctx
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
@@ -279,50 +274,16 @@ class MCPServerSave(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
existing_config = (existing_doc or {}).get("config") or {}
existing_doc, owner_id, is_owner, _moved, merged_credentials = ctx
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = merged_credentials
keep_actions = False
if auth_type == "oauth":
if config.get("oauth_task_id"):
if not is_owner:
# The OAuth flow stores tokens under the account that
# ran it; reconnecting as the owner is owner-only.
return make_response(
jsonify({
"success": False,
"message": _ONLY_OWNER_RECONNECTS,
}),
403,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(
config["oauth_task_id"], user
)
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif (
existing_doc is not None
and not moved
and existing_config.get("auth_type") == "oauth"
):
# Editing an already-connected server: keep its tools.
actions_metadata = existing_doc.get("actions") or []
keep_actions = True
else:
# Only the owner reaches here for an existing server (see
# ``_existing_mcp_context``), and every OAuth save needs the
# sign-in they just completed.
if not config.get("oauth_task_id"):
return make_response(
jsonify(
{
@@ -332,6 +293,22 @@ class MCPServerSave(Resource):
),
400,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(
config["oauth_task_id"], user
)
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif auth_type == "none" or merged_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
@@ -356,8 +333,7 @@ class MCPServerSave(Resource):
"redirect_uri",
]:
storage_config.pop(field, None)
# Kept actions already carry the owner's on/off and approval flags.
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
transformed_actions = transform_actions(actions_metadata)
display_name = data["displayName"]
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
+34
View File
@@ -350,6 +350,37 @@ def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
def check_oauth_mcp_owner_only(
ra: ResourceAccess, existing_config: Optional[dict], new_config: Optional[dict]
) -> None:
"""Keep a shared OAuth MCP server's connection with its owner.
MCP OAuth tokens are looked up by owner + server URL and a shared server
runs as its owner, so a grantee who moved an OAuth server, switched a
server to OAuth, or re-ran its sign-in would be using the owner's account
somewhere the owner never chose. Until connectors own OAuth accounts, any
connection change on a server that is (or would become) OAuth is
owner-only.
Args:
ra: The caller's access to the tool.
existing_config: The stored ``config``.
new_config: The incoming ``config``.
Raises:
AccessDenied: 403 when a non-owner touches an OAuth server's config.
"""
if ra.access == "owner":
return
configs = [c if isinstance(c, dict) else {} for c in (existing_config, new_config)]
auth_types = {c.get("auth_type") for c in configs}
if "oauth" in auth_types:
raise AccessDenied(403, SHARED_OAUTH_OWNER_ONLY)
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
"""Validate-free merge of an incoming config with the stored one, as the owner.
@@ -776,6 +807,8 @@ class UpdateTool(Resource):
if "config" in data:
tool_name = tool_doc.get("name", data.get("name"))
existing_config = tool_doc.get("config", {}) or {}
if tool_name == "mcp_tool":
check_oauth_mcp_owner_only(ra, existing_config, data["config"])
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
data["config"], existing_config
):
@@ -873,6 +906,7 @@ class UpdateToolConfig(Resource):
tool_name = tool_doc.get("name")
if tool_name == "mcp_tool":
check_oauth_mcp_owner_only(ra, tool_doc.get("config"), data["config"])
server_url = (data["config"].get("server_url") or "").strip()
if server_url:
try:
+45
View File
@@ -12,6 +12,7 @@ from docsgpt.agents.workflows.cel_evaluator import (
)
from docsgpt.api.user.resource_access import (
AccessDenied,
can_use_ref,
resolve,
sponsor_details,
sponsors_after_save,
@@ -124,6 +125,40 @@ def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
return refs
def _new_node_ref_denied(
conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
) -> Optional[AccessDenied]:
"""403 for the first node tool/source ``caller`` newly adds but can't use.
A workflow runs as its owner, so an editor saving the owner's graph must
not reference the owner's private tools or sources: the caller's own
access counts (``use_in_own`` for a tool, ``use`` for a source), not the
owner's. Refs already in the stored graph stay, like an agent's.
Args:
conn: Open database connection.
previous_nodes: The stored graph's nodes, in builder shape.
new_nodes: The nodes being saved.
caller: The editor saving.
Returns:
An :class:`AccessDenied` to return, or None when every new ref is fine.
"""
from docsgpt.agents.default_tools import is_synthesized_tool_id
existing = set(_node_refs(previous_nodes))
for resource_type, resource_id in _node_refs(new_nodes):
if (resource_type, resource_id) in existing:
continue
if resource_type == "tool" and is_synthesized_tool_id(resource_id):
continue
if resource_type == "source" and resource_id == "default":
continue
if not can_use_ref(conn, resource_type, resource_id, caller):
return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
return None
def _denied(err: AccessDenied):
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
return make_response(
@@ -639,6 +674,16 @@ class WorkflowDetail(Resource):
nodes_data = normalize_agent_node_json_schemas(nodes_data)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
if acting != user_id:
previous_nodes = [
serialize_node(n)
for n in WorkflowNodesRepository(conn).find_by_version(
pg_workflow_id, current_graph_version,
)
]
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
if denied is not None:
return _denied(denied)
next_graph_version = current_graph_version + 1
_write_graph(
+18
View File
@@ -35,6 +35,24 @@ def looks_like_uuid(value: Any) -> bool:
return isinstance(value, str) and bool(_UUID_RE.match(value))
def canonical_uuid(value: Any) -> Any:
"""The lowercase canonical form of a UUID string; anything else unchanged.
Postgres accepts any casing on ``CAST(... AS uuid)`` but returns the
lowercase form, so an id used as a dict key or stored in a JSON/array
column must be canonical to match what the database hands back.
Args:
value: A candidate id.
Returns:
``str(UUID(value))`` for a UUID, else ``value`` as given.
"""
if isinstance(value, UUID):
return str(value)
return str(UUID(value)) if looks_like_uuid(value) else value
def row_to_dict(row: Any) -> dict:
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
+1 -1
View File
@@ -1054,7 +1054,7 @@
"password": "Passwort"
},
"savedKeyHint": "Ein Schlüssel ist gespeichert. Leer lassen, um ihn zu behalten (nur solange der Server unverändert ist).",
"sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Anmeldung neu verbinden. Du kannst das Tool umbenennen, aber weder Server noch Konto ändern."
"sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Verbindung dieses Servers ändern oder die Anmeldung neu verbinden. Du kannst ihn weiterhin umbenennen und seine Aktionen bearbeiten."
},
"configErrors": {
"required": "{{field}} ist erforderlich",
+1 -1
View File
@@ -1060,7 +1060,7 @@
"password": "password"
},
"savedKeyHint": "A key is saved. Leave empty to keep it (only while the server is unchanged).",
"sharedOAuthOwnerOnly": "Only the owner can reconnect its sign-in, so you can rename it but not change its server or account."
"sharedOAuthOwnerOnly": "Only the owner can change this server's connection or reconnect its sign-in. You can still rename it and edit its actions."
},
"configErrors": {
"required": "{{field}} is required",
+1 -1
View File
@@ -1054,7 +1054,7 @@
"password": "contraseña"
},
"savedKeyHint": "Hay una clave guardada. Déjalo vacío para conservarla (solo mientras el servidor no cambie).",
"sharedOAuthOwnerOnly": "Solo el propietario puede volver a conectar su inicio de sesión, así que puedes cambiarle el nombre, pero no su servidor ni su cuenta."
"sharedOAuthOwnerOnly": "Solo el propietario puede cambiar la conexión de este servidor o volver a conectar su inicio de sesión. Aún puedes cambiarle el nombre y editar sus acciones."
},
"configErrors": {
"required": "{{field}} es obligatorio",
+1 -1
View File
@@ -1053,7 +1053,7 @@
"password": "パスワード"
},
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
"sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
"sharedOAuthOwnerOnly": "このサーバーの接続を変更したり、サインインを再接続したりできるのはオーナーだけです。名前の変更とアクションの編集は引き続き行えます。"
},
"configErrors": {
"required": "{{field}}は必須です",
+1 -1
View File
@@ -1104,7 +1104,7 @@
"password": "пароль"
},
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
"sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
"sharedOAuthOwnerOnly": "Изменить подключение этого сервера или переподключить вход может только владелец. Вы по-прежнему можете переименовать его и изменять его действия."
},
"configErrors": {
"required": "Поле «{{field}}» обязательно",
+1 -1
View File
@@ -1053,7 +1053,7 @@
"password": "密碼"
},
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
"sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
"sharedOAuthOwnerOnly": "只有擁有者可以變更此伺服器的連線或重新連結其登入。你仍然可以重新命名並編輯其動作。"
},
"configErrors": {
"required": "{{field}}為必填項",
+1 -1
View File
@@ -1053,7 +1053,7 @@
"password": "密码"
},
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
"sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
"sharedOAuthOwnerOnly": "只有所有者可以更改此服务器的连接或重新连接其登录。你仍然可以重命名它并编辑其操作。"
},
"configErrors": {
"required": "{{field}}为必填项",
+3 -5
View File
@@ -184,8 +184,7 @@ describe('MCPServerModal', () => {
expect(text()).toContain('Invalid server URL');
});
it('lets an editor rename an OAuth tool without reconnecting it', async () => {
saveMCPServer.mockReturnValue(json({ success: true }));
it('keeps a shared OAuth server read-only for an editor', async () => {
await render({
access: 'editor',
owner_label: 'Lena',
@@ -196,10 +195,9 @@ describe('MCPServerModal', () => {
expect(urlInput().disabled).toBe(true);
expect(button('settings.tools.mcp.testConnection')).toBeUndefined();
const save = button('settings.tools.mcp.save');
expect(save.disabled).toBe(false);
expect(save.disabled).toBe(true);
await act(async () => save.click());
expect(saveMCPServer).toHaveBeenCalledTimes(1);
expect(testMCPConnection).not.toHaveBeenCalled();
expect(saveMCPServer).not.toHaveBeenCalled();
});
it('keeps OAuth reconnect for the owner', async () => {
+4 -3
View File
@@ -114,8 +114,9 @@ export default function MCPServerModal({
// A tool shared with the caller (an editor reconnecting the owner's
// server): its saved secrets stay hidden and a new entry replaces them.
const isShared = !!server?.access && server.access !== 'owner';
// Only the owner can re-run an OAuth sign-in (the tokens are theirs), so a
// teammate may rename an OAuth tool but not change its server or account.
// Only the owner can change or re-run an OAuth server's sign-in (the tokens
// are theirs), so the modal is read-only for a teammate. The Tools menu
// doesn't offer it to them; this guards any other way in.
const oauthOwnerOnly = isShared && server?.auth_type === 'oauth';
const savedSecret = SECRET_FIELDS[formData.auth_type];
const hasSavedSecret =
@@ -652,7 +653,7 @@ export default function MCPServerModal({
submitLabel={t('settings.tools.mcp.save')}
onSubmit={handleSave}
pending={loading}
disabled={!saveActive && !oauthOwnerOnly}
disabled={!saveActive || oauthOwnerOnly}
/>
}
>
+57
View File
@@ -183,6 +183,63 @@ describe('ToolConfig', () => {
expect(alert?.className).toContain('text-destructive');
});
describe('a shared MCP server', () => {
const mcpTool = (authType: string) =>
({
id: 'mcp-1',
name: 'mcp_tool',
displayName: 'MCP',
description: '',
status: true,
access: 'editor',
allowed_actions: ['edit', 'edit_credentials', 'use', 'use_in_own'],
config: {
server_url: 'https://mcp.example.com/mcp',
auth_type: authType,
},
configRequirements: {
server_url: { type: 'string', label: 'Server URL', secret: false },
auth_type: { type: 'string', label: 'Auth', secret: false },
},
actions: [],
}) as unknown as UserToolType;
const rename = async () => {
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="settings.tools.customNamePlaceholder"]',
);
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
setter?.call(name, 'Renamed');
name?.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => {
buttonByText('settings.tools.save')?.click();
});
};
it('locks the connection of an OAuth server and saves without it', async () => {
updateTool.mockResolvedValue({ ok: true });
await render(mcpTool('oauth'));
expect(container.querySelector('fieldset')?.disabled).toBe(true);
await rename();
expect(updateTool).toHaveBeenCalledTimes(1);
expect(updateTool.mock.calls[0][0]).not.toHaveProperty('config');
expect(updateTool.mock.calls[0][0].customName).toBe('Renamed');
});
it('still lets an editor change a non-OAuth server', async () => {
updateTool.mockResolvedValue({ ok: true });
await render(mcpTool('bearer'));
expect(container.querySelector('fieldset')?.disabled).toBe(false);
await rename();
expect(updateTool.mock.calls[0][0]).toHaveProperty('config');
});
});
it('renders the API tool header actions as outline-primary pills', async () => {
await render(apiTool);
for (const label of [
+16 -3
View File
@@ -41,6 +41,7 @@ import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import { getMethodBadgeVariant } from '../utils/httpMethodColors';
import { can } from '../utils/accessUtils';
import { isSharedOAuthMcp } from '../utils/toolUtils';
import { areObjectsEqual } from '../utils/objectUtils';
import { cn, focusRing } from '@/lib/utils';
import { APIActionType, APIToolType, UserToolType } from './types';
@@ -121,7 +122,10 @@ export default function ToolConfig({
>(new Set());
const { t } = useTranslation();
const canEdit = can(tool, 'edit');
const canEditCredentials = can(tool, 'edit_credentials');
// A shared OAuth server's connection stays with its owner (the backend
// refuses it), so its fields lock like credentials the caller can't change.
const sharedOAuth = isSharedOAuthMcp(tool);
const canEditCredentials = can(tool, 'edit_credentials') && !sharedOAuth;
// Neither: the tool opens as a read-only view with no Save.
const readOnly = !canEdit && !canEditCredentials;
const access = React.useMemo(
@@ -274,7 +278,10 @@ export default function ToolConfig({
displayName: tool.displayName,
customName: customName,
description: tool.description,
config: configToSave,
// Locked config isn't sent, so a rename or action edit still saves.
...((canEditCredentials || tool.name === 'api_tool') && {
config: configToSave,
}),
actions: 'actions' in tool ? tool.actions : [],
status: tool.status,
},
@@ -398,7 +405,13 @@ export default function ToolConfig({
</div>
{readOnly && <ViewOnlyNotice />}
{!readOnly && !canEditCredentials && (
<ViewOnlyNotice message={t('common.credentialsLockedNotice')} />
<ViewOnlyNotice
message={
sharedOAuth
? t('settings.tools.mcp.sharedOAuthOwnerOnly')
: t('common.credentialsLockedNotice')
}
/>
)}
{saveError && (
<Alert variant="destructive" className="mb-2">
+10
View File
@@ -183,6 +183,16 @@ describe('Tools', () => {
]);
});
it("hides Reconnect from an editor of an OAuth server (the sign-in is the owner's)", async () => {
const oauthConfig = { ...baseTool.config, auth_type: 'oauth' };
await render([
{ ...editorTool, config: oauthConfig },
{ ...ownTool, config: oauthConfig },
]);
expect(menuLabels('ed')).toEqual(['settings.tools.edit']);
expect(menuLabels('own')).toContain('settings.tools.reconnect');
});
it('shows only View to a viewer, which opens the config read-only', async () => {
await render([viewerTool]);
expect(menuLabels('vw')).toEqual(['settings.tools.view']);
+11 -2
View File
@@ -26,7 +26,11 @@ import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import { can, isOwner, roleOf } from '../utils/accessUtils';
import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
import {
canAddToolToOwn,
isSharedOAuthMcp,
toolInChat,
} from '../utils/toolUtils';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
import { APIToolType, UserToolType } from './types';
@@ -140,7 +144,12 @@ export default function Tools() {
variant: 'default',
},
];
if (tool.name === 'mcp_tool' && can(tool, 'edit_credentials')) {
// A shared OAuth server's sign-in is the owner's to redo.
if (
tool.name === 'mcp_tool' &&
can(tool, 'edit_credentials') &&
!isSharedOAuthMcp(tool)
) {
options.push({
icon: RefreshCw,
label: t('settings.tools.reconnect'),
+21
View File
@@ -6,6 +6,7 @@ import {
isChatPickerToolVisible,
isChatToolVisible,
isClassicAgentToolVisible,
isSharedOAuthMcp,
toolInChat,
} from './toolUtils';
@@ -88,6 +89,26 @@ describe('canAddToolToOwn', () => {
});
});
describe('isSharedOAuthMcp', () => {
const oauth = { name: 'mcp_tool', config: { auth_type: 'oauth' } };
it('is true only for an OAuth MCP server shared with the caller', () => {
expect(isSharedOAuthMcp({ ...oauth, access: 'editor' })).toBe(true);
expect(isSharedOAuthMcp({ ...oauth, access: 'owner' })).toBe(false);
expect(isSharedOAuthMcp(oauth)).toBe(false);
expect(
isSharedOAuthMcp({
name: 'mcp_tool',
access: 'editor',
config: { auth_type: 'bearer' },
}),
).toBe(false);
expect(
isSharedOAuthMcp({ ...oauth, name: 'api_tool', access: 'editor' }),
).toBe(false);
});
});
describe('isChatPickerToolVisible', () => {
it('hides shared tools the caller may not add to their own chats', () => {
expect(
+12
View File
@@ -51,6 +51,18 @@ export const toolInChat = (tool: {
export const canAddToolToOwn = (tool: AccessFields): boolean =>
isOwner(tool) || can(tool, 'use_in_own');
/**
* A team-shared MCP server that signs in with OAuth. Its connection (URL,
* auth, sign-in) is the owner's alone, since the tokens are the owner's
* account; the backend refuses any change from anyone else.
*/
export const isSharedOAuthMcp = (
tool: AccessFields & { name?: string; config?: unknown },
): boolean =>
tool.name === 'mcp_tool' &&
!isOwner(tool) &&
(tool.config as { auth_type?: string } | undefined)?.auth_type === 'oauth';
// Composer Tools picker: the chat-popup rule, minus shared tools the caller
// can't turn on for their own chats.
export const isChatPickerToolVisible = (
-1
View File
@@ -1 +0,0 @@
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2025.full.d.ts","./node_modules/vitest/optional-runtime-types.d.ts","./node_modules/tinybench/dist/index.d.ts","./node_modules/vitest/dist/chunks/config.d.cu_b-wjj.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/dist/node/chunks/modulerunnertransport.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/vitest/dist/chunks/rpc.d.da9utv4e.d.ts","./node_modules/vitest/dist/chunLine truncated
-1
View File
@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/hero.tsx","./src/navigation.tsx","./src/pagenotfound.tsx","./src/env.ts","./src/main.tsx","./src/store.ts","./src/vite-env.d.ts","./src/admin/activity.tsx","./src/admin/adminui.test.ts","./src/admin/adminui.tsx","./src/admin/admins.tsx","./src/admin/loaderror.test.tsx","./src/admin/overview.tsx","./src/admin/quotaeditor.tsx","./src/admin/quotas.tsx","./src/admin/usage.tsx","./src/admin/usagechart.test.ts","./src/admin/usagechart.tsx","./src/admin/userquotamodal.tsx","./src/admin/userusagemodal.tsx","./src/admin/users.tsx","./src/admin/index.tsx","./src/admin/quotautils.test.ts","./src/admin/quotautils.ts","./src/admin/usagechartdata.test.ts","./src/admin/usagechartdata.ts","./src/agents/agentcard.test.tsx","./src/agents/agentcard.tsx","./src/agents/agentlogs.test.tsx","./src/agents/agentlogs.tsx","./src/agents/agentpageheader.test.tsx","./src/agents/agentpageheader.tsx","./src/agents/agentpreview.tsx","./src/agents/agentrouteguard.test.tsx","./src/agents/agentrouteguard.tsx","./src/agents/agentslist.tsx","./src/agents/foldercard.tsx","./src/agents/newagent.test.tsx","./src/agents/newagent.tsx","./src/agents/sharedagent.tsx","./src/agents/sharedagentcard.test.tsx","./src/agents/sharedagentcard.tsx","./src/agents/sharedagentgate.tsx","./src/agents/agentaccess.test.ts","./src/agents/agentaccess.ts","./src/agents/agentpreviewslice.ts","./src/agents/agents.config.ts","./src/agents/index.tsx","./src/agents/paths.test.ts","./src/agents/paths.ts","./src/agents/components/agentpagetoolbar.test.tsx","./src/agents/components/agentpagetoolbar.tsx","./src/agents/components/agentpreviewsheet.test.tsx","./src/agents/components/agentpreviewsheet.tsx","./src/agents/components/agenttypemodal.test.tsx","./src/agents/components/agenttypemodal.tsx","./src/agents/components/guardrailevents.test.tsx","./src/agents/components/guardrailevents.tsx","./src/agents/components/guardrailssection.test.tsx","./src/agents/components/guardrailssection.tsx","./src/agents/components/sponsoredresourcesnotice.test.tsx","./src/agents/components/sponsoredresourcesnotice.tsx","./src/agents/hooks/useagentsearch.ts","./src/agents/hooks/useagentsfetch.ts","./src/agents/schedules/rundetaildrawer.test.tsx","./src/agents/schedules/rundetaildrawer.tsx","./src/agents/schedules/runlog.test.tsx","./src/agents/schedules/runlog.tsx","./src/agents/schedules/scheduleformmodal.tsx","./src/agents/schedules/schedulerow.test.tsx","./src/agents/schedules/schedulerow.tsx","./src/agents/schedules/schedulertoolcallcard.test.ts","./src/agents/schedules/schedulertoolcallcard.tsx","./src/agents/schedules/schedulesview.test.tsx","./src/agents/schedules/schedulesview.tsx","./src/agents/schedules/statusbadge.test.tsx","./src/agents/schedules/statusbadge.tsx","./src/agents/schedules/timezonecombobox.test.ts","./src/agents/schedules/timezonecombobox.tsx","./src/agents/schedules/cronbuilder.test.ts","./src/agents/schedules/cronbuilder.ts","./src/agents/schedules/schedulesslice.test.ts","./src/agents/schedules/schedulesslice.ts","./src/agents/types/index.ts","./src/agents/types/schedule.ts","./src/agents/types/workflow.ts","./src/agents/workflow/canvascontrols.tsx","./src/agents/workflow/nodepalette.test.tsx","./src/agents/workflow/nodepalette.tsx","./src/agents/workflow/workflowbuilder.tsx","./src/agents/workflow/workflowminimap.test.tsx","./src/agents/workflow/workflowmodelscontext.ts","./src/agents/workflow/workflowpreview.test.tsx","./src/agents/workflow/workflowpreview.tsx","./src/agents/workflow/workflowrunartifacts.test.tsx","./src/agents/workflow/workflowrunartifacts.tsx","./src/agents/workflow/codenodeconfig.test.ts","./src/agents/workflow/codenodeconfig.ts","./src/agents/workflow/documentconfig.test.ts","./src/agents/workflow/documentconfig.ts","./src/agents/workflow/nodetones.test.ts","./src/agents/workflow/nodetones.ts","./src/agents/workflow/simplecel.test.ts","./src/agents/workflow/simplecel.ts","./src/agents/workflow/workflowhelpers.test.ts","./src/agents/workflow/workflowhelpers.ts","./src/agents/workflow/workflowpreviewslice.test.ts","./src/agents/workflow/workflowpreviewslice.ts","./src/agents/workflow/components/mobileblocker.tsx","./src/agents/workflow/components/nodedocumentscontrol.tsx","./src/agents/workflow/components/prompttextarea.test.tsx","./src/agents/workflow/components/prompttextarea.tsx","./src/agents/workflow/components/workflowdetailssheet.test.tsx","./src/agents/workflow/components/workflowdetailssheet.tsx","./src/agents/workflow/hooks/useundoredo.ts","./src/agents/workflow/nodes/basenode.tsx","./src/agents/workflow/nodes/codenode.tsx","./src/agents/workflow/nodes/conditionnode.tsx","./src/agents/workflow/nodes/outputvariableline.tsx","./src/agents/workflow/nodes/setstatenode.tsx","./src/agents/workflow/nodes/index.tsx","./src/agents/workflow/panels/agentpanel.test.tsx","./src/agents/workflow/panels/agentpanel.tsx","./src/agents/workflow/panels/codepanel.tsx","./src/agents/workflow/panels/conditionpanel.test.tsx"Line truncated
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfigFnObject;
export default _default;
-41
View File
@@ -1,41 +0,0 @@
/// <reference types="vitest" />
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react(), svgr()],
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, './src'),
},
// Radix keeps its body pointer-events lock in module scope. Any second copy
// npm nests, now or after a future bump, can leave that lock stuck on <body>.
dedupe: ['@radix-ui/react-dismissable-layer'],
},
server: {
// Extra dev hosts (e.g. a tailscale name) come from VITE_ALLOWED_HOSTS in
// an untracked .env.local; machine-specific names stay out of the repo.
allowedHosts: env.VITE_ALLOWED_HOSTS
? env.VITE_ALLOWED_HOSTS.split(',')
.map((h) => h.trim())
.filter(Boolean)
: [],
// Use polling for file watching when running inside Docker.
// Native fs events do not propagate from Windows hosts into Linux
// containers, so Chokidar falls back to polling which works reliably.
watch: env.DOCKER
? { usePolling: true, interval: 300 }
: undefined,
},
test: {
environment: 'happy-dom',
globals: true,
include: ['src/**/*.test.{ts,tsx}'],
setupFiles: ['./vitest.setup.ts'],
},
};
});
+15 -3
View File
@@ -246,14 +246,22 @@ class TestUpdateAgent:
{"name": "n", "folder_id": str(folder["id"])})
assert _status(resp) == 200
def test_workflow_validated_against_owner(self, app, pg_conn):
def test_workflow_validated_against_owner_and_owner_only_to_change(self, app, pg_conn):
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
agent_id = _agent(pg_conn, agent_type="workflow")
# An editor can't point the agent at another of the owner's workflows.
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
assert _status(resp) == 403
assert _row(pg_conn, agent_id)["workflow_id"] is None
resp = self._put(app, pg_conn, agent_id, OWNER, {"workflow": str(wf["id"])})
assert _status(resp) == 200
assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
# Re-sending the current one is a plain save.
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
assert _status(resp) == 200
mine = WorkflowsRepository(pg_conn).create(EDITOR, "editor-wf")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(mine["id"])})
@@ -269,10 +277,14 @@ class TestUpdateAgent:
_team_share(pg_conn, "tool", shared_tool, owner=STRANGER)
agent_id = _agent(pg_conn)
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
assert _status(ok) == 200
# The owner owning a tool isn't enough: it must reach the editor.
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool]})
assert _status(denied) == 403
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
assert _status(denied) == 403
assert _status(self._put(app, pg_conn, agent_id, OWNER, {"tools": [owner_tool]})) == 200
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
assert _status(ok) == 200
set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
# Already attached: keeping it is fine.
+212
View File
@@ -0,0 +1,212 @@
"""What a team editor may newly reference from the owner's agent or workflow.
An agent (and its workflow) runs as its owner, so owning a resource is not
enough for an editor to wire it in: the editor must be able to use it
themselves. Otherwise an editor of one shared agent could attach the owner's
private tool (run with the owner's credentials), source or prompt, or the
workflow of another of the owner's agents and then edit that graph.
Also covers id casing: an uppercase UUID must resolve the same switches as
the canonical lowercase one. Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
import pytest
from docsgpt.api.user.resource_access import resolve, set_settings
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_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
from tests.api.user.test_resource_sponsors import (
EDITOR,
OWNER,
_agent,
_call,
_put,
_row,
_status,
_wf_body,
)
@pytest.fixture
def app():
from flask import Flask
return Flask(__name__)
def _owner_private(conn):
"""A tool, prompt and source the owner never shared."""
tool = str(UserToolsRepository(conn).create(OWNER, "api_tool")["id"])
prompt = str(PromptsRepository(conn).create(OWNER, "private", "Owner prompt")["id"])
source = str(SourcesRepository(conn).create("owner-src", user_id=OWNER)["id"])
return tool, prompt, source
def _share_tool_with_editor(conn, team_id, tool, level="viewer"):
TeamResourceGrantsRepository(conn).grant(
team_id, "tool", tool, OWNER, OWNER, access_level=level, target_user_id=EDITOR
)
class TestAgentAttach:
def test_editor_cannot_attach_owner_private_tool(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
assert _status(resp) == 403
assert _row(pg_conn, agent_id)["tools"] in (None, [])
def test_editor_cannot_attach_owner_private_source(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
_, _, source = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"sources": [source]})
assert _status(resp) == 403
def test_editor_cannot_attach_owner_private_prompt(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
_, prompt, _ = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"prompt_id": prompt})
assert _status(resp) == 403
def test_editor_may_attach_owner_tool_shared_with_them(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
assert _status(resp) == 200, resp.get_json()
row = _row(pg_conn, agent_id)
assert [str(t) for t in row["tools"]] == [tool]
# The owner owns it, so it runs as the owner: no sponsor.
assert not row.get("resource_sponsors")
def test_editor_keeps_owner_refs_already_on_agent(self, app, pg_conn):
tool, prompt, source = _owner_private(pg_conn)
agent_id, _ = _agent(pg_conn, tools=[tool], prompt_id=prompt, source_id=source)
resp = _put(
app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source},
)
assert _status(resp) == 200, resp.get_json()
def test_owner_may_attach_own_private_tool(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
assert _status(_put(app, pg_conn, agent_id, OWNER, {"tools": [tool]})) == 200
class TestAgentWorkflowSwap:
def _two_workflow_agents(self, pg_conn):
"""Agent A (shared with EDITOR) and the owner's private agent B, each with a workflow."""
wf_a = str(WorkflowsRepository(pg_conn).create(OWNER, "A")["id"])
wf_b = str(WorkflowsRepository(pg_conn).create(OWNER, "B")["id"])
agent_a, _ = _agent(pg_conn, agent_type="workflow", workflow_id=wf_a)
AgentsRepository(pg_conn).create(
OWNER, "Private B", "published", description="d", key=f"k-{uuid.uuid4().hex}",
agent_type="workflow", workflow_id=wf_b,
)
return agent_a, wf_a, wf_b
def test_editor_cannot_swap_in_another_owner_workflow(self, app, pg_conn):
agent_a, wf_a, wf_b = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_b})
assert _status(resp) == 403
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_a
def test_editor_may_resend_current_workflow(self, app, pg_conn):
agent_a, wf_a, _ = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_a})
assert _status(resp) == 200, resp.get_json()
def test_owner_may_swap_workflow(self, app, pg_conn):
agent_a, _, wf_b = self._two_workflow_agents(pg_conn)
assert _status(_put(app, pg_conn, agent_a, OWNER, {"workflow": wf_b})) == 200
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_b
class TestWorkflowNodeAttach:
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_wf(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_cannot_add_owner_private_tool_to_node(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, _ = _owner_private(pg_conn)
assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))) == 403
def test_editor_cannot_add_owner_private_source_to_node(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
_, _, source = _owner_private(pg_conn)
assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(source=source))) == 403
def test_editor_keeps_owner_node_refs_already_in_graph(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, source = _owner_private(pg_conn)
body = _wf_body(tool=tool, source=source)
assert _status(self._put_wf(app, pg_conn, wid, OWNER, body)) == 200
resp = self._put_wf(app, pg_conn, wid, EDITOR, body)
assert _status(resp) == 200, resp.get_json()
def test_editor_may_add_tool_shared_with_them(self, app, pg_conn):
wid, team_id = self._setup(pg_conn)
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
resp = self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))
assert _status(resp) == 200, resp.get_json()
class TestUppercaseIds:
def _tool_not_usable_in_own(self, pg_conn, team_id):
"""An owner tool the EDITOR only views, with ``viewers_can_use_in_agents`` off."""
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OWNER)
return tool
def test_resolve_applies_switches_to_uppercase_id(self, pg_conn):
_, team_id = _agent(pg_conn)
tool = self._tool_not_usable_in_own(pg_conn, team_id)
lower = resolve(pg_conn, "tool", tool, EDITOR)
upper = resolve(pg_conn, "tool", tool.upper(), EDITOR)
assert lower is not None and upper is not None
assert not upper.can("use_in_own")
assert upper.settings == lower.settings
assert upper.resource_id == tool
def test_uppercase_tool_id_does_not_bypass_switch_on_attach(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = self._tool_not_usable_in_own(pg_conn, team_id)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool.upper()]})
assert _status(resp) == 403
def test_attached_ids_are_stored_canonical(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _owner_private(pg_conn)
resp = _put(
app, pg_conn, agent_id, OWNER,
{"tools": [tool.upper()], "prompt_id": prompt.upper(), "sources": [source.upper()]},
)
assert _status(resp) == 200, resp.get_json()
row = _row(pg_conn, agent_id)
assert [str(t) for t in row["tools"]] == [tool]
assert str(row["prompt_id"]) == prompt
assert [str(s) for s in row["extra_source_ids"] or []] + (
[str(row["source_id"])] if row.get("source_id") else []
) == [source]
+97 -5
View File
@@ -503,17 +503,44 @@ class TestMCPSaveAccess:
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
def test_editor_oauth_edit_without_reconnect_keeps_actions(self, app, pg_conn):
def test_editor_oauth_save_without_reconnect_is_owner_only(self, app, pg_conn):
# A shared OAuth server's connection is the owner's: an editor can't
# save it, even with the same URL (the path, scopes or client could
# still change while reusing the owner's tokens).
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
row = _row(pg_conn, tool["id"])
assert row["display_name"] == "Renamed"
assert [a["name"] for a in row["actions"]] == ["old"]
assert resp.status_code == 403
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_editor_cannot_switch_oauth_server_to_other_auth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "mine"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "oauth"
def test_editor_cannot_switch_server_to_oauth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
def test_owner_oauth_save_needs_a_completed_sign_in(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
from docsgpt.api.user.tools.mcp import MCPAuthStatus
@@ -562,6 +589,12 @@ class TestMCPTestEndpointAccess:
assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
def test_editor_test_of_stored_oauth_server_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool, bearer_token="mine"))
assert resp.status_code == 403 and not cls.called
def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
@@ -569,3 +602,62 @@ class TestMCPTestEndpointAccess:
"auth_type": "oauth", "transport_type": "http"}}
resp, cls = self._test(app, pg_conn, "ed", body)
assert resp.status_code == 403 and not cls.called
# ---------------------------------------------------------------------------
# 6. Shared OAuth MCP servers: the connection is the owner's
# ---------------------------------------------------------------------------
class TestSharedOAuthMCPConfig:
"""``/api/update_tool`` and ``/api/update_tool_config`` can't move a shared
OAuth server or switch a shared server to OAuth: OAuth tokens are looked up
by owner + server URL, so either would run on the owner's sign-in
elsewhere. Connection changes on OAuth servers are the owner's."""
OTHER = {"server_url": "https://other-mcp.example.org/mcp", "auth_type": "oauth"}
def _oauth_tool(self, conn):
tool = _mcp_tool(conn, auth_type="oauth")
_share(conn, tool["id"], "ed", "editor")
return tool
def test_update_tool_config_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_config_cannot_switch_server_to_oauth(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]),
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "bearer"
def test_editor_still_renames_oauth_server_without_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "customName": "Renamed"}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
def test_owner_may_change_oauth_server_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["config"]["server_url"] == self.OTHER["server_url"]