openenv/echo_env
6
1"""`openenv harbor` — run Harbor tasks with token-level capture.2 3Four commands, in the order you would use them:4 5 openenv harbor info what can this machine run right now?6 openenv harbor rollout --task-index 0 one rollout, end to end, no server7 openenv harbor serve the env server, for trainers and clients8 openenv harbor push the same server, deployed to a Space9 10`info` and `rollout` exist so the whole path can be exercised without standing up a server, which11makes the failure surface much smaller when something is wrong: if `rollout` works and `serve` does12not, the problem is the serving layer, not Harbor, the sandbox, the agent or capture.13 14Examples:15 16```bash17# what is usable, given the credentials on this machine18openenv harbor info --llm-url $LLM --dataset AdithyaSK/data_agent_rl_environment_eval19 20# one rollout on E2B with opencode21openenv harbor rollout \\22 --llm-url $LLM \\23 --dataset AdithyaSK/data_agent_rl_environment_eval \\24 --task-index 0 --harness opencode --sandbox e2b25 26# the same task on Modal with codex — harness and sandbox are per-rollout27openenv harbor rollout --llm-url $LLM --dataset $DS \\28 --task-index 0 --harness codex --sandbox modal29```30"""31 32from __future__ import annotations33 34import asyncio35import contextlib36import json37import os38import shutil39import tempfile40from pathlib import Path41from typing import Annotated, Any, Optional42 43import typer44 45app = typer.Typer(46 name="harbor",47 help="Run Harbor tasks with token-level capture",48 no_args_is_help=True,49)50 51_DATASET_HELP = (52 "Dataset spec: HF repo id, local dir, or Harbor `name@version`. Repeatable."53)54_LLM_HELP = "OpenAI-spec inference endpoint (vLLM). Required: there is no default, because a\nwrong or stale endpoint produces rollouts that look fine and carry no token ids."55_LLM_HELP_OPTIONAL = (56 "OpenAI-spec inference endpoint. Optional here: without it, `info` "57 "still reports sandboxes, datasets and harnesses."58)59_LLM_HELP_PUSH = (60 "The Space's own inference endpoint. Optional: without one, visitors connect their own model "61 "in the UI (see --visitor-endpoints)."62)63_KEY_HELP = (64 "Credential for the inference endpoint, for a hosted provider (OpenAI, Anthropic, HF "65 "Inference Providers). Defaults to $OPENENV_LLM_API_KEY. This is NOT the key the agent "66 "gets: that one is a capture session id, minted per rollout, and this value never leaves "67 "the server process."68)69_AUTH_HEADER_HELP = (70 "Header to send --api-key under. `Authorization` gets a `Bearer ` prefix; anything else "71 "(e.g. x-api-key) gets the raw key."72)73 74 75_SHARE_HELP = (76 "Let visitors of the UI run on this server's endpoint and key. --no-share-endpoint makes each "77 "visitor connect their own model. Default: shared."78)79_SHARE_HELP_PUSH = (80 "Let visitors of the Space's UI run on its endpoint and key, at your cost. Default: not shared, "81 "so each visitor connects their own model (signing in with Hugging Face, a token, or an endpoint)."82)83_VISITOR_HELP = (84 "Let visitors connect their own model in the UI: a Hugging Face token and model, or any "85 "OpenAI-compatible URL such as vLLM. Default: allowed."86)87_VISIBILITY_HELP = (88 "Who sees runs in the UI: `all` (every visitor sees every run) or `own` (each browser sees the "89 "runs it started). Default: all locally, own on a Space."90)91_HISTORY_HELP = "Keep finished UI runs on disk across restarts. Default: on locally, off on a Space."92_ROLLOUTS_HELP = (93 "Let visitors start rollouts from the UI. --no-rollouts makes it a read-only task browser. "94 "Default: on."95)96_PRIVATE_URLS_HELP = (97 "Let visitors connect an endpoint on a private or local address, such as http://localhost:8000/v1. "98 "Default: allowed only when the server listens on 127.0.0.1, since --host 0.0.0.0 makes this "99 "server call those addresses for anyone who can reach it."100)101_ADD_HELP = (102 "Let visitors add and remove Hub datasets from the UI. On a Space with a bucket they are copied "103 "into it; otherwise downloaded. Default: on only for a server on 127.0.0.1."104)105_UI_VARIABLES = (106 "OPENENV_HARBOR_UI_SERVER_ENDPOINT",107 "OPENENV_HARBOR_UI_VISITOR_ENDPOINTS",108 "OPENENV_HARBOR_RUN_HISTORY",109 "OPENENV_HARBOR_UI_ADD_DATASETS",110 "OPENENV_HARBOR_UI_ROLLOUTS",111 "OPENENV_HARBOR_RUN_VISIBILITY",112 "OPENENV_HARBOR_REWARD_KEY",113)114_REWARD_KEY_HELP = (115 "Which reward UI runs report for tasks with several and none named `reward`, or a "116 "comma-separated preference order. Unset, such a run shows each of them instead."117)118 119 120def _ui_env(121 share_endpoint: Optional[bool],122 visitor_endpoints: Optional[bool],123 run_visibility: str,124 run_history: Optional[bool],125 add_datasets: Optional[bool] = None,126 rollouts: Optional[bool] = None,127 private_urls: Optional[bool] = None,128 reward_key: str = "",129) -> dict[str, str]:130 """The UI's deployment settings as the variables `openenv.harbor.ui_settings` reads.131 132 Only flags that were given become variables, so an unset one keeps the default for where the133 server runs (a laptop or a Space).134 """135 if run_visibility and run_visibility not in ("all", "own"):136 raise typer.BadParameter("--run-visibility is `all` or `own`")137 out = {}138 for name, value in (139 ("OPENENV_HARBOR_UI_SERVER_ENDPOINT", share_endpoint),140 ("OPENENV_HARBOR_UI_VISITOR_ENDPOINTS", visitor_endpoints),141 ("OPENENV_HARBOR_RUN_HISTORY", run_history),142 ("OPENENV_HARBOR_UI_ADD_DATASETS", add_datasets),143 ("OPENENV_HARBOR_UI_ROLLOUTS", rollouts),144 # `serve` only, so not one of `_UI_VARIABLES`: on a Space these addresses are its own network145 ("OPENENV_HARBOR_UI_PRIVATE_URLS", private_urls),146 ):147 if value is not None:148 out[name] = "1" if value else "0"149 if run_visibility:150 out["OPENENV_HARBOR_RUN_VISIBILITY"] = run_visibility151 if reward_key.strip():152 out["OPENENV_HARBOR_REWARD_KEY"] = reward_key.strip()153 return out154 155 156def _remove_omitted_ui_variables(repo_id: str, supplied: dict[str, str]) -> None:157 """Reset omitted UI flags to deployment defaults on an incremental Space push.158 159 Space variables survive a push. Without this reconciliation, one old `--share-endpoint` remains160 enabled forever even when later pushes omit it and the CLI says the Space default is not shared.161 Only variables owned by this command are removed; unrelated operator configuration and every162 secret are left alone.163 """164 from huggingface_hub import HfApi165 166 api = HfApi()167 existing = api.get_space_variables(repo_id)168 for key in _UI_VARIABLES:169 if key in existing and key not in supplied:170 api.delete_space_variable(repo_id=repo_id, key=key)171 print(f"variable {key} reset to its Space default")172 173 174def _split(values: Optional[list[str]]) -> list[str]:175 """Accept both `--dataset a --dataset b` and `--dataset a,b`."""176 out: list[str] = []177 for value in values or []:178 out.extend(v.strip() for v in value.split(",") if v.strip())179 return out180 181 182def _hub_not_found(exc: Exception) -> bool:183 """Whether a Hub operation failed specifically because its resource does not exist."""184 if isinstance(exc, FileNotFoundError):185 return True186 response = getattr(exc, "response", None)187 return getattr(response, "status_code", None) == 404188 189 190@app.command("info")191def info(192 llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP_OPTIONAL)] = "",193 model: Annotated[194 str,195 typer.Option(196 "--model",197 help="Served model id. Auto-detected if the engine serves exactly one.",198 ),199 ] = "",200 dataset: Annotated[201 Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP)202 ] = None,203 api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "",204 auth_header: Annotated[205 str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP)206 ] = "Authorization",207 env_file: Annotated[208 str, typer.Option("--env-file", help="dotenv file with provider credentials.")209 ] = "",210 verbose: Annotated[211 bool,212 typer.Option("--verbose", help="List every harness, not only validated ones."),213 ] = False,214 json_output: Annotated[215 bool, typer.Option("--json", help="Emit machine-readable JSON.")216 ] = False,217) -> None:218 """Report engine, sandboxes, datasets and harnesses available here."""219 from openenv.harbor.startup import prepare220 221 caps = prepare(222 llm_url=llm_url or None,223 model=model or None,224 datasets=_split(dataset) or None,225 env_file=env_file or None,226 require_llm=False,227 quiet=True,228 api_key=api_key or None,229 auth_header=auth_header,230 )231 print(232 json.dumps(caps.to_dict(), indent=2)233 if json_output234 else caps.render(verbose=verbose)235 )236 237 238@app.command("rollout")239def rollout(240 llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP)],241 model: Annotated[str, typer.Option("--model", help="Served model id.")] = "",242 dataset: Annotated[243 Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP)244 ] = None,245 task_index: Annotated[246 int, typer.Option("--task-index", help="Index into the split.")247 ] = 0,248 harness: Annotated[249 str, typer.Option("--harness", help="Seam name, or `module:Class`.")250 ] = "opencode",251 sandbox: Annotated[252 str,253 typer.Option(254 "--sandbox", help="Harbor environment type, e.g. e2b | modal | docker."255 ),256 ] = "e2b",257 n: Annotated[258 int, typer.Option("-n", "--n-tasks", help="Run this many consecutive tasks.")259 ] = 1,260 port: Annotated[261 int, typer.Option("--port", help="Local port for the capture proxy.")262 ] = 8100,263 expose: Annotated[264 str,265 typer.Option(266 "--expose",267 help="How the sandbox reaches the capture proxy: gradio | cloudflare | direct.",268 ),269 ] = "gradio",270 trials_dir: Annotated[271 str, typer.Option("--trials-dir", help="Where Harbor writes trial artifacts.")272 ] = "",273 reward_key: Annotated[274 str,275 typer.Option("--reward-key", help="Which reward key is the training signal."),276 ] = "",277 keep_sandbox: Annotated[278 bool,279 typer.Option("--keep-sandbox", help="Leave sandboxes alive for debugging."),280 ] = False,281 force_build: Annotated[282 bool,283 typer.Option(284 "--force-build",285 help="Rebuild the sandbox image, bypassing the content-hash cache. Needed when a task pins deps loosely and its cached image has drifted.",286 ),287 ] = False,288 api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "",289 auth_header: Annotated[290 str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP)291 ] = "Authorization",292 env_file: Annotated[293 str, typer.Option("--env-file", help="dotenv file with provider credentials.")294 ] = "",295 out: Annotated[str, typer.Option("--out", help="Write the result JSON here.")] = "",296) -> None:297 """Run one or more rollouts without starting a server."""298 from openenv.harbor.runner import run_batch299 300 datasets = _split(dataset)301 if not datasets:302 raise typer.BadParameter("--dataset is required")303 304 results = asyncio.run(305 run_batch(306 llm_url=llm_url,307 model=model or None,308 dataset=datasets[0],309 task_indices=list(range(task_index, task_index + max(1, n))),310 harness=harness,311 sandbox=sandbox,312 port=port,313 expose=expose,314 trials_dir=Path(trials_dir) if trials_dir else None,315 reward_key=reward_key,316 keep_sandbox=keep_sandbox,317 force_build=force_build,318 env_file=env_file or None,319 api_key=api_key or None,320 auth_header=auth_header,321 )322 )323 324 if out:325 Path(out).write_text(json.dumps([r.model_dump() for r in results], indent=2))326 print(f"\nwrote {out}")327 raise typer.Exit(0 if all(r.ok for r in results) else 1)328 329 330@app.command("serve")331def serve(332 llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP)] = "",333 model: Annotated[str, typer.Option("--model", help="Served model id.")] = "",334 dataset: Annotated[335 Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP)336 ] = None,337 max_output_tokens: Annotated[338 int,339 typer.Option(340 "--max-output-tokens",341 help=(342 "Cap what an AGENT may request per turn. The default of 8192 is exactly what some "343 "harnesses ask for (opencode), so the clamp does nothing for them and their first "344 "call can exceed a small context window. A real agent turn is short; 4096 is ample."345 ),346 ),347 ] = 8192,348 host: Annotated[str, typer.Option("--host")] = "0.0.0.0",349 port: Annotated[350 int, typer.Option("--port", help="Env server port (faces the trainer).")351 ] = 8000,352 capture_port: Annotated[353 int,354 typer.Option("--capture-port", help="Capture proxy port (faces the sandbox)."),355 ] = 8100,356 expose: Annotated[357 str,358 typer.Option(359 "--expose",360 help="How the sandbox reaches the capture proxy: gradio | cloudflare | direct.",361 ),362 ] = "gradio",363 api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "",364 auth_header: Annotated[365 str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP)366 ] = "Authorization",367 env_file: Annotated[str, typer.Option("--env-file")] = "",368 share_endpoint: Annotated[369 Optional[bool],370 typer.Option("--share-endpoint/--no-share-endpoint", help=_SHARE_HELP),371 ] = None,372 visitor_endpoints: Annotated[373 Optional[bool],374 typer.Option("--visitor-endpoints/--no-visitor-endpoints", help=_VISITOR_HELP),375 ] = None,376 run_visibility: Annotated[377 str, typer.Option("--run-visibility", help=_VISIBILITY_HELP)378 ] = "",379 run_history: Annotated[380 Optional[bool],381 typer.Option("--run-history/--no-run-history", help=_HISTORY_HELP),382 ] = None,383 add_datasets: Annotated[384 Optional[bool],385 typer.Option("--add-datasets/--no-add-datasets", help=_ADD_HELP),386 ] = None,387 rollouts: Annotated[388 Optional[bool],389 typer.Option("--rollouts/--no-rollouts", help=_ROLLOUTS_HELP),390 ] = None,391 private_urls: Annotated[392 Optional[bool],393 typer.Option("--private-urls/--no-private-urls", help=_PRIVATE_URLS_HELP),394 ] = None,395 reward_key: Annotated[396 str, typer.Option("--reward-key", help=_REWARD_KEY_HELP)397 ] = "",398) -> None:399 """Serve Harbor tasks over the OpenEnv Task API and MCP.400 401 Two ports on purpose. The env server faces the trainer on an internal network; the capture proxy402 faces the sandbox and is the only thing published. Sharing one port would expose the env403 server as soon as the capture proxy became reachable.404 """405 from openenv.harbor.serving import serve_harbor406 407 os.environ.update(408 _ui_env(409 share_endpoint,410 visitor_endpoints,411 run_visibility,412 run_history,413 add_datasets,414 rollouts,415 private_urls,416 reward_key,417 )418 )419 # The UI's laptop defaults (this machine's token, private URLs) are for a loopback-only server.420 os.environ["OPENENV_HARBOR_UI_HOST"] = host421 serve_harbor(422 llm_url=llm_url,423 model=model or None,424 max_output_tokens=max_output_tokens or None,425 datasets=_split(dataset),426 host=host,427 port=port,428 capture_port=capture_port,429 expose=expose,430 env_file=env_file or None,431 api_key=api_key or None,432 auth_header=auth_header,433 )434 435 436@app.command("push")437def push(438 llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP_PUSH)] = "",439 repo_id: Annotated[440 str, typer.Option("--repo-id", help="Target, e.g. your-org/harbor-env.")441 ] = "",442 model: Annotated[str, typer.Option("--model", help="Served model id.")] = "",443 dataset: Annotated[444 Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP)445 ] = None,446 private: Annotated[447 bool,448 typer.Option(449 "--private",450 help="Create the Space private. The sandbox then cannot reach the capture proxy, so rollouts are not possible; use it only to park a deployment.",451 ),452 ] = False,453 hardware: Annotated[454 str, typer.Option("--hardware", help="Space hardware, e.g. cpu-basic.")455 ] = "",456 api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "",457 auth_header: Annotated[458 str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP)459 ] = "Authorization",460 env_file: Annotated[461 str,462 typer.Option(463 "--env-file", help="dotenv whose provider keys become Space SECRETS."464 ),465 ] = "",466 bucket: Annotated[467 str,468 typer.Option(469 "--bucket",470 help="Storage bucket holding the task suites. Defaults to a bucket named after the Space. Pass `none` to skip the bucket and let the Space download datasets instead.",471 ),472 ] = "",473 public_bucket: Annotated[474 Optional[bool],475 typer.Option(476 "--public-bucket/--private-bucket",477 help="Visibility of the Space's bucket. Private unless --public-bucket: it holds the task "478 "suites and, with run history on, every visitor's runs. Given for an existing bucket, it "479 "changes that bucket's visibility; not given, an existing bucket is left as it is.",480 ),481 ] = None,482 hf_login: Annotated[483 bool,484 typer.Option(485 "--hf-login/--no-hf-login",486 help="Let visitors sign in with Hugging Face to use their own account for Inference "487 "Providers (the `inference-api` scope), instead of pasting a token.",488 ),489 ] = True,490 recreate: Annotated[491 bool,492 typer.Option(493 "--recreate",494 help="Delete the Space first, then deploy fresh. A Space keeps variables, secrets, volumes and any file a previous push wrote, so an incremental deploy is not a clean test of what this bundle produces.",495 ),496 ] = False,497 dry_run: Annotated[498 bool, typer.Option("--dry-run", help="Show what would be pushed and stop.")499 ] = False,500 share_endpoint: Annotated[501 Optional[bool],502 typer.Option("--share-endpoint/--no-share-endpoint", help=_SHARE_HELP_PUSH),503 ] = None,504 visitor_endpoints: Annotated[505 Optional[bool],506 typer.Option("--visitor-endpoints/--no-visitor-endpoints", help=_VISITOR_HELP),507 ] = None,508 run_visibility: Annotated[509 str, typer.Option("--run-visibility", help=_VISIBILITY_HELP)510 ] = "",511 run_history: Annotated[512 Optional[bool],513 typer.Option("--run-history/--no-run-history", help=_HISTORY_HELP),514 ] = None,515 add_datasets: Annotated[516 Optional[bool],517 typer.Option("--add-datasets/--no-add-datasets", help=_ADD_HELP),518 ] = None,519 rollouts: Annotated[520 Optional[bool],521 typer.Option("--rollouts/--no-rollouts", help=_ROLLOUTS_HELP),522 ] = None,523 reward_key: Annotated[524 str, typer.Option("--reward-key", help=_REWARD_KEY_HELP)525 ] = "",526) -> None:527 """Deploy this environment to a Hugging Face Space.528 529 Takes the same arguments as `serve`, because a deployed Space needs exactly the same530 configuration — they are forwarded as Space variables, while provider credentials from531 `--env-file` are forwarded as Space *secrets* so they are not readable from the repo.532 """533 from pathlib import Path534 535 from openenv.cli.commands.push import push as _push536 from openenv.harbor.startup import load_env_file537 538 if not repo_id:539 raise typer.BadParameter("--repo-id is required, e.g. your-org/harbor-env")540 541 datasets = _split(dataset)542 ui_variables = _ui_env(543 share_endpoint,544 visitor_endpoints,545 run_visibility,546 run_history,547 add_datasets,548 rollouts,549 reward_key=reward_key,550 )551 if not share_endpoint and visitor_endpoints is False:552 # On a Space the endpoint is shared only when asked, so unset counts as not shared.553 raise typer.BadParameter(554 "--no-visitor-endpoints needs --share-endpoint: on a Space visitors use its endpoint "555 "only when it is shared, so they would have no model to run with."556 )557 if not llm_url and visitor_endpoints is False:558 raise typer.BadParameter(559 "--llm-url is required with --no-visitor-endpoints: a Space with no engine of its own "560 "that accepts none from visitors cannot run anything, and finding that out after "561 "deploying is worse than finding it out now."562 )563 if not llm_url:564 print(565 "NOTE: no --llm-url. Visitors run rollouts on a model they connect themselves (a "566 "Hugging Face token, or their own endpoint)."567 )568 elif share_endpoint is None:569 print(570 "NOTE: UI visitors connect their own model; the endpoint serves the Task API and MCP. "571 "Pass --share-endpoint to let them run on it too, at your cost."572 )573 574 if private:575 # A hosted deployment serves the capture proxy at <space-url>/capture. On a private Space576 # that URL demands an auth header the agent inside the sandbox does not send, so every model577 # call 401s and the rollout records nothing. Worth a warning rather than a hard failure:578 # parking a private deployment is legitimate, running rollouts against one is not.579 print(580 "WARNING: a private Space is not reachable from a sandbox. The capture proxy is "581 "served at <space-url>/capture, and a private Space requires an auth header the "582 "agent will not send, so rollouts will capture no model calls. Deploy public for "583 "rollouts. The proxy still refuses callers without a registered session id, which "584 "is what keeps a public deployment from being an open relay."585 )586 587 # HF datasets are attached as read-only volumes rather than downloaded. A Harbor suite is588 # thousands of small files (13k+ for a 2.2k-task dataset), so downloading on first request takes589 # minutes and burns the Space's ephemeral disk; a mount is instant and survives restarts. The590 # server needs no special case for it, because a mount path is just a local directory and591 # `resolve_task_dirs` already accepts one.592 # Every suite lives under one bucket, mounted at /data, named after the Space so the two stay593 # obviously paired. `--bucket none` opts out and falls back to downloading.594 hf_specs = [spec for spec in datasets if _looks_like_hf_repo(spec)]595 bucket = "" if bucket.lower() == "none" else (bucket or repo_id)596 mounts = {spec: f"{_MOUNT_ROOT}/{spec.replace('/', '__')}" for spec in hf_specs}597 598 # Non-secret configuration travels as plain Space variables.599 variables = {600 "OPENENV_LLM_URL": llm_url,601 "ENABLE_WEB_INTERFACE": "true",602 **ui_variables,603 }604 if bucket:605 # Where the UI puts datasets added from the page: a server-side copy into this bucket,606 # read back through the mount, as for the datasets pushed here.607 variables["OPENENV_HARBOR_BUCKET"] = bucket608 variables["OPENENV_HARBOR_BUCKET_MOUNT"] = _MOUNT_ROOT609 if datasets:610 variables["OPENENV_DATASETS"] = ",".join(datasets)611 if model:612 variables["OPENENV_MODEL"] = model613 # The header NAME is configuration, not a credential, so it belongs here. Its value never is.614 if auth_header and auth_header != "Authorization":615 variables["OPENENV_LLM_AUTH_HEADER"] = auth_header616 617 # Provider credentials travel as secrets. Only the keys the sandboxes need — never the whole618 # dotenv, which usually holds unrelated tokens.619 load_env_file(env_file or None)620 # Sandbox credentials, plus the keys a task's own verifier may need. A grader that cannot run621 # returns no reward at all, which is reported as `reward=None` rather than 0, so the rollout is622 # correctly not scored as a wrong answer, but it is also not usable for training. The DataAgent623 # grader reads OPENAI_API_KEY for its LLM-judge tier, and without it every semantically correct624 # answer that is not an exact string match goes ungraded.625 wanted = (626 "E2B_API_KEY",627 "MODAL_TOKEN_ID",628 "MODAL_TOKEN_SECRET",629 "DAYTONA_API_KEY",630 "HF_TOKEN",631 "OPENAI_API_KEY",632 "ANTHROPIC_API_KEY",633 "GEMINI_API_KEY",634 # The upstream inference credential. A SECRET rather than a variable: Space variables are635 # readable from the repo page, and this one buys inference against a paid endpoint.636 "OPENENV_LLM_API_KEY",637 )638 secrets = {k: os.environ[k] for k in wanted if os.environ.get(k)}639 # An --api-key passed on the command line outranks the dotenv, matching every other command.640 if api_key:641 secrets["OPENENV_LLM_API_KEY"] = api_key642 643 # src/openenv/cli/commands/harbor.py -> repo root is parents[4].644 # An installed wheel has no sibling envs/ dir, so fall back to $OPENENV_HARBOR_ENV_DIR.645 env_dir = Path(646 os.environ.get("OPENENV_HARBOR_ENV_DIR")647 or Path(__file__).resolve().parents[4] / "envs" / "harbor_env"648 )649 if not (env_dir / "openenv.yaml").is_file():650 raise typer.BadParameter(651 f"no harbor_env package at {env_dir}. Set OPENENV_HARBOR_ENV_DIR to its location "652 "(an installed openenv wheel does not ship the envs/ directory)."653 )654 # `openenv.harbor` does not exist in any released wheel, so a Space that pip-installs `openenv`655 # imports the release and dies on `No module named 'openenv.harbor'`. When pushing from a source656 # checkout, bundle the working tree instead; the Dockerfile puts /app/env ahead of site-packages.657 source_pkg = Path(__file__).resolve().parents[2] # .../src/openenv658 bundle = source_pkg if (source_pkg / "harbor").is_dir() else None659 660 print(f"env {env_dir}")661 print(f"repo {repo_id}{' (private)' if private else ''}")662 print(f"llm {llm_url}")663 print(664 f"datasets {', '.join(datasets) or '(none, set OPENENV_DATASETS on the Space)'}"665 )666 print(f"variables {sorted(variables)}")667 print(f"secrets {sorted(secrets)} (values never printed)")668 print(f"openenv {'bundled from ' + str(source_pkg) if bundle else 'from PyPI'}")669 if bucket:670 print(f"bucket {bucket} -> {_MOUNT_ROOT} ({len(hf_specs)} suite(s))")671 for spec, path in mounts.items():672 print(673 f"mount {spec} -> {path}"674 + (" (via bucket)" if bucket else " (read-only, not downloaded)")675 )676 if dry_run:677 print("\ndry run: nothing pushed")678 return679 680 if recreate:681 _delete_space(repo_id)682 683 if bucket:684 # Created even with no datasets to copy: datasets added from the page go into it too.685 _fill_bucket(bucket, hf_specs, public=public_bucket)686 687 with tempfile.TemporaryDirectory(prefix="openenv-harbor-push-") as tmp:688 staged = Path(tmp) / "env"689 shutil.copytree(690 env_dir,691 staged,692 ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".venv"),693 )694 if bundle is not None:695 # A hosted Space mounts the capture proxy on its own app and reaches it at the Space's696 # public URL, so the forwarding backends are dead code there. They are excluded rather697 # than merely unused: shipping code that shells out to `cloudflared` into a Space is698 # both pointless and the kind of thing platform abuse checks reject. `cli` goes for the699 # same reason, it is 36 files the server never imports.700 shutil.copytree(701 bundle,702 staged / "openenv",703 ignore=shutil.ignore_patterns(704 "__pycache__", "*.pyc", "forwarding.py", "cli"705 ),706 )707 if hf_login:708 _enable_hf_login(staged / "README.md")709 _prune_removed_files(repo_id, staged)710 _push(711 directory=str(staged),712 repo_id=repo_id,713 private=private,714 hardware=hardware or None,715 env_vars=[f"{k}={v}" for k, v in variables.items()],716 secrets=[f"{k}={v}" for k, v in secrets.items()],717 )718 _remove_omitted_ui_variables(repo_id, ui_variables)719 720 # After the push, because volumes attach to a Space that already exists and `--recreate` has721 # just deleted it. Setting them triggers one more rebuild, which is why this is last.722 attached = (723 _attach_bucket(repo_id, bucket, mounts)724 if bucket725 else _mount_datasets(repo_id, mounts)726 )727 if attached:728 # Only now is it safe to point the server at mount paths. Until the mount is confirmed,729 # `OPENENV_DATASETS` holds repo ids, so an unattached volume degrades to downloading rather730 # than to a server pointed at directories that do not exist.731 from huggingface_hub import HfApi732 733 HfApi().add_space_variable(734 repo_id=repo_id,735 key="OPENENV_DATASETS",736 value=",".join(mounts.get(d, d) for d in datasets),737 )738 print("mount OPENENV_DATASETS switched to mount paths")739 740 741# What visitors may sign in for: Inference Providers, billed to their own account. Nothing else.742_HF_LOGIN = (743 "hf_oauth: true",744 "hf_oauth_scopes:",745 " - inference-api",746 "hf_oauth_expiration_minutes: 480",747)748 749 750def _enable_hf_login(readme: Path) -> None:751 """Turn on "Sign in with Hugging Face" for the Space, in its README front matter.752 753 The Hub then provides the OAuth app (`OAUTH_CLIENT_ID` and friends), and the UI offers sign-in754 as a way to use Inference Providers with the visitor's own account.755 """756 text = readme.read_text() if readme.is_file() else ""757 if text.startswith("---"):758 end = text.index("\n---", 3)759 # Only the front matter counts: the README's own text may well mention `hf_oauth:`.760 if any(line.startswith("hf_oauth:") for line in text[:end].splitlines()):761 return762 text = text[:end] + "\n" + "\n".join(_HF_LOGIN) + text[end:]763 else:764 text = "---\n" + "\n".join(_HF_LOGIN) + "\n---\n\n" + text765 readme.write_text(text)766 767 768def _prune_removed_files(repo_id: str, staged: Path) -> None:769 """Delete files on the Space that this push no longer produces.770 771 `push` uploads but never deletes, so a file dropped from the bundle keeps running in the772 deployment forever. That is not a tidiness point: the first version of this command shipped the773 port-forwarding backends, and removing them locally left the deployed Space still carrying code774 that shells out to `cloudflared`, which is exactly what a platform abuse check objects to. A775 deployment has to reflect the bundle, not the union of every bundle ever pushed.776 777 Only the bundled `openenv/` subtree is pruned. Everything else in the Space may legitimately have778 been added out of band (a README edit through the web UI, a `.gitattributes`), and deleting a779 file this command never wrote is not its business.780 """781 from huggingface_hub import CommitOperationDelete, HfApi782 783 api = HfApi()784 try:785 remote = api.list_repo_files(repo_id, repo_type="space")786 except Exception as exc: # noqa: BLE001 - a new Space has nothing to prune787 print(f"prune skipped ({type(exc).__name__}); the Space may not exist yet")788 return789 790 local = {str(p.relative_to(staged)) for p in staged.rglob("*") if p.is_file()}791 stale = sorted(f for f in remote if f.startswith("openenv/") and f not in local)792 if not stale:793 return794 795 print(f"prune {len(stale)} file(s) no longer in the bundle, e.g. {stale[0]}")796 api.create_commit(797 repo_id=repo_id,798 repo_type="space",799 operations=[CommitOperationDelete(path_in_repo=f) for f in stale],800 commit_message="Remove files no longer part of the harbor_env bundle",801 )802 803 804# Where dataset volumes are attached inside the Space container.805_MOUNT_ROOT = "/data"806 807# Mirrors `openenv.harbor.tasks._DATASET_ROOT`; kept local so the CLI does not import the808# harbor extra just to compute a path.809_DATASET_CACHE = Path(810 os.environ.get("OPENENV_DATASET_CACHE")811 or (Path.home() / ".cache" / "openenv" / "harbor-datasets")812)813 814 815def _looks_like_hf_repo(spec: str) -> bool:816 """True for `owner/name`, false for a local path or a Harbor `name@version`."""817 return (818 spec.count("/") == 1819 and "@" not in spec820 and not spec.startswith((".", "/", "~"))821 )822 823 824def _mount_datasets(repo_id: str, mounts: dict[str, str]) -> bool:825 """Attach each dataset repo to the Space as a read-only volume.826 827 Downloading a Harbor suite inside a Space is the slow path twice over: thousands of small files828 fetched one round trip at a time, onto a disk that is wiped on restart, so the cost is paid again829 on every rebuild. A mounted repo is available as ordinary files immediately.830 831 Volumes are replaced wholesale by the API, so anything already attached is read first and kept.832 833 Returns:834 `bool`: Whether the volumes are confirmed attached. `False` means the caller must keep using835 repo ids and let the Space download, which is slower but works.836 """837 if not mounts:838 return False839 try:840 from huggingface_hub import HfApi, Volume841 except ImportError:842 print(843 "mount skipped: this huggingface_hub has no Volume support; "844 "the Space will download datasets instead"845 )846 return False847 848 api = HfApi()849 existing: list[Any] = []850 with contextlib.suppress(Exception):851 existing = [852 v853 for v in _attached_volumes(api, repo_id)854 if getattr(v, "mount_path", None) not in set(mounts.values())855 ]856 857 volumes = existing + [858 Volume(type="dataset", source=spec, mount_path=path, read_only=True)859 for spec, path in sorted(mounts.items())860 ]861 try:862 api.set_space_volumes(repo_id=repo_id, volumes=volumes)863 except Exception as exc: # noqa: BLE001 - a Space that cannot mount still works by downloading864 print(865 f"mount failed ({type(exc).__name__}: {str(exc)[:160]}). The Space will download "866 "datasets instead, which is slow but functional."867 )868 return False869 870 # Accepting the call is not evidence that the volume exists. Read it back, because the failure871 # mode of trusting it is a server configured to read directories that were never mounted.872 attached = _attached_mount_paths(api, repo_id)873 if not set(mounts.values()) <= attached:874 print(875 "mount not confirmed: the Space reports no attached volumes, so the datasets will "876 "be downloaded instead. Attach them from the Space settings if you want the mount."877 )878 return False879 print(f"mount attached {len(mounts)} dataset volume(s)")880 return True881 882 883def _delete_space(repo_id: str) -> None:884 """Delete the Space so the next push is a clean deployment.885 886 A Space accumulates state a push does not own: variables and secrets set by earlier runs, mounted887 volumes, and every file any previous push wrote. That makes an incremental deploy a poor test,888 because it can succeed on leftovers the bundle no longer produces. Deleting first means what runs889 is exactly what this command uploaded.890 891 Deliberately destructive, so it only ever happens behind `--recreate`.892 """893 from huggingface_hub import HfApi894 895 api = HfApi()896 try:897 api.delete_repo(repo_id=repo_id, repo_type="space")898 print(f"recreate deleted {repo_id}")899 except Exception as exc: # noqa: BLE001 - nothing to delete is the expected first-run case900 print(f"recreate nothing to delete ({type(exc).__name__})")901 902 903def _fill_bucket(bucket: str, specs: list[str], public: bool | None = None) -> None:904 """Create `bucket` if missing and copy each task suite into it, server side.905 906 A new bucket is private unless `public` is `True`. An existing one keeps its visibility unless907 `public` says otherwise: changing who can read a bucket is not something to do by default.908 909 `copy_files` copies by xet hash: the Hub moves the references, nothing is downloaded here and910 nothing is re-uploaded. That is the difference between seconds and the ~47k-file upload a local911 sync performs, and it is why the bucket is filled before the Space exists rather than after.912 913 Suites already present are skipped, so adding a dataset to a later `push` copies only the new914 one and leaves the rest untouched.915 """916 from huggingface_hub import HfApi917 918 api = HfApi()919 try:920 existing = api.bucket_info(bucket)921 except Exception as exc: # noqa: BLE001 - Hub exception classes vary across client versions922 if not _hub_not_found(exc):923 raise924 existing = None925 if existing is None:926 api.create_bucket(bucket, private=not public, exist_ok=True)927 print(f"bucket {bucket} created ({'public' if public else 'private'})")928 else:929 private = bool(existing.private)930 if public is not None and private == public:931 api.update_bucket_settings(bucket, private=not public)932 print(f"bucket {bucket} is now {'public' if public else 'private'}")933 elif not private and public is None:934 print(935 f"bucket {bucket} is PUBLIC. Anyone can read it, including run history if that is "936 "on. Pass --private-bucket to make it private."937 )938 939 try:940 present = {941 entry.path.split("/", 1)[0]942 for entry in api.list_bucket_tree(bucket)943 if getattr(entry, "path", "")944 }945 except Exception: # noqa: BLE001 - a brand new bucket may not be listable yet946 present = set()947 948 for spec in specs:949 prefix = spec.replace("/", "__")950 if prefix in present:951 print(f"bucket {spec} already present, skipped")952 continue953 print(954 f"copy hf://datasets/{spec} -> hf://buckets/{bucket}/{prefix} (server side)"955 )956 api.copy_files(f"hf://datasets/{spec}/", f"hf://buckets/{bucket}/{prefix}/")957 958 959def _attach_bucket(repo_id: str, bucket: str, mounts: dict[str, str]) -> bool:960 """Mount `bucket` on the Space and confirm it attached.961 962 Returns:963 `bool`: Whether the mount is confirmed. `False` leaves the caller on repo ids so the Space964 downloads rather than reading a mount that may not be there.965 """966 from huggingface_hub import HfApi, Volume967 968 api = HfApi()969 api.set_space_volumes(970 repo_id=repo_id,971 volumes=[Volume(type="bucket", source=bucket, mount_path=_MOUNT_ROOT)],972 )973 if _MOUNT_ROOT not in _attached_mount_paths(api, repo_id):974 print(975 f"mount not confirmed: no volume at {_MOUNT_ROOT}. Datasets will be downloaded "976 "instead. Attach the bucket from the Space settings to use the mount."977 )978 return False979 print(f"mount {bucket} attached at {_MOUNT_ROOT}")980 return bool(mounts)981 982 983def _attached_volumes(api: Any, repo_id: str) -> list[Any]:984 """Volumes currently mounted on `repo_id`.985 986 Read through `space_info().runtime`, not `get_space_runtime()`. The latter is served by an987 endpoint that does not carry a `volumes` key at all, so it always answers `None` and a check988 built on it reports every mount as missing. That false negative is worse than no check: it makes989 a working mount look broken and sends the caller down the slow path forever.990 """991 with contextlib.suppress(Exception):992 runtime = api.space_info(repo_id).runtime993 if runtime is not None:994 return list(runtime.volumes or [])995 return []996 997 998def _attached_mount_paths(api: Any, repo_id: str) -> set[str]:999 """Mount paths currently attached to `repo_id`."""1000 return {getattr(v, "mount_path", None) for v in _attached_volumes(api, repo_id)}1001 