Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ and deserialized through a registry.
| Package | Contents |
|---|---|
| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset` and `load_file_artifact_universe`. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`). `env/gateway/gateway.py` is the env-side gateway server, container-only code. |
| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Built-ins: `CliArtifact` (`cli`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`; legacy `service`), `EnvironmentUniverseArtifact` (`environment_universe`; legacy `service_universe`), `SkillArtifact` (`skill`). |
| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Built-ins: `CliArtifact` (`cli`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`; legacy `service`), `EnvironmentUniverseArtifact` (`environment_universe`; legacy `service_universe`), `SkillArtifact` (`skill`). A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull. |
| `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). |
| `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. |
| `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. |
Expand Down
27 changes: 27 additions & 0 deletions src/agent_env/artifact/artifacts/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@

from agent_env.artifact.artifact import Artifact, _write_twin
from agent_env.store.ids import fs_safe, image_repository, is_local_id
from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore
from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, names_registry, registry_host_from_ref
from agent_env.store.image_store.registry_api import pin_digest
from agent_env.utils.deprecation import OMITTED, renamed_keyword

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -208,6 +210,31 @@ def put_tar(
)
return store.put_document(instance)

@classmethod
def put_ref(cls, id: str, *, description: str, image_name: str) -> "DockerImageArtifact":
"""Register ``image_name``, an image already in a registry, writing this document only: nothing is pushed,
saved or uploaded, and a sandbox pulls the image. A tag is pinned to the digest the registry serves for it
now, recorded as ``name:tag@sha256:...``, so every run gets the same image; a digest is checked to exist.

``image_name`` must name its registry. The registry is read with the configured image store's credentials
where it holds that registry, anonymously otherwise. One on this machine is refused unless ``id``'s image
store is the local registry, since nothing else could pull from it."""
from agent_env.artifact.store import get_artifact_store
from agent_env.config import get_config

if not names_registry(image_name):
raise ValueError(f"{image_name!r} doesn't name its registry; spell it out, as in "
f"docker.io/library/{image_name}")
config = get_config()
if (is_loopback_host(registry_host_from_ref(image_name))
and not isinstance(config.get_image_store_for(id), LocalRegistryImageStore)):
raise ValueError(f"{image_name} is in a registry on this machine, which only an image in the local "
"registry's store, such as an @local one, can name")
pinned = pin_digest(image_name, config.get_image_store().auth(image_name))
store = get_artifact_store()
version = store.next_version(id)
return store.put_document(cls(id=id, version=version, description=description, image_name=pinned))

def load(self) -> bytes:
from agent_env.artifact.store import get_artifact_store
if not self.tar_gz_object_url:
Expand Down
56 changes: 36 additions & 20 deletions src/agent_env/cli/a2a_agent/put.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,32 @@
from agent_env.artifact import DockerImageArtifact
from agent_env.cli.utils import build_platform_option, detect_env_metadata, refuse_unwritable_ids
from agent_env.store.ids import derive_id, image_repository
from agent_env.utils.docker_build import build_image
from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, build_image


@click.command()
@click.option("--id", "agent_id", required=True, help="A2A agent id")
@click.option("--dockerfile", required=True, type=click.Path(exists=True), help="Path to Dockerfile")
@click.option("--dockerfile", default=None, type=click.Path(exists=True), help="Path to Dockerfile")
@click.option("--context", "context_path", default=None, type=click.Path(exists=True), help="Docker build context (defaults to Dockerfile's directory)")
@click.option("--image-ref", "image_ref", default=None,
help="An image already in a registry, e.g. ghcr.io/org/agent:v1: registered as it is, with its tag pinned "
"to the digest the registry serves now, and pulled by sandboxes (instead of --dockerfile)")
@click.option("--env-var", "env_var_pairs", multiple=True, help="Default env var KEY=VALUE (repeatable)")
@click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)")
@click.option("--min-disk-size-gb", type=float, default=None, help="Minimum disk size in GB for agent deployment")
@click.option("--default-model", type=str, default=None, help="Default model for agent prompts (e.g., claude-sonnet-4-6)")
@click.option("--skip-validation", is_flag=True, default=False, help="Skip A2A agent validation after registration")
@click.option("--litellm-api-key", type=str, default=None, help="LiteLLM API key for validation prompts")
@build_platform_option
def put(agent_id: str, dockerfile: str, context_path: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str):
"""Build a Docker image and register an A2A agent."""
def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_ref: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str):
"""Build a Docker image and register an A2A agent, or register one over an image already in a registry."""

if bool(dockerfile) == bool(image_ref):
click.echo("Error: exactly one of --dockerfile and --image-ref is required", err=True)
sys.exit(1)
if context_path and not dockerfile:
click.echo("Error: --context needs --dockerfile", err=True)
sys.exit(1)

default_env_vars = {}
for pair in env_var_pairs:
Expand All @@ -40,26 +50,32 @@ def put(agent_id: str, dockerfile: str, context_path: str | None, env_var_pairs:
key, value = pair.split("=", 1)
user_metadata[key] = value

dockerfile_path = Path(dockerfile)
context = Path(context_path) if context_path else dockerfile_path.parent
image_id = derive_id(agent_id, "agent_image")
image_tag = image_repository(image_id)
refuse_unwritable_ids(agent_id, image_id)
if image_ref:
if build_platform != DEFAULT_BUILD_PLATFORM:
click.echo(f"Warning: --platform {build_platform!r} is ignored for --image-ref, which builds nothing", err=True)
artifact = DockerImageArtifact.put_ref(image_id, description=f"A2A agent image for {agent_id}", image_name=image_ref)
click.echo(f"Registered image: id={artifact.id} version={artifact.version} image={artifact.image_name}")
metadata = {}
else:
dockerfile_path = Path(dockerfile)
context = Path(context_path) if context_path else dockerfile_path.parent
image_tag = image_repository(image_id)

click.echo(f"Building Docker image...")
build_image(dockerfile_path, context, image_tag, platform=build_platform)

click.echo(f"Creating DockerImageArtifact...")
artifact = DockerImageArtifact.put(
id=image_id,
description=f"A2A agent image for {agent_id}",
image_name=image_tag,
build_context_path=str(context),
dockerfile_path=str(dockerfile_path),
)
click.echo(f"Created artifact: id={artifact.id} version={artifact.version}")
click.echo(f"Building Docker image...")
build_image(dockerfile_path, context, image_tag, platform=build_platform)

metadata = detect_env_metadata(dockerfile_path, context)
click.echo(f"Creating DockerImageArtifact...")
artifact = DockerImageArtifact.put(
id=image_id,
description=f"A2A agent image for {agent_id}",
image_name=image_tag,
build_context_path=str(context),
dockerfile_path=str(dockerfile_path),
)
click.echo(f"Created artifact: id={artifact.id} version={artifact.version}")
metadata = detect_env_metadata(dockerfile_path, context)
metadata.update(user_metadata)
if min_disk_size_gb is not None:
metadata["min_disk_size_gb"] = min_disk_size_gb
Expand Down
83 changes: 49 additions & 34 deletions src/agent_env/cli/env/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ def _gate_release(env, report_dirs, override: bool) -> None:
@click.option("--context", "context_path", default=None, type=click.Path(exists=True), help="Docker build context (defaults to Dockerfile's directory)")
@click.option("--dockerfile-github-url", "dockerfile_github_url", default=None, help="GitHub URL to Dockerfile (e.g. https://github.com/owner/repo/tree/main/path/Dockerfile)")
@click.option("--docker-context-github-url", "docker_context_github_url", default=None, help="GitHub URL to build context directory (defaults to Dockerfile's parent)")
@click.option("--image-ref", "image_ref", default=None,
help="An image already in a registry, e.g. ghcr.io/org/server:v1: registered as it is, with its tag pinned "
"to the digest the registry serves now, and pulled by sandboxes (needs --environment-name)")
@environment_name_options
@env_provider_type_option("What deploys the env: 'gateway' (a gateway and service database in front of the server), 'server' (the "
"server on its own), or the type of an installed agent_env.env_providers plugin")
Expand All @@ -120,19 +123,21 @@ def _gate_release(env, report_dirs, override: bool) -> None:
@click.option("--override", "override", is_flag=True, default=False,
help="Run the release gate but publish even if it fails (records an audited override); implies --validate")
@build_platform_option
def put(env_id: str, dockerfile: str | None, context_path: str | None, dockerfile_github_url: str | None, docker_context_github_url: str | None, environment_name: str | None, env_provider_type: str, metadata_pairs: tuple[str, ...], run_validation: bool, override: bool, build_platform: str):
"""Build and upload an MCP server environment."""
def put(env_id: str, dockerfile: str | None, context_path: str | None, dockerfile_github_url: str | None, docker_context_github_url: str | None, image_ref: str | None, environment_name: str | None, env_provider_type: str, metadata_pairs: tuple[str, ...], run_validation: bool, override: bool, build_platform: str):
"""Build and upload an MCP server environment, or register one over an image already in a registry."""

environment_name = resolve_environment_name(environment_name, allow_missing=True)

if dockerfile and dockerfile_github_url:
click.echo("Error: --dockerfile and --dockerfile-github-url are mutually exclusive", err=True)
sources = [flag for flag, value in (("--dockerfile", dockerfile), ("--dockerfile-github-url", dockerfile_github_url),
("--image-ref", image_ref)) if value]
if len(sources) > 1:
click.echo(f"Error: {' and '.join(sources)} are mutually exclusive", err=True)
sys.exit(1)
if not dockerfile and not dockerfile_github_url:
click.echo("Error: either --dockerfile or --dockerfile-github-url is required", err=True)
if not sources:
click.echo("Error: one of --dockerfile, --dockerfile-github-url or --image-ref is required", err=True)
sys.exit(1)
if context_path and dockerfile_github_url:
click.echo("Error: --context cannot be used with --dockerfile-github-url", err=True)
if context_path and not dockerfile:
click.echo("Error: --context needs --dockerfile", err=True)
sys.exit(1)
if docker_context_github_url and not dockerfile_github_url:
click.echo("Error: --docker-context-github-url requires --dockerfile-github-url", err=True)
Expand Down Expand Up @@ -182,32 +187,43 @@ def _on_progress(step: str, message: str, percent: int) -> None:
_gate_release(env, [], override)
return

dockerfile_path = Path(dockerfile)
context = Path(context_path) if context_path else dockerfile_path.parent
if environment_name is None:
environment_name = card_name_from_source(str(dockerfile_path), str(context))
if not environment_name:
click.echo("Error: no @environment_card(name=...) found in the build source; pass --environment-name.", err=True)
sys.exit(1)
click.echo(f"Derived environment_name={environment_name!r} from the environment card.")
image_id = derive_id(env_id, "env_image")
image_tag = image_repository(image_id)
refuse_unwritable_ids(env_id, image_id)

click.echo(f"Building MCP server Docker image...")
build_image(dockerfile_path, context, image_tag, platform=build_platform)

click.echo(f"Creating DockerImageArtifact...")
artifact = DockerImageArtifact.put(
id=image_id,
description="Created from agent-env CLI",
image_name=image_tag,
build_context_path=str(context),
dockerfile_path=str(dockerfile_path),
)
click.echo(f"Created artifact: id={artifact.id} version={artifact.version}")

metadata = detect_env_metadata(dockerfile_path, context)
if image_ref:
if environment_name is None:
click.echo("Error: --image-ref needs --environment-name: an image in a registry has no environment card to "
"read it from", err=True)
sys.exit(1)
if build_platform != DEFAULT_BUILD_PLATFORM:
click.echo(f"Warning: --platform {build_platform!r} is ignored for --image-ref, which builds nothing", err=True)
artifact = DockerImageArtifact.put_ref(image_id, description="Registered from agent-env CLI", image_name=image_ref)
click.echo(f"Registered image: id={artifact.id} version={artifact.version} image={artifact.image_name}")
metadata, report_dirs = {}, []
else:
dockerfile_path = Path(dockerfile)
context = Path(context_path) if context_path else dockerfile_path.parent
if environment_name is None:
environment_name = card_name_from_source(str(dockerfile_path), str(context))
if not environment_name:
click.echo("Error: no @environment_card(name=...) found in the build source; pass --environment-name.", err=True)
sys.exit(1)
click.echo(f"Derived environment_name={environment_name!r} from the environment card.")
image_tag = image_repository(image_id)
click.echo(f"Building MCP server Docker image...")
build_image(dockerfile_path, context, image_tag, platform=build_platform)

click.echo(f"Creating DockerImageArtifact...")
artifact = DockerImageArtifact.put(
id=image_id,
description="Created from agent-env CLI",
image_name=image_tag,
build_context_path=str(context),
dockerfile_path=str(dockerfile_path),
)
click.echo(f"Created artifact: id={artifact.id} version={artifact.version}")
metadata = detect_env_metadata(dockerfile_path, context)
# Local build: look for the env-build handoff report next to the build context.
report_dirs = [str(context), str(dockerfile_path.parent)]
metadata.update(user_metadata)

click.echo(f"Creating MCPServerEnv...")
Expand All @@ -220,8 +236,7 @@ def _on_progress(step: str, message: str, percent: int) -> None:
)
click.echo(f"Created MCPServerEnv: id={env.id} version={env.version} environment_name={env.environment_name} env_provider_type={env.env_provider_type}")
if run_validation or override:
# Local build: look for the env-build handoff report next to the build context.
_gate_release(env, [str(context), str(dockerfile_path.parent)], override)
_gate_release(env, report_dirs, override)


_SPEC_FINDING_LIMIT = 20
Expand Down
7 changes: 4 additions & 3 deletions src/agent_env/store/image_store/oci_registry_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ def registry_host_from_ref(ref: str) -> str | None:


def is_loopback_host(host: str | None) -> bool:
"""Whether a registry ``host``, with or without its port, names this machine."""
"""Whether a registry ``host``, with or without its port, names this machine: a loopback address, the
unspecified ``0.0.0.0``, or ``host.docker.internal``, which only this machine's containers resolve."""
if not host:
return False
name = host[1:host.find("]")] if host.startswith("[") else host.rsplit(":", 1)[0]
return name == "localhost" or name.startswith("127.") or name == "::1"
name = (host[1:host.find("]")] if host.startswith("[") else host.rsplit(":", 1)[0]).lower()
return name in ("localhost", "::1", "0.0.0.0", "host.docker.internal") or name.startswith("127.")


class SecretStoreCredentials(OciRegistryCredentials):
Expand Down
Loading
Loading