Skip to content

Commit e8413f1

Browse files
committed
fix: handle missing clipboard in headless environments for langflow api-key command
pyperclip.copy() was called without error handling, causing an unhandled PyperclipException in Docker/SSH environments where no clipboard mechanism is available. The crash happened after the key was generated and the previous one deleted, making the new key unrecoverable. - Wrap pyperclip.copy() in try/except; show key regardless of clipboard availability - Adapt banner hint text based on whether clipboard copy succeeded - Add unit tests covering headless fallback and UnicodeEncodeError scenarios
1 parent 41d34b2 commit e8413f1

2 files changed

Lines changed: 113 additions & 6 deletions

File tree

src/backend/base/langflow/__main__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -950,13 +950,24 @@ def api_key_banner(unmasked_api_key) -> None:
950950
is_mac = platform.system() == "Darwin"
951951
import pyperclip
952952

953-
pyperclip.copy(unmasked_api_key.api_key)
953+
clipboard_available = False
954+
try:
955+
pyperclip.copy(unmasked_api_key.api_key)
956+
clipboard_available = True
957+
except Exception: # noqa: BLE001
958+
pass
959+
960+
clipboard_hint = (
961+
f"The API key has been copied to your clipboard. [bold]{['Ctrl', 'Cmd'][is_mac]} + V[/bold] to paste it."
962+
if clipboard_available
963+
else "Store this key securely — it will not be displayed again."
964+
)
954965
panel = Panel(
955966
f"[bold]API Key Created Successfully:[/bold]\n\n"
956967
f"[bold blue]{unmasked_api_key.api_key}[/bold blue]\n\n"
957968
"This is the only time the API key will be displayed. \n"
958969
"Make sure to store it in a secure location. \n\n"
959-
f"The API key has been copied to your clipboard. [bold]{['Ctrl', 'Cmd'][is_mac]} + V[/bold] to paste it.",
970+
f"{clipboard_hint}",
960971
box=box.ROUNDED,
961972
border_style="blue",
962973
expand=False,
@@ -972,8 +983,9 @@ def api_key_banner(unmasked_api_key) -> None:
972983
logger.info(unmasked_api_key.api_key)
973984
logger.info("This is the only time the API key will be displayed.")
974985
logger.info("Make sure to store it in a secure location.")
975-
ctrl_cmd = "Ctrl" if not is_mac else "Cmd"
976-
logger.info(f"The API key has been copied to your clipboard. {ctrl_cmd} + V to paste it.")
986+
if clipboard_available:
987+
ctrl_cmd = "Ctrl" if not is_mac else "Cmd"
988+
logger.info(f"The API key has been copied to your clipboard. {ctrl_cmd} + V to paste it.")
977989

978990

979991
def main() -> None:

src/backend/tests/unit/test_cli.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import socket
22
import threading
33
import time
4-
from unittest.mock import patch
4+
from unittest.mock import MagicMock, patch
55

66
import pytest
77
import typer
8-
from langflow.__main__ import _create_superuser, app, get_number_of_workers
8+
from langflow.__main__ import _create_superuser, api_key_banner, app, get_number_of_workers
99
from lfx.services import deps
1010

1111

@@ -147,6 +147,101 @@ async def test_failed_auth_token_validation(self, client, active_super_user): #
147147
assert exc_info.value.exit_code == 1
148148

149149

150+
class TestApiKeyBanner:
151+
"""Tests for api_key_banner clipboard fallback (headless environments)."""
152+
153+
def _make_key(self, value: str = "sk-test-1234"):
154+
mock = MagicMock()
155+
mock.api_key = value
156+
return mock
157+
158+
def test_clipboard_available_copies_key(self):
159+
"""When pyperclip works, key is copied and clipboard hint is shown."""
160+
key = self._make_key()
161+
with (
162+
patch("pyperclip.copy") as mock_copy,
163+
patch("langflow.__main__.Console") as mock_console_cls,
164+
):
165+
mock_console = MagicMock()
166+
mock_console_cls.return_value = mock_console
167+
168+
api_key_banner(key)
169+
170+
mock_copy.assert_called_once_with(key.api_key)
171+
printed = mock_console.print.call_args[0][0]
172+
assert "clipboard" in str(printed).lower()
173+
assert key.api_key in str(printed)
174+
175+
def test_clipboard_unavailable_still_prints_key(self):
176+
"""When pyperclip raises (headless/Docker), key is still displayed on stdout."""
177+
key = self._make_key()
178+
with (
179+
patch("pyperclip.copy", side_effect=Exception("No clipboard mechanism")),
180+
patch("langflow.__main__.Console") as mock_console_cls,
181+
):
182+
mock_console = MagicMock()
183+
mock_console_cls.return_value = mock_console
184+
185+
# Must NOT raise
186+
api_key_banner(key)
187+
188+
mock_console.print.assert_called_once()
189+
printed = mock_console.print.call_args[0][0]
190+
assert key.api_key in str(printed)
191+
192+
def test_clipboard_unavailable_shows_fallback_hint(self):
193+
"""When clipboard is unavailable, hint text must not mention clipboard."""
194+
key = self._make_key()
195+
with (
196+
patch("pyperclip.copy", side_effect=Exception("No clipboard mechanism")),
197+
patch("langflow.__main__.Console") as mock_console_cls,
198+
):
199+
mock_console = MagicMock()
200+
mock_console_cls.return_value = mock_console
201+
202+
api_key_banner(key)
203+
204+
printed = str(mock_console.print.call_args[0][0])
205+
assert "clipboard" not in printed.lower()
206+
assert "securely" in printed.lower()
207+
208+
def test_unicode_error_fallback_with_clipboard(self):
209+
"""On UnicodeEncodeError, logger fallback includes clipboard message when available."""
210+
key = self._make_key()
211+
with (
212+
patch("pyperclip.copy"),
213+
patch("langflow.__main__.Console") as mock_console_cls,
214+
patch("langflow.__main__.logger") as mock_logger,
215+
):
216+
mock_console = MagicMock()
217+
mock_console.print.side_effect = UnicodeEncodeError("utf-8", b"", 0, 1, "reason")
218+
mock_console_cls.return_value = mock_console
219+
220+
api_key_banner(key)
221+
222+
logged_messages = " ".join(str(c) for c in mock_logger.info.call_args_list)
223+
assert key.api_key in logged_messages
224+
assert "clipboard" in logged_messages.lower()
225+
226+
def test_unicode_error_fallback_without_clipboard(self):
227+
"""On UnicodeEncodeError, logger fallback omits clipboard message when unavailable."""
228+
key = self._make_key()
229+
with (
230+
patch("pyperclip.copy", side_effect=Exception("No clipboard mechanism")),
231+
patch("langflow.__main__.Console") as mock_console_cls,
232+
patch("langflow.__main__.logger") as mock_logger,
233+
):
234+
mock_console = MagicMock()
235+
mock_console.print.side_effect = UnicodeEncodeError("utf-8", b"", 0, 1, "reason")
236+
mock_console_cls.return_value = mock_console
237+
238+
api_key_banner(key)
239+
240+
logged_messages = " ".join(str(c) for c in mock_logger.info.call_args_list)
241+
assert key.api_key in logged_messages
242+
assert "clipboard" not in logged_messages.lower()
243+
244+
150245
def test_get_number_of_workers():
151246
"""Test that get_number_of_workers uses cpu_count on Linux."""
152247
with (

0 commit comments

Comments
 (0)