Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
43ec69c
security(dashboard): widen managed-files sensitive-filename guard pas…
srojk34 Jul 3, 2026
8b24376
fix(dashboard): close credential-dir-tree gap + .git-credentials in m…
kshitijk4poor Jul 4, 2026
e02fc28
Merge pull request #58222 from kshitijk4poor/salvage/dashboard-creden…
kshitijk4poor Jul 4, 2026
86a0c55
feat: allow suppressing Codex gpt-5.5 autoraise notice
shashwatgokhe Jul 4, 2026
d50aae0
fix(telegram): use wall deadline for init timeout
tianma-if Jul 4, 2026
a37fd66
fix(telegram): shut down abandoned init app + AUTHOR_MAP + cover the …
kshitijk4poor Jul 4, 2026
5daa5a0
Merge pull request #58293 from kshitijk4poor/salvage/telegram-init-de…
kshitijk4poor Jul 4, 2026
2d3eac5
fix(moa): apply prompt-caching decoration to the aggregator's one-sho…
srojk34 Jul 4, 2026
6e176e4
fix(compression): preserve user turn after compaction
tianma-if Jul 4, 2026
d8504df
refactor(compression): reuse _fresh_compaction_message_copy in user-t…
kshitijk4poor Jul 4, 2026
60906be
chore: map yingwaizhiying@gmail.com -> msh01 in AUTHOR_MAP
kshitijk4poor Jul 4, 2026
dba585c
fix(agent): deduplicate tool_call_id across the pre-API sanitizers (#…
kshitijk4poor Jul 4, 2026
8645b34
fix(telegram): bound updater.stop() with timeout to prevent CLOSE-WAI…
terry197913 Jul 4, 2026
b1c7b96
fix(telegram): bound the 3 sibling updater.stop() calls with the same…
kshitijk4poor Jul 4, 2026
81f1ba8
test(telegram): cancel leaked conflict-retry task before fatal assertion
kshitijk4poor Jul 4, 2026
7203898
Merge pull request #58350 from kshitijk4poor/salvage/dedup-tool-call-id
kshitijk4poor Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1403,10 +1403,15 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
# compact at ~136K — half the usable context). Gated by an opt-out config
# flag so the user can fall back to the global threshold; when the override
# fires we stash a one-time notification (replayed on the first turn) that
# tells the user what changed and how to revert.
# tells the user what changed and how to revert. The notice has its own
# display gate so users can keep the threshold autoraise without getting
# the banner on gateway turns.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
_codex_gpt55_autoraise_notice = str(
_compression_cfg.get("codex_gpt55_autoraise_notice", True)
).lower() in {"true", "1", "yes"}
agent._compression_threshold_autoraised = None
try:
from agent.auxiliary_client import (
Expand Down Expand Up @@ -1891,7 +1896,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
# gateway users get the same text replayed via _compression_warning on
# turn 1 (set below, after the warning slot is initialized).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
print(_build_codex_gpt55_autoraise_notice(_autoraise))

# Check immediately so CLI users see the warning at startup.
Expand All @@ -1902,7 +1907,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
Expand Down
50 changes: 50 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,12 @@ def _is_codex_interim(m: Dict) -> bool:
tc_id = msg.get("tool_call_id")
if tc_id and tc_id in known_tool_ids:
filtered.append(msg)
# Consume the id so a SECOND tool result carrying the same
# tool_call_id (duplicate from a retry/crash/session-resume
# glitch) falls into the drop branch below instead of being
# replayed — strict providers (DeepSeek) reject a duplicate
# tool_call_id with HTTP 400 (#58327). Credit: #55436.
known_tool_ids.discard(tc_id)
else:
repairs += 1
else:
Expand Down Expand Up @@ -2478,6 +2484,50 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
"Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results),
)

# 3. Deduplicate tool_call_ids. Strict providers (DeepSeek) reject a
# payload where the same tool_call_id appears more than once with HTTP 400
# "Duplicate value for 'tool_call_id'" (#58327). Duplicates can arise from
# retries, crash/resume glitches, or a compression window that re-emits a
# tool result. This is the final pre-API chokepoint, so dedup defensively
# here even though repair_message_sequence also consumes matched ids.
# (a) collapse duplicate tool_calls WITHIN an assistant message
# (b) drop later tool result messages reusing an already-seen id
seen_assistant_call_ids: set = set()
seen_result_call_ids: set = set()
deduped: List[Dict[str, Any]] = []
removed_dupes = 0
for msg in messages:
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
kept_tcs = []
for tc in msg.get("tool_calls") or []:
cid = _ra().AIAgent._get_tool_call_id_static(tc)
if cid and cid in seen_assistant_call_ids:
removed_dupes += 1
continue
if cid:
seen_assistant_call_ids.add(cid)
kept_tcs.append(tc)
if len(kept_tcs) != len(msg.get("tool_calls") or []):
msg = {**msg, "tool_calls": kept_tcs}
deduped.append(msg)
elif role == "tool":
cid = (msg.get("tool_call_id") or "").strip()
if cid and cid in seen_result_call_ids:
removed_dupes += 1
continue
if cid:
seen_result_call_ids.add(cid)
deduped.append(msg)
else:
deduped.append(msg)
if removed_dupes:
messages = deduped
_ra().logger.debug(
"Pre-call sanitizer: removed %d duplicate tool_call_id reference(s)",
removed_dupes,
)
return messages


Expand Down
42 changes: 42 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,47 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
return None


def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
"""Preserve a real user turn when a compressor returns assistant/tool-only context.

On repeated compaction the protected head decays to the system prompt only,
the middle summary can land as ``role="assistant"``, and a tool-heavy tail
can be all assistant/tool — so the compacted transcript can legitimately
contain zero user messages. Strict chat templates (LM Studio / llama.cpp
Jinja) then fail with "No user query found in messages" (#55677).

The restored turn is appended at the END: the guard only runs when
``compressed`` currently ends with an assistant/tool message (any existing
user turn — including a todo-snapshot append — short-circuits the
``any()`` check), so appending a user message never creates consecutive
same-role messages. ``_fresh_compaction_message_copy`` copies the message
and strips the ``_db_persisted`` marker so the rotation/in-place flush
still persists the restored row to the new session (#57491).

If the pre-compression transcript itself carried no user turn at all
(near-impossible — every real conversation opens with a user request —
but kept as a defensive backstop), a minimal continuation marker is
appended instead so strict templates still see a user message.
"""
if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed):
return
from agent.context_compressor import _fresh_compaction_message_copy

