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
17 changes: 5 additions & 12 deletions src/agent_env/store/object_store/gcs_object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,25 +289,18 @@ def issue_upload_policy(
expires_at=expires_at,
)

def signed_post(
self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int | None = None
) -> dict | None:
if not self._can_sign():
return None
return self._post_policy(url_prefix, min(expires_in, self._max_signed_seconds), max_bytes)

def _post_policy(self, url_prefix: str, expires_in: int, max_bytes: int | None) -> dict:
def _post_policy(self, url_prefix: str, expires_in: int, max_bytes: int) -> dict:
"""Built by hand: the client library's helper adds an exact-key condition, which would
confine the policy to one object. The prefix ends in ``/``, so that ``root`` does not
admit ``root-evil/``."""
bucket, prefix = _split(url_prefix.rstrip("/") + "/")
now = datetime.now(UTC).replace(microsecond=0)
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
credential = f"{self._signer.signer_email}/{now:%Y%m%d}/auto/storage/goog4_request"
conditions: list = [["starts-with", "$key", prefix], ["starts-with", "$Content-Type", ""]]
if max_bytes is not None:
conditions.append(["content-length-range", 0, max_bytes])
conditions += [
conditions: list = [
["starts-with", "$key", prefix],
["starts-with", "$Content-Type", ""],
["content-length-range", 0, max_bytes],
{"bucket": bucket},
{"x-goog-date": timestamp},
{"x-goog-credential": credential},
Expand Down
14 changes: 0 additions & 14 deletions src/agent_env/store/object_store/object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,20 +164,6 @@ def signed_put_url(self, object_url: str, expires_in: int = 3600) -> str | None:
or None if the backend can't produce one (e.g. local filesystem)."""
return None

def signed_post(
self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int | None = None
) -> dict | None:
"""Credentials letting a remote party upload objects it names itself, anywhere
under ``url_prefix``. None if the backend can't produce them.

Prefix-scoped so the uploader owns its file layout — the caller bounds where
it may write, not what it may call things. That is also why the grant is a
dict and not a url: a signed url covers one key, since the key is part of
what is signed, so a prefix needs a policy the uploader submits as form
fields. Pass the whole value on, not just ``url``.
"""
return None

def shared_credentials_env(self) -> dict[str, str]:
"""Environment variables handing credentials to the agents and env services agent-env
deploys, so they can use this store directly. None by default; a deployment that wants
Expand Down
11 changes: 2 additions & 9 deletions src/agent_env/store/object_store/s3_object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,19 +226,12 @@ def issue_upload_policy(
expires_at=datetime.now(UTC) + timedelta(seconds=expires_in),
)

def signed_post(
self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int | None = None
) -> dict | None:
return self._post_policy(url_prefix, expires_in, max_bytes)

def _post_policy(self, url_prefix: str, expires_in: int, max_bytes: int | None) -> dict:
def _post_policy(self, url_prefix: str, expires_in: int, max_bytes: int) -> dict:
"""A presigned POST for any key below ``url_prefix``: the key is a condition the uploader
fills in, not part of what is signed. The prefix ends in ``/``, so that ``root`` does not
admit ``root-evil/``."""
bucket, prefix = self._split(url_prefix.rstrip("/") + "/")
conditions: list = [["starts-with", "$key", prefix]]
if max_bytes is not None:
conditions.append(["content-length-range", 0, max_bytes])
conditions: list = [["starts-with", "$key", prefix], ["content-length-range", 0, max_bytes]]
# POST policies are default-deny on extra form fields: without this the
# uploader's Content-Type 403s and objects store as binary/octet-stream.
conditions.append(["starts-with", "$Content-Type", ""])
Expand Down
3 changes: 0 additions & 3 deletions src/agent_env/store/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,6 @@ def signed_get_url(self, object_url: str, expires_in: int = 3600) -> str | None:
def signed_put_url(self, object_url: str, expires_in: int = 3600) -> str | None:
return self._writing(object_url).signed_put_url(object_url, expires_in)

def signed_post(self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int | None = None) -> dict | None:
return self._writing(url_prefix).signed_post(url_prefix, expires_in=expires_in, max_bytes=max_bytes)

def shared_credentials_env(self) -> dict[str, str]:
"""None: an ``@local`` run's objects reach its sandboxes through grants, staging or pushed bytes, so the
configured store's credentials, the caller's own, stay with the caller."""
Expand Down
24 changes: 6 additions & 18 deletions tst/unit/store/gcs_object_store_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,24 +511,12 @@ def test_an_upload_policy_lasts_at_most_what_its_signer_can_sign(store, client):
def test_without_a_signer_there_is_no_upload_policy(unsigned):
with pytest.raises(GrantUnavailableError, match="no signer"):
unsigned.issue_upload_policy(f"gs://{BUCKET}/ns/", max_object_bytes=8, expires_in=600)
assert unsigned.signed_post(f"gs://{BUCKET}/ns/") is None


def test_signed_post_is_shaped_as_the_s3_store_s_and_clamped_to_the_signer(store, client):
post = store.signed_post(f"gs://{BUCKET}/snap/", expires_in=10**7)
assert set(post) == {"url", "fields"} and post["fields"]["key"] == "snap/${filename}"
document = _policy(post["fields"])
assert _expiration(document) <= datetime.now(UTC) + timedelta(seconds=43200)
assert not any(isinstance(c, list) and c[0] == "content-length-range" for c in document["conditions"])
slashless = _policy(store.signed_post(f"gs://{BUCKET}/snap")["fields"])
assert ["starts-with", "$key", "snap/"] in slashless["conditions"]
root = store.signed_post(f"gs://{BUCKET}/")["fields"]
assert root["key"] == "${filename}" and ["starts-with", "$key", ""] in _policy(root)["conditions"]
bounded = _policy(store.signed_post(f"gs://{BUCKET}/snap/", max_bytes=5)["fields"])
assert ["content-length-range", 0, 5] in bounded["conditions"]
keyed = GcsObjectStore(client, BUCKET, signer=_key_credentials())
expiration = _expiration(_policy(keyed.signed_post(f"gs://{BUCKET}/snap/", expires_in=10**7)["fields"]))
assert datetime.now(UTC) + timedelta(seconds=43200) < expiration <= datetime.now(UTC) + timedelta(seconds=604800)


def test_an_upload_policy_at_the_bucket_root_admits_every_key(store, signer):
write = store.issue_upload_policy(f"gs://{BUCKET}/", max_object_bytes=8, expires_in=600).write
assert write.fields["key"] == "${filename}"
assert ["starts-with", "$key", ""] in _policy(write.fields)["conditions"]


def test_changelog_capture_gets_the_object_form(store):
Expand Down
86 changes: 0 additions & 86 deletions tst/unit/store/s3_signed_post_test.py

This file was deleted.

2 changes: 1 addition & 1 deletion tst/unit/task/local_run_write_fence_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
LOCAL_TASK = "@local/~/bundle/tasks/t"
LOCAL_ENV = "@local/~/bundle/envs/tickets"
_DOCUMENT_WRITES = ("insert", "update", "update_one_and_get", "replace", "delete")
_OBJECT_WRITES = ("put", "put_file", "put_file_at", "signed_put_url", "signed_post", "issue_write_grant", "issue_upload_policy")
_OBJECT_WRITES = ("put", "put_file", "put_file_at", "signed_put_url", "issue_write_grant", "issue_upload_policy")


def _refusing(cls, methods):
Expand Down
Loading