From 906881c38bdd4494420bd557cb17986e347b29ee Mon Sep 17 00:00:00 2001 From: Cleo Date: Mon, 4 May 2026 21:51:39 -0600 Subject: [PATCH] fix(cli): catch OSError in _resolve_attachment_path to prevent ENAMETOOLONG dropping long slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user pastes a long slash command like \`/goal \` 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 --- cli.py | 16 +++++++++++++++- tests/cli/test_cli_file_drop.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index fcc08ce378..31ba863f9f 100644 --- a/cli.py +++ b/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 ` 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 diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py index fa6aac1ed1..a7a8c42e2d 100644 --- a/tests/cli/test_cli_file_drop.py +++ b/tests/cli/test_cli_file_drop.py @@ -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 ` + 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