AdithyaSK HF Staff commited on
Commit
d2be16b
·
verified ·
1 Parent(s): c828b92

Security: credential-free sandboxes via a per-rollout model proxy, CSP, local-mode hardening

Browse files
CONTRIBUTING.md CHANGED
@@ -17,8 +17,9 @@ uv run uvicorn app.main:app --port 8000 # http://localhost:8000
17
  ```
18
 
19
  Locally there is no OAuth app: your own Hugging Face token (`HF_TOKEN` or `hf auth login`) is the signed-in user,
20
- rollouts run on your account, and traces go to `./.local-runs`. `docker build -t mimo . && docker run -p 7860:7860 mimo`
21
- runs the same image the Space runs.
 
22
 
23
  ## How a rollout works
24
 
@@ -103,6 +104,8 @@ which opens each task's image as a sandbox; nothing in them comes from the hidde
103
  If a grader needs a fix, fix it around the vendored file, not inside it.
104
  * **"Not scored" is never 0.** If the testbed, the judge or the render fails, the reward is `None` and the page says
105
  why. Averaging infrastructure failures in as zeros misreads results; Xiaomi's harness masks them the same way.
 
 
106
  * **Nothing secret reaches a trace.** Tokens and endpoint keys are held in memory for the rollout only, and every
107
  event passes through the redaction in `Rollout._redact`. Never log a request header or an environment dump.
108
  * **Public means anonymous.** The public view of a rollout (`_public_view` in `app/main.py`) has no user, no sandbox
 
17
  ```
18
 
19
  Locally there is no OAuth app: your own Hugging Face token (`HF_TOKEN` or `hf auth login`) is the signed-in user,
20
+ rollouts run on your account, and traces go to `./.local-runs`. The same image the Space runs:
21
+ `docker build -t mimo . && docker run -p 127.0.0.1:7860:7860 -e HF_TOKEN -e MIMO_TRUST_NETWORK=1 mimo`.
22
+ Read [SECURITY.md](SECURITY.md) before exposing it beyond your own machine.
23
 
24
  ## How a rollout works
25
 
 
104
  If a grader needs a fix, fix it around the vendored file, not inside it.
105
  * **"Not scored" is never 0.** If the testbed, the judge or the render fails, the reward is `None` and the page says
106
  why. Averaging infrastructure failures in as zeros misreads results; Xiaomi's harness masks them the same way.
107
+ * **Sandboxes hold no credential on the Space.** Model and judge calls go through `/api/llm/<capability>`; don't
108
+ add code that puts a token or key into a sandbox when `config.PUBLIC_URL` is set.
109
  * **Nothing secret reaches a trace.** Tokens and endpoint keys are held in memory for the rollout only, and every
110
  event passes through the redaction in `Rollout._redact`. Never log a request header or an environment dump.
111
  * **Public means anonymous.** The public view of a rollout (`_public_view` in `app/main.py`) has no user, no sandbox
README.md CHANGED
@@ -48,7 +48,7 @@ and Jobs permissions). A rollout runs on **your** account:
48
 
49
  Nothing that grades a task (hidden tests, rubric answers, the expected crash) is in the sandbox while the agent works,
50
  and the agent has no web tools. Rollouts keep running if you close the page; find them under **Rollouts**.
51
- Traces are kept in a private bucket. Your token is never stored with them: it lives only in an encrypted cookie in your browser.
52
 
53
  ## Rollouts and the community
54
 
 
48
 
49
  Nothing that grades a task (hidden tests, rubric answers, the expected crash) is in the sandbox while the agent works,
50
  and the agent has no web tools. Rollouts keep running if you close the page; find them under **Rollouts**.
51
+ Traces are kept in a private bucket. Your token is never stored with them: it lives only in an encrypted cookie in your browser, and the sandbox never sees it (model calls go through a per-rollout proxy). See [SECURITY.md](SECURITY.md).
52
 
53
  ## Rollouts and the community
54
 
SECURITY.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security
2
+
3
+ How the explorer protects accounts, credentials and data, on the public Space and when you run it yourself.
4
+ Report a vulnerability privately to the FineEnvs team through Hugging Face rather than in a public discussion.
5
+
6
+ ## Credentials
7
+
8
+ * **Sessions.** Signing in (OAuth, or a pasted token) produces one cookie, encrypted with Fernet under
9
+ `SESSION_SECRET`, HttpOnly, Secure, valid 7 days. The token inside cannot be read back out of it.
10
+ * **On the Space, sandboxes hold no credential.** A rollout's sandbox reaches its model, and the General verifier
11
+ its judge, through `/api/llm/<capability>/v1`. The capability is random per rollout, works only while that
12
+ rollout is live, and only for its own agent model and judge; this server adds the user's HF token or endpoint key
13
+ on the way out. Whatever the agent runs, it cannot read your token.
14
+ * **Locally, the token is forwarded into the sandbox.** A remote HF Sandbox can't reach your machine, so it calls
15
+ the model directly with your token, and processes in the sandbox can read it. Use a token you can revoke, or set
16
+ `MIMO_PUBLIC_URL` to a public tunnel to this server so the proxy is used instead.
17
+ * **Nothing secret reaches a trace.** Every event is redacted (the exact token, endpoint key and capability, and
18
+ anything shaped like `hf_…`, `sk-…` or `Bearer …`) before it is stored. The General verifier prints the first
19
+ characters of its judge key; that is caught too.
20
+
21
+ ## Your own endpoint
22
+
23
+ This server calls a bring-your-own endpoint (to test it, list its models, proxy the agent, and for Music), so:
24
+ the URL must be `https`, every address its name resolves to must be public, and the connection goes to the address
25
+ that was checked (TLS still verified against the name), which closes DNS rebinding. Replies to probes are capped
26
+ at 4 MB, probes at 30 per account per 10 minutes, and redirects are not followed.
27
+
28
+ ## Requests
29
+
30
+ * **Cross-site requests.** The cookie is `SameSite=None` so the Space works inside the huggingface.co iframe, so
31
+ every state-changing request must come from this page (checked by `Origin`, or `Sec-Fetch-Site`).
32
+ * **Headers.** A strict Content-Security-Policy on every page (scripts only from this origin and jsDelivr, no
33
+ inline scripts, framing only by huggingface.co and hf.space), `nosniff`, a referrer policy and a permissions
34
+ policy. Task files and rollout artifacts are served with `Content-Security-Policy: sandbox`, so a task's HTML
35
+ can never run with this origin.
36
+ * **Input.** Model ids, URLs and keys are length- and character-limited; task files are confined to their
37
+ workspace; database tables are looked up, never interpolated; run ids and paths are validated.
38
+
39
+ ## Rollouts
40
+
41
+ * **Private rollouts** are visible only to their owner. Rollouts from before visibility existed stay private.
42
+ * **Public rollouts** are shown without who ran them: the public view has no user, no sandbox id, no endpoint URL,
43
+ and the owner's username and endpoint host are scrubbed from every string in the trace.
44
+ * Only the owner can stop a rollout or change its visibility. My rollouts lists only your own.
45
+ * Per account at most 4 rollouts run at once, and 24 across the Space.
46
+
47
+ ## Running it yourself
48
+
49
+ Without an OAuth app the server is in local mode: your machine's HF token (`HF_TOKEN` or `hf auth login`) is the
50
+ signed-in user. To keep that safe:
51
+
52
+ * It answers only to `localhost`, `127.0.0.1` and `[::1]` (a web page can't DNS-rebind its name onto your
53
+ machine to use your token). Set `MIMO_ALLOWED_HOSTS` to serve under another name.
54
+ * The machine's token signs in only requests from this machine. Anyone else on the network has to sign in with
55
+ their own token. Requests that arrive through a proxy are not treated as local.
56
+ * In Docker, publish to localhost only and say so explicitly:
57
+ `docker run -p 127.0.0.1:7860:7860 -e HF_TOKEN -e MIMO_TRUST_NETWORK=1 mimo`. Without `MIMO_TRUST_NETWORK=1`,
58
+ requests from Docker's network must sign in like anyone else.
59
+
60
+ ## Hosting your own Space
61
+
62
+ Set `SESSION_SECRET` as a Space secret (sessions otherwise use the OAuth client secret), keep the runs bucket
63
+ private, and leave `hf_oauth_scopes` at `inference-api` and `jobs`.
app/auth.py CHANGED
@@ -13,6 +13,7 @@ from __future__ import annotations
13
  import base64
