diff --git a/changelog.d/2313-consent-defer-active-handle.md b/changelog.d/2313-consent-defer-active-handle.md new file mode 100644 index 000000000..283311679 --- /dev/null +++ b/changelog.d/2313-consent-defer-active-handle.md @@ -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). diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 8219fa71e..d0046783d 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -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. + 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 diff --git a/tests/test_routes_agent_auth_requests.py b/tests/test_routes_agent_auth_requests.py index 1ab2fb873..31801bda8 100644 --- a/tests/test_routes_agent_auth_requests.py +++ b/tests/test_routes_agent_auth_requests.py @@ -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() diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index 2bd36a9ce..a3645f1a2 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -108,6 +108,7 @@ class CreateAuthRequest(BaseModel): class ApproveBody(BaseModel): granted_scopes: list[str] project_id: Optional[str] = None + defer_binding: bool = False class AssignAgentBody(BaseModel): @@ -365,6 +366,7 @@ async def approve_request_record( decided_by: str, project_id: str | None = None, display_name: str | None = None, + defer_binding: bool = False, ) -> dict: """Register an agent, mint its token, write grants + relationships + membership + a2a sync, and record the decision. @@ -377,11 +379,18 @@ async def approve_request_record( the consent route it is the approving admin's user_id, for invite auto-mode it is the invite's ``created_by``. + When ``defer_binding`` is true the token and grants are minted UNBOUND + (``project_id=None``): the project_id-required 400 guard is skipped, no + membership row or a2a channel is created, and project-scoped calls 403 + until the agent is later bound to a project via + ``POST /api/projects/{id}/members/assign-agent``. This complements the + create-new path (which binds to the picked project at mint time). + Returns ``{"status": "accepted", "canonical_id": ...}``. Raises ``HTTPException`` for the same guard failures as the consent route: - a project-scoped grant without a project_id (400), or an active-handle - collision (409). + a project-scoped grant without a project_id (400, unless deferred), or an + active-handle collision (409). """ auth_store = _get_auth_requests_store(request) @@ -411,14 +420,24 @@ async def approve_request_record( # blank/None project_id is not a real binding and must fail closed exactly # like a missing one; the redeem path passes the invite's project (always # non-empty for a project invite), so it passes this guard. + # + # When ``defer_binding`` is set the operator has explicitly chosen to mint + # the token unbound: the project-scoped grants are written with + # project_id=None and the operator will bind them to a project later via + # POST /api/projects/{id}/members/assign-agent. Skip the 400 in that path. needs_project = bool(set(granted_scopes) & _PROJECT_SCOPES) - if needs_project and not (project_id and project_id.strip()): + if needs_project and not defer_binding and not (project_id and project_id.strip()): missing = sorted(set(granted_scopes) & _PROJECT_SCOPES) raise HTTPException( status_code=400, detail=f"project_id is required when granting {missing}", ) + # When deferring, the token and grants are minted unbound (project_id=None) + # regardless of effective_project; project-scoped calls 403 until the agent + # is bound to a project later via assign-agent. + binding_project = None if defer_binding else effective_project + registry = _get_registry_store(request) private_pem, _public_pem = _get_keypair(request) grants_store = _get_grants_store(request) @@ -453,6 +472,15 @@ async def approve_request_record( # fallback for global scopes) would be safe only by invariant. Gate and # bind on project_id so cross-project escalation is impossible by # construction (kilo review, taOS #1862). + if defer_binding: + raise HTTPException( + status_code=409, + detail=( + f"handle '{handle}' is already in use by active agent " + f"{existing_active['canonical_id']}; use POST /api/projects/{{id}}/members/assign-agent " + f"to bind the existing identity to a project" + ), + ) if project_id and set(granted_scopes) & _PROJECT_SCOPES: existing_cid = existing_active["canonical_id"] token = mint_registry_token( @@ -542,18 +570,22 @@ async def approve_request_record( ), ) - # Issue the identity token. + # Issue the identity token. When deferring, binding_project is None so the + # token carries no project_id claim and is valid for identity/global calls + # only; project-scoped calls 403 until assign-agent binds it. token = mint_registry_token( canonical_id, private_pem, user_id=decided_by, framework=record["framework"], - project_id=effective_project, + project_id=binding_project, ) - # Record grants for each approved scope. + # Record grants for each approved scope. When deferring, grants are written + # unbound (project_id=None); assign-agent later writes the project-bound + # grant that makes project-scoped calls succeed. for scope in granted_scopes: - await grants_store.add_grant(canonical_id, scope, tier="once", project_id=effective_project) + await grants_store.add_grant(canonical_id, scope, tier="once", project_id=binding_project) # Also write a RelationshipManager permission edge so the existing # permission-check path (can_communicate etc.) is aware of the agent. await rel_mgr.set_permission(canonical_id, "taos-instance", scope) @@ -562,8 +594,8 @@ async def approve_request_record( # member of that project so it shows up in the project's Members and joins the # project a2a channel (membership is synced into the channel). Best-effort: a # membership failure never blocks the approval, which the token + grant already - # authorize. - if effective_project and set(granted_scopes) & _PROJECT_SCOPES: + # authorize. Skipped entirely when deferring (binding_project is None). + if binding_project and set(granted_scopes) & _PROJECT_SCOPES: try: pstore = getattr(request.app.state, "project_store", None) if pstore is not None: @@ -578,14 +610,14 @@ async def approve_request_record( # how the consent card scopes are narrowed. if "project_tasks" in granted_scopes or granted_canvas: await pstore.add_member( - project_id=effective_project, + project_id=binding_project, member_id=canonical_id, member_kind="native", role="member", ) if granted_canvas: await pstore.set_member_canvas( - project_id=effective_project, + project_id=binding_project, member_id=canonical_id, can_read=("canvas_read" in granted_canvas), can_write=("canvas_write" in granted_canvas), @@ -596,14 +628,14 @@ async def approve_request_record( await ensure_a2a_channel( request.app.state.chat_channels, pstore, - effective_project, + binding_project, config=getattr(request.app.state, "config", None), ) except Exception: # noqa: BLE001 - membership is best-effort, never blocks approval logger.warning( "auth-approve: could not sync %s membership/a2a channel for project %s", canonical_id, - effective_project, + binding_project, exc_info=True, ) @@ -800,6 +832,15 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user body.project_id if body.project_id is not None else record.get("project_id") ) + # defer_binding + an explicit project_id is contradictory: deferring means + # the operator will bind the identity to a project later, so naming one now + # is a mistake -- reject it up front rather than silently binding anyway. + if body.defer_binding and body.project_id and body.project_id.strip(): + raise HTTPException( + status_code=400, + detail="defer_binding cannot be combined with an explicit project_id", + ) + return await approve_request_record( request, record=record, @@ -807,6 +848,7 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user effective_project=effective_project, decided_by=user.user_id, project_id=body.project_id, + defer_binding=body.defer_binding, )