TiGa-RCE commited on
Commit
3315dab
·
verified ·
1 Parent(s): 47ff4df

Add reviewed opt-in private bucket save and restore

Browse files
BUCKET_BUNDLE.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Portable bucket bundle v1
2
+
3
+ The browser supports explicit local-file export/import and optional direct save/restore to an existing private bucket. Local export downloads one JSON bundle. Restore validates the complete bundle before staging the network and any optional track or recipe in the current page. A restored receipt is kept in page memory and offered as a separate download. Restore never starts a run or writes browser storage.
4
+
5
+ Bucket storage starts disconnected and never uploads automatically. The user enters a separate fine-grained token and explicitly connects, saves, refreshes, or restores. An expired/denied server response clears the page connection; the UI does not claim to know token expiry before a request. During those actions, the token and selected bundle pass through this app server to the user-owned private bucket; the app does not keep copies after the operation and exposes no bucket deletion, overwrite, creation, or visibility controls. Provider-side logs and retention are outside the app's deletion guarantee. The token is not included in the portable bundle. Existing run-retention controls remain independent. A separate local CLI guide is distributed with the candidate tooling for users who prefer local bucket transfer.
6
+
7
+ ## Envelope
8
+
9
+ The only top-level fields are `schema`, `version`, and `artifacts`. The schema is `ald.portable-bundle`, version `1`. The required artifact slot is `selected_network`; optional fixed slots are `selected_track`, `selected_recipe`, and `run_receipt`. Unknown slots or fields are rejected.
10
+
11
+ - A network stores its original `.evolution` bytes as base64 with byte length, SHA-256, format `evolution`, and kind `input` or `result`. The page validates decoded network content with its existing game-network validator and keeps the original bytes when staging it.
12
+ - A track stores original `.track` bytes with length and SHA-256. A built-in track also records its catalog ID and display label; an uploaded track has no extra identifying metadata.
13
+ - A recipe stores only version, A–J slot, name, and the existing validated training settings. Restore stages it into the form; saving it to the browser recipe library remains a separate user action.
14
+ - A run receipt is an allowlisted summary only: build ID, backend, output hash, and whitelisted report fields for vehicle, counts, metrics, file hashes, and settings. It excludes history, paths, filenames, usernames, account data, job IDs/keys, and raw errors. Receipts that do not match the bundled network or included track are omitted on export or rejected on import.
15
+
16
+ Caps are 4 MiB for network bytes, 1 MiB for track bytes, 32 KiB for recipe JSON, 128 KiB for receipt JSON, and 8 MiB for the complete bundle. Payload bytes and hashes must match exactly.
DATA_POLICY.md CHANGED
@@ -3,7 +3,7 @@
3
  Operator: TiGa (seeker-cyber-maker). Candidate version: 2026-10-13. This package is not a receipt that a hosted deployment or publication has occurred.
4
 
5
  Files are processed only for the requested job, not collected for our own
6
- training, analytics or public examples. Inputs remain in the browser until Run.
7
  The interface supports native CPU and, on a ZeroGPU deployment, an explicitly
8
  selected GPU job. CPU requests do not acquire a GPU lease.
9
 
@@ -61,8 +61,7 @@ acceptance limit; they are removed on return, error, or a fresh app load. Closin
61
  the tab loses them. HF handles password entry and authorization. The callback
62
  briefly carries a one-use authorization code and state in its URL; the app clears
63
  that URL before loading the OAuth helper. HF/proxy infrastructure handling is outside this
64
- application's deletion guarantee. The app does not request email, billing,
65
- repository access, or persistent bucket access.
66
 
67
  A non-GPU queue check forwards the visitor bearer credential to HF's fixed
68
  userinfo endpoint and returns only the username, PRO flag, and authentication
@@ -77,7 +76,7 @@ Results received by the browser and files saved locally are the user's to manage
77
  The browser retains its received result in page memory so Save received result
78
  again can retry a local save without another server retrieval. Discard browser
79
  result clears the app's reference; closing or refreshing the page loses it.
80
- No bucket support, network sandbox or provider-wide ZDR guarantee is offered.
81
  The service uses one result-owning process. HF framework and proxy behavior may have retention outside this app, so this is
82
  an application-level near-zero-retention design, not a provider-wide ZDR claim.
83
 
@@ -105,9 +104,7 @@ exported; they are not added to recipe storage.
105
  Network inspection, creation and editing take place in browser memory. Applying
106
  an edited network selects it as the next run's input; this alone sends nothing
107
  to the server. Exporting saves a new file, not the original source. No training
108
- history is attributed to freshly created or edited weights. A future bucket
109
- adapter is not implemented; it must be explicitly selected and must not silently
110
- turn public ephemeral storage into persistent storage.
111
 
112
 
113
  ## Optional public Steam leaderboard check
@@ -117,3 +114,12 @@ The “After a completed lap” checkbox is off by default. With it off, or when
117
  The app reads the public Steam Community track index and matching board XML without a Steam login or publisher key. It resolves exact catalog reference labels against that live index; an absent or ambiguous mapping is unavailable. The app retains only a short-lived in-process cache of public board rank/time rows (up to 60 seconds) and the public index mapping (up to five minutes). Player names, Steam IDs and raw XML are discarded after parsing. The comparison request and result are not stored in the job result store or recipe library.
118
 
119
  The displayed value is an insertion-position estimate against the fetched raw global board, at millisecond precision. The board may contain entries for different vehicle types; the estimate is not vehicle-filtered, an official rank, a submitted score, or proof that replica timing matches the Steam game. The check runs in the background with an eight-second browser deadline. It cannot delay result availability or download; a timeout leaves the completed result intact. Steam's community page may change or become unavailable, in which case the app reports the check as unavailable.
 
 
 
 
 
 
 
 
 
 
3
  Operator: TiGa (seeker-cyber-maker). Candidate version: 2026-10-13. This package is not a receipt that a hosted deployment or publication has occurred.
4
 
5
  Files are processed only for the requested job, not collected for our own
6
+ training, analytics or public examples. Inputs remain in the browser until Run or an explicit bucket save.
7
  The interface supports native CPU and, on a ZeroGPU deployment, an explicitly
8
  selected GPU job. CPU requests do not acquire a GPU lease.
9
 
 
61
  the tab loses them. HF handles password entry and authorization. The callback
62
  briefly carries a one-use authorization code and state in its URL; the app clears
63
  that URL before loading the OAuth helper. HF/proxy infrastructure handling is outside this
64
+ application's deletion guarantee. The OAuth flow does not request email, billing, repository access or bucket scopes. Optional bucket operations use the separate user-supplied credential described below.
 
65
 
66
  A non-GPU queue check forwards the visitor bearer credential to HF's fixed
67
  userinfo endpoint and returns only the username, PRO flag, and authentication
 
76
  The browser retains its received result in page memory so Save received result
77
  again can retry a local save without another server retrieval. Discard browser
78
  result clears the app's reference; closing or refreshing the page loses it.
79
+ Optional bucket access is separate from HF sign-in. The user enters a fine-grained token for an existing private bucket; it remains in page memory and is sent with the requested bundle through this app server only for explicit Connect, Save, Refresh or Restore actions. Training and result deletion do not require a bucket connection.
80
  The service uses one result-owning process. HF framework and proxy behavior may have retention outside this app, so this is
81
  an application-level near-zero-retention design, not a provider-wide ZDR claim.
82
 
 
104
  Network inspection, creation and editing take place in browser memory. Applying
105
  an edited network selects it as the next run's input; this alone sends nothing
106
  to the server. Exporting saves a new file, not the original source. No training
107
+ history is attributed to freshly created or edited weights.
 
 
108
 
109
 
110
  ## Optional public Steam leaderboard check
 
114
  The app reads the public Steam Community track index and matching board XML without a Steam login or publisher key. It resolves exact catalog reference labels against that live index; an absent or ambiguous mapping is unavailable. The app retains only a short-lived in-process cache of public board rank/time rows (up to 60 seconds) and the public index mapping (up to five minutes). Player names, Steam IDs and raw XML are discarded after parsing. The comparison request and result are not stored in the job result store or recipe library.
115
 
116
  The displayed value is an insertion-position estimate against the fetched raw global board, at millisecond precision. The board may contain entries for different vehicle types; the estimate is not vehicle-filtered, an official rank, a submitted score, or proof that replica timing matches the Steam game. The check runs in the background with an eight-second browser deadline. It cannot delay result availability or download; a timeout leaves the completed result intact. Steam's community page may change or become unavailable, in which case the app reports the check as unavailable.
117
+
118
+
119
+ ## Optional private bucket storage
120
+
121
+ Bucket storage starts disconnected and never saves automatically. The user enters an existing private bucket ID and a separate fine-grained HF token, then explicitly selects Connect, Save, Refresh saved bundles or Restore. The token is held in this page's memory, sent in the Authorization header only to the app's fixed bucket routes, and cleared on disconnect, after the app receives an expired/denied response, or on page close. It is not the HF OAuth identity token and is not stored in localStorage, recipes, portable bundles, run receipts, URLs or application logs.
122
+
123
+ For an explicit operation, the selected bundle and the bucket token pass through this app server to the user's private bucket. The storage bridge uses a short-lived worker and temporary cache, removes temporary files on completion or failure, and does not keep application copies after the operation. The saved object remains in the user's bucket under the app-owned prefix until the user manages it through another authorized tool; this app does not expose bucket deletion, overwriting, creation, or visibility changes. A failed save or restore leaves the page's received result and ordinary training controls available.
124
+
125
+ These are application-level handling rules, not a guarantee about HF, network, proxy or hosting-provider logs and retention. They do not establish provider-wide zero data retention or secure physical erasure.
FRONTEND_SOURCE.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
3
  "source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8",
4
- "output_sha256": "ef759a06305e94edb5ed0e49a587db8c39e3833ec2e02a1bec441c0287c385fd",
5
  "scope": "Frontend only; no engine, private data, track assets, API calls or storage copied",
6
- "status": "Four vehicle UI and Type plumbing; native vehicle support pending runtime qualification; optional Steam comparison",
7
  "track_atlas_sha256": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
8
- "client_sha256": "f227469481a6a393f78636fde214473f54219c8af016050b35c6ef1af86eff34",
9
  "track_map_sha256": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0"
10
  }
 
1
  {
2
  "source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
3
  "source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8",
4
+ "output_sha256": "f6834f72d072bbfea28af7f1a686c5439be06c5c0848873ab6723745ea330051",
5
  "scope": "Frontend only; no engine, private data, track assets, API calls or storage copied",
6
+ "status": "Local v1.1 hosted bucket candidate: explicit private bucket connect/save/list/restore plus local portable bundles; not deployed",
7
  "track_atlas_sha256": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
8
+ "client_sha256": "b47fc63b34fc68a7d3eb43317f574a16ebded99d296d7aea3d2f6f47a86ebc6b",
9
  "track_map_sha256": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0"
10
  }
RELEASE_MANIFEST.json CHANGED
@@ -4,15 +4,16 @@
4
  ".gitattributes": "c2ad16718fc51c888d32efc88c8f6e23ebe5fcf6f48e2954f896ba9770f24d72",
5
  "BETA_TESTING.md": "27e2492f9fe87cc04f52de2974b685b44041f7de9fa1155926b66c436f0bc613",
6
  "BINARY_PROVENANCE.json": "825d98f9ae91d88ee08143b11d1af9dd6f889c37f68dbea24163f718759dda3e",
 
7
  "CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
8
- "DATA_POLICY.md": "d5a4066759c823a34ad68a04cf7d9bfa2e39441b24b9466984d06fdb38eb566c",
9
  "FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
10
- "FRONTEND_SOURCE.json": "165fc6a32b0c132e73679ad7e69a1b30376ea0ac6a789283b22206e98d7212c9",
11
  "GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
12
  "IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
13
  "LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
14
  "README.md": "b2e1d4a21c1ffd0d652a6e2448284b358f4e42dbfa8a0af6b1842d55c2f7c954",
15
- "STORAGE_BOUNDARY.md": "9a1ffb5854230690dba3f0895f9a8e570716de47be2c553de13a6892dd740015",
16
  "TRACK_LIBRARY.md": "66b3656ed84f137d5b6116a0075731cd467818f555cca0969084aff67875319f",
17
  "UPSTREAM.json": "003235bbdf5e966561797fbe3d8bfc65c7c43d4a887fa54c3e6a96fb7c0c6938",
18
  "app.py": "162486abd6ac7c9281c8e75c15e33373e0fddba2baf6a799dfd54d333fd575d2",
@@ -25,6 +26,7 @@
25
  "bin/altd-sim": "0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166",
26
  "bin/extract_saved_track": "3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a",
27
  "bin/libaltd_gpu.so": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
 
28
  "build_info.py": "93d331c0405cd10076a544f87761bb4a00f6556bbbdab8abc12e070c4661bd78",
29
  "builtin_tracks.py": "2ecc2b1d00fe15701b0271d71e1e68d2e7e59c596330879494055f9b2a99c242",
30
  "builtin_tracks/catalog.json": "65006e70a87a5a53e3738166b0895bc4db9f99767dd891d917b68029abb4e80a",
@@ -56,14 +58,15 @@
56
  "builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
57
  "builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
