Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
97a7770
fix(store): the local object store writes atomically and keeps conten…
earakely-scale Sep 30, 2026
8ef1ddf
feat(store): the local object store issues HTTPS transfer grants
earakely-scale Sep 30, 2026
a9af97e
fix(store): keep the local CA's public trust files in their own reada…
earakely-scale Sep 30, 2026
f29d826
feat: agents on the local sandbox provider move objects through the l…
earakely-scale Sep 30, 2026
e91eea9
fix(store): a recorded content type names the file it describes, and …
earakely-scale Sep 30, 2026
0cbb1ac
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
38846c9
fix(store): a process keeps one local CA, and grant uploads bound the…
earakely-scale Sep 30, 2026
fdac845
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
1271813
fix(store): a write-once put stays exclusive on a filesystem without …
earakely-scale Sep 30, 2026
dd586ab
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
93a23ac
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
ba00e22
fix: local containers get the local CA only where local grants are ha…
earakely-scale Sep 30, 2026
5896423
fix(store): a live writer keeps its claim, and recorded types survive…
earakely-scale Sep 30, 2026
0214a47
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
379d392
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
a2d2793
fix(store): claim a key without hard links with a lock that dies with…
earakely-scale Sep 30, 2026
14081e1
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
f051005
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
340e1e6
fix: a trusted local container that fails to start is removed, and a …
earakely-scale Sep 30, 2026
d46296d
fix(store): a key's bytes and type are written and read under its loc…
earakely-scale Sep 30, 2026
67e8cb0
Merge the local store's key locks into the grant server, whose downlo…
earakely-scale Sep 30, 2026
45a48f4
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
7bf3aa2
fix(store): a grant download sends the bytes and type of one write
earakely-scale Sep 30, 2026
1b8b548
fix(store): a failed overwrite keeps the old type, and the sweep spar…
earakely-scale Sep 30, 2026
26ffcb7
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
35ea9bc
fix(store): an overwrite that lands is never reported as failed, and …
earakely-scale Sep 30, 2026
cea9c1b
Merge remote-tracking branch 'origin/edgararakelyan/local-store-atomi…
earakely-scale Sep 30, 2026
0ee81fb
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
f48f5a8
Merge remote-tracking branch 'origin/main' into edgararakelyan/local-…
earakely-scale Sep 30, 2026
5c4c9b3
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Sep 30, 2026
3ceb316
feat(store): each object store sets how long its transfer grants last…
earakely-scale Sep 30, 2026
9d8b992
fix(store): a local run leaves a store's own grant default alone, and…
earakely-scale Sep 30, 2026
d31ce4a
Merge remote-tracking branch 'origin/main' into edgararakelyan/local-…
earakely-scale Oct 1, 2026
1e20c00
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Oct 1, 2026
480b706
Merge remote-tracking branch 'origin/edgararakelyan/local-provider-tr…
earakely-scale Oct 1, 2026
72e35d4
Merge remote-tracking branch 'origin/main' into edgararakelyan/local-…
earakely-scale Oct 5, 2026
98f20a3
refactor(store): the local object store and its grant server live tog…
earakely-scale Oct 5, 2026
dc4ed3b
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Oct 5, 2026
e3e8f0c
Merge remote-tracking branch 'origin/edgararakelyan/local-provider-tr…
earakely-scale Oct 5, 2026
e653679
Merge main into local-transfer-grants
earakely-scale Oct 5, 2026
c7fd1a6
Merge local-transfer-grants (with main) into local-provider-transfer-…
earakely-scale Oct 5, 2026
f8e8f46
Merge local-provider-transfer-grants (with main) into store-grant-lif…
earakely-scale Oct 5, 2026
9ba5dea
fix(store): a HEAD through a local read grant reports the size and ty…
earakely-scale Oct 5, 2026
af900c0
Merge remote-tracking branch 'origin/edgararakelyan/local-transfer-gr…
earakely-scale Oct 5, 2026
9b82a53
Merge remote-tracking branch 'origin/edgararakelyan/local-provider-tr…
earakely-scale Oct 5, 2026
c18f840
Merge remote-tracking branch 'origin/main' into edgararakelyan/local-…
earakely-scale Oct 5, 2026
0b5313f
Merge remote-tracking branch 'origin/edgararakelyan/local-provider-tr…
earakely-scale Oct 5, 2026
7264979
Merge main into local-provider-transfer-grants
earakely-scale Oct 5, 2026
fa55fea
Merge commit '7264979' into edgararakelyan/store-grant-lifetime
earakely-scale Oct 5, 2026
f518cc3
Merge main into store-grant-lifetime
earakely-scale Oct 5, 2026
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
12 changes: 5 additions & 7 deletions src/agent_env/a2a_agent/object_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@
_INCREMENT_NAME = re.compile(r"^(?P<sequence>[0-9]{6})(?:\.[A-Za-z0-9][A-Za-z0-9._-]*)?$")
_TRANSFER_UNAVAILABLE = "transfer_unavailable" # the SDK's code for a store it could not reach

