Skip to content

feat(store): the local object store issues HTTPS transfer grants - #11

Merged
earakely-scale merged 25 commits into
mainfrom
edgararakelyan/local-transfer-grants
Oct 5, 2026
Merged

earakely-scale merged 25 commits into
mainfrom
edgararakelyan/local-transfer-grants

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This is the second of three PRs that let agents running in local sandboxes exchange files with the local object store over short-lived HTTPS grants, the same way they do with S3. It depends on #9 (atomic writes and kept content types) and is stacked on it. This PR adds the grants and the server that honours them. The next PR turns them on for agents.

LocalFilesystemObjectStore.issue_read_grant, issue_write_grant and issue_upload_policy now return real http-get, http-put and http-post-policy grants. So the grant payloads keep the shape agents already handle, and neither the agents nor the protocol change.

  • Grant server. An HTTPS server inside the issuing process: starlette on uvicorn, started by the first grant, in a daemon thread. It has one route, /v1/grants/{token}, and follows the S3 requests the grants stand in for:
    • GET returns the stored bytes and content type, and 404 when there's no object.
    • PUT requires the signed Content-Type (403 otherwise). It rejects a body larger than the grant with a 400, checking both the declared length and while it streams, then replaces the object atomically. Retries are safe.
    • Multipart POST must name its key before its file, as S3 requires. The key has to be a plain relative path under the grant's prefix, and a violation is a 403. The size bound is enforced while streaming, fields and part headers are bounded too, and the form's Content-Type is recorded. Fields after the file are ignored.
    • A token that doesn't verify, has expired or names another method gets a 403. There are no redirects.
  • Access logs are off, because they would record tokens. This is done with a quiet H11 protocol rather than access_log=False, which clears the process-wide uvicorn.access logger and would silence any other server in the process.
  • Tokens. v1.<claims>.<HMAC-SHA256>, signed with a key held only in this process's memory, so a grant stops working when the process exits. The claims are the operation, the key or key prefix, the expiry, the size bound, the content type and a grant id.
  • TLS from a per-user local CA. It is created under <state root>/tls/, with the key 0600, and never installed in host trust stores.
    • It is name-constrained to localhost, host.docker.internal, and loopback or private addresses, so even a leaked key can only vouch for those. Each process gets its own server certificate.
    • ca-bundle.pem is certifi's roots plus the CA, for SSL_CERT_FILE, which replaces the default trust. ca.pem is the CA alone, for trust stores that add to the defaults, such as NODE_EXTRA_CA_CERTS. Both live in a per-CA directory under tls/trust/ (0755), apart from the key, so that directory can be copied into a container whole.
    • A CA within 30 days of expiry, or one that can't be read, is replaced. A process keeps the CA it first loads, and each CA's trust files are written once into their own directory, tls/trust/<fingerprint>/. A renewal by another process therefore never changes what a running process's server or containers trust. A server certificate lasts as long as its CA.
  • Where it listens. On 127.0.0.1 for macOS and Windows, where Docker reaches the host's loopback as host.docker.internal. On native Linux it uses the Docker bridge gateway, which --add-host host.docker.internal:host-gateway points at. Grant URLs name host.docker.internal.
    • grant_bind_host and grant_advertise_host override both, via [stores.object] config = { … }.
    • The advertised host must be one the CA can vouch for. root is now optional and defaults to <state root>/object_store.
  • Not offered to agents yet. supports_transfer_grants stays False, so choose_transfer still picks the forms it picks today. Turning it on waits for the next PR, which makes local containers trust the CA and checks that an agent can reach the server.
  • Dependencies. Core now declares starlette, uvicorn, python-multipart, cryptography and certifi. All were already installed as dependencies of mcp and httpx. uvicorn needs 0.47 or later, for ssl_context_factory.

Test plan

  • pytest tst/unit packages/agentenv-protocol/tests -n auto: 5711 passed.
    • Grant cases against the real server over TLS: object_conformance.GRANT_CASES passes.
    • GET: exact bytes and type with no compression, 404 when missing, and HEAD.
    • Method checks: each grant allows only its own method (405 for DELETE); tampered, expired and closed-server grants are refused.
    • PUT: a wrong or missing type gets 403; an oversize body gets 400, both with a declared length and while chunked, and leaves nothing staged; retries overwrite.
    • POST: 403 for keys outside the prefix, traversal, //, ., a sibling, or the store's own files; 400 for an oversize upload, a key after the file, or no file; fields after the file are ignored; the content type is kept.
    • Two stores share one server and keep their own objects.
    • CA: handshakes over memory BIOs, with VERIFY_X509_STRICT, verify for localhost, host.docker.internal and 127.0.0.1, and fail for example.com and 8.8.8.8 because of the name constraints. The bundle's contents, the file modes, reuse, and replacement near expiry or when unreadable are covered.
    • Tokens: round-trip, expiry, another process's key, tampering, and invalid claims.
    • Settings: checked when the store is made; [stores.object] config keys are honoured.
    • Default bind: loopback off Linux, the bridge gateway on Linux, and loopback with a warning when docker can't say.
  • Plugin API check against the base: no break.
  • Manual, macOS with Rancher Desktop: from a stock agent image (Python 3.11, OpenSSL 3.5, httpx 0.28), with only SSL_CERT_FILE / NODE_EXTRA_CA_CERTS set to the mounted files:
    • httpx GET, PUT and multipart POST against a host server bound to 127.0.0.1, through host.docker.internal, all succeeded.
    • Node fetch succeeded, and public HTTPS still verified.
  • pytest -m 'not int_test_slow' tst/integration --ignore=tst/integration/env/gateway/gateway_test.py: 141 passed.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the latest change addresses the outstanding HEAD finding.

