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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ AGENT_ENV_ENVIRONMENT=dev

# Object (blob) store backend:
# - "local" (or unset): local filesystem at ~/.local/state/agent-env/object_store/ (no external infra)
# - S3: configure a [stores.object] table in config.toml (no built-in coordinates)
# - a hosted backend (S3, Cloud Storage): configure a [stores.object] table in config.toml (no built-in coordinates)
# AGENT_ENV_OBJECT_STORE=local

# Image (registry) store backend:
# - "local" (or unset): local OCI registry, e.g. registry:2 at localhost:5000 (no external infra)
# - ECR: configure a [stores.image] table in config.toml (no built-in coordinates)
# - a hosted registry (ECR): configure a [stores.image] table in config.toml (no built-in coordinates)
# AGENT_ENV_IMAGE_STORE=local

# Secret store backend (what secret: references in config resolve through):
# - "local" (or unset): process env vars, or a flat YAML/JSON file via [stores.secret] in config.toml
# - AWS Secrets Manager: configure a [stores.secret] table (no built-in coordinates)
# - a hosted secret manager (AWS Secrets Manager, Google Cloud Secret Manager): configure a [stores.secret] table (no built-in coordinates)
# AGENT_ENV_SECRET_STORE=local
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ clean-install-test: ## Build both distributions, install them clean from public
if $(SYSTEM_PYTHON) .github/scripts/clean_install.py --work "$$work"; then rm -rf "$$work"; \
else status=$$?; echo "kept $$work to inspect"; exit $$status; fi

test: ## Run the full test suite (includes integration; requires Docker/Mongo/AWS)
test: ## Run every tier: unit, integration and installer (needs Docker and a local OCI registry; tests needing uv, pipx, a model endpoint or a remote sandbox skip without them)

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 Tests do not skip without uv

The make test help says tests needing uv skip when it is missing. But the container-journey tests only check for Docker, then call uv through their fixtures. If Docker is available but uv is missing, make test fails instead of skipping those tests. Say that the container journey needs uv, or add a skip check.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Makefile
Line: 68

Comment:
**Tests do not skip without uv**

The `make test` help says tests needing `uv` skip when it is missing. But the container-journey tests only check for Docker, then call `uv` through their fixtures. If Docker is available but `uv` is missing, `make test` fails instead of skipping those tests. Say that the container journey needs `uv`, or add a skip check.

---

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

$(PYTHON) -m pytest tst/ -v

clean: ## Remove the virtualenv and Python caches
Expand Down
5 changes: 3 additions & 2 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,9 @@ objects are in the store.
SDK agents advertise only these shapes, and AgentEnv sends no others. It moves
a skill bundle, snapshot or changelog only through grants, so the call needs an
agent that advertises the object variant and an object store that issues grants
(the S3 store does, and namespace grants for changelog capture only when it
signs with long-term credentials); otherwise it fails before anything is sent.
(the S3, Cloud Storage and local stores do, though S3 issues the namespace grants
changelog capture needs only when it signs with long-term credentials); otherwise
it fails before anything is sent.
Skills given as SKILL.md text and trajectories returned inline need no grants.
A snapshot is restored only from the snapshot objects in the table above; one
that holds the runtime's own files instead cannot be. An
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Pre-deploy fit-check for the data-plane intake declaration.

