mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(cli): catch OSError in _resolve_attachment_path to prevent ENAMETOOLONG dropping long slash commands
When the user pastes a long slash command like \`/goal <long prose>\` into
\`hermes chat\`, the input flows into \`_detect_file_drop()\`, whose
\`starts_like_path\` prefilter accepts anything starting with \`/\` and
forwards it to \`_resolve_attachment_path()\`. That helper calls
\`Path.exists()\` which invokes \`os.stat()\`, which raises
\`OSError(errno=ENAMETOOLONG)\` — 63 on macOS, 36 on Linux — when the
candidate exceeds NAME_MAX (typically 255 bytes).
The OSError propagates up to the broad \`except Exception\` in
\`process_loop\` (cli.py:11798), gets logged at WARNING level, and the
user's input is silently dropped. From the user's POV the chat prompt
hangs — the only signal is in agent.log:
WARNING cli: process_loop unhandled error (msg may be lost):
[Errno 63] File name too long: "/goal Drive the space board..."
This affects any slash command with prose-length arguments — \`/goal\`
in particular but also \`/skill\`, \`/cron\`, custom user commands.
Fix: wrap the \`exists()\`/\`is_file()\` calls in try/except OSError so
structurally-invalid path candidates cleanly return None. The slash-
command dispatch path downstream (cli.py:11718) then handles the
input correctly.
Tests: two new regression cases in test_cli_file_drop.py cover the
original \`/goal\` reproducer and a synthetic long path. All 35 file-
drop tests pass.
Reproducer (without the fix):
python -c "from cli import _detect_file_drop;
_detect_file_drop('/goal ' + 'a'*300)"
→ OSError: [Errno 63] File name too long
This commit is contained in:
parent
a0fedfbb1b
commit
906881c38b
2 changed files with 46 additions and 1 deletions
16
cli.py
16
cli.py
|
|
@ -1550,7 +1550,21 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
|||
except Exception:
|
||||
resolved = path
|
||||
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
# Path.exists() / is_file() invoke os.stat(), which raises OSError when
|
||||
# the candidate string is structurally invalid as a path — most commonly
|
||||
# ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input
|
||||
# exceeds NAME_MAX (typically 255 bytes). This bites pasted slash
|
||||
# commands like `/goal <long prose>` because `_detect_file_drop()`'s
|
||||
# `starts_like_path` prefilter accepts any input starting with `/`,
|
||||
# then this resolver tries to stat it before short-circuiting on the
|
||||
# slash-command path. Without this guard the OSError propagates up to
|
||||
# the process_loop catch-all in _interactive_loop and the user input
|
||||
# is silently lost (the warning ends up in agent.log but the user sees
|
||||
# nothing — the prompt just hangs).
|
||||
try:
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,37 @@ class TestNonFileInputs:
|
|||
"""A directory path should not be treated as a file drop."""
|
||||
assert _detect_file_drop(str(tmp_path)) is None
|
||||
|
||||
def test_long_slash_command_does_not_raise(self):
|
||||
"""Regression: long pasted slash commands like `/goal <long prose>`
|
||||
used to raise OSError(ENAMETOOLONG, errno 63 macOS / 36 Linux)
|
||||
from `Path.exists()` inside `_resolve_attachment_path`, which
|
||||
propagated up to `process_loop`'s catch-all and silently lost
|
||||
the user's input. The fix wraps the stat call in a try/except
|
||||
OSError and returns None, letting the slash-command dispatch
|
||||
path handle the input downstream.
|
||||
|
||||
Reproducer: paste a `/goal` followed by ~430 chars of prose.
|
||||
Without the fix this triggers ENAMETOOLONG; with the fix it
|
||||
cleanly returns None (file-drop = no), so `_looks_like_slash_command`
|
||||
gets a chance to dispatch it.
|
||||
"""
|
||||
# 430-char `/goal` payload — well above NAME_MAX (255 bytes) on
|
||||
# all common filesystems.
|
||||
long_goal = (
|
||||
"/goal " + ("Drive the board: triage triage-status items, "
|
||||
"unblock spillover tasks where work is shipped, "
|
||||
"advance P1 items by decomposing where needed. ") * 4
|
||||
)
|
||||
assert len(long_goal) > 255 # confirms it would have triggered ENAMETOOLONG
|
||||
assert _detect_file_drop(long_goal) is None
|
||||
|
||||
def test_path_longer_than_namemax_does_not_raise(self):
|
||||
"""Defensive: a single token longer than NAME_MAX should return
|
||||
None, not raise. Could happen with absurdly long synthetic inputs
|
||||
from prompt-injection attempts or fuzzers."""
|
||||
very_long_path = "/" + ("a" * 300)
|
||||
assert _detect_file_drop(very_long_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: image file detection
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue