File size: 7,199 Bytes
ddd660b
 
 
 
cfea6f8
ddd660b
 
 
cfea6f8
ddd660b
 
 
cfea6f8
ddd660b
 
cfea6f8
ddd660b
 
cfea6f8
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
 
 
 
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
cfea6f8
ddd660b
 
 
 
 
 
 
 
 
cfea6f8
ddd660b
 
 
cfea6f8
ddd660b
cfea6f8
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
ddd660b
 
 
 
 
 
 
cfea6f8
 
ddd660b
 
 
 
 
 
 
 
 
cfea6f8
ddd660b
 
cfea6f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""Resolve and prepare per-user Hugging Face bucket workspaces."""

from __future__ import annotations

import hashlib
import json
import os
import re
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Mapping
from uuid import uuid4

from huggingface_hub import HfApi, get_token
from huggingface_hub.errors import BucketNotFoundError

from fast_agent import AgentAuth
from fast_agent.mcp.server.common import normalize_serve_oauth_provider


_SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._-]+")


@dataclass(frozen=True, slots=True)
class ResearchWorkspace:
    username: str
    session_id: str
    bucket_id: str
    root: str
    scratch: str
    output: str
    bucket_created: bool
    marker_paths: tuple[str, ...]
    bearer_token: str | None
    archive_space_id: str | None = None
    archive_space_url: str | None = None
    archive_app_url: str | None = None
    archive_status: str | None = None
    archive_template_version: str | None = None
    archive_installed_version: str | None = None
    archive_error: str | None = None


current_research_workspace: ContextVar[ResearchWorkspace | None] = ContextVar(
    "current_research_workspace",
    default=None,
)


def ensure_workspace(
    *,
    auth: AgentAuth | None,
    request_metadata: Mapping[str, Any],
    open_metadata: Mapping[str, object],
    create_bucket: bool = True,
    write_markers: bool = True,
    api: HfApi | None = None,
) -> ResearchWorkspace:
    """Resolve identity/session, ensure the bucket exists, and write markers."""
    token = _token(auth)
    whoami = _whoami(auth, token)
    username = _username(whoami)
    session_id = _session_id(request_metadata, open_metadata)
    bucket_id = f"{username}/research-agent"
    root = f"hf://buckets/{bucket_id}/{session_id}/"

    api = api or HfApi()
    bucket_created = False
    try:
        api.bucket_info(bucket_id, token=token)
    except BucketNotFoundError as exc:
        if not create_bucket:
            raise RuntimeError(
                f"Bucket {bucket_id!r} is not accessible: {exc}"
            ) from exc
        try:
            api.create_bucket(bucket_id, private=True, exist_ok=True, token=token)
            bucket_created = True
        except Exception as create_exc:
            raise RuntimeError(
                f"Could not create/access bucket {bucket_id!r}: {create_exc}"
            ) from create_exc

    marker_paths: tuple[str, ...] = ()
    if write_markers:
        marker = {
            "server": "research-agent",
            "username": username,
            "session_id": session_id,
            "bucket_id": bucket_id,
            "checked_at": datetime.now(UTC).isoformat(),
        }
        try:
            api.batch_bucket_files(
                bucket_id,
                add=[
                    (
                        json.dumps(marker, indent=2).encode("utf-8"),
                        f"{session_id}/scratch/.workspace.json",
                    ),
                    (b"", f"{session_id}/output/.keep"),
                ],
                token=token,
            )
        except Exception as exc:
            raise RuntimeError(
                f"Bucket {bucket_id!r} is accessible but marker write failed: {exc}"
            ) from exc
        marker_paths = (
            f"{root}scratch/.workspace.json",
            f"{root}output/.keep",
        )

    return ResearchWorkspace(
        username=username,
        session_id=session_id,
        bucket_id=bucket_id,
        root=root,
        scratch=f"{root}scratch/",
        output=f"{root}output/",
        bucket_created=bucket_created,
        marker_paths=marker_paths,
        bearer_token=token,
    )


def _token(auth: AgentAuth | None) -> str | None:
    if auth is not None and auth.token:
        return auth.token
    oauth_provider = normalize_serve_oauth_provider(os.getenv("FAST_AGENT_SERVE_OAUTH"))
    if oauth_provider == "huggingface":
        raise RuntimeError(
            "Hugging Face OAuth is enabled, but this request has no caller token."
        )
    env_token = os.getenv("HF_TOKEN")
    if env_token:
        return env_token
    return get_token()


def _whoami(auth: AgentAuth | None, token: str | bool | None) -> Mapping[str, Any]:
    """Return the authoritative Hugging Face whoami payload for this caller."""
    claims = dict(auth.claims) if auth is not None else {}
    whoami = claims.get("huggingface_whoami")
    if isinstance(whoami, dict) and whoami:
        return whoami

    try:
        return HfApi().whoami(token=token)
    except Exception as exc:
        raise RuntimeError(
            "Could not determine the Hugging Face user. Provide a bearer token, "
            "enable Hugging Face OAuth, set HF_TOKEN, or run `hf auth login`."
        ) from exc


def _username(whoami: Mapping[str, Any]) -> str:
    username = safe_segment(whoami.get("name"))
    if username:
        return username
    raise RuntimeError(
        f"Hugging Face whoami response did not include a usable name: {dict(whoami)!r}."
    )


def _session_id(
    request_metadata: Mapping[str, Any],
    open_metadata: Mapping[str, object],
) -> str:
    candidates = [
        request_metadata.get("research_workspace_id"),
        open_metadata.get("research_workspace_id"),
        request_metadata.get("request_session_id"),
        request_metadata.get("harness_session_id"),
        request_metadata.get("requested_session_id"),
        request_metadata.get("mcp_session_id"),
        open_metadata.get("harness_session_id"),
        open_metadata.get("requested_session_id"),
        open_metadata.get("mcp_session_id"),
    ]
    for candidate in candidates:
        value = _safe_session_segment(candidate)
        if value:
            return value
    # No usable session identity was supplied. Never fall back to a shared
    # constant ("default") — concurrent runs would collide on one bucket path
    # and leak one run's report into another's UI. Mint a unique id instead.
    return f"session-{uuid4().hex}"


def _safe_session_segment(value: object) -> str | None:
    """Sanitize a session id, keeping distinct inputs on distinct segments.

    ``safe_segment`` truncates to 96 chars and maps disallowed characters to
    ``-``, so two different client-supplied ids can collapse to the same
    segment. When sanitization loses information, append a short stable hash of
    the original so the mapping stays collision-resistant (and deterministic, so
    the same input still resolves to the same workspace across requests).
    """
    if value is None:
        return None
    raw = str(value).strip().strip("/")
    if not raw:
        return None
    safe = safe_segment(raw)
    if safe is None:
        return None
    if safe != raw:
        digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
        safe = f"{safe[:87].rstrip('.-_')}-{digest}"
    return safe


def safe_segment(value: object) -> str | None:
    if value is None:
        return None
    text = str(value).strip().strip("/")
    if not text:
        return None
    return _SAFE_SEGMENT.sub("-", text)[:96].strip(".-_") or None