fix(acp): an overlay HERMES_HOME stages prompt images in the owner's cache

An overlay task home attaches to the profile its symlinked lock names, but
image staging still used the overlay's ambient HERMES_HOME. The overlay does
not share cache/images, and the owner admits attachments only from its own
image cache, so every image prompt failed with invalid_params. Staging now
runs under the resolved owner home (context-local home override); the owner's
path check is unchanged.

Review finding on #140190 (Greptile)
Refs #134889 (ACP-18)
Refs #140138
This commit is contained in:
Teknium committed 2026-10-16 21:13:47 -07:00
1 parent 82f58120a9
commit a042d5a2c9
3 files changed
+37 -3

No files matched your search

+12 -2
View File
@@ -39,16 +39,26 @@ def _take(path):
os.unlink(path)
async def stage_user_content(content):
async def stage_user_content(content, home=None):
"""Shared-converter output -> ``(text, attachments)`` for ``prompt.submit``.
Text-only prompts stay a plain string. Media parts (image ``data:`` URLs from
image blocks, image resource links and embedded blobs, and remote image URLs once
fetched) are staged as bytes in the profile image cache; the authority commits them
at admission. A remote image URL that cannot be fetched is kept as text so the model
still sees it. Only a remote image awaits (its download); every other part stages inline."""
still sees it. Only a remote image awaits (its download); every other part stages inline.
``home`` is the owner's profile home: a per-task overlay ``HERMES_HOME`` does not share
``cache/images``, and admission accepts only the owner's cache (never relaxed here)."""
if isinstance(content, str):
return content, []
if home is not None:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(home)
try:
return await stage_user_content(content)
finally:
reset_hermes_home_override(token)
import base64
import binascii
from gateway.platforms.base import cache_image_from_bytes
+1 -1
View File
@@ -632,7 +632,7 @@ class GatewayACPAgent(GatewayReplyMixin, acp.Agent):
# submit itself, and the prompt then ends without being sent.
self._submitting.add(session_id)
try:
staged = await stage_user_content(content)
staged = await stage_user_content(content, self._home)
finally:
self._submitting.discard(session_id)
if session_id in self._pending_cancels:
@@ -39,3 +39,27 @@ async def test_overlay_home_attaches_to_the_owner_its_lock_names(daemon, tmp_pat
saved = await ws.rpc('session.resume', session_id=sid)
assert 'WS_SHARED overlay' in json.dumps(saved['messages'])
assert not (overlay[0] / 'gateway.sock').exists() and not (overlay[0] / 'gateway.pid').exists()
_ONE_PX_PNG = bytes.fromhex(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4'
'890000000a49444154789c6300010000000500010d0a2db40000000049454e44ae426082')
@pytest.mark.platforms('linux')
@pytest.mark.asyncio
async def test_overlay_home_image_prompt_is_staged_where_the_owner_admits_it(daemon, tmp_path, model_peer):
"""The overlay does not share ``cache/images``: an image is staged in the owner's cache, the only
place its admission accepts (#140190 review)."""
import base64
overlay = _overlay(daemon, tmp_path)
async with editor(overlay, tmp_path) as acp:
await acp.rpc('initialize', protocolVersion=1, clientCapabilities={})
sid = (await acp.rpc('session/new', cwd=str(tmp_path), mcpServers=[]))['result']['sessionId']
turn = await acp.rpc('session/prompt', sessionId=sid, prompt=[
{'type': 'text', 'text': 'WS_SHARED overlay image'},
{'type': 'image', 'data': base64.b64encode(_ONE_PX_PNG).decode(), 'mimeType': 'image/png'}])
assert 'result' in turn, (turn, (tmp_path / 'acp.log').read_text()[-2000:])
assert turn['result']['stopReason'] == 'end_turn', turn
assert any(daemon[0].glob('cache/images/img_*.png')), list(daemon[0].rglob('*.png'))
assert not any(overlay[0].glob('cache/images/*')), list(overlay[0].rglob('*.png'))