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