for msg in reversed(original_messages):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
compressed.append(_fresh_compaction_message_copy(msg))
return
compressed.append({
"role": "user",
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because the compacted transcript contained "
"no preserved user turn."
),
})


def compress_context(
agent: Any,
messages: list,
Expand Down Expand Up @@ -647,6 +688,7 @@ def _release_lock() -> None:
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
compressed.append({"role": "user", "content": todo_snapshot})
_ensure_compressed_has_user_turn(messages, compressed)

agent._invalidate_system_prompt()
new_system_prompt = agent._build_system_prompt(system_message)
Expand Down
41 changes: 28 additions & 13 deletions agent/moa_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,19 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
return out


def _maybe_apply_advisor_cache_control(
def _maybe_apply_moa_cache_control(
messages: list[dict[str, Any]],
runtime: dict[str, Any],
) -> list[dict[str, Any]]:
"""Decorate an advisor request with cache_control when its route honors it.
"""Decorate an advisor or aggregator request with cache_control when its
route honors it.

Reuses the SAME policy function as the main agent loop
(``anthropic_prompt_cache_policy``) resolved against the advisor slot's
own provider/base_url/api_mode/model, and the SAME breakpoint layout
(``apply_anthropic_cache_control``, system_and_3). This keeps advisor
calls decorated exactly like an acting agent on that provider would be —
no MoA-specific caching logic to drift.
(``anthropic_prompt_cache_policy``) resolved against the slot's own
provider/base_url/api_mode/model, and the SAME breakpoint layout
(``apply_anthropic_cache_control``, system_and_3). This keeps advisor and
aggregator calls decorated exactly like an acting agent on that provider
would be — no MoA-specific caching logic to drift.

Returns the messages unchanged on any resolution error or when the
policy says the route doesn't honor markers.
Expand All @@ -196,8 +197,8 @@ def _maybe_apply_advisor_cache_control(
from agent.prompt_caching import apply_anthropic_cache_control

# The policy function reads agent.* only as fallbacks for kwargs we
# don't pass; provide a stub so an advisor slot is judged purely on
# its own resolved runtime.
# don't pass; provide a stub so the slot is judged purely on its own
# resolved runtime.
stub = SimpleNamespace(provider="", base_url="", api_mode="", model="")
should_cache, native_layout = anthropic_prompt_cache_policy(
stub,
Expand All @@ -212,7 +213,7 @@ def _maybe_apply_advisor_cache_control(
messages, native_anthropic=native_layout
)
except Exception as exc: # pragma: no cover - decoration must never break a call
logger.debug("advisor cache_control decoration skipped: %s", exc)
logger.debug("MoA cache_control decoration skipped: %s", exc)
return messages


Expand Down Expand Up @@ -268,7 +269,7 @@ def _run_reference(
# caching is opt-in per request. OpenAI-family advisors are untouched
# (their caching is automatic; markers are ignored harmlessly, but we
# only decorate when the policy says the route honors them).
messages = _maybe_apply_advisor_cache_control(messages, runtime)
messages = _maybe_apply_moa_cache_control(messages, runtime)
response = call_llm(
task="moa_reference",
messages=messages,
Expand Down Expand Up @@ -617,13 +618,27 @@ def aggregate_moa_context(
)

agg_label = _slot_label(aggregator)
agg_runtime = _slot_runtime(aggregator)
try:
# Same cache_control decoration as _run_reference's advisor calls
# (see _maybe_apply_moa_cache_control) — this synthesis call is a
# third, independent MoA call path that 22c5048d9 did not cover (it
# only restored caching for the acting-aggregator turn in the
# persistent `provider: moa` model and for advisor fan-out). Without
# it, the one-shot `/moa <prompt>` command's synthesis call re-bills
# its full input (system-less prompt containing every joined
# reference output) on every invocation with zero cache_control
# breakpoints, even when the resolved aggregator slot is a
# cache-honoring route (e.g. Claude on OpenRouter/native Anthropic).
agg_messages = _maybe_apply_moa_cache_control(
[{"role": "user", "content": synth_prompt}], agg_runtime
)
response = call_llm(
task="moa_aggregator",
messages=[{"role": "user", "content": synth_prompt}],
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
**_slot_runtime(aggregator),
**agg_runtime,
)
synthesis = _extract_text(response)
except Exception as exc:
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,10 @@ def _ensure_hermes_home_managed(home: Path):
# exact route is affected — gpt-5.5 on OpenAI's
# direct API, OpenRouter, and Copilot keep the
# global threshold regardless.
"codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.5
# autoraise banner. Set False to keep the
# 85% threshold autoraise but suppress the
# user-facing notice in CLI/gateway output.
"in_place": True, # When True, compaction rewrites the message
# list and rebuilds the system prompt WITHOUT
# rotating the session id — the conversation
Expand Down
87 changes: 79 additions & 8 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,15 +1194,86 @@ class ManagedFilesPolicy:
# Filenames that must never be listed, read, or downloaded through the
# managed-files API. These typically contain credentials (API keys, tokens)
# and exposing them through the dashboard file browser is a security leak —
# see issue #57505.
# see issue #57505. The set mirrors the credential-file basenames of the two
# canonical credential guards elsewhere in the codebase
# (agent.file_safety.get_read_block_error and
# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab
# doesn't lag behind them — an operator can point the managed root at
# HERMES_HOME itself, at which point every one of these basenames is a live
# secret store sitting in the browsable tree.
_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({
"auth.json",
"auth.lock",
"credentials",
"config.yaml",
".anthropic_oauth.json",
"google_token.json",
"google_oauth_pending.json",
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
# git's credential-store helper cache (agent.file_safety blocks this too).
".git-credentials",
})

# Directory names whose entire subtree is credential material. Both canonical
# guards deny these as directory trees, not basenames:
# * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"}
# * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match)
# The managed-files API lets the browser descend into subdirs, so a
# basename-only guard would still expose e.g. ``mcp-tokens/<server>.json``
# (live MCP OAuth tokens) and ``pairing/<x>``. We match on ANY path component
# so these trees are blocked wherever they appear under the browsable root,
# without needing to resolve them relative to HERMES_HOME.
_SENSITIVE_MANAGED_DIR_NAMES = frozenset({
"mcp-tokens",
"pairing",
})


def _is_sensitive_filename(name: str) -> bool:
"""Return True for ``.env`` and any ``.env.<suffix>`` variant.
"""Return True for a basename the managed-files API must never expose.

Covers ``.env`` / ``.env.<suffix>`` / ``.envrc`` variants plus the
canonical Hermes credential-store basenames (see
``_SENSITIVE_MANAGED_FILE_BASENAMES`` above).

Case-insensitive so ``.ENV`` / ``.Env.local`` on case-insensitive
filesystems (macOS/Windows mounts) can't slip past the guard.
Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on
case-insensitive filesystems (macOS/Windows mounts) can't slip past
the guard.

Basename-only: for the directory-tree credential stores
(``mcp-tokens/``, ``pairing/``) that the canonical guards also deny,
use :func:`_is_sensitive_path`, which the API call sites route through.
"""
lowered = name.lower()
return lowered == ".env" or lowered.startswith(".env.")
if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc":
return True
return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES


def _is_sensitive_path(path: Path) -> bool:
"""Return True for any path the managed-files API must never expose.

Combines the basename denylist (:func:`_is_sensitive_filename`) with a
credential-directory-tree check: a path is sensitive if its own basename
is sensitive OR any of its path components is a credential directory
(``mcp-tokens`` / ``pairing``). The component match is case-insensitive
and needs no HERMES_HOME resolution, so it blocks these trees wherever
they sit under the operator-configured managed root — closing the gap
the canonical guards cover as directory trees but a basename-only check
would miss.

Read-side only: this guards list/read/download (the #57505 exfil surface).
The write endpoints (upload/mkdir/delete) are a separate threat class
handled by the write-path checks; extending this guard to them is out of
scope for this fix.
"""
if _is_sensitive_filename(path.name):
return True
return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts)


_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
Expand Down Expand Up @@ -1636,7 +1707,7 @@ async def list_managed_files(request: Request, path: Optional[str] = None):
entries = [
_managed_file_entry(policy, child)
for child in target.iterdir()
if not _is_sensitive_filename(child.name)
if not _is_sensitive_path(child)
]
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not readable")
Expand All @@ -1663,7 +1734,7 @@ async def read_managed_file(request: Request, path: str):
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_filename(target.name):
if _is_sensitive_path(target):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")

try:
Expand Down Expand Up @@ -1707,7 +1778,7 @@ async def download_managed_file(request: Request, path: str):
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_filename(target.name):
if _is_sensitive_path(target):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")

try:
Expand Down
Loading
Loading