58
  "builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
59
- "client.js": "f227469481a6a393f78636fde214473f54219c8af016050b35c6ef1af86eff34",
60
  "delivery.py": "07453999c3831a16e8b1fcc2b4ae5fb28beb7d3952a3fae8de65d16eb6f11e5b",
61
  "gpu_tickets.py": "b81aadb5ed42cec9212d44da7e7bbb7b413c5bd517383cac0b49ec9b4d659c92",
62
  "hf-auth.js": "d5cf17b961d1f2efa9d4648d33ba86a148eff0d542c74a121710c4df35c92936",
63
  "hf_identity.py": "6993d0238ada835606dbdc3275c17a3fe5a5cac5ad2156ab78230acf2d051eba",
64
- "index.html": "ef759a06305e94edb5ed0e49a587db8c39e3833ec2e02a1bec441c0287c385fd",
65
- "requirements.txt": "9b532747e51bd052e82090ece860d6dafe027407f4512dc74272c148ea104c22",
66
- "server.py": "e00369e6840f0cab5350824e95fc0577ae59524d87ad0a34c32795708be17662",
 
67
  "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
68
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
69
  "track-map.js": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0",
 
4
  ".gitattributes": "c2ad16718fc51c888d32efc88c8f6e23ebe5fcf6f48e2954f896ba9770f24d72",
5
  "BETA_TESTING.md": "27e2492f9fe87cc04f52de2974b685b44041f7de9fa1155926b66c436f0bc613",
6
  "BINARY_PROVENANCE.json": "825d98f9ae91d88ee08143b11d1af9dd6f889c37f68dbea24163f718759dda3e",
7
+ "BUCKET_BUNDLE.md": "34dfebb783ca61b5b362ca9b2d936e19c87ea440f87558a8a5cfcb11ce1fdd30",
8
  "CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
9
+ "DATA_POLICY.md": "015ba656568b956f058359d141bd6adb9e8b7fd07bcad50c10c132455c7a4a19",
10
  "FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
11
+ "FRONTEND_SOURCE.json": "1adfdbd649fb75b9ce80d58134c182b19e4b1877b40130efac0767a80947b6cd",
12
  "GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
13
  "IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
14
  "LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
15
  "README.md": "b2e1d4a21c1ffd0d652a6e2448284b358f4e42dbfa8a0af6b1842d55c2f7c954",
16
+ "STORAGE_BOUNDARY.md": "e0df12d422a3e9bcc85468fcb6af202fd65551cba6f84d2c8f70f03e8e8cc55e",
17
  "TRACK_LIBRARY.md": "66b3656ed84f137d5b6116a0075731cd467818f555cca0969084aff67875319f",
18
  "UPSTREAM.json": "003235bbdf5e966561797fbe3d8bfc65c7c43d4a887fa54c3e6a96fb7c0c6938",
19
  "app.py": "162486abd6ac7c9281c8e75c15e33373e0fddba2baf6a799dfd54d333fd575d2",
 
26
  "bin/altd-sim": "0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166",
27
  "bin/extract_saved_track": "3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a",
28
  "bin/libaltd_gpu.so": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
29
+ "bucket_storage.py": "6dedcdc6391be574805208e36c4f553e1c3e6faf41c68783c4c825e093dc9d94",
30
  "build_info.py": "93d331c0405cd10076a544f87761bb4a00f6556bbbdab8abc12e070c4661bd78",
31
  "builtin_tracks.py": "2ecc2b1d00fe15701b0271d71e1e68d2e7e59c596330879494055f9b2a99c242",
32
  "builtin_tracks/catalog.json": "65006e70a87a5a53e3738166b0895bc4db9f99767dd891d917b68029abb4e80a",
 
58
  "builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
59
  "builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
60
  "builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
61
+ "client.js": "b47fc63b34fc68a7d3eb43317f574a16ebded99d296d7aea3d2f6f47a86ebc6b",
62
  "delivery.py": "07453999c3831a16e8b1fcc2b4ae5fb28beb7d3952a3fae8de65d16eb6f11e5b",
63
  "gpu_tickets.py": "b81aadb5ed42cec9212d44da7e7bbb7b413c5bd517383cac0b49ec9b4d659c92",
64
  "hf-auth.js": "d5cf17b961d1f2efa9d4648d33ba86a148eff0d542c74a121710c4df35c92936",
65
  "hf_identity.py": "6993d0238ada835606dbdc3275c17a3fe5a5cac5ad2156ab78230acf2d051eba",
66
+ "index.html": "f6834f72d072bbfea28af7f1a686c5439be06c5c0848873ab6723745ea330051",
67
+ "portable_bundle.py": "933915422905883e74081fa073a8024024d3d92662bf653f2174c212fa6cb5da",
68
+ "requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
69
+ "server.py": "209936a7f21c290514b1dda68f3c9121366df907c9f7b9ffb08a17c9941ec29f",
70
  "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
71
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
72
  "track-map.js": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0",
STORAGE_BOUNDARY.md CHANGED
@@ -16,7 +16,7 @@ same settings contract is used independently of the selected storage policy.
16
  | Received candidate / receipt | Browser page memory | Explicit discard, replacement, page reload/close; explicit export to disk |
17
  | Optional HF identity / token | Browser page memory, separate from job/recipe storage | Sign out, expiry, or page reload/close; no app auth cookie or server session database |
18
  | One-use HF PKCE verifier/nonce | Tab-local sessionStorage for the sign-in redirect only | Five-minute acceptance limit; removed on return/error/fresh app load or tab close; never stores access tokens or files |
19
- | User bucket | Not implemented | Must require an explicit opt-in adapter and separate retention policy |
20
 
21
  `recipes.cjs` has no host/network dependency. Its `createStore(storage)` receives
22
  only getItem/setItem/removeItem capabilities. A future bucket adapter must keep
@@ -25,8 +25,7 @@ bytes to recipe JSON. A public profile must not inherit private persistent
25
  artifact storage implicitly. Server cleanup must never clear the recipe library;
26
  clearing recipes must never delete a server job, bucket, or downloaded file.
27
 
28
- The current app has no bucket credentials, persistent HF identity storage, telemetry, or
29
- automatic recipe upload. Browser-local storage is site-specific and may be
30
  blocked by browser privacy settings. A local runner and an HF iframe do not
31
  automatically share their library; explicit JSON export/import is the bridge.
32
 
 
16
  | Received candidate / receipt | Browser page memory | Explicit discard, replacement, page reload/close; explicit export to disk |
17
  | Optional HF identity / token | Browser page memory, separate from job/recipe storage | Sign out, expiry, or page reload/close; no app auth cookie or server session database |
18
  | One-use HF PKCE verifier/nonce | Tab-local sessionStorage for the sign-in redirect only | Five-minute acceptance limit; removed on return/error/fresh app load or tab close; never stores access tokens or files |
19
+ | User private bucket | Separate optional connection with explicit Save, Refresh and Restore | Only user-selected bundles are sent through the app server; the object remains in the user bucket until managed elsewhere; this app exposes no delete/overwrite/create/visibility operation |
20
 
21
  `recipes.cjs` has no host/network dependency. Its `createStore(storage)` receives
22
  only getItem/setItem/removeItem capabilities. A future bucket adapter must keep
 
25
  artifact storage implicitly. Server cleanup must never clear the recipe library;
26
  clearing recipes must never delete a server job, bucket, or downloaded file.
27
 
28
+ The app has no persistent HF identity or bucket credential storage, telemetry, or automatic recipe/bundle upload. Bucket operations are explicit and application-level cleanup does not make provider-wide retention claims. Browser-local storage is site-specific and may be
 
29
  blocked by browser privacy settings. A local runner and an HF iframe do not
30
  automatically share their library; explicit JSON export/import is the bridge.
31
 
