Repository navigation
feat: agents on the local sandbox provider move objects through the local store's grants - #13
Conversation
…t types A write now lands whole or not at all. Bytes are staged in a reserved .agentenv-tmp directory inside the root and put in place with a rename (an overwrite) or a hard link (a write-once put), so a crash or a concurrent reader never sees a partial object, and a write-once put that loses a race raises ObjectAlreadyExistsError instead of replacing the winner. Filesystems without hard links fall back to an exclusive create. A write's content type is recorded in a reserved .agentenv-meta tree and returned by get_object_metadata. The default type, which a writer that names none gets, reads back as unknown, so readers keep guessing from the name as before. Both directories are refused as keys and left out of listings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LocalFilesystemObjectStore's issue_read_grant, issue_write_grant and issue_upload_policy now
return real http-get, http-put and http-post-policy grants, served by an HTTPS server inside the
process that issued them. It is started by the first grant and runs in a daemon thread: starlette
on uvicorn, one route, /v1/grants/{token}, with no access log.
- Tokens are HMAC-SHA256-signed claims (operation, key or key prefix, expiry, size bound, content
type) under a key held only in this process's memory; the server trusts nothing else.
- GET returns the stored bytes and type. PUT requires the signed Content-Type, bounds the body to
the grant while it streams, and replaces the object atomically. A multipart POST names its key
before its file, must stay under the grant's prefix, and is bounded the same way.
- TLS comes from a per-user local CA under <state root>/tls, name-constrained to localhost,
host.docker.internal and loopback or private addresses, with ca.pem and ca-bundle.pem (the
public roots plus the CA) for containers to trust. It is never installed on the host.
- The server listens on 127.0.0.1, or the Docker bridge gateway on Linux, and grant URLs name
host.docker.internal; grant_bind_host and grant_advertise_host override both. root is now
optional and defaults to <state root>/object_store.
supports_transfer_grants stays false, so no agent is handed these grants yet: containers must
first trust the CA and reach the server.
starlette, uvicorn (0.47 for ssl_context_factory), python-multipart, cryptography and certifi
are declared as core dependencies; all were already installed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ble directory ca.pem and ca-bundle.pem now live in <state root>/tls/trust/, mode 0755, apart from the CA key, so the directory can be copied into a container whole and read by whatever user it runs as. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ocal store's grants The local object store now offers its HTTPS transfer grants (grants = "auto", the default; "off" hands out none), so on the all-local configuration skill bundles, trajectories, snapshot saves and loads, and changelog capture and replay move through the object forms agents already take. - Local containers trust the local transfer CA: LocalSandboxProvider creates the container, copies the CA's public trust files to /etc/agentenv/, then starts it, with SSL_CERT_FILE and REQUESTS_CA_BUNDLE on the bundle (public roots plus the CA) and NODE_EXTRA_CA_CERTS on the CA, unless the caller's env sets them. The base provider's docker run moves into protected _start_container / _container_args hooks, with the same script. - Grants go only to agents they reach: ObjectStore.grants_reach(sandbox_type), true by default and only for "local" on the local store (a local run asks its local store). choose_transfer and every call builder take the agent's sandbox_type, so an agent on another provider keeps the inline forms it gets today. - A legacy s3_prefix / skill_s3_url form carrying a local store's file:// URL is refused, saying why: grants off, an agent that takes only the S3 form, or grants that do not reach it. - An objects-form call that fails with transfer_unavailable names this process's grant server and the grant_bind_host / grant_advertise_host settings. - The validator's presigned-URI probe falls back to issue_read_grant. - A slow integration test proves an SDK agent in a local container uploads its trajectory through a local grant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…a write-once put never exposes an empty object A content type is recorded with the identity of the file it was written for (inode, size, mtime, which a rename keeps), so a type recorded for another write - a racing overwrite that renamed first, or an old record an overwrite could not remove - reads back as unknown, never as the wrong type. On a filesystem without hard links a write-once put now checks for the key and renames into place, instead of reserving the key with an empty file that readers could see and a crash could leave. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants
…ir part headers A process now keeps the local CA it first loads, and each CA's trust files are written once into a directory of their own under tls/trust/, so the grant server and the containers a process starts agree on the CA even when another process renews the one on disk. A server certificate lasts as long as its CA rather than 90 days, since a server keeps one for its whole life. A multipart upload's part headers are bounded to 16 KiB, as its fields and file already are. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ants' into edgararakelyan/local-provider-transfer-grants
…hard links Where no hard link can claim the key, a write-once put now claims it with a marker created exclusively in the staging directory, held only from the existence check to the rename. A racing writer waits for the marker to go and then finds the winner's object; a marker a crash left behind is taken over once it is ten seconds old. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
Comments Outside DiffThese findings could not be posted inline.
|
…nded out, including agents placed on a local VM sandbox The local provider now copies the CA in and sets the trust variables only when the configured object store (behind @Local routing too) is a local store that hands out grants; otherwise it runs the container as before, so a hosted store or grants = "off" adds no startup step that can fail. An agent placed on a local VM sandbox, which starts through A2AAgent._run_container rather than the provider, is given the CA the same way (and, on Linux, the host.docker.internal mapping). The validator's image probe uses a grant only where it reaches the provider the validation agents will deploy on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a copy that keeps modification times Without hard links, a write-once put's claim marker now names its owner's pid and is taken over only once that process has exited, not after ten seconds, so a slow live writer never loses it. On Windows, where rename refuses an existing target, the rename itself is the claim. A recorded content type is now paired with the file's size and modification time, and each write stamps its staged file with a modification time no other write shares (now, to the millisecond, plus a random remainder). Racing writes still never pair bytes with another write's type, and a copy of the store that keeps modification times keeps its types, where the inode it no longer paired with would not survive one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
… its holder A write-once put on a filesystem without hard links now claims its key with an exclusive flock on a file in the staging directory rather than a marker naming a pid. The kernel drops the lock when its holder exits, so a crashed writer never blocks the key and a reused pid can never pass for a live one; a live holder is waited for however long it takes. The file stays in place so every writer locks the same one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
…chain led by the local provider counts as local for the validator's probe start_trusting now removes the created container when the copy or the start fails, so a linked agent on a local VM sandbox (whose sandbox the agent does not own, and so never tears down) leaves its container name free for the next attempt. The validator's image-by-URI probe judges a fallback chain by its first provider, the one that runs the agent unless it fails; ChainedSandboxProvider gains a read-only providers property for that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…k, and staged files of dead writes are swept Chaos testing on exFAT, which keeps modification times to 10 ms, showed two same-size overwrites of one key could leave one's type recorded against the other's bytes (5 in 4,129 races): the per-write time stamp that tells writes apart is rounded away there. A write now holds the key's lock (an exclusive flock) across placing its bytes and recording its type, and a metadata read holds it shared, so no write lands between another's two steps and no reader sees one write's type with another's bytes, whatever the filesystem's time resolution. An overwrite drops the old type first, so one cut short leaves the type unknown, never wrong. Keys share 64 lock files, which also replace the per-key claim files the no-hard-link path used; Windows, which has no flock, keeps relying on the stamp, which NTFS's 100 ns times preserve. A store's first write also removes files staged by writes that died (untouched for an hour), which crashes left behind in .agentenv-tmp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ads now read the type under the key's lock Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
The GET handler now opens the object under the key's shared lock and reads its type against that open file, then streams from it, so a racing overwrite can no longer pair its bytes with the type of the object that was there when the type was read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es any file a live write holds An overwrite now sets the old type record aside while its bytes land and puts it back if they do not, so a rename that fails keeps the old object's type; one cut short after its bytes land still reads as unknown, never as the old type. Each staged file is held under an exclusive flock by its writer for as long as the write lives, and the sweep removes only staged files no process holds (and none younger than a minute, which may not be held yet), so a write paused for any length of time never loses its file. Windows, which has no flock, is not swept. Keys now share 256 lock files rather than 64, so fewer unrelated keys wait on one another, and the tests that hold a lock with flock skip on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants
…a staged file that cannot be locked is cleaned up Removing the set-aside type record after an overwrite's bytes land, and refreshing its time when it is set aside, are now best effort: a failure there leaves a file the sweep removes, rather than reporting a completed write as failed. A staged file whose lock cannot be taken is closed and removed like any other. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c-writes' into edgararakelyan/local-transfer-grants
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
…transfer-grants # Conflicts: # src/agent_env/store/object_store/local_object_store.py
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # README.md
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # src/agent_env/providers/sandbox_providers/sandbox_provider.py # tst/unit/providers/sandbox_providers/local_sandbox_test.py
…ether in object_store/local The local backend is now four modules, the store, its grant server, the CA and the grant tokens, and only the local store uses the last three. They move into one package, beside the single-module S3 and GCS backends: local_object_store.py -> local/store.py local_grants/server.py -> local/grant_server.py local_grants/tls.py -> local/tls.py local_grants/tokens.py -> local/tokens.py A rename only: imports and docstrings follow the files, and nothing else changes. LocalFilesystemObjectStore is still exported from agent_env.store.object_store and agent_env.store, which is how everything outside this repository imports it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ants' into edgararakelyan/local-provider-transfer-grants # Conflicts: # tst/unit/store/local_grant_server_test.py # tst/unit/store/test_object_transfer_grants.py
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…grants main now sends objects only through grants, so the reachability check moves into the object-form refusal: a call whose grants do not reach the agent's sandbox is refused, naming the provider. _s3_form_url and skill_add_call's agent_name, which existed for the S3 forms, are gone; object_transfer_reach_test follows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| if object_url is not None: | ||
| _require_object_form(method, BundleSkillRequest, store, operation="skill add") | ||
| _require_object_form( | ||
| method, BundleSkillRequest, store, operation="skill add", sandbox_type=sandbox_type | ||
| ) |
There was a problem hiding this comment.
When an agent accepts only skill_s3_url, an S3-backed skill now reaches _require_object_form and fails before the agent receives it. The same grant-only requirement stops snapshot saves on agents that accept only s3_prefix. Changelog capture also fails when S3 cannot issue a grant for the full capture lifetime. Keep the older forms for reachable S3 objects.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/a2a_agent/object_transfer.py
Line: 312-315
Comment:
**Older S3 calls fail**
When an agent accepts only `skill_s3_url`, an S3-backed skill now reaches `_require_object_form` and fails before the agent receives it. The same grant-only requirement stops snapshot saves on agents that accept only `s3_prefix`. Changelog capture also fails when S3 cannot issue a grant for the full capture lifetime. Keep the older forms for reachable S3 objects.
**Knowledge Base Used:**
- [A2A agent runtime](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/a2a-agent-runtime.md)
- [Store backends and querying](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/store-backends-and-querying.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Intentional: main removed the S3 transfer forms in #54, and this merge carries that in. Object-backed skills, snapshots and changelogs go only through grants now, and an agent that advertises only the S3 forms is refused before anything is sent. #46's call with the removed portable= is fixed in e0fd15b.
There was a problem hiding this comment.
Thanks for the clarification. Given that #54 intentionally removes the S3 transfer forms and this PR carries that change, refusing agents that advertise only skill_s3_url/s3_prefix is expected rather than a regression. The portable= usage from #46 is also fixed in e0fd15b, so this finding is no longer actionable.
There was a problem hiding this comment.
This isn't from this PR. Its only change on these lines passes sandbox_type= through. For an S3 store grants_reach is True, so S3 behaves exactly as it does on main. The requirement that S3-backed skills, snapshot saves and changelogs use the object form comes from #54, which removed the S3 transfer forms on purpose.
The same holds for the findings posted outside the diff, which came into view with the merge of main:
trajectory_s3_prefixanswers,agent_changelog_s3_prefixin saved plans, and non-portable snapshots: refactor(a2a)!: remove the S3 transfer forms #54's intended breaking change.- The id-length check in
snapshot_series.py: from feat(store)!: name every derived id <base>__<suffix>, bare or @local #26. It validates<base>__snapshot-<step>-workspace, which is longer than either id actually written, so it is stricter than needed, but only for ids within about 10 bytes of the limit. Not changed here. portable=Truein feat(a2a): agents the object store's grants can't reach get their objects through their own staging #46'sstaging_test.py: feat(a2a): agents the object store's grants can't reach get their objects through their own staging #46's branch no longer passes it.
There was a problem hiding this comment.
You're right. The change on these lines only threads sandbox_type into _require_object_form; with grants_reach=True for S3, it does not alter S3 behavior. The S3-form removals are intentional in #54, and the trajectory/changelog/snapshot and ID-length findings are from earlier PRs rather than this diff. None of these findings is actionable for PR #13.
…pe without reading the object Starlette's StreamingResponse has no HEAD case, so a HEAD streamed the whole object only for the server to drop the body. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ants' into edgararakelyan/local-provider-transfer-grants
…provider-transfer-grants # Conflicts: # src/agent_env/store/object_store/local/grant_server.py # src/agent_env/store/object_store/local/store.py # tst/unit/store/local_grant_server_test.py # tst/unit/store/test_object_transfer_grants.py
Main's derived-id change dropped refuse_local_derivation from the validator and added derive_id/key_segment; keep both sides' imports. Main's new acceptance test expected validating an @Local agent to stop at the local store because it cannot sign URLs. With local grants the store serves the probe image to agents on the local provider, so the test now deploys its agents on a remote provider, where the refusal still comes before any task is written, and matches the refusal's new wording. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
Summary
This is the third of three PRs, stacked on #11. It turns on the local object store's HTTPS transfer grants for agents on the
localsandbox provider. With it, skill bundles, trajectories, snapshot saves and loads, and changelog capture and replay work on the all-local configuration the same way they do against S3, through the forms agents already take. Agents and the protocol don't change.LocalFilesystemObjectStoregetsgrants = "auto" | "off"(default"auto"), and with"auto"supports_transfer_grantsis true."off"hands out no grants.@localrouting) is a local store that hands out grants. Otherwise containers run exactly as before. In that caseLocalSandboxProviderrunsdocker create, thendocker cpof the CA's public trust directory to/etc/agentenv/, thendocker start, and removes the container if the copy or the start fails.A2AAgent._run_container, is given the CA the same way, plus the Linuxhost.docker.internalmapping.SSL_CERT_FILEandREQUESTS_CA_BUNDLEtoca-bundle.pem(certifi's roots plus the CA) andNODE_EXTRA_CA_CERTStoca.pem. A variable the caller's env already sets wins./apppaths.docker runmoves into two protected hooks,_start_containerand_container_args, and its script is byte-identical.ObjectStore.grants_reach(sandbox_type)defaults to true, for public HTTPS URLs.local.LocalRunObjectStoreasks its local store, since an@localrun writes there.choose_transfertakes the agent'ssandbox_type, and every call builder and call site threads the deployed agent's type through: skills, snapshot save and load, changelog enable and apply, trajectoryget(prompt, judge, partial and validator), and the validator's probes.@localrun keeps getting its trajectory and SKILL.md inline, as it does today.file://in S3-named fields. A legacys3_prefixorskill_s3_urlform carrying a local store'sfile://URL, which no agent can use, is now refused with the reason: grants off, a legacy-only agent, or grants that don't reach the agent's provider. A mode-None refusal adds the reachability reason when that is why.transfer_unavailableand its grants came from this process's grant server, the error names the server's origin and bind address and points atgrant_bind_host/grant_advertise_host. It never quotes a grant.issue_read_grantwhen the store signs no URL, but only when those grants reach the provider the validation agents will deploy on. A fallback chain counts as its first provider.ChainedSandboxProvidergains a read-onlyprovidersproperty.grants_reachfor custom stores need a page there, as a follow-up.Merge order
issue_*methods, advertised to no one yet.bump-version.Each one merges after its required checks, Greptile, and a code-owner approval, and gets rebased onto
mainafter the previous squash-merge.Test plan
pytest tst/unit packages/agentenv-protocol/tests -n auto: 5751 passed, 13 skippedgrantson and off, reach per sandbox type, S3 reaching any type, and routing delegation.transfer_unavailablebut not fortransfer_rejected, with no token in it.test_an_agent_uploads_its_trajectory_through_a_local_grantandtest_load_artifact_stages_files_into_env_and_agentpassed (run withDOCKER_DEFAULT_PLATFORM=linux/amd64, since the test image cache builds amd64 images an arm64 host can't otherwise pull). Here the SDK echo agent, running as a non-root user in a local container, uploads its trajectory through a local grant.pytest -m 'not int_test_slow' tst/integration --ignore=tst/integration/env/gateway/gateway_test.py: 141 passed, and green in CI'sintegration-local.integration-local-slowjob runs the new test on Linux (the bridge-gateway bind plushost-gateway) and is green. The model-gated slow tests skip there, as before.s3_prefix/skill_s3_urlpayload carrying a localfile://URL now assert the refusal, and their positive cases moved to S3 URLs. Test fakes gainedgrants_reach, and fixtures name their agent'ssandbox_type.🤖 Generated with Claude Code
This PR appears safe to merge; no new actionable issue remains.
Fix with agent prompt
Summary
Local agents can now use the local object store’s HTTPS transfer grants, with containers trusting the local CA before they start. Transfer choices check which sandbox can reach a store’s grants, and validator probes and transfer errors now account for reachable local grants.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR Store[Object store] --> Reach{Grant reaches agent?} Reach -->|Yes| Grant[Send object grant] Reach -->|No| Inline[Use inline form or refuse] Grant --> Local[Local container trusts CA]Reviews (15) · Last reviewed commit: "Merge main into local-provider-transfer-..."