mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 07:11:56 +00:00
MCP rollback for connectors
This commit is contained in:
1 parent
0622883ce4
commit
68c1e12b6f
29 files changed
+650
-160
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1053,7 +1053,7 @@
|
||||
"password": "パスワード"
|
||||
},
|
||||
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
|
||||
"sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
|
||||
"sharedOAuthOwnerOnly": "このサーバーの接続を変更したり、サインインを再接続したりできるのはオーナーだけです。名前の変更とアクションの編集は引き続き行えます。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}は必須です",
|
||||
|
||||
@@ -1104,7 +1104,7 @@
|
||||
"password": "пароль"
|
||||
},
|
||||
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
|
||||
"sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
|
||||
"sharedOAuthOwnerOnly": "Изменить подключение этого сервера или переподключить вход может только владелец. Вы по-прежнему можете переименовать его и изменять его действия."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "Поле «{{field}}» обязательно",
|
||||
|
||||
@@ -1053,7 +1053,7 @@
|
||||
"password": "密碼"
|
||||
},
|
||||
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
|
||||
"sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
|
||||
"sharedOAuthOwnerOnly": "只有擁有者可以變更此伺服器的連線或重新連結其登入。你仍然可以重新命名並編輯其動作。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}為必填項",
|
||||
|
||||
@@ -1053,7 +1053,7 @@
|
||||
"password": "密码"
|
||||
},
|
||||
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
|
||||
"sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
|
||||
"sharedOAuthOwnerOnly": "只有所有者可以更改此服务器的连接或重新连接其登录。你仍然可以重命名它并编辑其操作。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}为必填项",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 +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 +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
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
declare const _default: import("vite").UserConfigFnObject;
|
||||
export default _default;
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user