Stop stale sponsor records from vouching for re-added resources

A resource attached in a save now ignores any sponsor recorded for it
before it was removed: the caller must be able to sponsor it and confirm.
YAML import prunes the sponsors of resources it drops, for agents and
workflow graphs. Sponsor details, with the resources' names, go only to
people who may edit the agent. The workflow read returns the names of
every node tool and source, so editors can remove the owner's private
ones. An agent image is stored only once the save is known to go ahead.
This commit is contained in:
arc53-machine committed 2026-09-29 16:49:51 +01:00
1 parent 2f6f4edac2
commit 4d001f6a20
5 files changed
+292 -26

No files matched your search

+35 -9
View File
@@ -1491,18 +1491,24 @@ def _apply_workflow(
if existing_id:
existing = wf_repo.get(str(existing_id), user)
if existing is not None:
from docsgpt.api.user.resource_access import prune_sponsors
from docsgpt.api.user.workflows.routes import _node_refs
pg_workflow_id = str(existing["id"])
next_version = get_workflow_graph_version(existing) + 1
_write_graph(conn, pg_workflow_id, next_version, nodes_data, edges_data)
wf_repo.update(
pg_workflow_id,
user,
{
"name": name,
"description": description,
"current_graph_version": next_version,
},
)
workflow_fields = {
"name": name,
"description": description,
"current_graph_version": next_version,
}
# Sponsors of node resources the file dropped go too, so a stale
# record can't vouch for the resource if someone adds it back.
sponsors = existing.get("resource_sponsors") or {}
pruned = prune_sponsors(sponsors, _node_refs(nodes_data))
if pruned != sponsors:
workflow_fields["resource_sponsors"] = pruned
wf_repo.update(pg_workflow_id, user, workflow_fields)
WorkflowNodesRepository(conn).delete_other_versions(pg_workflow_id, next_version)
WorkflowEdgesRepository(conn).delete_other_versions(pg_workflow_id, next_version)
return pg_workflow_id
@@ -1512,6 +1518,25 @@ def _apply_workflow(
return str(created["id"])
def _prune_agent_sponsors(agents_repo: AgentsRepository, agent_id: str, user: str) -> None:
"""Drop sponsor records of resources the imported agent no longer references.
Args:
agents_repo: Repository on the import's connection.
agent_id: The updated agent.
user: Its owner.
"""
from docsgpt.api.user.resource_access import agent_refs, prune_sponsors
row = agents_repo.get(agent_id, user)
if not row:
return
sponsors = row.get("resource_sponsors") or {}
pruned = prune_sponsors(sponsors, agent_refs(row))
if pruned != sponsors:
agents_repo.update(agent_id, user, {"resource_sponsors": pruned})
def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict:
"""Create or update an agent from a parsed YAML doc.
@@ -1648,6 +1673,7 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
# Only a brand-new agent (the create path below) starts as a draft.
fields = {**authoritative, **optional, "name": spec.get("name")}
if agents_repo.update(str(target["agent_id"]), user, fields):
_prune_agent_sponsors(agents_repo, str(target["agent_id"]), user)
if orphaned_workflow_id and not agents_repo.count_by_workflow(
orphaned_workflow_id, user
):
+22 -13
View File
@@ -566,8 +566,10 @@ class GetAgent(Resource):
ra = resolve(conn, "agent", agent_id, user)
if ra is not None:
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
# Edit-page detail: who vouches for resources the owner can't use.
if agent and ra.can("view"):
# Edit-page detail: who vouches for resources the owner can't
# use, with their names. Only for people who may edit the
# agent: the names can be an editor's private resources.
if agent and ra.can("edit"):
sponsored = sponsor_details(conn, "agent", agent, viewer=user)
if not agent:
return {"status": "Not found"}, 404
@@ -1058,14 +1060,11 @@ class UpdateAgent(Resource):
)
pg_agent_id = str(existing_agent["id"])
existing_image = existing_agent.get("image", "") or ""
image_url, image_error = handle_image_upload(
request,
existing_image,
existing_agent.get("user_id") or user,
storage,
)
if image_error:
return image_error
# The image is stored only once the save is known to go
# ahead, so a refused save (a sponsor confirmation round
# trip, a validation error) leaves no orphaned file.
image_file = request.files.get("image")
has_new_image = bool(image_file and image_file.filename)
update_fields: dict = {}
allowed_fields = [
@@ -1313,9 +1312,7 @@ class UpdateAgent(Resource):
f"Field '{field}' cannot be empty", user, field
)
update_fields[field] = value
if image_url and image_url != existing_image:
update_fields["image"] = image_url
if not update_fields:
if not update_fields and not has_new_image:
return _reject("No valid update data provided", user)
newly_generated_key = None
@@ -1467,6 +1464,18 @@ class UpdateAgent(Resource):
if plan.sponsors != (existing_agent.get("resource_sponsors") or {}):
update_fields["resource_sponsors"] = plan.sponsors
if has_new_image:
image_url, image_error = handle_image_upload(
request,
existing_image,
existing_agent.get("user_id") or user,
storage,
)
if image_error:
return image_error
if image_url and image_url != existing_image:
update_fields["image"] = image_url
# Apply update. Owner writes use the dual-key guard; team-editor
# writes go by-id (already authorized) with an optimistic-lock
# check when the client supplies the row's expected updated_at.