Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/2313-consent-defer-active-handle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Changed

- Approving an agent auth-request with `defer_binding` now returns 409 when that
agent already has an active handle, and the response points the operator at
`POST /api/projects/{project_id}/members/assign-agent`. It previously advised
minting a second identity, which splits an agent's memory and grants across
two canonical ids (#2313).
9 changes: 9 additions & 0 deletions docs/agent-coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,15 @@ further project via `POST /api/projects/{project_id}/members/assign-agent`
active identity (the existing canonical_id and token are reused instead of
409ing).

Deferred binding and an existing active handle are mutually exclusive. Approving
an auth-request with `defer_binding` mints the token and grants UNBOUND, so the
agent has no project until `assign-agent` binds it. If that agent ALREADY holds
an active handle the approve returns **409** and names
`POST /api/projects/{project_id}/members/assign-agent` as the route to use. Do
not resolve that 409 by minting a second identity: canonical ids are issued once
per agent (`{slug}-{YYYYMMDD}-{HHMMSS}`), and a duplicate splits the agent's
memory and grants across two ids that never reconcile.
Comment on lines +292 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clarify the minting behavior for the 409 path.

The paragraph first states that defer_binding mints an unbound token, then states that an existing active handle returns 409. State that minting applies only when no active handle exists, and that the conflict path must not mint a second identity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/agent-coordination.md` around lines 292 - 299, Update the
deferred-binding paragraph to state that minting the unbound token occurs only
when the agent has no active handle. Explicitly state that the 409 conflict path
must not mint a second identity, and direct users to the existing assign-agent
route.


Reserved name prefixes: registration rejects any name whose slug is or starts
with `user-`, `human-`, `admin-` or `taos-` (including casing, spacing and
punctuation obfuscations like `U s e r`), so an external agent cannot mint an
Expand Down
298 changes: 298 additions & 0 deletions tests/test_routes_agent_auth_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1380,3 +1380,301 @@ async def test_approved_active_handle_is_nonempty(
await registry.close()
await auth_store.close()
await grants.close()


class TestDeferBindingApproval:
"""PR 2187 fix-forward: defer_binding must be wired through _do_approve so the
admin's 'Assign later' choice actually mints an unbound token instead of
silently binding to a project."""

@pytest.mark.asyncio
async def test_defer_with_project_scopes_no_project_id_succeeds_unbound(
self, client, monkeypatch, tmp_path
):
"""defer_binding=true + project scopes + no explicit project_id returns 200
with an unbound token (no project_id claim), unbound grants, no membership
row, and no a2a channel. The project_id-required 400 guard is skipped."""
from tinyagentos.agent_registry_store import (
AgentRegistryStore,
load_or_create_signing_keypair,
verify_registry_token,
)
from tinyagentos.auth_requests_store import AuthRequestsStore
from tinyagentos.agent_grants_store import AgentGrantsStore
from tinyagentos.projects.project_store import ProjectStore

registry = AgentRegistryStore(tmp_path / "reg-defer.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-defer.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-defer.db")
await grants.init()
pstore = ProjectStore(tmp_path / "projects-defer.db")
await pstore.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-defer")

project = await pstore.create_project(
name="Defer Proj", slug="defer-proj", created_by="u"
)

# The agent REQUESTED project_tasks against a project, but the admin
# defers binding: no explicit project_id on approve, defer_binding=true.
record = await auth_store.create(
identity_claim="@defer-bot",
framework="defer-cli",
requested_scopes=["project_tasks"],
requested_skills=None,
reason="",
duration_secs=None,
project_id=project["id"],
)

monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
monkeypatch.setattr(client._transport.app.state, "project_store", pstore)
monkeypatch.setattr(
client._transport.app.state, "agent_registry_keypair", (priv, pub)
)

resp = await client.post(
f"/api/agents/auth-requests/{record['id']}/approve",
json={"granted_scopes": ["project_tasks"], "defer_binding": True},
)
assert resp.status_code == 200, resp.text
cid = resp.json()["canonical_id"]

# Token carries NO project_id claim (minted unbound).
approved = await auth_store.get(record["id"])
claims = verify_registry_token(approved["token"], pub)
assert "project_id" not in claims

# Grants are written UNBOUND (project_id IS NULL).
agent_grants = await grants.list_grants(cid)
assert len(agent_grants) == 1
assert agent_grants[0]["scope"] == "project_tasks"
assert agent_grants[0]["project_id"] is None

# No membership row created for the project.
members = await pstore.list_members(project["id"])
assert len(members) == 0

# No a2a channel created for the project.
channels = await client._transport.app.state.chat_channels.list_channels(
project_id=project["id"]
)
assert not any(c.get("name") == "a2a" for c in channels)

await registry.close()
await auth_store.close()
await grants.close()
await pstore.close()

@pytest.mark.asyncio
async def test_defer_with_explicit_project_id_returns_400(
self, client, monkeypatch, tmp_path
):
"""defer_binding=true combined with an explicit project_id is contradictory
and must 400, rather than silently binding anyway (the bug PR 2187 shipped)."""
from tinyagentos.agent_registry_store import (
AgentRegistryStore,
load_or_create_signing_keypair,
)
from tinyagentos.auth_requests_store import AuthRequestsStore
from tinyagentos.agent_grants_store import AgentGrantsStore
from tinyagentos.projects.project_store import ProjectStore

registry = AgentRegistryStore(tmp_path / "reg-defer-400.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-defer-400.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-defer-400.db")
await grants.init()
pstore = ProjectStore(tmp_path / "projects-defer-400.db")
await pstore.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-defer-400")

project = await pstore.create_project(
name="Defer400", slug="defer-400", created_by="u"
)

record = await auth_store.create(
identity_claim="@defer400-bot",
framework="defer-cli",
requested_scopes=["project_tasks"],
requested_skills=None,
reason="",
duration_secs=None,
project_id=None,
)

monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
monkeypatch.setattr(client._transport.app.state, "project_store", pstore)
monkeypatch.setattr(
client._transport.app.state, "agent_registry_keypair", (priv, pub)
)

resp = await client.post(
f"/api/agents/auth-requests/{record['id']}/approve",
json={
"granted_scopes": ["project_tasks"],
"project_id": project["id"],
"defer_binding": True,
},
)
assert resp.status_code == 400, resp.text
assert "defer_binding" in resp.text

# Nothing should have been registered by the rejected approval.
assert await registry.list_all() == []

await registry.close()
await auth_store.close()
await grants.close()
await pstore.close()

@pytest.mark.asyncio
async def test_non_deferred_project_binding_unchanged(
self, client, monkeypatch, tmp_path
):
"""Without defer_binding, a project-scoped approval binds to the explicit
project_id as before: token carries the project claim, grants are bound,
membership + a2a channel are created."""
from tinyagentos.agent_registry_store import (
AgentRegistryStore,
load_or_create_signing_keypair,
verify_registry_token,
)
from tinyagentos.auth_requests_store import AuthRequestsStore
from tinyagentos.agent_grants_store import AgentGrantsStore
from tinyagentos.projects.project_store import ProjectStore

registry = AgentRegistryStore(tmp_path / "reg-nondefer.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-nondefer.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-nondefer.db")
await grants.init()
pstore = ProjectStore(tmp_path / "projects-nondefer.db")
await pstore.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-nondefer")

project = await pstore.create_project(
name="NonDefer", slug="nondefer-proj", created_by="u"
)

record = await auth_store.create(
identity_claim="@nondefer-bot",
framework="defer-cli",
requested_scopes=["project_tasks"],
requested_skills=None,
reason="",
duration_secs=None,
project_id=project["id"],
)

monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
monkeypatch.setattr(client._transport.app.state, "project_store", pstore)
monkeypatch.setattr(
client._transport.app.state, "agent_registry_keypair", (priv, pub)
)

resp = await client.post(
f"/api/agents/auth-requests/{record['id']}/approve",
json={
"granted_scopes": ["project_tasks"],
"project_id": project["id"],
},
)
assert resp.status_code == 200, resp.text
cid = resp.json()["canonical_id"]

# Token carries the project_id claim.
approved = await auth_store.get(record["id"])
claims = verify_registry_token(approved["token"], pub)
assert claims.get("project_id") == project["id"]

# Grants are bound to the project.
agent_grants = await grants.list_grants(cid)
assert any(
g["scope"] == "project_tasks" and g["project_id"] == project["id"]
for g in agent_grants
)

# Membership row created.
members = await pstore.list_members(project["id"])
assert any(m["member_id"] == cid for m in members)

# a2a channel created.
channels = await client._transport.app.state.chat_channels.list_channels(
project_id=project["id"]
)
assert any(c.get("name") == "a2a" for c in channels)

await registry.close()
await auth_store.close()
await grants.close()
await pstore.close()

@pytest.mark.asyncio
async def test_defer_with_active_handle_returns_409_assign_agent(
self, client, monkeypatch, tmp_path
):
"""defer_binding=true when the handle already maps to an active identity
must 409 and point the operator at assign-agent, not at minting a
duplicate identity."""
from tinyagentos.agent_registry_store import (
AgentRegistryStore,
load_or_create_signing_keypair,
)
from tinyagentos.auth_requests_store import AuthRequestsStore
from tinyagentos.agent_grants_store import AgentGrantsStore

registry = AgentRegistryStore(tmp_path / "reg-defer-active.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-defer-active.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-defer-active.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-defer-active")

existing = await registry.register(
framework="openclaw",
display_name="defer-bot",
user_id="user-existing",
origin="taos-deployed",
handle="defer-bot",
)

record = await auth_store.create(
identity_claim="@defer-bot",
framework="defer-cli",
requested_scopes=["memory_read"],
requested_skills=None,
reason="",
duration_secs=None,
project_id=None,
)

monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
monkeypatch.setattr(
client._transport.app.state, "agent_registry_keypair", (priv, pub)
)

resp = await client.post(
f"/api/agents/auth-requests/{record['id']}/approve",
json={"granted_scopes": ["memory_read"], "defer_binding": True},
)
assert resp.status_code == 409, resp.text
assert "assign-agent" in resp.text
assert "pick a different identity_claim" not in resp.text

await registry.close()
await auth_store.close()
await grants.close()
Loading
Loading