Pure functions over plain dicts — no I/O, no Mongo/S3 — so they run at authoring/
Pure functions over plain dicts — no I/O, no store access — so they run at authoring/
registration time. The agent-env-side ``preflight(universe, env)`` wraps these by
reading ``client.intake_declaration(card)`` and each service's seed ``data.json``.
"""
Expand Down
17 changes: 12 additions & 5 deletions packages/agentenv-protocol/src/agentenv_protocol/transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_CHUNK_BYTES = 64 * 1024
_UTC = timezone.utc # noqa: UP017 -- datetime.UTC requires Python 3.11.
# Idle limits, not totals, as long as botocore's: long enough for a slow uplink to drain its
# Idle limits, not totals, of a minute, as cloud storage SDKs use: long enough for a slow uplink to drain its
# buffers before the store answers, short enough that a stalled connection is retried within
# the minutes agent-env waits for an extension call.
TRANSFER_IDLE_TIMEOUT_SECONDS = 60.0
Expand Down Expand Up @@ -271,20 +271,27 @@ def _raise_for_transfer_status(response: httpx.Response) -> None:
if (
response.status_code in (408, 429)
or response.status_code >= 500
or _is_s3_request_timeout(response)
or _is_store_request_timeout(response)
):
raise TransferError("transfer_unavailable", _UNAVAILABLE)
raise TransferError("transfer_rejected", "The object store rejected the transfer.")


def _is_s3_request_timeout(response: httpx.Response) -> bool:
"""S3 answers an upload that stalled with a 400 whose error code is RequestTimeout."""
# Body markers of a stalled upload that a store answers with a 400 rather than a 408.
_STALLED_UPLOAD_MARKERS = (
b"<Code>RequestTimeout</Code>", # S3
)


def _is_store_request_timeout(response: httpx.Response) -> bool:
"""An upload that stalled, answered with a 400 by a store that does not send a 408 for it."""
if response.status_code != 400:
return False
try:
return b"<Code>RequestTimeout</Code>" in response.content[:1024]
head = response.content[:1024]
except httpx.ResponseNotRead:
return False
return any(marker in head for marker in _STALLED_UPLOAD_MARKERS)


@contextmanager
Expand Down
6 changes: 3 additions & 3 deletions src/agent_env/artifact/artifacts/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ def put(

version = store.next_version(id)

# Use put_object_file (boto3 managed multipart upload) instead of
# put_object (s3.put_object — hard 5 GB single-object limit). Streams
# directly from disk so large files don't get loaded into RAM either.
# put_object_file, not put_object: a hosted store uploads a file in parts, where a
# single put is capped (5 GB on S3). It streams from disk, so large files don't get
# loaded into RAM either.
stored_url = store.put_object_file(
artifact_type="file",
id=id,
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/artifact/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def _execute_count(self) -> int:


class ArtifactStore:
"""Store for artifact persistence in MongoDB and S3."""
"""Store for artifact persistence in the configured document and object stores."""

def __init__(self) -> None:
self._versioned_cache: VersionedEntityStoreCache["Artifact"] = VersionedEntityStoreCache(
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/cli/artifact/file_artifact_universe.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def get_many(
"""Mass-download many FileArtifactUniverses in parallel.

