Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

160 changes: 91 additions & 69 deletions src/agent_env/cli/env/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from agent_env.utils.card_naming import card_name_from_github, card_name_from_source
from agent_env.utils.docker.build import DEFAULT_BUILD_PLATFORM, build_image
from agent_env.env.envs.website import WebsiteEnv
from agent_env.providers.env_providers.constants import AGENT_ENV_WEBSITE_HEALTH_PATH
from agent_env.store.ids import derive_id, image_repository


Expand All @@ -30,14 +31,24 @@ def website():

@website.command()
@click.option("--id", "env_id", required=True, help="Env id")
@click.option("--backend-dockerfile", default=None, type=click.Path(exists=True), help="Path to backend Dockerfile")
@click.option("--backend-docker-context", default=None, type=click.Path(exists=True), help="Backend Docker build context (defaults to Dockerfile's directory)")
@click.option("--backend-dockerfile-github-url", default=None, help="GitHub URL to backend Dockerfile")
@click.option("--backend-docker-context-github-url", default=None, help="GitHub URL to backend build context directory")
@click.option("--frontend-dockerfile", default=None, type=click.Path(exists=True), help="Path to frontend Dockerfile")
@click.option("--server-dockerfile", "--backend-dockerfile", "backend_dockerfile", default=None, type=click.Path(exists=True),
help="Path to the Dockerfile of the server image, which serves the site's pages and API")
@click.option("--server-docker-context", "--backend-docker-context", "backend_docker_context", default=None,
type=click.Path(exists=True), help="The server image's build context (defaults to its Dockerfile's directory)")
@click.option("--server-dockerfile-github-url", "--backend-dockerfile-github-url", "backend_dockerfile_github_url",
default=None, help="GitHub URL to the server image's Dockerfile")
@click.option("--server-docker-context-github-url", "--backend-docker-context-github-url",
"backend_docker_context_github_url", default=None, help="GitHub URL to the server image's build context directory")
@click.option("--server-image-ref", "server_image_ref", default=None,
help="A server image already in a registry, e.g. ghcr.io/org/site:v1: registered as it is, with its tag "
"pinned to the digest the registry serves now (instead of a Dockerfile)")
@click.option("--frontend-dockerfile", default=None, type=click.Path(exists=True),
help="Path to a separate frontend Dockerfile (deprecated: build the pages into the server image)")
@click.option("--frontend-docker-context", default=None, type=click.Path(exists=True), help="Frontend Docker build context (defaults to Dockerfile's directory)")
@click.option("--frontend-dockerfile-github-url", default=None, help="GitHub URL to frontend Dockerfile")
@click.option("--frontend-dockerfile-github-url", default=None, help="GitHub URL to a separate frontend Dockerfile (deprecated)")
@click.option("--frontend-docker-context-github-url", default=None, help="GitHub URL to frontend build context directory")
@click.option("--health-path", default=AGENT_ENV_WEBSITE_HEALTH_PATH, show_default=True,
help="The path that answers 200 once the site is usable")
@environment_name_options
@env_provider_type_option("What deploys the env: 'gateway' (a gateway in front of the website), or the type of an installed "
"agent_env.env_providers plugin", "website")
Expand All @@ -50,36 +61,41 @@ def put(
backend_docker_context: str | None,
backend_dockerfile_github_url: str | None,
backend_docker_context_github_url: str | None,
server_image_ref: str | None,
frontend_dockerfile: str | None,
frontend_docker_context: str | None,
frontend_dockerfile_github_url: str | None,
frontend_docker_context_github_url: str | None,
health_path: str,
environment_name: str | None,
env_provider_type: str,
metadata_pairs: tuple[str, ...],
skip_validation: bool,
build_platform: str,
):
"""Build and upload a website environment (frontend + backend)."""
"""Build and upload a website environment: its server image, which serves the site's pages and API, from a
Dockerfile, GitHub or a registry, and any deprecated separate frontend image."""
environment_name = resolve_environment_name(environment_name, allow_missing=True)

