From 8c2db5c554c2960b04e5a4a06d25c96bcfa9a7e8 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 31 Jul 2026 20:45:55 +0500 Subject: [PATCH 1/2] fix: handle TimeoutExpired and OSError in dispatch_command() subprocess.run() with timeout parameter can raise TimeoutExpired if the timeout elapses. Also catch OSError for cases where the binary is not found or not executable (TOCTOU race between shutil.which and run). --- src/specify_cli/integrations/base.py | 37 +++++++++++++------ .../integrations/copilot/__init__.py | 37 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index cca4f13976..c3043aaec6 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -407,18 +407,31 @@ def dispatch_command( "stderr": "", } - result = subprocess.run( - exec_args, - capture_output=True, - text=True, - cwd=cwd, - timeout=timeout, - ) - return { - "exit_code": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - } + try: + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + except subprocess.TimeoutExpired: + return { + "exit_code": 124, + "stdout": "", + "stderr": f"Command timed out after {timeout}s", + } + except OSError as exc: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Failed to execute command: {exc}", + } # -- Primitives — building blocks for setup() ------------------------- diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index e6f86e8991..e78c2ffd7e 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -320,18 +320,31 @@ def dispatch_command( "stderr": "", } - result = subprocess.run( - cli_args, - capture_output=True, - text=True, - cwd=cwd, - timeout=timeout, - ) - return { - "exit_code": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - } + try: + result = subprocess.run( + cli_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + except subprocess.TimeoutExpired: + return { + "exit_code": 124, + "stdout": "", + "stderr": f"Command timed out after {timeout}s", + } + except OSError as exc: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Failed to execute command: {exc}", + } def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" From fb6acd30ba8d68e83e16c991a14bcaf68f6aaf54 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 23 Aug 2026 07:56:04 +0500 Subject: [PATCH 2/2] fix: add OSError handling to streaming dispatch branches The streaming branch (stream=True, the default) only caught KeyboardInterrupt. An OSError (e.g. command not found on PATH) would propagate as an unhandled exception. Add OSError handling matching the non-streaming branch pattern. --- src/specify_cli/integrations/base.py | 6 ++++++ src/specify_cli/integrations/copilot/__init__.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index c3043aaec6..ed5cd0cf5e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -401,6 +401,12 @@ def dispatch_command( "stdout": "", "stderr": "Interrupted by user", } + except OSError as exc: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Failed to execute command: {exc}", + } return { "exit_code": result.returncode, "stdout": "", diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index e78c2ffd7e..b5300a148b 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -314,6 +314,12 @@ def dispatch_command( "stdout": "", "stderr": "Interrupted by user", } + except OSError as exc: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Failed to execute command: {exc}", + } return { "exit_code": result.returncode, "stdout": "",