# The time budget of one transfer, outermost first: a grant outlives agent-env's wait for the
# agent's answer, which outlasts the SDK's retries of stalled connections
# The time budget of one transfer, outermost first: a grant (the issuing store's
# grant_lifetime_seconds, 12 hours unless configured) outlives agent-env's wait for the agent's
# answer, which outlasts the SDK's retries of stalled connections
# (agentenv_protocol.transfers.TRANSFER_STALL_BUDGET_SECONDS).
GRANT_LIFETIME_SECONDS = 3600
TRANSFER_TIMEOUT_SECONDS = 600 # a call during which the agent moves objects
REPLY_TIMEOUT_SECONDS = 120 # a call during which it moves none

Expand Down Expand Up @@ -198,9 +198,7 @@ def write_object(store: ObjectStore, url: str, *, media_type: str, max_bytes: in
return WriteObject(
media_type=media_type,
max_bytes=max_bytes,
write=store.issue_write_grant(
url, media_type=media_type, max_bytes=max_bytes, expires_in=GRANT_LIFETIME_SECONDS
),
write=store.issue_write_grant(url, media_type=media_type, max_bytes=max_bytes),
)


Expand All @@ -219,7 +217,7 @@ def read_object(
media_type=media_type or stored_type,
max_bytes=max_bytes or max(metadata.size, 1),
size_bytes=metadata.size,
read=store.issue_read_grant(url, expires_in=GRANT_LIFETIME_SECONDS),
read=store.issue_read_grant(url),
)


Expand Down
4 changes: 4 additions & 0 deletions src/agent_env/store/object_store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from agent_env.store.object_store.local.store import LocalFilesystemObjectStore
from agent_env.store.object_store.object_store import (
DEFAULT_CONTENT_TYPE,
DEFAULT_GRANT_LIFETIME_SECONDS,
MIN_GRANT_LIFETIME_SECONDS,
ObjectMetadata,
ObjectStore,
UploadPolicy,
Expand All @@ -19,6 +21,8 @@
"ObjectMetadata",
"UploadPolicy",
"DEFAULT_CONTENT_TYPE",
"DEFAULT_GRANT_LIFETIME_SECONDS",
"MIN_GRANT_LIFETIME_SECONDS",
"S3ObjectStore",
"LocalFilesystemObjectStore",
]
27 changes: 22 additions & 5 deletions src/agent_env/store/object_store/gcs_object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
from agent_env.store.base import GrantUnavailableError, ObjectAlreadyExistsError, ObjectNotFoundError
from agent_env.store.object_store.object_store import (
DEFAULT_CONTENT_TYPE,
DEFAULT_GRANT_LIFETIME_SECONDS,
ObjectMetadata,
ObjectStore,
UploadPolicy,
grant_lifetime,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -84,7 +86,14 @@ class GcsObjectStore(ObjectStore):

max_single_upload_bytes = 5 * 1024**4 # one Cloud Storage object

def __init__(self, client, bucket: str, *, signer: Signing | None = None) -> None:
def __init__(
self,
client,
bucket: str,
*,
signer: Signing | None = None,
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS,
) -> None:
self._client = client
self._bucket = bucket
self._signer = signer
Expand All @@ -93,11 +102,17 @@ def __init__(self, client, bucket: str, *, signer: Signing | None = None) -> Non
self._max_signed_seconds = (
_MAX_KEY_SIGNED_SECONDS if isinstance(signer, service_account.Credentials) else _MAX_IAM_SIGNED_SECONDS
)
self.grant_lifetime_seconds = grant_lifetime(grant_lifetime_seconds, most=self._max_signed_seconds)
self._warned_unsigned = False

@classmethod
def from_config(
cls, *, bucket: str, project: str | None = None, signing_service_account: str | None = None
cls,
*,
bucket: str,
project: str | None = None,
signing_service_account: str | None = None,
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS,
) -> GcsObjectStore:
"""Authenticate through Application Default Credentials. With ``signing_service_account``
the store always signs as that account through IAM, which needs
Expand Down Expand Up @@ -127,7 +142,7 @@ def from_config(
quota = getattr(credentials, "quota_project_id", None)
billed = f" (requests are billed to quota project {quota!r})" if quota else ""
raise ConfigError(f"Cannot sign as {signer.signer_email}{billed}: {e}") from e
return cls(client, bucket, signer=signer)
return cls(client, bucket, signer=signer, grant_lifetime_seconds=grant_lifetime_seconds)

def put(
self,
Expand Down Expand Up @@ -230,7 +245,8 @@ def signed_put_url(self, object_url: str, expires_in: int = 3600) -> str | None:
return None
return self._sign(blob, "PUT", min(expires_in, self._max_signed_seconds))

def issue_read_grant(self, object_url: str, *, expires_in: int = 3600) -> HttpGetGrant:
def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
blob = self._blob(object_url)
expires_at = self._grant_expiry(expires_in)
return HttpGetGrant(kind="http-get", url=self._sign(blob, "GET", expires_in), expires_at=expires_at)
Expand All @@ -241,8 +257,9 @@ def issue_write_grant(
*,
media_type: str,
max_bytes: int,
expires_in: int = 3600,
expires_in: int | None = None,
) -> HttpPutGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
blob = self._blob(object_url)
expires_at = self._grant_expiry(expires_in)
bound = {"x-goog-content-length-range": f"0,{max_bytes}"}
Expand Down
11 changes: 9 additions & 2 deletions src/agent_env/store/object_store/local/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
from agent_env.store.object_store.local.tokens import Op
from agent_env.store.object_store.object_store import (
DEFAULT_CONTENT_TYPE,
DEFAULT_GRANT_LIFETIME_SECONDS,
ObjectMetadata,
ObjectStore,
UploadPolicy,
grant_lifetime,
)

logger = logging.getLogger(__name__)
Expand All @@ -47,6 +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
_LOCAL_SANDBOX_TYPE = "local" # LocalSandbox.type: containers on this host's Docker, which trust the local CA

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -80,6 +83,7 @@ def __init__(
grants: str = "auto",
grant_bind_host: str | None = None,
grant_advertise_host: str | None = None,
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS,
) -> None:
if grants not in _GRANT_MODES:
raise ValueError(f"grants must be one of {_GRANT_MODES}, got {grants!r}")
Expand All @@ -92,6 +96,7 @@ def __init__(
check_local_host(grant_advertise_host)
self._root = Path(root) if root is not None else state_root() / "object_store"
self.supports_transfer_grants = grants == "auto"
self.grant_lifetime_seconds = grant_lifetime(grant_lifetime_seconds, most=_MAX_GRANT_LIFETIME_SECONDS)
self._grant_bind_host = grant_bind_host
self._grant_advertise_host = grant_advertise_host
self._swept = False
Expand Down Expand Up @@ -191,13 +196,15 @@ def get_object_key(self, object_url: str) -> str:
def grants_reach(self, sandbox_type: str | None) -> bool:
return sandbox_type == _LOCAL_SANDBOX_TYPE

def issue_read_grant(self, object_url: str, *, expires_in: int = 3600) -> HttpGetGrant:
def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
url, expires_at = self._grant("get", expires_in, key=self._grant_key(object_url))
return HttpGetGrant(kind="http-get", url=url, expires_at=expires_at)

def issue_write_grant(
self, object_url: str, *, media_type: str, max_bytes: int, expires_in: int = 3600
self, object_url: str, *, media_type: str, max_bytes: int, expires_in: int | None = None
) -> HttpPutGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
url, expires_at = self._grant(
"put", expires_in, key=self._grant_key(object_url), max_bytes=max_bytes, content_type=media_type
)
Expand Down
30 changes: 26 additions & 4 deletions src/agent_env/store/object_store/object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@
)

DEFAULT_CONTENT_TYPE = "application/octet-stream"
DEFAULT_GRANT_LIFETIME_SECONDS = 12 * 60 * 60
# The shortest a store's grants may be set to last: longer than the ten minutes agent-env waits for an agent
# to move an object through one.
MIN_GRANT_LIFETIME_SECONDS = 15 * 60


def grant_lifetime(seconds: object, *, most: int | None = None) -> int:
"""``seconds`` checked as a store's ``grant_lifetime_seconds`` setting: a whole number of seconds, at
least ``MIN_GRANT_LIFETIME_SECONDS``, and no more than ``most`` where the store cannot sign for longer."""
if isinstance(seconds, bool) or not isinstance(seconds, int):
raise ValueError(f"grant_lifetime_seconds must be a whole number of seconds, got {seconds!r}")
if seconds < MIN_GRANT_LIFETIME_SECONDS:
raise ValueError(
f"grant_lifetime_seconds must be at least {MIN_GRANT_LIFETIME_SECONDS}, to outlast a transfer; got {seconds}"
)
if most is not None and seconds > most:
raise ValueError(f"grant_lifetime_seconds can be at most {most} for this store, got {seconds}")
return seconds


@dataclass(frozen=True)
Expand Down Expand Up @@ -47,6 +65,9 @@ class ObjectStore(ABC):
supports_transfer_grants: bool = False
# The largest object one upload through a grant can create, where the provider caps it.
max_single_upload_bytes: int | None = None
# How long a read or write grant lasts when its caller names no lifetime; the built-in stores
# take it as their grant_lifetime_seconds setting.
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS

@classmethod
def from_config(cls, **config) -> ObjectStore:
Expand Down Expand Up @@ -167,11 +188,12 @@ def grants_reach(self, sandbox_type: str | None) -> bool:
return True

def issue_read_grant(
self, object_url: str, *, expires_in: int = 3600
self, object_url: str, *, expires_in: int | None = None
) -> HttpGetGrant:
"""An HTTPS GET grant for the existing object at ``object_url``. ``expires_at`` is at most
``expires_in`` away, and earlier if the store's signing credentials expire first; raise
GrantUnavailableError for an ``expires_in`` beyond what the store can sign."""
``expires_in`` away (None: ``grant_lifetime_seconds``), and earlier if the store's signing
credentials expire first; raise GrantUnavailableError for an ``expires_in`` beyond what the
store can sign."""
raise NotImplementedError(
f"{type(self).__name__} cannot issue remote object-transfer grants"
)
Expand All @@ -182,7 +204,7 @@ def issue_write_grant(
*,
media_type: str,
max_bytes: int,
expires_in: int = 3600,
expires_in: int | None = None,
) -> HttpPutGrant:
"""An HTTPS PUT grant for one object at ``object_url``, signed for ``media_type`` and
bounded to ``max_bytes`` where the provider can enforce that; expiry as for a read grant."""
Expand Down
29 changes: 24 additions & 5 deletions src/agent_env/store/object_store/s3_object_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
from agent_env.store.base import GrantUnavailableError, ObjectAlreadyExistsError, ObjectNotFoundError
from agent_env.store.object_store.object_store import (
DEFAULT_CONTENT_TYPE,
DEFAULT_GRANT_LIFETIME_SECONDS,
ObjectMetadata,
ObjectStore,
UploadPolicy,
grant_lifetime,
)

logger = logging.getLogger(__name__)
Expand All @@ -51,10 +53,17 @@ class S3ObjectStore(ObjectStore):
max_single_upload_bytes = 5 * 1024 * 1024 * 1024 # one S3 PUT

def __init__(
self, client, bucket: str, *, region: str | None = None, share_credentials: bool = False
self,
client,
bucket: str,
*,
region: str | None = None,
share_credentials: bool = False,
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS,
) -> None:
if not isinstance(share_credentials, bool):
raise ValueError(f"share_credentials must be true or false, got {share_credentials!r}")
self.grant_lifetime_seconds = grant_lifetime(grant_lifetime_seconds, most=_MAX_SIGV4_EXPIRY_SECONDS)
self._s3 = client
self._bucket = bucket
self._region = region
Expand All @@ -66,7 +75,12 @@ def __init__(

@classmethod
def from_config(
cls, *, bucket: str, region: str | None = None, share_credentials: bool = False
cls,
*,
bucket: str,
region: str | None = None,
share_credentials: bool = False,
grant_lifetime_seconds: int = DEFAULT_GRANT_LIFETIME_SECONDS,
) -> S3ObjectStore:
"""Build an adaptive-retry boto3 S3 client for ``bucket``."""
kwargs = {
Expand All @@ -78,7 +92,10 @@ def from_config(
}
if region:
kwargs["region_name"] = region
return cls(boto3.client("s3", **kwargs), bucket, region=region, share_credentials=share_credentials)
return cls(
boto3.client("s3", **kwargs), bucket, region=region, share_credentials=share_credentials,
grant_lifetime_seconds=grant_lifetime_seconds,
)

@property
def bucket(self) -> str:
Expand Down Expand Up @@ -164,8 +181,9 @@ def signed_put_url(self, object_url: str, expires_in: int = 3600) -> str | None:
return self._signed_url("put_object", object_url, expires_in)

def issue_read_grant(
self, object_url: str, *, expires_in: int = 3600
self, object_url: str, *, expires_in: int | None = None
) -> HttpGetGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
return HttpGetGrant(
kind="http-get",
url=self._signed_url("get_object", object_url, expires_in),
Expand All @@ -178,8 +196,9 @@ def issue_write_grant(
*,
media_type: str,
max_bytes: int,
expires_in: int = 3600,
expires_in: int | None = None,
) -> HttpPutGrant:
expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in
# A presigned S3 PUT cannot bound the upload size, so the uploader enforces max_bytes.
return HttpPutGrant(
kind="http-put",
Expand Down
16 changes: 12 additions & 4 deletions src/agent_env/store/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,12 +558,14 @@ def signed_post(self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int
def shared_credentials_env(self) -> dict[str, str]:
return self.configured.shared_credentials_env()

def issue_read_grant(self, object_url: str, *, expires_in: int = 3600) -> HttpGetGrant:
return self._at(object_url).issue_read_grant(object_url, expires_in=expires_in)
def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant:
return self._at(object_url).issue_read_grant(object_url, **_lifetime(expires_in))

def issue_write_grant(self, object_url: str, *, media_type: str, max_bytes: int, expires_in: int = 3600) -> HttpPutGrant:
def issue_write_grant(
self, object_url: str, *, media_type: str, max_bytes: int, expires_in: int | None = None
) -> HttpPutGrant:
return self._writing(object_url).issue_write_grant(
object_url, media_type=media_type, max_bytes=max_bytes, expires_in=expires_in
object_url, media_type=media_type, max_bytes=max_bytes, **_lifetime(expires_in)
)

def issue_upload_policy(self, prefix_url: str, *, max_object_bytes: int, expires_in: int) -> UploadPolicy:
Expand Down Expand Up @@ -604,3 +606,9 @@ def auth(self, ref: str) -> RegistryAuth | None:

def _for(self, repository: str) -> ImageStore:
return self.local if repository.startswith(_LOCAL_REPOSITORY_PREFIX) else self.configured


def _lifetime(expires_in: int | None) -> dict[str, int]:
"""``expires_in`` as a keyword only when the caller named one, so the owning store applies its own default,
whatever it is."""
return {} if expires_in is None else {"expires_in": expires_in}
3 changes: 2 additions & 1 deletion tst/unit/a2a_agent/object_transfer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
read_objects_under,
write_object,
)
from agent_env.store.object_store import DEFAULT_GRANT_LIFETIME_SECONDS
from tst.util.granting_object_store import GrantingObjectStore


Expand Down Expand Up @@ -253,7 +254,7 @@ def test_the_transfer_time_budget_nests():
assert (
TRANSFER_STALL_BUDGET_SECONDS
< object_transfer.TRANSFER_TIMEOUT_SECONDS
< object_transfer.GRANT_LIFETIME_SECONDS
< DEFAULT_GRANT_LIFETIME_SECONDS
)


Expand Down
2 changes: 1 addition & 1 deletion tst/unit/a2a_agent/validator_trajectory_dag_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def grants_reach(self, sandbox_type: str | None) -> bool:
def get_object_key(self, object_url: str) -> str:
return object_url.removeprefix("s3://test-bucket/")

def issue_write_grant(self, object_url, *, media_type, max_bytes, expires_in):
def issue_write_grant(self, object_url, *, media_type, max_bytes, expires_in=None):
self.write_grants.append(object_url)
return HttpPutGrant(
kind="http-put",
Expand Down
Loading
Loading