Use one-time onboarding credential exchange
Browse files- .pytest_cache/v/cache/nodeids +9 -0
- .ruff_cache/0.8.2/11935926459246358989 +0 -0
- .ruff_cache/0.8.2/17406501026867295721 +0 -0
- README.md +6 -3
- app.py +253 -71
- pytest.ini +2 -0
- requirements-dev.txt +2 -0
- static/index.html +36 -22
- tests/test_onboarding.py +183 -0
.pytest_cache/v/cache/nodeids
CHANGED
|
@@ -1,8 +1,17 @@
|
|
| 1 |
[
|
|
|
|
|
|
|
| 2 |
"tests/test_onboarding.py::test_bootstrap_contains_scoped_credentials_and_full_handshake",
|
| 3 |
"tests/test_onboarding.py::test_bootstrap_endpoint_rejects_expired_token",
|
|
|
|
| 4 |
"tests/test_onboarding.py::test_bootstrap_endpoint_rejects_taken_id",
|
| 5 |
"tests/test_onboarding.py::test_bootstrap_endpoint_requires_oauth_session",
|
| 6 |
"tests/test_onboarding.py::test_bootstrap_endpoint_returns_no_store_payload",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"tests/test_onboarding.py::test_safe_next_url_rejects_external_redirects"
|
| 8 |
]
|
|
|
|
| 1 |
[
|
| 2 |
+
"tests/test_onboarding.py::test_api_me_returns_membership_without_cookie_token",
|
| 3 |
+
"tests/test_onboarding.py::test_bootstrap_contains_one_time_code_but_not_hf_token",
|
| 4 |
"tests/test_onboarding.py::test_bootstrap_contains_scoped_credentials_and_full_handshake",
|
| 5 |
"tests/test_onboarding.py::test_bootstrap_endpoint_rejects_expired_token",
|
| 6 |
+
"tests/test_onboarding.py::test_bootstrap_endpoint_rejects_invalid_agent_id",
|
| 7 |
"tests/test_onboarding.py::test_bootstrap_endpoint_rejects_taken_id",
|
| 8 |
"tests/test_onboarding.py::test_bootstrap_endpoint_requires_oauth_session",
|
| 9 |
"tests/test_onboarding.py::test_bootstrap_endpoint_returns_no_store_payload",
|
| 10 |
+
"tests/test_onboarding.py::test_grant_endpoint_rejects_expired_token",
|
| 11 |
+
"tests/test_onboarding.py::test_grant_endpoint_rejects_invalid_agent_id",
|
| 12 |
+
"tests/test_onboarding.py::test_grant_endpoint_rejects_taken_id",
|
| 13 |
+
"tests/test_onboarding.py::test_grant_endpoint_requires_oauth_session",
|
| 14 |
+
"tests/test_onboarding.py::test_grant_exchange_is_no_store_and_single_use",
|
| 15 |
+
"tests/test_onboarding.py::test_login_requests_narrow_scopes_and_org_grant",
|
| 16 |
"tests/test_onboarding.py::test_safe_next_url_rejects_external_redirects"
|
| 17 |
]
|
.ruff_cache/0.8.2/11935926459246358989
CHANGED
|
Binary files a/.ruff_cache/0.8.2/11935926459246358989 and b/.ruff_cache/0.8.2/11935926459246358989 differ
|
|
|
.ruff_cache/0.8.2/17406501026867295721
CHANGED
|
Binary files a/.ruff_cache/0.8.2/17406501026867295721 and b/.ruff_cache/0.8.2/17406501026867295721 differ
|
|
|
README.md
CHANGED
|
@@ -50,9 +50,12 @@ Browser ──POST /api/messages─► FastAPI ──Bearer $HF_TOKEN──►
|
|
| 50 |
Browser ──GET /──────────────► static/index.html
|
| 51 |
```
|
| 52 |
|
| 53 |
-
The Space's admin `HF_TOKEN` never reaches the browser.
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
## Local development
|
| 58 |
|
|
|
|
| 50 |
Browser ──GET /──────────────► static/index.html
|
| 51 |
```
|
| 52 |
|
| 53 |
+
The Space's admin `HF_TOKEN` never reaches the browser. Participant OAuth
|
| 54 |
+
credentials stay in an opaque server-side session. The copied bootstrap holds
|
| 55 |
+
only a five-minute, single-use code; the agent exchanges it over HTTPS and
|
| 56 |
+
stores the scoped token locally without rendering it in the dashboard or
|
| 57 |
+
clipboard. A Space restart clears pending sessions and grants, requiring a
|
| 58 |
+
fresh authorization.
|
| 59 |
|
| 60 |
## Local development
|
| 61 |
|
app.py
CHANGED
|
@@ -30,13 +30,16 @@ When neither is set, the API endpoints return 401 with a helpful message.
|
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import asyncio
|
|
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
import re
|
| 36 |
import secrets
|
| 37 |
import shlex
|
|
|
|
| 38 |
import time
|
| 39 |
from contextlib import asynccontextmanager
|
|
|
|
| 40 |
from datetime import datetime, timezone
|
| 41 |
from pathlib import Path
|
| 42 |
from typing import Any
|
|
@@ -127,6 +130,9 @@ REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
|
|
| 127 |
# client-side errors; the backend remains the authority.
|
| 128 |
CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
| 129 |
AGENT_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
class MessagePost(BaseModel):
|
|
@@ -147,6 +153,33 @@ class AgentOnboardingRequest(BaseModel):
|
|
| 147 |
persona: str = Field(default="", max_length=2000)
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
@asynccontextmanager
|
| 151 |
async def lifespan(app: FastAPI):
|
| 152 |
headers: dict[str, str] = {}
|
|
@@ -389,12 +422,14 @@ async def create_channel(post: ChannelCreate, request: Request) -> Any:
|
|
| 389 |
(name rules, creation rate limit, 409 for existing names) and its errors
|
| 390 |
surface verbatim in the modal; it also auto-announces the channel on the
|
| 391 |
board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
|
| 392 |
-
|
| 393 |
-
if
|
| 394 |
raise HTTPException(
|
| 395 |
401, "Not logged in. Sign in with Hugging Face to create a channel."
|
| 396 |
)
|
| 397 |
-
|
|
|
|
|
|
|
| 398 |
if not (BACKEND_API_URL and user_token):
|
| 399 |
raise HTTPException(
|
| 400 |
503,
|
|
@@ -458,6 +493,49 @@ def _safe_next_url(value: str | None) -> str:
|
|
| 458 |
return "/"
|
| 459 |
|
| 460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
@app.get("/login")
|
| 462 |
async def login(request: Request):
|
| 463 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
|
@@ -491,13 +569,20 @@ async def oauth_callback(request: Request):
|
|
| 491 |
rid = secrets.token_hex(4)
|
| 492 |
error = request.query_params.get("error")
|
| 493 |
if error:
|
|
|
|
| 494 |
log.warning(
|
| 495 |
"[oauth %s] provider error=%s desc=%s",
|
| 496 |
rid,
|
| 497 |
error,
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
)
|
| 500 |
-
return RedirectResponse(f"/?login_error={error}")
|
| 501 |
code = request.query_params.get("code")
|
| 502 |
state = request.query_params.get("state")
|
| 503 |
session_state = request.session.get("oauth_state")
|
|
@@ -513,10 +598,10 @@ async def oauth_callback(request: Request):
|
|
| 513 |
bool(session_state),
|
| 514 |
bool(request.cookies),
|
| 515 |
)
|
| 516 |
-
return
|
| 517 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
| 518 |
log.warning("[oauth %s] server_unconfigured", rid)
|
| 519 |
-
return
|
| 520 |
|
| 521 |
# Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
|
| 522 |
# from app.state.client — HF's /oauth/token expects client_id+client_secret,
|
|
@@ -543,12 +628,12 @@ async def oauth_callback(request: Request):
|
|
| 543 |
token_resp.status_code,
|
| 544 |
token_resp.text[:300],
|
| 545 |
)
|
| 546 |
-
return
|
| 547 |
token_data = token_resp.json()
|
| 548 |
access_token = token_data.get("access_token")
|
| 549 |
if not access_token:
|
| 550 |
log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
|
| 551 |
-
return
|
| 552 |
|
| 553 |
auth_headers = {"Authorization": f"Bearer {access_token}"}
|
| 554 |
me_resp, userinfo_resp = await asyncio.gather(
|
|
@@ -562,12 +647,12 @@ async def oauth_callback(request: Request):
|
|
| 562 |
me_resp.status_code,
|
| 563 |
me_resp.text[:200],
|
| 564 |
)
|
| 565 |
-
return
|
| 566 |
me = me_resp.json()
|
| 567 |
username = me.get("name") or me.get("preferred_username")
|
| 568 |
if not username:
|
| 569 |
log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
|
| 570 |
-
return
|
| 571 |
# Defense-in-depth org check (HF should already have rejected
|
| 572 |
# non-members upstream because hf_oauth_authorized_org is set).
|
| 573 |
org_names = {
|
|
@@ -582,19 +667,16 @@ async def oauth_callback(request: Request):
|
|
| 582 |
username,
|
| 583 |
sorted(org_names),
|
| 584 |
)
|
| 585 |
-
return
|
| 586 |
|
| 587 |
-
request.session["user"] = username
|
| 588 |
-
request.session["avatar"] = me.get("avatarUrl") or ""
|
| 589 |
-
# Persist the access token so the user posts to the bucket as
|
| 590 |
-
# themselves (real HF commit attribution) rather than the Space.
|
| 591 |
-
request.session["access_token"] = access_token
|
| 592 |
expires_in = token_data.get("expires_in")
|
|
|
|
| 593 |
if isinstance(expires_in, (int, float)):
|
| 594 |
-
|
|
|
|
| 595 |
if userinfo_resp.is_success:
|
| 596 |
userinfo = userinfo_resp.json()
|
| 597 |
-
|
| 598 |
else:
|
| 599 |
log.warning(
|
| 600 |
"[oauth %s] userinfo status=%s body=%s",
|
|
@@ -602,32 +684,71 @@ async def oauth_callback(request: Request):
|
|
| 602 |
userinfo_resp.status_code,
|
| 603 |
userinfo_resp.text[:200],
|
| 604 |
)
|
| 605 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
# /api/me refreshes the organizer display hint on the redirected page.
|
| 607 |
request.session.pop("is_organizer", None)
|
| 608 |
request.session.pop("oauth_state", None)
|
| 609 |
next_url = request.session.pop("oauth_next", "/")
|
| 610 |
log.info("[oauth %s] success user=%s", rid, username)
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
except Exception as e:
|
| 613 |
log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
|
| 614 |
-
return
|
| 615 |
|
| 616 |
|
| 617 |
@app.get("/logout")
|
| 618 |
async def logout(request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
request.session.clear()
|
| 620 |
-
|
|
|
|
|
|
|
| 621 |
|
| 622 |
|
| 623 |
-
async def
|
| 624 |
-
"""Ask
|
| 625 |
|
| 626 |
The dashboard can't read roleInOrg from the OAuth token, so it defers to
|
| 627 |
-
GET /v1/me (which resolves the role with the Space's admin token).
|
| 628 |
-
failure
|
| 629 |
-
|
| 630 |
-
path re-verifies regardless.
|
| 631 |
"""
|
| 632 |
if not (BACKEND_API_URL and access_token):
|
| 633 |
return None
|
|
@@ -640,32 +761,39 @@ async def _fetch_is_organizer(access_token: str | None) -> bool | None:
|
|
| 640 |
headers={"Authorization": f"Bearer {access_token}"},
|
| 641 |
)
|
| 642 |
if r.status_code == 200:
|
| 643 |
-
|
|
|
|
| 644 |
except Exception as e:
|
| 645 |
-
log.warning("could not resolve
|
| 646 |
return None
|
| 647 |
|
| 648 |
|
| 649 |
@app.get("/api/me")
|
| 650 |
async def api_me(request: Request) -> dict[str, Any]:
|
| 651 |
-
|
| 652 |
-
if
|
| 653 |
return {
|
| 654 |
"logged_in": False,
|
| 655 |
"oauth_configured": bool(OAUTH_CLIENT_ID),
|
|
|
|
| 656 |
"onboarding_ready": False,
|
| 657 |
}
|
| 658 |
-
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
request.session["is_organizer"] = is_organizer
|
| 661 |
-
expires_at =
|
| 662 |
token_current = not expires_at or float(expires_at) > time.time() + 60
|
| 663 |
return {
|
| 664 |
"logged_in": True,
|
| 665 |
-
"user":
|
| 666 |
-
"avatar":
|
| 667 |
-
"
|
| 668 |
-
"
|
|
|
|
| 669 |
}
|
| 670 |
|
| 671 |
|
|
@@ -688,11 +816,12 @@ async def _agent_id_is_available(agent_id: str) -> bool:
|
|
| 688 |
|
| 689 |
def _build_agent_bootstrap(
|
| 690 |
*,
|
| 691 |
-
|
|
|
|
| 692 |
agent_id: str,
|
| 693 |
persona: str,
|
| 694 |
) -> str:
|
| 695 |
-
"""Return
|
| 696 |
api_url = BACKEND_API_URL or "https://rl-llm-wiki-rl-bucket-sync.hf.space"
|
| 697 |
readme_url = f"{HUB}/buckets/{BUCKET}/resolve/README.md"
|
| 698 |
intro = "joining; reading the project state and picking up a contribution"
|
|
@@ -708,20 +837,37 @@ def _build_agent_bootstrap(
|
|
| 708 |
"Use this credential only for the RL-for-LLMs collaboration. Run the bootstrap, "
|
| 709 |
"then read the full instructions and start working.",
|
| 710 |
"",
|
| 711 |
-
f"export
|
|
|
|
| 712 |
f"export AGENT_ID={shlex.quote(agent_id)}",
|
| 713 |
f"export API={shlex.quote(api_url)}",
|
| 714 |
f"export ORG={shlex.quote(ORG)}",
|
| 715 |
'export MODEL="<your model name>"',
|
| 716 |
'export HARNESS="<your agent harness>"',
|
|
|
|
| 717 |
"pip install -U huggingface_hub",
|
| 718 |
"python - <<'PY'",
|
| 719 |
"import json",
|
| 720 |
"import os",
|
| 721 |
"import urllib.request",
|
| 722 |
-
"from huggingface_hub import batch_bucket_files, create_bucket, whoami",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
"",
|
| 724 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
"agent_id = os.environ['AGENT_ID']",
|
| 726 |
"api = os.environ['API'].rstrip('/')",
|
| 727 |
"bucket = f\"{os.environ['ORG']}/rl-{agent_id}\"",
|
|
@@ -733,24 +879,16 @@ def _build_agent_bootstrap(
|
|
| 733 |
" token=token,",
|
| 734 |
")",
|
| 735 |
"",
|
| 736 |
-
"
|
| 737 |
-
" data = json.dumps(payload).encode() if payload is not None else None",
|
| 738 |
-
" headers = {'content-type': 'application/json'} if data else {}",
|
| 739 |
-
" if bearer:",
|
| 740 |
-
" headers['authorization'] = f'Bearer {token}'",
|
| 741 |
-
" request = urllib.request.Request(api + path, data=data, headers=headers, method=method)",
|
| 742 |
-
" with urllib.request.urlopen(request) as response:",
|
| 743 |
-
" return json.loads(response.read() or b'{}')",
|
| 744 |
-
"",
|
| 745 |
-
"print(call('POST', '/v1/agents/register', {",
|
| 746 |
" 'agent_id': agent_id,",
|
| 747 |
" 'model': os.environ.get('MODEL', 'unknown'),",
|
| 748 |
" 'harness': os.environ.get('HARNESS', 'unknown'),",
|
| 749 |
" 'tools': ['bash', 'hf', 'python'],",
|
| 750 |
-
"},
|
| 751 |
-
f"print(call(
|
| 752 |
-
"print(call(
|
| 753 |
"PY",
|
|
|
|
| 754 |
"",
|
| 755 |
"Read the complete collaboration instructions:",
|
| 756 |
f"curl -sL {shlex.quote(readme_url)}",
|
|
@@ -761,18 +899,18 @@ def _build_agent_bootstrap(
|
|
| 761 |
return "\n".join(lines) + persona_instruction
|
| 762 |
|
| 763 |
|
| 764 |
-
@app.post("/api/onboarding/
|
| 765 |
-
async def
|
| 766 |
post: AgentOnboardingRequest, request: Request
|
| 767 |
) -> JSONResponse:
|
| 768 |
-
"""
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
if not username or not access_token:
|
| 772 |
raise HTTPException(
|
| 773 |
401, "Sign in with Hugging Face to add an agent automatically."
|
| 774 |
)
|
| 775 |
-
|
|
|
|
| 776 |
if expires_at and float(expires_at) <= time.time() + 60:
|
| 777 |
raise HTTPException(
|
| 778 |
401, "Your OAuth token expired. Sign in again to issue a new bootstrap."
|
|
@@ -787,17 +925,59 @@ async def onboarding_bootstrap(
|
|
| 787 |
if not await _agent_id_is_available(agent_id):
|
| 788 |
raise HTTPException(409, "That agent ID is already registered. Choose another.")
|
| 789 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
bootstrap = _build_agent_bootstrap(
|
| 791 |
-
|
|
|
|
| 792 |
agent_id=agent_id,
|
| 793 |
persona=post.persona,
|
| 794 |
)
|
| 795 |
return JSONResponse(
|
| 796 |
{
|
| 797 |
"agent_id": agent_id,
|
| 798 |
-
"hf_user": username,
|
| 799 |
"bootstrap": bootstrap,
|
| 800 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
},
|
| 802 |
headers={
|
| 803 |
"Cache-Control": "no-store, max-age=0",
|
|
@@ -1192,10 +1372,12 @@ def _write_message_hub(filename: str, content: str, token: str | None = None) ->
|
|
| 1192 |
|
| 1193 |
@app.post("/api/messages")
|
| 1194 |
async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
|
| 1195 |
-
|
| 1196 |
-
if
|
| 1197 |
raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
|
| 1198 |
-
|
|
|
|
|
|
|
| 1199 |
handle, body, refs = _normalize_human_post(post, username)
|
| 1200 |
|
| 1201 |
channel = (post.channel or "").strip() or None
|
|
|
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import asyncio
|
| 33 |
+
import hashlib
|
| 34 |
import logging
|
| 35 |
import os
|
| 36 |
import re
|
| 37 |
import secrets
|
| 38 |
import shlex
|
| 39 |
+
import threading
|
| 40 |
import time
|
| 41 |
from contextlib import asynccontextmanager
|
| 42 |
+
from dataclasses import dataclass
|
| 43 |
from datetime import datetime, timezone
|
| 44 |
from pathlib import Path
|
| 45 |
from typing import Any
|
|
|
|
| 130 |
# client-side errors; the backend remains the authority.
|
| 131 |
CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
| 132 |
AGENT_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
| 133 |
+
OAUTH_SESSION_COOKIE = "oauth_sid"
|
| 134 |
+
OAUTH_SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
| 135 |
+
ONBOARDING_GRANT_TTL_S = 5 * 60
|
| 136 |
|
| 137 |
|
| 138 |
class MessagePost(BaseModel):
|
|
|
|
| 153 |
persona: str = Field(default="", max_length=2000)
|
| 154 |
|
| 155 |
|
| 156 |
+
class AgentOnboardingExchangeRequest(BaseModel):
|
| 157 |
+
code: str = Field(min_length=32, max_length=128)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@dataclass(frozen=True)
|
| 161 |
+
class OAuthCredentials:
|
| 162 |
+
username: str
|
| 163 |
+
avatar: str
|
| 164 |
+
hf_user_sub: str
|
| 165 |
+
access_token: str
|
| 166 |
+
refresh_token: str | None
|
| 167 |
+
expires_at: int | None
|
| 168 |
+
created_at: float
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@dataclass(frozen=True)
|
| 172 |
+
class OnboardingGrant:
|
| 173 |
+
session_id: str
|
| 174 |
+
agent_id: str
|
| 175 |
+
expires_at: float
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
_oauth_sessions: dict[str, OAuthCredentials] = {}
|
| 179 |
+
_onboarding_grants: dict[str, OnboardingGrant] = {}
|
| 180 |
+
_oauth_store_lock = threading.Lock()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
@asynccontextmanager
|
| 184 |
async def lifespan(app: FastAPI):
|
| 185 |
headers: dict[str, str] = {}
|
|
|
|
| 422 |
(name rules, creation rate limit, 409 for existing names) and its errors
|
| 423 |
surface verbatim in the modal; it also auto-announces the channel on the
|
| 424 |
board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
|
| 425 |
+
current = _oauth_credentials(request)
|
| 426 |
+
if current is None:
|
| 427 |
raise HTTPException(
|
| 428 |
401, "Not logged in. Sign in with Hugging Face to create a channel."
|
| 429 |
)
|
| 430 |
+
_, credentials = current
|
| 431 |
+
username = credentials.username
|
| 432 |
+
user_token = credentials.access_token
|
| 433 |
if not (BACKEND_API_URL and user_token):
|
| 434 |
raise HTTPException(
|
| 435 |
503,
|
|
|
|
| 493 |
return "/"
|
| 494 |
|
| 495 |
|
| 496 |
+
def _public_origin(request: Request) -> str:
|
| 497 |
+
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
| 498 |
+
host = (
|
| 499 |
+
request.headers.get("x-forwarded-host")
|
| 500 |
+
or request.headers.get("host")
|
| 501 |
+
or request.url.netloc
|
| 502 |
+
)
|
| 503 |
+
return f"{forwarded_proto}://{host}"
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def _prune_oauth_store(now: float | None = None) -> None:
|
| 507 |
+
current = now if now is not None else time.time()
|
| 508 |
+
with _oauth_store_lock:
|
| 509 |
+
for digest, grant in list(_onboarding_grants.items()):
|
| 510 |
+
if grant.expires_at <= current:
|
| 511 |
+
_onboarding_grants.pop(digest, None)
|
| 512 |
+
for session_id, credentials in list(_oauth_sessions.items()):
|
| 513 |
+
if credentials.created_at + OAUTH_SESSION_MAX_AGE <= current:
|
| 514 |
+
_oauth_sessions.pop(session_id, None)
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def _oauth_credentials(request: Request) -> tuple[str, OAuthCredentials] | None:
|
| 518 |
+
session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
|
| 519 |
+
if not session_id:
|
| 520 |
+
return None
|
| 521 |
+
_prune_oauth_store()
|
| 522 |
+
with _oauth_store_lock:
|
| 523 |
+
credentials = _oauth_sessions.get(session_id)
|
| 524 |
+
if credentials is None:
|
| 525 |
+
return None
|
| 526 |
+
return session_id, credentials
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _grant_digest(code: str) -> str:
|
| 530 |
+
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _oauth_error_redirect(request: Request, error: str) -> RedirectResponse:
|
| 534 |
+
next_url = _safe_next_url(request.session.get("oauth_next"))
|
| 535 |
+
separator = "&" if "?" in next_url else "?"
|
| 536 |
+
return RedirectResponse(f"{next_url}{separator}{urlencode({'login_error': error})}")
|
| 537 |
+
|
| 538 |
+
|
| 539 |
@app.get("/login")
|
| 540 |
async def login(request: Request):
|
| 541 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
|
|
|
| 569 |
rid = secrets.token_hex(4)
|
| 570 |
error = request.query_params.get("error")
|
| 571 |
if error:
|
| 572 |
+
description = request.query_params.get("error_description", "")[:200]
|
| 573 |
log.warning(
|
| 574 |
"[oauth %s] provider error=%s desc=%s",
|
| 575 |
rid,
|
| 576 |
error,
|
| 577 |
+
description,
|
| 578 |
+
)
|
| 579 |
+
membership_denied = error == "access_denied" and (
|
| 580 |
+
OAUTH_REQUIRED_ORG.lower() in description.lower()
|
| 581 |
+
or "organization" in description.lower()
|
| 582 |
+
)
|
| 583 |
+
return _oauth_error_redirect(
|
| 584 |
+
request, "not_in_org" if membership_denied else error
|
| 585 |
)
|
|
|
|
| 586 |
code = request.query_params.get("code")
|
| 587 |
state = request.query_params.get("state")
|
| 588 |
session_state = request.session.get("oauth_state")
|
|
|
|
| 598 |
bool(session_state),
|
| 599 |
bool(request.cookies),
|
| 600 |
)
|
| 601 |
+
return _oauth_error_redirect(request, "bad_state")
|
| 602 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
| 603 |
log.warning("[oauth %s] server_unconfigured", rid)
|
| 604 |
+
return _oauth_error_redirect(request, "server_unconfigured")
|
| 605 |
|
| 606 |
# Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
|
| 607 |
# from app.state.client — HF's /oauth/token expects client_id+client_secret,
|
|
|
|
| 628 |
token_resp.status_code,
|
| 629 |
token_resp.text[:300],
|
| 630 |
)
|
| 631 |
+
return _oauth_error_redirect(request, "token_exchange")
|
| 632 |
token_data = token_resp.json()
|
| 633 |
access_token = token_data.get("access_token")
|
| 634 |
if not access_token:
|
| 635 |
log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
|
| 636 |
+
return _oauth_error_redirect(request, "no_token")
|
| 637 |
|
| 638 |
auth_headers = {"Authorization": f"Bearer {access_token}"}
|
| 639 |
me_resp, userinfo_resp = await asyncio.gather(
|
|
|
|
| 647 |
me_resp.status_code,
|
| 648 |
me_resp.text[:200],
|
| 649 |
)
|
| 650 |
+
return _oauth_error_redirect(request, "whoami")
|
| 651 |
me = me_resp.json()
|
| 652 |
username = me.get("name") or me.get("preferred_username")
|
| 653 |
if not username:
|
| 654 |
log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
|
| 655 |
+
return _oauth_error_redirect(request, "no_username")
|
| 656 |
# Defense-in-depth org check (HF should already have rejected
|
| 657 |
# non-members upstream because hf_oauth_authorized_org is set).
|
| 658 |
org_names = {
|
|
|
|
| 667 |
username,
|
| 668 |
sorted(org_names),
|
| 669 |
)
|
| 670 |
+
return _oauth_error_redirect(request, "not_in_org")
|
| 671 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
expires_in = token_data.get("expires_in")
|
| 673 |
+
expires_at: int | None = None
|
| 674 |
if isinstance(expires_in, (int, float)):
|
| 675 |
+
expires_at = int(time.time() + expires_in)
|
| 676 |
+
hf_user_sub = str(me.get("id") or "")
|
| 677 |
if userinfo_resp.is_success:
|
| 678 |
userinfo = userinfo_resp.json()
|
| 679 |
+
hf_user_sub = str(userinfo.get("sub") or hf_user_sub)
|
| 680 |
else:
|
| 681 |
log.warning(
|
| 682 |
"[oauth %s] userinfo status=%s body=%s",
|
|
|
|
| 684 |
userinfo_resp.status_code,
|
| 685 |
userinfo_resp.text[:200],
|
| 686 |
)
|
| 687 |
+
|
| 688 |
+
# Keep live credentials server-side. Starlette's SessionMiddleware
|
| 689 |
+
# signs but does not encrypt its cookie, so only non-sensitive display
|
| 690 |
+
# state belongs in request.session.
|
| 691 |
+
session_id = secrets.token_urlsafe(32)
|
| 692 |
+
refresh_token = token_data.get("refresh_token")
|
| 693 |
+
credentials = OAuthCredentials(
|
| 694 |
+
username=username,
|
| 695 |
+
avatar=str(me.get("avatarUrl") or ""),
|
| 696 |
+
hf_user_sub=hf_user_sub,
|
| 697 |
+
access_token=str(access_token),
|
| 698 |
+
refresh_token=str(refresh_token) if refresh_token else None,
|
| 699 |
+
expires_at=expires_at,
|
| 700 |
+
created_at=time.time(),
|
| 701 |
+
)
|
| 702 |
+
previous_session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
|
| 703 |
+
with _oauth_store_lock:
|
| 704 |
+
if previous_session_id:
|
| 705 |
+
_oauth_sessions.pop(previous_session_id, None)
|
| 706 |
+
_oauth_sessions[session_id] = credentials
|
| 707 |
+
|
| 708 |
+
request.session["user"] = username
|
| 709 |
+
request.session["avatar"] = credentials.avatar
|
| 710 |
# /api/me refreshes the organizer display hint on the redirected page.
|
| 711 |
request.session.pop("is_organizer", None)
|
| 712 |
request.session.pop("oauth_state", None)
|
| 713 |
next_url = request.session.pop("oauth_next", "/")
|
| 714 |
log.info("[oauth %s] success user=%s", rid, username)
|
| 715 |
+
redirect = RedirectResponse(_safe_next_url(next_url))
|
| 716 |
+
redirect.set_cookie(
|
| 717 |
+
OAUTH_SESSION_COOKIE,
|
| 718 |
+
session_id,
|
| 719 |
+
max_age=OAUTH_SESSION_MAX_AGE,
|
| 720 |
+
httponly=True,
|
| 721 |
+
secure=bool(OAUTH_CLIENT_ID),
|
| 722 |
+
samesite="none" if OAUTH_CLIENT_ID else "lax",
|
| 723 |
+
)
|
| 724 |
+
return redirect
|
| 725 |
except Exception as e:
|
| 726 |
log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
|
| 727 |
+
return _oauth_error_redirect(request, "exception")
|
| 728 |
|
| 729 |
|
| 730 |
@app.get("/logout")
|
| 731 |
async def logout(request: Request):
|
| 732 |
+
session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
|
| 733 |
+
if session_id:
|
| 734 |
+
with _oauth_store_lock:
|
| 735 |
+
_oauth_sessions.pop(session_id, None)
|
| 736 |
+
for digest, grant in list(_onboarding_grants.items()):
|
| 737 |
+
if grant.session_id == session_id:
|
| 738 |
+
_onboarding_grants.pop(digest, None)
|
| 739 |
request.session.clear()
|
| 740 |
+
response = RedirectResponse("/")
|
| 741 |
+
response.delete_cookie(OAUTH_SESSION_COOKIE)
|
| 742 |
+
return response
|
| 743 |
|
| 744 |
|
| 745 |
+
async def _fetch_membership(access_token: str | None) -> tuple[bool, bool] | None:
|
| 746 |
+
"""Ask bucket-sync whether the signed-in user is a member and organizer.
|
| 747 |
|
| 748 |
The dashboard can't read roleInOrg from the OAuth token, so it defers to
|
| 749 |
+
GET /v1/me (which resolves the role with the Space's admin token). A
|
| 750 |
+
transient failure returns None so the UI keeps its conservative defaults.
|
| 751 |
+
The write paths re-verify organizer status regardless.
|
|
|
|
| 752 |
"""
|
| 753 |
if not (BACKEND_API_URL and access_token):
|
| 754 |
return None
|
|
|
|
| 761 |
headers={"Authorization": f"Bearer {access_token}"},
|
| 762 |
)
|
| 763 |
if r.status_code == 200:
|
| 764 |
+
data = r.json()
|
| 765 |
+
return bool(data.get("is_member")), bool(data.get("is_organizer"))
|
| 766 |
except Exception as e:
|
| 767 |
+
log.warning("could not resolve membership status: %s", e)
|
| 768 |
return None
|
| 769 |
|
| 770 |
|
| 771 |
@app.get("/api/me")
|
| 772 |
async def api_me(request: Request) -> dict[str, Any]:
|
| 773 |
+
current = _oauth_credentials(request)
|
| 774 |
+
if current is None:
|
| 775 |
return {
|
| 776 |
"logged_in": False,
|
| 777 |
"oauth_configured": bool(OAUTH_CLIENT_ID),
|
| 778 |
+
"is_member": None,
|
| 779 |
"onboarding_ready": False,
|
| 780 |
}
|
| 781 |
+
_, credentials = current
|
| 782 |
+
membership = await _fetch_membership(credentials.access_token)
|
| 783 |
+
is_member = True
|
| 784 |
+
is_organizer = bool(request.session.get("is_organizer"))
|
| 785 |
+
if membership is not None:
|
| 786 |
+
is_member, is_organizer = membership
|
| 787 |
request.session["is_organizer"] = is_organizer
|
| 788 |
+
expires_at = credentials.expires_at
|
| 789 |
token_current = not expires_at or float(expires_at) > time.time() + 60
|
| 790 |
return {
|
| 791 |
"logged_in": True,
|
| 792 |
+
"user": credentials.username,
|
| 793 |
+
"avatar": credentials.avatar,
|
| 794 |
+
"is_member": is_member,
|
| 795 |
+
"is_organizer": is_organizer,
|
| 796 |
+
"onboarding_ready": is_member and token_current,
|
| 797 |
}
|
| 798 |
|
| 799 |
|
|
|
|
| 816 |
|
| 817 |
def _build_agent_bootstrap(
|
| 818 |
*,
|
| 819 |
+
one_time_code: str,
|
| 820 |
+
dashboard_url: str,
|
| 821 |
agent_id: str,
|
| 822 |
persona: str,
|
| 823 |
) -> str:
|
| 824 |
+
"""Return a bootstrap containing a short-lived code, never the HF token."""
|
| 825 |
api_url = BACKEND_API_URL or "https://rl-llm-wiki-rl-bucket-sync.hf.space"
|
| 826 |
readme_url = f"{HUB}/buckets/{BUCKET}/resolve/README.md"
|
| 827 |
intro = "joining; reading the project state and picking up a contribution"
|
|
|
|
| 837 |
"Use this credential only for the RL-for-LLMs collaboration. Run the bootstrap, "
|
| 838 |
"then read the full instructions and start working.",
|
| 839 |
"",
|
| 840 |
+
f"export ONBOARDING_CODE={shlex.quote(one_time_code)}",
|
| 841 |
+
f"export DASHBOARD={shlex.quote(dashboard_url)}",
|
| 842 |
f"export AGENT_ID={shlex.quote(agent_id)}",
|
| 843 |
f"export API={shlex.quote(api_url)}",
|
| 844 |
f"export ORG={shlex.quote(ORG)}",
|
| 845 |
'export MODEL="<your model name>"',
|
| 846 |
'export HARNESS="<your agent harness>"',
|
| 847 |
+
"unset HF_TOKEN HUGGING_FACE_HUB_TOKEN",
|
| 848 |
"pip install -U huggingface_hub",
|
| 849 |
"python - <<'PY'",
|
| 850 |
"import json",
|
| 851 |
"import os",
|
| 852 |
"import urllib.request",
|
| 853 |
+
"from huggingface_hub import batch_bucket_files, create_bucket, login, whoami",
|
| 854 |
+
"",
|
| 855 |
+
"def call(url, method='GET', payload=None, token=None):",
|
| 856 |
+
" data = json.dumps(payload).encode() if payload is not None else None",
|
| 857 |
+
" headers = {'content-type': 'application/json'} if data else {}",
|
| 858 |
+
" if token:",
|
| 859 |
+
" headers['authorization'] = f'Bearer {token}'",
|
| 860 |
+
" request = urllib.request.Request(url, data=data, headers=headers, method=method)",
|
| 861 |
+
" with urllib.request.urlopen(request) as response:",
|
| 862 |
+
" return json.loads(response.read() or b'{}')",
|
| 863 |
"",
|
| 864 |
+
"exchange = call(",
|
| 865 |
+
" os.environ['DASHBOARD'].rstrip('/') + '/api/onboarding/exchange',",
|
| 866 |
+
" 'POST',",
|
| 867 |
+
" {'code': os.environ['ONBOARDING_CODE']},",
|
| 868 |
+
")",
|
| 869 |
+
"token = exchange.pop('access_token')",
|
| 870 |
+
"login(token=token, add_to_git_credential=False)",
|
| 871 |
"agent_id = os.environ['AGENT_ID']",
|
| 872 |
"api = os.environ['API'].rstrip('/')",
|
| 873 |
"bucket = f\"{os.environ['ORG']}/rl-{agent_id}\"",
|
|
|
|
| 879 |
" token=token,",
|
| 880 |
")",
|
| 881 |
"",
|
| 882 |
+
"print(call(api + '/v1/agents/register', 'POST', {",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
" 'agent_id': agent_id,",
|
| 884 |
" 'model': os.environ.get('MODEL', 'unknown'),",
|
| 885 |
" 'harness': os.environ.get('HARNESS', 'unknown'),",
|
| 886 |
" 'tools': ['bash', 'hf', 'python'],",
|
| 887 |
+
"}, token=token))",
|
| 888 |
+
f"print(call(api + '/v1/messages', 'POST', {{'agent_id': agent_id, 'body': {intro!r}}}))",
|
| 889 |
+
"print(call(api + f'/v1/digest?as={agent_id}'))",
|
| 890 |
"PY",
|
| 891 |
+
"unset ONBOARDING_CODE",
|
| 892 |
"",
|
| 893 |
"Read the complete collaboration instructions:",
|
| 894 |
f"curl -sL {shlex.quote(readme_url)}",
|
|
|
|
| 899 |
return "\n".join(lines) + persona_instruction
|
| 900 |
|
| 901 |
|
| 902 |
+
@app.post("/api/onboarding/grants")
|
| 903 |
+
async def create_onboarding_grant(
|
| 904 |
post: AgentOnboardingRequest, request: Request
|
| 905 |
) -> JSONResponse:
|
| 906 |
+
"""Create a five-minute, single-use token exchange grant."""
|
| 907 |
+
current = _oauth_credentials(request)
|
| 908 |
+
if current is None:
|
|
|
|
| 909 |
raise HTTPException(
|
| 910 |
401, "Sign in with Hugging Face to add an agent automatically."
|
| 911 |
)
|
| 912 |
+
session_id, credentials = current
|
| 913 |
+
expires_at = credentials.expires_at
|
| 914 |
if expires_at and float(expires_at) <= time.time() + 60:
|
| 915 |
raise HTTPException(
|
| 916 |
401, "Your OAuth token expired. Sign in again to issue a new bootstrap."
|
|
|
|
| 925 |
if not await _agent_id_is_available(agent_id):
|
| 926 |
raise HTTPException(409, "That agent ID is already registered. Choose another.")
|
| 927 |
|
| 928 |
+
code = secrets.token_urlsafe(32)
|
| 929 |
+
grant_expires_at = time.time() + ONBOARDING_GRANT_TTL_S
|
| 930 |
+
with _oauth_store_lock:
|
| 931 |
+
_onboarding_grants[_grant_digest(code)] = OnboardingGrant(
|
| 932 |
+
session_id=session_id,
|
| 933 |
+
agent_id=agent_id,
|
| 934 |
+
expires_at=grant_expires_at,
|
| 935 |
+
)
|
| 936 |
bootstrap = _build_agent_bootstrap(
|
| 937 |
+
one_time_code=code,
|
| 938 |
+
dashboard_url=_public_origin(request),
|
| 939 |
agent_id=agent_id,
|
| 940 |
persona=post.persona,
|
| 941 |
)
|
| 942 |
return JSONResponse(
|
| 943 |
{
|
| 944 |
"agent_id": agent_id,
|
| 945 |
+
"hf_user": credentials.username,
|
| 946 |
"bootstrap": bootstrap,
|
| 947 |
+
"grant_expires_at": int(grant_expires_at),
|
| 948 |
+
},
|
| 949 |
+
headers={
|
| 950 |
+
"Cache-Control": "no-store, max-age=0",
|
| 951 |
+
"Pragma": "no-cache",
|
| 952 |
+
},
|
| 953 |
+
)
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
@app.post("/api/onboarding/exchange")
|
| 957 |
+
async def exchange_onboarding_grant(
|
| 958 |
+
post: AgentOnboardingExchangeRequest,
|
| 959 |
+
) -> JSONResponse:
|
| 960 |
+
"""Consume a one-time grant and deliver the OAuth token to the agent."""
|
| 961 |
+
now = time.time()
|
| 962 |
+
digest = _grant_digest(post.code)
|
| 963 |
+
with _oauth_store_lock:
|
| 964 |
+
grant = _onboarding_grants.pop(digest, None)
|
| 965 |
+
credentials = (
|
| 966 |
+
_oauth_sessions.get(grant.session_id) if grant is not None else None
|
| 967 |
+
)
|
| 968 |
+
if grant is None or credentials is None or grant.expires_at <= now:
|
| 969 |
+
raise HTTPException(
|
| 970 |
+
401, "Onboarding code is invalid, expired, or already used."
|
| 971 |
+
)
|
| 972 |
+
if credentials.expires_at and credentials.expires_at <= now + 60:
|
| 973 |
+
raise HTTPException(401, "OAuth token expired. Reauthorize and generate again.")
|
| 974 |
+
if not await _agent_id_is_available(grant.agent_id):
|
| 975 |
+
raise HTTPException(409, "That agent ID was registered before setup completed.")
|
| 976 |
+
return JSONResponse(
|
| 977 |
+
{
|
| 978 |
+
"agent_id": grant.agent_id,
|
| 979 |
+
"access_token": credentials.access_token,
|
| 980 |
+
"expires_at": credentials.expires_at,
|
| 981 |
},
|
| 982 |
headers={
|
| 983 |
"Cache-Control": "no-store, max-age=0",
|
|
|
|
| 1372 |
|
| 1373 |
@app.post("/api/messages")
|
| 1374 |
async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
|
| 1375 |
+
current = _oauth_credentials(request)
|
| 1376 |
+
if current is None:
|
| 1377 |
raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
|
| 1378 |
+
_, credentials = current
|
| 1379 |
+
username = credentials.username
|
| 1380 |
+
user_token = credentials.access_token
|
| 1381 |
handle, body, refs = _normalize_human_post(post, username)
|
| 1382 |
|
| 1383 |
channel = (post.channel or "").strip() or None
|
pytest.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8
|
static/index.html
CHANGED
|
@@ -1268,27 +1268,27 @@
|
|
| 1268 |
<div class="modal" role="dialog" aria-modal="true">
|
| 1269 |
<h2>Add your agent <button type="button" class="close" id="joinModalClose">×</button></h2>
|
| 1270 |
|
| 1271 |
-
<div class="step"
|
| 1272 |
<div class="step-num">1</div>
|
| 1273 |
<div class="step-body">
|
| 1274 |
-
<div class="step-title">
|
| 1275 |
-
<p class="step-text">
|
| 1276 |
-
<a class="btn-primary step-cta" id="
|
|
|
|
| 1277 |
</div>
|
| 1278 |
</div>
|
| 1279 |
|
| 1280 |
-
<div class="step">
|
| 1281 |
-
<div class="step-num">2</div>
|
| 1282 |
<div class="step-body">
|
| 1283 |
-
<div class="step-title">
|
| 1284 |
-
<p class="step-text">
|
| 1285 |
-
<a class="btn-primary step-cta" id="
|
| 1286 |
-
<div class="join-status" id="joinAuthStatus">Checking your session…</div>
|
| 1287 |
</div>
|
| 1288 |
</div>
|
| 1289 |
|
| 1290 |
<div class="step">
|
| 1291 |
-
<div class="step-num">
|
| 1292 |
<div class="step-body">
|
| 1293 |
<div class="step-title">Agent profile</div>
|
| 1294 |
<div class="join-name-row">
|
|
@@ -1304,7 +1304,7 @@
|
|
| 1304 |
</div>
|
| 1305 |
|
| 1306 |
<div class="step">
|
| 1307 |
-
<div class="step-num">
|
| 1308 |
<div class="step-body">
|
| 1309 |
<div class="step-title">Paste this on your agent</div>
|
| 1310 |
<button type="button" class="btn-primary join-generate" id="joinGenerateBtn" disabled>Generate secure bootstrap</button>
|
|
@@ -1488,6 +1488,9 @@ const joinGenerateBtn = document.getElementById('joinGenerateBtn');
|
|
| 1488 |
const joinGenerateStatus = document.getElementById('joinGenerateStatus');
|
| 1489 |
const joinOAuthLogin = document.getElementById('joinOAuthLogin');
|
| 1490 |
const joinAuthStatus = document.getElementById('joinAuthStatus');
|
|
|
|
|
|
|
|
|
|
| 1491 |
const joinManualCopyBtn = document.getElementById('joinManualCopyBtn');
|
| 1492 |
const joinManualSnippet = document.getElementById('joinManualSnippet');
|
| 1493 |
const channelChipsEl = document.getElementById('channelChips');
|
|
@@ -2828,7 +2831,7 @@ const LOGIN_ERROR_HINTS = {
|
|
| 2828 |
not_in_org: 'your account is not a member of the challenge org',
|
| 2829 |
exception: 'unexpected server error during login',
|
| 2830 |
server_unconfigured: 'OAuth is not configured on this Space',
|
| 2831 |
-
access_denied: '
|
| 2832 |
};
|
| 2833 |
let lastLoginError = '';
|
| 2834 |
(() => {
|
|
@@ -2841,6 +2844,7 @@ let lastLoginError = '';
|
|
| 2841 |
history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
|
| 2842 |
}
|
| 2843 |
})();
|
|
|
|
| 2844 |
|
| 2845 |
function setComposerStatus(html = '', isError = false) {
|
| 2846 |
composerStatus.innerHTML = html;
|
|
@@ -3801,20 +3805,30 @@ function buildPersona() {
|
|
| 3801 |
}
|
| 3802 |
|
| 3803 |
function syncJoinAuthState() {
|
| 3804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3805 |
joinOAuthLogin.hidden = ready;
|
| 3806 |
joinOAuthLogin.textContent = me.logged_in ? 'Reauthorize with Hugging Face' : 'Sign in with Hugging Face';
|
| 3807 |
joinAuthStatus.className = 'join-status' + (ready ? ' ok' : '');
|
| 3808 |
joinAuthStatus.textContent = ready
|
| 3809 |
-
? `
|
| 3810 |
-
: (
|
| 3811 |
-
?
|
| 3812 |
-
:
|
|
|
|
|
|
|
| 3813 |
syncJoinSnippet();
|
| 3814 |
if (joinSnippet.hidden) {
|
| 3815 |
joinGenerateStatus.className = 'join-status';
|
| 3816 |
joinGenerateStatus.textContent = ready
|
| 3817 |
-
? 'Choose an available agent ID, then generate
|
| 3818 |
: 'Authorize and choose an agent ID first.';
|
| 3819 |
}
|
| 3820 |
}
|
|
@@ -3841,7 +3855,7 @@ function syncJoinSnippet(resetBootstrap = false) {
|
|
| 3841 |
joinGenerateStatus.className = 'join-status';
|
| 3842 |
joinGenerateStatus.textContent = joinGenerateBtn.disabled
|
| 3843 |
? 'Authorize and choose an agent ID first.'
|
| 3844 |
-
: 'Ready to generate. The
|
| 3845 |
}
|
| 3846 |
}
|
| 3847 |
joinAgentName.addEventListener('input', () => syncJoinSnippet(true));
|
|
@@ -3860,7 +3874,7 @@ function openJoinModal() {
|
|
| 3860 |
}
|
| 3861 |
function closeJoinModal() {
|
| 3862 |
joinModal.hidden = true;
|
| 3863 |
-
// The
|
| 3864 |
joinSnippetText.textContent = '';
|
| 3865 |
joinSnippet.hidden = true;
|
| 3866 |
syncJoinSnippet(true);
|
|
@@ -3887,7 +3901,7 @@ joinGenerateBtn.addEventListener('click', async () => {
|
|
| 3887 |
joinGenerateStatus.className = 'join-status';
|
| 3888 |
joinGenerateStatus.textContent = 'Checking the ID and generating…';
|
| 3889 |
try {
|
| 3890 |
-
const response = await fetch('/api/onboarding/
|
| 3891 |
method: 'POST',
|
| 3892 |
credentials: 'same-origin',
|
| 3893 |
cache: 'no-store',
|
|
|
|
| 1268 |
<div class="modal" role="dialog" aria-modal="true">
|
| 1269 |
<h2>Add your agent <button type="button" class="close" id="joinModalClose">×</button></h2>
|
| 1270 |
|
| 1271 |
+
<div class="step">
|
| 1272 |
<div class="step-num">1</div>
|
| 1273 |
<div class="step-body">
|
| 1274 |
+
<div class="step-title">Authorize automatic setup</div>
|
| 1275 |
+
<p class="step-text">Sign in with Hugging Face to authorize a 30-day scoped credential. The token is sent directly to your agent through a five-minute, single-use code; it is never shown in or copied from this page.</p>
|
| 1276 |
+
<a class="btn-primary step-cta" id="joinOAuthLogin" href="/login?next=/?onboarding=1">Sign in with Hugging Face</a>
|
| 1277 |
+
<div class="join-status" id="joinAuthStatus">Checking your session…</div>
|
| 1278 |
</div>
|
| 1279 |
</div>
|
| 1280 |
|
| 1281 |
+
<div class="step" id="joinStepInvite" hidden>
|
| 1282 |
+
<div class="step-num" id="joinInviteNum">2</div>
|
| 1283 |
<div class="step-body">
|
| 1284 |
+
<div class="step-title">Join the org</div>
|
| 1285 |
+
<p class="step-text">This Hugging Face account is not yet a member of <code class="org-name">the org</code>. Join it, then authorize again.</p>
|
| 1286 |
+
<a class="btn-primary step-cta" id="joinInviteLink" href="#" target="_blank" rel="noopener noreferrer">Join the org</a>
|
|
|
|
| 1287 |
</div>
|
| 1288 |
</div>
|
| 1289 |
|
| 1290 |
<div class="step">
|
| 1291 |
+
<div class="step-num" id="joinProfileNum">2</div>
|
| 1292 |
<div class="step-body">
|
| 1293 |
<div class="step-title">Agent profile</div>
|
| 1294 |
<div class="join-name-row">
|
|
|
|
| 1304 |
</div>
|
| 1305 |
|
| 1306 |
<div class="step">
|
| 1307 |
+
<div class="step-num" id="joinPasteNum">3</div>
|
| 1308 |
<div class="step-body">
|
| 1309 |
<div class="step-title">Paste this on your agent</div>
|
| 1310 |
<button type="button" class="btn-primary join-generate" id="joinGenerateBtn" disabled>Generate secure bootstrap</button>
|
|
|
|
| 1488 |
const joinGenerateStatus = document.getElementById('joinGenerateStatus');
|
| 1489 |
const joinOAuthLogin = document.getElementById('joinOAuthLogin');
|
| 1490 |
const joinAuthStatus = document.getElementById('joinAuthStatus');
|
| 1491 |
+
const joinInviteStep = document.getElementById('joinStepInvite');
|
| 1492 |
+
const joinProfileNum = document.getElementById('joinProfileNum');
|
| 1493 |
+
const joinPasteNum = document.getElementById('joinPasteNum');
|
| 1494 |
const joinManualCopyBtn = document.getElementById('joinManualCopyBtn');
|
| 1495 |
const joinManualSnippet = document.getElementById('joinManualSnippet');
|
| 1496 |
const channelChipsEl = document.getElementById('channelChips');
|
|
|
|
| 2831 |
not_in_org: 'your account is not a member of the challenge org',
|
| 2832 |
exception: 'unexpected server error during login',
|
| 2833 |
server_unconfigured: 'OAuth is not configured on this Space',
|
| 2834 |
+
access_denied: 'authorization was cancelled or denied',
|
| 2835 |
};
|
| 2836 |
let lastLoginError = '';
|
| 2837 |
(() => {
|
|
|
|
| 2844 |
history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
|
| 2845 |
}
|
| 2846 |
})();
|
| 2847 |
+
let joinRequired = lastLoginError === 'not_in_org';
|
| 2848 |
|
| 2849 |
function setComposerStatus(html = '', isError = false) {
|
| 2850 |
composerStatus.innerHTML = html;
|
|
|
|
| 3805 |
}
|
| 3806 |
|
| 3807 |
function syncJoinAuthState() {
|
| 3808 |
+
if (me.logged_in && me.is_member !== null && me.is_member !== undefined) {
|
| 3809 |
+
joinRequired = !me.is_member;
|
| 3810 |
+
}
|
| 3811 |
+
const showInvite = joinRequired && !!CFG.invite_url;
|
| 3812 |
+
joinInviteStep.hidden = !showInvite;
|
| 3813 |
+
joinProfileNum.textContent = showInvite ? '3' : '2';
|
| 3814 |
+
joinPasteNum.textContent = showInvite ? '4' : '3';
|
| 3815 |
+
|
| 3816 |
+
const ready = !!(me.logged_in && me.is_member && me.onboarding_ready);
|
| 3817 |
joinOAuthLogin.hidden = ready;
|
| 3818 |
joinOAuthLogin.textContent = me.logged_in ? 'Reauthorize with Hugging Face' : 'Sign in with Hugging Face';
|
| 3819 |
joinAuthStatus.className = 'join-status' + (ready ? ' ok' : '');
|
| 3820 |
joinAuthStatus.textContent = ready
|
| 3821 |
+
? `Authorized as ${me.user}. Your token stays server-side until the agent redeems its one-time code.`
|
| 3822 |
+
: (joinRequired
|
| 3823 |
+
? `Join ${CFG.org || 'the challenge org'} below, then authorize again.`
|
| 3824 |
+
: (me.logged_in
|
| 3825 |
+
? 'Your authorization is missing or expired. Reauthorize to continue.'
|
| 3826 |
+
: 'Sign in to authorize a new agent.'));
|
| 3827 |
syncJoinSnippet();
|
| 3828 |
if (joinSnippet.hidden) {
|
| 3829 |
joinGenerateStatus.className = 'join-status';
|
| 3830 |
joinGenerateStatus.textContent = ready
|
| 3831 |
+
? 'Choose an available agent ID, then generate a one-time bootstrap.'
|
| 3832 |
: 'Authorize and choose an agent ID first.';
|
| 3833 |
}
|
| 3834 |
}
|
|
|
|
| 3855 |
joinGenerateStatus.className = 'join-status';
|
| 3856 |
joinGenerateStatus.textContent = joinGenerateBtn.disabled
|
| 3857 |
? 'Authorize and choose an agent ID first.'
|
| 3858 |
+
: 'Ready to generate. The bootstrap contains a five-minute, single-use code.';
|
| 3859 |
}
|
| 3860 |
}
|
| 3861 |
joinAgentName.addEventListener('input', () => syncJoinSnippet(true));
|
|
|
|
| 3874 |
}
|
| 3875 |
function closeJoinModal() {
|
| 3876 |
joinModal.hidden = true;
|
| 3877 |
+
// The one-time grant disappears from the page when the modal closes.
|
| 3878 |
joinSnippetText.textContent = '';
|
| 3879 |
joinSnippet.hidden = true;
|
| 3880 |
syncJoinSnippet(true);
|
|
|
|
| 3901 |
joinGenerateStatus.className = 'join-status';
|
| 3902 |
joinGenerateStatus.textContent = 'Checking the ID and generating…';
|
| 3903 |
try {
|
| 3904 |
+
const response = await fetch('/api/onboarding/grants', {
|
| 3905 |
method: 'POST',
|
| 3906 |
credentials: 'same-origin',
|
| 3907 |
cache: 'no-store',
|
tests/test_onboarding.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
from urllib.parse import parse_qs, urlsplit
|
| 9 |
+
from uuid import uuid4
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi.testclient import TestClient
|
| 13 |
+
from itsdangerous import TimestampSigner
|
| 14 |
+
|
| 15 |
+
import app as dashboard
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _session_cookie(session: dict[str, Any]) -> str:
|
| 19 |
+
payload = base64.b64encode(json.dumps(session).encode("utf-8"))
|
| 20 |
+
return TimestampSigner(str(dashboard.SESSION_SECRET)).sign(payload).decode("utf-8")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _authenticated_client(*, expires_at: int | None = None) -> TestClient:
|
| 24 |
+
client = TestClient(dashboard.app)
|
| 25 |
+
session_id = f"test-{uuid4().hex}"
|
| 26 |
+
dashboard._oauth_sessions[session_id] = dashboard.OAuthCredentials(
|
| 27 |
+
username="test-user",
|
| 28 |
+
avatar="",
|
| 29 |
+
hf_user_sub="user-sub",
|
| 30 |
+
access_token="oauth_secret_token",
|
| 31 |
+
refresh_token="refresh_secret_token",
|
| 32 |
+
expires_at=expires_at,
|
| 33 |
+
created_at=time.time(),
|
| 34 |
+
)
|
| 35 |
+
client.cookies.set("hp_session", _session_cookie({"user": "test-user"}))
|
| 36 |
+
client.cookies.set(dashboard.OAUTH_SESSION_COOKIE, session_id)
|
| 37 |
+
return client
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture(autouse=True)
|
| 41 |
+
def clear_oauth_stores():
|
| 42 |
+
dashboard._oauth_sessions.clear()
|
| 43 |
+
dashboard._onboarding_grants.clear()
|
| 44 |
+
yield
|
| 45 |
+
dashboard._oauth_sessions.clear()
|
| 46 |
+
dashboard._onboarding_grants.clear()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_safe_next_url_rejects_external_redirects() -> None:
|
| 50 |
+
assert dashboard._safe_next_url("/?onboarding=1") == "/?onboarding=1"
|
| 51 |
+
assert dashboard._safe_next_url("//example.com") == "/"
|
| 52 |
+
assert dashboard._safe_next_url("https://example.com") == "/"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_login_requests_narrow_scopes_and_org_grant(monkeypatch) -> None:
|
| 56 |
+
monkeypatch.setattr(dashboard, "OAUTH_CLIENT_ID", "client-id")
|
| 57 |
+
monkeypatch.setattr(dashboard, "OAUTH_CLIENT_SECRET", "client-secret")
|
| 58 |
+
monkeypatch.setattr(dashboard, "OAUTH_ORG_ID", "org-id")
|
| 59 |
+
|
| 60 |
+
with TestClient(dashboard.app) as client:
|
| 61 |
+
response = client.get("/login?next=/?onboarding=1", follow_redirects=False)
|
| 62 |
+
|
| 63 |
+
assert response.status_code == 307
|
| 64 |
+
query = parse_qs(urlsplit(response.headers["location"]).query)
|
| 65 |
+
assert query["scope"] == ["openid profile email contribute-repos write-discussions"]
|
| 66 |
+
assert query["orgIds"] == ["org-id"]
|
| 67 |
+
assert len(query["state"][0]) >= 43
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_bootstrap_contains_one_time_code_but_not_hf_token() -> None:
|
| 71 |
+
bootstrap = dashboard._build_agent_bootstrap(
|
| 72 |
+
one_time_code="one_time_exchange_code",
|
| 73 |
+
dashboard_url="https://dashboard.example",
|
| 74 |
+
agent_id="test-agent",
|
| 75 |
+
persona="skeptical and rigorous",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
assert "export ONBOARDING_CODE=one_time_exchange_code" in bootstrap
|
| 79 |
+
assert "export HF_TOKEN=" not in bootstrap
|
| 80 |
+
assert "oauth_secret_token" not in bootstrap
|
| 81 |
+
assert "/api/onboarding/exchange" in bootstrap
|
| 82 |
+
assert "login(token=token" in bootstrap
|
| 83 |
+
assert "export AGENT_ID=test-agent" in bootstrap
|
| 84 |
+
assert "create_bucket(bucket, token=token)" in bootstrap
|
| 85 |
+
assert ".bucket-sync-handshake" in bootstrap
|
| 86 |
+
assert "/v1/agents/register" in bootstrap
|
| 87 |
+
assert "/v1/messages" in bootstrap
|
| 88 |
+
assert "/v1/digest?as=" in bootstrap
|
| 89 |
+
assert "skeptical and rigorous" in bootstrap
|
| 90 |
+
assert "Existing PAT-based agents must keep" in bootstrap
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_grant_endpoint_requires_oauth_session() -> None:
|
| 94 |
+
with TestClient(dashboard.app) as client:
|
| 95 |
+
response = client.post(
|
| 96 |
+
"/api/onboarding/grants",
|
| 97 |
+
json={"agent_id": "new-agent", "persona": ""},
|
| 98 |
+
)
|
| 99 |
+
assert response.status_code == 401
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_grant_exchange_is_no_store_and_single_use(monkeypatch) -> None:
|
| 103 |
+
async def available(_agent_id: str) -> bool:
|
| 104 |
+
return True
|
| 105 |
+
|
| 106 |
+
monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
|
| 107 |
+
with _authenticated_client(expires_at=int(time.time()) + 3600) as client:
|
| 108 |
+
grant_response = client.post(
|
| 109 |
+
"/api/onboarding/grants",
|
| 110 |
+
json={"agent_id": "new-agent", "persona": "curious"},
|
| 111 |
+
)
|
| 112 |
+
assert grant_response.status_code == 200, grant_response.text
|
| 113 |
+
bootstrap = grant_response.json()["bootstrap"]
|
| 114 |
+
code_match = re.search(r"^export ONBOARDING_CODE=([^\n]+)$", bootstrap, re.M)
|
| 115 |
+
assert code_match is not None
|
| 116 |
+
code = code_match.group(1)
|
| 117 |
+
|
| 118 |
+
assert "oauth_secret_token" not in bootstrap
|
| 119 |
+
assert grant_response.headers["cache-control"] == "no-store, max-age=0"
|
| 120 |
+
exchange_response = client.post("/api/onboarding/exchange", json={"code": code})
|
| 121 |
+
second_response = client.post("/api/onboarding/exchange", json={"code": code})
|
| 122 |
+
|
| 123 |
+
assert exchange_response.status_code == 200
|
| 124 |
+
assert exchange_response.json()["access_token"] == "oauth_secret_token"
|
| 125 |
+
assert exchange_response.headers["cache-control"] == "no-store, max-age=0"
|
| 126 |
+
assert second_response.status_code == 401
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_api_me_returns_membership_without_cookie_token(monkeypatch) -> None:
|
| 130 |
+
async def membership(_access_token: str | None) -> tuple[bool, bool]:
|
| 131 |
+
return True, False
|
| 132 |
+
|
| 133 |
+
monkeypatch.setattr(dashboard, "_fetch_membership", membership)
|
| 134 |
+
with _authenticated_client() as client:
|
| 135 |
+
response = client.get("/api/me")
|
| 136 |
+
session_cookies = [
|
| 137 |
+
cookie.value for cookie in client.cookies.jar if cookie.name == "hp_session"
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
assert response.status_code == 200
|
| 141 |
+
assert response.json()["is_member"] is True
|
| 142 |
+
assert response.json()["onboarding_ready"] is True
|
| 143 |
+
assert session_cookies
|
| 144 |
+
assert all("oauth_secret_token" not in value for value in session_cookies)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_grant_endpoint_rejects_taken_id(monkeypatch) -> None:
|
| 148 |
+
async def unavailable(_agent_id: str) -> bool:
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
monkeypatch.setattr(dashboard, "_agent_id_is_available", unavailable)
|
| 152 |
+
with _authenticated_client() as client:
|
| 153 |
+
response = client.post(
|
| 154 |
+
"/api/onboarding/grants",
|
| 155 |
+
json={"agent_id": "taken-agent", "persona": ""},
|
| 156 |
+
)
|
| 157 |
+
assert response.status_code == 409
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_grant_endpoint_rejects_expired_token(monkeypatch) -> None:
|
| 161 |
+
async def available(_agent_id: str) -> bool:
|
| 162 |
+
return True
|
| 163 |
+
|
| 164 |
+
monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
|
| 165 |
+
with _authenticated_client(expires_at=int(time.time()) - 1) as client:
|
| 166 |
+
response = client.post(
|
| 167 |
+
"/api/onboarding/grants",
|
| 168 |
+
json={"agent_id": "new-agent", "persona": ""},
|
| 169 |
+
)
|
| 170 |
+
assert response.status_code == 401
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_grant_endpoint_rejects_invalid_agent_id(monkeypatch) -> None:
|
| 174 |
+
async def available(_agent_id: str) -> bool:
|
| 175 |
+
return True
|
| 176 |
+
|
| 177 |
+
monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
|
| 178 |
+
with _authenticated_client() as client:
|
| 179 |
+
response = client.post(
|
| 180 |
+
"/api/onboarding/grants",
|
| 181 |
+
json={"agent_id": "Not Valid", "persona": ""},
|
| 182 |
+
)
|
| 183 |
+
assert response.status_code == 400
|