From da3feb9b8247b11c35010364b4e43539373237cd Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:54:23 +0200 Subject: [PATCH 1/4] feat(collab): add member_kind=human + collab invite kind + two-sided consent (B1) - Widen project_store add_member validation from (native, clone) to (native, clone, human), accepting contact_id as member_id. - Add kind (agent|collab), pin_required (bool), and contact_id columns to project_invites table with boot-time migrations for existing DBs. - Extend ProjectInviteStore.mint() with kind, pin_required, and contact_id parameters, plus mark_accepted() for collab response flow. - Add POST /api/projects/{id}/invites/collab route for minting collab invites and delivering them as signed envelopes over the peer channel. - Update peer inbox to dispatch collab_invite envelopes to Decisions cards (approve_deny type) and handle collab_invite_accept/decline response envelopes (add member_kind=human on accept, expire on decline). - Widen projects routes add_member to accept mode=human with contact_id field. - Add 12 tests covering human member kind, collab invite minting, migration safety, and mark_accepted flow. Reuses #2002 invite state machine. Design: docs/design/cross-user-collaboration.md --- tests/projects/test_invite_store.py | 183 +++++++++++++++++++++++++ tests/projects/test_project_store.py | 49 +++++++ tinyagentos/projects/invite_store.py | 82 +++++++++++- tinyagentos/projects/project_store.py | 2 +- tinyagentos/routes/peer.py | 186 +++++++++++++++++++++++++- tinyagentos/routes/project_invites.py | 181 +++++++++++++++++++++++++ tinyagentos/routes/projects.py | 12 +- 7 files changed, 685 insertions(+), 10 deletions(-) diff --git a/tests/projects/test_invite_store.py b/tests/projects/test_invite_store.py index d192216ee..0f9f40415 100644 --- a/tests/projects/test_invite_store.py +++ b/tests/projects/test_invite_store.py @@ -539,3 +539,186 @@ async def test_rollback_only_touches_claimed(store): await store.rollback_to_pending(iid) row = await store.get(iid) assert row["status"] == "redeemed" # stays redeemed + + +# --------------------------------------------------------------------------- +# B1: collab invite kind + pin_required + contact_id +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_mint_collab_invite(store): + result = await store.mint( + project_id="prj-1", + scopes=[], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + pin_required=True, + contact_id="hub:hogne", + ) + assert result["record"]["kind"] == "collab" + assert result["record"]["pin_required"] == 1 + assert result["record"]["contact_id"] == "hub:hogne" + assert result["record"]["status"] == "pending" + assert len(result["pin"]) == 4 + + +@pytest.mark.asyncio +async def test_mint_collab_invite_no_pin_required(store): + result = await store.mint( + project_id="prj-1", + scopes=[], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + pin_required=False, + contact_id="hub:hogne", + ) + assert result["record"]["pin_required"] == 0 + + +@pytest.mark.asyncio +async def test_mint_rejects_invalid_kind(store): + with pytest.raises(ValueError, match="invalid invite kind"): + await store.mint( + project_id="prj-1", + scopes=[], + approval_mode="auto", + check_interval_secs=1800, + created_by="u", + kind="invalid", + ) + + +@pytest.mark.asyncio +async def test_default_kind_is_agent(store): + result = await store.mint( + project_id="prj-1", + scopes=["a2a_send"], + approval_mode="auto", + check_interval_secs=1800, + created_by="u", + ) + assert result["record"]["kind"] == "agent" + assert result["record"]["pin_required"] == 1 + assert result["record"]["contact_id"] is None + + +@pytest.mark.asyncio +async def test_list_pending_collab_for_contact(store): + await store.mint( + project_id="prj-a", + scopes=[], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + contact_id="hub:hogne", + ) + await store.mint( + project_id="prj-b", + scopes=[], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + contact_id="hub:hogne", + ) + # Agent-kind invite for the same contact — should NOT appear in collab list + await store.mint( + project_id="prj-c", + scopes=["a2a_send"], + approval_mode="auto", + check_interval_secs=1800, + created_by="u", + kind="agent", + contact_id="hub:hogne", + ) + items = await store.list_pending_collab_for_contact("hub:hogne") + assert len(items) == 2 + for item in items: + assert item["kind"] == "collab" + assert item["contact_id"] == "hub:hogne" + assert "pin_hash" not in item + + +@pytest.mark.asyncio +async def test_list_pending_collab_for_unknown_contact(store): + items = await store.list_pending_collab_for_contact("hub:nobody") + assert items == [] + + +@pytest.mark.asyncio +async def test_mark_accepted_flips_to_redeemed(store): + result = await store.mint( + project_id="prj-1", + scopes=[], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + contact_id="hub:hogne", + ) + iid = result["record"]["invite_id"] + # Claim it + await store.redeem(iid, result["pin"]) + # mark_accepted should flip claimed → redeemed + await store.mark_accepted(iid, "hub:hogne") + row = await store.get(iid) + assert row["status"] == "redeemed" + assert row["redeemed_by"] == "hub:hogne" + + +@pytest.mark.asyncio +async def test_boot_migration_adds_kind_pin_required_contact_id(tmp_path): + """A legacy DB without kind/pin_required/contact_id columns must survive init + with defaults applied.""" + db_path = tmp_path / "legacy_invites_b1.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE project_invites ( + invite_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + pin_hash TEXT NOT NULL, + scopes TEXT NOT NULL, + approval_mode TEXT NOT NULL, + check_interval_secs INTEGER, + created_by TEXT NOT NULL, + created_ts REAL NOT NULL, + expires_ts REAL NOT NULL, + redeem_attempts INTEGER DEFAULT 0, + status TEXT NOT NULL, + redeemed_by TEXT, + redeemed_request_id TEXT, + display_name TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO project_invites + (invite_id, project_id, pin_hash, scopes, approval_mode, + check_interval_secs, created_by, created_ts, expires_ts, + redeem_attempts, status, redeemed_by) + VALUES ('000001', 'prj-1', 'abc', '[]', 'auto', 1800, 'u', 1.0, ?, 0, 'pending', NULL) + """, + (time.time() + 900,), + ) + conn.commit() + conn.close() + + store = ProjectInviteStore(db_path) + await store.init() + row = await store.get("000001") + assert row is not None + assert row["status"] == "pending" + # Defaults applied by migration + assert row.get("kind") == "agent" + assert row.get("pin_required") == 1 + assert row.get("contact_id") is None + await store.close() diff --git a/tests/projects/test_project_store.py b/tests/projects/test_project_store.py index ce6e13c25..e203cc35b 100644 --- a/tests/projects/test_project_store.py +++ b/tests/projects/test_project_store.py @@ -165,3 +165,52 @@ async def test_backfill_only_runs_for_null_lead(tmp_path): assert (await s.get_project(p2["id"]))["lead_member_id"] is None finally: await s.close() + + +# --------------------------------------------------------------------------- +# B1: member_kind="human" +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_human_member(store): + p = await store.create_project(name="Shared", slug="shared", created_by="u") + await store.add_member( + p["id"], + member_id="hub:hogne", + member_kind="human", + role="member", + ) + members = await store.list_members(p["id"]) + assert len(members) == 1 + assert members[0]["member_id"] == "hub:hogne" + assert members[0]["member_kind"] == "human" + assert members[0]["source_agent_id"] is None + assert members[0]["memory_seed"] == "none" + + +@pytest.mark.asyncio +async def test_add_member_rejects_invalid_kind(store): + p = await store.create_project(name="A", slug="a", created_by="u") + with pytest.raises(ValueError): + await store.add_member(p["id"], member_id="x", member_kind="bot") + + +@pytest.mark.asyncio +async def test_human_member_can_coexist_with_native(store): + p = await store.create_project(name="Mixed", slug="mixed", created_by="u") + await store.add_member(p["id"], member_id="agent-1", member_kind="native") + await store.add_member(p["id"], member_id="hub:hogne", member_kind="human") + members = await store.list_members(p["id"]) + assert len(members) == 2 + kinds = {m["member_kind"] for m in members} + assert kinds == {"native", "human"} + + +@pytest.mark.asyncio +async def test_remove_human_member(store): + p = await store.create_project(name="A", slug="a", created_by="u") + await store.add_member(p["id"], member_id="hub:hogne", member_kind="human") + await store.remove_member(p["id"], "hub:hogne") + members = await store.list_members(p["id"]) + assert len(members) == 0 diff --git a/tinyagentos/projects/invite_store.py b/tinyagentos/projects/invite_store.py index 0ef9ab208..338e5558a 100644 --- a/tinyagentos/projects/invite_store.py +++ b/tinyagentos/projects/invite_store.py @@ -29,12 +29,18 @@ status TEXT NOT NULL, redeemed_by TEXT, redeemed_request_id TEXT, - display_name TEXT + display_name TEXT, + kind TEXT NOT NULL DEFAULT 'agent', + pin_required INTEGER NOT NULL DEFAULT 1, + contact_id TEXT ); """ MIGRATIONS: list = [] +# Valid values for the invite kind column. +_VALID_KINDS = frozenset({"agent", "collab"}) + class InvitePinError(Exception): pass @@ -89,6 +95,21 @@ async def _post_init(self) -> None: "ALTER TABLE project_invites ADD COLUMN display_name TEXT" ) await self._db.commit() + if "kind" not in existing_cols: + await self._db.execute( + "ALTER TABLE project_invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'agent'" + ) + await self._db.commit() + if "pin_required" not in existing_cols: + await self._db.execute( + "ALTER TABLE project_invites ADD COLUMN pin_required INTEGER NOT NULL DEFAULT 1" + ) + await self._db.commit() + if "contact_id" not in existing_cols: + await self._db.execute( + "ALTER TABLE project_invites ADD COLUMN contact_id TEXT" + ) + await self._db.commit() await self._db.execute( "CREATE INDEX IF NOT EXISTS idx_project_invites_project " "ON project_invites(project_id)" @@ -107,10 +128,18 @@ def _generate_pin(self) -> str: async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, check_interval_secs: int, created_by: str, - display_name: str | None = None) -> dict: + display_name: str | None = None, + kind: str = "agent", + pin_required: bool = True, + contact_id: str | None = None) -> dict: if self._db is None: raise RuntimeError("ProjectInviteStore not initialised") + if kind not in _VALID_KINDS: + raise ValueError( + f"invalid invite kind: {kind!r} — must be one of {sorted(_VALID_KINDS)}" + ) + # The pending cap is per-scope: project-scoped invites are capped per # project, OS-level (project_id IS NULL) invites are capped as a group. # SQL ``= NULL`` never matches, so branch on IS NULL to keep the cap live @@ -158,8 +187,8 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, INSERT INTO project_invites (invite_id, project_id, pin_hash, scopes, approval_mode, check_interval_secs, created_by, created_ts, expires_ts, status, - display_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?) + display_name, kind, pin_required, contact_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?) """, ( invite_id, @@ -172,6 +201,9 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, now, expires_ts, display_name, + kind, + int(pin_required), + contact_id, ), ) await self._db.commit() @@ -204,6 +236,9 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, "redeemed_by": None, "redeemed_request_id": None, "display_name": display_name, + "kind": kind, + "pin_required": int(pin_required), + "contact_id": contact_id, }, "pin": pin, } @@ -256,6 +291,26 @@ async def list_os_level(self) -> list[dict]: result.append(d) return result + async def list_pending_collab_for_contact( + self, contact_id: str + ) -> list[dict]: + """List pending collab-kind invites addressed to a specific contact.""" + if self._db is None: + raise RuntimeError("ProjectInviteStore not initialised") + cursor = await self._db.execute( + "SELECT * FROM project_invites " + "WHERE kind = 'collab' AND contact_id = ? AND status = 'pending' " + "ORDER BY created_ts DESC", + (contact_id,), + ) + rows = await cursor.fetchall() + result = [] + for row in rows: + d = self._row_to_dict(row) + d.pop("pin_hash", None) + result.append(d) + return result + async def revoke(self, invite_id: str) -> bool: if self._db is None: raise RuntimeError("ProjectInviteStore not initialised") @@ -341,6 +396,25 @@ async def mark_redeemed( ) await self._db.commit() + async def mark_accepted(self, invite_id: str, accepted_by: str) -> None: + """Record who accepted a collab invite and flip claimed→redeemed. + + Same pattern as ``mark_redeemed`` but carried on the acceptance response + envelope rather than a redeem route call. Only touches invites in + 'claimed' status. + """ + if self._db is None: + raise RuntimeError("ProjectInviteStore not initialised") + await self._db.execute( + """ + UPDATE project_invites + SET redeemed_by = ?, status = 'redeemed' + WHERE invite_id = ? AND status = 'claimed' + """, + (accepted_by, invite_id), + ) + await self._db.commit() + async def rollback_to_pending(self, invite_id: str) -> None: """Roll back a claimed invite to pending so the caller can retry after a failed approve (issue #1993). Only touches invites in 'claimed' status.""" diff --git a/tinyagentos/projects/project_store.py b/tinyagentos/projects/project_store.py index d6b006b1e..3fe1190d3 100644 --- a/tinyagentos/projects/project_store.py +++ b/tinyagentos/projects/project_store.py @@ -261,7 +261,7 @@ async def add_member( source_agent_id: str | None = None, memory_seed: str = "none", ) -> None: - if member_kind not in ("native", "clone"): + if member_kind not in ("native", "clone", "human"): raise ValueError(f"invalid member_kind: {member_kind}") if memory_seed not in ("none", "snapshot", "empty"): raise ValueError(f"invalid memory_seed: {memory_seed}") diff --git a/tinyagentos/routes/peer.py b/tinyagentos/routes/peer.py index 013dc73ff..c23df2642 100644 --- a/tinyagentos/routes/peer.py +++ b/tinyagentos/routes/peer.py @@ -214,11 +214,19 @@ async def peer_inbox(body: PeerEnvelope, request: Request): # Mark peer as seen await store.mark_peer_seen(contact_id) - # Log the envelope kind for debugging; the real dispatch (collab invites, - # chat messages, etc.) happens in later milestones. + # Dispatch the envelope by kind. kind = envelope.get("kind", "unknown") + body_data = envelope.get("body", {}) + + if kind == "collab_invite": + return await _handle_collab_invite(request, contact_id, envelope, body_data) + + if kind in ("collab_invite_accept", "collab_invite_decline"): + return await _handle_collab_response(request, contact_id, envelope, body_data, kind) + + # Log unrecognised kinds for debugging; they are accepted but not dispatched. logger.info( - "peer_inbox: contact=%s kind=%s nonce=%s", + "peer_inbox: contact=%s kind=%s nonce=%s (unrecognised kind — accepted, no dispatch)", contact_id, kind, envelope.get("nonce", "?"), ) @@ -338,3 +346,175 @@ async def peer_ack(body: PeerAck, request: Request): ) return {"status": "acked", "envelope_id": body.envelope_id} + + +# --------------------------------------------------------------------------- +# Collab invite dispatch handlers +# --------------------------------------------------------------------------- + + +async def _handle_collab_invite( + request: Request, + contact_id: str, + envelope: dict, + body_data: dict, +) -> dict: + """Handle an incoming collab_invite envelope — create a Decisions card + so the local human can accept or decline the invitation. + + The envelope body is expected to contain: + invite_id, project_id, project_name, project_slug, inviter, + pin_required, display_name + """ + decision_store = getattr(request.app.state, "decision_store", None) + if decision_store is None: + logger.warning( + "peer_inbox: collab_invite received but decision_store not available" + ) + return {"status": "received", "kind": "collab_invite", "dispatched": False} + + invite_id = body_data.get("invite_id", "unknown") + project_name = body_data.get("project_name", "unknown project") + inviter = body_data.get("inviter", contact_id) + pin_required = body_data.get("pin_required", True) + + question = ( + f"{inviter} invites you to collaborate on project " + f"\"{project_name}\" as a human member. " + "You will appear in the members list, can chat in the project, " + "and can later delegate agents to work on it." + ) + + options = [ + {"label": "Accept", "value": "accept", "recommended": True, + "rationale": "Join the project as a collaborator"}, + {"label": "Decline", "value": "decline", + "rationale": "Decline the invitation"}, + ] + + metadata: dict = { + "envelope_kind": "collab_invite", + "invite_id": invite_id, + "inviter": inviter, + "contact_id": contact_id, + } + for k in ("project_id", "project_slug", "pin_required", "display_name"): + if k in body_data: + metadata[k] = body_data[k] + + try: + decision = await decision_store.create( + from_agent=f"peer:{contact_id}", + question=question, + type="approve_deny", + options=options, + priority="blocking", + metadata=metadata, + ) + logger.info( + "peer_inbox: collab_invite → decision %s created for contact=%s", + decision["id"], contact_id, + ) + except Exception as exc: + logger.error( + "peer_inbox: failed to create decision for collab_invite: %s", exc + ) + return { + "status": "received", + "kind": "collab_invite", + "dispatched": False, + "error": str(exc), + } + + return { + "status": "received", + "kind": "collab_invite", + "decision_id": decision["id"], + "dispatched": True, + } + + +async def _handle_collab_response( + request: Request, + contact_id: str, + envelope: dict, + body_data: dict, + kind: str, +) -> dict: + """Handle a collab invite response envelope (accept or decline). + + On accept: add the contact as member_kind=\"human\" to the project. + On decline: mark the invite as expired. + + The envelope body is expected to contain: + invite_id, project_id, accepted (bool) + """ + invite_id = body_data.get("invite_id", "") + project_id = body_data.get("project_id", "") + accepted = body_data.get("accepted", kind == "collab_invite_accept") + + if not invite_id or not project_id: + logger.warning( + "peer_inbox: %s missing invite_id or project_id from contact=%s", + kind, contact_id, + ) + return {"status": "received", "kind": kind, "dispatched": False} + + project_store = getattr(request.app.state, "project_store", None) + invite_store = getattr(request.app.state, "project_invites", None) + + if accepted: + # ---- Accept: add the contact as member_kind="human" ---- + if project_store is None: + return {"status": "received", "kind": kind, "dispatched": False, + "error": "project_store not available"} + + try: + await project_store.add_member( + project_id=project_id, + member_id=contact_id, + member_kind="human", + role="member", + ) + await project_store.log_activity( + project_id, contact_id, "member.added", + {"member_id": contact_id, "kind": "human", "via": "collab_invite"}, + ) + logger.info( + "peer_inbox: %s → added %s as human member to project %s", + kind, contact_id, project_id, + ) + except Exception as exc: + logger.error( + "peer_inbox: failed to add member for %s: %s", kind, exc + ) + return { + "status": "received", "kind": kind, "dispatched": False, + "error": str(exc), + } + + # Mark the invite as redeemed. + if invite_store is not None: + try: + await invite_store.mark_accepted(invite_id, contact_id) + except Exception: + pass # audit best-effort + else: + # ---- Decline: mark invite as expired ---- + if invite_store is not None: + try: + row = await invite_store.get(invite_id) + if row and row.get("status") == "pending": + await invite_store._db.execute( + "UPDATE project_invites SET status = 'expired' WHERE invite_id = ?", + (invite_id,), + ) + await invite_store._db.commit() + except Exception: + pass # audit best-effort + logger.info( + "peer_inbox: %s → invite %s declined by %s", + kind, invite_id, contact_id, + ) + + return {"status": "received", "kind": kind, "dispatched": True} diff --git a/tinyagentos/routes/project_invites.py b/tinyagentos/routes/project_invites.py index 45fa88f31..0d55dcd9b 100644 --- a/tinyagentos/routes/project_invites.py +++ b/tinyagentos/routes/project_invites.py @@ -796,6 +796,187 @@ async def revoke_os_invite( return JSONResponse(content=None, status_code=204) +# --------------------------------------------------------------------------- +# Collab invites — human collaborator invitations over the peer channel +# --------------------------------------------------------------------------- + + +class MintCollabInviteIn(BaseModel): + contact_id: str = Field(..., description="Hub contact ID, e.g. hub:hogne") + pin_required: bool = Field(default=True, description="Require PIN verification") + display_name: str | None = Field(default=None, max_length=64) + + @field_validator("contact_id") + @classmethod + def _check_contact_id(cls, v: str) -> str: + if not v.startswith("hub:"): + raise ValueError("contact_id must start with 'hub:'") + return v + + @field_validator("display_name") + @classmethod + def _clean_display_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + if any(ord(c) < 0x20 for c in v): + raise ValueError("display_name must not contain control characters") + return v + + +@router.post("/api/projects/{project_id}/invites/collab") +async def mint_collab_invite( + project_id: str, + payload: MintCollabInviteIn, + request: Request, + user: CurrentUser = Depends(current_user), +): + """Mint a collab-kind invite for a human contact and deliver it as a signed + envelope over the peer channel. + + The inviter consents by minting; the invitee consents via a Decisions card + generated on their instance when the envelope arrives. The PIN (if + pin_required) is delivered out of band — the envelope body carries the + invite_id and project info but not the PIN itself. + """ + project_store = request.app.state.project_store + invite_store = request.app.state.project_invites + contacts_store = getattr(request.app.state, "contacts_store", None) + + # ---- validate project ownership ---- + project = await project_store.get_project(project_id) + if project is None: + return JSONResponse({"error": "project not found"}, status_code=404) + require_owner_or_admin(user, project["user_id"]) + + # ---- validate contact exists and is active ---- + if contacts_store is None: + return JSONResponse( + {"error": "peer channel not available — contacts store not configured"}, + status_code=503, + ) + contact = await contacts_store.get_contact(payload.contact_id) + if contact is None: + return JSONResponse( + {"error": f"contact {payload.contact_id} not found"}, + status_code=404, + ) + if contact.get("status") != "active": + return JSONResponse( + {"error": f"contact {payload.contact_id} is not active"}, + status_code=400, + ) + + # ---- check no duplicate pending collab invite for this contact+project ---- + existing = await invite_store.list_pending_collab_for_contact(payload.contact_id) + for inv in existing: + if inv.get("project_id") == project_id: + return JSONResponse( + {"error": "a pending collab invite already exists for this contact+project"}, + status_code=409, + ) + + # ---- mint the invite ---- + collab_scopes: list[str] = [] # human collaborators need no agent scopes + try: + result = await invite_store.mint( + project_id=project_id, + scopes=collab_scopes, + approval_mode="manual", # collab invites always require consent + check_interval_secs=1800, + created_by=user.user_id, + display_name=payload.display_name, + kind="collab", + pin_required=payload.pin_required, + contact_id=payload.contact_id, + ) + except InvitePendingCapError as exc: + return JSONResponse({"error": str(exc)}, status_code=429) + + record = result["record"] + pin = result["pin"] + + # ---- deliver envelope over peer channel ---- + envelope: dict | None = None + delivery_error: str | None = None + try: + from tinyagentos.peer import build_envelope, resolve_local_identity_id + + # Resolve the local hub identity and the contact's hub username. + contact_username = contact.get("hub_username", "") + local_id = resolve_local_identity_id(request.app.state.data_dir) + if local_id is None: + delivery_error = "hub identity not configured" + else: + local_username = local_id.removeprefix("hub:") + envelope_body = { + "invite_id": record["invite_id"], + "project_id": project_id, + "project_name": project.get("name", ""), + "project_slug": project.get("slug", ""), + "inviter": local_id, + "pin_required": payload.pin_required, + "display_name": payload.display_name, + } + envelope = build_envelope( + from_username=local_username, + to_username=contact_username, + kind="collab_invite", + body=envelope_body, + ) + + # POST the envelope to the remote contact's peer inbox. + # The peer link's endpoints are tried in priority order. + peer_link = await contacts_store.get_peer_link(payload.contact_id) + if peer_link is None or not peer_link.get("endpoints"): + delivery_error = "no peer endpoints for contact" + else: + import httpx + + outbound_token = peer_link.get("outbound_token", "") + delivered = False + for ep in sorted( + peer_link["endpoints"], + key=lambda e: e.get("priority", 99), + ): + inbox_url = f"{ep['url'].rstrip('/')}/api/peer/inbox" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + inbox_url, + json={"envelope": envelope}, + headers={ + "Authorization": f"Bearer {outbound_token}", + "Content-Type": "application/json", + }, + ) + if resp.status_code < 500: + delivered = True + break + except Exception: + continue + if not delivered: + delivery_error = ( + "failed to deliver invite envelope to any peer endpoint" + ) + except Exception as exc: + delivery_error = f"envelope delivery failed: {exc}" + + response = { + "invite_id": record["invite_id"], + "pin": pin if payload.pin_required else None, + "expires_ts": record["expires_ts"], + "kind": "collab", + "contact_id": payload.contact_id, + "delivered": delivery_error is None, + } + if delivery_error: + response["delivery_error"] = delivery_error + return response + + # --------------------------------------------------------------------------- # Redeem (auth-EXEMPT) + content-negotiated invite advert # --------------------------------------------------------------------------- diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py index 401d22893..a593c7a89 100644 --- a/tinyagentos/routes/projects.py +++ b/tinyagentos/routes/projects.py @@ -217,11 +217,12 @@ async def delete_project( class AddMemberIn(BaseModel): - mode: str # "native" | "clone" + mode: str # "native" | "clone" | "human" agent_id: str | None = None source_agent_id: str | None = None clone_memory: bool = True role: str = "member" + contact_id: str | None = None # for mode="human": the contact's hub id @router.post("/api/projects/{project_id}/members") @@ -251,8 +252,15 @@ async def add_member( member_kind = "clone" source_agent_id = payload.source_agent_id memory_seed = "snapshot" if payload.clone_memory else "empty" + elif payload.mode == "human": + if not payload.contact_id: + return JSONResponse({"error": "contact_id required for human members"}, status_code=400) + member_id = payload.contact_id + member_kind = "human" + source_agent_id = None + memory_seed = "none" else: - return JSONResponse({"error": "mode must be native|clone"}, status_code=400) + return JSONResponse({"error": "mode must be native|clone|human"}, status_code=400) await store.add_member( project_id=project_id, From d4d2be653379023c58eef66e1cd9423142cac8df Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:00:38 +0200 Subject: [PATCH 2/4] fix(collab): resolve 5 Kilo findings on B1 collab invite flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes: - Accept path now verifies invite exists, is pending, and matches project_id + contact_id before adding member (broken access control). - mark_accepted now accepts both 'pending' and 'claimed' status so collab invites (minted as pending) transition correctly. WARNING fixes: - Delivery status check changed from <500 to 200-299 so 4xx failures are not misreported as success. - Decline path uses new mark_expired() store method instead of reaching into private _db attribute. - Empty hub_username is now guarded before building envelope. Added: mark_expired() store method, updated tests for direct pending→redeemed mark_accepted, and from-claimed path. --- tests/projects/test_invite_store.py | 47 +++++++++++++++++++++++++-- tinyagentos/projects/invite_store.py | 22 ++++++++++--- tinyagentos/routes/peer.py | 25 ++++++++++---- tinyagentos/routes/project_invites.py | 8 +++-- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/tests/projects/test_invite_store.py b/tests/projects/test_invite_store.py index 0f9f40415..713dbf3fe 100644 --- a/tests/projects/test_invite_store.py +++ b/tests/projects/test_invite_store.py @@ -663,15 +663,58 @@ async def test_mark_accepted_flips_to_redeemed(store): contact_id="hub:hogne", ) iid = result["record"]["invite_id"] - # Claim it + # mark_accepted works directly from pending for collab invites + await store.mark_accepted(iid, "hub:hogne") + row = await store.get(iid) + assert row["status"] == "redeemed" + assert row["redeemed_by"] == "hub:hogne" + + +@pytest.mark.asyncio +async def test_mark_accepted_from_claimed(store): + """mark_accepted also works on claimed invites (agent flow that was claimed first).""" + result = await store.mint( + project_id="prj-1", + scopes=["a2a_send"], + approval_mode="auto", + check_interval_secs=1800, + created_by="u", + kind="agent", + ) + iid = result["record"]["invite_id"] + # Claim first (agent redeem path) await store.redeem(iid, result["pin"]) - # mark_accepted should flip claimed → redeemed + row = await store.get(iid) + assert row["status"] == "claimed" + # mark_accepted should still work from claimed await store.mark_accepted(iid, "hub:hogne") row = await store.get(iid) assert row["status"] == "redeemed" assert row["redeemed_by"] == "hub:hogne" +@pytest.mark.asyncio +async def test_mark_expired_pending_invite(store): + result = await store.mint( + project_id="prj-1", + scopes=["a2a_send"], + approval_mode="auto", + check_interval_secs=1800, + created_by="u", + ) + iid = result["record"]["invite_id"] + ok = await store.mark_expired(iid) + assert ok is True + row = await store.get(iid) + assert row["status"] == "expired" + + +@pytest.mark.asyncio +async def test_mark_expired_nonexistent(store): + ok = await store.mark_expired("000000") + assert ok is False + + @pytest.mark.asyncio async def test_boot_migration_adds_kind_pin_required_contact_id(tmp_path): """A legacy DB without kind/pin_required/contact_id columns must survive init diff --git a/tinyagentos/projects/invite_store.py b/tinyagentos/projects/invite_store.py index 338e5558a..454c199a3 100644 --- a/tinyagentos/projects/invite_store.py +++ b/tinyagentos/projects/invite_store.py @@ -397,11 +397,12 @@ async def mark_redeemed( await self._db.commit() async def mark_accepted(self, invite_id: str, accepted_by: str) -> None: - """Record who accepted a collab invite and flip claimed→redeemed. + """Record who accepted a collab invite and flip pending→redeemed. - Same pattern as ``mark_redeemed`` but carried on the acceptance response - envelope rather than a redeem route call. Only touches invites in - 'claimed' status. + Collab invites are minted as 'pending' (no redeem/PIN step on the inviter + side — the PIN is delivered out of band to the invitee). This method + transitions the invite directly from 'pending' to 'redeemed', or from + 'claimed' to 'redeemed' for invites that went through the claim flow first. """ if self._db is None: raise RuntimeError("ProjectInviteStore not initialised") @@ -409,7 +410,7 @@ async def mark_accepted(self, invite_id: str, accepted_by: str) -> None: """ UPDATE project_invites SET redeemed_by = ?, status = 'redeemed' - WHERE invite_id = ? AND status = 'claimed' + WHERE invite_id = ? AND status IN ('pending', 'claimed') """, (accepted_by, invite_id), ) @@ -426,6 +427,17 @@ async def rollback_to_pending(self, invite_id: str) -> None: ) await self._db.commit() + async def mark_expired(self, invite_id: str) -> bool: + """Mark a pending invite as expired. Returns True if a row was updated.""" + if self._db is None: + raise RuntimeError("ProjectInviteStore not initialised") + cursor = await self._db.execute( + "UPDATE project_invites SET status = 'expired' WHERE invite_id = ? AND status = 'pending'", + (invite_id,), + ) + await self._db.commit() + return cursor.rowcount > 0 + async def _fetch_row(self, invite_id: str) -> aiosqlite.Row | None: cursor = await self._db.execute( "SELECT * FROM project_invites WHERE invite_id = ?", (invite_id,) diff --git a/tinyagentos/routes/peer.py b/tinyagentos/routes/peer.py index c23df2642..c2d283104 100644 --- a/tinyagentos/routes/peer.py +++ b/tinyagentos/routes/peer.py @@ -469,6 +469,23 @@ async def _handle_collab_response( return {"status": "received", "kind": kind, "dispatched": False, "error": "project_store not available"} + # Verify the invite exists, is pending, and matches the project + contact. + if invite_store is not None: + invite = await invite_store.get(invite_id) + if invite is None: + return {"status": "received", "kind": kind, "dispatched": False, + "error": f"invite {invite_id} not found"} + if invite.get("status") != "pending": + return {"status": "received", "kind": kind, "dispatched": False, + "error": f"invite {invite_id} is not pending (status={invite.get('status')})"} + if invite.get("project_id") != project_id: + return {"status": "received", "kind": kind, "dispatched": False, + "error": f"invite {invite_id} project mismatch"} + if invite.get("contact_id") != contact_id: + return {"status": "received", "kind": kind, "dispatched": False, + "error": f"invite {invite_id} contact mismatch: " + f"expected {invite.get('contact_id')}, got {contact_id}"} + try: await project_store.add_member( project_id=project_id, @@ -503,13 +520,7 @@ async def _handle_collab_response( # ---- Decline: mark invite as expired ---- if invite_store is not None: try: - row = await invite_store.get(invite_id) - if row and row.get("status") == "pending": - await invite_store._db.execute( - "UPDATE project_invites SET status = 'expired' WHERE invite_id = ?", - (invite_id,), - ) - await invite_store._db.commit() + await invite_store.mark_expired(invite_id) except Exception: pass # audit best-effort logger.info( diff --git a/tinyagentos/routes/project_invites.py b/tinyagentos/routes/project_invites.py index 0d55dcd9b..9966dae1b 100644 --- a/tinyagentos/routes/project_invites.py +++ b/tinyagentos/routes/project_invites.py @@ -905,11 +905,13 @@ async def mint_collab_invite( from tinyagentos.peer import build_envelope, resolve_local_identity_id # Resolve the local hub identity and the contact's hub username. - contact_username = contact.get("hub_username", "") + contact_username = (contact.get("hub_username") or "").strip() + if not contact_username: + delivery_error = "contact has no hub_username" local_id = resolve_local_identity_id(request.app.state.data_dir) if local_id is None: delivery_error = "hub identity not configured" - else: + if delivery_error is None and contact_username and local_id: local_username = local_id.removeprefix("hub:") envelope_body = { "invite_id": record["invite_id"], @@ -952,7 +954,7 @@ async def mint_collab_invite( "Content-Type": "application/json", }, ) - if resp.status_code < 500: + if 200 <= resp.status_code < 300: delivered = True break except Exception: From 7d3ab8287a8821485201162f14eea75b56f42578 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:03:44 +0200 Subject: [PATCH 3/4] fix(collab): wrap resolve_local_identity_id in asyncio.to_thread CodeRabbit nitpick: the blocking sqlite3 call inside resolve_local_identity_id must be offloaded to a thread to avoid blocking the async event loop in the collab invite route handler. Mirrors the pattern already used in routes/peer.py. --- tinyagentos/routes/project_invites.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tinyagentos/routes/project_invites.py b/tinyagentos/routes/project_invites.py index 9966dae1b..8d93caad0 100644 --- a/tinyagentos/routes/project_invites.py +++ b/tinyagentos/routes/project_invites.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import html import json import logging @@ -908,7 +909,9 @@ async def mint_collab_invite( contact_username = (contact.get("hub_username") or "").strip() if not contact_username: delivery_error = "contact has no hub_username" - local_id = resolve_local_identity_id(request.app.state.data_dir) + local_id = await asyncio.to_thread( + resolve_local_identity_id, request.app.state.data_dir + ) if local_id is None: delivery_error = "hub identity not configured" if delivery_error is None and contact_username and local_id: From b9209390dbbb462a3a256f3e117d670c514f9c4f Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:47:07 +0200 Subject: [PATCH 4/4] fix(collab): reject scoped collab invites in mint() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collab invites (kind=collab) must carry no agent scopes — delegation arrives later via the D-milestone handshake. Raise ValueError when kind=collab and scopes is non-empty. Fixes jaylfc MUST-FIX on PR #2045. --- tests/projects/test_invite_store.py | 13 +++++++++++++ tinyagentos/projects/invite_store.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/tests/projects/test_invite_store.py b/tests/projects/test_invite_store.py index 713dbf3fe..509404a19 100644 --- a/tests/projects/test_invite_store.py +++ b/tests/projects/test_invite_store.py @@ -607,6 +607,19 @@ async def test_default_kind_is_agent(store): assert result["record"]["contact_id"] is None +@pytest.mark.asyncio +async def test_mint_collab_rejects_scopes(store): + with pytest.raises(ValueError, match="collab invites must carry no scopes"): + await store.mint( + project_id="prj-1", + scopes=["a2a_send"], + approval_mode="manual", + check_interval_secs=1800, + created_by="u", + kind="collab", + ) + + @pytest.mark.asyncio async def test_list_pending_collab_for_contact(store): await store.mint( diff --git a/tinyagentos/projects/invite_store.py b/tinyagentos/projects/invite_store.py index 454c199a3..e0c0240df 100644 --- a/tinyagentos/projects/invite_store.py +++ b/tinyagentos/projects/invite_store.py @@ -140,6 +140,13 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, f"invalid invite kind: {kind!r} — must be one of {sorted(_VALID_KINDS)}" ) + # Human-collaborator invites carry no agent scopes — delegation arrives + # later via the D-milestone handshake, never on the human invite. + if kind == "collab" and scopes: + raise ValueError( + "collab invites must carry no scopes" + ) + # The pending cap is per-scope: project-scoped invites are capped per # project, OS-level (project_id IS NULL) invites are capped as a group. # SQL ``= NULL`` never matches, so branch on IS NULL to keep the cap live