From a042d5a2c95015e87fe22ca4bc569fde4c0342ba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:47:11 -0700 Subject: [PATCH] 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 --- acp_adapter/gateway_media.py | 14 +++++++++-- acp_adapter/gateway_server.py | 2 +- .../acp_adapter/test_gateway_overlay_home.py | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/acp_adapter/gateway_media.py b/acp_adapter/gateway_media.py index 07883fe810..86060aa1d8 100644 --- a/acp_adapter/gateway_media.py +++ b/acp_adapter/gateway_media.py @@ -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 diff --git a/acp_adapter/gateway_server.py b/acp_adapter/gateway_server.py index 88d1e196ce..2f67bf9da8 100644 --- a/acp_adapter/gateway_server.py +++ b/acp_adapter/gateway_server.py @@ -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: diff --git a/tests/acp_adapter/test_gateway_overlay_home.py b/tests/acp_adapter/test_gateway_overlay_home.py index c40f5ca172..d9d9d8e2e8 100644 --- a/tests/acp_adapter/test_gateway_overlay_home.py +++ b/tests/acp_adapter/test_gateway_overlay_home.py @@ -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'))