Skip to content

Commit d7f1b27

Browse files
abidlabsgradio-pr-botCopilotclaude
authored
Avoid HF token leaks in static snapshots (#535)
Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 675ef66 commit d7f1b27

7 files changed

Lines changed: 122 additions & 39 deletions

File tree

.changeset/rich-queens-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trackio": patch
3+
---
4+
5+
feat:Avoid HF token leaks in static snapshots

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ trackio.sync(
218218

219219
This uploads your local project database to a new or existing Space. The Space will display all your logged experiments and metrics, and if a custom frontend is configured or passed explicitly it will be deployed there too.
220220

221+
Static Trackio Spaces (`sdk="static"`) are read-only browser-only snapshots, so their snapshot data must be public. Use the default Gradio Space for private dashboards; `sdk="static"` does not support `private=True`.
222+
221223
**Example workflow:**
222224

223225
```py

docs/source/cli_commands.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Deploy as a static Space (reads from an HF Bucket, no server needed):
3939
trackio sync --project "my-project" --space-id "username/space_id" --sdk static
4040
```
4141

42+
Static Spaces serve data directly from the browser and therefore require public snapshot data. `--sdk static --private` is not supported; use the default Gradio SDK for private dashboards.
43+
4244
Sync all projects that have unsynced data to their configured Spaces:
4345

4446
```sh
@@ -51,7 +53,7 @@ trackio sync --all
5153
| `--space-id` | The HF Space ID to sync to (e.g. `username/space_id`). If not provided, uses the previously-configured Space |
5254
| `--all` | Sync all projects with unsynced data |
5355
| `--sdk` | `gradio` (default) for a live server, or `static` for a read-only bucket-backed Space |
54-
| `--private` | Make the Space private if creating a new one |
56+
| `--private` | Make the Space private if creating a new Gradio Space. Not supported with `--sdk static` |
5557
| `--force` | Overwrite the existing database without prompting |
5658

5759
## Freeze Command
@@ -73,10 +75,11 @@ trackio freeze --space-id "username/my-space" --project "my-project" --new-space
7375
| `--space-id` | The source Gradio Space ID (required) |
7476
| `--project` | The project to freeze (required) |
7577
| `--new-space-id` | The destination static Space ID. Defaults to `{space_id}_static` |
76-
| `--private` | Make the new static Space private |
78+
| `--private` | Not supported for static frozen snapshots |
7779

7880
> **Note:** The source must be a Gradio Space with a bucket mounted at `/data`. If the destination Space already exists and is not a Trackio static Space, `freeze` will refuse to overwrite it.
7981
> The frozen Space is a snapshot. Later metrics synced to the original Gradio Space do not appear in the frozen static Space unless you run `freeze` again.
82+
> Static frozen snapshots require public destination data, so `trackio freeze --private` is not supported.
8083
8184
## List Commands
8285

docs/source/deploy_embed.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ trackio.sync(project="my-project", space_id="username/space_id", sdk="static")
4242
trackio sync --project "my-project" --space-id "username/space_id" --sdk static
4343
```
4444

45-
Static Spaces are lightweight and free — they serve a read-only dashboard backed by Parquet files in an HF Bucket.
45+
Static Spaces are lightweight and free — they serve a read-only dashboard backed by Parquet files in an HF Bucket. Because the dashboard runs entirely in the browser, static Trackio Spaces require public snapshot data and do not support `private=True`. Use the default Gradio Space (`sdk="gradio"`) for private dashboards.
4646

4747
## Freezing a Space Snapshot
4848

@@ -61,6 +61,7 @@ trackio freeze --space-id "username/my-space" --project "my-project"
6161
This creates a new static Space (by default named `{space_id}_static`) containing a snapshot of the project's data from the source Space's bucket. The original Space is not modified.
6262

6363
Note that`freeze()` is a one-time snapshot. If new metrics are later uploaded to the original Gradio Space, the frozen static Space will not update automatically.
64+
The source Gradio Space can use a private bucket, but the frozen static snapshot is public data. `freeze(private=True)` is not supported; use a Gradio Space if the frozen dashboard must stay private.
6465

6566
You can customize the destination:
6667

@@ -69,7 +70,6 @@ trackio.freeze(
6970
space_id="username/my-space",
7071
project="my-project",
7172
new_space_id="username/my-snapshot",
72-
private=True,
7373
)
7474
```
7575

tests/unit/test_deploy.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import io
2+
import json
23
from types import SimpleNamespace
34
from unittest.mock import patch
45

6+
import pytest
7+
58
from trackio import deploy
69
from trackio.bucket_storage import _list_bucket_file_paths
710
from trackio.frontend_config import ResolvedFrontend
@@ -133,3 +136,64 @@ def test_deploy_as_static_space_uploads_resolved_frontend(tmp_path, monkeypatch)
133136
assert any(
134137
call["folder_path"] == str(frontend_dir) for call in fake_api.uploaded_folders
135138
)
139+
140+
141+
def _make_static_deploy_env(tmp_path, monkeypatch):
142+
frontend_dir = tmp_path / "static-frontend"
143+
frontend_dir.mkdir()
144+
(frontend_dir / "index.html").write_text("<!doctype html>")
145+
146+
fake_api = _FakeHfApi()
147+
monkeypatch.setattr(deploy.huggingface_hub, "HfApi", lambda: fake_api)
148+
monkeypatch.setattr(deploy.huggingface_hub, "create_repo", lambda *a, **k: None)
149+
monkeypatch.setattr(
150+
deploy,
151+
"resolve_frontend_dir",
152+
lambda frontend_dir=None, announce=False: ResolvedFrontend(
153+
path=frontend_dir.resolve(),
154+
source="argument",
155+
is_custom=True,
156+
),
157+
)
158+
return fake_api, frontend_dir
159+
160+
161+
def _get_uploaded_config(fake_api):
162+
config_upload = next(
163+
item
164+
for item in fake_api.uploaded_files
165+
if item["path_in_repo"] == "config.json"
166+
)
167+
return json.loads(config_upload["payload"])
168+
169+
170+
def test_deploy_as_static_space_config_omits_hf_token(tmp_path, monkeypatch):
171+
fake_api, frontend_dir = _make_static_deploy_env(tmp_path, monkeypatch)
172+
173+
deploy.deploy_as_static_space(
174+
"abidlabs/static-space",
175+
None,
176+
"demo-project",
177+
bucket_id="abidlabs/static-bucket",
178+
hf_token="hf_supersecrettoken",
179+
frontend_dir=frontend_dir,
180+
)
181+
182+
config = _get_uploaded_config(fake_api)
183+
assert "hf_token" not in config
184+
assert "token" not in config
185+
assert "hf_supersecrettoken" not in json.dumps(config)
186+
187+
188+
def test_deploy_as_static_space_rejects_private_true(tmp_path, monkeypatch):
189+
_, frontend_dir = _make_static_deploy_env(tmp_path, monkeypatch)
190+
191+
with pytest.raises(ValueError, match="private=True is not supported"):
192+
deploy.deploy_as_static_space(
193+
"abidlabs/static-space",
194+
None,
195+
"demo-project",
196+
bucket_id="abidlabs/static-bucket",
197+
private=True,
198+
frontend_dir=frontend_dir,
199+
)

trackio/deploy.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import tempfile
88
import threading
99
import time
10+
import warnings
1011
from collections import Counter
1112
from importlib.resources import files
1213
from pathlib import Path
@@ -902,12 +903,18 @@ def deploy_as_static_space(
902903
if on_spaces():
903904
return
904905

906+
if private is True:
907+
raise ValueError(
908+
"private=True is not supported for static Trackio Spaces. Static Spaces "
909+
"run entirely in the browser, so their snapshot data must be public. "
910+
"Use sdk='gradio' for a private dashboard."
911+
)
905912
hf_api = huggingface_hub.HfApi()
906913

907914
try:
908915
huggingface_hub.create_repo(
909916
space_id,
910-
private=private,
917+
private=False,
911918
space_sdk="static",
912919
repo_type="space",
913920
exist_ok=True,
@@ -918,7 +925,7 @@ def deploy_as_static_space(
918925
huggingface_hub.login(add_to_git_credential=False)
919926
huggingface_hub.create_repo(
920927
space_id,
921-
private=private,
928+
private=False,
922929
space_sdk="static",
923930
repo_type="space",
924931
exist_ok=True,
@@ -960,8 +967,13 @@ def deploy_as_static_space(
960967
config["bucket_id"] = bucket_id
961968
if dataset_id is not None:
962969
config["dataset_id"] = dataset_id
963-
if hf_token and private:
964-
config["hf_token"] = hf_token
970+
if hf_token is not None:
971+
warnings.warn(
972+
"`hf_token` is ignored by deploy_as_static_space() for static Space "
973+
"deployment and will be removed in a future release.",
974+
DeprecationWarning,
975+
stacklevel=2,
976+
)
965977

966978
_retry_hf_write(
967979
"Static Space config upload",
@@ -1017,7 +1029,8 @@ def sync(
10171029
private (`bool`, *optional*):
10181030
Whether to make the Space private. If None (default), the repo will be
10191031
public unless the organization's default is private. This value is ignored
1020-
if the repo already exists.
1032+
if the repo already exists. Not supported with ``sdk="static"`` because
1033+
static Trackio dashboards read snapshot data directly from the browser.
10211034
force (`bool`, *optional*, defaults to `False`):
10221035
If `True`, overwrite the existing database without prompting for confirmation.
10231036
If `False`, prompt the user before overwriting an existing database.
@@ -1038,6 +1051,12 @@ def sync(
10381051
"""
10391052
if sdk not in ("gradio", "static"):
10401053
raise ValueError(f"sdk must be 'gradio' or 'static', got '{sdk}'")
1054+
if sdk == "static" and private is True:
1055+
raise ValueError(
1056+
"private=True is not supported for static Trackio Spaces. Static Spaces "
1057+
"run entirely in the browser, so their snapshot data must be public. "
1058+
"Use sdk='gradio' for a private dashboard."
1059+
)
10411060
bucket_id_was_explicit = bucket_id is not None
10421061

10431062
if space_id is None:
@@ -1064,18 +1083,16 @@ def _do_sync():
10641083

10651084
if sdk == "static":
10661085
if dataset_id is not None:
1067-
upload_dataset_for_static(project, dataset_id, private=private)
1068-
hf_token = huggingface_hub.utils.get_token() if private else None
1086+
upload_dataset_for_static(project, dataset_id, private=False)
10691087
deploy_as_static_space(
10701088
space_id,
10711089
dataset_id,
10721090
project,
1073-
private=private,
1074-
hf_token=hf_token,
1091+
private=False,
10751092
frontend_dir=frontend_dir,
10761093
)
10771094
elif bucket_id is not None:
1078-
create_bucket_if_not_exists(bucket_id, private=private)
1095+
create_bucket_if_not_exists(bucket_id, private=False)
10791096
upload_project_to_bucket_for_static(project, bucket_id)
10801097
print(
10811098
f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
@@ -1085,8 +1102,7 @@ def _do_sync():
10851102
None,
10861103
project,
10871104
bucket_id=bucket_id,
1088-
private=private,
1089-
hf_token=huggingface_hub.utils.get_token() if private else None,
1105+
private=False,
10901106
frontend_dir=frontend_dir,
10911107
)
10921108
else:
@@ -1165,15 +1181,21 @@ def freeze(
11651181
The ID for the new static Space. If not provided, defaults to
11661182
`"{space_id}_static"`.
11671183
private (`bool`, *optional*):
1168-
Whether to make the new Space private. If None (default), the repo
1169-
will be public unless the organization's default is private.
1184+
Not supported. Frozen static dashboards read snapshot data directly
1185+
from the browser, so the destination snapshot must be public.
11701186
bucket_id (`str`, *optional*):
11711187
The ID of the HF Bucket for the new static Space's data storage.
11721188
If not provided, one is auto-generated from the new Space ID.
11731189
11741190
Returns:
11751191
`str`: The Space ID of the newly created static Space.
11761192
"""
1193+
if private is True:
1194+
raise ValueError(
1195+
"private=True is not supported for frozen static Trackio Spaces. Static "
1196+
"Spaces run entirely in the browser, so their snapshot data must be "
1197+
"public. Use a Gradio Space if the frozen dashboard must stay private."
1198+
)
11771199
space_id, _, _ = preprocess_space_and_dataset_ids(space_id, None, None)
11781200

11791201
try:
@@ -1214,7 +1236,7 @@ def freeze(
12141236
except RepositoryNotFoundError:
12151237
pass
12161238

1217-
create_bucket_if_not_exists(bucket_id, private=private)
1239+
create_bucket_if_not_exists(bucket_id, private=False)
12181240
export_from_bucket_for_static(source_bucket_id, bucket_id, project)
12191241
print(
12201242
f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
@@ -1224,8 +1246,7 @@ def freeze(
12241246
None,
12251247
project,
12261248
bucket_id=bucket_id,
1227-
private=private,
1228-
hf_token=huggingface_hub.utils.get_token() if private else None,
1249+
private=False,
12291250
frontend_dir=frontend_dir,
12301251
)
12311252
return new_space_id

trackio/frontend/src/lib/staticApi.js

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,6 @@ function resolveUrl(filename) {
1515
return `https://huggingface.co/datasets/${config.dataset_id}/resolve/main/${filename}`;
1616
}
1717

18-
function authHeaders() {
19-
if (config.hf_token) {
20-
return { Authorization: `Bearer ${config.hf_token}` };
21-
}
22-
return {};
23-
}
24-
2518
export async function initialize(cfg) {
2619
config = cfg;
2720
}
@@ -41,28 +34,25 @@ export function getReadOnlySource() {
4134

4235
async function getMetricsData() {
4336
if (metricsData) return metricsData;
44-
metricsData = await readParquet(resolveUrl("metrics.parquet"), authHeaders());
37+
metricsData = await readParquet(resolveUrl("metrics.parquet"));
4538
return metricsData;
4639
}
4740

4841
async function getSystemData() {
4942
if (systemData) return systemData;
50-
systemData = await readParquet(
51-
resolveUrl("aux/system_metrics.parquet"),
52-
authHeaders(),
53-
);
43+
systemData = await readParquet(resolveUrl("aux/system_metrics.parquet"));
5444
return systemData;
5545
}
5646

5747
async function getConfigsData() {
5848
if (configsData) return configsData;
59-
configsData = await readParquet(resolveUrl("aux/configs.parquet"), authHeaders());
49+
configsData = await readParquet(resolveUrl("aux/configs.parquet"));
6050
return configsData;
6151
}
6252

6353
async function getRunsJson() {
6454
if (runsData) return runsData;
65-
const resp = await fetch(resolveUrl("runs.json"), { headers: authHeaders() });
55+
const resp = await fetch(resolveUrl("runs.json"));
6656
if (!resp.ok) {
6757
runsData = [];
6858
return runsData;
@@ -73,7 +63,7 @@ async function getRunsJson() {
7363

7464
async function getSettingsJson() {
7565
if (settingsData) return settingsData;
76-
const resp = await fetch(resolveUrl("settings.json"), { headers: authHeaders() });
66+
const resp = await fetch(resolveUrl("settings.json"));
7767
if (!resp.ok) {
7868
settingsData = {};
7969
return settingsData;
@@ -435,7 +425,6 @@ export async function getProjectFiles() {
435425
if (config.bucket_id) {
436426
const resp = await fetch(
437427
`https://huggingface.co/api/buckets/${config.bucket_id}/tree?prefix=media/files/&recursive=true`,
438-
{ headers: authHeaders() },
439428
);
440429
if (!resp.ok) {
441430
fileListData = [];
@@ -453,7 +442,6 @@ export async function getProjectFiles() {
453442

454443
const resp = await fetch(
455444
`https://huggingface.co/api/datasets/${config.dataset_id}`,
456-
{ headers: authHeaders() },
457445
);
458446
if (!resp.ok) {
459447
fileListData = [];
@@ -497,7 +485,7 @@ export async function fetchMediaBlob(path) {
497485
const url = resolveUrl(`media/${relative}`);
498486
if (blobCache.has(url)) return blobCache.get(url);
499487

500-
const resp = await fetch(url, { headers: authHeaders() });
488+
const resp = await fetch(url);
501489
if (!resp.ok) return url;
502490
const blob = await resp.blob();
503491
const blobUrl = URL.createObjectURL(blob);

0 commit comments

Comments
 (0)