Skip to content

Commit f3c127c

Browse files
committed
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langflow into sso-admin-settings
2 parents 85d4d4d + b4d31b1 commit f3c127c

40 files changed

Lines changed: 1520 additions & 629 deletions

File tree

.secrets.baseline

Lines changed: 271 additions & 404 deletions
Large diffs are not rendered by default.

scripts/a11y/a11y_routes.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,6 @@
103103
"path": "/settings/messages",
104104
"surface": "Messages settings page",
105105
"ready": [{ "testId": "settings_menu_header" }]
106-
},
107-
{
108-
"id": "account-delete",
109-
"path": "/account/delete",
110-
"surface": "Delete account page",
111-
"ready": [{ "role": "button", "name": "delete", "first": true }]
112106
}
113107
],
114108
"dynamic": [

src/backend/base/langflow/agentic/services/flow_executor.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from fastapi import HTTPException
1313
from lfx.cli.script_loader import extract_structured_result
1414
from lfx.events.event_manager import EventManager, create_default_event_manager
15-
from lfx.execution import get_default_coordinator
15+
from lfx.execution import aget_default_coordinator
1616
from lfx.log.logger import logger
1717
from lfx.schema.schema import InputValueRequest
1818
from lfx.utils.flow_validation import CustomComponentValidationError
@@ -60,11 +60,9 @@ async def _run_graph_with_events(
6060
graph.prepare()
6161
inputs = InputValueRequest(input_value=input_value) if input_value else None
6262

63+
coordinator = await aget_default_coordinator()
6364
results = [
64-
payload
65-
async for payload in get_default_coordinator().stream(
66-
graph, initial_inputs=inputs, event_manager=event_manager
67-
)
65+
payload async for payload in coordinator.stream(graph, initial_inputs=inputs, event_manager=event_manager)
6866
]
6967
execution_result.result = extract_structured_result(results)
7068
except Exception as e: # noqa: BLE001
@@ -137,7 +135,8 @@ async def execute_flow_file(
137135
graph.prepare()
138136
inputs = InputValueRequest(input_value=input_value) if input_value else None
139137

140-
results = [payload async for payload in get_default_coordinator().stream(graph, initial_inputs=inputs)]
138+
coordinator = await aget_default_coordinator()
139+
results = [payload async for payload in coordinator.stream(graph, initial_inputs=inputs)]
141140
flow_result = extract_structured_result(results)
142141
except HTTPException:
143142
raise

src/backend/base/langflow/initial_setup/starter_projects/Content Aggregator.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/services/deps.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import sys
34
from contextlib import asynccontextmanager
45
from typing import TYPE_CHECKING, Union
56

@@ -248,10 +249,45 @@ def get_queue_service() -> JobQueueService:
248249
return get_service(ServiceType.JOB_QUEUE_SERVICE, JobQueueServiceFactory())
249250

250251

252+
def _is_lfx_no_op_auth_service(service: object) -> bool:
253+
"""Report whether ``service`` is LFX's no-op auth stub.
254+
255+
Resolved through ``sys.modules`` rather than a direct import so that merely asking the
256+
question cannot import ``lfx.services.auth.service`` -- that import is what registers
257+
the stub for the ``auth_service`` slot in the first place. If the module was never
258+
imported the stub cannot be the resolved service anyway.
259+
"""
260+
lfx_auth_module = sys.modules.get("lfx.services.auth.service")
261+
return lfx_auth_module is not None and isinstance(service, lfx_auth_module.AuthService)
262+
263+
251264
def get_auth_service() -> BaseAuthService:
252-
"""Retrieve the authentication service."""
265+
"""Retrieve the authentication service.
266+
267+
LFX's no-op ``AuthService`` claims the ``auth_service`` slot at import time via
268+
``@register_service(..., override=True)``, and a registered service class always wins
269+
over the factory passed here as a fallback. That stub must never be the resolved
270+
service in a Langflow process: it raises ``NotImplementedError`` for user/token
271+
operations and answers API-key checks with ``None``. ``register_all_service_factories()``
272+
overrides it during startup, so anything touching auth before (or without) that call
273+
would otherwise get the stub -- swap it out for Langflow's implementation here.
274+
275+
A plugin-provided auth service (e.g. SSO, registered through ``lfx.toml``) is a
276+
different class and is left untouched.
277+
"""
253278
from langflow.services.auth.factory import AuthServiceFactory
254279

280+
service = get_service(ServiceType.AUTH_SERVICE, AuthServiceFactory())
281+
if not _is_lfx_no_op_auth_service(service):
282+
return service
283+
284+
from lfx.services.manager import get_service_manager
285+
286+
from langflow.services.auth.service import AuthService
287+
288+
service_manager = get_service_manager()
289+
service_manager.register_service_class(ServiceType.AUTH_SERVICE, AuthService, override=True)
290+
service_manager.update(ServiceType.AUTH_SERVICE)
255291
return get_service(ServiceType.AUTH_SERVICE, AuthServiceFactory())
256292

257293

src/backend/tests/unit/components/processing/test_save_file_component.py

Lines changed: 180 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def cleanup_test_files(self):
1717
"""Clean up test files after all tests in the class complete."""
1818
yield
1919
# Clean up test files created during tests
20-
test_files = ["test_data.json", "test_message.txt", "test_output.csv", "test_page.html"]
20+
test_files = ["test_data.json", "test_message.txt", "test_output.csv", "test_page.html", "test_s3_output.txt"]
2121
for filename in test_files:
2222
filepath = Path(filename)
2323
if filepath.exists():
@@ -109,7 +109,7 @@ async def test_save_dataframe_to_csv(self, component_class):
109109
mock_db = AsyncMock()
110110
mock_session.return_value.__aenter__.return_value = mock_db
111111
mock_get_user.return_value = MagicMock()
112-
mock_upload.return_value = "test_output.csv"
112+
mock_upload.return_value = MagicMock(path="test_output.csv", provider="s3")
113113

114114
# Execute - real temp file creation, real DataFrame.to_csv(), real cleanup
115115
result = await component.save_to_file()
@@ -143,7 +143,7 @@ async def test_save_data_to_json(self, component_class):
143143
mock_db = AsyncMock()
144144
mock_session.return_value.__aenter__.return_value = mock_db
145145
mock_get_user.return_value = MagicMock()
146-
mock_upload.return_value = "test_data.json"
146+
mock_upload.return_value = MagicMock(path="test_data.json", provider="s3")
147147

148148
result = await component.save_to_file()
149149

@@ -175,13 +175,172 @@ async def test_save_message_to_txt(self, component_class):
175175
mock_db = AsyncMock()
176176
mock_session.return_value.__aenter__.return_value = mock_db
177177
mock_get_user.return_value = MagicMock()
178-
mock_upload.return_value = "test_message.txt"
178+
mock_upload.return_value = MagicMock(path="test_message.txt", provider="s3")
179179

180180
result = await component.save_to_file()
181181

182182
assert "saved successfully" in result.text
183183
assert "test_message.txt" in result.text
184184

185+
@pytest.mark.asyncio
186+
async def test_save_local_mode_with_s3_backend_cleans_staging_and_reports_storage_path(
187+
self, component_class, tmp_path
188+
):
189+
"""Remote (S3) backend: Local mode deletes the staging file and reports the storage path.
190+
191+
This guards the fix for the leak where Local mode left a redundant copy in
192+
cwd and surfaced a misleading local path when the backend was S3.
193+
"""
194+
component = component_class(_user_id=str(uuid4()))
195+
message = Message(text="This should end up only in S3")
196+
component.set_attributes(
197+
{
198+
"input": message,
199+
"file_name": "test_s3_output",
200+
"local_format": "txt",
201+
"storage_location": [{"name": "Local"}],
202+
}
203+
)
204+
205+
# upload_user_file returns the durable storage location + provider
206+
upload_response = MagicMock()
207+
upload_response.path = "files/user-uuid/test_s3_output.txt"
208+
upload_response.provider = "s3"
209+
210+
# Force the storage backend to look remote (S3) without restricting paths
211+
settings_mock = MagicMock()
212+
settings_mock.storage_type = "s3"
213+
settings_mock.restrict_local_file_access = False
214+
settings_mock.config_dir = str(tmp_path)
215+
settings_service_mock = MagicMock()
216+
settings_service_mock.settings = settings_mock
217+
218+
with (
219+
patch("langflow.api.v2.files.upload_user_file", new_callable=AsyncMock) as mock_upload,
220+
patch("lfx.services.deps.session_scope") as mock_session,
221+
patch(
222+
"langflow.services.database.models.user.crud.get_user_by_id", new_callable=AsyncMock
223+
) as mock_get_user,
224+
patch(
225+
"lfx.components.files_and_knowledge.save_file.get_settings_service",
226+
return_value=settings_service_mock,
227+
),
228+
):
229+
mock_db = AsyncMock()
230+
mock_session.return_value.__aenter__.return_value = mock_db
231+
mock_get_user.return_value = MagicMock()
232+
mock_upload.return_value = upload_response
233+
234+
result = await component.save_to_file()
235+
236+
# Message reports the durable storage destination + provider, not a local path
237+
assert "files/user-uuid/test_s3_output.txt" in result.text
238+
assert "S3" in result.text
239+
assert str(tmp_path) not in result.text
240+
# The local staging file was cleaned up
241+
assert not (Path.cwd() / "test_s3_output.txt").exists()
242+
243+
@pytest.mark.asyncio
244+
async def test_append_mode_remote_backend_accumulates_across_calls(self, component_class, tmp_path):
245+
"""append_mode + remote (S3) backend keeps accumulating, not resetting to overwrite.
246+
247+
Guards against the staging cleanup deleting the local accumulator that append
248+
relies on (should_append hinges on the file persisting across calls). Without
249+
the append exception, the second call would silently overwrite.
250+
"""
251+
file_name = "test_append_remote"
252+
staging = Path.cwd() / f"{file_name}.txt"
253+
if staging.exists():
254+
staging.unlink()
255+
256+
settings_mock = MagicMock()
257+
settings_mock.storage_type = "s3"
258+
settings_mock.restrict_local_file_access = False
259+
settings_mock.config_dir = str(tmp_path)
260+
settings_service_mock = MagicMock()
261+
settings_service_mock.settings = settings_mock
262+
263+
upload_response = MagicMock()
264+
upload_response.path = f"files/uid/{file_name}.txt"
265+
upload_response.provider = "s3"
266+
267+
def make_component(text):
268+
component = component_class(_user_id=str(uuid4()))
269+
component.set_attributes(
270+
{
271+
"input": Message(text=text),
272+
"file_name": file_name,
273+
"local_format": "txt",
274+
"append_mode": True,
275+
"storage_location": [{"name": "Local"}],
276+
}
277+
)
278+
return component
279+
280+
try:
281+
with (
282+
patch("langflow.api.v2.files.upload_user_file", new_callable=AsyncMock) as mock_upload,
283+
patch("lfx.services.deps.session_scope") as mock_session,
284+
patch(
285+
"langflow.services.database.models.user.crud.get_user_by_id", new_callable=AsyncMock
286+
) as mock_get_user,
287+
patch(
288+
"lfx.components.files_and_knowledge.save_file.get_settings_service",
289+
return_value=settings_service_mock,
290+
),
291+
):
292+
mock_db = AsyncMock()
293+
mock_session.return_value.__aenter__.return_value = mock_db
294+
mock_get_user.return_value = MagicMock()
295+
mock_upload.return_value = upload_response
296+
297+
await make_component("line one").save_to_file()
298+
# Staging file must survive so the next call can append to it
299+
assert staging.exists()
300+
await make_component("line two").save_to_file()
301+
302+
# Content accumulated across both calls — not overwritten
303+
assert staging.read_text(encoding="utf-8") == "line one\nline two"
304+
finally:
305+
if staging.exists():
306+
staging.unlink()
307+
308+
@pytest.mark.asyncio
309+
async def test_save_aws_mode_namespaces_key_by_user_id(self, component_class):
310+
"""AWS mode namespaces the S3 key by user_id so multi-user runs don't collide.
311+
312+
Layout must be {s3_prefix}/{user_id}/{file_name}.{ext} — otherwise every
313+
user writing the same file_name overwrites the same key.
314+
"""
315+
user_id = str(uuid4())
316+
component = component_class(_user_id=user_id)
317+
component.set_attributes(
318+
{
319+
"input": Message(text="hello aws"),
320+
"file_name": "report",
321+
"aws_format": "txt",
322+
"storage_location": [{"name": "AWS"}],
323+
"aws_access_key_id": "test-access-key", # pragma: allowlist secret
324+
"aws_secret_access_key": "test-secret-key", # pragma: allowlist secret
325+
"bucket_name": "my-bucket",
326+
"aws_region": "us-east-1",
327+
"s3_prefix": "files",
328+
}
329+
)
330+
331+
mock_s3 = MagicMock()
332+
with (
333+
patch("boto3.client", return_value=mock_s3),
334+
patch("lfx.base.data.cloud_storage_utils.validate_aws_credentials"),
335+
):
336+
result = await component.save_to_file()
337+
338+
# upload_file(temp_path, bucket, key) — third positional arg is the S3 key
339+
mock_s3.upload_file.assert_called_once()
340+
key = mock_s3.upload_file.call_args[0][2]
341+
assert key == f"files/{user_id}/report.txt"
342+
assert "my-bucket" in result.text
343+
185344
@pytest.mark.asyncio
186345
async def test_save_message_to_html(self, component_class):
187346
"""Test saving Message to html format."""
@@ -207,7 +366,7 @@ async def test_save_message_to_html(self, component_class):
207366
mock_db = AsyncMock()
208367
mock_session.return_value.__aenter__.return_value = mock_db
209368
mock_get_user.return_value = MagicMock()
210-
mock_upload.return_value = "test_page.html"
369+
mock_upload.return_value = MagicMock(path="test_page.html", provider="s3")
211370

212371
result = await component.save_to_file()
213372

@@ -319,7 +478,7 @@ async def test_file_name_with_extension_stripped(self, component_class):
319478
mock_db = AsyncMock()
320479
mock_session.return_value.__aenter__.return_value = mock_db
321480
mock_get_user.return_value = MagicMock()
322-
mock_upload.return_value = "test_output.csv"
481+
mock_upload.return_value = MagicMock(path="test_output.csv", provider="s3")
323482

324483
result = await component.save_to_file()
325484

@@ -349,6 +508,16 @@ async def test_append_mode_txt_file(self, component_class):
349508
}
350509
)
351510

511+
# This test verifies LOCAL-backend append semantics (the file persists on
512+
# disk and is re-read), so pin the backend to local. Under a remote (S3)
513+
# backend the staging file is intentionally deleted after upload.
514+
settings_mock = MagicMock()
515+
settings_mock.storage_type = "local"
516+
settings_mock.restrict_local_file_access = False
517+
settings_mock.config_dir = str(tmp_path.parent)
518+
settings_service_mock = MagicMock()
519+
settings_service_mock.settings = settings_mock
520+
352521
# Mock the path resolution to return our temp file
353522
with (
354523
patch("lfx.components.files_and_knowledge.save_file.Path") as mock_path_class,
@@ -357,13 +526,17 @@ async def test_append_mode_txt_file(self, component_class):
357526
patch(
358527
"langflow.services.database.models.user.crud.get_user_by_id", new_callable=AsyncMock
359528
) as mock_get_user,
529+
patch(
530+
"lfx.components.files_and_knowledge.save_file.get_settings_service",
531+
return_value=settings_service_mock,
532+
),
360533
):
361534
# Make Path() return our temp file path
362535
mock_path_class.return_value = tmp_path
363536
mock_db = AsyncMock()
364537
mock_session.return_value.__aenter__.return_value = mock_db
365538
mock_get_user.return_value = MagicMock()
366-
mock_upload.return_value = tmp_path.name
539+
mock_upload.return_value = MagicMock(path=tmp_path.name, provider="local")
367540

368541
result = await component.save_to_file()
369542

src/backend/tests/unit/services/auth/test_pluggable_auth.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,33 @@ async def test_get_current_user_delegates_to_service(dummy_auth_registration):
8686
assert ("get_current_user", None, "q", None, None) in dummy.calls
8787
assert response["user"] == "dummy"
8888
assert response["db"] is db
89+
90+
91+
def test_get_auth_service_replaces_lfx_no_op_stub():
92+
"""LFX's no-op auth service must never be what Langflow resolves.
93+
94+
It self-registers for the ``auth_service`` slot at import time, so any process that
95+
touches auth before ``register_all_service_factories()`` would otherwise get a service
96+
that raises ``NotImplementedError`` for user/token operations and answers API-key
97+
checks with ``None``.
98+
"""
99+
from langflow.services.auth.service import AuthService as LangflowAuthService
100+
from langflow.services.deps import get_auth_service
101+
from lfx.services.auth.service import AuthService as LFXNoOpAuthService
102+
103+
service_manager = get_service_manager()
104+
previous_class = service_manager.service_classes.get(ServiceType.AUTH_SERVICE)
105+
previous_instance = service_manager.services.pop(ServiceType.AUTH_SERVICE, None)
106+
service_manager.register_service_class(ServiceType.AUTH_SERVICE, LFXNoOpAuthService, override=True)
107+
108+
try:
109+
assert isinstance(service_manager.get(ServiceType.AUTH_SERVICE), LFXNoOpAuthService)
110+
assert isinstance(get_auth_service(), LangflowAuthService)
111+
finally:
112+
service_manager.services.pop(ServiceType.AUTH_SERVICE, None)
113+
if previous_class is not None:
114+
service_manager.service_classes[ServiceType.AUTH_SERVICE] = previous_class
115+
else:
116+
service_manager.service_classes.pop(ServiceType.AUTH_SERVICE, None)
117+
if previous_instance is not None:
118+
service_manager.services[ServiceType.AUTH_SERVICE] = previous_instance

0 commit comments

Comments
 (0)