14
  import hashlib
15
  import json
 
16
  import secrets
17
  import time
18
  from urllib.parse import urlencode
@@ -21,7 +22,7 @@ import httpx
21
  from cryptography.fernet import Fernet, InvalidToken
22
  from fastapi import APIRouter, HTTPException, Request
23
  from fastapi.responses import JSONResponse, RedirectResponse
24
- from pydantic import BaseModel
25
 
26
  from . import config
27
 
@@ -66,8 +67,28 @@ def _cookie_user(request: Request) -> dict | None:
66
  return data if data.get("exp", 0) > time.time() else None
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def current_user(request: Request) -> dict | None:
70
- return _cookie_user(request) or (_local_user() if config.LOCAL_MODE else None)
 
 
 
 
 
71
 
72
 
73
  def require_user(request: Request) -> dict:
@@ -99,6 +120,8 @@ def login(request: Request):
99
  for s, t in list(_states.items()):
100
  if now - t > 600:
101
  _states.pop(s, None)
 
 
102
  state = secrets.token_urlsafe(24)
103
  _states[state] = now
104
  q = urlencode({"client_id": config.OAUTH_CLIENT_ID, "redirect_uri": _redirect_uri(request), "response_type": "code",
@@ -130,7 +153,7 @@ def callback(request: Request, code: str = "", state: str = ""):
130
 
131
  # ── access token ─────────────────────────────────────────────────────────────
132
  class TokenLogin(BaseModel):
133
- token: str
134
 
135
 
136
  def _token_gaps(access: dict) -> list[str]:
@@ -168,13 +191,6 @@ def token_login(body: TokenLogin):
168
  "exp": time.time() + config.SESSION_DAYS * 86400, "missing_scopes": gaps})
169
 
170
 
171
- @router.get("/logout")
172
- def logout():
173
- resp = RedirectResponse("/")
174
- resp.delete_cookie(COOKIE, samesite="none", secure=True, path="/")
175
- return resp
176
-
177
-
178
  @router.post("/api/logout")
179
  def logout_api():
180
  resp = JSONResponse({"ok": True})
 
13
  import base64
14
  import hashlib
15
  import json
16
+ import os
17
  import secrets
18
  import time
19
  from urllib.parse import urlencode
 
22
  from cryptography.fernet import Fernet, InvalidToken
23
  from fastapi import APIRouter, HTTPException, Request
24
  from fastapi.responses import JSONResponse, RedirectResponse
25
+ from pydantic import BaseModel, Field
26
 
27
  from . import config
28
 
 
67
  return data if data.get("exp", 0) > time.time() else None
68
 
69
 
70
+ TRUST_NETWORK = os.environ.get("MIMO_TRUST_NETWORK") == "1"
71
+
72
+
73
+ def _is_loopback(request: Request) -> bool:
74
+ import ipaddress
75
+ # a browser on this machine talks to us directly; forwarding headers mean a proxy is in between (or someone
76
+ # spoofing one, which uvicorn's --proxy-headers would otherwise believe), so the peer isn't provably local
77
+ if any(h in request.headers for h in ("x-forwarded-for", "x-real-ip", "forwarded")):
78
+ return False
79
+ try:
80
+ return ipaddress.ip_address((request.client.host if request.client else "") or "").is_loopback
81
+ except ValueError:
82
+ return False
83
+
84
+
85
  def current_user(request: Request) -> dict | None:
86
+ """Locally, the machine's own token signs in only requests from this machine (a browser on it, or
87
+ `docker run -p 127.0.0.1:...` with MIMO_TRUST_NETWORK=1): anyone else on the network has to sign in."""
88
+ u = _cookie_user(request)
89
+ if u or not config.LOCAL_MODE:
90
+ return u
91
+ return _local_user() if (_is_loopback(request) or TRUST_NETWORK) else None
92
 
93
 
94
  def require_user(request: Request) -> dict:
 
120
  for s, t in list(_states.items()):
121
  if now - t > 600:
122
  _states.pop(s, None)
123
+ if len(_states) > 5000: # an unauthenticated route: don't let it grow without bound
124
+ raise HTTPException(429, "Too many sign-ins in progress. Try again in a few minutes.")
125
  state = secrets.token_urlsafe(24)
126
  _states[state] = now
127
  q = urlencode({"client_id": config.OAUTH_CLIENT_ID, "redirect_uri": _redirect_uri(request), "response_type": "code",
 
153
 
154
  # ── access token ─────────────────────────────────────────────────────────────
155
  class TokenLogin(BaseModel):
156
+ token: str = Field(max_length=300)
157
 
158
 
159
  def _token_gaps(access: dict) -> list[str]:
 
191
  "exp": time.time() + config.SESSION_DAYS * 86400, "missing_scopes": gaps})
192
 
193
 
 
 
 
 
 
 
 
194
  @router.post("/api/logout")
195
  def logout_api():
196
  resp = JSONResponse({"ok": True})
app/config.py CHANGED
@@ -23,6 +23,10 @@ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
23
  OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
24
  OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
25
  SPACE_HOST = os.environ.get("SPACE_HOST", "")
 
 
 
 
26
  LOCAL_MODE = not OAUTH_CLIENT_ID
