Skip to content
Closed
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
345 changes: 345 additions & 0 deletions tests/test_routes_agent_auth_requests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import pytest


Expand Down Expand Up @@ -531,3 +532,347 @@ async def test_get_unknown_id_returns_404(self, client, monkeypatch):
monkeypatch.setattr(client._transport.app.state, "auth_requests", store)
resp = await client.get("/api/agents/auth-requests/nonexistent123")
assert resp.status_code == 404


class TestHandleSetOnApprove:
"""Slice 7: approve sets the registry handle from the sanitized identity
claim; the new agent then passes the a2a bus 'no handle' gate."""

@pytest.mark.asyncio
async def test_approve_sets_handle_on_registry(self, client, monkeypatch, tmp_path):
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-handle.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-handle.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-handle.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-handle")

record = await auth_store.create(
identity_claim="@taOSmd-dev",
framework="openclaw",
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"]},
)
assert resp.status_code == 200, resp.text

agents = await registry.list_all()
assert len(agents) == 1
assert agents[0]["handle"] == "taosmd-dev"

handle = (agents[0].get("handle") or "").strip()
assert handle, "handle must be set so the a2a 'no handle' gate passes"

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


class TestHandleCollisionActiveRejects:
"""Slice 7: a handle collision with an ACTIVE identity returns 409 and
leaves the auth request PENDING so the approver can pick another variant."""

@pytest.mark.asyncio
async def test_409_when_handle_collides_with_active(
self, client, monkeypatch, tmp_path
):
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-collide.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-collide.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-collide.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-collide")

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

record = await auth_store.create(
identity_claim="@taOSmd-dev",
framework="openclaw",
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"]},
)
assert resp.status_code == 409, resp.text

pending = await auth_store.get(record["id"])
assert pending is not None
assert pending["status"] == "pending"

active_agents = await registry.list_all(status="active")
assert len(active_agents) == 1

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


class TestHandleCollisionSuspendedAllowsReuse:
"""Slice 7: if the previous holder of the handle is SUSPENDED, the handle
may be reused and approval succeeds."""

@pytest.mark.asyncio
async def test_approve_succeeds_after_handle_holder_suspended(
self, client, monkeypatch, tmp_path
):
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-suspended.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-suspended.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-suspended.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-suspended")

old_agent = await registry.register(
framework="openclaw",
display_name="old-agent",
user_id="user-old",
origin="taos-deployed",
handle="taosmd-dev",
)
await registry.set_status(
old_agent["canonical_id"], "suspended", actor="user-old"
)

record = await auth_store.create(
identity_claim="@taOSmd-dev",
framework="openclaw",
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"]},
)
assert resp.status_code == 200, resp.text

new_agents = await registry.list_all()
new_active = [a for a in new_agents if a["status"] == "active"]
assert len(new_active) == 1
assert new_active[0]["handle"] == "taosmd-dev"

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


class TestPartialUniqueHandleIndex:
"""The DB (not just the application pre-check) must reject a duplicate
active handle, so two concurrent approvals of the same identity_claim
cannot both become active with identical handles (a2a 'from' spoofing)."""

@pytest.mark.asyncio
async def test_index_blocks_two_active_with_same_handle(self, tmp_path):
from tinyagentos.agent_registry_store import AgentRegistryStore

registry = AgentRegistryStore(tmp_path / "reg-idx.db")
await registry.init()

# Both born pending with the same handle (allowed: index only covers
# active + non-empty handle).
a = await registry.register(
framework="openclaw", display_name="a", origin="external-selfjoin",
handle="dup-handle",
)
b = await registry.register(
framework="openclaw", display_name="b", origin="external-selfjoin",
handle="dup-handle",
)
await registry.set_status(a["canonical_id"], "active")

# The second activation must be rejected by the partial unique index.
with pytest.raises(Exception) as exc:
await registry.set_status(b["canonical_id"], "active")
assert "ux_agent_active_handle" in str(exc.value) or "UNIQUE" in str(exc.value)

# And the loser must never be left active with the handle.
b_row = await registry.get(b["canonical_id"])
assert b_row["status"] == "pending"
await registry.close()


class TestConcurrentApproveSameIdentity:
"""Two approvals racing on the SAME identity_claim: exactly one active
agent with the handle set, the other returns 409 and leaves no extra
active (or pending) agent behind."""

@pytest.mark.asyncio
async def test_concurrent_approve_one_active_other_409(
self, client, monkeypatch, tmp_path
):
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-race.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-race.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-race.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-race")