Provide either --id (repeatable) or --batch-id. Files are pulled directly
from S3 in parallel (no hop through the hub backend), so this scales to
from the object store in parallel, with nothing in between, so this scales to
hundreds of universes.
"""
import concurrent.futures
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/cli/env/state/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def init_env_state(env_id: str, env_state_type: str | None, ttl_seconds: int):
state_type = env_state_type

try:
build_state_provider(state_type) # resolve before any Mongo/AWS work, so a typo fails fast
build_state_provider(state_type) # resolve before any store work, so a typo fails fast
except ValueError as e:
raise click.BadParameter(str(e), param_hint="'--env-state-type'") from e

Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/config/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@


def class_name(impl: Any) -> Optional[str]:
"""The class alone — ``S3ObjectStore``, not the dotted path."""
"""The class alone — ``LocalFilesystemObjectStore``, not the dotted path."""
return impl.rpartition(":")[2] if isinstance(impl, str) else None


Expand Down
40 changes: 12 additions & 28 deletions src/agent_env/config/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
document_store/documents.db under the per-user state root (``paths.state_root``).
MongoDB has no built-in coordinates; configure it via a [stores.document] table.
- Object store (AGENT_ENV_OBJECT_STORE): "local" (or unset): filesystem under
object_store/ in the per-user state root. S3 has no built-in coordinates; configure
it via a [stores.object] table.
object_store/ in the per-user state root. A hosted backend (S3, Cloud Storage) has no
built-in coordinates; configure it via a [stores.object] table.
- Image store (AGENT_ENV_IMAGE_STORE): "local" (or unset): an OCI registry at
localhost:5000. ECR has no built-in coordinates; configure it via a
localhost:5000. A hosted registry (ECR) has no built-in coordinates; configure it via a
[stores.image] table.
- Secret store (AGENT_ENV_SECRET_STORE): "local" (or unset): process env vars /
optional local file. AWS Secrets Manager has no built-in coordinates; configure
it via a [stores.secret] table.
optional local file. A hosted secret manager (AWS Secrets Manager, Google Cloud Secret
Manager) has no built-in coordinates; configure it via a [stores.secret] table.

AGENT_ENV_FIXTURE_PREFIX (default ""): prepends <prefix>/ to the keys of artifact
objects, image builds, env and agent snapshots, changelogs, default agent and judge
Expand Down Expand Up @@ -64,12 +64,8 @@
_STORE_BACKEND_MONGO = "mongo"
_STORE_BACKEND_LOCAL = "local"

_OBJECT_BACKEND_S3 = "s3"
_SECRET_BACKEND_AWS = "aws"

_RUNNER_BACKEND_LOCAL = "local"

_IMAGE_BACKEND_ECR = "ecr"
_DEFAULT_LOCAL_REGISTRY_HOST = "localhost:5000"


Expand Down Expand Up @@ -309,7 +305,7 @@ def _get_secret(self) -> Mapping[str, Any]:
if not callable(load):
raise ConfigError(
f"{type(store).__name__} exposes no combined secret mapping; "
"_get_secret() needs a bundle-backed secret store (aws/local)."
"_get_secret() needs a bundle-backed secret store, one whose _load() returns every secret."
)
bundle = load()
if not bundle:
Expand Down Expand Up @@ -700,18 +696,14 @@ def _resolve_object_section(self) -> dict:
return self.trace_section("object").value

def _object_alias(self, name: str) -> dict:
if name == _OBJECT_BACKEND_S3:
raise ConfigError(
"The 's3' object backend has no built-in coordinates: configure a "
"[stores.object] table (and unset AGENT_ENV_OBJECT_STORE, which overrides it)."
)
if name == _STORE_BACKEND_LOCAL:
return {
"impl": "agent_env.store.object_store:LocalFilesystemObjectStore",
"config": {"root": self._local_object_store_path()},
}
raise ConfigError(
f"Unknown AGENT_ENV_OBJECT_STORE={name!r} (expected 'local', or a [stores.object] table for a hosted backend)"
f"Unknown AGENT_ENV_OBJECT_STORE={name!r}: expected 'local', or a [stores.object] table for a "
"hosted backend (and unset AGENT_ENV_OBJECT_STORE, which overrides it)"
)

def _local_object_store_path(self) -> str:
Expand Down Expand Up @@ -771,18 +763,14 @@ def _resolve_image_section(self) -> dict:
return self.trace_section("image").value

def _image_alias(self, name: str) -> dict:
if name == _IMAGE_BACKEND_ECR:
raise ConfigError(
"The 'ecr' image backend has no built-in coordinates: configure a "
"[stores.image] table (and unset AGENT_ENV_IMAGE_STORE, which overrides it)."
)
if name == _STORE_BACKEND_LOCAL:
return {
"impl": "agent_env.store.image_store:LocalRegistryImageStore",
"config": {"registry_host": _DEFAULT_LOCAL_REGISTRY_HOST},
}
raise ConfigError(
f"Unknown AGENT_ENV_IMAGE_STORE={name!r} (expected 'local', or a [stores.image] table for ecr)"
f"Unknown AGENT_ENV_IMAGE_STORE={name!r}: expected 'local', or a [stores.image] table for a "
"hosted registry (and unset AGENT_ENV_IMAGE_STORE, which overrides it)"
)

def _local_store(self, kind: str) -> Any:
Expand Down Expand Up @@ -924,15 +912,11 @@ def _resolve_secret_section(self) -> dict:
return self.trace_section("secret").value

def _secret_alias(self, name: str) -> dict:
if name == _SECRET_BACKEND_AWS:
raise ConfigError(
"The 'aws' secret backend has no built-in coordinates: configure a [stores.secret] "
"table (and unset AGENT_ENV_SECRET_STORE, which overrides it)."
)
if name == _STORE_BACKEND_LOCAL:
return {"impl": "agent_env.store.secret_store:LocalSecretStore", "config": {}}
raise ConfigError(
f"Unknown AGENT_ENV_SECRET_STORE={name!r} (expected 'local', or a [stores.secret] table for a hosted backend)"
f"Unknown AGENT_ENV_SECRET_STORE={name!r}: expected 'local', or a [stores.secret] table for a "
"hosted backend (and unset AGENT_ENV_SECRET_STORE, which overrides it)"
)

def get_artifact_key_prefix(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/env/envs/_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ async def load_by_signed_url(env: MCPServerEnv | WebsiteEnv, file_artifact: File
url = await asyncio.to_thread(store.signed_get_url, file_artifact.object_url, len(LOAD_OPERATIONS) * timeout)
if url is None:
raise RuntimeError(f"{deployed_by}, so its server fetches {file_artifact.object_url} itself, and {type(store).__name__} "
"can't sign a URL for it; loading into such an env needs an object store that signs URLs, such as S3")
"can't sign a URL for it; loading into such an env needs an object store that signs URLs (S3, Cloud Storage)")
await protocol_v1.reset_data(base_url, timeout=timeout)
await protocol_v1.add_data(base_url, [FilePart(file={
"uri": url,
Expand Down
4 changes: 2 additions & 2 deletions src/agent_env/providers/env_state/local_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ def service_db_config(self) -> "ServiceDBConfig":
return self._service_db_config

def store_images_to_load(self) -> list["DockerImageArtifact"]:
"""The servicedb image set (servicedb + pgweb + db-mcp) the VM preloads via S3 tar — the
artifacts (with ``tar_gz_s3_url``), which the image-name config can't provide. Reuses the
"""The servicedb image set (servicedb + pgweb + db-mcp) the VM preloads from stored tars — the
artifacts (with ``tar_gz_object_url``), which the image-name config can't provide. Reuses the
cached default env, so no extra ``Env.get`` beyond ``service_db_config``'s."""
env = self._default_service_db_env()
return [
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/store/document_store/document_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ def next_version(self, id: str) -> int:
"""Allocate the version a write to ``id`` would land on.

The id is checked here and not only at ``put`` because every artifact
helper calls this first and then writes remote data — an object to S3, an image to a
helper calls this first and then writes remote data — an object to the object store, an image to a
registry — before it has a document to store. Refusing the id at the end would leave
that data orphaned with no artifact record pointing at it.
"""
Expand Down
6 changes: 3 additions & 3 deletions src/agent_env/store/object_store/local/grant_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""The in-process HTTPS server that honours local object-transfer grants.

One route, ``/v1/grants/{token}``, with the semantics of the S3 requests the grants stand in for:
One route, ``/v1/grants/{token}``, with the semantics of the hosted-store requests the grants stand in for:
GET returns an object's exact bytes; PUT writes one object of the claimed type and size; a
multipart POST writes one object anywhere under the claimed prefix. A token that does not verify,
has expired or names another method is a 403; a request outside the grant's bounds is a 4xx.
Expand Down Expand Up @@ -52,7 +52,7 @@
DEFAULT_ADVERTISE_HOST = "host.docker.internal"
_START_TIMEOUT_SECONDS = 10
_OPS_BY_METHOD = {"GET": "get", "HEAD": "get", "PUT": "put", "POST": "post"}
_UPLOAD_KEY_FIELD = "key" # the form fields a policy upload names its key and file by, as S3's do
_UPLOAD_KEY_FIELD = "key" # the form fields a policy upload names its key and file by (HttpPostPolicyGrant's path/file fields)
_UPLOAD_FILE_FIELD = "file"
_MAX_FORM_FIELDS = 32
_MAX_FORM_FIELD_BYTES = 64 * 1024
Expand Down Expand Up @@ -328,7 +328,7 @@ async def _post(store: LocalFilesystemObjectStore, claims: GrantClaims, request:


class _PolicyUpload:
"""One multipart POST under a policy grant, read as S3 reads one: form fields, then the file,
"""One multipart POST under a policy grant, read as a hosted store reads one: form fields, then the file,
which is written to a staged file as it arrives. Fields after the file are ignored."""

def __init__(self, store: LocalFilesystemObjectStore, claims: GrantClaims, stack: contextlib.ExitStack) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/store/object_store/local/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
_STAGED_PREFIX = "staged-"
_STAGED_GRACE_SECONDS = 60 # a staged file younger than this may not be locked by its writer yet
_GRANT_MODES = ("auto", "off")
_MAX_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60 # as S3's; a grant cannot outlive its process anyway
_MAX_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60 # a hosted store's longest signature; a grant cannot outlive its process anyway
_LOCAL_SANDBOX_TYPE = "local" # LocalSandbox.type: containers on this host's Docker, which trust the local CA

logger = logging.getLogger(__name__)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Integration tests for EnvironmentArtifact.

These tests run against the configured object and document stores.
Requires AWS credentials with access to secrets manager and S3.
"""

import json
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Integration tests for EnvironmentUniverseArtifact.

These tests run against the configured object and document stores.
Requires AWS credentials with access to secrets manager and S3.
"""

import json
Expand Down
3 changes: 1 addition & 2 deletions tst/integration/env/env_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Integration tests for Env persistence.

These tests use real MongoDB (Atlas dev database).
Requires AWS credentials with access to secrets manager.
These tests run against the configured stores and build a real Docker image.
"""

import subprocess
Expand Down
4 changes: 2 additions & 2 deletions tst/unit/config/test_explain_and_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_a_path_no_layer_supplies_is_unset_not_attributed_to_a_default(config_fi


def test_explain_agrees_with_the_resolver_about_an_unresolvable_section(config_file, monkeypatch):
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "s3")
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "hosted")
report = explain_path("stores.object")
assert report.error is not None # reported, not raised
assert report.winner.where == "$AGENT_ENV_OBJECT_STORE"
Expand Down Expand Up @@ -296,7 +296,7 @@ def test_a_child_that_cannot_resolve_is_not_shown_as_merely_empty(tmp_path, monk
path = tmp_path / "config.toml"
path.write_text(CONFIG)
monkeypatch.setenv(config_loader.ENV_CONFIG_PATH, str(path))
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "s3") # a backend with no coordinates
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "hosted") # not a backend name

