diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fd7020fa..595b68c1 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -306,7 +306,9 @@ Self-contained product knowledge base. **All knowledge capability lives in this ### Custom Agent Persistence -Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. +Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are *listed* only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. + +**Visibility invariant: list-visible ⇒ openable.** Single-record reads (`AgentStore.get_visible`, which gates `GET /api/agents/{id}` *and* run creation via `_enforce_agent_access`) are deliberately **more lenient** than the listing filters — they must cover the union of every listing surface so an agent that shows up in any list never 404s when opened or chatted with: built-in / owned / published (baseline), **`is_private=true`** (the private-square password is the *discovery* gate; reads are not re-gated — necessary because moving an agent into the private square forces `published=false`), and **favorited by the requesting user** (the "我的" listing keeps favorites after the owner unpublishes). Admins additionally fall back to `get_any` in the detail route and run gate, matching the all-agents admin listing. Plain unpublished agents stay hidden from strangers. Predicates: `AgentRepository._visible_filter` / `MemoryAgentStore._is_fetchable`. Tests: `tests/test_agent_visibility.py`. **Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 75d8f9cd..9e4ffd72 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -723,6 +723,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse: await _maybe_sync_legacy_agents(store) user_id = _current_user_id(request) record = await store.get_visible(agent_id, user_id) + if record is None and _is_admin_user(request): + # The admin console lists every agent (?admin=true), so an admin + # must be able to open any of them — mirror that here. + record = await store.get_any(agent_id) if record is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Detail view legitimately reads SOUL.md + config.yaml — hop to a diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46625e28..c5c2baec 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -477,6 +477,9 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: store = get_agent_store(request) await _maybe_sync_legacy_agent_records(store) user_id = get_effective_user_id() + # Admins see every agent in the admin console listing (?admin=true), so + # they may also start a run against any of them. + is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin" for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) @@ -484,8 +487,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: raise HTTPException(status_code=422, detail=str(exc)) from exc if normalized is None: continue - if await store.get_visible(normalized, user_id) is None: - raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") + if await store.get_visible(normalized, user_id) is not None: + continue + if is_admin and await store.get_any(normalized) is not None: + continue + raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") # --------------------------------------------------------------------------- diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 002653a0..51003e8b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -32,6 +32,15 @@ class MemoryAgentStore(AgentStore): def _is_visible(agent: dict[str, Any], user_id: str) -> bool: return agent.get("user_id") is None or agent.get("user_id") == user_id or bool(agent.get("published")) + def _is_fetchable(self, agent: dict[str, Any], user_id: str) -> bool: + """Single-record visibility (``get_visible``) — the union of every + listing surface, so a list-visible agent always opens. Mirrors + ``AgentRepository._visible_filter``: baseline visibility OR + private-square (password is the discovery gate, reads are not + re-gated) OR favorited (the "我的" listing keeps favorites even after + the owner unpublishes).""" + return self._is_visible(agent, user_id) or bool(agent.get("is_private")) or (user_id, agent["id"]) in self._favorites + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: rows = [_copy(agent) for agent in self._agents.values() if self._is_visible(agent, user_id)] rows.sort(key=lambda agent: agent.get("created_at") or "") @@ -39,7 +48,7 @@ class MemoryAgentStore(AgentStore): async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None: agent = self._agents.get(agent_id) - if agent is None or not self._is_visible(agent, user_id): + if agent is None or not self._is_fetchable(agent, user_id): return None return _copy(agent) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 1e7564e3..2e662401 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -40,12 +40,27 @@ class AgentRepository(AgentStore): def _visible_filter(user_id: str): """Visibility for single-record fetches (``get_visible``). - Lenient: a caller who already knows the agent id can fetch it even - when it's flagged ``is_private``. The private-square password is the - *discovery* gate (listings); individual reads are not re-gated, so a - bookmarked / linked private agent stays openable. + Lenient — must cover the union of every listing surface, so any agent + a user can see in a list always opens (detail + chat) instead of + 404ing: + + - built-in / owned / published — the baseline gallery visibility; + - ``is_private`` — the private-square password is the *discovery* + gate (listings); individual reads are not re-gated, so a private + agent opened from the square (or a bookmark/link) stays openable. + Note moving an agent into the private square forces + ``published=false``, so this clause cannot lean on ``published``; + - favorited — the "我的" listing keeps favorited agents even after + the owner unpublishes them, so reads keep honoring the favorite. """ - return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True)) + fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) + return or_( + AgentRow.user_id.is_(None), + AgentRow.user_id == user_id, + AgentRow.published.is_(True), + AgentRow.is_private.is_(True), + AgentRow.id.in_(fav_subq), + ) @staticmethod def _listing_filter(user_id: str):