27
  # `jobs` runs the sandbox on the user's account, `inference-api` calls Inference Providers as them.
28
  OAUTH_SCOPES = ["openid", "profile", "inference-api", "jobs"]
 
23
  OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
24
  OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
25
  SPACE_HOST = os.environ.get("SPACE_HOST", "")
26
+ # Where a sandbox can reach this server. When set (always on a Space), sandboxes never hold the user's HF token or
27
+ # endpoint key: model and judge calls go through /api/llm/<per-rollout capability>. Locally a remote sandbox can't
28
+ # reach your machine, so the token is forwarded into it instead (set MIMO_PUBLIC_URL to a tunnel to avoid that).
29
+ PUBLIC_URL = (os.environ.get("MIMO_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
30
  LOCAL_MODE = not OAUTH_CLIENT_ID
31
  # `jobs` runs the sandbox on the user's account, `inference-api` calls Inference Providers as them.
32
  OAUTH_SCOPES = ["openid", "profile", "inference-api", "jobs"]
app/endpoints.py CHANGED
@@ -1,8 +1,9 @@
1
  """Bring your own model: any OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, OpenAI, Together, OpenRouter...).
2
 
3
  The sandbox still runs on the user's Hugging Face account, so they sign in with HF either way; only the agent's
4
- model calls go to their endpoint. The API key is held in memory for the rollout and handed to the sandbox
5
- through a root-only file. It is never written to a trace, to the run record or to disk on this server.
 
6
 
7
  This server calls the endpoint too (to test it, list its models, and for Music, which has no sandbox), so the
8
  URL must be https and resolve to public addresses only: otherwise anyone could make the Space probe its own
@@ -25,24 +26,61 @@ class EndpointError(ValueError):
25
  pass
26
 
27
 
28
- def check_url(base_url: str) -> str:
29
- u = urlparse((base_url or "").strip())
30
- if u.scheme != "https" or not u.hostname:
31
- raise EndpointError("Use an https URL, like https://api.example.com/v1. The sandbox reaches it over the internet.")
32
- if u.username or u.password:
33
- raise EndpointError("Put the key in the API key field, not in the URL.")
34
  try:
35
- infos = socket.getaddrinfo(u.hostname, u.port or 443, proto=socket.IPPROTO_TCP)
36
  except socket.gaierror:
37
- raise EndpointError(f"Couldn't resolve {u.hostname}.")
 
38
  for *_, addr in infos:
39
  ip = ipaddress.ip_address(addr[0])
40
  if not ip.is_global:
41
- raise EndpointError(f"{u.hostname} resolves to a private address. The endpoint has to be reachable from "
42
  "the internet, because the agent calls it from an HF Sandbox.")
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return u.geturl().rstrip("/")
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def _headers(key: str | None) -> dict:
47
  return {"Authorization": f"Bearer {key}"} if key else {}
48
 
@@ -60,7 +98,7 @@ def _explain(r: httpx.Response) -> str:
60
 
61
  def list_models(base_url: str, key: str | None) -> list[str]:
62
  base = check_url(base_url)
63
- r = httpx.get(f"{base}/models", headers=_headers(key), timeout=TIMEOUT)
64
  if r.status_code != 200:
65
  raise EndpointError(f"Couldn't list models: {_explain(r)}")
66
  data = r.json()
@@ -78,13 +116,13 @@ def test(base_url: str, key: str | None, model: str) -> dict:
78
  if not (model or "").strip():
79
  raise EndpointError("Enter the model name your endpoint expects.")
80
  t = time.time()
81
- r = httpx.post(f"{base}/chat/completions", headers=_headers(key), timeout=60, json={
82
  "model": model.strip(), "max_tokens": 400, "tools": [PROBE_TOOL], "tool_choice": "auto",
83
  "messages": [{"role": "user", "content": "Call record_answer with the answer to 2+2."}]})
84
  ms = round((time.time() - t) * 1000)
85
  if r.status_code != 200:
86
  # some servers reject the tools field outright: tell chat-only apart from broken
87
- plain = httpx.post(f"{base}/chat/completions", headers=_headers(key), timeout=60, json={
88
  "model": model.strip(), "max_tokens": 50, "messages": [{"role": "user", "content": "Say OK."}]})
89
  if plain.status_code == 200:
90
  return {"ok": True, "tools": False, "ms": ms, "detail": f"Chat works, but tool calls failed: {_explain(r)}"}
 
1
  """Bring your own model: any OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, OpenAI, Together, OpenRouter...).
2
 
3
  The sandbox still runs on the user's Hugging Face account, so they sign in with HF either way; only the agent's
4
+ model calls go to their endpoint. The API key is held in memory for the rollout. On the Space it never enters the
5
+ sandbox (calls go through this server's per-rollout proxy); locally it is handed over through a root-only file.
6
+ It is never written to a trace, to the run record or to disk on this server.
7
 
8
  This server calls the endpoint too (to test it, list its models, and for Music, which has no sandbox), so the
9
  URL must be https and resolve to public addresses only: otherwise anyone could make the Space probe its own
 
26
  pass
27
 
28
 
29
+ def _resolve(host: str, port: int) -> list[str]:
 
 
 
 
 
30
  try:
31
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
32
  except socket.gaierror:
33
+ raise EndpointError(f"Couldn't resolve {host}.")
34
+ ips = []
35
  for *_, addr in infos:
36
  ip = ipaddress.ip_address(addr[0])
37
  if not ip.is_global:
38
+ raise EndpointError(f"{host} resolves to a private address. The endpoint has to be reachable from "
39
  "the internet, because the agent calls it from an HF Sandbox.")
40
+ ips.append(addr[0])
41
+ return ips
42
+
43
+
44
+ def check_url(base_url: str) -> str:
45
+ u = urlparse((base_url or "").strip())
46
+ if u.scheme != "https" or not u.hostname:
47
+ raise EndpointError("Use an https URL, like https://api.example.com/v1. The sandbox reaches it over the internet.")
48
+ if u.username or u.password:
49
+ raise EndpointError("Put the key in the API key field, not in the URL.")
50
+ if len(base_url) > 500:
51
+ raise EndpointError("That URL is too long.")
52
+ _resolve(u.hostname, u.port or 443)
53
  return u.geturl().rstrip("/")
54
 
55
 
56
+ def pinned(url: str) -> tuple[str, dict, dict]:
57
+ """(url with the host replaced by an IP we checked, Host header, TLS extensions).
58
+
59
+ Checking a hostname and then letting the HTTP client resolve it again leaves a gap: a DNS answer that
60
+ changes in between (DNS rebinding) could point the second lookup at this server's own network. Connecting
61
+ to the address that was checked closes it; TLS is still verified against the real hostname (SNI)."""
62
+ u = urlparse(url)
63
+ ip = _resolve(u.hostname, u.port or 443)[0]
64
+ host_ip = f"[{ip}]" if ":" in ip else ip
65
+ netloc = host_ip + (f":{u.port}" if u.port else "")
66
+ return u._replace(netloc=netloc).geturl(), {"Host": u.netloc}, {"sni_hostname": u.hostname}
67
+
68
+
69
+ MAX_BODY = 4_000_000 # a probe's reply is small; don't let an endpoint stream gigabytes into this server
70
+
71
+
72
+ def request(method: str, url: str, key: str | None, **kw) -> httpx.Response:
73
+ target, host, ext = pinned(url)
74
+ with httpx.Client(timeout=kw.pop("timeout", TIMEOUT), follow_redirects=False) as c:
75
+ with c.stream(method, target, headers={**_headers(key), **host}, extensions=ext, **kw) as r:
76
+ body = b""
77
+ for chunk in r.iter_bytes():
78
+ body += chunk
79
+ if len(body) > MAX_BODY:
80
+ raise EndpointError("The endpoint's reply was too large.")
81
+ return httpx.Response(r.status_code, headers=r.headers, content=body, request=r.request)
82
+
83
+
84
  def _headers(key: str | None) -> dict:
85
  return {"Authorization": f"Bearer {key}"} if key else {}
86
 
 
98
 
99
  def list_models(base_url: str, key: str | None) -> list[str]:
100
  base = check_url(base_url)
101
+ r = request("GET", f"{base}/models", key)
102
  if r.status_code != 200:
103
  raise EndpointError(f"Couldn't list models: {_explain(r)}")
104
  data = r.json()
 
116
  if not (model or "").strip():
117
  raise EndpointError("Enter the model name your endpoint expects.")
118
  t = time.time()
119
+ r = request("POST", f"{base}/chat/completions", key, timeout=60, json={
120
  "model": model.strip(), "max_tokens": 400, "tools": [PROBE_TOOL], "tool_choice": "auto",
121
  "messages": [{"role": "user", "content": "Call record_answer with the answer to 2+2."}]})
122
  ms = round((time.time() - t) * 1000)
123
  if r.status_code != 200:
124
  # some servers reject the tools field outright: tell chat-only apart from broken
125
+ plain = request("POST", f"{base}/chat/completions", key, timeout=60, json={
126
  "model": model.strip(), "max_tokens": 50, "messages": [{"role": "user", "content": "Say OK."}]})
127
  if plain.status_code == 200:
128
  return {"ok": True, "tools": False, "ms": ms, "detail": f"Chat works, but tool calls failed: {_explain(r)}"}
app/main.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
 
8
  import json
9
  import mimetypes
 
10
  import random
11
  from functools import lru_cache
12
 
@@ -34,6 +35,30 @@ async def _revalidate(request: Request, call_next):
34
  return resp
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  @app.middleware("http")
38
  async def _same_origin_posts(request: Request, call_next):
39
  """The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
@@ -161,10 +186,13 @@ def list_models():
161
 
162
 
163
  # ── rollouts ─────────────────────────────────────────────────────────────────
 
 
 
164
  class Endpoint(BaseModel):
165
- base_url: str
166
- model: str
167
- api_key: str | None = None
168
  price_in: float | None = Field(None, ge=0, le=1000) # $ per 1M tokens, only for the cost shown
169
  price_out: float | None = Field(None, ge=0, le=1000)
170
 
@@ -178,24 +206,37 @@ class Params(BaseModel):
178
 
179
 
180
  class RunRequest(BaseModel):
181
- task_id: str
182
- model: str | None = None
183
- provider: str | None = None
184
- judge: str | None = None
185
  endpoint: Endpoint | None = None
186
  params: Params = Params()
187
  visibility: Literal["public", "private"] = "public"
188
 
189
 
190
  class EndpointProbe(BaseModel):
191
- base_url: str
192
- api_key: str | None = None
193
- model: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
 
196
  @app.post("/api/endpoints/models")
197
  def endpoint_models(body: EndpointProbe, request: Request):
198
- auth.require_user(request) # this server makes the call, so only for signed-in users
199
  try:
200
  return {"models": endpoints.list_models(body.base_url, body.api_key)}
201
  except endpoints.EndpointError as e:
@@ -206,7 +247,7 @@ def endpoint_models(body: EndpointProbe, request: Request):
206
 
207
  @app.post("/api/endpoints/test")
208
  def endpoint_test(body: EndpointProbe, request: Request):
209
- auth.require_user(request)
210
  try:
211
  return endpoints.test(body.base_url, body.api_key, body.model or "")
212
  except endpoints.EndpointError as e:
@@ -273,16 +314,26 @@ def _is_public(run: dict) -> bool:
273
  return run.get("visibility") == "public"
274
 
275
 
276
- def _scrub(value, name: str):
 
 
277
  if isinstance(value, str):
278
- return value.replace(name, "[user]") if name and len(name) > 2 else value
 
 
279
  if isinstance(value, dict):
280
- return {k: _scrub(v, name) for k, v in value.items()}
281
  if isinstance(value, list):
282
- return [_scrub(v, name) for v in value]
283
  return value
284
 
285
 
 
 
 
 
 
 
286
  def _public_view(run: dict) -> dict:
287
  out = {k: run.get(k) for k in PUBLIC_FIELDS if k in run}
288
  ep = run.get("endpoint")
@@ -291,7 +342,7 @@ def _public_view(run: dict) -> dict:
291
  if run.get("error"):
292
  out["error"] = run["error"]
293
  out["visibility"] = "public"
294
- return _scrub(out, run.get("user") or "")
295
 
296
 
297
  def _owner_view(run: dict) -> dict:
@@ -329,7 +380,7 @@ def get_run(run_id: str, request: Request, after: int = 0):
329
  events = core.events(run_id, after)
330
  if owner:
331
  return {"run": _owner_view(run), "events": events, "live": live is not None}
332
- return {"run": _public_view(run), "events": _scrub(events, run.get("user") or ""), "live": live is not None}
333
 
334
 
335
  class Visibility(BaseModel):
@@ -447,7 +498,56 @@ def artifact(run_id: str, name: str, request: Request):
447
  data = store.read_artifact(run_id, name)
448
  if data is None:
449
  raise HTTPException(404, "no such artifact")
450
- return Response(data, media_type=mimetypes.guess_type(name)[0] or "application/octet-stream")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
 
453
  # ── the site ────────────────────────────────────────────────────────���────────
 
7
 
8
  import json
9
  import mimetypes
10
+ import os
11
  import random
12
  from functools import lru_cache
13
 
 
35
  return resp
36
 
37
 
38
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"} | {h.strip() for h in os.environ.get("MIMO_ALLOWED_HOSTS", "").split(",") if h.strip()}
39
+ CSP = ("default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
40
+ "font-src https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self'; frame-src 'self'; object-src 'none'; "
41
+ "base-uri 'none'; form-action 'self'; frame-ancestors 'self' https://huggingface.co https://*.hf.space")
42
+
43
+
44
+ @app.middleware("http")
45
+ async def _security(request: Request, call_next):
46
+ """Locally, only answer to localhost: a web page could otherwise DNS-rebind its own name to 127.0.0.1 and drive
47
+ this server (and the machine's HF token) from the browser. Everywhere: the usual security headers."""
48
+ if config.LOCAL_MODE:
49
+ host = (request.headers.get("host") or "").rsplit(":", 1)[0] if not (request.headers.get("host") or "").startswith("[") \
50
+ else (request.headers.get("host") or "").split("]")[0] + "]"
51
+ if host not in LOCAL_HOSTS:
52
+ return JSONResponse({"detail": "host not allowed (set MIMO_ALLOWED_HOSTS to serve under another name)"}, 403)
53
+ resp = await call_next(request)
54
+ resp.headers.setdefault("X-Content-Type-Options", "nosniff")
55
+ resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
56
+ resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
57
+ if not request.url.path.startswith("/api/"):
58
+ resp.headers.setdefault("Content-Security-Policy", CSP)
59
+ return resp
60
+
61
+
62
  @app.middleware("http")
63
  async def _same_origin_posts(request: Request, call_next):
64
  """The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
 
186
 
187
 
188
  # ── rollouts ─────────────────────────────────────────────────────────────────
189
+ SAFE_NAME = r"^[A-Za-z0-9._:/@+\-]{1,200}$" # model ids: letters, digits and the punctuation real ids use
190
+
191
+
192
  class Endpoint(BaseModel):
193
+ base_url: str = Field(max_length=500)
194
+ model: str = Field(pattern=SAFE_NAME)
195
+ api_key: str | None = Field(None, max_length=500)
196
  price_in: float | None = Field(None, ge=0, le=1000) # $ per 1M tokens, only for the cost shown
197
  price_out: float | None = Field(None, ge=0, le=1000)
198
 
 
206
 
207
 
208
  class RunRequest(BaseModel):
209
+ task_id: str = Field(max_length=200)
210
+ model: str | None = Field(None, pattern=SAFE_NAME)
211
+ provider: str | None = Field(None, pattern=SAFE_NAME)
212
+ judge: str | None = Field(None, pattern=SAFE_NAME)
213
  endpoint: Endpoint | None = None
214
  params: Params = Params()
215
  visibility: Literal["public", "private"] = "public"
216
 
217
 
218
  class EndpointProbe(BaseModel):
219
+ base_url: str = Field(max_length=500)
220
+ api_key: str | None = Field(None, max_length=500)
221
+ model: str | None = Field(None, pattern=SAFE_NAME)
222
+
223
+
224
+ _probes: dict[str, list[float]] = {}
225
+
226
+
227
+ def _probe_limit(user: str) -> None:
228
+ """This server makes the call, so cap how often one account can have it do so."""
229
+ import time as _t
230
+ now = _t.time()
231
+ hits = [t for t in _probes.get(user, []) if now - t < 600]
232
+ if len(hits) >= 30:
233
+ raise HTTPException(429, "Too many endpoint checks. Wait a few minutes.")
234
+ _probes[user] = hits + [now]
235
 
236
 
237
  @app.post("/api/endpoints/models")
238
  def endpoint_models(body: EndpointProbe, request: Request):
239
+ _probe_limit(auth.require_user(request)["name"]) # this server makes the call, so only for signed-in users
240
  try:
241
  return {"models": endpoints.list_models(body.base_url, body.api_key)}
242
  except endpoints.EndpointError as e:
 
247
 
248
  @app.post("/api/endpoints/test")
249
  def endpoint_test(body: EndpointProbe, request: Request):
250
+ _probe_limit(auth.require_user(request)["name"])
251
  try:
252
  return endpoints.test(body.base_url, body.api_key, body.model or "")
253
  except endpoints.EndpointError as e:
 
314
  return run.get("visibility") == "public"
315
 
316
 
317
+ def _scrub(value, secrets_: list[str] | str):
318
+ if isinstance(secrets_, str):
319
+ secrets_ = [secrets_] if secrets_ and len(secrets_) > 2 else []
320
  if isinstance(value, str):
321
+ for x in secrets_:
322
+ value = value.replace(x, "[redacted]" if "." in x or "/" in x else "[user]")
323
+ return value
324
  if isinstance(value, dict):
325
+ return {k: _scrub(v, secrets_) for k, v in value.items()}
326
  if isinstance(value, list):
327
+ return [_scrub(v, secrets_) for v in value]
328
  return value
329
 
330
 
331
+ def _private_strings(run: dict) -> list[str]:
332
+ """What must never show publicly: the owner's name, and where their own endpoint lives."""
333
+ ep = run.get("endpoint") or {}
334
+ return [x for x in (run.get("user"), ep.get("base_url"), ep.get("host")) if x and len(x) > 2]
335
+
336
+
337
  def _public_view(run: dict) -> dict:
338
  out = {k: run.get(k) for k in PUBLIC_FIELDS if k in run}
339
  ep = run.get("endpoint")
 
342
  if run.get("error"):
343
  out["error"] = run["error"]
344
  out["visibility"] = "public"
345
+ return _scrub(out, _private_strings(run))
346
 
347
 
348
  def _owner_view(run: dict) -> dict:
 
380
  events = core.events(run_id, after)
381
  if owner:
382
  return {"run": _owner_view(run), "events": events, "live": live is not None}
383
+ return {"run": _public_view(run), "events": _scrub(events, _private_strings(run)), "live": live is not None}
384
 
385
 
386
  class Visibility(BaseModel):
 
498
  data = store.read_artifact(run_id, name)
499
  if data is None:
500
  raise HTTPException(404, "no such artifact")
501
+ return Response(data, media_type=mimetypes.guess_type(name)[0] or "application/octet-stream",
502
+ headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff"})
503
+
504
+
505
+ # ── the model proxy ──────────────────────────────────────────────────────────
506
+ # On the Space, a sandbox holds no credential: OpenCode (and the General verifier's judge) call this route with a
507
+ # per-rollout capability in the path. It works only while that rollout is live, only for the rollout's own agent
508
+ # model and judge, and this server adds the user's token or endpoint key on the way out.
509
+ @app.post("/api/llm/{cap}/v1/chat/completions")
510
+ async def llm_proxy(cap: str, request: Request):
511
+ import asyncio
512
+
513
+ from starlette.background import BackgroundTask
514
+ from starlette.responses import StreamingResponse
515
+
516
+ r = core.by_cap(cap)
517
+ if r is None:
518
+ return JSONResponse({"error": {"message": "unknown or finished rollout"}}, 404)
519
+ raw = await request.body()
520
+ if len(raw) > 20_000_000:
521
+ return JSONResponse({"error": {"message": "request too large"}}, 413)
522
+ try:
523
+ body = json.loads(raw)
524
+ except ValueError:
525
+ return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
526
+ up = r.upstream(str(body.get("model") or ""))
527
+ if up is None:
528
+ return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
529
+ base, key = up
530
+ url = f"{base}/chat/completions"
531
+ headers = {"Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {})}
532
+ ext = {}
533
+ if r.run.get("endpoint") and base != config.ROUTER: # the user's own endpoint: connect to the address we checked
534
+ try:
535
+ url, host, ext = await asyncio.to_thread(endpoints.pinned, url)
536
+ except endpoints.EndpointError as e:
537
+ return JSONResponse({"error": {"message": str(e)}}, 502)
538
+ headers.update(host)
539
+ client = httpx.AsyncClient(timeout=httpx.Timeout(900, connect=30), follow_redirects=False)
540
+ try:
541
+ resp = await client.send(client.build_request("POST", url, content=raw, headers=headers, extensions=ext), stream=True)
542
+ except httpx.HTTPError as e:
543
+ await client.aclose()
544
+ return JSONResponse({"error": {"message": f"upstream unreachable: {type(e).__name__}"}}, 502)
545
+
546
+ async def close():
547
+ await resp.aclose()
548
+ await client.aclose()
549
+ return StreamingResponse(resp.aiter_bytes(), status_code=resp.status_code,
550
+ media_type=resp.headers.get("content-type", "application/json"), background=BackgroundTask(close))
551
 
552
 
553
  # ── the site ────────────────────────────────────────────────────────���────────
app/runner/core.py CHANGED
@@ -17,6 +17,7 @@ The user's token is held here for the rollout's lifetime only. It never reaches
17
  from __future__ import annotations
18
 
19
  import re
 
20
  import threading
21
  import time
22
  import traceback
@@ -27,6 +28,7 @@ from .. import catalog, config, models, store, version
27
 
28
  _pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
29
  _live: dict[str, "Rollout"] = {}
 
30
  _live_lock = threading.Lock()
31
 
32
 
@@ -47,6 +49,8 @@ class Rollout:
47
  self.id = run["id"]
48
  self.token = token
49
  self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
 
 
50
  self.events: list[dict] = store.read_events(self.id)
51
  self._pending: list[dict] = []
52
  self._last_flush = time.time()
@@ -62,7 +66,7 @@ class Rollout:
62
  """Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
63
  (the General verifier, for one, prints the first characters of its judge key)."""
64
  if isinstance(v, str):
65
- for secret in (self.token, self.agent_key):
66
  if secret:
67
  v = v.replace(secret, "[redacted]")
68
  return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
@@ -131,7 +135,7 @@ class Rollout:
131
  self.update(flavor=flavor)
132
  t = time.time()
133
  self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
134
- forward_hf_token=True, token=self.token, start_timeout=900,
135
  labels={"app": "mimo-explorer", "run": self.id})
136
  self.update(sandbox_id=self.sandbox.id)
137
  self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
@@ -145,6 +149,19 @@ class Rollout:
145
  mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
146
  return config.ROUTER, self.token, mid
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def sh(self, cmd: str, timeout: float = 600, **kw):
149
  """Run a shell command in the sandbox; never raises on a non-zero exit."""
150
  self.check_cancel()
@@ -176,6 +193,8 @@ class Rollout:
176
  self._flush()
177
  self.stop_sandbox()
178
  self.agent_key = None
 
 
179
  with _live_lock:
180
  _live.pop(self.id, None)
181
 
@@ -231,10 +250,17 @@ def submit(user: str, token: str, task: dict, model: str, provider: str | None,
231
  r = Rollout(run, token, agent_key)
232
  with _live_lock:
233
  _live[r.id] = r
 
234
  _pool.submit(r.execute)
235
  return run
236
 
237
 
 
 
 
 
 
 
238
  def live(run_id: str) -> Rollout | None:
239
  with _live_lock:
240
  return _live.get(run_id)
@@ -253,6 +279,8 @@ def cancel(run_id: str) -> bool:
253
  if r is None:
254
  return False
255
  r.cancelled.set()
 
 
256
  # Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
257
  r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
258
  r.phase("done", "error", "stopped by you")
 
17
  from __future__ import annotations
18
 
19
  import re
20
+ import secrets
21
  import threading
22
  import time
23
  import traceback
 
28
 
29
  _pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
30
  _live: dict[str, "Rollout"] = {}
31
+ _caps: dict[str, "Rollout"] = {} # model-proxy capability -> its rollout, while it runs
32
  _live_lock = threading.Lock()
33
 
34
 
 
49
  self.id = run["id"]
50
  self.token = token
51
  self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
52
+ # what the sandbox gets instead of credentials (see config.PUBLIC_URL): valid while this rollout is live
53
+ self.cap = secrets.token_urlsafe(32)
54
  self.events: list[dict] = store.read_events(self.id)
55
  self._pending: list[dict] = []
56
  self._last_flush = time.time()
 
66
  """Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
67
  (the General verifier, for one, prints the first characters of its judge key)."""
68
  if isinstance(v, str):
69
+ for secret in (self.token, self.agent_key, getattr(self, "cap", None)):
70
  if secret:
71
  v = v.replace(secret, "[redacted]")
72
  return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
 
135
  self.update(flavor=flavor)
136
  t = time.time()
137
  self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
138
+ forward_hf_token=not config.PUBLIC_URL, token=self.token, start_timeout=900,
139
  labels={"app": "mimo-explorer", "run": self.id})
140
  self.update(sandbox_id=self.sandbox.id)
141
  self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
 
149
  mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
150
  return config.ROUTER, self.token, mid
151
 
152
+ def llm_base(self) -> str | None:
153
+ """The OpenAI-compatible base URL the sandbox should use, or None to call the provider directly."""
154
+ return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1" if config.PUBLIC_URL else None
155
+
156
+ def upstream(self, model: str) -> tuple[str, str | None] | None:
157
+ """For the model proxy: (base URL, key) for a model this rollout may call, else None."""
158
+ base, key, mid = self.agent_api()
159
+ if model == mid:
160
+ return base, key
161
+ if self.run.get("judge") and model == self.run["judge"]:
162
+ return config.ROUTER, self.token
163
+ return None
164
+
165
  def sh(self, cmd: str, timeout: float = 600, **kw):
166
  """Run a shell command in the sandbox; never raises on a non-zero exit."""
167
  self.check_cancel()
 
193
  self._flush()
194
  self.stop_sandbox()
195
  self.agent_key = None
196
+ with _live_lock:
197
+ _caps.pop(self.cap, None)
198
  with _live_lock:
199
  _live.pop(self.id, None)
200
 
 
250
  r = Rollout(run, token, agent_key)
251
  with _live_lock:
252
  _live[r.id] = r
253
+ _caps[r.cap] = r
254
  _pool.submit(r.execute)
255
  return run
256
 
257
 
258
+ def by_cap(cap: str) -> Rollout | None:
259
+ with _live_lock:
260
+ r = _caps.get(cap)
261
+ return r if r is not None and not r.cancelled.is_set() else None
262
+
263
+
264
  def live(run_id: str) -> Rollout | None:
265
  with _live_lock:
266
  return _live.get(run_id)
 
279
  if r is None:
280
  return False
281
  r.cancelled.set()
282
+ with _live_lock:
283
+ _caps.pop(r.cap, None)
284
  # Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
285
  r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
286
  r.phase("done", "error", "stopped by you")
app/runner/domains.py CHANGED
@@ -297,8 +297,9 @@ class General:
297
  r.sh(f"chown agent:agent {shlex.quote(man['cwd'])}/answer.md")
298
  self._push(r, root, [(u["source"], u["target"]) for u in man["verifier"]["uploads"]], "verifier")
299
  judge = r.run.get("judge") or "thinkingmachines/Inkling"
300
- env = {"GA_JUDGE_KEY": "$HF_TOKEN", "GA_JUDGE_MODEL": judge, "GA_JUDGE_API": "chat",
301
- "GA_JUDGE_URL": config.ROUTER, "VERIFY_DETERMINISTIC": "1", "VERIFY_AGENT_JUDGE": "1"}
 
302
  exports = " ".join(f'{k}="{v}"' for k, v in env.items())
303
  rf = man["verifier"].get("reward_file") or "/logs/verifier/reward.json"
304
  rd = man["verifier"].get("reward_detail_file") or "/logs/verifier/reward_detail.json"
@@ -529,9 +530,10 @@ class Music:
529
  params = r.run.get("params") or {}
530
  if r.run.get("scripted") is not None: # validation: score a fixed piece, no model call
531
  return self._score(r, raw, r.run.get("scripted_final", ""), "", {}, params)
532
- if r.run.get("endpoint"):
 
533
  from .. import endpoints
534
- base = endpoints.check_url(base) # re-checked at call time: DNS can change after the test
535
  text, thinking, usage = "", "", {}
536
  # verl recipes/design/config/music.yaml: response_length 100000. Providers that cap output lower refuse
537
  # such a request outright, so step down rather than fail the rollout on a limit.
@@ -541,8 +543,8 @@ class Music:
541
  **({"temperature": params["temperature"]} if params.get("temperature") is not None else {}),
542
  **({"reasoning_effort": params["thinking"]} if params.get("thinking") not in (None, "default") else {}),
543
  "messages": [{"role": "user", "content": raw["prompt"]}]}
544
- with httpx.stream("POST", f"{base}/chat/completions", timeout=900, json=body,
545
- headers={"Authorization": f"Bearer {key}"} if key else {}) as resp:
546
  if resp.status_code != 200:
547
  err = resp.read()[:400].decode(errors="replace")
548
  if resp.status_code in (400, 422) and n + 1 < len(budgets):
 
297
  r.sh(f"chown agent:agent {shlex.quote(man['cwd'])}/answer.md")
298
  self._push(r, root, [(u["source"], u["target"]) for u in man["verifier"]["uploads"]], "verifier")
299
  judge = r.run.get("judge") or "thinkingmachines/Inkling"
300
+ proxy = r.llm_base() # on the Space the judge goes through the rollout's proxy: no token in the sandbox
301
+ env = {"GA_JUDGE_KEY": "proxied" if proxy else "$HF_TOKEN", "GA_JUDGE_MODEL": judge, "GA_JUDGE_API": "chat",
302
+ "GA_JUDGE_URL": proxy or config.ROUTER, "VERIFY_DETERMINISTIC": "1", "VERIFY_AGENT_JUDGE": "1"}
303
  exports = " ".join(f'{k}="{v}"' for k, v in env.items())
304
  rf = man["verifier"].get("reward_file") or "/logs/verifier/reward.json"
305
  rd = man["verifier"].get("reward_detail_file") or "/logs/verifier/reward_detail.json"
 
530
  params = r.run.get("params") or {}
531
  if r.run.get("scripted") is not None: # validation: score a fixed piece, no model call
532
  return self._score(r, raw, r.run.get("scripted_final", ""), "", {}, params)
533
+ url, host_hdr, ext = f"{base}/chat/completions", {}, {}
534
+ if r.run.get("endpoint"): # the user's own endpoint: connect to the address that was checked
535
  from .. import endpoints
536
+ url, host_hdr, ext = endpoints.pinned(f"{endpoints.check_url(base)}/chat/completions")
537
  text, thinking, usage = "", "", {}
538
  # verl recipes/design/config/music.yaml: response_length 100000. Providers that cap output lower refuse
539
  # such a request outright, so step down rather than fail the rollout on a limit.
 
543
  **({"temperature": params["temperature"]} if params.get("temperature") is not None else {}),
544
  **({"reasoning_effort": params["thinking"]} if params.get("thinking") not in (None, "default") else {}),
545
  "messages": [{"role": "user", "content": raw["prompt"]}]}
546
+ with httpx.stream("POST", url, timeout=900, json=body, extensions=ext,
547
+ headers={**host_hdr, **({"Authorization": f"Bearer {key}"} if key else {})}) as resp:
548
  if resp.status_code != 200:
549
  err = resp.read()[:400].decode(errors="replace")
550
  if resp.status_code in (400, 422) and n + 1 < len(budgets):
app/runner/opencode.py CHANGED
@@ -63,17 +63,20 @@ def install(r) -> str:
63
 
64
 
65
  def config_for(model: str, provider: str | None, steps: int, mcp: list[dict] | None = None,
66
- endpoint: dict | None = None, params: dict | None = None) -> dict:
 
 
67
  params = params or {}
68
- if endpoint: # your own OpenAI-compatible endpoint; the key arrives as AGENT_API_KEY
69
  mid, prov = model, {"byo": {"npm": "@ai-sdk/openai-compatible", "name": "Your endpoint",
70
- "options": {"baseURL": endpoint["base_url"], "apiKey": "{env:AGENT_API_KEY}"},
 
71
  "models": {model: {"name": model, "tool_call": True}}}}
72
  ref = f"byo/{model}"
73
  else:
74
  mid = f"{model}:{provider}" if provider else model
75
  prov = {"hf": {"npm": "@ai-sdk/openai-compatible", "name": "Hugging Face Inference Providers",
76
- "options": {"baseURL": config.ROUTER, "apiKey": "{env:HF_TOKEN}"},
77
  "models": {mid: {"name": model, "tool_call": True}}}}
78
  ref = f"hf/{mid}"
79
  entry = next(iter(next(iter(prov.values()))["models"].values()))
@@ -104,12 +107,13 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
104
  ep, params = r.run.get("endpoint"), r.run.get("params") or {}
105
  steps = params.get("steps") or steps
106
  timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
107
- cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params)
 
108
  r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
109
  r.sandbox.files.write("/tmp/prompt.txt", prompt)
110
  r.sh("chmod 644 /tmp/opencode.json /tmp/prompt.txt")
111
  key_env = ""
112
- if ep:
113
  # a root-only file, read by the root shell when it builds the command: the key is never on a command line
114
  r.sandbox.files.write("/root/.agent_key", r.agent_key or "")
115
  r.sh("chmod 600 /root/.agent_key")
@@ -122,7 +126,8 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
122
  "XDG_CACHE_HOME": f"{xdg}/cache", "XDG_STATE_HOME": f"{xdg}/state"}