# Validate mutual exclusion
if backend_dockerfile and backend_dockerfile_github_url:
click.echo("Error: --backend-dockerfile and --backend-dockerfile-github-url are mutually exclusive", err=True)
sys.exit(1)
if not backend_dockerfile and not backend_dockerfile_github_url:
click.echo("Error: either --backend-dockerfile or --backend-dockerfile-github-url is required", err=True)
if sum(map(bool, (backend_dockerfile, backend_dockerfile_github_url, server_image_ref))) != 1:
click.echo("Error: exactly one of --server-dockerfile, --server-dockerfile-github-url and --server-image-ref is "
"required", err=True)
sys.exit(1)
if frontend_dockerfile and frontend_dockerfile_github_url:
click.echo("Error: --frontend-dockerfile and --frontend-dockerfile-github-url are mutually exclusive", err=True)
sys.exit(1)
if not frontend_dockerfile and not frontend_dockerfile_github_url:
click.echo("Error: either --frontend-dockerfile or --frontend-dockerfile-github-url is required", err=True)
sys.exit(1)
use_github = bool(backend_dockerfile_github_url)
if use_github != bool(frontend_dockerfile_github_url):
click.echo("Error: backend and frontend must both use local Dockerfiles or both use GitHub URLs", err=True)
if (frontend_dockerfile and not backend_dockerfile) or (frontend_dockerfile_github_url and not use_github):
click.echo("Error: a frontend image is built the way the server image is, both from local Dockerfiles or both "
"from GitHub URLs", err=True)
sys.exit(1)
if server_image_ref and not environment_name:
click.echo("Error: --server-image-ref needs --environment-name, which a registry image's card can't give here",
err=True)
sys.exit(1)
if frontend_dockerfile or frontend_dockerfile_github_url:
click.echo("Warning: a separate frontend image is deprecated; build the site's pages into its server image",
err=True)

user_metadata = {}
for pair in metadata_pairs:
Expand All @@ -88,12 +104,13 @@ def put(
sys.exit(1)
key, value = pair.split("=", 1)
user_metadata[key] = value
site = {"health_path": health_path}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bad health paths waste builds

--health-path is checked only after the image has been built and saved. For example, --health-path healthz finishes a GitHub build before WebsiteEnv.put rejects the missing slash. Local builds and registry registration also save an image artifact before failing. This is non-blocking, but wastes work and leaves saved artifacts from failed commands. Check health_path_problem before starting any build or registry write.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/cli/env/website.py
Line: 107

Comment:
**Bad health paths waste builds**

`--health-path` is checked only after the image has been built and saved. For example, `--health-path healthz` finishes a GitHub build before `WebsiteEnv.put` rejects the missing slash. Local builds and registry registration also save an image artifact before failing. This is non-blocking, but wastes work and leaves saved artifacts from failed commands. Check `health_path_problem` before starting any build or registry write.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex


if use_github:
if environment_name is None:
environment_name = card_name_from_github(backend_dockerfile_github_url, backend_docker_context_github_url, github_token=os.environ.get("GITHUB_TOKEN"))
if not environment_name:
click.echo("Error: could not read @environment_card(name=...) from the backend GitHub source; pass --environment-name.", err=True)
click.echo("Error: could not read @environment_card(name=...) from the server image's GitHub source; pass --environment-name.", err=True)
sys.exit(1)
click.echo(f"Derived environment_name={environment_name!r} from the environment card.")
if build_platform != DEFAULT_BUILD_PLATFORM:
Expand All @@ -105,7 +122,7 @@ def put(
def _redraw():
if lines_printed[0] > 0:
click.echo(f"\033[{lines_printed[0]}A", nl=False)
click.echo(f"\033[2K {click.style('Backend:', fg='cyan')} {backend_status[0]}")
click.echo(f"\033[2K {click.style('Server:', fg='cyan')} {backend_status[0]}")
click.echo(f"\033[2K {click.style('Frontend:', fg='magenta')} {frontend_status[0]}")
lines_printed[0] = 2

Expand All @@ -121,14 +138,15 @@ def _frontend_progress(step: str, message: str, percent: int) -> None:
id=env_id,
backend_dockerfile_github_url=backend_dockerfile_github_url,
backend_docker_context_github_url=backend_docker_context_github_url,
frontend_dockerfile_github_url=frontend_dockerfile_github_url,
frontend_dockerfile_github_url=frontend_dockerfile_github_url or "",
frontend_docker_context_github_url=frontend_docker_context_github_url,
environment_name=environment_name,
metadata=user_metadata if user_metadata else None,
on_backend_progress=_backend_progress,
on_frontend_progress=_frontend_progress,
github_token=os.environ.get("GITHUB_TOKEN"),
env_provider_type=env_provider_type,
**site,
))
click.echo(f"Created WebsiteEnv: id={env.id} version={env.version} environment_name={env.environment_name}")
if not skip_validation:
Expand All @@ -137,64 +155,68 @@ def _frontend_progress(step: str, message: str, percent: int) -> None:
click.echo(f"Validation task: {instance_id}")
return