report = explain_path("stores")
broken = next(c for c in report.children if c.path == "stores.object")
Expand Down
5 changes: 2 additions & 3 deletions tst/unit/store/test_config_image_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ def test_default_backend_is_local(monkeypatch, tmp_path):
assert section["impl"] == "agent_env.store.image_store:LocalRegistryImageStore"


def test_ecr_alias_raises_actionable(monkeypatch):
# No built-in coordinates: the error names the table AND the overriding env var.
monkeypatch.setenv("AGENT_ENV_IMAGE_STORE", "ecr")
def test_an_unknown_backend_names_the_table_and_the_overriding_env_var(monkeypatch):
monkeypatch.setenv("AGENT_ENV_IMAGE_STORE", "hosted")
with pytest.raises(ConfigError, match=r"\[stores\.image\].*AGENT_ENV_IMAGE_STORE"):
Config().get_image_store()

Expand Down
5 changes: 2 additions & 3 deletions tst/unit/store/test_config_object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ def test_default_backend_is_local(monkeypatch, tmp_path):
assert section["impl"] == "agent_env.store.object_store:LocalFilesystemObjectStore"


def test_s3_alias_raises_actionable(monkeypatch):
# No built-in coordinates: the error names the table AND the overriding env var.
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "s3")
def test_an_unknown_backend_names_the_table_and_the_overriding_env_var(monkeypatch):
monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "hosted")
with pytest.raises(ConfigError, match=r"\[stores\.object\].*AGENT_ENV_OBJECT_STORE"):
Config().get_object_store()

Expand Down
6 changes: 3 additions & 3 deletions tst/unit/store/test_config_secret_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ def test_default_backend_is_local(monkeypatch, tmp_path):
assert store._load() == {}


def test_aws_selector_raises_actionable_config_error(monkeypatch):
def test_an_unknown_backend_names_the_table_and_the_overriding_env_var(monkeypatch):
monkeypatch.delenv("AGENT_ENV_CONFIG", raising=False)
monkeypatch.setenv("AGENT_ENV_SECRET_STORE", "aws")
with pytest.raises(ConfigError, match=r"stores\.secret"):
monkeypatch.setenv("AGENT_ENV_SECRET_STORE", "hosted")
with pytest.raises(ConfigError, match=r"\[stores\.secret\].*AGENT_ENV_SECRET_STORE"):
Config().get_secret_store()


Expand Down
Loading
Loading