# Two independent auth requests for the SAME identity_claim. The
# per-request lock does NOT protect this case (different request ids),
# so this exercises the unique-index race directly.
r1 = await auth_store.create(
identity_claim="@taOSmd-dev", framework="openclaw",
requested_scopes=["memory_read"], requested_skills=None, reason="",
duration_secs=None, project_id=None,
)
r2 = await auth_store.create(
identity_claim="@taOSmd-dev", framework="openclaw",
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)
)

async def _approve(rid):
return await client.post(
f"/api/agents/auth-requests/{rid}/approve",
json={"granted_scopes": ["memory_read"]},
)

# Drive both approvals concurrently so neither pre-check sees an active
# handle yet; the unique index decides the winner.
resps = await asyncio.gather(_approve(r1["id"]), _approve(r2["id"]))

statuses = sorted(r.status_code for r in resps)
assert statuses == [200, 409], [r.status_code for r in resps]

active = await registry.list_all(status="active")
assert len(active) == 1, active
# The winner's handle is set and non-empty.
assert active[0]["handle"] == "taosmd-dev"
assert active[0]["handle"]

# The loser is not left behind as an active or pending agent.
assert len(await registry.list_all()) == 1

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

@pytest.mark.asyncio
async def test_approved_active_handle_is_nonempty(
self, client, monkeypatch, tmp_path
):
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-nonempty.db")
await registry.init()
auth_store = AuthRequestsStore(tmp_path / "auth-nonempty.db")
await auth_store.init()
grants = AgentGrantsStore(tmp_path / "grants-nonempty.db")
await grants.init()
priv, pub = load_or_create_signing_keypair(tmp_path / "keys-nonempty")

record = await auth_store.create(
identity_claim="grok-bot", framework="grok",
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"]},
)
assert resp.status_code == 200, resp.text

active = await registry.list_all(status="active")
assert len(active) == 1
# Never left active with an empty handle (the orphan-window bug).
assert active[0]["handle"]
assert active[0]["handle"] == "grok-bot"

await registry.close()
await auth_store.close()
await grants.close()
30 changes: 30 additions & 0 deletions tinyagentos/agent_registry_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
revoked_at TEXT,
status TEXT NOT NULL DEFAULT 'active'
);

-- Partial unique index: at most ONE active agent may own any given non-empty
-- handle. SQLite enforces this atomically, so two concurrent consent approvals
-- of the same identity_claim cannot both flip to 'active' with the same handle.
-- Pending / suspended / revoked rows and empty handles are excluded from the
-- index, so (a) a handle can be reused once its owner leaves 'active', and (b)
-- pre-existing empty-handle active agents never block the index's creation.
CREATE UNIQUE INDEX IF NOT EXISTS ux_agent_active_handle

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Unique index creation can abort init() on existing databases with duplicate active handles

This CREATE UNIQUE INDEX IF NOT EXISTS ux_agent_active_handle runs unconditionally inside SCHEMA via executescript at init() (base_store.py:38), and the WHERE status='active' AND handle!='' guard only excludes empty-handle rows. The earlier, unindexed version of this very feature (flagged by the prior review) could already persist two ACTIVE rows sharing a non-empty handle, so any database written in that window will fail the index build with IntegrityError and abort init(), taking down the whole registry store on upgrade.

Build this in a guarded migration that first detects/clears duplicate active handles (e.g. keep the earliest, rename or suspend the rest) before creating the index, so init() cannot fail on pre-existing bad data. The test in TestPartialUniqueHandleIndex only covers fresh DBs, not the upgrade path.

Minor note (route agent_auth_requests.py:381): the rollback() here is effectively a no-op because register() already committed the pending row (agent_registry_store.py:424) — the real cleanup is the delete() call. The surrounding comment is slightly inaccurate but harmless.

ON agent_registry(handle)
WHERE status = 'active' AND handle != '';
"""

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -425,6 +435,26 @@ async def register(
# Read
# ------------------------------------------------------------------

async def rollback(self) -> None:
"""Roll back the current transaction, clearing any failed write so the
connection can accept new statements. No-op if the store is closed or
no transaction is open.
"""
if self._db is not None:
await self._db.rollback()

async def delete(self, canonical_id: str) -> None:
"""Permanently remove *canonical_id* (used to clean up a half-registered
row when a concurrent approve wins the active-handle race). Returns
silently if *canonical_id* does not exist.
"""
if self._db is None:
raise RuntimeError("AgentRegistryStore not initialised")
await self._db.execute(
"DELETE FROM agent_registry WHERE canonical_id = ?", (canonical_id,)
)
await self._db.commit()

async def get(self, canonical_id: str) -> Optional[dict]:
"""Return the record for *canonical_id*, or ``None``."""
if self._db is None:
Expand Down
Loading