# Local Docker build path
backend_dockerfile_path = Path(backend_dockerfile)
backend_ctx = Path(backend_docker_context) if backend_docker_context else backend_dockerfile_path.parent
backend_id, frontend_id = derive_id(env_id, "backend_image"), derive_id(env_id, "frontend_image")
refuse_unwritable_ids(env_id, backend_id, frontend_id)
backend_tag = image_repository(backend_id)
if environment_name is None:
environment_name = card_name_from_source(str(backend_dockerfile_path), str(backend_ctx))
if not environment_name:
click.echo("Error: no @environment_card(name=...) found in the backend source; pass --environment-name.", err=True)
sys.exit(1)
click.echo(f"Derived environment_name={environment_name!r} from the environment card.")

click.echo("Building website backend Docker image...")
build_image(backend_dockerfile_path, backend_ctx, backend_tag, platform=build_platform)

click.echo("Creating backend DockerImageArtifact...")
backend_artifact = DockerImageArtifact.put(
id=backend_id,
description="Website backend image created from agent-env CLI",
image_name=backend_tag,
)
click.echo(f"Created backend artifact: id={backend_artifact.id} version={backend_artifact.version}")

frontend_dockerfile_path = Path(frontend_dockerfile)
frontend_ctx = Path(frontend_docker_context) if frontend_docker_context else frontend_dockerfile_path.parent
frontend_tag = image_repository(frontend_id)

click.echo("Building website frontend Docker image...")
build_image(frontend_dockerfile_path, frontend_ctx, frontend_tag, platform=build_platform)

click.echo("Creating frontend DockerImageArtifact...")
frontend_artifact = DockerImageArtifact.put(
id=frontend_id,
description="Website frontend image created from agent-env CLI",
image_name=frontend_tag,
)
click.echo(f"Created frontend artifact: id={frontend_artifact.id} version={frontend_artifact.version}")

metadata = detect_env_metadata(backend_dockerfile_path, backend_ctx)
refuse_unwritable_ids(env_id, backend_id, *([frontend_id] if frontend_dockerfile else []))
metadata: dict[str, str] = {}
if server_image_ref:
if build_platform != DEFAULT_BUILD_PLATFORM:
click.echo(f"Warning: --platform {build_platform!r} is ignored for --server-image-ref, which builds nothing", err=True)
backend_artifact = DockerImageArtifact.put_ref(backend_id, description=f"Website server image for {env_id}",
image_name=server_image_ref)
click.echo(f"Registered server image: id={backend_artifact.id} version={backend_artifact.version} image={backend_artifact.image_name}")
else:
backend_dockerfile_path = Path(backend_dockerfile)
backend_ctx = Path(backend_docker_context) if backend_docker_context else backend_dockerfile_path.parent
if environment_name is None:
environment_name = card_name_from_source(str(backend_dockerfile_path), str(backend_ctx))
if not environment_name:
click.echo("Error: no @environment_card(name=...) found in the server image's source; pass --environment-name.", err=True)
sys.exit(1)
click.echo(f"Derived environment_name={environment_name!r} from the environment card.")
backend_artifact = _build(backend_id, backend_dockerfile_path, backend_ctx, "server", build_platform)
metadata = detect_env_metadata(backend_dockerfile_path, backend_ctx)

frontend_artifact = None
if frontend_dockerfile:
frontend_dockerfile_path = Path(frontend_dockerfile)
frontend_ctx = Path(frontend_docker_context) if frontend_docker_context else frontend_dockerfile_path.parent
frontend_artifact = _build(frontend_id, frontend_dockerfile_path, frontend_ctx, "frontend", build_platform)
metadata.update(user_metadata)

click.echo("Creating WebsiteEnv...")
env = WebsiteEnv.put(
id=env_id,
backend_docker_image_artifact=backend_artifact,
frontend_docker_image_artifact=frontend_artifact,
environment_name=environment_name,
metadata=metadata if metadata else None,
env_provider_type=env_provider_type,
)
click.echo(f"Created WebsiteEnv: id={env.id} version={env.version} environment_name={env.environment_name} env_provider_type={env.env_provider_type}")
try:
env = WebsiteEnv.put(
id=env_id,
server_docker_image_artifact=backend_artifact,
frontend_docker_image_artifact=frontend_artifact,
environment_name=environment_name,
metadata=metadata if metadata else None,
env_provider_type=env_provider_type,
**site,
)
except ValueError as e:
raise click.ClickException(str(e)) from e
click.echo(f"Created WebsiteEnv: id={env.id} version={env.version} environment_name={env.environment_name} "
f"env_provider_type={env.env_provider_type}")
if not skip_validation:
click.echo("\nValidating environment (use --skip-validation to skip)...")
instance_id = asyncio.run(env.validate(on_progress=click.echo))
click.echo(f"Validation task: {instance_id}")