123
  if (params.get("max_tokens") or 0) > 32000: # upstream silently caps output at 32k otherwise
124
  flags["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] = str(params["max_tokens"])
125
- env = f"env {key_env}HF_TOKEN=\"$HF_TOKEN\" HOME={home or '$HOME'} " + " ".join(f"{k}={v}" for k, v in flags.items())
 
126
  # --thinking streams the model's reasoning as its own events, so the trace shows why, not only what;
127
  # --title skips the extra model call that would otherwise name the session
128
  inner = f'{env} opencode run --format json --thinking --auto --title mimo-rollout "$(cat /tmp/prompt.txt)"'
@@ -146,7 +151,7 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
146
  try:
147
  res = r.sh(cmd, timeout=timeout, on_stdout=on_out, on_stderr=on_err)
148
  finally:
149
- if ep:
150
  r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
151
  if buf[0].strip():
152
  _event(r, buf[0], texts)
 
63
 
64
 
65
  def config_for(model: str, provider: str | None, steps: int, mcp: list[dict] | None = None,
66
+ endpoint: dict | None = None, params: dict | None = None, proxy: str | None = None) -> dict:
67
+ """With `proxy` (the Space), every model call goes to this server's per-rollout proxy and the sandbox holds no
68
+ credential at all; the capability in the URL is the only secret, and it dies with the rollout."""
69
  params = params or {}
70
+ if endpoint: # your own OpenAI-compatible endpoint; without the proxy, the key arrives as AGENT_API_KEY
71
  mid, prov = model, {"byo": {"npm": "@ai-sdk/openai-compatible", "name": "Your endpoint",
72
+ "options": {"baseURL": proxy or endpoint["base_url"],
73
+ "apiKey": "proxied" if proxy else "{env:AGENT_API_KEY}"},
74
  "models": {model: {"name": model, "tool_call": True}}}}
75
  ref = f"byo/{model}"
76
  else:
77
  mid = f"{model}:{provider}" if provider else model
78
  prov = {"hf": {"npm": "@ai-sdk/openai-compatible", "name": "Hugging Face Inference Providers",
79
+ "options": {"baseURL": proxy or config.ROUTER, "apiKey": "proxied" if proxy else "{env:HF_TOKEN}"},
80
  "models": {mid: {"name": model, "tool_call": True}}}}
81
  ref = f"hf/{mid}"
82
  entry = next(iter(next(iter(prov.values()))["models"].values()))
 
107
  ep, params = r.run.get("endpoint"), r.run.get("params") or {}
108
  steps = params.get("steps") or steps
109
  timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
110
+ proxy = r.llm_base()
111
+ cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
112
  r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
113
  r.sandbox.files.write("/tmp/prompt.txt", prompt)
114
  r.sh("chmod 644 /tmp/opencode.json /tmp/prompt.txt")
115
  key_env = ""
116
+ if ep and not proxy:
117
  # a root-only file, read by the root shell when it builds the command: the key is never on a command line
118
  r.sandbox.files.write("/root/.agent_key", r.agent_key or "")
119
  r.sh("chmod 600 /root/.agent_key")
 
126
  "XDG_CACHE_HOME": f"{xdg}/cache", "XDG_STATE_HOME": f"{xdg}/state"}