Fix All in CursorFindings

  1. P2 HEAD reads the whole file ▶
Fix with agent prompt
### Issue 1
src/agent_env/store/object_store/local/grant_server.py:252-253
A `HEAD` request reaches `_get`, which now returns a `StreamingResponse` backed by `_chunks`. The server reads the entire object even though the request returns only headers. For large objects, size checks waste disk work. Return the size and type headers without streaming the file for `HEAD`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The local object store now issues expiring HTTPS grants, and an in-process server uses them to read and write objects. A per-user local certificate authority and container-reachable addresses support HTTPS, while agents keep their existing transfer-method choice.

  • GET grants read one object; PUT and multipart grants write objects within their limits.
  • Signed tokens limit each grant by operation, scope, and expiry.
  • Local HTTPS trust files and platform-specific bind defaults support access from containers.
Diagram
sequenceDiagram
    participant Client
    participant Store as Local object store
    participant Server as Grant server
    participant Files as Stored files
    Client->>Store: Request transfer grant
    Store->>Server: Register store and sign grant
    Server-->>Client: HTTPS grant URL
    Client->>Server: GET, HEAD, PUT, or POST
    Server->>Server: Check token and method
    Server->>Files: Read metadata or transfer bytes
    Server-->>Client: Response
Loading

Reviews (13) · Last reviewed commit: "fix(store): a HEAD through a local read ..."

earakely-scale and others added 2 commits September 30, 2026 06:50
…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>
@earakely-scale
earakely-scale requested review from a team and polakamtejas as code owners September 30, 2026 17:16
…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>
Comment thread src/agent_env/store/object_store/local/grant_server.py
Comment thread src/agent_env/store/object_store/local_grants/tls.py Outdated
Comment thread src/agent_env/store/object_store/local/tls.py
Comment thread src/agent_env/store/object_store/local/grant_server.py
earakely-scale and others added 5 commits September 30, 2026 07:51
…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>
…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
@greptile-apps

This comment has been minimized.

earakely-scale and others added 2 commits September 30, 2026 08:14
… 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
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

The latest summary's two concerns are about the local store code from #9, which this PR stacks on: write-once races on filesystems without hard links, and recorded types disappearing. Both are fixed there, and this branch has merged them:

  • Write-once without hard links. A write-once put claims its key with a marker that names its owner's pid. A claim is taken over only once the owner process has exited, and on Windows the non-overwriting os.rename is the claim. (1271813, 5896423)
  • Recorded types. A type is paired with its file's size and a per-write unique modification time. Racing writes read back as unknown, never as the other write's type, and a copy of the store that keeps modification times keeps its types. (e91eea9, 5896423)

No inline findings remain open on this PR's own code. The four earlier threads were each fixed or answered in 38846c9.

earakely-scale and others added 2 commits September 30, 2026 08:24
… 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
@scaleapi scaleapi deleted a comment from linear-code Bot Sep 30, 2026
earakely-scale and others added 2 commits September 30, 2026 09:50
…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>
Comment thread src/agent_env/store/object_store/local_grants/server.py Outdated
earakely-scale and others added 2 commits September 30, 2026 10:23
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>
Comment on lines +252 to +253
return StreamingResponse(
_chunks(f), media_type=content_type or DEFAULT_CONTENT_TYPE, headers={"Content-Length": str(st.st_size)}

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 HEAD reads the whole file

A HEAD request reaches _get, which now returns a StreamingResponse backed by _chunks. The server reads the entire object even though the request returns only headers. For large objects, size checks waste disk work. Return the size and type headers without streaming the file for HEAD.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/store/object_store/local_grants/server.py
Line: 252-253

Comment:
**`HEAD` reads the whole file**

A `HEAD` request reaches `_get`, which now returns a `StreamingResponse` backed by `_chunks`. The server reads the entire object even though the request returns only headers. For large objects, size checks waste disk work. Return the size and type headers without streaming the file for `HEAD`.

---

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ba5dea: a HEAD answers from the open file's size and type and closes it without reading; Starlette's StreamingResponse has no HEAD case, so before this the server streamed the whole object only to drop it. test_a_head_reports_the_size_without_reading_the_object fails if a HEAD reads the object.

earakely-scale and others added 3 commits September 30, 2026 10:34
…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
Base automatically changed from edgararakelyan/local-store-atomic-writes to main September 30, 2026 20:59
…transfer-grants

# Conflicts:
#	src/agent_env/store/object_store/local_object_store.py
@earakely-scale
earakely-scale requested a review from a team as a code owner October 1, 2026 20:12
earakely-scale and others added 2 commits October 5, 2026 09:50
…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>
Comment thread src/agent_env/store/object_store/__init__.py
earakely-scale and others added 2 commits October 5, 2026 11:04
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…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>
@earakely-scale
earakely-scale merged commit 8c489e1 into main Oct 5, 2026
13 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/local-transfer-grants branch October 5, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant