Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Migrate to client-side OAuth via @huggingface/hub
Browse files- Frontend: oauthLoginUrl + oauthHandleRedirectIfPresent for login flow
- Token stored in localStorage, sent via Bearer header + WS query param
- Works in HF Spaces iframes (no third-party cookies needed)
- Backend: user's HF token threaded through Session β tools
- Jobs now run with authenticated user's credentials (not just admin token)
- Fallback to env HF_TOKEN when no user token available
Co-authored-by: Cursor <cursoragent@cursor.com>
- agent/core/session.py +2 -0
- agent/tools/jobs_tool.py +13 -6
- backend/routes/agent.py +17 -3
- backend/session_manager.py +6 -1
- frontend/package-lock.json +93 -0
- frontend/package.json +1 -0
- frontend/src/hooks/useAuth.ts +79 -33
- frontend/src/utils/api.ts +34 -30
agent/core/session.py
CHANGED
|
@@ -86,6 +86,8 @@ class Session:
|
|
| 86 |
self.is_running = True
|
| 87 |
self.current_task: asyncio.Task | None = None
|
| 88 |
self.pending_approval: Optional[dict[str, Any]] = None
|
|
|
|
|
|
|
| 89 |
|
| 90 |
# Session trajectory logging
|
| 91 |
self.logged_events: list[dict] = []
|
|
|
|
| 86 |
self.is_running = True
|
| 87 |
self.current_task: asyncio.Task | None = None
|
| 88 |
self.pending_approval: Optional[dict[str, Any]] = None
|
| 89 |
+
# User's HF OAuth token β set by session_manager after construction
|
| 90 |
+
self.hf_token: Optional[str] = None
|
| 91 |
|
| 92 |
# Session trajectory logging
|
| 93 |
self.logged_events: list[dict] = []
|
agent/tools/jobs_tool.py
CHANGED
|
@@ -122,8 +122,11 @@ def _filter_uv_install_output(logs: list[str]) -> list[str]:
|
|
| 122 |
return logs
|
| 123 |
|
| 124 |
|
| 125 |
-
def _add_environment_variables(
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
# Start with user-provided env vars, then force-set token last
|
| 129 |
result = dict(params or {})
|
|
@@ -502,7 +505,7 @@ class HfJobsTool:
|
|
| 502 |
image=image,
|
| 503 |
command=command,
|
| 504 |
env=args.get("env"),
|
| 505 |
-
secrets=_add_environment_variables(args.get("secrets")),
|
| 506 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 507 |
timeout=args.get("timeout", "30m"),
|
| 508 |
namespace=self.namespace,
|
|
@@ -720,7 +723,7 @@ To verify, call this tool with `{{"operation": "inspect", "job_id": "{job_id}"}}
|
|
| 720 |
command=command,
|
| 721 |
schedule=schedule,
|
| 722 |
env=args.get("env"),
|
| 723 |
-
secrets=_add_environment_variables(args.get("secrets")),
|
| 724 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 725 |
timeout=args.get("timeout", "30m"),
|
| 726 |
namespace=self.namespace,
|
|
@@ -1019,8 +1022,12 @@ async def hf_jobs_handler(
|
|
| 1019 |
Event(event_type="tool_log", data={"tool": "hf_jobs", "log": log})
|
| 1020 |
)
|
| 1021 |
|
| 1022 |
-
#
|
| 1023 |
-
hf_token =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
|
| 1025 |
|
| 1026 |
tool = HfJobsTool(
|
|
|
|
| 122 |
return logs
|
| 123 |
|
| 124 |
|
| 125 |
+
def _add_environment_variables(
|
| 126 |
+
params: Dict[str, Any] | None, user_token: str | None = None
|
| 127 |
+
) -> Dict[str, Any]:
|
| 128 |
+
# Prefer the authenticated user's OAuth token, fall back to global env var
|
| 129 |
+
token = user_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
|
| 130 |
|
| 131 |
# Start with user-provided env vars, then force-set token last
|
| 132 |
result = dict(params or {})
|
|
|
|
| 505 |
image=image,
|
| 506 |
command=command,
|
| 507 |
env=args.get("env"),
|
| 508 |
+
secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
|
| 509 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 510 |
timeout=args.get("timeout", "30m"),
|
| 511 |
namespace=self.namespace,
|
|
|
|
| 723 |
command=command,
|
| 724 |
schedule=schedule,
|
| 725 |
env=args.get("env"),
|
| 726 |
+
secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
|
| 727 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 728 |
timeout=args.get("timeout", "30m"),
|
| 729 |
namespace=self.namespace,
|
|
|
|
| 1022 |
Event(event_type="tool_log", data={"tool": "hf_jobs", "log": log})
|
| 1023 |
)
|
| 1024 |
|
| 1025 |
+
# Prefer the authenticated user's OAuth token, fall back to global env
|
| 1026 |
+
hf_token = (
|
| 1027 |
+
(getattr(session, "hf_token", None) if session else None)
|
| 1028 |
+
or os.environ.get("HF_TOKEN")
|
| 1029 |
+
or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 1030 |
+
)
|
| 1031 |
namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
|
| 1032 |
|
| 1033 |
tool = HfJobsTool(
|
backend/routes/agent.py
CHANGED
|
@@ -7,7 +7,7 @@ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
|
|
| 7 |
import logging
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
-
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
| 11 |
|
| 12 |
from dependencies import get_current_user, get_ws_user
|
| 13 |
from litellm import acompletion
|
|
@@ -126,13 +126,27 @@ async def generate_title(
|
|
| 126 |
|
| 127 |
|
| 128 |
@router.post("/session", response_model=SessionResponse)
|
| 129 |
-
async def create_session(
|
|
|
|
|
|
|
| 130 |
"""Create a new agent session bound to the authenticated user.
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
Returns 503 if the server or user has reached the session limit.
|
| 133 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
try:
|
| 135 |
-
session_id = await session_manager.create_session(
|
|
|
|
|
|
|
| 136 |
except SessionCapacityError as e:
|
| 137 |
raise HTTPException(status_code=503, detail=str(e))
|
| 138 |
return SessionResponse(session_id=session_id, ready=True)
|
|
|
|
| 7 |
import logging
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
| 11 |
|
| 12 |
from dependencies import get_current_user, get_ws_user
|
| 13 |
from litellm import acompletion
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
@router.post("/session", response_model=SessionResponse)
|
| 129 |
+
async def create_session(
|
| 130 |
+
request: Request, user: dict = Depends(get_current_user)
|
| 131 |
+
) -> SessionResponse:
|
| 132 |
"""Create a new agent session bound to the authenticated user.
|
| 133 |
|
| 134 |
+
The user's HF access token is extracted from the Authorization header
|
| 135 |
+
and stored in the session so that tools (e.g. hf_jobs) can act on
|
| 136 |
+
behalf of the user.
|
| 137 |
+
|
| 138 |
Returns 503 if the server or user has reached the session limit.
|
| 139 |
"""
|
| 140 |
+
# Extract the user's HF token from the Bearer header
|
| 141 |
+
hf_token = None
|
| 142 |
+
auth_header = request.headers.get("Authorization", "")
|
| 143 |
+
if auth_header.startswith("Bearer "):
|
| 144 |
+
hf_token = auth_header[7:]
|
| 145 |
+
|
| 146 |
try:
|
| 147 |
+
session_id = await session_manager.create_session(
|
| 148 |
+
user_id=user["user_id"], hf_token=hf_token
|
| 149 |
+
)
|
| 150 |
except SessionCapacityError as e:
|
| 151 |
raise HTTPException(status_code=503, detail=str(e))
|
| 152 |
return SessionResponse(session_id=session_id, ready=True)
|
backend/session_manager.py
CHANGED
|
@@ -49,6 +49,7 @@ class AgentSession:
|
|
| 49 |
tool_router: ToolRouter
|
| 50 |
submission_queue: asyncio.Queue
|
| 51 |
user_id: str = "dev" # Owner of this session
|
|
|
|
| 52 |
task: asyncio.Task | None = None
|
| 53 |
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 54 |
is_active: bool = True
|
|
@@ -85,7 +86,7 @@ class SessionManager:
|
|
| 85 |
if s.user_id == user_id and s.is_active
|
| 86 |
)
|
| 87 |
|
| 88 |
-
async def create_session(self, user_id: str = "dev") -> str:
|
| 89 |
"""Create a new agent session and return its ID.
|
| 90 |
|
| 91 |
Session() and ToolRouter() constructors contain blocking I/O
|
|
@@ -138,6 +139,9 @@ class SessionManager:
|
|
| 138 |
|
| 139 |
tool_router, session = await asyncio.to_thread(_create_session_sync)
|
| 140 |
|
|
|
|
|
|
|
|
|
|
| 141 |
# Create wrapper
|
| 142 |
agent_session = AgentSession(
|
| 143 |
session_id=session_id,
|
|
@@ -145,6 +149,7 @@ class SessionManager:
|
|
| 145 |
tool_router=tool_router,
|
| 146 |
submission_queue=submission_queue,
|
| 147 |
user_id=user_id,
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
async with self._lock:
|
|
|
|
| 49 |
tool_router: ToolRouter
|
| 50 |
submission_queue: asyncio.Queue
|
| 51 |
user_id: str = "dev" # Owner of this session
|
| 52 |
+
hf_token: str | None = None # User's HF OAuth token for tool execution
|
| 53 |
task: asyncio.Task | None = None
|
| 54 |
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 55 |
is_active: bool = True
|
|
|
|
| 86 |
if s.user_id == user_id and s.is_active
|
| 87 |
)
|
| 88 |
|
| 89 |
+
async def create_session(self, user_id: str = "dev", hf_token: str | None = None) -> str:
|
| 90 |
"""Create a new agent session and return its ID.
|
| 91 |
|
| 92 |
Session() and ToolRouter() constructors contain blocking I/O
|
|
|
|
| 139 |
|
| 140 |
tool_router, session = await asyncio.to_thread(_create_session_sync)
|
| 141 |
|
| 142 |
+
# Store user's HF token on the session so tools can use it
|
| 143 |
+
session.hf_token = hf_token
|
| 144 |
+
|
| 145 |
# Create wrapper
|
| 146 |
agent_session = AgentSession(
|
| 147 |
session_id=session_id,
|
|
|
|
| 149 |
tool_router=tool_router,
|
| 150 |
submission_queue=submission_queue,
|
| 151 |
user_id=user_id,
|
| 152 |
+
hf_token=hf_token,
|
| 153 |
)
|
| 154 |
|
| 155 |
async with self._lock:
|
frontend/package-lock.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"dependencies": {
|
| 11 |
"@emotion/react": "^11.13.0",
|
| 12 |
"@emotion/styled": "^11.13.0",
|
|
|
|
| 13 |
"@mui/icons-material": "^6.1.0",
|
| 14 |
"@mui/material": "^6.1.0",
|
| 15 |
"react": "^18.3.1",
|
|
@@ -1019,6 +1020,30 @@
|
|
| 1019 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
| 1020 |
}
|
| 1021 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1022 |
"node_modules/@humanfs/core": {
|
| 1023 |
"version": "0.19.1",
|
| 1024 |
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
|
@@ -2217,6 +2242,16 @@
|
|
| 2217 |
"url": "https://github.com/sponsors/epoberezkin"
|
| 2218 |
}
|
| 2219 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2220 |
"node_modules/ansi-styles": {
|
| 2221 |
"version": "4.3.0",
|
| 2222 |
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
|
@@ -2425,6 +2460,19 @@
|
|
| 2425 |
"url": "https://github.com/sponsors/wooorm"
|
| 2426 |
}
|
| 2427 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2428 |
"node_modules/clsx": {
|
| 2429 |
"version": "2.1.1",
|
| 2430 |
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
@@ -2590,6 +2638,13 @@
|
|
| 2590 |
"dev": true,
|
| 2591 |
"license": "ISC"
|
| 2592 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2593 |
"node_modules/error-ex": {
|
| 2594 |
"version": "1.3.4",
|
| 2595 |
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
|
@@ -3270,6 +3325,16 @@
|
|
| 3270 |
"node": ">=0.10.0"
|
| 3271 |
}
|
| 3272 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3273 |
"node_modules/is-glob": {
|
| 3274 |
"version": "4.0.3",
|
| 3275 |
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
@@ -4976,6 +5041,21 @@
|
|
| 4976 |
"url": "https://github.com/sponsors/wooorm"
|
| 4977 |
}
|
| 4978 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4979 |
"node_modules/stringify-entities": {
|
| 4980 |
"version": "4.0.4",
|
| 4981 |
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
|
@@ -4990,6 +5070,19 @@
|
|
| 4990 |
"url": "https://github.com/sponsors/wooorm"
|
| 4991 |
}
|
| 4992 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4993 |
"node_modules/strip-json-comments": {
|
| 4994 |
"version": "3.1.1",
|
| 4995 |
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
|
|
|
| 10 |
"dependencies": {
|
| 11 |
"@emotion/react": "^11.13.0",
|
| 12 |
"@emotion/styled": "^11.13.0",
|
| 13 |
+
"@huggingface/hub": "^2.8.1",
|
| 14 |
"@mui/icons-material": "^6.1.0",
|
| 15 |
"@mui/material": "^6.1.0",
|
| 16 |
"react": "^18.3.1",
|
|
|
|
| 1020 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
| 1021 |
}
|
| 1022 |
},
|
| 1023 |
+
"node_modules/@huggingface/hub": {
|
| 1024 |
+
"version": "2.8.1",
|
| 1025 |
+
"resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.8.1.tgz",
|
| 1026 |
+
"integrity": "sha512-VAsXdMiIHPteXQJhrwaBEiePTWiJ0zBSymHdnX4J+AijjNN0h3RzGfkKemXcu75gu/TmRLFY9l8+2Tkdmpis0w==",
|
| 1027 |
+
"license": "MIT",
|
| 1028 |
+
"dependencies": {
|
| 1029 |
+
"@huggingface/tasks": "^0.19.82"
|
| 1030 |
+
},
|
| 1031 |
+
"bin": {
|
| 1032 |
+
"hfjs": "dist/cli.js"
|
| 1033 |
+
},
|
| 1034 |
+
"engines": {
|
| 1035 |
+
"node": ">=18"
|
| 1036 |
+
},
|
| 1037 |
+
"optionalDependencies": {
|
| 1038 |
+
"cli-progress": "^3.12.0"
|
| 1039 |
+
}
|
| 1040 |
+
},
|
| 1041 |
+
"node_modules/@huggingface/tasks": {
|
| 1042 |
+
"version": "0.19.84",
|
| 1043 |
+
"resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.84.tgz",
|
| 1044 |
+
"integrity": "sha512-nn8DtUW7EZb4QcV+AdNbggPDbvaN32+FldkJakDVml0Aa18fSWjxePdxaRejfTlJYX9oGanOjQKj++NDVRYFXw==",
|
| 1045 |
+
"license": "MIT"
|
| 1046 |
+
},
|
| 1047 |
"node_modules/@humanfs/core": {
|
| 1048 |
"version": "0.19.1",
|
| 1049 |
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
|
|
|
| 2242 |
"url": "https://github.com/sponsors/epoberezkin"
|
| 2243 |
}
|
| 2244 |
},
|
| 2245 |
+
"node_modules/ansi-regex": {
|
| 2246 |
+
"version": "5.0.1",
|
| 2247 |
+
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
| 2248 |
+
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
| 2249 |
+
"license": "MIT",
|
| 2250 |
+
"optional": true,
|
| 2251 |
+
"engines": {
|
| 2252 |
+
"node": ">=8"
|
| 2253 |
+
}
|
| 2254 |
+
},
|
| 2255 |
"node_modules/ansi-styles": {
|
| 2256 |
"version": "4.3.0",
|
| 2257 |
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
|
|
|
| 2460 |
"url": "https://github.com/sponsors/wooorm"
|
| 2461 |
}
|
| 2462 |
},
|
| 2463 |
+
"node_modules/cli-progress": {
|
| 2464 |
+
"version": "3.12.0",
|
| 2465 |
+
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
|
| 2466 |
+
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
|
| 2467 |
+
"license": "MIT",
|
| 2468 |
+
"optional": true,
|
| 2469 |
+
"dependencies": {
|
| 2470 |
+
"string-width": "^4.2.3"
|
| 2471 |
+
},
|
| 2472 |
+
"engines": {
|
| 2473 |
+
"node": ">=4"
|
| 2474 |
+
}
|
| 2475 |
+
},
|
| 2476 |
"node_modules/clsx": {
|
| 2477 |
"version": "2.1.1",
|
| 2478 |
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
|
|
| 2638 |
"dev": true,
|
| 2639 |
"license": "ISC"
|
| 2640 |
},
|
| 2641 |
+
"node_modules/emoji-regex": {
|
| 2642 |
+
"version": "8.0.0",
|
| 2643 |
+
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
| 2644 |
+
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
| 2645 |
+
"license": "MIT",
|
| 2646 |
+
"optional": true
|
| 2647 |
+
},
|
| 2648 |
"node_modules/error-ex": {
|
| 2649 |
"version": "1.3.4",
|
| 2650 |
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
|
|
|
| 3325 |
"node": ">=0.10.0"
|
| 3326 |
}
|
| 3327 |
},
|
| 3328 |
+
"node_modules/is-fullwidth-code-point": {
|
| 3329 |
+
"version": "3.0.0",
|
| 3330 |
+
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
| 3331 |
+
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
| 3332 |
+
"license": "MIT",
|
| 3333 |
+
"optional": true,
|
| 3334 |
+
"engines": {
|
| 3335 |
+
"node": ">=8"
|
| 3336 |
+
}
|
| 3337 |
+
},
|
| 3338 |
"node_modules/is-glob": {
|
| 3339 |
"version": "4.0.3",
|
| 3340 |
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
|
|
| 5041 |
"url": "https://github.com/sponsors/wooorm"
|
| 5042 |
}
|
| 5043 |
},
|
| 5044 |
+
"node_modules/string-width": {
|
| 5045 |
+
"version": "4.2.3",
|
| 5046 |
+
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
| 5047 |
+
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
| 5048 |
+
"license": "MIT",
|
| 5049 |
+
"optional": true,
|
| 5050 |
+
"dependencies": {
|
| 5051 |
+
"emoji-regex": "^8.0.0",
|
| 5052 |
+
"is-fullwidth-code-point": "^3.0.0",
|
| 5053 |
+
"strip-ansi": "^6.0.1"
|
| 5054 |
+
},
|
| 5055 |
+
"engines": {
|
| 5056 |
+
"node": ">=8"
|
| 5057 |
+
}
|
| 5058 |
+
},
|
| 5059 |
"node_modules/stringify-entities": {
|
| 5060 |
"version": "4.0.4",
|
| 5061 |
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
|
|
|
| 5070 |
"url": "https://github.com/sponsors/wooorm"
|
| 5071 |
}
|
| 5072 |
},
|
| 5073 |
+
"node_modules/strip-ansi": {
|
| 5074 |
+
"version": "6.0.1",
|
| 5075 |
+
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
| 5076 |
+
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
| 5077 |
+
"license": "MIT",
|
| 5078 |
+
"optional": true,
|
| 5079 |
+
"dependencies": {
|
| 5080 |
+
"ansi-regex": "^5.0.1"
|
| 5081 |
+
},
|
| 5082 |
+
"engines": {
|
| 5083 |
+
"node": ">=8"
|
| 5084 |
+
}
|
| 5085 |
+
},
|
| 5086 |
"node_modules/strip-json-comments": {
|
| 5087 |
"version": "3.1.1",
|
| 5088 |
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
frontend/package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
| 12 |
"dependencies": {
|
| 13 |
"@emotion/react": "^11.13.0",
|
| 14 |
"@emotion/styled": "^11.13.0",
|
|
|
|
| 15 |
"@mui/icons-material": "^6.1.0",
|
| 16 |
"@mui/material": "^6.1.0",
|
| 17 |
"react": "^18.3.1",
|
|
|
|
| 12 |
"dependencies": {
|
| 13 |
"@emotion/react": "^11.13.0",
|
| 14 |
"@emotion/styled": "^11.13.0",
|
| 15 |
+
"@huggingface/hub": "^2.8.1",
|
| 16 |
"@mui/icons-material": "^6.1.0",
|
| 17 |
"@mui/material": "^6.1.0",
|
| 18 |
"react": "^18.3.1",
|
frontend/src/hooks/useAuth.ts
CHANGED
|
@@ -1,31 +1,89 @@
|
|
| 1 |
/**
|
| 2 |
-
*
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
* Exports `triggerLogin()` for components that need to start the OAuth flow
|
| 8 |
-
* (e.g. the "Start Session" button on the welcome screen).
|
| 9 |
*/
|
| 10 |
|
| 11 |
import { useEffect } from 'react';
|
|
|
|
| 12 |
import { useAgentStore } from '@/store/agentStore';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
/** Redirect to
|
| 15 |
-
export function triggerLogin() {
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export function useAuth() {
|
| 20 |
const setUser = useAgentStore((s) => s.setUser);
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
try {
|
| 25 |
-
const
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
setUser({
|
| 30 |
authenticated: true,
|
| 31 |
username: data.username,
|
|
@@ -35,28 +93,16 @@ export function useAuth() {
|
|
| 35 |
return;
|
| 36 |
}
|
| 37 |
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
const statusData = await statusRes.json();
|
| 42 |
-
if (!statusData.auth_enabled) {
|
| 43 |
-
// Dev mode β set dev user so the app is usable
|
| 44 |
-
setUser({ authenticated: true, username: 'dev' });
|
| 45 |
-
return;
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
// Auth is enabled but user is not logged in.
|
| 49 |
-
// Don't redirect β let the welcome screen show first.
|
| 50 |
-
// The user will be prompted to log in when they click "Start Session".
|
| 51 |
-
setUser(null);
|
| 52 |
} catch {
|
| 53 |
-
// Backend
|
| 54 |
-
setUser({ authenticated: true, username: 'dev' });
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
| 58 |
-
|
|
|
|
| 59 |
}, [setUser]);
|
| 60 |
-
|
| 61 |
-
return { triggerLogin };
|
| 62 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Client-side OAuth using @huggingface/hub.
|
| 3 |
*
|
| 4 |
+
* Works inside HF Spaces iframes (no third-party cookies needed).
|
| 5 |
+
* Token is stored in localStorage and sent via Authorization header.
|
|
|
|
|
|
|
|
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
import { useEffect } from 'react';
|
| 9 |
+
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
+
import { logger } from '@/utils/logger';
|
| 12 |
+
|
| 13 |
+
const TOKEN_KEY = 'hf_oauth_token';
|
| 14 |
+
|
| 15 |
+
/** Get the stored HF access token (or null). */
|
| 16 |
+
export function getStoredToken(): string | null {
|
| 17 |
+
try {
|
| 18 |
+
return localStorage.getItem(TOKEN_KEY);
|
| 19 |
+
} catch {
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** Clear the stored token (logout). */
|
| 25 |
+
export function clearStoredToken(): void {
|
| 26 |
+
try {
|
| 27 |
+
localStorage.removeItem(TOKEN_KEY);
|
| 28 |
+
} catch {
|
| 29 |
+
// Ignore
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
|
| 33 |
+
/** Redirect to HF OAuth login. */
|
| 34 |
+
export async function triggerLogin(): Promise<void> {
|
| 35 |
+
const url = await oauthLoginUrl({
|
| 36 |
+
scopes: 'openid profile read-repos write-repos manage-repos inference-api jobs',
|
| 37 |
+
});
|
| 38 |
+
window.location.href = url;
|
| 39 |
}
|
| 40 |
|
| 41 |
+
/**
|
| 42 |
+
* Hook: on mount, check for OAuth redirect result or existing token.
|
| 43 |
+
* Sets the user in the agent store when authenticated.
|
| 44 |
+
*/
|
| 45 |
export function useAuth() {
|
| 46 |
const setUser = useAgentStore((s) => s.setUser);
|
| 47 |
|
| 48 |
useEffect(() => {
|
| 49 |
+
let cancelled = false;
|
| 50 |
+
|
| 51 |
+
async function init() {
|
| 52 |
+
// 1. Check if we're returning from an OAuth redirect
|
| 53 |
+
const oauthResult = await oauthHandleRedirectIfPresent();
|
| 54 |
+
|
| 55 |
+
if (oauthResult) {
|
| 56 |
+
// Store the access token
|
| 57 |
+
localStorage.setItem(TOKEN_KEY, oauthResult.accessToken);
|
| 58 |
+
logger.log('OAuth login successful:', oauthResult.userInfo?.name);
|
| 59 |
+
|
| 60 |
+
if (!cancelled) {
|
| 61 |
+
setUser({
|
| 62 |
+
authenticated: true,
|
| 63 |
+
username: oauthResult.userInfo?.name || oauthResult.userInfo?.preferred_username || 'user',
|
| 64 |
+
name: oauthResult.userInfo?.name,
|
| 65 |
+
picture: oauthResult.userInfo?.picture,
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// 2. Check for existing token in localStorage
|
| 72 |
+
const token = getStoredToken();
|
| 73 |
+
if (!token) {
|
| 74 |
+
// Not logged in β welcome screen will handle login trigger
|
| 75 |
+
if (!cancelled) setUser(null);
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 3. Validate the stored token
|
| 80 |
try {
|
| 81 |
+
const res = await fetch('/auth/me', {
|
| 82 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 83 |
+
});
|
| 84 |
+
if (res.ok) {
|
| 85 |
+
const data = await res.json();
|
| 86 |
+
if (!cancelled && data.authenticated) {
|
| 87 |
setUser({
|
| 88 |
authenticated: true,
|
| 89 |
username: data.username,
|
|
|
|
| 93 |
return;
|
| 94 |
}
|
| 95 |
}
|
| 96 |
+
// Token invalid β clear it
|
| 97 |
+
clearStoredToken();
|
| 98 |
+
if (!cancelled) setUser(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
} catch {
|
| 100 |
+
// Backend unreachable in dev β set dev user
|
| 101 |
+
if (!cancelled) setUser({ authenticated: true, username: 'dev' });
|
| 102 |
}
|
| 103 |
}
|
| 104 |
|
| 105 |
+
init();
|
| 106 |
+
return () => { cancelled = true; };
|
| 107 |
}, [setUser]);
|
|
|
|
|
|
|
| 108 |
}
|
frontend/src/utils/api.ts
CHANGED
|
@@ -1,58 +1,62 @@
|
|
| 1 |
/**
|
| 2 |
-
* Centralized API utilities
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
* In development (no OAuth):
|
| 9 |
-
* - Auth is bypassed on the backend, no token needed
|
| 10 |
*/
|
| 11 |
|
| 12 |
-
|
| 13 |
-
function getApiBase(): string {
|
| 14 |
-
// In development, Vite proxies /api and /auth to the backend
|
| 15 |
-
// In production, same origin
|
| 16 |
-
return '';
|
| 17 |
-
}
|
| 18 |
|
| 19 |
-
/** Wrapper around fetch that includes
|
| 20 |
export async function apiFetch(
|
| 21 |
path: string,
|
| 22 |
options: RequestInit = {}
|
| 23 |
): Promise<Response> {
|
| 24 |
-
const url = `${getApiBase()}${path}`;
|
| 25 |
-
|
| 26 |
const headers: Record<string, string> = {
|
| 27 |
'Content-Type': 'application/json',
|
| 28 |
...(options.headers as Record<string, string>),
|
| 29 |
};
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
...options,
|
| 33 |
headers,
|
| 34 |
-
credentials: 'include', //
|
| 35 |
});
|
| 36 |
|
| 37 |
-
// Handle 401
|
| 38 |
if (response.status === 401) {
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
return response;
|
| 50 |
}
|
| 51 |
|
| 52 |
-
/** Build the WebSocket URL for a session, including auth token
|
| 53 |
export function getWebSocketUrl(sessionId: string): string {
|
| 54 |
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Centralized API utilities.
|
| 3 |
*
|
| 4 |
+
* Reads the HF OAuth token from localStorage and injects it as
|
| 5 |
+
* an Authorization: Bearer header on every request.
|
| 6 |
+
* WebSocket URLs include the token as a query parameter.
|
|
|
|
|
|
|
|
|
|
| 7 |
*/
|
| 8 |
|
| 9 |
+
import { getStoredToken, triggerLogin } from '@/hooks/useAuth';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
/** Wrapper around fetch that includes auth and common headers. */
|
| 12 |
export async function apiFetch(
|
| 13 |
path: string,
|
| 14 |
options: RequestInit = {}
|
| 15 |
): Promise<Response> {
|
|
|
|
|
|
|
| 16 |
const headers: Record<string, string> = {
|
| 17 |
'Content-Type': 'application/json',
|
| 18 |
...(options.headers as Record<string, string>),
|
| 19 |
};
|
| 20 |
|
| 21 |
+
// Inject Bearer token if available
|
| 22 |
+
const token = getStoredToken();
|
| 23 |
+
if (token) {
|
| 24 |
+
headers['Authorization'] = `Bearer ${token}`;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const response = await fetch(path, {
|
| 28 |
...options,
|
| 29 |
headers,
|
| 30 |
+
credentials: 'include', // Still send cookies for backward compat
|
| 31 |
});
|
| 32 |
|
| 33 |
+
// Handle 401 β trigger login if auth is required
|
| 34 |
if (response.status === 401) {
|
| 35 |
+
try {
|
| 36 |
+
const authStatus = await fetch('/auth/status');
|
| 37 |
+
const data = await authStatus.json();
|
| 38 |
+
if (data.auth_enabled) {
|
| 39 |
+
await triggerLogin();
|
| 40 |
+
throw new Error('Authentication required β redirecting to login.');
|
| 41 |
+
}
|
| 42 |
+
} catch (e) {
|
| 43 |
+
if (e instanceof Error && e.message.includes('redirecting')) throw e;
|
| 44 |
+
// auth/status failed β ignore
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
return response;
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/** Build the WebSocket URL for a session, including auth token. */
|
| 52 |
export function getWebSocketUrl(sessionId: string): string {
|
| 53 |
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 54 |
+
const base = `${protocol}//${window.location.host}/api/ws/${sessionId}`;
|
| 55 |
+
|
| 56 |
+
// Pass token as query param (WebSocket can't set custom headers from browser)
|
| 57 |
+
const token = getStoredToken();
|
| 58 |
+
if (token) {
|
| 59 |
+
return `${base}?token=${encodeURIComponent(token)}`;
|
| 60 |
+
}
|
| 61 |
+
return base;
|
| 62 |
}
|