bucket_storage.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Isolated, user-token-only Hugging Face Bucket operations for the API."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import uuid
15
+
16
+ from portable_bundle import BundleError, MAX_BUNDLE, load_bundle
17
+
18
+ HF_ENDPOINT = "https://huggingface.co"
19
+ MAX_REQUEST = 2 * MAX_BUNDLE + 64 * 1024
20
+ MAX_CHILD_INPUT = 4 * ((MAX_BUNDLE + 2) // 3) + 64 * 1024
21
+ MAX_CHILD_OUTPUT = 128 * 1024
22
+ CHILD_TIMEOUT_SECONDS = 55
23
+ MAX_LIST_SCAN = 50
24
+ APP_KEY = re.compile(r"^ald-exports/([0-9a-f]{32}|[0-9a-f]{64})/bundle\.json$")
25
+ BUCKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}/[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
26
+
27
+
28
+ class BucketStorageError(Exception):
29
+ """Safe error category; never carries upstream text or credentials."""
30
+
31
+ def __init__(self, category: str):
32
+ self.category = category
33
+ super().__init__(category)
34
+
35
+
36
+ def valid_bucket_id(value: object) -> bool:
37
+ return isinstance(value, str) and bool(BUCKET_ID.fullmatch(value))
38
+
39
+
40
+ def valid_bundle_key(value: object) -> bool:
41
+ return isinstance(value, str) and bool(APP_KEY.fullmatch(value))
42
+
43
+
44
+ def _child_environment(cache_root: Path) -> dict[str, str]:
45
+ # Do not inherit host HF tokens, endpoints, cache locations, or login state.
46
+ env = {
47
+ "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
48
+ "LANG": "C.UTF-8",
49
+ "LC_ALL": "C.UTF-8",
50
+ "TMPDIR": str(cache_root),
51
+ "HF_HOME": str(cache_root / "hf"),
52
+ "HF_HUB_CACHE": str(cache_root / "hub-cache"),
53
+ "HF_XET_CACHE": str(cache_root / "xet-cache"),
54
+ "HF_XET_LOG_DEST": str(cache_root / "xet.log"),
55
+ "HF_HUB_DISABLE_TELEMETRY": "1",
56
+ "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1",
57
+ "HF_HUB_DISABLE_PROGRESS_BARS": "1",
58
+ "PYTHONNOUSERSITE": "1",
59
+ "ALD_BUCKET_CHILD": "1",
60
+ "ALD_BUCKET_TEMP": str(cache_root),
61
+ }
62
+ return env
63
+
64
+
65
+ def run_bucket_operation(operation: str, token: str, bucket_id: str, **args):
66
+ """Run one HF operation in an isolated process with a temporary Xet cache."""
67
+ if operation not in {"connect", "list", "save", "restore"}:
68
+ raise BucketStorageError("invalid_request")
69
+ if not isinstance(token, str) or not token or len(token) > 4096 or any(ch.isspace() or ord(ch) < 33 for ch in token):
70
+ raise BucketStorageError("unauthorized")
71
+ if not valid_bucket_id(bucket_id):
72
+ raise BucketStorageError("invalid_request")
73
+
74
+ try:
75
+ with tempfile.TemporaryDirectory(prefix="ald-bucket-") as temp_name:
76
+ temp = Path(temp_name)
77
+ payload = {"operation": operation, "token": token, "bucket_id": bucket_id, **args}
78
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
79
+ if len(raw) > MAX_CHILD_INPUT:
80
+ raise BucketStorageError("invalid_request")
81
+ proc = subprocess.run(
82
+ [sys.executable, str(Path(__file__).resolve()), "--child"],
83
+ input=raw,
84
+ stdout=subprocess.PIPE,
85
+ stderr=subprocess.DEVNULL,
86
+ timeout=CHILD_TIMEOUT_SECONDS,
87
+ check=False,
88
+ shell=False,
89
+ env=_child_environment(temp),
90
+ )
91
+ if len(proc.stdout) > MAX_CHILD_OUTPUT:
92
+ raise BucketStorageError("storage_unavailable")
93
+ try:
94
+ result = json.loads(proc.stdout.decode("utf-8"))
95
+ except (UnicodeDecodeError, json.JSONDecodeError):
96
+ raise BucketStorageError("storage_unavailable") from None
97
+ if proc.returncode != 0 or not isinstance(result, dict):
98
+ category = result.get("error") if isinstance(result, dict) else None
99
+ if category in {"unauthorized", "forbidden", "not_found", "invalid_request", "storage_unavailable"}:
100
+ raise BucketStorageError(category)
101
+ raise BucketStorageError("storage_unavailable")
102
+ if operation in {"save", "restore"}:
103
+ expected_name = "readback.json" if operation == "save" else "restore.json"
104
+ file_path = temp / expected_name
105
+ if not file_path.is_file() or file_path.is_symlink():
106
+ raise BucketStorageError("storage_unavailable")
107
+ if file_path.stat().st_size > MAX_BUNDLE:
108
+ raise BucketStorageError("invalid_request")
109
+ content = file_path.read_bytes()
110
+ if operation == "save":
111
+ if hashlib.sha256(content).hexdigest() != result.get("sha256"):
112
+ raise BucketStorageError("storage_unavailable")
113
+ result["verified_bytes"] = content
114
+ else:
115
+ try:
116
+ load_bundle(content)
117
+ except BundleError:
118
+ raise BucketStorageError("invalid_bundle") from None
119
+ result["bundle_bytes"] = content
120
+ return result
121
+ except subprocess.TimeoutExpired:
122
+ raise BucketStorageError("timeout") from None
123
+ except BucketStorageError:
124
+ raise
125
+ except Exception:
126
+ raise BucketStorageError("storage_unavailable") from None
127
+
128
+
129
+ def _verify_bucket(api, token: str, bucket_id: str):
130
+ try:
131
+ user = api.whoami(token=token, cache=False)
132
+ except Exception:
133
+ raise BucketStorageError("unauthorized") from None
134
+ username = user.get("name") if isinstance(user, dict) else None
135
+ if not isinstance(username, str) or not username or bucket_id.split("/", 1)[0] != username:
136
+ raise BucketStorageError("forbidden")
137
+ try:
138
+ info = api.bucket_info(bucket_id, token=token)
139
+ except Exception:
140
+ # Inaccessible and nonexistent buckets deliberately share one response.
141
+ raise BucketStorageError("forbidden") from None
142
+ if getattr(info, "id", None) != bucket_id or getattr(info, "private", None) is not True:
143
+ raise BucketStorageError("forbidden")
144
+ return username
145
+
146
+
147
+ def _download_descriptor(api, token: str, bucket_id: str, key: str, destination: Path):
148
+ from huggingface_hub._buckets import BucketFile
149
+
150
+ metadata = api.get_bucket_file_metadata(bucket_id, key, token=token)
151
+ size = getattr(metadata, "size", None)
152
+ xet_data = getattr(metadata, "xet_file_data", None)
153
+ xet_hash = getattr(xet_data, "file_hash", None)
154
+ if type(size) is not int or size < 0 or size > MAX_BUNDLE or not isinstance(xet_hash, str) or not xet_hash:
155
+ raise BucketStorageError("invalid_request")
156
+ descriptor = BucketFile(
157
+ type="file",
158
+ path=key,
159
+ size=size,
160
+ xetHash=xet_hash,
161
+ mtime=None,
162
+ uploadedAt=None,
163
+ )
164
+ try:
165
+ api.download_bucket_files(
166
+ bucket_id,
167
+ [(descriptor, destination)],
168
+ raise_on_missing_files=True,
169
+ token=token,
170
+ )
171
+ except Exception:
172
+ raise BucketStorageError("storage_unavailable") from None
173
+ if not destination.is_file() or destination.is_symlink() or destination.stat().st_size != size:
174
+ raise BucketStorageError("storage_unavailable")
175
+ return size, xet_hash
176
+
177
+
178
+ def _perform_operation(request: dict, api, temp: Path) -> dict:
179
+ operation = request["operation"]
180
+ token = request["token"]
181
+ bucket_id = request["bucket_id"]
182
+ username = _verify_bucket(api, token, bucket_id)
183
+ if operation == "connect":
184
+ return {"status": "connected", "bucket_id": bucket_id, "username": username, "private": True}
185
+ if operation == "list":
186
+ items = []
187
+ truncated = False
188
+ for index, item in enumerate(api.list_bucket_tree(bucket_id, prefix="ald-exports", recursive=True, token=token)):
189
+ if index >= MAX_LIST_SCAN:
190
+ truncated = True
191
+ break
192
+ key = getattr(item, "path", None)
193
+ size = getattr(item, "size", None)
194
+ match = APP_KEY.fullmatch(key) if isinstance(key, str) else None
195
+ if match and type(size) is int and 0 <= size <= MAX_BUNDLE:
196
+ uploaded = getattr(item, "uploaded_at", None) or getattr(item, "mtime", None)
197
+ if isinstance(uploaded, datetime):
198
+ if uploaded.tzinfo is None:
199
+ uploaded = uploaded.replace(tzinfo=timezone.utc)
200
+ date_label = uploaded.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
201
+ else:
202
+ date_label = "Saved bundle"
203
+ items.append({"key": key, "bytes": size, "label": f"{date_label} · {match.group(1)[:8]}"})
204
+ return {"items": items, "truncated": truncated}
205
+ if operation == "save":
206
+ encoded = request.get("bundle_base64")
207
+ if not isinstance(encoded, str) or len(encoded) > 4 * ((MAX_BUNDLE + 2) // 3):
208
+ raise BucketStorageError("invalid_request")
209
+ try:
210
+ bundle = base64.b64decode(encoded, validate=True)
211
+ load_bundle(bundle)
212
+ except Exception:
213
+ raise BucketStorageError("invalid_bundle") from None
214
+ digest = hashlib.sha256(bundle).hexdigest()
215
+ prefix = f"ald-exports/{uuid.uuid4().hex}"
216
+ key = f"{prefix}/bundle.json"
217
+ source = temp / "upload"
218
+ source.mkdir(mode=0o700)
219
+ (source / "bundle.json").write_bytes(bundle)
220
+ try:
221
+ api.sync_bucket(
222
+ str(source), f"hf://buckets/{bucket_id}/{prefix}",
223
+ delete=False, ignore_existing=True, quiet=True, token=token,
224
+ )
225
+ except Exception:
226
+ raise BucketStorageError("storage_unavailable") from None
227
+ readback = temp / "readback.json"
228
+ size, _ = _download_descriptor(api, token, bucket_id, key, readback)
229
+ if size != len(bundle) or hashlib.sha256(readback.read_bytes()).hexdigest() != digest:
230
+ raise BucketStorageError("storage_unavailable")
231
+ return {"status": "saved_verified", "bucket_id": bucket_id, "key": key, "bytes": size, "sha256": digest}
232
+ key = request.get("key")
233
+ if not valid_bundle_key(key):
234
+ raise BucketStorageError("invalid_request")
235
+ restore = temp / "restore.json"
236
+ size, _ = _download_descriptor(api, token, bucket_id, key, restore)
237
+ try:
238
+ bundle = restore.read_bytes()
239
+ load_bundle(bundle)
240
+ except Exception:
241
+ raise BucketStorageError("invalid_bundle") from None
242
+ return {"status": "restored", "key": key, "bytes": size}
243
+
244
+
245
+ def _child_main() -> int:
246
+ if os.environ.get("ALD_BUCKET_CHILD") != "1":
247
+ return 2
248
+ raw = sys.stdin.buffer.read(MAX_CHILD_INPUT + 1)
249
+ if len(raw) > MAX_CHILD_INPUT:
250
+ print(json.dumps({"error": "invalid_request"}))
251
+ return 2
252
+ try:
253
+ request = json.loads(raw.decode("utf-8"))
254
+ if not isinstance(request, dict) or request.get("operation") not in {"connect", "list", "save", "restore"}:
255
+ raise BucketStorageError("invalid_request")
256
+ operation = request["operation"]
257
+ token = request.get("token")
258
+ bucket_id = request.get("bucket_id")
259
+ if not isinstance(token, str) or not token or len(token) > 4096 or any(c.isspace() or ord(c) < 33 for c in token):
260
+ raise BucketStorageError("unauthorized")
261
+ if not valid_bucket_id(bucket_id):
262
+ raise BucketStorageError("invalid_request")
263
+ from huggingface_hub import HfApi
264
+
265
+ api = HfApi(endpoint=HF_ENDPOINT, token=False)
266
+ temp = Path(os.environ["ALD_BUCKET_TEMP"])
267
+ result = _perform_operation(request, api, temp)
268
+ print(json.dumps(result, separators=(",", ":")))
269
+ return 0
270
+ except BucketStorageError as exc:
271
+ print(json.dumps({"error": exc.category}, separators=(",", ":")))
272
+ return 1
273
+ except Exception:
274
+ print(json.dumps({"error": "storage_unavailable"}, separators=(",", ":")))
275
+ return 1
276
+
277
+
278
+ if __name__ == "__main__" and len(sys.argv) == 2 and sys.argv[1] == "--child":
279
+ raise SystemExit(_child_main())
client.js CHANGED
@@ -15,6 +15,16 @@ let runReceipt = null;
15
  let appBuild = null;
16
  let leaderboardRequest = null;
17
  let leaderboardGeneration = 0;
 
 
 
 
 
 
 
 
 
 
18
  // Display context affects quota guidance, never whether GPU can be selected.
19
  const hostedStandalone = location.hostname.endsWith('.hf.space') && window.self === window.top;
20
  const say = text => { $('status').textContent = text; };
@@ -28,24 +38,143 @@ const trackLibrary=window.TrackLibrary?.mount({document,fetch,digest:sha});
28
  window.TrackMap?.mount({document,fetch,picker:trackLibrary});
29
  const trackSource=()=>trackLibrary?trackLibrary.file():Promise.resolve($('track').files[0]);
30
  function buttons() {
31
- trackLibrary?.setBusy(busy || !!job);
32
- window.HFAuth?.setBusy(busy || !!job);
33
- window.setTrainingBusy?.(busy || !!job);
34
- window.setEditorBusy?.(busy || !!job);
35
- $('run').disabled = busy || !!job || !capabilitiesReady || !networkSource() || ($('backend').value==='gpu'&&!gpuAvailable);
36
- $('backend').disabled = busy || !!job || !capabilitiesReady;
 
37
  $('download').disabled = busy || !job;
38
  $('erase').disabled = busy || !job;
39
  $('keepServer').disabled = busy || !!job;
40
  $('saveLocal').disabled = busy || !localResult;
41
  $('forgetLocal').disabled = busy || !localResult;
42
- $('track').disabled=busy || !!job;
43
- $('network').disabled=busy || !!job;
44
- if($('batch_start'))$('batch_start').disabled=busy || !!job || !capabilitiesReady || ($('backend').value==='gpu'&&!gpuAvailable);
45
  if($('batch_stop'))$('batch_stop').disabled=!batchActive || batchStopRequested;
46
  if($('run_export'))$('run_export').disabled=busy || !runReceipt;
47
  if($('batch_export'))$('batch_export').disabled=busy || !batchReceipt;
 
 
 
 
 
 
 
 
 
 
48
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  $('backend').addEventListener('change',buttons);
50
  $('leaderboard_opt_in')?.addEventListener('change',()=>{
51
  if(!$('leaderboard_opt_in').checked&&leaderboardRequest){
@@ -87,7 +216,7 @@ const headers = () => ({'X-Job-Key':job.key});
87
  const networkSource=()=>stagedNetwork || $('network').files[0];
88
  $('network').onchange=()=>{stagedNetwork=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';buttons();};
89
  window.TrainingClient={
90
- isBusy:()=>busy||!!job,
91
  useNetwork(file){
92
  if(busy||job)throw Error('Finish or delete the current result before changing the network');
93
  stagedNetwork=file;$('network').value='';
@@ -367,3 +496,155 @@ if($('run_export'))$('run_export').onclick=()=>{
367
  const url=URL.createObjectURL(new Blob([JSON.stringify(runReceipt,null,2)],{type:'application/json'}));
368
  const a=document.createElement('a');a.href=url;a.download='ald-run-receipt.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
369
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  let appBuild = null;
16
  let leaderboardRequest = null;
17
  let leaderboardGeneration = 0;
18
+ let portableReceipt = null;
19
+ let bundleRestoring = false;
20
+ let bucketToken = null;
21
+ let bucketId = null;
22
+ let bucketUsername = null;
23
+ let bucketItems = [];
24
+ let bucketOperation = false;
25
+ let bucketOperationId = 0;
26
+ let bucketEpoch = 0;
27
+ let bundleRestoreId = 0;
28
  // Display context affects quota guidance, never whether GPU can be selected.
29
  const hostedStandalone = location.hostname.endsWith('.hf.space') && window.self === window.top;
30
  const say = text => { $('status').textContent = text; };
 
38
  window.TrackMap?.mount({document,fetch,picker:trackLibrary});
39
  const trackSource=()=>trackLibrary?trackLibrary.file():Promise.resolve($('track').files[0]);
40
  function buttons() {
41
+ const unavailable=busy||!!job||bundleRestoring||bucketOperation;
42
+ trackLibrary?.setBusy(unavailable);
43
+ window.HFAuth?.setBusy(unavailable);
44
+ window.setTrainingBusy?.(unavailable);
45
+ window.setEditorBusy?.(unavailable);
46
+ $('run').disabled = unavailable || !capabilitiesReady || !networkSource() || ($('backend').value==='gpu'&&!gpuAvailable);
47
+ $('backend').disabled = unavailable || !capabilitiesReady;
48
  $('download').disabled = busy || !job;
49
  $('erase').disabled = busy || !job;
50
  $('keepServer').disabled = busy || !!job;
51
  $('saveLocal').disabled = busy || !localResult;
52
  $('forgetLocal').disabled = busy || !localResult;
53
+ $('track').disabled=unavailable;
54
+ $('network').disabled=unavailable;
55
+ if($('batch_start'))$('batch_start').disabled=unavailable || !capabilitiesReady || ($('backend').value==='gpu'&&!gpuAvailable);
56
  if($('batch_stop'))$('batch_stop').disabled=!batchActive || batchStopRequested;
57
  if($('run_export'))$('run_export').disabled=busy || !runReceipt;
58
  if($('batch_export'))$('batch_export').disabled=busy || !batchReceipt;
59
+ if($('bundle_export'))$('bundle_export').disabled=unavailable;
60
+ if($('bundle_import'))$('bundle_import').disabled=unavailable;
61
+ if($('bucket_connect'))$('bucket_connect').disabled=unavailable||!!bucketToken;
62
+ if($('bucket_id'))$('bucket_id').disabled=unavailable||!!bucketToken;
63
+ if($('bucket_token'))$('bucket_token').disabled=unavailable||!!bucketToken;
64
+ if($('bucket_disconnect'))$('bucket_disconnect').disabled=unavailable||!bucketToken;
65
+ if($('bucket_save'))$('bucket_save').disabled=unavailable||!bucketToken||(!networkSource()&&!localResult);
66
+ if($('bucket_refresh'))$('bucket_refresh').disabled=unavailable||!bucketToken;
67
+ if($('bucket_restore'))$('bucket_restore').disabled=unavailable||!bucketToken||!$('bucket_items')?.value;
68
+ if($('bucket_items'))$('bucket_items').disabled=unavailable||!bucketToken;
69
  }
70
+ function renderBucketConnection(){
71
+ const connected=!!bucketToken&&!!bucketId;
72
+ $('bucketConnectForm')?.toggleAttribute('hidden',connected);
73
+ $('bucketConnected')?.toggleAttribute('hidden',!connected);
74
+ if($('bucket_account'))$('bucket_account').textContent=connected?'Connected as '+bucketUsername+' · '+bucketId:'';
75
+ const select=$('bucket_items');
76
+ if(select){
77
+ const options=[];
78
+ if(!bucketItems.length){const option=document.createElement('option');option.value='';option.textContent='Refresh to list saved bundles';options.push(option);}
79
+ for(const item of bucketItems){const option=document.createElement('option');option.value=item.key;option.textContent=item.label+' · '+item.bytes+' bytes';options.push(option);}
80
+ select.replaceChildren(...options);
81
+ if(options.length)select.value='';
82
+ }
83
+ buttons();
84
+ }
85
+ const bucketKeyValid=key=>typeof key==='string'&&/^ald-exports\/(?:[0-9a-f]{32}|[0-9a-f]{64})\/bundle\.json$/.test(key);
86
+ function bucketSnapshot(){return {token:bucketToken,id:bucketId,epoch:bucketEpoch};}
87
+ function bucketSnapshotCurrent(snapshot){return !!snapshot?.token&&snapshot.epoch===bucketEpoch&&snapshot.token===bucketToken&&snapshot.id===bucketId;}
88
+ function clearBucketConnection(snapshot=null){
89
+ if(snapshot&&!bucketSnapshotCurrent(snapshot))return false;
90
+ bucketEpoch++;
91
+ bucketOperationId++;bucketOperation=false;
92
+ bundleRestoreId++;bundleRestoring=false;
93
+ bucketToken=null;bucketId=null;bucketUsername=null;bucketItems=[];
94
+ if($('bucket_token'))$('bucket_token').value='';
95
+ if($('bucket_id'))$('bucket_id').value='';
96
+ if($('bucket_status'))$('bucket_status').textContent='Bucket connection cleared. Reconnect to continue.';
97
+ renderBucketConnection();
98
+ return true;
99
+ }
100
+ async function bucketRequest(endpoint,body,snapshot){
101
+ if(!['connect','list','save','restore'].includes(endpoint))throw Error('Unsupported bucket action');
102
+ const current=()=>endpoint==='connect'
103
+ ? snapshot.epoch===bucketEpoch&&bucketToken===null&&bucketId===null
104
+ : bucketSnapshotCurrent(snapshot);
105
+ const response=await fetch('/api/bucket/'+endpoint,{method:'POST',cache:'no-store',credentials:'omit',headers:{'Content-Type':'application/json','Authorization':'Bearer '+snapshot.token},body:JSON.stringify(body)});
106
+ if(!current())throw Error('Bucket connection changed; the old response was ignored');
107
+ if(response.status===401||response.status===403){if(endpoint!=='connect')clearBucketConnection(snapshot);throw Error('Bucket access expired or was denied. Reconnect to continue.');}
108
+ if(!response.ok)throw Error('Bucket action failed. Check the bucket settings and try again.');
109
+ return response;
110
+ }
111
+ function beginBucketOperation(){bucketOperation=true;const id=++bucketOperationId;buttons();return id;}
112
+ function endBucketOperation(id){if(id!==bucketOperationId)return;bucketOperation=false;buttons();}
113
+ if($('bucket_connect'))$('bucket_connect').onclick=async()=>{
114
+ const id=$('bucket_id').value.trim(),token=$('bucket_token').value.trim();
115
+ if(!id||!token){$('bucket_status').textContent='Enter the existing private bucket ID and its fine-grained token.';return;}
116
+ const epoch=++bucketEpoch,snapshot={token,id,epoch},operationId=beginBucketOperation();$('bucket_status').textContent='Checking private bucket access…';
117
+ try{
118
+ const response=await bucketRequest('connect',{bucket_id:id},snapshot),result=await response.json();
119
+ if(epoch!==bucketEpoch||bucketToken!==null||bucketId!==null)return;
120
+ if(result?.status!=='connected'||result.bucket_id!==id||result.private!==true||typeof result.username!=='string'||!result.username.trim()||result.username.length>200)throw Error('Private bucket access could not be verified.');
121
+ bucketToken=token;bucketId=id;bucketUsername=result.username;bucketItems=[];$('bucket_token').value='';
122
+ $('bucket_status').textContent='Connected. Refresh saved bundles or choose Save; nothing is uploaded automatically.';
123
+ renderBucketConnection();
124
+ }catch(error){if(epoch===bucketEpoch){bucketToken=null;bucketId=null;bucketUsername=null;bucketItems=[];$('bucket_token').value='';$('bucket_status').textContent=error.message||'Bucket connection failed.';renderBucketConnection();}}
125
+ finally{if(epoch===bucketEpoch)endBucketOperation(operationId);}
126
+ };
127
+ if($('bucket_disconnect'))$('bucket_disconnect').onclick=()=>{
128
+ if(bucketOperation)return;
129
+ clearBucketConnection();$('bucket_status').textContent='Disconnected. The bucket token was cleared from this page.';
130
+ };
131
+ if($('bucket_refresh'))$('bucket_refresh').onclick=async()=>{
132
+ if(!bucketToken)return;
133
+ const snapshot=bucketSnapshot(),operationId=beginBucketOperation();$('bucket_status').textContent='Refreshing this app’s saved bundle list…';
134
+ try{
135
+ const response=await bucketRequest('list',{bucket_id:snapshot.id},snapshot),result=await response.json();
136
+ if(!bucketSnapshotCurrent(snapshot))return;
137
+ if(!Array.isArray(result?.items)||result.items.length>50||typeof result.truncated!=='boolean')throw Error('The saved bundle list was not valid.');
138
+ const checked=result.items.map(item=>{
139
+ if(!bucketKeyValid(item?.key)||!Number.isSafeInteger(item.bytes)||item.bytes<1||item.bytes>window.PortableBundle.LIMITS.bundle||typeof item.label!=='string'||!item.label.trim()||item.label.length>120)throw Error('The saved bundle list was not valid.');
140
+ return {key:item.key,bytes:item.bytes,label:item.label};
141
+ });
142
+ bucketItems=checked;renderBucketConnection();
143
+ $('bucket_status').textContent=checked.length?checked.length+' saved bundle'+(checked.length===1?'':'s')+' available.'+(result.truncated?' Listing limited; additional bundles may not be shown.':''):'No bundles saved by this app were found.';
144
+ }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent=error.message||'Could not refresh saved bundles.';}
145
+ finally{endBucketOperation(operationId);}
146
+ };
147
+ if($('bucket_items'))$('bucket_items').addEventListener('change',buttons);
148
+ if($('bucket_save'))$('bucket_save').onclick=async()=>{
149
+ if(!bucketToken)return;
150
+ const snapshot=bucketSnapshot(),operationId=beginBucketOperation();$('bucket_status').textContent='Building and verifying the selected bundle…';
151
+ try{
152
+ const {text}=await buildPortableBundle();
153
+ if(!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; no save was attempted.');
154
+ const bytes=new TextEncoder().encode(text),sha256=await window.PortableBundle.sha(bytes);
155
+ if(bytes.byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
156
+ const response=await bucketRequest('save',{bucket_id:snapshot.id,bundle:text},snapshot),result=await response.json();
157
+ if(!bucketSnapshotCurrent(snapshot))return;
158
+ if(result?.status!=='saved_verified'||result.bucket_id!==snapshot.id||!bucketKeyValid(result.key)||result.bytes!==bytes.byteLength||result.sha256!==sha256)throw Error('The app could not verify the saved bundle. Refresh the list before trying again; the remote save may have completed.');
159
+ $('bucket_status').textContent='Bundle saved and read back successfully. Refresh the list to see it. Your current result remains available for download.';
160
+ }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent=error.message||'Bundle save failed. Your current result remains available for download.';}
161
+ finally{endBucketOperation(operationId);}
162
+ };
163
+ if($('bucket_restore'))$('bucket_restore').onclick=async()=>{
164
+ if(!bucketToken)return;
165
+ const snapshot=bucketSnapshot(),key=$('bucket_items').value;
166
+ if(!bucketKeyValid(key)||!bucketItems.some(item=>item.key===key)){ $('bucket_status').textContent='Choose a listed saved bundle to restore.';return; }
167
+ const operationId=beginBucketOperation(),restoreId=++bundleRestoreId;bundleRestoring=true;buttons();$('bucket_status').textContent='Downloading and validating the selected bundle…';
168
+ try{
169
+ const response=await bucketRequest('restore',{bucket_id:snapshot.id,key},snapshot);
170
+ const text=await response.text();
171
+ if(!bucketSnapshotCurrent(snapshot))return;
172
+ if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
173
+ await restorePortableText(text,{lockHeld:true,isCurrent:()=>bucketSnapshotCurrent(snapshot)});
174
+ if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle restored in this page only. No run started and the existing result remains available.';
175
+ }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle not restored: '+(error.message||'validation failed')+'. Existing selections and results were left unchanged.';}
176
+ finally{if(restoreId===bundleRestoreId)bundleRestoring=false;endBucketOperation(operationId);}
177
+ };
178
  $('backend').addEventListener('change',buttons);
179
  $('leaderboard_opt_in')?.addEventListener('change',()=>{
180
  if(!$('leaderboard_opt_in').checked&&leaderboardRequest){
 
216
  const networkSource=()=>stagedNetwork || $('network').files[0];
217
  $('network').onchange=()=>{stagedNetwork=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';buttons();};
218
  window.TrainingClient={
219
+ isBusy:()=>busy||!!job||bundleRestoring,
220
  useNetwork(file){
221
  if(busy||job)throw Error('Finish or delete the current result before changing the network');
222
  stagedNetwork=file;$('network').value='';
 
496
  const url=URL.createObjectURL(new Blob([JSON.stringify(runReceipt,null,2)],{type:'application/json'}));
497
  const a=document.createElement('a');a.href=url;a.download='ald-run-receipt.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
498
  };
499
+ const bundleMessage=text=>{if($('bundle_status'))$('bundle_status').textContent=text;};
500
+ const bundleExact=(value,keys)=>{
501
+ if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Unexpected receipt fields');
502
+ };
503
+ const bundleFinite=value=>typeof value==='number'&&Number.isFinite(value);
504
+ function cleanRunReceipt(source){
505
+ if(!source||!source.report)return null;
506
+ const report=source.report,buildId=source.build_id??source.build?.build_id??report.build?.build_id;
507
+ if(source.version!==1||source.state!=='completed'||typeof buildId!=='string'||buildId.length>128||!['cpu','gpu'].includes(source.backend)||!/^[a-f0-9]{64}$/.test(source.output_sha256))return null;
508
+ const vehicleNames=['Formula','Rally','Snowmobile','Truck'];
509
+ if(!Number.isInteger(report.vehicle_type)||report.vehicle_type<0||report.vehicle_type>3||report.vehicle_name!==vehicleNames[report.vehicle_type]||
510
+ !Number.isInteger(report.generations_completed)||report.generations_completed<0||!Number.isInteger(report.generations_requested)||report.generations_requested<1||
511
+ !Number.isInteger(report.population)||report.population<1||!bundleFinite(report.elapsed_seconds)||report.elapsed_seconds<0||
512
+ !bundleFinite(report.best_score)||!bundleFinite(report.mean_score)||
513
+ !(report.best_lap_seconds===null||(bundleFinite(report.best_lap_seconds)&&report.best_lap_seconds>0))||
514
+ !(report.lapped_cars===null||(Number.isInteger(report.lapped_cars)&&report.lapped_cars>=0))||
515
+ !Number.isInteger(report.simulated_ticks)||report.simulated_ticks<0||!Number.isInteger(report.requested_ticks)||report.requested_ticks<1||report.candidate_validated!==true||
516
+ !['cpu','cuda'].includes(report.execution_backend)||report.execution_backend!==(source.backend==='gpu'?'cuda':'cpu'))return null;
517
+ try{
518
+ bundleExact(report.inputs,['track_sha256','network_sha256']);
519
+ if(!/^[a-f0-9]{64}$/.test(report.inputs.track_sha256)||!/^[a-f0-9]{64}$/.test(report.inputs.network_sha256))return null;
520
+ bundleExact(report.settings,Object.keys(window.RecipeLibrary.defaults));
521
+ const settings=window.RecipeLibrary.settings(report.settings);
522
+ return {version:1,kind:'run',state:'completed',build_id:buildId,backend:source.backend,output_sha256:source.output_sha256,
523
+ report:{vehicle_type:report.vehicle_type,vehicle_name:report.vehicle_name,generations_completed:report.generations_completed,
524
+ generations_requested:report.generations_requested,population:report.population,elapsed_seconds:report.elapsed_seconds,
525
+ best_score:report.best_score,mean_score:report.mean_score,best_lap_seconds:report.best_lap_seconds,lapped_cars:report.lapped_cars,
526
+ simulated_ticks:report.simulated_ticks,requested_ticks:report.requested_ticks,candidate_validated:true,
527
+ execution_backend:report.execution_backend,inputs:{track_sha256:report.inputs.track_sha256,network_sha256:report.inputs.network_sha256},settings}};
528
+ }catch{return null;}
529
+ }
530
+ function validatePortableReceipt(receipt,network,track){
531
+ bundleExact(receipt,['version','kind','state','build_id','backend','output_sha256','report']);
532
+ const report=receipt.report,reportKeys=['vehicle_type','vehicle_name','generations_completed','generations_requested','population','elapsed_seconds','best_score','mean_score','best_lap_seconds','lapped_cars','simulated_ticks','requested_ticks','candidate_validated','execution_backend','inputs','settings'];
533
+ bundleExact(report,reportKeys);
534
+ const clean=cleanRunReceipt(receipt);
535
+ if(receipt.version!==1||receipt.kind!=='run'||receipt.state!=='completed'||!clean)throw Error('Run receipt failed its allowlist or value checks');
536
+ const inputMatches=network.kind==='input'&&report.inputs.network_sha256===network.sha256;
537
+ const outputMatches=network.kind==='result'&&receipt.output_sha256===network.sha256;
538
+ if(!inputMatches&&!outputMatches)throw Error('Run receipt does not describe the bundled network');
539
+ if(track&&report.inputs.track_sha256!==track.sha256)throw Error('Run receipt does not describe the bundled track');
540
+ return clean;
541
+ }
542
+ async function validateNetworkBytes(bytes){
543
+ let decoded=bytes;
544
+ if(bytes[0]===31&&bytes[1]===139){
545
+ if(typeof DecompressionStream!=='function')throw Error('This browser cannot validate compressed .evolution files');
546
+ const reader=new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')).getReader(),chunks=[];let length=0;
547
+ try{while(true){const {value,done}=await reader.read();if(done)break;length+=value.byteLength;if(length>4*1024*1024)throw Error('Expanded network exceeds 4 MiB');chunks.push(value);}}
548
+ finally{reader.releaseLock();}
549
+ decoded=new Uint8Array(length);let at=0;for(const chunk of chunks){decoded.set(chunk,at);at+=chunk.byteLength;}
550
+ }
551
+ const value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(decoded));window.NetworkEditor.compatible(value);return value;
552
+ }
553
+ async function validateBundleTrack(track){
554
+ if(!track)return null;
555
+ const file=new File([track.bytes],'portable-track.track',{type:'application/octet-stream'});
556
+ const saved=await window.TrackMap.decode(file);
557
+ const response=await fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'});
558
+ if(!response.ok)throw Error('Track preview reference unavailable; bundle was not restored');
559
+ window.TrackMap.model(saved,await response.json());
560
+ return file;
561
+ }
562
+ async function buildPortableBundle(){
563
+ const api=window.PortableBundle;if(!api)throw Error('Portable bundle support is unavailable');
564
+ const inputNetwork=networkSource(),selected=inputNetwork||localResult;if(!selected)throw Error('Choose or download a network/result first');
565
+ const kind=inputNetwork?'input':'result',networkBytes=new Uint8Array(await selected.arrayBuffer());
566
+ await validateNetworkBytes(networkBytes);
567
+ const network=await api.artifact('evolution',kind,networkBytes),artifacts={selected_network:network};
568
+ let selectedTrack=null,trackInfo=null;
569
+ if($('bundle_track').checked){
570
+ const source=await trackSource();if(!source)throw Error('Choose a track before including it');
571
+ const bytes=new Uint8Array(await source.arrayBuffer());trackInfo=trackLibrary?.info()??{source:'upload'};
572
+ selectedTrack=await api.artifact('track','selected',bytes);
573
+ delete selectedTrack.kind;
574
+ if(trackInfo.source==='builtin')Object.assign(selectedTrack,{source:'builtin',track_id:trackInfo.id,display_name:trackInfo.display_name});
575
+ else selectedTrack.source='upload';
576
+ artifacts.selected_track=selectedTrack;
577
+ }
578
+ if($('bundle_recipe').checked){
579
+ const recipe=window.getPortableRecipe?.();if(recipe)artifacts.selected_recipe={version:1,slot:recipe.slot,name:recipe.name,settings:window.RecipeLibrary.settings(recipe.settings)};
580
+ }
581
+ let note='';
582
+ const receiptSource=runReceipt||portableReceipt;
583
+ if($('bundle_receipt').checked&&receiptSource){
584
+ const candidate=cleanRunReceipt(receiptSource);
585
+ const matches=candidate&&((kind==='result'&&candidate.output_sha256===network.sha256)||(kind==='input'&&candidate.report.inputs.network_sha256===network.sha256));
586
+ if(matches&&(!trackInfo||trackInfo.source!=='builtin'&&trackInfo.source!=='upload'||!selectedTrack||candidate.report.inputs.track_sha256===selectedTrack.sha256))artifacts.run_receipt=candidate;
587
+ else note=' The latest run receipt did not match this selected network/track and was omitted.';
588
+ }
589
+ const text=await api.pack(artifacts);
590
+ return {text,note};
591
+ }
592
+ async function exportPortableBundle(){
593
+ const {text,note}=await buildPortableBundle(),url=URL.createObjectURL(new Blob([text],{type:'application/json'}));
594
+ const a=document.createElement('a');a.href=url;a.download='ald-portable-bundle.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
595
+ bundleMessage('Portable bundle downloaded to this device. It has not been uploaded or saved by the app.'+note);
596
+ }
597
+ if($('bundle_export'))$('bundle_export').onclick=async()=>{
598
+ try{await exportPortableBundle();}catch(error){bundleMessage('Bundle not exported: '+error.message);}
599
+ };
600
+ async function restorePortableText(text,{lockHeld=false,isCurrent=null}={}){
601
+ if((bundleRestoring&&!lockHeld)||busy||job)throw Error('Finish the active run before restoring a bundle');
602
+ if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
603
+ const parsed=await window.PortableBundle.parse(text);
604
+ await validateNetworkBytes(parsed.network.bytes);
605
+ if(parsed.track){
606
+ if(parsed.track.metadata.source==='builtin'){
607
+ const entry=trackLibrary?.entries().find(e=>e.id===parsed.track.metadata.track_id);
608
+ if(!entry||entry.display_name!==parsed.track.metadata.display_name||entry.bytes!==parsed.track.bytes.byteLength||entry.sha256!==parsed.track.metadata.sha256)throw Error('Built-in track does not match this app’s catalog');
609
+ }
610
+ parsed.track.file=await validateBundleTrack(parsed.track);
611
+ }
612
+ if(parsed.recipe){bundleExact(parsed.recipe.settings,Object.keys(window.RecipeLibrary.defaults));parsed.recipe.settings=window.RecipeLibrary.settings(parsed.recipe.settings);}
613
+ if(parsed.receipt)parsed.receipt=validatePortableReceipt(parsed.receipt,parsed.network,parsed.track?.metadata);
614
+ let trackTransfer=null;
615
+ if(parsed.track?.metadata.source==='upload'){
616
+ if(typeof DataTransfer!=='function')throw Error('This browser cannot stage an uploaded track; no bundle data was applied');
617
+ trackTransfer=new DataTransfer();trackTransfer.items.add(parsed.track.file);
618
+ }
619
+ const networkFile=new File([parsed.network.bytes],'portable-network.evolution',{type:'application/octet-stream'});
620
+ // Recheck immediately before applying; a run or connection change during validation must not partially restore state.
621
+ if(busy||job)throw Error('A run started while the bundle was being validated; no bundle data was applied');
622
+ if(isCurrent&&!isCurrent())throw Error('Bucket connection changed while the bundle was being validated; no bundle data was applied');
623
+ if(parsed.track){
624
+ if(parsed.track.metadata.source==='builtin'){
625
+ const picker=$('trackPresetDrive');picker.value=parsed.track.metadata.track_id;picker.dispatchEvent(new Event('change',{bubbles:true}));
626
+ }else{
627
+ $('track').files=trackTransfer.files;$('track').dispatchEvent(new Event('change',{bubbles:true}));
628
+ }
629
+ }
630
+ window.TrainingClient.useNetwork(networkFile);
631
+ if(parsed.recipe)window.stagePortableRecipe(parsed.recipe);
632
+ portableReceipt=parsed.receipt||null;
633
+ $('bundle_receipt_download').disabled=!portableReceipt;
634
+ bundleMessage('Bundle restored in this page only. Network and optional track/recipe are staged; no run started and no browser storage or server was changed.'+(portableReceipt?' Sanitized receipt is available for separate download.':''));
635
+ }
636
+ if($('bundle_import'))$('bundle_import').onchange=async()=>{
637
+ const input=$('bundle_import'),file=input.files?.[0];if(!file)return;let restoreId=null;
638
+ try{
639
+ if(bundleRestoring||busy||job)throw Error('Finish the active run before restoring a bundle');
640
+ restoreId=++bundleRestoreId;bundleRestoring=true;buttons();
641
+ if(file.size>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
642
+ await restorePortableText(await file.text(),{lockHeld:true});
643
+ }catch(error){bundleMessage('Bundle not restored: '+error.message+'. The existing selections were left unchanged.');}
644
+ finally{if(restoreId===bundleRestoreId)bundleRestoring=false;buttons();input.value='';}
645
+ };
646
+ if($('bundle_receipt_download'))$('bundle_receipt_download').onclick=()=>{
647
+ if(!portableReceipt)return;
648
+ const url=URL.createObjectURL(new Blob([JSON.stringify(portableReceipt,null,2)],{type:'application/json'}));
649
+ const a=document.createElement('a');a.href=url;a.download='ald-run-receipt.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
650
+ };
index.html CHANGED
@@ -46,6 +46,8 @@
46
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
47
  <pre id="runSummary" style="white-space:pre-wrap;line-height:1.7" aria-live="polite"></pre><label class="keep-result"><input id="leaderboard_opt_in" type="checkbox"> After a completed lap, check its position against the public Steam leaderboard</label><p class="privacy-note">Optional, read-only check for supported built-in tracks. It sends the track ID and best lap to this app server; Steam receives only a public leaderboard read. No score is submitted. The result is an estimate, not an official rank.</p><p id="leaderboard_status" role="status" aria-live="polite"></p>
48
  <p><button id="run_export" disabled>Export run receipt</button></p><p class="hint">Receipts include build identity, settings, metrics and file hashes. Review them before sharing; they contain no network weights or job access keys.</p>
 
 
49
  <p id="deadline"></p><p id="status" role="status">Ready. Inputs remain on this device until you run the test.</p>
50
  <details><summary>Effective training settings</summary><pre id="effectiveSettings"></pre></details><details><summary>Generation results</summary><pre id="generationHistory"></pre></details><details><summary>Star challenges — not evaluated</summary><p>The current runner has star-challenge reporting. Its evaluator and required statistics have not been ported to HF; no star result is inferred from a completed test.</p></details></section></div>
51
  </div><section id="training" class="pane" hidden><div class="card"><h2>Training settings</h2><p class="hint">Choose the track on Drive & view. Choose the training settings and reward mix here, then start the run on Drive & view. Recipes and batch sequences are saved in this browser.</p><p><label for="trackPresetTraining">Training track</label><select id="trackPresetTraining" data-track-library disabled><option value="">Upload a custom track</option></select></p><p data-track-status role="status" class="hint">Loading built-in replica tracks…</p><div id="training_controls"><label for="training_ticks">Simulated ticks per generation</label><input id="training_ticks" type="number" min="1" max="18000" value="60"><small>60 ticks = one simulated second. Current preview limit: population × generations × ticks ≤ 4,000,000; execution timeout 25 seconds.</small>
@@ -294,6 +296,18 @@
294
  q('end_rate_group').hidden=false;
295
  applySettings(api.defaults);
296
  window.getTrainingSettings=executableSettings;
 
 
 
 
 
 
 
 
 
 
 
 
297
  window.setTrainingBusy=busy=>{
298
  for(const id of editable)q(id).disabled=busy;
299
  for(const node of q('rewards').querySelectorAll('input,select,button'))node.disabled=busy;
@@ -449,7 +463,73 @@
449
  }
450
  return {create,mount};
451
  });
452
- </script><script src="/track-map.js"></script><script src="/client.js"></script><script src="/hf-auth.js"></script><script>'use strict';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  // Navigation only. Editor and training/storage adapters are separate layers.
454
  document.querySelectorAll('[data-tab]').forEach(button=>button.addEventListener('click',()=>{
455
  document.querySelectorAll('.pane').forEach(pane=>pane.hidden=pane.id!==button.dataset.tab);
 
46
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
47
  <pre id="runSummary" style="white-space:pre-wrap;line-height:1.7" aria-live="polite"></pre><label class="keep-result"><input id="leaderboard_opt_in" type="checkbox"> After a completed lap, check its position against the public Steam leaderboard</label><p class="privacy-note">Optional, read-only check for supported built-in tracks. It sends the track ID and best lap to this app server; Steam receives only a public leaderboard read. No score is submitted. The result is an estimate, not an official rank.</p><p id="leaderboard_status" role="status" aria-live="polite"></p>
48
  <p><button id="run_export" disabled>Export run receipt</button></p><p class="hint">Receipts include build identity, settings, metrics and file hashes. Review them before sharing; they contain no network weights or job access keys.</p>
49
+ <details id="portableBundle"><summary>Portable bucket bundle</summary><p>Build, export, or restore one validated bundle. Saving to an existing private bucket is optional and happens only when you explicitly connect, save, or restore. No automatic uploads occur.</p><label><input id="bundle_track" type="checkbox"> Include the selected track</label><label><input id="bundle_recipe" type="checkbox"> Include the recipe in the selected slot, if saved</label><label><input id="bundle_receipt" type="checkbox"> Include a sanitized receipt for the selected completed result</label><div class="toolbar"><button id="bundle_export" type="button">Export portable bundle</button><label for="bundle_import">Restore portable bundle (.json)<input id="bundle_import" type="file" accept=".json,application/json"></label><button id="bundle_receipt_download" type="button" disabled>Download staged receipt</button></div><p id="bundle_status" role="status" aria-live="polite">No bucket action has been requested.</p>
50
+ <details id="bucketStorage"><summary>Connect to an existing private bucket</summary><p>Use a fine-grained Hugging Face token that can access your private bucket. The token is sent to this app only for explicit bucket actions, stays in page memory, and is cleared when you disconnect, the app receives an expired or denied response, or this page closes. The app cannot create buckets or change visibility. Normal runs do not require a bucket connection.</p><div id="bucketConnectForm"><label for="bucket_id">Private bucket ID</label><input id="bucket_id" type="text" autocomplete="off" placeholder="username/bucket-name"><label for="bucket_token">Fine-grained bucket token</label><input id="bucket_token" type="password" autocomplete="new-password" spellcheck="false"><button id="bucket_connect" type="button">Connect bucket</button></div><div id="bucketConnected" hidden><p id="bucket_account"></p><button id="bucket_disconnect" type="button">Disconnect bucket</button><div class="toolbar"><button id="bucket_save" type="button" disabled>Save selected bundle</button><button id="bucket_refresh" type="button">Refresh saved bundles</button></div><label for="bucket_items">Saved bundles from this app</label><select id="bucket_items" size="4"><option value="">Refresh to list saved bundles</option></select><button id="bucket_restore" type="button" disabled>Restore selected bundle</button></div><p id="bucket_status" role="status" aria-live="polite">Not connected. Bucket storage is optional.</p></details></details>
51
  <p id="deadline"></p><p id="status" role="status">Ready. Inputs remain on this device until you run the test.</p>
52
  <details><summary>Effective training settings</summary><pre id="effectiveSettings"></pre></details><details><summary>Generation results</summary><pre id="generationHistory"></pre></details><details><summary>Star challenges — not evaluated</summary><p>The current runner has star-challenge reporting. Its evaluator and required statistics have not been ported to HF; no star result is inferred from a completed test.</p></details></section></div>
53
  </div><section id="training" class="pane" hidden><div class="card"><h2>Training settings</h2><p class="hint">Choose the track on Drive & view. Choose the training settings and reward mix here, then start the run on Drive & view. Recipes and batch sequences are saved in this browser.</p><p><label for="trackPresetTraining">Training track</label><select id="trackPresetTraining" data-track-library disabled><option value="">Upload a custom track</option></select></p><p data-track-status role="status" class="hint">Loading built-in replica tracks…</p><div id="training_controls"><label for="training_ticks">Simulated ticks per generation</label><input id="training_ticks" type="number" min="1" max="18000" value="60"><small>60 ticks = one simulated second. Current preview limit: population × generations × ticks ≤ 4,000,000; execution timeout 25 seconds.</small>
 
296
  q('end_rate_group').hidden=false;
297
  applySettings(api.defaults);
298
  window.getTrainingSettings=executableSettings;
299
+ let stagedPortableRecipe=false;
300
+ window.getPortableRecipe=()=>{
301
+ if(stagedPortableRecipe)return {slot:q('recipe_slot').value,name:q('recipe_name').value,settings:currentSettings()};
302
+ const slot=q('recipe_slot').value;return read().recipes.find(r=>r.slot===slot)||null;
303
+ };
304
+ window.stagePortableRecipe=recipe=>{
305
+ if(!recipe||! /^[A-J]$/.test(recipe.slot)||typeof recipe.name!=='string'||!recipe.name.trim()||recipe.name.length>80)throw Error('Invalid selected recipe');
306
+ const settings=api.settings(recipe.settings);q('recipe_slot').value=recipe.slot;q('recipe_name').value=recipe.name.trim();
307
+ applySettings(settings);q('preset').value='custom';q('reward_preset').value='custom';stagedPortableRecipe=true;
308
+ message('Imported recipe staged in the training form. Save it explicitly to add it to this browser’s recipe library. No run started.');
309
+ };
310
+ q('recipe_slot').addEventListener('change',()=>{stagedPortableRecipe=false;});
311
  window.setTrainingBusy=busy=>{
312
  for(const id of editable)q(id).disabled=busy;
313
  for(const node of q('rewards').querySelectorAll('input,select,button'))node.disabled=busy;
 
463
  }
464
  return {create,mount};
465
  });
466
+ </script><script src="/track-map.js"></script><script>/* Explicit local portable-bundle format. This module never stores or sends data. */
467
+ (function(root,factory){
468
+ if(typeof module==='object'&&module.exports)module.exports=factory();
469
+ else root.PortableBundle=factory();
470
+ })(typeof window==='undefined'?globalThis:window,function(){
471
+ const SCHEMA='ald.portable-bundle',LIMITS={network:4*1024*1024,track:1024*1024,recipe:32768,receipt:131072,bundle:8*1024*1024};
472
+ const exact=(value,keys)=>{
473
+ if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Unexpected portable bundle fields');
474
+ };
475
+ const utf8=value=>new TextEncoder().encode(value);
476
+ const sha=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),b=>b.toString(16).padStart(2,'0')).join('');
477
+ function b64(bytes){let text='';for(let i=0;i<bytes.length;i+=8192)text+=String.fromCharCode(...bytes.subarray(i,i+8192));return btoa(text);}
478
+ function unb64(text){
479
+ if(typeof text!=='string'||text.length>Math.ceil(LIMITS.network/3)*4+8)throw Error('Invalid portable artifact encoding');
480
+ let binary;try{binary=atob(text);}catch{throw Error('Invalid portable artifact encoding');}
481
+ const bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));if(b64(bytes)!==text)throw Error('Non-canonical portable artifact encoding');return bytes;
482
+ }
483
+ async function artifact(format,kind,bytes){
484
+ const data=bytes instanceof Uint8Array?bytes:new Uint8Array(bytes);
485
+ const limit=format==='evolution'?LIMITS.network:LIMITS.track;
486
+ if(!data.length||data.byteLength>limit)throw Error(format==='evolution'?'Network exceeds 4 MiB bundle limit':'Track exceeds 1 MiB bundle limit');
487
+ return {format,kind,bytes:data.byteLength,sha256:await sha(data),data_base64:b64(data)};
488
+ }
489
+ async function pack(artifacts){
490
+ if(!artifacts||!artifacts.selected_network)throw Error('A selected network is required');
491
+ const clean={schema:SCHEMA,version:1,artifacts};
492
+ const text=JSON.stringify(clean,null,2);if(utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
493
+ return text;
494
+ }
495
+ async function parse(text){
496
+ if(typeof text!=='string'||utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
497
+ let bundle;try{bundle=JSON.parse(text);}catch{throw Error('Portable bundle is not valid JSON');}
498
+ exact(bundle,['schema','version','artifacts']);if(bundle.schema!==SCHEMA||bundle.version!==1)throw Error('Unsupported portable bundle version');
499
+ const allowed=['selected_network','selected_track','selected_recipe','run_receipt'];
500
+ if(!bundle.artifacts||typeof bundle.artifacts!=='object'||Array.isArray(bundle.artifacts)||Object.keys(bundle.artifacts).some(k=>!allowed.includes(k))||!bundle.artifacts.selected_network)throw Error('Portable bundle needs a selected network and fixed artifact slots');
501
+ const a=bundle.artifacts,networkMeta=a.selected_network,networkBytes=await checkArtifact(networkMeta,'evolution',['format','kind','bytes','sha256','data_base64'],LIMITS.network);
502
+ if(!['input','result'].includes(networkMeta.kind))throw Error('Invalid selected network kind');
503
+ let track=null,recipe=null,receipt=null;
504
+ if(Object.hasOwn(a,'selected_track')){
505
+ const t=a.selected_track;if(!t||typeof t!=='object')throw Error('Invalid selected track');
506
+ const builtin=t.source==='builtin';
507
+ exact(t,builtin?['format','source','track_id','display_name','bytes','sha256','data_base64']:['format','source','bytes','sha256','data_base64']);
508
+ if(!['builtin','upload'].includes(t.source)||t.format!=='track')throw Error('Invalid selected track metadata');
509
+ if(builtin&&(!/^[a-z0-9-]{1,80}$/.test(t.track_id)||typeof t.display_name!=='string'||!t.display_name.length||t.display_name.length>100))throw Error('Invalid built-in track identity');
510
+ track={metadata:t,bytes:await checkArtifact(t,'track',Object.keys(t),LIMITS.track)};
511
+ }
512
+ if(Object.hasOwn(a,'selected_recipe')){
513
+ const r=a.selected_recipe;exact(r,['version','slot','name','settings']);
514
+ if(r.version!==1||! /^[A-J]$/.test(r.slot)||typeof r.name!=='string'||!r.name.trim()||r.name.length>80)throw Error('Invalid selected recipe');
515
+ const text=JSON.stringify(r);if(utf8(text).byteLength>LIMITS.recipe)throw Error('Recipe exceeds 32 KiB bundle limit');
516
+ recipe=r;
517
+ }
518
+ if(Object.hasOwn(a,'run_receipt')){
519
+ const r=a.run_receipt;exact(r,['version','kind','state','build_id','backend','output_sha256','report']);
520
+ if(r.version!==1||r.kind!=='run'||r.state!=='completed'||typeof r.build_id!=='string'||r.build_id.length>128||!['cpu','gpu'].includes(r.backend)||! /^[a-f0-9]{64}$/.test(r.output_sha256)||!r.report||typeof r.report!=='object'||Array.isArray(r.report))throw Error('Invalid sanitized run receipt');
521
+ const text=JSON.stringify(r);if(utf8(text).byteLength>LIMITS.receipt)throw Error('Run receipt exceeds 128 KiB bundle limit');
522
+ receipt=r;
523
+ }
524
+ return {bundle,network:{...networkMeta,bytes:networkBytes},track,recipe,receipt};
525
+ }
526
+ async function checkArtifact(value,format,keys,limit){
527
+ exact(value,keys);if(value.format!==format||!Number.isInteger(value.bytes)||value.bytes<1||value.bytes>limit||! /^[a-f0-9]{64}$/.test(value.sha256))throw Error('Invalid '+format+' artifact metadata');
528
+ const bytes=unb64(value.data_base64);if(bytes.byteLength!==value.bytes||await sha(bytes)!==value.sha256)throw Error(format+' bytes or SHA-256 do not match');
529
+ return bytes;
530
+ }
531
+ return {SCHEMA,LIMITS,sha,artifact,pack,parse};
532
+ });</script><script src="/client.js"></script><script src="/hf-auth.js"></script><script>'use strict';
533
  // Navigation only. Editor and training/storage adapters are separate layers.
534
  document.querySelectorAll('[data-tab]').forEach(button=>button.addEventListener('click',()=>{
535
  document.querySelectorAll('.pane').forEach(pane=>pane.hidden=pane.id!==button.dataset.tab);
portable_bundle.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict versioned portable bundle validator shared by CLI and hosted API."""
2
+ from __future__ import annotations
3
+ import base64, binascii, gzip, hashlib, io, json, math, os, re, sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+ PACKAGE_ROOT = Path(__file__).resolve().parent
7
+ sys.path.insert(0, str(PACKAGE_ROOT))
8
+ from worker import network_input as _game_network_input, track_input as _game_track_input
9
+
10
+ SCHEMA = "ald.portable-bundle"
11
+ VERSION = 1
12
+ MAX_NETWORK = 4 * 1024 * 1024
13
+ MAX_TRACK = 1 * 1024 * 1024
14
+ MAX_RECIPE = 32 * 1024
15
+ MAX_RECEIPT = 128 * 1024
16
+ MAX_BUNDLE = 8 * 1024 * 1024
17
+ MAX_EXPANDED_JSON = 4 * 1024 * 1024
18
+ HEX64 = re.compile(r"^[0-9a-f]{64}$")
19
+ BUCKET_PATH = re.compile(r"^hf://buckets/([A-Za-z0-9][A-Za-z0-9_.-]{0,63})/([A-Za-z0-9][A-Za-z0-9_.-]{0,127})(?:/([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*))?$")
20
+ SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
21
+
22
+ RECIPE_KEYS = {
23
+ "population", "generations", "ticks", "mutation", "seed", "mutation_end",
24
+ "schedule", "selection", "parents", "elite", "crossover", "decay",
25
+ "adaptive_mutation", "control_hz", "workers", "collision", "idle", "rewards",
26
+ }
27
+ METRICS = {
28
+ "total_score", "total_speed", "total_abs_steering", "total_actual_distance",
29
+ "total_drifting_amount", "total_momentum_change_frequency", "total_throttle_change_frequency",
30
+ "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center",
31
+ "network_novelty", "collision_count", "best_lap_performance", "total_racing_line_efficiency",
32
+ }
33
+ RECEIPT_KEYS = {"version", "kind", "state", "build_id", "backend", "output_sha256", "report"}
34
+ REPORT_KEYS = {
35
+ "vehicle_type", "vehicle_name", "generations_completed", "generations_requested", "population",
36
+ "elapsed_seconds", "best_score", "mean_score", "best_lap_seconds", "lapped_cars",
37
+ "simulated_ticks", "requested_ticks", "candidate_validated", "execution_backend", "inputs", "settings",
38
+ }
39
+ INPUT_HASH_KEYS = {"track_sha256", "network_sha256"}
40
+
41
+
42
+ class BundleError(ValueError):
43
+ pass
44
+
45
+
46
+ def _no_constant(value: str) -> None:
47
+ raise BundleError(f"Non-finite JSON number is not allowed: {value}")
48
+
49
+
50
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
51
+ out: dict[str, Any] = {}
52
+ for key, value in pairs:
53
+ if key in out:
54
+ raise BundleError(f"Duplicate JSON key: {key}")
55
+ out[key] = value
56
+ return out
57
+
58
+
59
+ def _json_bytes(value: Any) -> bytes:
60
+ try:
61
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
62
+ except (TypeError, ValueError, UnicodeEncodeError) as exc:
63
+ raise BundleError("Value is not valid portable JSON") from exc
64
+
65
+
66
+ def _parse_json(raw: bytes, *, limit: int, label: str) -> Any:
67
+ if len(raw) > limit:
68
+ raise BundleError(f"{label} exceeds its byte limit")
69
+ try:
70
+ return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object, parse_constant=_no_constant)
71
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
72
+ raise BundleError(f"{label} must be valid UTF-8 JSON") from exc
73
+
74
+
75
+ def _strict_keys(value: Any, allowed: set[str], required: set[str], label: str) -> dict[str, Any]:
76
+ if not isinstance(value, dict):
77
+ raise BundleError(f"{label} must be an object")
78
+ keys = set(value)
79
+ if not required <= keys or keys - allowed:
80
+ raise BundleError(f"{label} has missing or unsupported fields")
81
+ return value
82
+
83
+
84
+ def _is_int(value: Any, low: int, high: int) -> bool:
85
+ return type(value) is int and low <= value <= high
86
+
87
+
88
+ def _is_number(value: Any, low: float, high: float) -> bool:
89
+ return type(value) in (int, float) and math.isfinite(value) and low <= value <= high
90
+
91
+
92
+ def validate_settings(value: Any) -> dict[str, Any]:
93
+ settings = _strict_keys(value, RECIPE_KEYS, RECIPE_KEYS, "recipe settings")
94
+ bounds = {
95
+ "population": (1, 128), "generations": (1, 1000), "ticks": (1, 18000),
96
+ "seed": (0, 2147483647), "parents": (1, 128), "elite": (0, 128), "workers": (1, 4),
97
+ }
98
+ if any(not _is_int(settings[k], *bounds[k]) for k in bounds):
99
+ raise BundleError("recipe settings contain an out-of-range integer")
100
+ for key in ("mutation", "mutation_end"):
101
+ if not _is_number(settings[key], 0, 4.2):
102
+ raise BundleError("recipe mutation rates must be finite and between 0 and 4.2")
103
+ if settings["mutation"] <= 0 or (settings["schedule"] != "fixed" and settings["mutation_end"] <= 0):
104
+ raise BundleError("recipe mutation rates must be positive for the selected schedule")
105
+ if not _is_number(settings["decay"], 0, 1):
106
+ raise BundleError("recipe decay must be between 0 and 1")
107
+ choices = {
108
+ "schedule": {"fixed", "linear", "geometric"},
109
+ "selection": {"best", "tournament", "roulette"},
110
+ "crossover": {"none", "single_point", "uniform"},
111
+ "control_hz": {15, 30, 60},
112
+ }
113
+ if any(type(settings[k]) is not str for k in ("schedule", "selection", "crossover")):
114
+ raise BundleError("recipe contains an unsupported option")
115
+ if any(settings[k] not in values for k, values in choices.items()):
116
+ raise BundleError("recipe contains an unsupported option")
117
+ if type(settings["control_hz"]) is not int:
118
+ raise BundleError("recipe control_hz must be an integer")
119
+ if any(type(settings[k]) is not bool for k in ("adaptive_mutation", "collision", "idle")):
120
+ raise BundleError("recipe boolean setting is invalid")
121
+ if settings["parents"] > settings["population"] or settings["elite"] > settings["population"]:
122
+ raise BundleError("recipe parents or elite exceeds population")
123
+ rewards = settings["rewards"]
124
+ if not isinstance(rewards, list) or not 1 <= len(rewards) <= 5:
125
+ raise BundleError("recipe must have between one and five rewards")
126
+ seen: set[str] = set()
127
+ for reward in rewards:
128
+ _strict_keys(reward, {"metric", "type", "weight"}, {"metric", "type", "weight"}, "reward")
129
+ if (not isinstance(reward["metric"], str) or not isinstance(reward["type"], str)
130
+ or reward["metric"] not in METRICS or reward["type"] not in {"default", "average"}
131
+ or reward["metric"] in seen or not _is_int(reward["weight"], -100, 100)):
132
+ raise BundleError("recipe reward is invalid or duplicated")
133
+ seen.add(reward["metric"])
134
+ if all(reward["weight"] == 0 for reward in rewards):
135
+ raise BundleError("recipe rewards must include a nonzero weight")
136
+ return settings
137
+
138
+
139
+ def validate_recipe(value: Any) -> dict[str, Any]:
140
+ recipe = _strict_keys(value, {"version", "slot", "name", "settings"}, {"version", "slot", "name", "settings"}, "selected recipe")
141
+ if recipe["version"] != 1 or type(recipe["version"]) is not int:
142
+ raise BundleError("selected recipe version is unsupported")
143
+ if not isinstance(recipe["slot"], str) or not re.fullmatch(r"[A-J]", recipe["slot"]):
144
+ raise BundleError("selected recipe slot must be A through J")
145
+ if not isinstance(recipe["name"], str) or not recipe["name"].strip() or len(recipe["name"]) > 80:
146
+ raise BundleError("selected recipe name must contain 1 to 80 characters")
147
+ validate_settings(recipe["settings"])
148
+ return recipe
149
+
150
+
151
+ def validate_receipt(value: Any) -> dict[str, Any]:
152
+ receipt = _strict_keys(value, RECEIPT_KEYS, RECEIPT_KEYS, "run receipt")
153
+ if type(receipt["version"]) is not int or receipt["version"] != 1 or receipt["kind"] != "run":
154
+ raise BundleError("run receipt version or kind is unsupported")
155
+ if (not isinstance(receipt["state"], str) or receipt["state"] != "completed"
156
+ or not isinstance(receipt["backend"], str) or receipt["backend"] not in {"cpu", "gpu"}):
157
+ raise BundleError("run receipt state or backend is unsupported")
158
+ if not isinstance(receipt["build_id"], str) or len(receipt["build_id"]) > 128 or not re.fullmatch(r"[A-Za-z0-9._:+-]+", receipt["build_id"]):
159
+ raise BundleError("run receipt build id is invalid")
160
+ if not isinstance(receipt["output_sha256"], str) or not HEX64.fullmatch(receipt["output_sha256"]):
161
+ raise BundleError("run receipt output hash is invalid")
162
+ report = _strict_keys(receipt["report"], REPORT_KEYS, REPORT_KEYS, "run receipt report")
163
+ if not _is_int(report["vehicle_type"], 0, 3) or report["vehicle_name"] != ("Formula", "Rally", "Snowmobile", "Truck")[report["vehicle_type"]]:
164
+ raise BundleError("run receipt vehicle is invalid")
165
+ for key in ("generations_completed", "generations_requested", "population", "simulated_ticks", "requested_ticks"):
166
+ if not _is_int(report[key], 0, 4_000_000):
167
+ raise BundleError("run receipt count is invalid")
168
+ if report["generations_requested"] < 1 or report["population"] < 1 or report["requested_ticks"] < 1:
169
+ raise BundleError("run receipt requested counts and population must be positive")
170
+ for key in ("best_score", "mean_score"):
171
+ if not _is_number(report[key], -1e12, 1e12):
172
+ raise BundleError("run receipt metric is non-finite or out of range")
173
+ if not _is_number(report["elapsed_seconds"], 0, 1e12):
174
+ raise BundleError("run receipt elapsed time is invalid")
175
+ if report["best_lap_seconds"] is not None and not _is_number(report["best_lap_seconds"], 0, 1e12):
176
+ raise BundleError("run receipt best lap is invalid")
177
+ if report["best_lap_seconds"] == 0:
178
+ raise BundleError("run receipt best lap must be positive")
179
+ if report["lapped_cars"] is not None and not _is_int(report["lapped_cars"], 0, 128):
180
+ raise BundleError("run receipt lapped car count is invalid")
181
+ if (report["candidate_validated"] is not True or not isinstance(report["execution_backend"], str)
182
+ or report["execution_backend"] not in {"cpu", "cuda"}):
183
+ raise BundleError("run receipt execution status is invalid")
184
+ hashes = _strict_keys(report["inputs"], INPUT_HASH_KEYS, INPUT_HASH_KEYS, "run receipt inputs")
185
+ if any(not isinstance(hashes[k], str) or not HEX64.fullmatch(hashes[k]) for k in INPUT_HASH_KEYS):
186
+ raise BundleError("run receipt input hash is invalid")
187
+ validate_settings(report["settings"])
188
+ return receipt
189
+
190
+
191
+ def _bounded_json_payload(raw: bytes, label: str) -> Any:
192
+ """Validate payload JSON with bounded gzip expansion, preserving bytes elsewhere."""
193
+ expanded = raw
194
+ if raw.startswith(b"\x1f\x8b"):
195
+ try:
196
+ with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream:
197
+ expanded = stream.read(MAX_EXPANDED_JSON + 1)
198
+ except (OSError, EOFError) as exc:
199
+ raise BundleError(f"{label} is not a valid gzip JSON payload") from exc
200
+ if len(expanded) > MAX_EXPANDED_JSON:
201
+ raise BundleError(f"{label} expands beyond 4 MiB")
202
+ value = _parse_json(expanded, limit=MAX_EXPANDED_JSON, label=label)
203
+ if not isinstance(value, dict):
204
+ raise BundleError(f"{label} must contain a JSON object")
205
+ return value
206
+
207
+
208
+ def _validate_game_payload(raw: bytes, label: str, validator: Any) -> None:
209
+ try:
210
+ validator(raw)
211
+ except (ValueError, KeyError, TypeError, OSError, EOFError) as exc:
212
+ raise BundleError(f"{label} does not pass the current game input validator") from exc
213
+
214
+
215
+ def _read_regular_file(path_value: str | Path, *, limit: int, label: str) -> bytes:
216
+ path = Path(path_value).expanduser()
217
+ if not path.is_absolute():
218
+ path = Path.cwd() / path
219
+ # Reject every symlinked component, not only a final symlink.
220
+ absolute = Path(os.path.abspath(path))
221
+ cursor = Path(absolute.anchor)
222
+ for part in absolute.parts[1:]:
223
+ cursor = cursor / part
224
+ if cursor.is_symlink():
225
+ raise BundleError(f"{label} path includes a symlink")
226
+ try:
227
+ st = absolute.stat()
228
+ except OSError as exc:
229
+ raise BundleError(f"{label} file cannot be read") from exc
230
+ if not absolute.is_file() or st.st_size > limit:
231
+ raise BundleError(f"{label} must be a regular file no larger than its limit")
232
+ try:
233
+ raw = absolute.read_bytes()
234
+ except OSError as exc:
235
+ raise BundleError(f"{label} file cannot be read") from exc
236
+ if len(raw) > limit:
237
+ raise BundleError(f"{label} exceeds its byte limit")
238
+ return raw
239
+
240
+
241
+ def _slot(role: str, raw: bytes, *, fmt: str) -> dict[str, Any]:
242
+ return {
243
+ "format": fmt,
244
+ "bytes": len(raw),
245
+ "sha256": hashlib.sha256(raw).hexdigest(),
246
+ "data_base64": base64.b64encode(raw).decode("ascii"),
247
+ }
248
+
249
+
250
+ def _decode_slot(value: Any, *, role: str, limit: int, fmt: str, allowed_extra: set[str], required_extra: set[str]) -> bytes:
251
+ allowed = {"format", "bytes", "sha256", "data_base64"} | allowed_extra
252
+ required = {"format", "bytes", "sha256", "data_base64"} | required_extra
253
+ item = _strict_keys(value, allowed, required, role)
254
+ if item["format"] != fmt:
255
+ raise BundleError(f"{role} format is unsupported")
256
+ if type(item["bytes"]) is not int or item["bytes"] < 0 or item["bytes"] > limit:
257
+ raise BundleError(f"{role} byte count exceeds its limit")
258
+ if not isinstance(item["sha256"], str) or not HEX64.fullmatch(item["sha256"]):
259
+ raise BundleError(f"{role} SHA-256 is invalid")
260
+ encoded = item["data_base64"]
261
+ if not isinstance(encoded, str) or len(encoded) > 4 * ((limit + 2) // 3):
262
+ raise BundleError(f"{role} base64 content exceeds its limit")
263
+ try:
264
+ raw = base64.b64decode(encoded, validate=True)
265
+ except (ValueError, binascii.Error) as exc:
266
+ raise BundleError(f"{role} base64 content is invalid") from exc
267
+ if len(raw) != item["bytes"] or len(raw) > limit:
268
+ raise BundleError(f"{role} byte count does not match its data")
269
+ if hashlib.sha256(raw).hexdigest() != item["sha256"]:
270
+ raise BundleError(f"{role} SHA-256 does not match its data")
271
+ return raw
272
+
273
+
274
+ def load_bundle(raw: bytes, *, catalog_path: Path | None = None) -> tuple[dict[str, Any], dict[str, bytes]]:
275
+ value = _parse_json(raw, limit=MAX_BUNDLE, label="bundle")
276
+ bundle = _strict_keys(value, {"schema", "version", "artifacts"}, {"schema", "version", "artifacts"}, "bundle")
277
+ if bundle["schema"] != SCHEMA or type(bundle["version"]) is not int or bundle["version"] != VERSION:
278
+ raise BundleError("bundle schema or version is unsupported")
279
+ artifacts = bundle["artifacts"]
280
+ if not isinstance(artifacts, dict) or not {"selected_network"} <= set(artifacts) or set(artifacts) - {"selected_network", "selected_track", "selected_recipe", "run_receipt"}:
281
+ raise BundleError("bundle must contain a selected network and only supported optional artifacts")
282
+ files: dict[str, bytes] = {}
283
+ net = _strict_keys(artifacts["selected_network"], {"format", "kind", "bytes", "sha256", "data_base64"}, {"format", "kind", "bytes", "sha256", "data_base64"}, "selected network")
284
+ if not isinstance(net["kind"], str) or net["kind"] not in {"input", "result"}:
285
+ raise BundleError("selected network kind is unsupported")
286
+ files["selected_network.evolution"] = _decode_slot(net, role="selected network", limit=MAX_NETWORK, fmt="evolution", allowed_extra={"kind"}, required_extra={"kind"})
287
+ _bounded_json_payload(files["selected_network.evolution"], "selected network")
288
+ _validate_game_payload(files["selected_network.evolution"], "selected network", _game_network_input)
289
+
290
+ if "selected_track" in artifacts:
291
+ track = artifacts["selected_track"]
292
+ if not isinstance(track, dict):
293
+ raise BundleError("selected track must be an object")
294
+ source = track.get("source")
295
+ base_extra = {"source"}
296
+ req_extra = {"source"}
297
+ if source == "builtin":
298
+ base_extra |= {"track_id", "display_name"}
299
+ req_extra |= {"track_id", "display_name"}
300
+ if not isinstance(track.get("track_id"), str) or not isinstance(track.get("display_name"), str):
301
+ raise BundleError("built-in track metadata is invalid")
302
+ catalog_path = catalog_path or PACKAGE_ROOT / "builtin_tracks/catalog.json"
303
+ catalog = _parse_json(catalog_path.read_bytes(), limit=2 * 1024 * 1024, label="built-in track catalog")
304
+ matched = [x for x in catalog.get("tracks", []) if x.get("id") == track["track_id"] and x.get("display_name") == track["display_name"]]
305
+ if len(matched) != 1:
306
+ raise BundleError("built-in track id and label must match the local catalog")
307
+ elif source == "upload":
308
+ if "track_id" in track or "display_name" in track:
309
+ raise BundleError("uploaded track must not include built-in catalog metadata")
310
+ else:
311
+ raise BundleError("selected track source is unsupported")
312
+ files["selected_track.track"] = _decode_slot(track, role="selected track", limit=MAX_TRACK, fmt="track", allowed_extra=base_extra, required_extra=req_extra)
313
+ if source == "builtin" and (len(files["selected_track.track"]) != matched[0].get("bytes")
314
+ or hashlib.sha256(files["selected_track.track"]).hexdigest() != matched[0].get("sha256")):
315
+ raise BundleError("built-in track bytes do not match the selected catalog entry")
316
+ _bounded_json_payload(files["selected_track.track"], "selected track")
317
+ _validate_game_payload(files["selected_track.track"], "selected track", _game_track_input)
318
+
319
+ if "selected_recipe" in artifacts:
320
+ recipe = validate_recipe(artifacts["selected_recipe"])
321
+ recipe_bytes = _json_bytes(recipe)
322
+ if len(recipe_bytes) > MAX_RECIPE:
323
+ raise BundleError("selected recipe exceeds 32 KiB")
324
+ files["selected_recipe.json"] = recipe_bytes
325
+
326
+ if "run_receipt" in artifacts:
327
+ receipt = validate_receipt(artifacts["run_receipt"])
328
+ report = receipt["report"]
329
+ network_sha = net["sha256"]
330
+ if net["kind"] == "input":
331
+ network_matches = report["inputs"]["network_sha256"] == network_sha
332
+ else:
333
+ network_matches = receipt["output_sha256"] == network_sha
334
+ if not network_matches:
335
+ raise BundleError("run receipt does not describe the bundled network")
336
+ if "selected_track" in artifacts and report["inputs"]["track_sha256"] != artifacts["selected_track"]["sha256"]:
337
+ raise BundleError("run receipt does not describe the bundled track")
338
+ if receipt["backend"] == "cpu" and report["execution_backend"] != "cpu":
339
+ raise BundleError("run receipt backend does not match its execution backend")
340
+ if receipt["backend"] == "gpu" and report["execution_backend"] != "cuda":
341
+ raise BundleError("run receipt backend does not match its execution backend")
342
+ receipt_bytes = _json_bytes(receipt)
343
+ if len(receipt_bytes) > MAX_RECEIPT:
344
+ raise BundleError("run receipt exceeds 128 KiB")
345
+ files["run_receipt.json"] = receipt_bytes
346
+ return bundle, files
347
+
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  fastapi==0.141.1
2
  uvicorn==0.52.1
3
  spaces==0.51.3
 
 
1
  fastapi==0.141.1
2
  uvicorn==0.52.1
3
  spaces==0.51.3
4
+ huggingface_hub==1.33.0
server.py CHANGED
@@ -13,6 +13,8 @@ from training_config import validate_config
13
  from hf_identity import oauth_config
14
  from builtin_tracks import TrackLibrary
15
  from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
 
 
16
 
17
  ROOT=Path(__file__).resolve().parent
18
 
@@ -20,7 +22,106 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
20
  app=create_api(store)
21
  admission=threading.BoundedSemaphore(1)
22
  leaderboard_admission=threading.BoundedSemaphore(1)
 
23
  library=track_library if track_library is not None else TrackLibrary(ROOT/'builtin_tracks')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  @app.get('/api/tracks')
25
  def track_catalog():
26
  return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})
 
13
  from hf_identity import oauth_config
14
  from builtin_tracks import TrackLibrary
15
  from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
16
+ from bucket_storage import BucketStorageError, MAX_REQUEST as BUCKET_MAX_REQUEST, run_bucket_operation, valid_bucket_id, valid_bundle_key
17
+ from portable_bundle import BundleError, load_bundle, MAX_BUNDLE
18
 
19
  ROOT=Path(__file__).resolve().parent
20
 
 
22
  app=create_api(store)
23
  admission=threading.BoundedSemaphore(1)
24
  leaderboard_admission=threading.BoundedSemaphore(1)
25
+ bucket_admission=threading.BoundedSemaphore(1)
26
  library=track_library if track_library is not None else TrackLibrary(ROOT/'builtin_tracks')
27
+
28
+ @app.middleware('http')
29
+ async def bucket_no_store(request:Request, call_next):
30
+ response=await call_next(request)
31
+ if request.url.path.startswith('/api/bucket/'):
32
+ response.headers['Cache-Control']='no-store'
33
+ return response
34
+
35
+ def bucket_token(request:Request):
36
+ authorization=request.headers.get('authorization','')
37
+ if not authorization.startswith('Bearer '):
38
+ raise HTTPException(401,'Storage credential required')
39
+ token=authorization[7:]
40
+ if not token or len(token)>4096 or any(ch.isspace() or ord(ch)<33 for ch in token):
41
+ raise HTTPException(401,'Storage credential required')
42
+ return token
43
+
44
+ async def bucket_body(request:Request, token:str, *, limit:int, fields:set[str]):
45
+ chunks=bytearray()
46
+ try:
47
+ async for chunk in request.stream():
48
+ if len(chunks)+len(chunk)>limit: raise HTTPException(413,'Storage request too large')
49
+ chunks.extend(chunk)
50
+ def unique_object(pairs):
51
+ result={}
52
+ for key,value in pairs:
53
+ if key in result: raise ValueError('duplicate key')
54
+ result[key]=value
55
+ return result
56
+ value=json.loads(chunks.decode('utf-8'),object_pairs_hook=unique_object)
57
+ if not isinstance(value,dict) or set(value)!=fields:
58
+ raise ValueError('invalid body')
59
+ except HTTPException:
60
+ raise
61
+ except Exception:
62
+ raise HTTPException(400,'Invalid storage request') from None
63
+ finally:
64
+ chunks.clear()
65
+ if not valid_bucket_id(value.get('bucket_id')):
66
+ raise HTTPException(400,'Invalid storage request')
67
+ return value
68
+
69
+ def bucket_http_error(exc:BucketStorageError):
70
+ status={'unauthorized':401,'forbidden':403,'not_found':404,'invalid_request':400,
71
+ 'invalid_bundle':400,'storage_unavailable':502,'timeout':504}.get(exc.category,502)
72
+ detail=('Storage credential is invalid' if status==401 else
73
+ 'Storage access is unavailable' if status==403 else
74
+ 'Invalid portable bundle' if exc.category=='invalid_bundle' else
75
+ 'Storage operation timed out' if status==504 else
76
+ 'Storage operation failed')
77
+ return HTTPException(status,detail)
78
+
79
+ async def bucket_operation(request:Request, operation:str, *, fields:set[str], limit:int=4096):
80
+ token=bucket_token(request)
81
+ if not bucket_admission.acquire(blocking=False):
82
+ raise HTTPException(429,'Storage operation busy')
83
+ try:
84
+ body=await bucket_body(request,token,limit=limit,fields=fields)
85
+ if operation=='save':
86
+ bundle=body.get('bundle')
87
+ if not isinstance(bundle,str) or len(bundle.encode('utf-8'))>MAX_BUNDLE:
88
+ raise HTTPException(400,'Invalid portable bundle')
89
+ try: load_bundle(bundle.encode('utf-8'))
90
+ except BundleError: raise HTTPException(400,'Invalid portable bundle') from None
91
+ call_args={'bundle_base64':base64.b64encode(bundle.encode('utf-8')).decode('ascii')}
92
+ elif operation=='restore':
93
+ if not valid_bundle_key(body.get('key')):
94
+ raise HTTPException(400,'Invalid storage request')
95
+ call_args={'key':body['key']}
96
+ else:
97
+ call_args={}
98
+ try:
99
+ result=await run_in_threadpool(run_bucket_operation,operation,token,body['bucket_id'],**call_args)
100
+ except BucketStorageError as exc:
101
+ raise bucket_http_error(exc) from None
102
+ return result
103
+ finally:
104
+ bucket_admission.release()
105
+
106
+ @app.post('/api/bucket/connect')
107
+ async def bucket_connect(request:Request):
108
+ result=await bucket_operation(request,'connect',fields={'bucket_id'})
109
+ return JSONResponse(result,headers={'Cache-Control':'no-store'})
110
+
111
+ @app.post('/api/bucket/list')
112
+ async def bucket_list(request:Request):
113
+ result=await bucket_operation(request,'list',fields={'bucket_id'})
114
+ return JSONResponse(result,headers={'Cache-Control':'no-store'})
115
+
116
+ @app.post('/api/bucket/save')
117
+ async def bucket_save(request:Request):
118
+ result=await bucket_operation(request,'save',fields={'bucket_id','bundle'},limit=BUCKET_MAX_REQUEST)
119
+ return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')},headers={'Cache-Control':'no-store'})
120
+
121
+ @app.post('/api/bucket/restore')
122
+ async def bucket_restore(request:Request):
123
+ result=await bucket_operation(request,'restore',fields={'bucket_id','key'})
124
+ return Response(result['bundle_bytes'],media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
125
  @app.get('/api/tracks')
126
  def track_catalog():
127
  return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})