def _build(image_id: str, dockerfile: Path, context: Path, role: str, platform: str) -> DockerImageArtifact:
"""Build ``dockerfile`` into the image ``image_id`` and store it with its build context, as an a2a agent's image
is stored."""
tag = image_repository(image_id)
click.echo(f"Building website {role} Docker image...")
build_image(dockerfile, context, tag, platform=platform)
artifact = DockerImageArtifact.put(id=image_id, description=f"Website {role} image created from agent-env CLI",
image_name=tag, build_context_path=str(context), dockerfile_path=str(dockerfile),
platform=platform)
click.echo(f"Created {role} artifact: id={artifact.id} version={artifact.version}")
return artifact


@website.command(name="load-environment-artifact")
@click.option("--id", "env_id", default=None, help="WebsiteEnv id (optional; cross-checked against the instance)")
@click.option("--environment-artifact-id", "environment_artifact_id",
Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/env/envs/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ async def put_from_github(
on_frontend_progress: ProgressCallback | None = None,
github_token: str | None = None,
env_provider_type: str = "gateway",
health_path: str = DEFAULT_HEALTH_PATH,
) -> WebsiteEnv:
"""Build the server image, from ``backend_dockerfile_github_url``, and any deprecated frontend image from
GitHub in parallel, and create a WebsiteEnv."""
Expand Down Expand Up @@ -382,6 +383,7 @@ async def put_from_github(
environment_name=environment_name,
metadata=combined_metadata,
env_provider_type=env_provider_type,
health_path=health_path,
)
return env

Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/task/episode/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def _rubrics_verifier(step: RubricsVerifierTaskStep, outside: Outside) -> list[s
"modify_env_tool_access", "reset_env", "review",
"run_container_unit_tests_verifier", "run_docker_container", "snapshot_env", "snapshot_computer_env", "sync_env_clock",
"validation_gate_aggregator", "verify_env_card", "verify_env_core_protocol", "verify_mcp_tool_schema",
"verify_spec_conformance", "verify_universe_load_export_roundtrip",
"verify_spec_conformance", "verify_universe_load_export_roundtrip", "website_state_verifier",
# They read only what an outside turn leaves: its reply.
"agent_prompt_response_verifier", "verify_mcp_env_assessment",
# It skips an agent that isn't deployed.
Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/task_step/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]:
from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep
from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep
from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import RunContainerUnitTestsVerifierTaskStep
from agent_env.task_step.task_steps.verifiers.website_state_verifier import WebsiteStateVerifierTaskStep
from agent_env.task_step.task_steps.run_docker_container import RunDockerContainerTaskStep
from agent_env.task_step.task_steps.mcp_env_validator.verify_mcp_tool_schema import VerifyMCPToolSchemaTaskStep
from agent_env.task_step.task_steps.mcp_env_validator.verify_spec_conformance import VerifySpecConformanceTaskStep
Expand Down Expand Up @@ -107,6 +108,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]:
_get_type(InstallAgentTaskStep): InstallAgentTaskStep,
_get_type(RubricsVerifierTaskStep): RubricsVerifierTaskStep,
_get_type(RunContainerUnitTestsVerifierTaskStep): RunContainerUnitTestsVerifierTaskStep,
_get_type(WebsiteStateVerifierTaskStep): WebsiteStateVerifierTaskStep,
_get_type(RunDockerContainerTaskStep): RunDockerContainerTaskStep,
_get_type(VerifyMCPToolSchemaTaskStep): VerifyMCPToolSchemaTaskStep,
_get_type(VerifySpecConformanceTaskStep): VerifySpecConformanceTaskStep,
Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/task_step/task_steps/verifiers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_env.task_step.task_steps.verifiers.agent_prompt_response_verifier import AgentPromptResponseVerifierTaskStep
from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import RunContainerUnitTestsVerifierTaskStep
from agent_env.task_step.task_steps.verifiers.verify_sandbox import VerifySandboxTaskStep
from agent_env.task_step.task_steps.verifiers.website_state_verifier import WebsiteStateVerifierTaskStep
from agent_env.task_step.task_steps.verifiers.aggregate_verifiers import AggregateVerifiersTaskStep

__all__ = [
Expand All @@ -26,5 +27,6 @@
"ScoreAggregator",
"Turn",
"VerifySandboxTaskStep",
"WebsiteStateVerifierTaskStep",
"aggregate_score",
]
Loading
Loading