127
  if (params.get("max_tokens") or 0) > 32000: # upstream silently caps output at 32k otherwise
128
  flags["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] = str(params["max_tokens"])
129
+ token_env = "" if proxy else 'HF_TOKEN="$HF_TOKEN" '
130
+ env = f"env {key_env}{token_env}HOME={home or '$HOME'} " + " ".join(f"{k}={v}" for k, v in flags.items())
131
  # --thinking streams the model's reasoning as its own events, so the trace shows why, not only what;
132
  # --title skips the extra model call that would otherwise name the session
133
  inner = f'{env} opencode run --format json --thinking --auto --title mimo-rollout "$(cat /tmp/prompt.txt)"'
 
151
  try:
152
  res = r.sh(cmd, timeout=timeout, on_stdout=on_out, on_stderr=on_err)
153
  finally:
154
+ if ep and not proxy:
155
  r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
156
  if buf[0].strip():
157
  _event(r, buf[0], texts)
web/index.html CHANGED
@@ -12,7 +12,7 @@
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
14
  <link rel="stylesheet" href="/app.css">
15
- <script>try { const t = localStorage.getItem("theme"); if (t) document.documentElement.dataset.theme = t; } catch (e) {}</script>
16
  </head>
17
  <body>
18
  <div class="progress" id="progress" aria-hidden="true"><i></i></div>
 
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
14
  <link rel="stylesheet" href="/app.css">
15
+ <script src="/js/boot.js"></script>
16
  </head>
17
  <body>
18
  <div class="progress" id="progress" aria-hidden="true"><i></i></div>
web/js/boot.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ // Runs before the page paints (loaded blocking in <head>): apply the saved theme so there's no flash.
2
+ try { const t = localStorage.getItem("theme"); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* private mode */ }
web/js/explore.js CHANGED
@@ -50,7 +50,7 @@ export async function mount(el, params) {
50
  if (!DATA) {
51
  el.innerHTML = skeleton();
52
  try { await progress.wrap(load()); }
53
- catch (e) { el.innerHTML = `<div class="wrap page">${emptyState("alert", "Couldn't load the environments", esc(e.message), `<button class="btn" onclick="location.reload()">${icon("refresh")}Try again</button>`)}</div>`; return; }
54
  if (root !== el) return;
55
  }
56
  if (params) {
 
50
  if (!DATA) {
51
  el.innerHTML = skeleton();
52
  try { await progress.wrap(load()); }
53
+ catch (e) { el.innerHTML = `<div class="wrap page">${emptyState("alert", "Couldn't load the environments", esc(e.message), `<button class="btn" id="reload" type="button">${icon("refresh")}Try again</button>`)}</div>`; $("#reload", el)?.addEventListener("click", () => location.reload()); return; }
54
  if (root !== el) return;
55
  }
56
  if (params) {