Skip to content

feat(protocol): SDK agents serve a staging extension agent-env can move objects through - #39

Merged
earakely-scale merged 5 commits into
mainfrom
edgararakelyan/agent-staging-extension
Oct 5, 2026
Merged

earakely-scale merged 5 commits into
mainfrom
edgararakelyan/agent-staging-extension

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

The local object store's grants reach only agents on the local machine. An agent on a remote sandbox (Modal, E2B, a plugin provider) can't reach them. For that agent, skill bundles, snapshots and changelogs were refused, and trajectories came back inline. The core change that fixes this needs a way to hand such an agent its objects without any connection back to the user's machine. This PR is the agent side: every SDK agent now serves a staging extension, urn:agentenv:staging/v1. It is a small object store on the agent's own server at /ext/staging. Before a call, agent-env pushes into it what the agent will read. After the call, agent-env pulls out what the agent wrote.

The grants agent-env sends stay ordinary http-get / http-put / http-post-policy grants, with URLs that name staged paths on the agent's own URL. So no request or response model changes, and extension handlers and the upload / download / NamespaceUploader helpers are untouched. An agent that doesn't use the SDK can serve the same routes and add one card entry.

Route Does
PUT {path} stores the body (streamed to disk, renamed into place)
GET {path} returns it, with an ETag
POST {prefix} stores a multipart upload's file at {prefix}/{key}, as an upload-policy grant is used
GET {prefix}/ lists {objects: [{path, size_bytes, etag}]}
DELETE {path} removes it; with If-Match, only while it still has that tag, so an object rewritten since it was read survives
DELETE {prefix}/ removes everything below the prefix
  • A path's first segment is an id agent-env generates (at least 22 characters) and shares with no one. That id is what keeps staged objects private on a public agent URL, the same posture as the agent's other routes on that URL.
  • Everything staged counts against AGENTENV_STAGING_MAX_BYTES: 16 GiB by default, and 0 turns staging off and drops the card entry.
  • Staged objects live in AGENTENV_STAGING_DIR (default: a directory under the system temp dir).
  • python-multipart joins the agent extra, for the upload forms.
  • A second commit makes transfers.redacting_request_urls public. agent-env now requests secret-bearing URLs itself (its pushes and pulls) and keeps them out of httpx's logs the same way the helpers do.

This merges independently of the local-grant stack. Core starts using it in the follow-up PR, which is stacked on this one.

How it was tested

  • pytest packages/agentenv-protocol/tests: 301 passed. test_a2a_staging.py is new. It covers:
    • the card entry;
    • put/get/list/delete;
    • If-Match;
    • refused short ids and unnormalized paths, and refused prefix listing without an id;
    • the quota, including a replacement needing room beside the old copy;
    • a refused streamed write leaving nothing behind;
    • the protocol's own upload, download and NamespaceUploader round-tripping through the routes.
  • pytest tst/unit: 5473 passed (core is unchanged by this PR).
  • uv lock --check is clean. The lock resolves from PyPI only.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge, though symlinked staging directories could change permissions on shared files.

Fix All in CursorFindings

  1. P1 Security Staging IDs become visible ▶
  2. P1 Shared stores lose coordination ▶
  3. P2 Shared directory becomes private ▶
  4. P2 Staged files survive restarts ▶
Fix with agent prompt
### Issue 1
packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py:undefined-242
If the agent runs on a multiuser host with a typical umask, these directories let another local user list the IDs meant to keep staged files private. That user can then use an ID with the public `GET` route to read a file. Create the staging directories so other users cannot list them.

**How this was verified:** Default directory creation does not restrict access, while the public read route checks only the path’s ID length.

### Issue 2
packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py:104-108
If two app instances or workers use the same staging directory, each has its own lock and byte count. Neither sees the other’s pending writes, so together they can exceed `AGENTENV_STAGING_MAX_BYTES`. Another instance can also rewrite a file between an `If-Match` check and deletion, causing the newer file to be deleted. Coordinate stores that share a directory, or give each one its own directory.

### Issue 3
packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py:440-442
If `AGENTENV_STAGING_DIR/objects` or `incoming` is a symlink to a shared directory, `_private` follows it and changes that directory to `0700`. A staging write can then lock other users out of files outside staging. Reject symlinked staging directories before changing their permissions.

### Issue 4
packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py:115-116
When `AGENTENV_STAGING_DIR` is unset, each server run creates a new temporary staging directory, but nothing removes it when the server stops. Staged files pile up across restarts, using disk space and keeping data longer than the documentation promises. Remove the directory when the server shuts down.

---

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

Summary

SDK agents now host a staging store that lets AgentEnv move objects through the agent when object-store grants cannot reach a sandbox. The PR also makes the URL-redaction helper public for other grant callers.

  • Adds staging routes for storing, reading, listing, and deleting objects.
  • Adds a configurable storage cap and private staging directories.
  • Makes redacting_request_urls available to callers.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Core[agent-env] -->|push objects| Stage[Agent staging routes]
  Stage -->|read grants| Agent[Agent handler]
  Agent -->|write grants| Stage
  Stage -->|pull objects| Core
Loading

Reviews (4) · Last reviewed commit: "fix(protocol): staging tightens its own ..."

earakely-scale and others added 2 commits October 1, 2026 10:10
…ve objects through

When an object store's grants cannot reach an agent, as with a local store and
an agent on a remote sandbox, agent-env needs another way to hand the agent its
objects and collect what it writes. Every SDK agent now serves
urn:agentenv:staging/v1 at /ext/staging: a small object store on the agent's own
server that agent-env pushes into before a call and pulls from after it. The
grants it sends stay ordinary HTTPS grants naming staged paths on the agent's
own URL, so extension handlers and the transfer helpers are unchanged.

