Files
hermes-agent/tests/agent/test_context_references.py
finn763 a164569429 fix(agent): admit and sync gateway-staged attachments on remote execution backends (#110174)
Desktop paste/file attachments land in Hermes-managed staging dirs on the
GATEWAY (composer-pastes/ for large text pastes, attachments/ for dropped
files), but on the Remote SSH topology the workspace root (TERMINAL_CWD) is a
path on the SSH HOST - the two filesystems are fully disjoint, as the issue
thread confirms. Two gaps combined to reject every staged attachment with
"path is outside the allowed workspace":

- _resolve_path admitted only allowed_root + composer-paste roots, so a
  gateway-staged attachments/ path was refused outright. Admit the
  _CACHE_DIRS staging roots (attachments/, images/, cache/*, composer-pastes/)
  via a helper that asks get_cache_directory_mounts - the gateway's OWN
  payload is never a workspace escape, and the path-traversal and
  credential-deny guards in _ensure_reference_path_allowed still run after.
  Anything else outside the workspace stays blocked.
- composer-pastes/ was missing from _CACHE_DIRS, so its bytes never reached
  the remote: ssh/daytona/vercel_sandbox sync via iter_sync_files ->
  iter_cache_files, and to_agent_visible_cache_path only translates mounted
  dirs - a paste attached on a fresh session dangled on the remote host.

Tests cover the disjoint-filesystem SSH topology end-to-end (text inlines,
binary renders the synced ~/.hermes path), the still-refused stranger path,
local-backend unchanged, and the composer-pastes mount+sync enumeration.

Consolidates PR #110387 by Finn763 (the _agent_staged_path guard widening and
the SSH-topology tests, adapted to the current _ensure_reference_path_allowed
ordering) with PR #103412 by ericmaddox (whose mapping insight is subsumed by
the _CACHE_DIRS entry, which fixes both the sync and the translation).

Co-authored-by: ericmaddox <ericmaddox@users.noreply.github.com>
2026-09-26 18:00:17 -05:00

750 lines
27 KiB
Python

from __future__ import annotations
import asyncio
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
def _git(cwd: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
@pytest.fixture
def sample_repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init")
_git(repo, "config", "user.name", "Hermes Tests")
_git(repo, "config", "user.email", "tests@example.com")
(repo / "src").mkdir()
(repo / "src" / "main.py").write_text(
"def alpha():\n"
" return 'a'\n\n"
"def beta():\n"
" return 'b'\n",
encoding="utf-8",
)
(repo / "src" / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
(repo / "README.md").write_text("# Demo\n", encoding="utf-8")
(repo / "blob.bin").write_bytes(b"\x00\x01\x02binary")
_git(repo, "add", ".")
_git(repo, "commit", "-m", "initial")
(repo / "src" / "main.py").write_text(
"def alpha():\n"
" return 'changed'\n\n"
"def beta():\n"
" return 'b'\n",
encoding="utf-8",
)
(repo / "src" / "helper.py").write_text("VALUE = 2\n", encoding="utf-8")
_git(repo, "add", "src/helper.py")
return repo
def test_parse_typed_references_ignores_emails_and_handles():
from agent.context_references import parse_context_references
message = (
"email me at user@example.com and ping @teammate "
"but include @file:src/main.py:1-2 plus @diff and @git:2 "
"and @url:https://example.com/docs"
)
refs = parse_context_references(message)
assert [ref.kind for ref in refs] == ["file", "diff", "git", "url"]
assert refs[0].target == "src/main.py"
assert refs[0].line_start == 1
assert refs[0].line_end == 2
assert refs[2].target == "2"
def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
from agent.context_references import preprocess_context_references
real_run = subprocess.run
def blocked_rg(*args, **kwargs):
cmd = args[0] if args else kwargs.get("args")
if isinstance(cmd, list) and cmd and cmd[0] == "rg":
raise PermissionError("rg blocked by policy")
return real_run(*args, **kwargs)
with patch("agent.context_references.subprocess.run", side_effect=blocked_rg):
result = preprocess_context_references(
"Review @folder:src/",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
assert "src/" in result.message
assert "main.py" in result.message
assert "helper.py" in result.message
assert not result.warnings
def test_folder_listing_outside_cwd_inside_widened_allowed_root(tmp_path: Path):
"""allowed_root may be widened beyond cwd; @folder: targets there must
still produce a listing rather than a ValueError-as-warning."""
from agent.context_references import preprocess_context_references
cwd = tmp_path / "proj"
cwd.mkdir()
shared = tmp_path / "shared"
(shared / "sub").mkdir(parents=True)
(shared / "a.txt").write_text("x\n", encoding="utf-8")
(shared / "sub" / "b.txt").write_text("y\n", encoding="utf-8")
result = preprocess_context_references(
"Review @folder:../shared",
cwd=cwd,
allowed_root=tmp_path,
context_length=100_000,
)
assert result.expanded
assert not result.warnings
# Header is the allowed_root-relative display, never the absolute path.
assert "\nshared/\n" in result.message
assert str(tmp_path) not in result.message
assert "a.txt" in result.message
assert "b.txt" in result.message
def test_folder_listing_inside_cwd_unchanged(sample_repo: Path):
"""Control: in-cwd listings keep their cwd-relative display shape."""
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Review @folder:src/",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
assert not result.warnings
assert "src/" in result.message
assert "main.py" in result.message
def test_missing_file_becomes_warning(sample_repo: Path):
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Check @file:nope.txt",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
assert len(result.warnings) == 1
assert "not found" in result.message.lower()
def test_oversized_text_file_falls_back_to_tool_readable_path(tmp_path: Path):
from agent.context_references import preprocess_context_references
payload = tmp_path / "large.txt"
payload.write_text("FULL-CONTENT-MARKER\n" + ("x" * 8_000), encoding="utf-8")
result = preprocess_context_references(
f"Inspect @file:{payload.name}",
cwd=tmp_path,
context_length=1_000,
)
assert result.expanded
assert not result.blocked
assert str(payload) in result.message
assert "too large to inline safely" in result.message
assert "read_file" in result.message
assert "FULL-CONTENT-MARKER" not in result.message
# The fallback block alone carries the message — a companion warning would
# repeat "too large to inline safely" under --- Context Warnings ---.
assert not result.warnings
def test_file_line_range_is_applied_before_oversized_fallback(tmp_path: Path):
from agent.context_references import preprocess_context_references
payload = tmp_path / "large.txt"
payload.write_text(
"first line\nsecond line\n" + "\n".join("x" * 200 for _ in range(100)),
encoding="utf-8",
)
result = preprocess_context_references(
f"Inspect @file:{payload.name}:1-2",
cwd=tmp_path,
context_length=1_000,
)
assert result.expanded
assert not result.blocked
assert "first line\nsecond line" in result.message
assert "too large to inline safely" not in result.message
def test_oversized_file_refused_without_full_read(tmp_path: Path, monkeypatch):
from agent import context_references
from agent.context_references import preprocess_context_references
payload = tmp_path / "huge.txt"
payload.write_text("x" * 100_000, encoding="utf-8")
def _boom(*args, **kwargs):
raise AssertionError("oversized file was read in full")
monkeypatch.setattr(Path, "read_text", _boom)
result = preprocess_context_references(
f"Inspect @file:{payload.name}", cwd=tmp_path, context_length=1_000,
)
assert "too large to inline safely" in result.message
assert str(payload) in result.message
def test_line_range_ref_streams_only_the_window(tmp_path: Path, monkeypatch):
from agent.context_references import preprocess_context_references
def _boom(*args, **kwargs):
raise AssertionError("ranged ref read the whole file")
monkeypatch.setattr(Path, "read_text", _boom)
small = tmp_path / "small.txt"
small.write_text("first\nsecond\nthird\nfourth\n", encoding="utf-8")
result = preprocess_context_references(
f"Inspect @file:{small.name}:2-3", cwd=tmp_path, context_length=1_000,
)
assert "second\nthird" in result.message
assert "first" not in result.message
assert "fourth" not in result.message
def test_binary_sniff_reads_prefix_only(tmp_path: Path):
from agent.context_references import _is_binary_file
# .txt keeps the mime guess on the text side so the byte sniff runs.
payload = tmp_path / "data.txt"
payload.write_bytes(b"\x00" * 10 + b"x" * 1_000_000)
read_calls = []
orig_open = Path.open
def _counting_open(self, *args, **kwargs):
fh = orig_open(self, *args, **kwargs)
if "b" in (args[0] if args else kwargs.get("mode", "r")):
orig_read = fh.read
def _read(*a, **k):
data = orig_read(*a, **k)
read_calls.append(len(data))
return data
fh.read = _read
return fh
with patch.object(Path, "open", _counting_open):
assert _is_binary_file(payload)
assert read_calls and max(read_calls) <= 4096
def test_folder_listing_caps_line_count_io(tmp_path: Path, monkeypatch):
from agent import context_references
from agent.context_references import preprocess_context_references
assets = tmp_path / "assets"
assets.mkdir()
big = assets / "big.txt"
big.write_bytes(b"line\n" * (context_references._LINE_COUNT_MAX_BYTES // 5 + 2))
(assets / "small.txt").write_text("a\nb\n", encoding="utf-8")
result = preprocess_context_references("list @folder:assets", cwd=tmp_path, context_length=10_000_000)
assert "big.txt" in result.message and "bytes" in result.message
assert "3 lines" in result.message # small file still reports a line count
def test_line_range_bounds_reads_on_single_line_file(tmp_path: Path, monkeypatch):
from agent.context_references import preprocess_context_references
payload = tmp_path / "oneline.txt"
payload.write_text("x" * 100_000, encoding="utf-8") # one giant line, no newline
read_sizes, returned = [], []
orig_open = Path.open
def _counting_open(self, *args, **kwargs):
fh = orig_open(self, *args, **kwargs)
mode = args[0] if args else kwargs.get("mode", "r")
if "b" not in mode:
orig_readline = fh.readline
def _readline(*a, **k):
read_sizes.append(a[0] if a else -1)
piece = orig_readline(*a, **k)
returned.append(len(piece))
return piece
fh.readline = _readline
return fh
monkeypatch.setattr(Path, "open", _counting_open)
result = preprocess_context_references(
f"Inspect @file:{payload.name}:1-1", cwd=tmp_path, context_length=1_000,
)
assert "too large to inline safely" in result.message
# hard_limit = 500 tokens -> char budget 2000 -> each readline bounded at 2001
assert read_sizes and max(read_sizes) <= 2001
# ... and the giant line is never materialized: reading stops once the budget is exceeded
# instead of collecting every 2001-char piece up to the newline (review follow-up).
assert sum(returned) <= 2 * 2001
def test_run_quiet_caps_child_output(tmp_path: Path):
import sys
from agent.context_references import _MAX_QUIET_OUTPUT_BYTES, _run_quiet
huge = _run_quiet(
[sys.executable, "-c", f"import sys; sys.stdout.write('x' * {_MAX_QUIET_OUTPUT_BYTES + 1000})"],
tmp_path, 30,
)
assert huge.returncode != 0
assert len(huge.stdout) <= _MAX_QUIET_OUTPUT_BYTES
# A child that flushes past the cap and exits 0 before the drain thread reads it (the common
# case for a fast writer on a loaded runner) must still report failure: the caller's fallback
# path keys on the returncode, and a 0 with truncated stdout was a silent success.
with patch("agent.context_references.subprocess.Popen") as popen:
import io
fake = popen.return_value
fake.stdout = io.BytesIO(b"x" * (_MAX_QUIET_OUTPUT_BYTES + 1000))
fake.stderr = io.BytesIO(b"")
fake.returncode = 0
fake.wait.return_value = 0
exited = _run_quiet([sys.executable, "-c", "pass"], tmp_path, 30)
assert exited.returncode != 0
assert len(exited.stdout) <= _MAX_QUIET_OUTPUT_BYTES
ok = _run_quiet([sys.executable, "-c", "print('hello')"], tmp_path, 30)
assert ok.returncode == 0 and "hello" in ok.stdout
def test_reference_count_is_capped(tmp_path: Path):
from agent.context_references import _MAX_EXPANDED_REFERENCES, preprocess_context_references
for i in range(_MAX_EXPANDED_REFERENCES + 4):
(tmp_path / f"f{i}.txt").write_text("data", encoding="utf-8")
result = preprocess_context_references(
" ".join(f"@file:f{i}.txt" for i in range(_MAX_EXPANDED_REFERENCES + 4)),
cwd=tmp_path, context_length=10_000_000,
)
skipped = [w for w in result.warnings if "not expanded" in w]
assert len(skipped) == 4
def test_multiple_individually_safe_files_still_obey_aggregate_limit(tmp_path: Path):
from agent.context_references import preprocess_context_references
for name in ("first.txt", "second.txt"):
(tmp_path / name).write_text("x" * 1_200, encoding="utf-8")
result = preprocess_context_references(
"Inspect @file:first.txt and @file:second.txt",
cwd=tmp_path,
context_length=1_000,
)
assert result.blocked
assert not result.expanded
assert "context injection refused" in "\n".join(result.warnings)
def test_binary_reference_block_maps_host_attachment_to_container_path(tmp_path: Path, monkeypatch):
"""Docker backend: a staged binary attachment's host path is rendered as the
bind-mounted in-container path so the agent's tools can read it.
Regression test for #76577 — the container has its own filesystem, so the
gateway host path would dangle inside the sandbox.
"""
from agent.context_references import preprocess_context_references
hermes_home = tmp_path / ".hermes"
attachments = hermes_home / "attachments"
attachments.mkdir(parents=True)
payload = attachments / "archive.zip"
payload.write_bytes(b"PK\x03\x04binary-zip-bytes")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
result = preprocess_context_references(
f"Read the attachment @file:{payload}",
cwd=tmp_path,
context_length=100_000,
)
assert result.expanded
# Default container base for the docker backend is /root/.hermes.
assert "/root/.hermes/attachments/archive.zip" in result.message
assert "binary file, not inlined" in result.message
def test_oversized_text_reference_maps_host_attachment_to_container_path(
tmp_path: Path, monkeypatch
):
from agent.context_references import preprocess_context_references
hermes_home = tmp_path / ".hermes"
attachments = hermes_home / "attachments"
attachments.mkdir(parents=True)
payload = attachments / "large.txt"
payload.write_text("x" * 8_000, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
result = preprocess_context_references(
f"Read the attachment @file:{payload}",
cwd=tmp_path,
context_length=1_000,
)
assert result.expanded
assert not result.blocked
attached_context = result.message.split("--- Attached Context ---", 1)[1]
assert "/root/.hermes/attachments/large.txt" in attached_context
assert "too large to inline safely" in result.message
def test_binary_reference_block_keeps_host_path_on_local_backend(tmp_path: Path, monkeypatch):
"""Local backend: no translation — the agent's tools run on the host."""
from agent.context_references import preprocess_context_references
hermes_home = tmp_path / ".hermes"
attachments = hermes_home / "attachments"
attachments.mkdir(parents=True)
payload = attachments / "archive.zip"
payload.write_bytes(b"PK\x03\x04binary-zip-bytes")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "local")
result = preprocess_context_references(
f"Read the attachment @file:{payload}",
cwd=tmp_path,
context_length=100_000,
)
assert result.expanded
assert str(payload) in result.message
assert "/root/.hermes/attachments/" not in result.message
# ── Gateway container + Remote SSH execution backend (#110174) ───────────────
# ``file.attach`` stages pastes/drops into the session home — the GATEWAY's own
# filesystem — while the workspace root (TERMINAL_CWD) is a path on the SSH host.
# The two filesystems are disjoint, so the workspace check used to reject every
# staged attachment ("path is outside the allowed workspace") and the agent never
# saw the content even though the gateway held the bytes.
_SSH_WORKSPACE = "/srv/repos" # the SSH host's workspace root; absent on the gateway
def _stage_outside_workspace(tmp_path: Path, monkeypatch, name: str, subdir: str = "attachments") -> Path:
"""Stage *name* under a HERMES_HOME that lives outside the workspace (SSH topology)."""
hermes_home = tmp_path / "gateway-data" # /opt/data inside the gateway container
(hermes_home / subdir).mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "ssh")
return hermes_home / subdir / name
def test_ssh_backend_staged_text_attachment_still_expands(tmp_path: Path, monkeypatch):
"""The gateway owns the bytes, so the ref must inline them, not refuse the path."""
from agent.context_references import format_reference_value, preprocess_context_references
payload = _stage_outside_workspace(
tmp_path, monkeypatch, "Pasted content (3-5.4 KB).txt", subdir="composer-pastes")
payload.write_text("pasted body\n", encoding="utf-8")
result = preprocess_context_references(
f"Summarize @file:{format_reference_value(str(payload))}",
cwd=_SSH_WORKSPACE,
allowed_root=_SSH_WORKSPACE,
context_length=100_000,
)
assert result.expanded
assert result.warnings == []
assert "pasted body" in result.message
def test_ssh_backend_staged_binary_attachment_points_at_the_synced_remote_path(tmp_path: Path, monkeypatch):
"""Binaries stay on disk: the ref must render the path the ssh backend resolves (the
file-sync mirrors the staging dirs to ``~/.hermes`` on the remote)."""
from agent.context_references import preprocess_context_references
payload = _stage_outside_workspace(tmp_path, monkeypatch, "archive.zip")
payload.write_bytes(b"PK\x03\x04binary-zip-bytes")
result = preprocess_context_references(
f"Read the attachment @file:{payload}",
cwd=_SSH_WORKSPACE,
allowed_root=_SSH_WORKSPACE,
context_length=100_000,
)
assert result.expanded
assert "binary file, not inlined" in result.message
assert "~/.hermes/attachments/archive.zip" in result.message
def test_ref_outside_workspace_and_staging_dirs_is_still_refused(tmp_path: Path, monkeypatch):
"""Staging dirs are the only widening: any other path outside the workspace stays blocked."""
from agent.context_references import preprocess_context_references
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "gateway-data"))
monkeypatch.setenv("TERMINAL_ENV", "ssh")
secret = tmp_path / "elsewhere" / "notes.txt"
secret.parent.mkdir(parents=True)
secret.write_text("not-for-the-agent\n", encoding="utf-8")
result = preprocess_context_references(
f"@file:{secret}",
cwd=_SSH_WORKSPACE,
allowed_root=_SSH_WORKSPACE,
context_length=100_000,
)
assert any("outside the allowed workspace" in warning for warning in result.warnings)
assert "not-for-the-agent" not in result.message
def test_local_backend_staged_attachment_keeps_the_host_path(tmp_path: Path, monkeypatch):
"""Local backend: gateway and tools share a filesystem — no remote path rewrite."""
from agent.context_references import preprocess_context_references
payload = _stage_outside_workspace(tmp_path, monkeypatch, "archive.zip")
payload.write_bytes(b"PK\x03\x04binary-zip-bytes")
monkeypatch.setenv("TERMINAL_ENV", "local")
result = preprocess_context_references(
f"Read the attachment @file:{payload}",
cwd=tmp_path / "workspace",
allowed_root=tmp_path / "workspace",
context_length=100_000,
)
assert result.expanded
assert "binary file, not inlined" in result.message
assert "~/.hermes/attachments/" not in result.message
@pytest.mark.asyncio
async def test_blocks_canonical_read_denylist_credential_stores(tmp_path: Path, monkeypatch):
"""@file expansion must honour the canonical read deny-list.
The narrow in-module list historically missed the real credential stores
(provider keys, OAuth tokens, MCP tokens, project-local .env). Because the
gateway routes untrusted remote message text through reference expansion,
a chat peer could otherwise attach `@file:~/.hermes/auth.json` and read the
operator's keys into context. These must all be refused, with their secret
bodies kept out of the expanded message.
"""
from agent.context_references import preprocess_context_references_async
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
hermes_home = tmp_path / ".hermes"
(hermes_home).mkdir(parents=True)
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"openai": "sk-AUTHJSON-SECRET"}\n', encoding="utf-8")
oauth = hermes_home / ".anthropic_oauth.json"
oauth.write_text('{"access_token": "OAUTH-SECRET"}\n', encoding="utf-8")
mcp_token = hermes_home / "mcp-tokens" / "github.json"
mcp_token.parent.mkdir(parents=True)
mcp_token.write_text('{"token": "MCP-TOKEN-SECRET"}\n', encoding="utf-8")
project_env = tmp_path / "project" / ".env"
project_env.parent.mkdir(parents=True)
project_env.write_text("DB_PASSWORD=ENV-SECRET\n", encoding="utf-8")
result = await preprocess_context_references_async(
"inspect @file:.hermes/auth.json and @file:.hermes/.anthropic_oauth.json "
"and @file:.hermes/mcp-tokens/github.json and @file:project/.env",
cwd=tmp_path,
allowed_root=tmp_path,
context_length=100_000,
)
assert result.expanded
for secret in (
"sk-AUTHJSON-SECRET",
"OAUTH-SECRET",
"MCP-TOKEN-SECRET",
"ENV-SECRET",
):
assert secret not in result.message
assert sum("sensitive credential" in warning for warning in result.warnings) == 4
@pytest.mark.asyncio
async def test_canonical_guard_fails_closed_when_lookup_raises(tmp_path: Path, monkeypatch):
"""If the canonical read guard raises, the reference must fail CLOSED.
The guard exists specifically to cover credential stores the narrow local
list misses (auth.json, ...). If get_read_block_error ever raised, silently
falling through to the local list would re-open that exact hole — and the
gateway feeds untrusted remote text here, so a chat peer could then attach
auth.json. The reference must be refused and the secret kept out of the
expanded message.
"""
from agent.context_references import preprocess_context_references_async
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(parents=True)
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"openai": "sk-AUTHJSON-SECRET"}\n', encoding="utf-8")
def _boom(_path):
raise RuntimeError("guard resolution failed")
monkeypatch.setattr("agent.file_safety.get_read_block_error", _boom)
result = await preprocess_context_references_async(
"inspect @file:.hermes/auth.json",
cwd=tmp_path,
allowed_root=tmp_path,
context_length=100_000,
)
assert "sk-AUTHJSON-SECRET" not in result.message
assert any(
"credential deny-list" in warning or "sensitive credential" in warning
for warning in result.warnings
)
@pytest.mark.parametrize(
"value",
[
"/tmp/plain.png",
"/Users/me/Library/Application Support/Hermes/composer-images/a.png",
r"C:\Users\John Doe\Pictures\cat.png",
"/tmp/report (final).pdf",
"/tmp/it's here.png",
'/tmp/say "hi".png',
],
)
def test_format_reference_value_round_trips_through_the_parser(value):
"""Whatever the path contains, the formatted ref must parse back whole —
an unquoted value stops at the first space and strands the tail as text."""
from agent.context_references import REFERENCE_PATTERN, format_reference_value
match = REFERENCE_PATTERN.search(f"@file:{format_reference_value(value)}")
assert match is not None
assert match.group("value").strip("`\"'") == value
@pytest.mark.asyncio
async def test_side_thread_expansion_guards_the_served_profile_home(tmp_path: Path, monkeypatch):
"""Inside a running loop (the gateway / TUI turn) the sync wrapper hops to a side thread; that
thread must inherit the caller's profile scope so the credential guard checks the SERVED
profile's home, not the launch profile's (a served profile's skill-hub cache was attachable)."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from agent.context_references import preprocess_context_references
launch_home = tmp_path / "launch"
served_home = launch_home / "profiles" / "b"
hub_file = served_home / "skills" / ".hub" / "injected.md"
hub_file.parent.mkdir(parents=True)
hub_file.write_text("HUB-CACHE-BODY\n", encoding="utf-8")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(launch_home))
token = set_hermes_home_override(served_home)
try:
result = preprocess_context_references(
"read @file:profiles/b/skills/.hub/injected.md", cwd=launch_home, allowed_root=launch_home,
context_length=100_000)
finally:
reset_hermes_home_override(token)
assert "HUB-CACHE-BODY" not in result.message
assert any("internal Hermes path" in w for w in result.warnings)
@pytest.mark.asyncio
async def test_composer_paste_outside_workspace_is_attached_but_sibling_dir_is_not(tmp_path, monkeypatch):
"""Desktop saves a large paste under <HERMES_HOME>/composer-pastes and attaches it
as `@file:`; the chat cwd is almost never an ancestor of that directory, so the
workspace guard must admit exactly that anchored root (#117149) — and nothing
that merely contains the substring next to it."""
from agent.context_references import preprocess_context_references_async
monkeypatch.setenv("HOME", str(tmp_path))
hermes_home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
paste = hermes_home / "composer-pastes" / "pasted_content_1.txt"
paste.parent.mkdir(parents=True)
paste.write_text("PASTED-BODY-MARKER\n", encoding="utf-8")
lookalike = hermes_home / "my-composer-pastes-backup" / "secret.txt"
lookalike.parent.mkdir(parents=True)
lookalike.write_text("LOOKALIKE-SECRET\n", encoding="utf-8")
workspace = tmp_path / "project"
workspace.mkdir()
result = await preprocess_context_references_async(
f"see @file:{paste} and @file:{lookalike}",
cwd=workspace,
allowed_root=workspace,
context_length=100_000,
)
assert result.expanded
assert "PASTED-BODY-MARKER" in result.message
assert "LOOKALIKE-SECRET" not in result.message
assert "outside the allowed workspace" in "\n".join(result.warnings)