mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 07:11:56 +00:00
Name every person on agent pages by one rule
people_named_to decides whom the edit page and the share dialog name: the reader, the holder's owner, anyone sharing a team with the reader and, for the owner, whoever sponsored something on the holder. A resource's owner, named as whom to ask or whose credentials a tool uses, also needs the reader to see that resource. Sponsors, runs_as, contact and account in resource_states and sponsors in sponsor_details all follow it; anyone else is left unnamed and the pages say someone else. A tool's run details (note, credential_mode, account, writes) come from one function and only for a tool that runs. The share dialog keys each person's own account on the per_user_account note, as the notice does, and the unused noteKey helper and its string are gone.
This commit is contained in:
1 parent
3cab8af753
commit
4b08e94be7
15 files changed
+435
-195
No files matched your search
@@ -162,7 +162,7 @@ Every tool, source and prompt on the agent runs with one person's access for eve
|
||||
| A connected tool shared as **Your account** | That account on the service | **Your Notion account**, or **dana@example.com's Notion account** for a teammate's tool |
|
||||
| A connected tool shared as **Each person's own** | The account of whoever is chatting; they connect it the first time. API and widget users run the agent as its owner, so they get the owner's account | **Each person's own Notion account (API and widget: yours)** |
|
||||
|
||||
A teammate's name is shown only to people who share a team with them; anyone else sees **Someone else's** account or credentials.
|
||||
The list and the edit page name a person only when you know them already: you, the agent's owner, anyone who shares a team with you and, when you own the agent, whoever sponsored something on it. An item's owner is named for its account or credentials, or as the person to ask about it, only when you can also see that item. Anyone else shows as someone else, for example **Someone else's access**.
|
||||
|
||||
An editor becomes a sponsor only after confirming who will reach the resource through the agent. A resource whose sponsor or owner loses access stops running and is marked **Stopped** in the list, with the reason (see [When a resource stops working](#when-a-resource-stops-working)).
|
||||
|
||||
@@ -197,7 +197,7 @@ A save that needs confirmation returns:
|
||||
}
|
||||
```
|
||||
|
||||
Owners and editors see sponsored resources on the agent's edit page and in `resource_sponsors` from `GET /api/get_agent` (and `GET /api/workflows/<id>`). Viewers get an empty list. Each entry has the resource (`key`, `type`, `id`, `name`), the sponsor (`user_id`, `label`), `state` (`active` or `inactive`), `reason` when inactive (`sponsor_cannot_edit_agent` or `sponsor_cannot_edit_resource`), and `can_confirm`, which says whether you may take an inactive one over.
|
||||
Owners and editors see sponsored resources on the agent's edit page and in `resource_sponsors` from `GET /api/get_agent` (and `GET /api/workflows/<id>`). Viewers get an empty list. Each entry has the resource (`key`, `type`, `id`, `name`), the sponsor (`user_id`, `label`, both `null` for someone you don't know; see [Sharing agents and what they use](#sharing-agents-and-what-they-use)), `state` (`active` or `inactive`), `reason` when inactive (`sponsor_cannot_edit_agent` or `sponsor_cannot_edit_resource`), and `can_confirm`, which says whether you may take an inactive one over.
|
||||
|
||||
<Callout type="warning" emoji="⚠️">
|
||||
After upgrading, resources sponsored by someone with only `viewer` access to them stop running. An editor who owns or can edit such a resource can choose **Run … with my access** on the agent's edit page to start it again.
|
||||
@@ -213,12 +213,12 @@ Every run checks each tool, source and prompt on the agent (and each tool and so
|
||||
| `owner_lost_access` | The owner can no longer use it, for example a team stopped sharing it with them. Left out of runs. | Ask its owner to share it again, choose **Run … with my access** if you may sponsor it, or remove it. |
|
||||
| `sponsor_cannot_edit_agent`, `sponsor_cannot_edit_resource` | Its sponsor lost access (see above). Left out of runs. | Choose **Run … with my access** if you may sponsor it, or remove it. |
|
||||
| `connection_needs_reconnect` | The account the tool uses was disconnected or needs signing in again. It can't run until someone signs in again. | If it is your account, choose **Reconnect**; otherwise ask the tool's owner. |
|
||||
| `connection_removed` | The tool's connection was removed but the tool was kept. It can't run until the account is connected again. | Ask the tool's owner to connect the account again, or remove it. |
|
||||
| `connection_removed` | The tool's connection was removed but the tool was kept, and it has no credentials of its own. It can't run until the account is connected again. | Ask the tool's owner to connect the account again, or remove it. |
|
||||
| `connector_disabled` | An admin turned the service off. It can't run until it is turned back on. | Ask an admin to turn it back on, or remove it. |
|
||||
|
||||
Only tools that run on their owner's account (owner mode) are checked this way. A tool shared in member mode runs on each person's own account, so the owner's account doesn't decide whether it runs: it is listed as running, with the note that each person uses their own account, and whoever runs it is asked to connect their own account when they need to.
|
||||
|
||||
**Remove** takes the item off the form; save to store it. Through the API, `GET /api/get_agent` and `GET /api/workflows/<id>` return `resource_states` to owners and editors (an empty list to everyone else): one entry per attached resource with `key`, `type`, `id`, `name`, `state` (`active` or `stopped`), `reason`, `note` (`per_user_account` for a member-mode tool), `sponsor` (`{user_id, label}`), `contact_role` and `contact`, `connection`, `can_confirm` and `can_reconnect`. `contact_role` is `resource_owner` when the resource's owner can fix it; `contact` names them only when you can see the resource yourself or they own the agent. `connection` names the service; its `id` comes only with `can_reconnect`, and the account's own name only for its owner. `runs_as` is the live sponsor a running resource runs as (`null` when it runs as the owner). A running tool also has `credential_mode` (`owner` or `member` for a connected tool, else `null`), `account` (whose saved credentials or `owner`-mode connection it uses; `user_id` and `label` are `null` when you share no team with that person), `owner_credential_writes` (its write actions on those credentials, which the API write allowlist covers) and `writes_allowed` (`false` when an admin turned off changes through its service). When anything can be taken over, the response also carries `sponsor_audience`, the same shape as `audience` above. A name is shown only for a resource that runs, that someone sponsored, that you can see yourself, or that is on an agent (agent saves check every reference). If the run state can't be worked out, the read still succeeds with an empty list.
|
||||
**Remove** takes the item off the form; save to store it. Through the API, `GET /api/get_agent` and `GET /api/workflows/<id>` return `resource_states` to owners and editors (an empty list to everyone else): one entry per attached resource with `key`, `type`, `id`, `name`, `state` (`active` or `stopped`), `reason`, `note` (`per_user_account` for a running member-mode tool), `sponsor` (`{user_id, label}`), `contact_role` and `contact`, `connection`, `can_confirm` and `can_reconnect`. `contact_role` is `resource_owner` when the resource's owner can fix it; `contact` names them (`{user_id, label}`) when you know them, and is `null` otherwise. `connection` names the service; its `id` comes only with `can_reconnect`, and the account's own name only for its owner. `runs_as` is the live sponsor a running resource runs as (`null` when it runs as the owner). A running tool also has `credential_mode` (`owner` or `member` for a connected tool, else `null`), `account` (whose saved credentials or `owner`-mode connection it uses), `owner_credential_writes` (its write actions on those credentials, which the API write allowlist covers) and `writes_allowed` (`false` when an admin turned off changes through its service). When anything can be taken over, the response also carries `sponsor_audience`, the same shape as `audience` above. People are named by the rule in [Sharing agents and what they use](#sharing-agents-and-what-they-use): `sponsor`, `runs_as` and `account` have `user_id` and `label` set to `null` for someone you don't know, and `contact` is `null`. A resource's name is shown only for a resource that runs, that someone sponsored, that you can see yourself, or that is on an agent (agent saves check every reference). If the run state can't be worked out, the read still succeeds with an empty list.
|
||||
|
||||
The state comes from the checks a run uses, so a source, prompt or tool the page shows as running is one a run uses, and one shown as stopped is left out of runs or can't run until it's fixed, as the table says. Each resource a run leaves out is logged as `resource_stopped` with the agent or workflow, the resource's type and id, and the reason.
|
||||
|
||||
|
||||
+154
-118
@@ -995,8 +995,10 @@ def sponsor_details(
|
||||
|
||||
Returns:
|
||||
list: Per sponsored resource ``{key, type, id, name, user_id, label,
|
||||
state, reason, active, can_confirm}``. ``label`` is the sponsor's
|
||||
email when on file; ``state`` is ``active`` or ``inactive``, and
|
||||
state, reason, active, can_confirm}``. ``user_id`` and ``label`` (the
|
||||
sponsor's email when on file) are both None for a sponsor the reader
|
||||
doesn't know (see :func:`people_named_to`); ``state`` is ``active``
|
||||
or ``inactive``, and
|
||||
``reason`` (None while active) is :data:`REASON_CANNOT_EDIT_HOLDER`
|
||||
or :data:`REASON_CANNOT_EDIT_RESOURCE`; ``active`` mirrors ``state``.
|
||||
``can_confirm`` says whether ``viewer`` may take an inactive one
|
||||
@@ -1005,16 +1007,8 @@ def sponsor_details(
|
||||
sponsors = holder.get("resource_sponsors") or {}
|
||||
if not sponsors:
|
||||
return []
|
||||
user_ids = sorted({u for u in sponsors.values() if u})
|
||||
labels = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
|
||||
"AND email IS NOT NULL AND email <> ''"
|
||||
),
|
||||
{"ids": user_ids},
|
||||
).fetchall()
|
||||
) if user_ids else {}
|
||||
named = {user_id for user_id, _ in people_named_to(conn, viewer, holder, [(u, None) for u in sponsors.values()])}
|
||||
labels = _user_labels(conn, named)
|
||||
entries = []
|
||||
for key, user_id in sponsors.items():
|
||||
resource_type, _, resource_id = key.partition(":")
|
||||
@@ -1036,8 +1030,7 @@ def sponsor_details(
|
||||
"type": resource_type,
|
||||
"id": resource_id,
|
||||
"name": names.get(key),
|
||||
"user_id": user_id,
|
||||
"label": labels.get(user_id) or user_id,
|
||||
**(_person(user_id, labels, user_id in named) or {"user_id": None, "label": None}),
|
||||
"state": "active" if active else "inactive",
|
||||
"reason": reason,
|
||||
"active": active,
|
||||
@@ -1117,17 +1110,34 @@ NOTE_PER_USER_ACCOUNT = "per_user_account"
|
||||
CONTACT_RESOURCE_OWNER = "resource_owner"
|
||||
|
||||
|
||||
def _connection_state(conn: Connection, tool: dict, owner: Optional[str], policies_box: list) -> tuple:
|
||||
"""``(reason, connection, note, mode, writes_allowed)`` for a tool the holder runs as ``owner``.
|
||||
# What ``resource_states`` says about how a tool runs, for anything that
|
||||
# isn't a running tool.
|
||||
_NO_RUN_DETAILS = {
|
||||
"note": None,
|
||||
"credential_mode": None,
|
||||
"account": None,
|
||||
"owner_credential_writes": [],
|
||||
"writes_allowed": True,
|
||||
}
|
||||
|
||||
|
||||
def _tool_run_state(conn: Connection, tool: dict, owner: Optional[str], policies_box: list) -> tuple:
|
||||
"""``(reason, connection, run)`` for a tool the holder runs as ``owner``.
|
||||
|
||||
Resolved the way the run resolves it. Only an owner-mode tool's account
|
||||
is judged (see ``connection_stop_reason``); a member-mode one runs on
|
||||
each caller's own account and gets :data:`NOTE_PER_USER_ACCOUNT`.
|
||||
each caller's own account.
|
||||
|
||||
``connection`` is ``(id, connector_key, name)`` for a tool with a
|
||||
connection or one that lost it, else None. ``mode`` (``owner`` or
|
||||
``member``, after any mode an admin forces) is set for a tool that has
|
||||
a connection. ``writes_allowed`` is False when an admin turned off
|
||||
changes through the tool's connector.
|
||||
connection or one that lost it, else None. ``run`` is what
|
||||
``resource_states`` says about a tool that runs, :data:`_NO_RUN_DETAILS`
|
||||
for one that doesn't: ``note`` (:data:`NOTE_PER_USER_ACCOUNT` in member
|
||||
mode), ``credential_mode`` (``owner`` or ``member`` after any mode an
|
||||
admin forces, for a tool with a connection), ``account`` (the tool's
|
||||
owner, whose saved credentials or owner-mode connection it acts with),
|
||||
the ``owner_credential_writes`` outside callers need allowlisted, and
|
||||
``writes_allowed`` (False when an admin turned off changes through its
|
||||
connector).
|
||||
"""
|
||||
from docsgpt.connectors import catalog, service
|
||||
from docsgpt.connectors.resolve import (
|
||||
@@ -1139,19 +1149,30 @@ def _connection_state(conn: Connection, tool: dict, owner: Optional[str], polici
|
||||
|
||||
if not tool.get("connection_id"):
|
||||
reason = connection_stop_reason(tool, None)
|
||||
if reason is None:
|
||||
return None, None, None, None, True
|
||||
key = (tool.get("config") or {}).get(REMOVED_CONNECTION_KEY) or None
|
||||
definition = catalog.get_definition(key) if key else catalog.definition_for_tool(tool.get("name") or "")
|
||||
connection = (None, definition.key if definition else key, definition.name if definition else None)
|
||||
return reason, connection, None, None, True
|
||||
if not policies_box:
|
||||
policies_box.append(service.load_policies(conn))
|
||||
resolved = resolve_connection(tool, owner, conn=conn, policies=policies_box[0])
|
||||
reason = connection_stop_reason(tool, resolved)
|
||||
note = NOTE_PER_USER_ACCOUNT if reason is None and resolved.mode == MODE_MEMBER else None
|
||||
connection = (resolved.connection_id, resolved.connector_key, resolved.connector_name)
|
||||
return reason, connection, note, resolved.mode, resolved.writes_allowed
|
||||
if reason is not None:
|
||||
marker = (tool.get("config") or {}).get(REMOVED_CONNECTION_KEY)
|
||||
key = marker if isinstance(marker, str) and marker else None
|
||||
definition = catalog.get_definition(key) if key else catalog.definition_for_tool(tool.get("name") or "")
|
||||
connection = (None, definition.key if definition else key, definition.name if definition else None)
|
||||
return reason, connection, dict(_NO_RUN_DETAILS)
|
||||
mode, writes_allowed, connection = None, True, None
|
||||
else:
|
||||
if not policies_box:
|
||||
policies_box.append(service.load_policies(conn))
|
||||
resolved = resolve_connection(tool, owner, conn=conn, policies=policies_box[0])
|
||||
connection = (resolved.connection_id, resolved.connector_key, resolved.connector_name)
|
||||
reason = connection_stop_reason(tool, resolved)
|
||||
if reason is not None:
|
||||
return reason, connection, dict(_NO_RUN_DETAILS)
|
||||
mode, writes_allowed = resolved.mode, resolved.writes_allowed
|
||||
member = mode == MODE_MEMBER
|
||||
return None, connection, {
|
||||
"note": NOTE_PER_USER_ACCOUNT if member else None,
|
||||
"credential_mode": mode,
|
||||
"account": tool.get("user_id") if not member and holds_owner_credentials(tool) else None,
|
||||
"owner_credential_writes": owner_credential_writes(tool) if writes_allowed else [],
|
||||
"writes_allowed": writes_allowed,
|
||||
}
|
||||
|
||||
|
||||
def _connection_payload(connection: Optional[tuple], reader_owns: bool, can_reconnect: bool) -> Optional[dict]:
|
||||
@@ -1169,50 +1190,64 @@ def _connection_payload(connection: Optional[tuple], reader_owns: bool, can_reco
|
||||
return {"id": connection_id if can_reconnect else None, "connector_key": connector_key, "name": name}
|
||||
|
||||
|
||||
def _tool_run_details(conn: Connection, tool: dict, owner: Optional[str], policies_box: list) -> tuple:
|
||||
"""``(reason, connection, note, details)`` for a tool the holder runs as ``owner``.
|
||||
def people_named_to(
|
||||
conn: Connection,
|
||||
viewer: Optional[str],
|
||||
holder: dict,
|
||||
people: Iterable[tuple[Optional[str], Optional[tuple[str, str]]]],
|
||||
) -> set:
|
||||
"""Which people an agent or workflow page may name to its reader.
|
||||
|
||||
``details`` holds what the share dialog says about a running tool:
|
||||
``credential_mode``, ``account`` (the user id whose saved credentials or
|
||||
``owner``-mode connection it acts with: the tool's owner), the
|
||||
``owner_credential_writes`` outside callers need allowlisted, and
|
||||
``writes_allowed``.
|
||||
The one rule for every person the page names (a sponsor, whom a
|
||||
resource runs as, whom to ask, whose credentials a tool uses): only
|
||||
someone the reader knows already. That is the reader, the holder's
|
||||
owner, anyone who sponsored something on the reader's own holder, and
|
||||
anyone who shares a team with the reader; a person named as the owner
|
||||
of one resource only when, besides sharing a team, the reader can see
|
||||
that resource too. Anyone else is left unnamed.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
viewer: The user reading the page.
|
||||
holder: The agent or workflow row (``user_id``, ``resource_sponsors``).
|
||||
people: ``(user_id, ref)`` pairs; ``ref`` is the ``(type, id)`` the
|
||||
person is named as the owner of, or None.
|
||||
|
||||
Returns:
|
||||
set: The ``(user_id, ref)`` pairs that may be named.
|
||||
"""
|
||||
reason, connection, note, mode, writes_allowed = _connection_state(conn, tool, owner, policies_box)
|
||||
holds = mode != "member" and holds_owner_credentials(tool)
|
||||
return reason, connection, note, {
|
||||
"credential_mode": mode,
|
||||
"account": tool.get("user_id") if holds else None,
|
||||
"owner_credential_writes": owner_credential_writes(tool) if writes_allowed else [],
|
||||
"writes_allowed": writes_allowed,
|
||||
}
|
||||
|
||||
|
||||
def _people_visible_to(conn: Connection, viewer: Optional[str], owner: Optional[str], user_ids) -> set:
|
||||
"""The ``user_ids`` the reader may see named: themselves, the holder's owner, and teammates."""
|
||||
ids = sorted({u for u in user_ids if u})
|
||||
visible = {u for u in ids if u in (viewer, owner)}
|
||||
rest = [u for u in ids if u not in visible]
|
||||
pairs = {(user_id, ref) for user_id, ref in people if user_id}
|
||||
owner = holder.get("user_id")
|
||||
known = {u for u in (viewer, owner) if u}
|
||||
if viewer and viewer == owner:
|
||||
known.update(u for u in (holder.get("resource_sponsors") or {}).values() if u)
|
||||
rest = sorted({user_id for user_id, _ in pairs if user_id not in known})
|
||||
teammates: set = set()
|
||||
if viewer and rest:
|
||||
visible.update(row[0] for row in conn.execute(
|
||||
teammates = {row[0] for row in conn.execute(
|
||||
text(
|
||||
"SELECT DISTINCT theirs.user_id FROM team_members mine "
|
||||
"JOIN team_members theirs ON theirs.team_id = mine.team_id "
|
||||
"WHERE mine.user_id = :viewer AND theirs.user_id = ANY(:ids)"
|
||||
),
|
||||
{"viewer": viewer, "ids": rest},
|
||||
).fetchall())
|
||||
return visible
|
||||
).fetchall()}
|
||||
named = set()
|
||||
for user_id, ref in pairs:
|
||||
if user_id in known or (
|
||||
user_id in teammates and (ref is None or resolve(conn, ref[0], ref[1], viewer) is not None)
|
||||
):
|
||||
named.add((user_id, ref))
|
||||
return named
|
||||
|
||||
|
||||
# What ``resource_states`` says about a running tool's credentials, for
|
||||
# anything that isn't a running tool.
|
||||
_NO_RUN_DETAILS = {
|
||||
"credential_mode": None,
|
||||
"account": None,
|
||||
"owner_credential_writes": [],
|
||||
"writes_allowed": True,
|
||||
}
|
||||
def _person(user_id: Optional[str], labels: dict, named: bool) -> Optional[dict]:
|
||||
"""``{user_id, label}`` for someone the reader may see named, both None otherwise."""
|
||||
if not user_id:
|
||||
return None
|
||||
if not named:
|
||||
return {"user_id": None, "label": None}
|
||||
return {"user_id": user_id, "label": labels.get(user_id) or user_id}
|
||||
|
||||
|
||||
def resource_states(
|
||||
@@ -1253,25 +1288,26 @@ def resource_states(
|
||||
``active`` or ``stopped``; ``reason`` (None while active) is one of
|
||||
``deleted``, ``owner_lost_access``, the sponsor reasons,
|
||||
``connection_needs_reconnect``, ``connection_removed`` or
|
||||
``connector_disabled``. ``note`` is ``per_user_account`` for a tool
|
||||
that runs on each caller's own account. ``sponsor`` is the recorded
|
||||
sponsor as ``{user_id, label}``. ``contact_role`` is
|
||||
``resource_owner`` when the resource's owner (not the reader) can fix
|
||||
it; ``contact`` names them only when the reader can see the resource
|
||||
or they own the holder, else None. ``connection`` (``{id,
|
||||
connector_key, name}``) names the service of a connected tool, and of
|
||||
one whose connection reason stopped it; ``id`` only when the reader
|
||||
may reconnect it, and the account's own name only for its owner.
|
||||
``runs_as`` (``{user_id, label}``) is the live sponsor a running item
|
||||
runs as, None when it runs as the owner or doesn't run.
|
||||
For a running tool, ``credential_mode`` is ``owner`` or ``member``
|
||||
when it has a connection (else None); ``account`` is whose saved
|
||||
credentials or ``owner``-mode connection it acts with (the tool's
|
||||
owner), ``{user_id: None, label: None}`` when the reader shares no
|
||||
team with them; ``owner_credential_writes`` names its write actions
|
||||
on those credentials (what the API write allowlist covers); and
|
||||
``connector_disabled``. ``sponsor`` (``{user_id, label}``) is the
|
||||
recorded sponsor. ``contact_role`` is ``resource_owner`` when the
|
||||
resource's owner (not the reader) can fix it, and ``contact`` names
|
||||
them, or is None. ``connection`` (``{id, connector_key, name}``)
|
||||
names the service of a connected tool, and of one whose connection
|
||||
reason stopped it; ``id`` only when the reader may reconnect it, and
|
||||
the account's own name only for its owner. ``runs_as`` (``{user_id,
|
||||
label}``) is the live sponsor a running item runs as, None when it
|
||||
runs as the owner or doesn't run.
|
||||
For a running tool, ``note`` is ``per_user_account`` when it runs on
|
||||
each caller's own account; ``credential_mode`` is ``owner`` or
|
||||
``member`` when it has a connection (else None); ``account`` is whose
|
||||
saved credentials or ``owner``-mode connection it acts with (the
|
||||
tool's owner); ``owner_credential_writes`` names its write actions on
|
||||
those credentials (what the API write allowlist covers); and
|
||||
``writes_allowed`` is False when an admin turned off changes through
|
||||
its connector. Other items get None, None, ``[]`` and True.
|
||||
its connector. Other items get None, None, None, ``[]`` and True.
|
||||
Every person is named only to a reader who knows them
|
||||
(:func:`people_named_to`): ``sponsor``, ``runs_as`` and ``account``
|
||||
are then ``{user_id: None, label: None}``, and ``contact`` None.
|
||||
``can_confirm``: the reader may take it over on their next save;
|
||||
``can_reconnect``: the reader owns the connection that needs signing
|
||||
in again.
|
||||
@@ -1293,7 +1329,7 @@ def resource_states(
|
||||
viewer_edits = bool(viewer and viewer != owner and _holder_editable_by(conn, holder_type, holder, viewer))
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
policies_box: list = []
|
||||
entries = []
|
||||
entries: list[dict] = []
|
||||
|
||||
def reader_sees(resource_type: str, rid: str) -> bool:
|
||||
return bool(viewer) and resolve(conn, resource_type, rid, viewer) is not None
|
||||
@@ -1301,13 +1337,13 @@ def resource_states(
|
||||
for resource_type, rid in pairs:
|
||||
key = sponsor_key(resource_type, rid)
|
||||
info = rows.get(key) or {}
|
||||
connection = note = None
|
||||
details = dict(_NO_RUN_DETAILS)
|
||||
connection = None
|
||||
run = dict(_NO_RUN_DETAILS)
|
||||
if resource_type == "tool":
|
||||
tool_row, access = resolve_holder_tool(conn, holder_type, holder, rid, tools_repo=tools_repo)
|
||||
reason = access.reason
|
||||
if tool_row is not None and reason is None:
|
||||
reason, connection, note, details = _tool_run_details(conn, tool_row, owner, policies_box)
|
||||
reason, connection, run = _tool_run_state(conn, tool_row, owner, policies_box)
|
||||
else:
|
||||
access = ref_access(conn, holder_type, holder, resource_type, rid)
|
||||
reason = access.reason
|
||||
@@ -1322,17 +1358,14 @@ def resource_states(
|
||||
and viewer_edits
|
||||
and can_sponsor_ref(conn, resource_type, rid, viewer)
|
||||
)
|
||||
contact = contact_role = None
|
||||
if (
|
||||
reason in (REASON_OWNER_LOST_ACCESS, REASON_CONNECTION_NEEDS_RECONNECT, REASON_CONNECTION_REMOVED)
|
||||
# Whoever owns the resource can share it again or fix its account.
|
||||
contact = (
|
||||
resource_owner
|
||||
if reason in (REASON_OWNER_LOST_ACCESS, REASON_CONNECTION_NEEDS_RECONNECT, REASON_CONNECTION_REMOVED)
|
||||
and resource_owner
|
||||
and resource_owner != viewer
|
||||
):
|
||||
# Whoever owns the resource can share it again or fix its
|
||||
# account; name them only to someone who knows them already.
|
||||
contact_role = CONTACT_RESOURCE_OWNER
|
||||
if resource_owner == owner or reader_sees(resource_type, rid):
|
||||
contact = resource_owner
|
||||
else None
|
||||
)
|
||||
name_visible = bool(reason is None or sponsor or holder_type == "agent" or reader_sees(resource_type, rid))
|
||||
can_reconnect = bool(
|
||||
reason == REASON_CONNECTION_NEEDS_RECONNECT
|
||||
@@ -1348,34 +1381,37 @@ def resource_states(
|
||||
"name": info.get("name") if name_visible else None,
|
||||
"state": "active" if reason is None else "stopped",
|
||||
"reason": reason,
|
||||
"note": note,
|
||||
"note": run["note"],
|
||||
"sponsor": sponsor,
|
||||
"contact": contact,
|
||||
"contact_role": contact_role,
|
||||
"contact_role": CONTACT_RESOURCE_OWNER if contact else None,
|
||||
"connection": _connection_payload(connection, bool(viewer) and viewer == resource_owner, can_reconnect),
|
||||
"runs_as": runs_as,
|
||||
**details,
|
||||
"credential_mode": run["credential_mode"],
|
||||
"account": run["account"],
|
||||
"owner_credential_writes": run["owner_credential_writes"],
|
||||
"writes_allowed": run["writes_allowed"],
|
||||
"can_confirm": can_confirm,
|
||||
"can_reconnect": can_reconnect,
|
||||
})
|
||||
labels = _user_labels(
|
||||
conn, [u for e in entries for u in (e["sponsor"], e["contact"], e["runs_as"], e["account"])]
|
||||
)
|
||||
# Every person is named by the one rule (:func:`people_named_to`); whom
|
||||
# to ask and whose credentials a tool uses are named as the owner of
|
||||
# that resource.
|
||||
people = [
|
||||
(entry[field_name], (entry["type"], entry["id"]) if field_name in ("contact", "account") else None)
|
||||
for entry in entries
|
||||
for field_name in ("sponsor", "runs_as", "contact", "account")
|
||||
]
|
||||
named = people_named_to(conn, viewer, holder, people)
|
||||
labels = _user_labels(conn, [user_id for user_id, ref in named])
|
||||
for entry in entries:
|
||||
for field_name in ("sponsor", "contact", "runs_as"):
|
||||
user_id = entry[field_name]
|
||||
entry[field_name] = {"user_id": user_id, "label": labels.get(user_id) or user_id} if user_id else None
|
||||
# Whose credentials a tool uses is named only to a reader who may see
|
||||
# that person; anyone else learns it's someone else's.
|
||||
named = _people_visible_to(conn, viewer, owner, [e["account"] for e in entries])
|
||||
for entry in entries:
|
||||
user_id = entry["account"]
|
||||
if user_id:
|
||||
entry["account"] = (
|
||||
{"user_id": user_id, "label": labels.get(user_id) or user_id}
|
||||
if user_id in named
|
||||
else {"user_id": None, "label": None}
|
||||
)
|
||||
ref = (entry["type"], entry["id"])
|
||||
entry["sponsor"] = _person(entry["sponsor"], labels, (entry["sponsor"], None) in named)
|
||||
entry["runs_as"] = _person(entry["runs_as"], labels, (entry["runs_as"], None) in named)
|
||||
entry["account"] = _person(entry["account"], labels, (entry["account"], ref) in named)
|
||||
# Whom to ask stays a role for someone the reader doesn't know.
|
||||
contact = _person(entry["contact"], labels, (entry["contact"], ref) in named)
|
||||
entry["contact"] = contact if contact and contact["user_id"] else None
|
||||
return entries
|
||||
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ describe('AgentUsesSection', () => {
|
||||
id: 't3',
|
||||
name: 'Slack tool',
|
||||
credential_mode: 'member',
|
||||
note: 'per_user_account',
|
||||
connection: { id: 'c1', connector_key: 'slack', name: 'Slack' },
|
||||
}),
|
||||
item({
|
||||
@@ -193,6 +194,7 @@ describe('AgentUsesSection', () => {
|
||||
id: 't3',
|
||||
name: 'Slack tool',
|
||||
credential_mode: 'member',
|
||||
note: 'per_user_account',
|
||||
connection: { id: null, connector_key: 'slack', name: 'Slack' },
|
||||
}),
|
||||
],
|
||||
@@ -223,6 +225,54 @@ describe('AgentUsesSection', () => {
|
||||
expect(rowOf('Once sponsored')?.textContent).not.toContain('bob');
|
||||
});
|
||||
|
||||
it('says someone else for a sponsor the reader does not know', async () => {
|
||||
await render(
|
||||
agentWith(
|
||||
[
|
||||
item({
|
||||
id: 't1',
|
||||
name: 'Runs as a stranger',
|
||||
sponsor: { user_id: null, label: null },
|
||||
runs_as: { user_id: null, label: null },
|
||||
}),
|
||||
item({
|
||||
id: 't2',
|
||||
name: 'Stopped',
|
||||
state: 'stopped',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
sponsor: { user_id: null, label: null },
|
||||
}),
|
||||
],
|
||||
{ access: 'editor', allowed_actions: ['edit', 'share', 'use'] },
|
||||
),
|
||||
);
|
||||
await open();
|
||||
expect(rowOf('Runs as a stranger')?.textContent).toContain(
|
||||
`${K}.access.other`,
|
||||
);
|
||||
expect(rowOf('Stopped')?.textContent).toContain(
|
||||
'agents.form.resourceStates.reason.sponsorCannotEditAgentOther',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the per_user_account note for a tool each person connects', async () => {
|
||||
await render(
|
||||
agentWith([
|
||||
item({
|
||||
id: 't1',
|
||||
name: 'Slack tool',
|
||||
credential_mode: 'member',
|
||||
note: 'per_user_account',
|
||||
connection: { id: 'c1', connector_key: 'slack', name: 'Slack' },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await open();
|
||||
expect(rowOf('Slack tool')?.textContent).toContain(
|
||||
`${K}.access.member(service=Slack)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('names whose saved credentials a tool without a connection uses', async () => {
|
||||
await render(
|
||||
agentWith([
|
||||
@@ -329,6 +379,7 @@ describe('AgentUsesSection', () => {
|
||||
id: 't1',
|
||||
name: 'Slack tool',
|
||||
credential_mode: 'member',
|
||||
note: 'per_user_account',
|
||||
connection: { id: null, connector_key: 'slack', name: 'Slack' },
|
||||
owner_credential_writes: ['send'],
|
||||
}),
|
||||
@@ -347,6 +398,7 @@ describe('AgentUsesSection', () => {
|
||||
id: 't2',
|
||||
name: 'Slack tool',
|
||||
credential_mode: 'member',
|
||||
note: 'per_user_account',
|
||||
owner_credential_writes: ['send'],
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -77,6 +77,9 @@ export default function AgentUsesSection({
|
||||
// Without a user id (authentication off) the one local user is everyone.
|
||||
const isYou = (userId: string) =>
|
||||
readerId ? userId === readerId : ownerReads;
|
||||
// A tool each person connects runs on their own account (the notice's
|
||||
// note), except for API and widget users.
|
||||
const perUser = (item: ResourceState) => item.note === 'per_user_account';
|
||||
const allowlist = agent.config?.api_write_allowlist ?? [];
|
||||
const nameOf = (item: ResourceState) =>
|
||||
item.name || t('agents.form.sponsors.unknownItem');
|
||||
@@ -86,7 +89,7 @@ export default function AgentUsesSection({
|
||||
const service = item.connection?.name;
|
||||
const suffix = service ? '' : 'NoService';
|
||||
const named = service ? { ...plain, service } : plain;
|
||||
if (item.credential_mode === 'member')
|
||||
if (perUser(item))
|
||||
// API and widget callers run the agent as its owner, so they use the
|
||||
// owner's own account.
|
||||
return t(
|
||||
@@ -111,18 +114,18 @@ export default function AgentUsesSection({
|
||||
const accessLabel = (item: ResourceState): string => {
|
||||
const credentials = credentialsLabel(item);
|
||||
if (credentials) return credentials;
|
||||
if (item.runs_as)
|
||||
return isYou(item.runs_as.user_id)
|
||||
if (item.runs_as) {
|
||||
const { user_id: userId, label } = item.runs_as;
|
||||
if (!userId) return t(`${K}.access.other`);
|
||||
return isYou(userId)
|
||||
? t(`${K}.access.you`)
|
||||
: t(`${K}.access.person`, {
|
||||
...plain,
|
||||
person: item.runs_as.label || item.runs_as.user_id,
|
||||
});
|
||||
: t(`${K}.access.person`, { ...plain, person: label || userId });
|
||||
}
|
||||
return ownerReads ? t(`${K}.access.you`) : t(`${K}.access.owner`);
|
||||
};
|
||||
|
||||
const stoppedText = (item: ResourceState) =>
|
||||
t(reasonKey(item.reason, ownerReads), {
|
||||
t(reasonKey(item.reason, ownerReads, Boolean(item.sponsor?.user_id)), {
|
||||
...plain,
|
||||
name: nameOf(item),
|
||||
service:
|
||||
@@ -154,12 +157,8 @@ export default function AgentUsesSection({
|
||||
item.writes_allowed !== false &&
|
||||
blockedWrites(item, allowlist).length > 0,
|
||||
);
|
||||
const blockedMember = blocked.some(
|
||||
(item) => item.credential_mode === 'member',
|
||||
);
|
||||
const blockedOwned = blocked.some(
|
||||
(item) => item.credential_mode !== 'member',
|
||||
);
|
||||
const blockedMember = blocked.some(perUser);
|
||||
const blockedOwned = blocked.some((item) => !perUser(item));
|
||||
const editor = ownerReads ? '' : 'Editor';
|
||||
const writesNote = blockedOwned
|
||||
? [
|
||||
|
||||
@@ -6,7 +6,6 @@ import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { Agent, ResourceSponsor, ResourceState } from '../types';
|
||||
import ResourceStatusNotice, {
|
||||
noteKey,
|
||||
unnamedResourceLabel,
|
||||
} from './ResourceStatusNotice';
|
||||
|
||||
@@ -202,6 +201,38 @@ describe('ResourceStatusNotice', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('says someone else for a sponsor the reader does not know', async () => {
|
||||
await render(
|
||||
{
|
||||
...editorAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 't1', user_id: null, label: null }),
|
||||
sponsor({ id: 't2', user_id: null, label: null }),
|
||||
],
|
||||
},
|
||||
[
|
||||
stoppedItem({
|
||||
reason: 'sponsor_cannot_edit_resource',
|
||||
sponsor: { user_id: null, label: null },
|
||||
}),
|
||||
stoppedItem({
|
||||
key: 'tool:t3',
|
||||
id: 't3',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
sponsor: { user_id: 'bob', label: 'bob@example.com' },
|
||||
}),
|
||||
],
|
||||
);
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('agents.form.sponsors.addedByOther');
|
||||
expect(text).not.toContain('agents.form.sponsors.addedBy.');
|
||||
expect(text).toContain(
|
||||
'agents.form.resourceStates.reason.sponsorCannotEditItemOther',
|
||||
);
|
||||
// A sponsor the reader knows is named.
|
||||
expect(text).toMatch(/reason\.sponsorCannotEditAgent(?!Other)/);
|
||||
});
|
||||
|
||||
it('tells the owner they lost access, and an editor that the owner did', async () => {
|
||||
const item = stoppedItem({ reason: 'owner_lost_access' });
|
||||
await render(baseAgent, [item]);
|
||||
@@ -280,13 +311,6 @@ describe('ResourceStatusNotice', () => {
|
||||
).toBe('agents.form.resourceStates.unnamed.source|0f1e2d3c');
|
||||
});
|
||||
|
||||
it("has words for a tool on each person's own account", () => {
|
||||
expect(noteKey('per_user_account')).toBe(
|
||||
'agents.form.resourceStates.note.perUserAccount',
|
||||
);
|
||||
expect(noteKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('notes that a stopped prompt falls back to the default', async () => {
|
||||
await render(baseAgent, [
|
||||
stoppedItem({ key: 'prompt:p1', type: 'prompt', id: 'p1' }),
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
Agent,
|
||||
ResourceSponsor,
|
||||
ResourceState,
|
||||
ResourceStateNote,
|
||||
ResourceStateReason,
|
||||
} from '../types';
|
||||
|
||||
@@ -39,11 +38,16 @@ type ResourceStatusNoticeProps = {
|
||||
showAttachNote?: boolean;
|
||||
};
|
||||
|
||||
/** The message key that says why an item stopped. */
|
||||
/**
|
||||
* The message key that says why an item stopped. `sponsorNamed` is false
|
||||
* when the read doesn't name the sponsor, who is then "someone else".
|
||||
*/
|
||||
export function reasonKey(
|
||||
reason: ResourceStateReason | null,
|
||||
ownerReads: boolean,
|
||||
sponsorNamed = true,
|
||||
) {
|
||||
const other = sponsorNamed ? '' : 'Other';
|
||||
switch (reason) {
|
||||
case 'deleted':
|
||||
return 'agents.form.resourceStates.reason.deleted';
|
||||
@@ -52,9 +56,9 @@ export function reasonKey(
|
||||
? 'agents.form.resourceStates.reason.ownerLostAccessYou'
|
||||
: 'agents.form.resourceStates.reason.ownerLostAccess';
|
||||
case 'sponsor_cannot_edit_agent':
|
||||
return 'agents.form.resourceStates.reason.sponsorCannotEditAgent';
|
||||
return `agents.form.resourceStates.reason.sponsorCannotEditAgent${other}`;
|
||||
case 'sponsor_cannot_edit_resource':
|
||||
return 'agents.form.resourceStates.reason.sponsorCannotEditItem';
|
||||
return `agents.form.resourceStates.reason.sponsorCannotEditItem${other}`;
|
||||
case 'connection_needs_reconnect':
|
||||
return 'agents.form.resourceStates.reason.connectionNeedsReconnect';
|
||||
case 'connection_removed':
|
||||
@@ -66,13 +70,6 @@ export function reasonKey(
|
||||
}
|
||||
}
|
||||
|
||||
/** The words for a running item's `note`, or null when it has none. */
|
||||
export function noteKey(note: ResourceStateNote | null | undefined) {
|
||||
return note === 'per_user_account'
|
||||
? 'agents.form.resourceStates.note.perUserAccount'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A name for an item the reader may not see: its kind and a short id. */
|
||||
export function unnamedResourceLabel(
|
||||
t: TFunction,
|
||||
@@ -106,11 +103,14 @@ function askKey(item: ResourceState): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Items grouped by the person who added them, in first-seen order. */
|
||||
/**
|
||||
* Items grouped by the person who added them, in first-seen order; people
|
||||
* the reader doesn't know form one group.
|
||||
*/
|
||||
function groupByPerson(sponsors: ResourceSponsor[]) {
|
||||
const groups = new Map<string, ResourceSponsor[]>();
|
||||
for (const sponsor of sponsors) {
|
||||
const key = sponsor.label || sponsor.user_id;
|
||||
const key = sponsor.label || sponsor.user_id || '';
|
||||
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
@@ -170,11 +170,16 @@ export default function ResourceStatusNotice({
|
||||
<AlertDescription>
|
||||
{running.map((items) => (
|
||||
<p key={items[0].key || `${items[0].type}:${items[0].id}`}>
|
||||
{t('agents.form.sponsors.addedBy', {
|
||||
...plain,
|
||||
person: items[0].label || items[0].user_id,
|
||||
names: listFormat.format(items.map(resolveName)),
|
||||
})}
|
||||
{t(
|
||||
items[0].user_id
|
||||
? 'agents.form.sponsors.addedBy'
|
||||
: 'agents.form.sponsors.addedByOther',
|
||||
{
|
||||
...plain,
|
||||
person: items[0].label || items[0].user_id,
|
||||
names: listFormat.format(items.map(resolveName)),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
@@ -196,12 +201,19 @@ export default function ResourceStatusNotice({
|
||||
return (
|
||||
<li key={item.key} className="flex flex-col gap-1.5">
|
||||
<p>
|
||||
{t(reasonKey(item.reason, ownerReads), {
|
||||
...plain,
|
||||
name,
|
||||
service,
|
||||
person: item.sponsor?.label || item.sponsor?.user_id,
|
||||
})}
|
||||
{t(
|
||||
reasonKey(
|
||||
item.reason,
|
||||
ownerReads,
|
||||
Boolean(item.sponsor?.user_id),
|
||||
),
|
||||
{
|
||||
...plain,
|
||||
name,
|
||||
service,
|
||||
person: item.sponsor?.label || item.sponsor?.user_id,
|
||||
},
|
||||
)}
|
||||
{ask
|
||||
? ` ${t(ask, { ...plain, person: item.contact?.label })}`
|
||||
: ''}
|
||||
|
||||
@@ -19,9 +19,10 @@ export type ResourceSponsor = {
|
||||
id: string;
|
||||
/** The item's name, looked up whoever owns it. */
|
||||
name?: string | null;
|
||||
user_id: string;
|
||||
/** Null, with `label`, for someone the reader doesn't know. */
|
||||
user_id: string | null;
|
||||
/** The person's email when on file, else their user id. */
|
||||
label: string;
|
||||
label: string | null;
|
||||
state?: 'active' | 'inactive';
|
||||
/** Null while it runs; else whether the person lost the agent or the item. */
|
||||
reason?: ResourceSponsorReason | null;
|
||||
@@ -43,8 +44,11 @@ export type ResourceStateReason =
|
||||
/** Something to know about an item that runs (`ResourceState.note`). */
|
||||
export type ResourceStateNote = 'per_user_account';
|
||||
|
||||
/** A person the page names: their email when on file, else their user id. */
|
||||
export type ResourcePerson = { user_id: string; label: string };
|
||||
/**
|
||||
* A person the page names: their email when on file, else their user id.
|
||||
* Both are null for someone the reader doesn't know.
|
||||
*/
|
||||
export type ResourcePerson = { user_id: string | null; label: string | null };
|
||||
|
||||
/**
|
||||
* Whether an attached tool, source or prompt runs (`resource_states` on the
|
||||
@@ -64,7 +68,7 @@ export type ResourceState = {
|
||||
note?: ResourceStateNote | null;
|
||||
/** Who it ran with the access of, when someone else added it. */
|
||||
sponsor?: ResourcePerson | null;
|
||||
/** Someone other than the reader who can fix it, when the reader may know them. */
|
||||
/** Someone other than the reader who can fix it, when the reader knows them. */
|
||||
contact?: ResourcePerson | null;
|
||||
/** Who can fix it (`resource_owner`), named or not. */
|
||||
contact_role?: 'resource_owner' | null;
|
||||
@@ -86,7 +90,7 @@ export type ResourceState = {
|
||||
* Whose saved credentials or owner-mode connection a running tool uses
|
||||
* (the tool's owner); both null when the reader may not see who.
|
||||
*/
|
||||
account?: { user_id: string | null; label: string | null } | null;
|
||||
account?: ResourcePerson | null;
|
||||
/** Its write actions on credentials its owner stored (the API write allowlist's). */
|
||||
owner_credential_writes?: string[];
|
||||
/** False when an admin turned off changes through its connector. */
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
"access": {
|
||||
"you": "Dein Zugriff",
|
||||
"person": "Zugriff von {{person}}",
|
||||
"other": "Zugriff einer anderen Person",
|
||||
"owner": "Zugriff des Eigentümers",
|
||||
"member": "Das eigene {{service}}-Konto jeder Person (API und Widget: deins)",
|
||||
"memberNoService": "Das eigene Konto jeder Person (API und Widget: deins)",
|
||||
@@ -2400,6 +2401,7 @@
|
||||
"attachNote": "Tools, Quellen und Prompts, die du hinzufügst und die der Eigentümer nicht nutzen kann, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff. Du musst sie besitzen oder bearbeiten können und wirst um Bestätigung gebeten.",
|
||||
"publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
|
||||
"addedBy": "Hinzugefügt von {{person}}: {{names}}",
|
||||
"addedByOther": "Hinzugefügt von einer anderen Person: {{names}}",
|
||||
"unknownItem": "Unbenanntes Element",
|
||||
"notAllowed": "Du kannst {{names}} diesem Agenten nicht hinzufügen. Der Eigentümer kann sie nicht nutzen, und du kannst nur teilen, was du besitzt oder bearbeiten kannst.",
|
||||
"takeOver": "{{name}} mit meinem Zugriff ausführen",
|
||||
@@ -2414,7 +2416,9 @@
|
||||
"ownerLostAccessYou": "Du kannst {{name}} nicht mehr verwenden.",
|
||||
"ownerLostAccess": "Der Eigentümer des Agenten kann {{name}} nicht mehr verwenden.",
|
||||
"sponsorCannotEditAgent": "{{name}} lief mit dem Zugriff von {{person}}, und diese Person kann den Agenten nicht mehr bearbeiten.",
|
||||
"sponsorCannotEditAgentOther": "{{name}} lief mit dem Zugriff einer anderen Person, und diese Person kann den Agenten nicht mehr bearbeiten.",
|
||||
"sponsorCannotEditItem": "{{name}} lief mit dem Zugriff von {{person}}, und diese Person kann es nicht mehr bearbeiten.",
|
||||
"sponsorCannotEditItemOther": "{{name}} lief mit dem Zugriff einer anderen Person, und diese Person kann es nicht mehr bearbeiten.",
|
||||
"connectionNeedsReconnect": "Für {{name}} muss das {{service}}-Konto erneut angemeldet werden.",
|
||||
"connectionRemoved": "Das {{service}}-Konto, das {{name}} verwendet hat, wurde entfernt.",
|
||||
"connectorDisabled": "Ein Admin hat {{service}} deaktiviert, daher kann {{name}} nicht laufen.",
|
||||
@@ -2439,9 +2443,6 @@
|
||||
"source": "Quelle {{id}}",
|
||||
"prompt": "Prompt {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "Jede Person verwendet ihr eigenes Konto"
|
||||
},
|
||||
"chip": "Läuft nicht: {{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"access": {
|
||||
"you": "Your access",
|
||||
"person": "{{person}}'s access",
|
||||
"other": "Someone else's access",
|
||||
"owner": "The owner's access",
|
||||
"member": "Each person's own {{service}} account (API and widget: yours)",
|
||||
"memberNoService": "Each person's own account (API and widget: yours)",
|
||||
@@ -2418,6 +2419,7 @@
|
||||
"attachNote": "Tools, sources and prompts you add that the owner can't use run with your access for everyone who uses this agent. You need to own them or be able to edit them, and you'll be asked to confirm.",
|
||||
"publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
|
||||
"addedBy": "Added by {{person}}: {{names}}",
|
||||
"addedByOther": "Added by someone else: {{names}}",
|
||||
"unknownItem": "Unnamed item",
|
||||
"notAllowed": "You can't add {{names}} to this agent. The owner can't use them, and you can only share what you own or can edit.",
|
||||
"takeOver": "Run {{name}} with my access",
|
||||
@@ -2432,7 +2434,9 @@
|
||||
"ownerLostAccessYou": "You can no longer use {{name}}.",
|
||||
"ownerLostAccess": "The agent's owner can no longer use {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} ran with {{person}}'s access, and they can no longer edit this agent.",
|
||||
"sponsorCannotEditAgentOther": "{{name}} ran with someone else's access, and they can no longer edit this agent.",
|
||||
"sponsorCannotEditItem": "{{name}} ran with {{person}}'s access, and they can no longer edit it.",
|
||||
"sponsorCannotEditItemOther": "{{name}} ran with someone else's access, and they can no longer edit it.",
|
||||
"connectionNeedsReconnect": "{{name}} needs its {{service}} account signed in again.",
|
||||
"connectionRemoved": "The {{service}} account {{name}} used was removed.",
|
||||
"connectorDisabled": "An admin turned off {{service}}, so {{name}} can't run.",
|
||||
@@ -2457,9 +2461,6 @@
|
||||
"source": "Source {{id}}",
|
||||
"prompt": "Prompt {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "Each person uses their own account"
|
||||
},
|
||||
"chip": "Not running: {{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
"access": {
|
||||
"you": "Tu acceso",
|
||||
"person": "Acceso de {{person}}",
|
||||
"other": "Acceso de otra persona",
|
||||
"owner": "Acceso del propietario",
|
||||
"member": "La propia cuenta de {{service}} de cada persona (API y widget: la tuya)",
|
||||
"memberNoService": "La propia cuenta de cada persona (API y widget: la tuya)",
|
||||
@@ -2400,6 +2401,7 @@
|
||||
"attachNote": "Las herramientas, fuentes y prompts que añadas y que el propietario no pueda usar se ejecutan con tu acceso para todos los que usen este agente. Debes ser su propietario o poder editarlos, y se te pedirá que lo confirmes.",
|
||||
"publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
|
||||
"addedBy": "Añadido por {{person}}: {{names}}",
|
||||
"addedByOther": "Añadido por otra persona: {{names}}",
|
||||
"unknownItem": "Elemento sin nombre",
|
||||
"notAllowed": "No puedes añadir {{names}} a este agente. El propietario no puede usarlos y solo puedes compartir lo que es tuyo o puedes editar.",
|
||||
"takeOver": "Ejecutar {{name}} con mi acceso",
|
||||
@@ -2414,7 +2416,9 @@
|
||||
"ownerLostAccessYou": "Ya no puedes usar {{name}}.",
|
||||
"ownerLostAccess": "El propietario del agente ya no puede usar {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} se ejecutaba con el acceso de {{person}}, que ya no puede editar este agente.",
|
||||
"sponsorCannotEditAgentOther": "{{name}} se ejecutaba con el acceso de otra persona, que ya no puede editar este agente.",
|
||||
"sponsorCannotEditItem": "{{name}} se ejecutaba con el acceso de {{person}}, que ya no puede editarlo.",
|
||||
"sponsorCannotEditItemOther": "{{name}} se ejecutaba con el acceso de otra persona, que ya no puede editarlo.",
|
||||
"connectionNeedsReconnect": "{{name}} necesita que su cuenta de {{service}} vuelva a iniciar sesión.",
|
||||
"connectionRemoved": "Se eliminó la cuenta de {{service}} que usaba {{name}}.",
|
||||
"connectorDisabled": "Un administrador desactivó {{service}}, así que {{name}} no puede ejecutarse.",
|
||||
@@ -2439,9 +2443,6 @@
|
||||
"source": "Fuente {{id}}",
|
||||
"prompt": "Prompt {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "Cada persona usa su propia cuenta"
|
||||
},
|
||||
"chip": "Sin ejecutar: {{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -772,6 +772,7 @@
|
||||
"access": {
|
||||
"you": "あなたのアクセス",
|
||||
"person": "{{person}} のアクセス",
|
||||
"other": "他の人のアクセス",
|
||||
"owner": "オーナーのアクセス",
|
||||
"member": "各自の {{service}} アカウント(API とウィジェット:あなたのもの)",
|
||||
"memberNoService": "各自のアカウント(API とウィジェット:あなたのもの)",
|
||||
@@ -2385,6 +2386,7 @@
|
||||
"attachNote": "オーナーが使用できないツール、ソース、プロンプトを追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。自分が所有しているか編集できるものに限られ、追加時に確認を求められます。",
|
||||
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
|
||||
"addedBy": "{{person}} が追加: {{names}}",
|
||||
"addedByOther": "他の人が追加: {{names}}",
|
||||
"unknownItem": "名前のない項目",
|
||||
"notAllowed": "{{names}} をこのエージェントに追加できません。オーナーはそれらを使用できず、共有できるのは自分が所有しているか編集できるものだけです。",
|
||||
"takeOver": "{{name}} を自分のアクセス権で実行",
|
||||
@@ -2399,7 +2401,9 @@
|
||||
"ownerLostAccessYou": "{{name}} を使用できなくなりました。",
|
||||
"ownerLostAccess": "エージェントの所有者は {{name}} を使用できなくなりました。",
|
||||
"sponsorCannotEditAgent": "{{name}} は {{person}} のアクセス権で実行されていましたが、この人はこのエージェントを編集できなくなりました。",
|
||||
"sponsorCannotEditAgentOther": "{{name}} は他の人のアクセス権で実行されていましたが、この人はこのエージェントを編集できなくなりました。",
|
||||
"sponsorCannotEditItem": "{{name}} は {{person}} のアクセス権で実行されていましたが、この人はそれを編集できなくなりました。",
|
||||
"sponsorCannotEditItemOther": "{{name}} は他の人のアクセス権で実行されていましたが、この人はそれを編集できなくなりました。",
|
||||
"connectionNeedsReconnect": "{{name}} の {{service}} アカウントに再度サインインする必要があります。",
|
||||
"connectionRemoved": "{{name}} が使っていた {{service}} アカウントは削除されました。",
|
||||
"connectorDisabled": "管理者が {{service}} をオフにしたため、{{name}} は実行できません。",
|
||||
@@ -2424,9 +2428,6 @@
|
||||
"source": "ソース {{id}}",
|
||||
"prompt": "プロンプト {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "各自が自分のアカウントを使用します"
|
||||
},
|
||||
"chip": "実行されていません: {{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -817,6 +817,7 @@
|
||||
"access": {
|
||||
"you": "Ваш доступ",
|
||||
"person": "Доступ {{person}}",
|
||||
"other": "Доступ другого пользователя",
|
||||
"owner": "Доступ владельца",
|
||||
"member": "Собственный аккаунт {{service}} у каждого (API и виджет: ваш)",
|
||||
"memberNoService": "Собственный аккаунт у каждого (API и виджет: ваш)",
|
||||
@@ -2492,6 +2493,7 @@
|
||||
"attachNote": "Инструменты, источники и промпты, которые вы добавляете и которые владелец не может использовать, работают с вашим доступом для всех, кто пользуется этим агентом. Вы должны быть их владельцем или иметь право их редактировать, и вас попросят это подтвердить.",
|
||||
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
|
||||
"addedBy": "Добавил(а) {{person}}: {{names}}",
|
||||
"addedByOther": "Добавил(а) другой пользователь: {{names}}",
|
||||
"unknownItem": "Элемент без названия",
|
||||
"notAllowed": "Вы не можете добавить {{names}} к этому агенту. Владелец не может их использовать, а делиться можно только тем, чем вы владеете или что можете редактировать.",
|
||||
"takeOver": "Запускать {{name}} с моим доступом",
|
||||
@@ -2506,7 +2508,9 @@
|
||||
"ownerLostAccessYou": "Вы больше не можете использовать {{name}}.",
|
||||
"ownerLostAccess": "Владелец агента больше не может использовать {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} работал с доступом {{person}}, а этот человек больше не может редактировать агента.",
|
||||
"sponsorCannotEditAgentOther": "{{name}} работал с доступом другого пользователя, а этот человек больше не может редактировать агента.",
|
||||
"sponsorCannotEditItem": "{{name}} работал с доступом {{person}}, а этот человек больше не может его редактировать.",
|
||||
"sponsorCannotEditItemOther": "{{name}} работал с доступом другого пользователя, а этот человек больше не может его редактировать.",
|
||||
"connectionNeedsReconnect": "Для {{name}} нужно снова войти в аккаунт {{service}}.",
|
||||
"connectionRemoved": "Аккаунт {{service}}, который использовал {{name}}, удалён.",
|
||||
"connectorDisabled": "Администратор отключил {{service}}, поэтому {{name}} не может работать.",
|
||||
@@ -2531,9 +2535,6 @@
|
||||
"source": "Источник {{id}}",
|
||||
"prompt": "Промпт {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "Каждый использует свой аккаунт"
|
||||
},
|
||||
"chip": "Не работает: {{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -772,6 +772,7 @@
|
||||
"access": {
|
||||
"you": "你的存取權限",
|
||||
"person": "{{person}} 的存取權限",
|
||||
"other": "其他人的存取權限",
|
||||
"owner": "擁有者的存取權限",
|
||||
"member": "每個人自己的 {{service}} 帳號(API 和小工具:你的)",
|
||||
"memberNoService": "每個人自己的帳號(API 和小工具:你的)",
|
||||
@@ -2385,6 +2386,7 @@
|
||||
"attachNote": "你新增的、擁有者無法使用的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。你必須擁有它們或能夠編輯它們,並且需要確認。",
|
||||
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
|
||||
"addedBy": "由 {{person}} 新增:{{names}}",
|
||||
"addedByOther": "由其他人新增:{{names}}",
|
||||
"unknownItem": "未命名項目",
|
||||
"notAllowed": "無法將 {{names}} 新增到此智慧代理。擁有者無法使用它們,而你只能分享自己擁有或可以編輯的項目。",
|
||||
"takeOver": "以我的存取權限執行 {{name}}",
|
||||
@@ -2399,7 +2401,9 @@
|
||||
"ownerLostAccessYou": "你已無法使用 {{name}}。",
|
||||
"ownerLostAccess": "代理的擁有者已無法使用 {{name}}。",
|
||||
"sponsorCannotEditAgent": "{{name}} 先前以 {{person}} 的存取權限執行,而對方已無法編輯此代理。",
|
||||
"sponsorCannotEditAgentOther": "{{name}} 先前以其他人的存取權限執行,而對方已無法編輯此代理。",
|
||||
"sponsorCannotEditItem": "{{name}} 先前以 {{person}} 的存取權限執行,而對方已無法編輯它。",
|
||||
"sponsorCannotEditItemOther": "{{name}} 先前以其他人的存取權限執行,而對方已無法編輯它。",
|
||||
"connectionNeedsReconnect": "{{name}} 的 {{service}} 帳戶需要重新登入。",
|
||||
"connectionRemoved": "{{name}} 使用的 {{service}} 帳戶已被移除。",
|
||||
"connectorDisabled": "管理員已關閉 {{service}},因此 {{name}} 無法執行。",
|
||||
@@ -2424,9 +2428,6 @@
|
||||
"source": "來源 {{id}}",
|
||||
"prompt": "提示詞 {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "每個人使用自己的帳戶"
|
||||
},
|
||||
"chip": "未在執行:{{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -772,6 +772,7 @@
|
||||
"access": {
|
||||
"you": "你的访问权限",
|
||||
"person": "{{person}} 的访问权限",
|
||||
"other": "其他人的访问权限",
|
||||
"owner": "所有者的访问权限",
|
||||
"member": "每个人自己的 {{service}} 账号(API 和小组件:你的)",
|
||||
"memberNoService": "每个人自己的账号(API 和小组件:你的)",
|
||||
@@ -2385,6 +2386,7 @@
|
||||
"attachNote": "你添加的、所有者无法使用的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。你必须拥有它们或能够编辑它们,并且需要确认。",
|
||||
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
|
||||
"addedBy": "由 {{person}} 添加:{{names}}",
|
||||
"addedByOther": "由其他人添加:{{names}}",
|
||||
"unknownItem": "未命名项目",
|
||||
"notAllowed": "无法将 {{names}} 添加到此智能体。所有者无法使用它们,而你只能共享自己拥有或可以编辑的项目。",
|
||||
"takeOver": "以我的访问权限运行 {{name}}",
|
||||
@@ -2399,7 +2401,9 @@
|
||||
"ownerLostAccessYou": "你已无法使用 {{name}}。",
|
||||
"ownerLostAccess": "智能体的所有者已无法使用 {{name}}。",
|
||||
"sponsorCannotEditAgent": "{{name}} 此前以 {{person}} 的访问权限运行,而其已无法编辑此智能体。",
|
||||
"sponsorCannotEditAgentOther": "{{name}} 此前以其他人的访问权限运行,而其已无法编辑此智能体。",
|
||||
"sponsorCannotEditItem": "{{name}} 此前以 {{person}} 的访问权限运行,而其已无法编辑它。",
|
||||
"sponsorCannotEditItemOther": "{{name}} 此前以其他人的访问权限运行,而其已无法编辑它。",
|
||||
"connectionNeedsReconnect": "{{name}} 的 {{service}} 账户需要重新登录。",
|
||||
"connectionRemoved": "{{name}} 使用的 {{service}} 账户已被移除。",
|
||||
"connectorDisabled": "管理员已关闭 {{service}},因此 {{name}} 无法运行。",
|
||||
@@ -2424,9 +2428,6 @@
|
||||
"source": "来源 {{id}}",
|
||||
"prompt": "提示词 {{id}}"
|
||||
},
|
||||
"note": {
|
||||
"perUserAccount": "每个人使用自己的账户"
|
||||
},
|
||||
"chip": "未在运行:{{count}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
|
||||
@@ -874,6 +874,112 @@ class TestContact:
|
||||
assert state["name"] is None
|
||||
|
||||
|
||||
class TestNamingPeople:
|
||||
"""One rule names every person on the page: only people the reader knows."""
|
||||
|
||||
def test_contact_the_reader_shares_no_team_with_is_not_named(self, pg_conn):
|
||||
"""Seeing the item isn't enough: its owner left every team the reader is in."""
|
||||
agent_id, team_id = _agent(pg_conn)
|
||||
source = _team_source(pg_conn, team_id)
|
||||
AgentsRepository(pg_conn).update_by_id(agent_id, {"extra_source_ids": [source]})
|
||||
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "source", source)
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
team_id, "source", source, OTHER, OTHER, target_user_id=EDITOR
|
||||
)
|
||||
TeamMembersRepository(pg_conn).remove_member(team_id, OTHER)
|
||||
state = _states(pg_conn, agent_id, viewer=EDITOR)[f"source:{source}"]
|
||||
assert state["reason"] == REASON_OWNER_LOST_ACCESS
|
||||
assert state["contact"] is None
|
||||
assert state["contact_role"] == "resource_owner"
|
||||
|
||||
def test_account_of_a_teammate_whose_tool_the_reader_cannot_see_is_not_named(self, pg_conn):
|
||||
agent_id, team_id = _agent(pg_conn)
|
||||
TeamMembersRepository(pg_conn).add_member(team_id, OWNER)
|
||||
TeamMembersRepository(pg_conn).add_member(team_id, OTHER)
|
||||
tool = str(UserToolsRepository(pg_conn).create(
|
||||
OTHER, "telegram", connection_id=_connection(pg_conn, user=OTHER),
|
||||
)["id"])
|
||||
TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OTHER, OTHER, target_user_id=OWNER)
|
||||
AgentsRepository(pg_conn).update_by_id(agent_id, {"tools": [tool]})
|
||||
with _patch_db(pg_conn):
|
||||
as_owner = _states(pg_conn, agent_id)[f"tool:{tool}"]
|
||||
as_editor = _states(pg_conn, agent_id, viewer=EDITOR)[f"tool:{tool}"]
|
||||
assert as_owner["account"] == {"user_id": OTHER, "label": OTHER}
|
||||
assert as_editor["account"] == {"user_id": None, "label": None}
|
||||
|
||||
def test_a_sponsor_from_another_team_is_named_to_the_owner_only(self, app, pg_conn):
|
||||
from docsgpt.api.user.resource_access import sponsor_details
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
second = "sp-editor-2"
|
||||
agent_id, _ = _agent(pg_conn)
|
||||
other_team = str(TeamsRepository(pg_conn).create("T2", f"t2-{uuid.uuid4().hex[:8]}", OWNER)["id"])
|
||||
TeamMembersRepository(pg_conn).add_member(other_team, second)
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
other_team, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=second
|
||||
)
|
||||
tool = str(UserToolsRepository(pg_conn).create(second, "api_tool")["id"])
|
||||
assert _status(_put(app, pg_conn, agent_id, second,
|
||||
{"tools": [tool], "confirm_sponsor": _confirm(("tool", tool))})) == 200
|
||||
agent = _row(pg_conn, agent_id)
|
||||
as_owner = _states(pg_conn, agent_id)[f"tool:{tool}"]
|
||||
as_editor = _states(pg_conn, agent_id, viewer=EDITOR)[f"tool:{tool}"]
|
||||
assert as_owner["runs_as"] == {"user_id": second, "label": second}
|
||||
assert as_owner["sponsor"] == {"user_id": second, "label": second}
|
||||
assert as_editor["runs_as"] == {"user_id": None, "label": None}
|
||||
assert as_editor["sponsor"] == {"user_id": None, "label": None}
|
||||
[owner_detail] = sponsor_details(pg_conn, "agent", agent, viewer=OWNER)
|
||||
[editor_detail] = sponsor_details(pg_conn, "agent", agent, viewer=EDITOR)
|
||||
assert (owner_detail["user_id"], owner_detail["label"]) == (second, second)
|
||||
assert (editor_detail["user_id"], editor_detail["label"]) == (None, None)
|
||||
assert editor_detail["active"] is True
|
||||
|
||||
|
||||
class TestRemovedConnectionMarker:
|
||||
def test_removed_connection_without_a_catalog_key_is_still_marked(self, pg_conn):
|
||||
from docsgpt.connectors import service
|
||||
|
||||
cid = str(pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO connector_sessions (user_id, provider, auth_kind, status) "
|
||||
"VALUES (:u, 'legacy-service', 'api_key', 'connected') RETURNING id"
|
||||
),
|
||||
{"u": OWNER},
|
||||
).scalar())
|
||||
tool = str(UserToolsRepository(pg_conn).create(OWNER, "telegram", connection_id=cid)["id"])
|
||||
agent_id, _ = _agent(pg_conn, tools=[tool])
|
||||
row = pg_conn.execute(text("SELECT * FROM connector_sessions WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": cid}).mappings().one()
|
||||
service.remove_connection(pg_conn, dict(row), tools="keep")
|
||||
with _patch_db(pg_conn):
|
||||
state = _states(pg_conn, agent_id)[f"tool:{tool}"]
|
||||
assert (state["state"], state["reason"]) == ("stopped", REASON_CONNECTION_REMOVED)
|
||||
assert state["connection"]["name"] == "Telegram"
|
||||
|
||||
def test_kept_tool_given_its_own_credentials_runs(self, pg_conn):
|
||||
from docsgpt.security.encryption import encrypt_credentials
|
||||
|
||||
tool = str(UserToolsRepository(pg_conn).create(OWNER, "telegram", config={
|
||||
"removed_connection": "telegram",
|
||||
"encrypted_credentials": encrypt_credentials({"token": "t"}, OWNER),
|
||||
})["id"])
|
||||
agent_id, _ = _agent(pg_conn, tools=[tool])
|
||||
with _patch_db(pg_conn):
|
||||
state = _states(pg_conn, agent_id)[f"tool:{tool}"]
|
||||
assert (state["state"], state["reason"]) == ("active", None)
|
||||
|
||||
def test_a_stopped_tool_says_nothing_about_how_it_runs(self, pg_conn):
|
||||
tool = str(UserToolsRepository(pg_conn).create(
|
||||
OWNER, "telegram", connection_id=_connection(pg_conn, status="reconnect_needed"),
|
||||
)["id"])
|
||||
agent_id, _ = _agent(pg_conn, tools=[tool])
|
||||
with _patch_db(pg_conn):
|
||||
state = _states(pg_conn, agent_id)[f"tool:{tool}"]
|
||||
assert state["reason"] == REASON_CONNECTION_NEEDS_RECONNECT
|
||||
assert (state["note"], state["credential_mode"], state["account"]) == (None, None, None)
|
||||
assert (state["owner_credential_writes"], state["writes_allowed"]) == ([], True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads never fail on run state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user