- PUT/GET/DELETE by path, multipart POST under a prefix (the upload-policy
  shape), and GET/DELETE of a prefix to list or clear it.
- Writes land in a staging file and are renamed into place; each write gets a
  fresh ETag, and DELETE with If-Match spares an object rewritten since it was
  read.
- A path's first segment must be a long, unguessable id; everything staged
  counts against AGENTENV_STAGING_MAX_BYTES (16 GiB; 0 turns staging off).
- python-multipart joins the agent extra for the upload forms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…move objects through grant URLs themselves

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 1, 2026 21:12
used = await self._usage()
try:
replaced = target.stat().st_size if target.is_file() else 0
target.parent.mkdir(parents=True, exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Staging IDs become visible

If the agent runs on a multiuser host with a typical umask, these directories let another local user list the IDs meant to keep staged files private. That user can then use an ID with the public GET route to read a file. Create the staging directories so other users cannot list them.

How this was verified: Default directory creation does not restrict access, while the public read route checks only the path’s ID length.

Knowledge Base Used: A2A agent runtime

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py
Line: 223

Comment:
**Staging IDs become visible**

If the agent runs on a multiuser host with a typical umask, these directories let another local user list the IDs meant to keep staged files private. That user can then use an ID with the public `GET` route to read a file. Create the staging directories so other users cannot list them.

**How this was verified:** Default directory creation does not restrict access, while the public read route checks only the path’s ID length.

**Knowledge Base Used:** [A2A agent runtime](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/a2a-agent-runtime.md)

---

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 ff1d3d4: the directories staging makes (objects/, incoming/, and the per-process default root from mkdtemp) are 0700. A directory given with AGENTENV_STAGING_DIR is used as it is, since chmodding a shared path like /tmp would be worse.

Comment on lines +104 to +108
self._objects = self.root / "objects"
self._incoming = self.root / "incoming"
self._lock = asyncio.Lock()
self._used: int | None = None # stored plus reserved bytes, counted on first use
self._last_stamp = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Shared stores lose coordination

If two app instances or workers use the same staging directory, each has its own lock and byte count. Neither sees the other’s pending writes, so together they can exceed AGENTENV_STAGING_MAX_BYTES. Another instance can also rewrite a file between an If-Match check and deletion, causing the newer file to be deleted. Coordinate stores that share a directory, or give each one its own directory.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py
Line: 104-108

Comment:
**Shared stores lose coordination**

If two app instances or workers use the same staging directory, each has its own lock and byte count. Neither sees the other’s pending writes, so together they can exceed `AGENTENV_STAGING_MAX_BYTES`. Another instance can also rewrite a file between an `If-Match` check and deletion, causing the newer file to be deleted. Coordinate stores that share a directory, or give each one its own directory.

---

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.

Documented in ff1d3d4 rather than coordinated: one server process owns a staging directory. An A2A agent is single-process already (the A2A SDK's task store is in memory), and without AGENTENV_STAGING_DIR each server now gets a directory of its own, so two never share one by default.

Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py Outdated
…its own, and leaves agents its paths when off

- The directories holding staged objects are made 0700, so another user on the
  host can't list the ids that keep them private. A directory given with
  AGENTENV_STAGING_DIR is used as it is; only the ones staging makes change.
- Without AGENTENV_STAGING_DIR each server stages in a directory of its own,
  made on first use, so a restarted server never counts what an earlier one
  left against its limit. One server process owns a staging directory: the
  byte count and the If-Match check are its own.
- With staging off, an agent's own route below /ext/staging is no conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +114 to +115
if self._root is None:
self._root = Path(tempfile.mkdtemp(prefix="agentenv-staging-"))

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 Staged files survive restarts

When AGENTENV_STAGING_DIR is unset, each server run creates a new temporary staging directory, but nothing removes it when the server stops. Staged files pile up across restarts, using disk space and keeping data longer than the documentation promises. Remove the directory when the server shuts down.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py
Line: 114-115

Comment:
**Staged files survive restarts**

When `AGENTENV_STAGING_DIR` is unset, each server run creates a new temporary staging directory, but nothing removes it when the server stops. Staged files pile up across restarts, using disk space and keeping data longer than the documentation promises. Remove the directory when the server shuts down.

---

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 4caa425: the directory a server makes for itself is removed at exit (atexit); a directory given with AGENTENV_STAGING_DIR is left alone. A killed process can still leave one behind in the system temp dir, which the OS cleans. Test: test_a_servers_own_staging_directory_goes_when_it_exits.

earakely-scale and others added 2 commits October 1, 2026 14:39
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…re left open

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +440 to +442
directory.mkdir(parents=True, exist_ok=True)
if directory.stat().st_mode & 0o077:
directory.chmod(0o700)

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 Shared directory becomes private

If AGENTENV_STAGING_DIR/objects or incoming is a symlink to a shared directory, _private follows it and changes that directory to 0700. A staging write can then lock other users out of files outside staging. Reject symlinked staging directories before changing their permissions.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/staging.py
Line: 440-442

Comment:
**Shared directory becomes private**

If `AGENTENV_STAGING_DIR/objects` or `incoming` is a symlink to a shared directory, `_private` follows it and changes that directory to `0700`. A staging write can then lock other users out of files outside staging. Reject symlinked staging directories before changing their permissions.

---

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

@earakely-scale
earakely-scale merged commit cddd256 into main Oct 5, 2026
13 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/agent-staging-extension branch October 5, 2026 16:45
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