Team Ai
Apppublic

openenv/echo_env

sourceHugging Faceupdated 1d agoView on Hugging Face
6likes
harbor.py1001 linesDownload Raw Back to commands
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