feat: improve lfx serve production-readiness (#13121)

* docs: add design spec for lfx serve multi-flow startup and dynamic upload

* docs: add implementation plan for lfx serve multi-flow startup and dynamic upload

* feat(serve): add FlowRegistry for mutable in-process flow management

* feat(serve): add flow_id_from_content for content-based flow ID generation

* fix(test): update streaming tests to use FlowRegistry API

* test(serve): add upload endpoint tests

* feat(serve): add build_registry_from_directory and build_registry_from_paths helpers

* refactor(serve): remove dead _analyze_graph_structure and _generate_dynamic_run_description helpers

* feat(serve): accept directory and multiple file paths at startup; wire registry to serve_command

* [autofix.ci] apply automated fixes

* fix(serve): move traceback import to module level; remove planning docs from branch

* [autofix.ci] apply automated fixes

* fix(serve): deepcopy graph in stream_flow; use json.dumps for error stream payload

* test(serve): verify stream setup error payload is valid JSON with special characters

* [autofix.ci] apply automated fixes

* fix(serve): use load_flow_from_json directly in _load_graph_and_meta for clear error messages

* docs(serve): document multi-worker and persistence limitations on FlowRegistry

* feat(serve): allow starting server with no flows; accept full Langflow flow JSON on upload

- Remove the requirement for a flow path/JSON/stdin at startup; lfx serve
  now starts with an empty FlowRegistry when no input is provided, ready
  to accept flows via POST /flows/upload/
- UploadFlowRequest now accepts the full Langflow export JSON directly
  (extra="allow", id field added) so callers can do -d @flow.json without
  wrapping; load_flow_from_json receives the full dict and the flow's own
  stable id is used when present

* fix(serve): harden upload endpoint and remove unused verbose_print param

- Pass body.model_dump(exclude={"replace"}) to load_flow_from_json instead of
  body.model_dump(); keeps the top-level "data" key the function expects while
  excluding the internal replace flag
- Set graph.flow_id after upload so it matches the registry key
- Return 409 Conflict on duplicate flow ID; add replace=true field to allow
  explicit overwrite
- Remove unused verbose_print param from _load_graph_and_meta and update callers
- Clarify directory scan is top-level only (non-recursive) in CLI help text
- Add test_upload_full_export_as_body regression test: sends a complete Langflow
  export JSON as the request body (curl -d @flow.json pattern) where body.data
  has no nested "data" key — would have caught the KeyError('data') regression

* fix(serve): address PR review comments

- Restore .py script support: _load_graph_and_meta now dispatches to
  load_graph_from_script for .py paths; CLI validation accepts .json and .py;
  help text updated in both _running_commands.py and commands.py
- Fix flow ID collision in build_registry_from_paths: compute a shared common
  root from all supplied paths so same-named files in different directories
  get distinct UUIDv5 IDs instead of silently overwriting each other
- Harden FlowRegistry.add(): add overwrite=False parameter that raises
  ValueError on duplicate IDs; startup paths keep default (collision = error),
  upload endpoint passes overwrite=body.replace
- Add regression test: test_same_filename_in_different_dirs_gets_distinct_ids
- Update test_duplicate_add_replaces_graph to reflect new overwrite semantics

* test(serve): add unit tests for .py script path in serve command

- test_load_graph_and_meta_dispatches_to_script_loader_for_py: verifies
  _load_graph_and_meta calls load_graph_from_script (not load_flow_from_json)
  for .py paths, and that meta.title and relative_path are set correctly
- test_serve_command_accepts_py_file: end-to-end check that lfx serve my.py
  reaches the server (exit_code == 0)
- test_serve_command_rejects_unsupported_extension: .txt and other unknown
  extensions must exit non-zero with a helpful message

* [autofix.ci] apply automated fixes

* Add --no-env-fallback option to support scoped request_vars

* [autofix.ci] apply automated fixes

* feat(serve): filesystem-backed flow registry for multi-worker sharing

Adds a FlowStore abstraction with two implementations:
- NullFlowStore (default): in-memory only, existing behavior unchanged
- FilesystemFlowStore: persists each flow as {id}.json; writes are
  atomic (tmp→rename); reads and deletes are TOCTOU-safe; path
  traversal guard rejects unsafe flow IDs

FlowRegistry now delegates persistence to FlowStore:
- add(raw_json=...) writes to store when raw JSON is available
- get() falls through to store on cache miss, reconstructs + caches
- list_metas() merges in-memory and store-side entries
- remove() propagates deletion to store
- warm_from_store() pre-warms the in-memory cache at startup

New --flow-dir option on lfx serve activates the filesystem store.
All uvicorn workers pointing at the same directory serve the same
flows — use /tmp/lfx-flows for single-pod sharing or a PVC mount
for cross-pod sharing. Raw JSON is plumbed through _load_graph_and_meta
and both builder functions so startup-loaded .json flows are persisted
automatically; .py flows remain in-memory only.

* feat(serve): add --workers option for multi-worker uvicorn support

Adds --workers N to lfx serve, enabling multiple uvicorn processes to
share flows via --flow-dir.

serve_command is restructured from @syncify async to plain sync: all
async registry-building is extracted into _build_serve_registry() and
called via asyncio.run(), then uvicorn.run() handles the server.

For workers > 1, uvicorn requires an import string rather than an app
object. The factory create_serve_app() in serve_app.py reads
LFX_SERVE_FLOW_DIR and LFX_SERVE_NO_ENV_FALLBACK from env, pre-warms
from the shared FilesystemFlowStore, and returns a ready FastAPI app.
The parent sets those env vars unconditionally before dispatching.

For workers == 1, the pre-built app object is used directly (no change
to existing single-worker behavior). A warning is emitted when
--workers > 1 is used without --flow-dir since each worker would have
an isolated in-memory registry.

The startup panel flow count is now accurate for single-worker +
--flow-dir by calling warm_from_store() before printing.

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* fix(serve): remove no_args_is_help, drop verbose_print param, harden _safe_path

- Remove no_args_is_help=True from serve command so lfx serve with no
  arguments starts with an empty registry instead of showing help
- Remove unused verbose_print parameter from create_multi_serve_app and
  update all callers (14 call sites across tests)
- Wrap _safe_path's relative_to() check in try/except for a clear error
  message; restructure delete() to use try/except/else (TRY300)
- Add TYPE_CHECKING guard for FlowStore annotation in commands.py; wrap
  long --workers help string; restore FBT003 noqa on no_env_fallback
- Fix test_env_fallback_skipped_when_flag_true to allow logger's getenv
  calls while still asserting the credential variable is never looked up
- Fix ARG002/D205/RUF059 lint issues in test files

* [autofix.ci] apply automated fixes

* fix(serve): resolve merge conflicts and clean up test section comments

* ruff - test updates

* fix(serve): cross-worker delete propagation and HTTP 500 for execution failures

Cross-worker stale cache (multi-worker DELETE bug):
- Add FlowStore.is_persistent to distinguish real stores from NullFlowStore
- Track _store_sourced in FlowRegistry for flows written to a persistent store
- get() re-reads the store for store-sourced flows to detect cross-worker deletes;
  evicts and returns None if the file is gone
- list_metas() skips store-sourced flows whose store key is no longer present
- Add _evict() helper to clean up _flows, _store_meta_cache, _store_keys, _store_sourced

HTTP status for execution failures:
- POST /flows/{id}/run now returns HTTP 500 (not 200) when execution raises or
  when the flow returns success=false; body is still RunResponse for debug detail
- Updated OpenAPI responses declaration: 500 now maps to RunResponse model

* fix(serve): harden multi-worker registry correctness and safety

- Add FlowAlreadyRegisteredError (ValueError subclass) so upload_flow
  catches only the specific race-condition exception rather than any
  ValueError from unrelated internals.

- Add _SERVE_ENV_PREFIX constant; rebuild LFX_SERVE_* env var names from
  it and add try/finally cleanup so those vars are always deleted after
  uvicorn.run() returns in multi-worker mode.

- Reject .py startup files when --workers > 1: Python graphs cannot be
  serialized to the filesystem store so workers would silently start with
  empty registries for those flows.

- Remove warm_from_store() from build_registry_from_paths/directory; the
  builders no longer pre-warm to avoid double-loading when the startup dir
  and flow-dir overlap. serve_command still warms for single-worker mode.

- Fix FlowRegistry.__len__ to delegate to list_metas() so store-only
  flows (not yet cache-loaded) are counted; this makes the startup panel
  and /health flow_count accurate for multi-worker + pre-placed files.

- Fix FlowRegistry.remove() to delete both the primary store key file and
  any alternate-keyed file (UUID or stem), ensuring cross-worker DELETE
  propagation: after a delete, every worker's next request to that flow
  triggers the per-request stale check, reads None from the store, and
  returns 404 regardless of which key its cache entry uses.

- Document the per-request stale check in FlowRegistry docstring with
  latency guidance for network volumes vs. local tmpfs.

- Add tests for all of the above, including a cross-worker DELETE
  propagation scenario validated by a live 2-worker integration test.

* fix(serve): load startup flows in every uvicorn worker via LFX_SERVE_STARTUP_PATHS

Previously, lfx serve flow.json --workers 4 silently dropped startup flows:
the parent loaded them into a NullFlowStore registry that workers never
inherited. Each worker called create_serve_app() fresh and started empty,
returning flow_count: 0 from /health and 404 for the startup flow.

Fix: before calling uvicorn.run() in multi-worker mode, set
LFX_SERVE_STARTUP_PATHS to the JSON-encoded list of resolved startup file
paths (or the inline-JSON temp file path). create_serve_app() reads this env
var and re-loads the startup flows independently in each worker process using
a ThreadPoolExecutor to avoid RuntimeError from asyncio.run() being called
inside uvicorn's already-running event loop.

Startup flows loaded this way live in each worker's in-memory registry.
Uploaded flows still require --flow-dir for cross-worker sharing (existing
warning preserved). The LFX_SERVE_* env vars are cleaned up after
uvicorn.run() returns via the existing prefix-sweep finally block.

Tests added:
- test_serve_command_sets_startup_paths_env_for_multi_worker: verifies
  LFX_SERVE_STARTUP_PATHS is set before uvicorn.run() and cleaned up after
- TestCreateServeAppFactory: unit tests for create_serve_app() in all three
  modes (empty start, with startup paths, empty paths list)

* fix(serve): fix 3 worker/store consistency issues

Issue 1 — redundant store writes with --flow-dir + startup files + workers > 1:
When flow_dir is set the parent already persisted startup flows to the store.
create_serve_app() was re-loading and re-writing them in every worker.
Fix: create_serve_app() now skips LFX_SERVE_STARTUP_PATHS when flow_dir is
set and relies solely on warm_from_store(). Similarly, serve_command now only
populates LFX_SERVE_STARTUP_PATHS when flow_dir is NOT set.

Issue 2 — .py restriction was too broad:
The error blocked .py + --workers > 1 even without --flow-dir, where each
worker can safely re-execute the .py via LFX_SERVE_STARTUP_PATHS. The error
now only fires when --flow-dir is also set (workers would skip startup paths
and warm_from_store, missing the .py flow entirely since it can't be stored).

Issue 3 — parent built FastAPI app for multi-worker then discarded it:
create_multi_serve_app() was called before the workers > 1 / else branch,
building a full FastAPI app that was never served in multi-worker mode.
Moved into the else branch so it's only built for single-worker.

Tests:
- test_create_serve_app_with_flow_dir_skips_startup_paths_uses_store:
  asserts build_registry_from_paths NOT called when flow_dir is set;
  flow comes from store via warm_from_store instead.
- test_create_serve_app_without_flow_dir_loads_startup_paths_from_files:
  asserts flow IS loaded when no flow_dir.
- test_serve_command_does_not_set_startup_paths_when_flow_dir_set:
  asserts LFX_SERVE_STARTUP_PATHS is [] when flow_dir is set.
- test_serve_command_allows_py_with_multiple_workers_no_flow_dir:
  asserts .py + workers>1 + no flow_dir is allowed and path appears in env.

* test(serve): remove duplicate test and fix mislabeled test name

- Remove test_create_serve_app_loads_startup_paths: functionally identical
  to test_create_serve_app_without_flow_dir_loads_startup_paths_from_files
  (same setup, same assertions, added in a prior session).
- Rename test_verify_api_key_header_takes_precedence to
  test_verify_api_key_query_param_takes_precedence: the test passes the
  correct key as the query_param argument and the wrong key as header_param,
  then asserts the query param value is returned — query param has precedence,
  not header. The old name was the opposite of what the test actually checks.

* [autofix.ci] apply automated fixes

* fix(serve): fix concurrent request isolation and startup UX

Three bug fixes discovered via live QA:

1. Component.__deepcopy__ produced shallow copies of _outputs_map,
   _edges, and _components, causing concurrent requests to share the
   same Output objects across graph copies.  Output.cache=True by
   default: the first request's output.value was served to all
   subsequent concurrent requests, producing identical responses for
   different inputs.  Fixed by deep-copying all three fields with memo.

2. POST /flows/upload/ without an explicit id used uuid5(content),
   which is deterministic — two uploads of any flow with the same
   nodes/edges structure always generated the same UUID and the second
   always returned 409.  Fixed by using uuid4() (random).

3. Startup CLI emitted emoji and exclamation-point decoration in the
   serve banner and verbose output.  Replaced with plain text.

* [autofix.ci] apply automated fixes

* fix(serve): address PR review comments

- Move 409 conflict check before load_flow_from_json+prepare so callers
  with an explicit id don't pay the parse cost for a flow that will be
  rejected anyway
- Remove unreachable FlowAlreadyRegisteredError catch: no await exists
  between registry.get() and registry.add(), so asyncio cannot
  interleave another request in that window
- Update FlowMeta.id and UploadFlowResponse.id field descriptions from
  stale "Deterministic UUIDv5" to plain "Flow identifier (UUID)"

* test(serve): fix upload duplicate/replace tests after uuid4 change

Both tests relied on uuid5(content) determinism to generate the same
flow ID from identical data. With uuid4, each upload gets a fresh ID,
so duplicate detection was never triggered. Tests now supply an
explicit id field to exercise the 409/replace paths directly.

* fix(serve): address code review suggestions

- Fix test_write_is_atomic to glob for any *.tmp files rather than
  checking a hardcoded filename that could never exist
- Remove incorrect noqa: B904 from validate_id_is_uuid (not in an
  except block); use `from None` instead
- Add TTL cache (1 s) for FlowStore.list_ids() in FlowRegistry to
  avoid one filesystem glob per /health request; mutations invalidate
  immediately, warm_from_store and delete-scan bypass it
- Document the UUID/stem dual-key alias invariant on _flows
- Add test verifying create_serve_app() propagates load errors from
  the ThreadPoolExecutor startup path (fail fast, not silent empty registry)

* [autofix.ci] apply automated fixes

* fix: lfx serve no_env_fallback, deepcopy cycle, cross-worker delete (#13297)

fix: address concurrency and isolation issues in lfx serve

- Re-apply no_env_fallback to the per-request graph copy in the run and
  stream endpoints; deepcopy() drops graph.context so the flag was lost.
- Register the copy in memo before recursive deepcopy calls in
  Component.__deepcopy__ so reference cycles are preserved.
- Check the store directly in list_metas() so cross-worker deletes are
  reflected immediately instead of after the TTL cache window.
- Remove the redundant upload-conflict test (already covered by
  test_upload_duplicate_without_replace_returns_409).

Also mark test secrets with allowlist pragmas and fix two docstring
lint warnings in the touched test file.

* [autofix.ci] apply automated fixes

* feat(lfx): WXO/TRM request-scoped variables for lfx serve (child of #13121) (#13327)

* feat(lfx): WXO/TRM request-scoped variables for lfx serve global_vars

Align lfx serve with the TRM/WXO credential contract on top of #13121:
- Parse LANGFLOW_REQUEST_VARIABLES from global_vars payloads
- ContextVar-backed lookup in VariableService (no os.environ mutation)
- Activate request scope during execute_graph_with_capture

Child of add-workers-and-upload-lfx (PR #13121).

* [autofix.ci] apply automated fixes

* fix(lfx): harden request-scoped variable resolution + add isolation t… (#13347)

fix(lfx): harden request-scoped variable resolution + add isolation tests

Addresses PR review of WXO/TRM request-scoped variables for lfx serve:

- service: honor no_env_fallback in VariableService so model/KB credential
  resolution no longer falls back to os.environ under --no-env-fallback;
  correct the get_variable precedence docstring (5-step interleaved order)
- request_scope: add a per-request no_env_fallback ContextVar; use precise
  Token type; document the None-vs-empty scope contract
- common: activate/reset the no_env_fallback scope from graph.context
- runtime_variables: log (instead of silently swallowing) a malformed or
  non-object LANGFLOW_REQUEST_VARIABLES blob; anchor the override comment to
  the TRM contract

Tests:
- add request-scope concurrency isolation + no-env-fallback suite
- add execute_graph_with_capture wiring test (graph.context -> ContextVars)
- give TestGraphExecution mock graphs a real context, fixing 11 tests that
  broke once the helper began reading graph.context

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com>

* Update use of stem alias tracking to ensure proper deletes

* fix(lfx): enforce no_env_fallback across all credential resolution paths

Closes credential-isolation gaps in lfx serve and hardens request-scoped
variable handling:

- VariableService: skip the process-wide LANGFLOW_REQUEST_VARIABLES env read
  when env fallback is disabled (previously leaked process credentials for an
  empty-global_vars request despite --no-env-fallback).
- Unified models: gate os.environ reads in get_api_key_for_provider and the
  connection-config fallbacks in instantiation.py (WatsonX URL/project-id,
  Ollama base-url, OpenRouter headers) on the no-env-fallback flag.
- run_until_complete: propagate the caller's contextvars into the worker
  thread so request-scoped vars and the flag survive the thread hop.
- Request variables: serialize structured (dict/list) values as valid JSON and
  drop nulls instead of producing lossy Python reprs or a truthy "None".
- FlowRegistry: extract the duplicated store-alias scan shared by add/remove.

Adds tests for request-scope isolation, credential/env gating, and contextvar
propagation.

* fix(lfx): close no_env_fallback credential leaks and harden serve isolation

- credentials: honor no_env_fallback in get_all_variables_for_provider so served
  flows no longer leak process env (was defeating the _env_if_allowed guards in
  instantiation.py via pre-filled provider_vars)
- loading: apply the no_env_fallback contract to table load_from_db column
  resolution (sibling of load_from_env_vars, previously still read os.getenv)
- variable service: resolve request-scoped vars (exact name + x-langflow-global-var
  alias) before env fallback so a caller's per-request credential is never shadowed
  by a same-named process env var
- runtime_variables: flatten the LANGFLOW_REQUEST_VARIABLES blob into graph context
  for parity between the ContextVar and load_from_db resolution paths
- flow_store: clean up the partial temp file when an atomic write fails
- commands: on multi-worker shutdown remove only the serve env vars we set, not the
  whole LFX_SERVE_* namespace
- components: add check_component_env_writes lint banning os.environ writes; stop the
  mem0 component from writing the OpenAI key into the process environment

* fix(lfx): contain corrupt flow-store files so one bad file can't crash worker startup

FilesystemFlowStore.read() only caught FileNotFoundError, so a corrupted/partial
{id}.json in a shared store (PVC corruption, an external writer killed mid-write)
raised JSONDecodeError. warm_from_store() calls get() for every store id at startup
with no per-file guard, so a single bad file would crash every worker's startup and
500 GET /flows.

- read(): catch JSONDecodeError/OSError/UnicodeDecodeError, log, return None (treat
  as absent) so warm_from_store/list_metas/get skip it and other flows keep serving.
- warm_from_store(): skip+log a flow that fails to reconstruct (corrupt JSON, or valid
  JSON referencing a component not available in this build) instead of aborting startup.
- tests: corrupt-JSON read returns None; warm_from_store skips unloadable flows.

* fix test -- request wins over env now

* fix test. mock graph context correctly

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@logspace.ai>
Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com>
This commit is contained in:
Jordan Frazier
2026-06-03 11:33:39 -04:00
committed by GitHub
parent 1e0515f019
commit 71cd5ce376
36 changed files with 5242 additions and 1005 deletions

View File

@ -66,6 +66,12 @@ repos:
language: system
files: ^src/backend/base/langflow/api/.*\.py$
pass_filenames: false
- id: component-env-writes
name: Components must not write to os.environ (process-global; bleeds across requests)
entry: python scripts/lint/check_component_env_writes.py
language: system
files: ^(src/lfx/src/lfx/components/.*\.py|src/backend/base/langflow/components/.*\.py|src/bundles/.*\.py)$
pass_filenames: false
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:

View File

@ -8889,7 +8889,7 @@
"filename": "src/lfx/src/lfx/cli/serve_app.py",
"hashed_secret": "b894b81be94cf8fa8d7536475aaec876addf05c8",
"is_verified": false,
"line_number": 40,
"line_number": 49,
"is_secret": false
}
],

View File

@ -250,6 +250,7 @@ flake8-bugbear.extend-immutable-calls = [
"fastapi.Depends",
"fastapi.File",
"fastapi.Query",
"typer.Argument",
"typer.Option",
]
flake8-type-checking.runtime-evaluated-base-classes = [

View File

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""CI guard: components must not WRITE to ``os.environ``.
``os.environ`` is process-global. A warm, long-lived serving process (``lfx serve``'s
worker pool, or the main Langflow server) handles many callers' requests in one
process, so a per-request write like ``os.environ["OPENAI_API_KEY"] = caller_key``
bleeds into other requests on that worker and persists until overwritten — leaking
one caller's credential to another. (The bleed is per-worker: uvicorn ``--workers N``
are separate processes, but every concurrent request inside one worker shares its
``os.environ``.)
READS are fine — this guard only flags WRITES. Pass per-request values through the
component's config/params instead of mutating the environment.
This walks every component source file (stdlib AST only — zero dependency on the lfx
package, since CI may run before it is importable) and flags:
os.environ[k] = v os.environ = {...} del os.environ[k]
os.environ.update(...) os.environ.setdefault(...) .pop/.clear/.popitem
os.putenv(...) os.unsetenv(...) load_dotenv(...)
Usage::
python scripts/lint/check_component_env_writes.py # scan default roots
python scripts/lint/check_component_env_writes.py FILE... # check specific files
python scripts/lint/check_component_env_writes.py --root path/to/components
Exit codes:
0 -- no disallowed env writes
1 -- one or more disallowed env writes (or a file failed to parse)
"""
from __future__ import annotations
import argparse
import ast
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# Component source roots (matches scripts/migrate/check_bare_names.py coverage).
DEFAULT_ROOTS = (
"src/lfx/src/lfx/components",
"src/backend/base/langflow/components",
"src/bundles",
)
# Deliberate, reviewed exceptions: repo-relative path -> set of allowed violation kinds.
# Keep this list tiny and justified; an entry here means "this component is knowingly
# allowed to mutate the process environment". Prefer fixing the component over adding one.
ALLOWLIST: dict[str, set[str]] = {
# The "Dotenv" component's entire purpose is to load a user-supplied .env blob into
# the process environment. It is a known multi-tenant hazard flagged for separate
# review; allow only the load_dotenv call, still flagging any direct os.environ write.
"src/lfx/src/lfx/components/datastax/dotenv.py": {"load_dotenv"},
}
_MUTATING_METHODS = {"setdefault", "update", "pop", "clear", "popitem", "__setitem__", "__delitem__"}
class _EnvWriteVisitor(ast.NodeVisitor):
"""Flags writes to ``os.environ`` (and ``load_dotenv()``), import-alias-aware.
A first pass over the tree collects local names that refer to ``os``,
``os.environ``, ``os.putenv``, ``os.unsetenv`` and ``dotenv.load_dotenv``
(including ``as``-aliases), so ``import os as system; system.environ[X]=Y``
and ``from dotenv import load_dotenv as ld; ld()`` are caught.
"""
def __init__(self) -> None:
self.violations: list[tuple[int, str, str]] = [] # (lineno, kind, snippet)
self.os_aliases: set[str] = {"os"}
self.environ_aliases: set[str] = {"environ"}
self.putenv_aliases: set[str] = set()
self.unsetenv_aliases: set[str] = set()
self.load_dotenv_aliases: set[str] = {"load_dotenv"}
# First pass: collect import aliases module-wide (over-broad on scope is fine for a
# security lint — we'd rather flag a write that imports `os` locally than miss it).
def collect_aliases(self, tree: ast.AST) -> None:
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "os":
self.os_aliases.add(alias.asname or alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module == "os":
for alias in node.names:
local = alias.asname or alias.name
if alias.name == "environ":
self.environ_aliases.add(local)
elif alias.name == "putenv":
self.putenv_aliases.add(local)
elif alias.name == "unsetenv":
self.unsetenv_aliases.add(local)
elif node.module == "dotenv":
for alias in node.names:
if alias.name == "load_dotenv":
self.load_dotenv_aliases.add(alias.asname or alias.name)
def _is_environ(self, node: ast.AST) -> bool:
"""True for ``<os_alias>.environ`` or a bare ``<environ_alias>``."""
if isinstance(node, ast.Attribute):
return node.attr == "environ" and isinstance(node.value, ast.Name) and node.value.id in self.os_aliases
return isinstance(node, ast.Name) and node.id in self.environ_aliases
def _flag(self, lineno: int, kind: str, snippet: str) -> None:
self.violations.append((lineno, kind, snippet))
def visit_Assign(self, node: ast.Assign) -> None:
for target in node.targets:
if isinstance(target, ast.Subscript) and self._is_environ(target.value):
self._flag(node.lineno, "assign", "os.environ[...] = ...")
elif self._is_environ(target):
self._flag(node.lineno, "rebind", "os.environ = ...")
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
if isinstance(node.target, ast.Subscript) and self._is_environ(node.target.value):
self._flag(node.lineno, "assign", "os.environ[...]: ... = ...")
self.generic_visit(node)
def visit_AugAssign(self, node: ast.AugAssign) -> None:
target = node.target
if (isinstance(target, ast.Subscript) and self._is_environ(target.value)) or self._is_environ(target):
self._flag(node.lineno, "assign", "os.environ augmented assignment")
self.generic_visit(node)
def visit_Delete(self, node: ast.Delete) -> None:
for target in node.targets:
if isinstance(target, ast.Subscript) and self._is_environ(target.value):
self._flag(node.lineno, "del", "del os.environ[...]")
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
func = node.func
if isinstance(func, ast.Attribute):
if func.attr in _MUTATING_METHODS and self._is_environ(func.value):
self._flag(node.lineno, f"mutate:{func.attr}", f"os.environ.{func.attr}(...)")
elif (
func.attr in {"putenv", "unsetenv"}
and isinstance(func.value, ast.Name)
and func.value.id in self.os_aliases
):
self._flag(node.lineno, func.attr, f"os.{func.attr}(...)")
elif func.attr == "load_dotenv":
self._flag(node.lineno, "load_dotenv", "load_dotenv(...)")
elif isinstance(func, ast.Name):
if func.id in self.putenv_aliases:
self._flag(node.lineno, "putenv", f"{func.id}(...)")
elif func.id in self.unsetenv_aliases:
self._flag(node.lineno, "unsetenv", f"{func.id}(...)")
elif func.id in self.load_dotenv_aliases:
self._flag(node.lineno, "load_dotenv", f"{func.id}(...)")
self.generic_visit(node)
def _iter_py_files(roots: list[Path]) -> list[Path]:
files: list[Path] = []
for root in roots:
if not root.exists():
continue
for path in sorted(root.rglob("*.py")):
parts = path.parts
if "tests" in parts or path.name.startswith("test_"):
continue
files.append(path)
return files
def _check_file(path: Path) -> list[tuple[str, int, str, str]]:
try:
rel = path.resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
rel = path.as_posix()
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (OSError, SyntaxError) as exc:
return [(rel, getattr(exc, "lineno", 0) or 0, "parse-error", str(exc))]
visitor = _EnvWriteVisitor()
visitor.collect_aliases(tree)
visitor.visit(tree)
allowed = ALLOWLIST.get(rel, set())
out: list[tuple[str, int, str, str]] = []
for lineno, kind, snippet in visitor.violations:
if kind in allowed:
continue
out.append((rel, lineno, kind, snippet))
return out
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("files", nargs="*", help="Specific files to check (default: scan component roots).")
parser.add_argument("--root", action="append", default=None, help="Override component root(s) to scan.")
args = parser.parse_args(argv)
if args.files:
targets = [Path(f).resolve() for f in args.files if f.endswith(".py")]
else:
roots = [Path(r) if Path(r).is_absolute() else REPO_ROOT / r for r in (args.root or DEFAULT_ROOTS)]
targets = _iter_py_files(roots)
violations: list[tuple[str, int, str, str]] = []
for path in targets:
violations.extend(_check_file(path))
if not violations:
return 0
print("ERROR: component(s) write to os.environ (process-global — bleeds across requests")
print(" in a shared serving process such as `lfx serve`):\n")
for rel, lineno, kind, snippet in sorted(violations):
print(f" {rel}:{lineno} [{kind}] {snippet}")
print(
"\nFix: pass per-request values through the component's config/params, not the\n"
"process environment (reads are fine; only writes bleed). If a write is a\n"
"deliberate, reviewed exception, add it to ALLOWLIST in\n"
"scripts/lint/check_component_env_writes.py with a justification."
)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -24,11 +24,24 @@ class TestMem0CloudValidation:
@patch("lfx.components.mem0.mem0_chat_memory.Memory")
def test_build_mem0_works_when_not_in_cloud(self, mock_memory):
"""Test that build_mem0 works when ASTRA_CLOUD_DISABLE_COMPONENT is false."""
"""build_mem0 works when not in cloud, passing the OpenAI key through mem0 config.
The key must NOT be written to os.environ: that is process-global and persists
across requests, so in a shared serving process (lfx serve) one caller's key
would leak into other concurrent requests.
"""
with patch.dict(os.environ, {"ASTRA_CLOUD_DISABLE_COMPONENT": "false"}):
os.environ.pop("OPENAI_API_KEY", None)
component = Mem0MemoryComponent(openai_api_key="test-key")
component.build_mem0()
mock_memory.assert_called_once()
# Key flows through config, not the environment.
mock_memory.from_config.assert_called_once()
config = mock_memory.from_config.call_args.kwargs["config_dict"]
assert config["llm"]["config"]["api_key"] == "test-key"
assert config["embedder"]["config"]["api_key"] == "test-key"
# Regression guard for the credential-bleed bug: never pollute os.environ.
assert "OPENAI_API_KEY" not in os.environ
def test_build_search_results_uses_mem0_2_filters_for_search(self):
"""Test that search uses filters for Mem0 2.x entity scoping."""

View File

@ -369,6 +369,10 @@ async def test_update_table_params_with_fallback_to_env():
# Create mock custom component
custom_component = MagicMock()
# graph.context is a dict in production; set it explicitly so no_env_fallback resolves
# to False (a bare MagicMock's .context.get(...) returns a truthy mock and would
# silently disable env fallback).
custom_component.graph.context = {}
custom_component.get_variable = AsyncMock(side_effect=ValueError("variable not found."))
# Set up table params
@ -406,6 +410,10 @@ async def test_update_table_params_mixed_db_and_env():
# Create mock custom component
custom_component = MagicMock()
# graph.context is a dict in production; set it explicitly so no_env_fallback resolves
# to False (a bare MagicMock's .context.get(...) returns a truthy mock and would
# silently disable env fallback).
custom_component.graph.context = {}
async def mock_get_variable(name, **_kwargs):
if name == "DB_KEY":

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@ from uuid import UUID
from lfx.log.logger import logger
from lfx.services.deps import get_variable_service, session_scope
from lfx.services.variable.request_scope import is_env_fallback_disabled
from lfx.utils.async_helpers import run_until_complete
from lfx.utils.secrets import secret_value_to_str
@ -60,9 +61,12 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key:
value = secret_value_to_str(value, strip=True)
if value:
return value
env_value = os.environ.get(var_name)
if env_value and env_value.strip():
return env_value.strip()
# Honor the request's no-env-fallback contract: skip os.environ when disabled so a
# served flow stays isolated from process-wide credentials (matches VariableService).
if not is_env_fallback_disabled():
env_value = os.environ.get(var_name)
if env_value and env_value.strip():
return env_value.strip()
return None
if api_key and api_key.strip():
@ -115,6 +119,8 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key:
if api_key:
return api_key
if is_env_fallback_disabled():
return None
return os.getenv(variable_name)
@ -147,8 +153,13 @@ def get_all_variables_for_provider(user_id: UUID | str | None, provider: str) ->
if not provider_vars:
return result
# If no user_id, only check environment variables
# If no user_id, only check environment variables. Honor the request's no-env-fallback
# contract: a served flow under no_env_fallback stays isolated from process-wide
# credentials, so return nothing rather than leaking os.environ into provider_vars
# (which would defeat the _env_if_allowed guards in instantiation.py).
if user_id is None or (isinstance(user_id, str) and user_id == "None"):
if is_env_fallback_disabled():
return result
for var_info in provider_vars:
var_key = var_info.get("variable_key")
if var_key:
@ -183,7 +194,10 @@ def get_all_variables_for_provider(user_id: UUID | str | None, provider: str) ->
if value:
values[var_key] = value
except (ValueError, Exception): # noqa: BLE001
# Variable not found - check environment
# Variable not found - check environment, unless the request disables
# env fallback (keeps served flows isolated from process-wide credentials).
if is_env_fallback_disabled():
continue
env_value = _env_value_for(var_key)
if env_value:
values[var_key] = env_value

View File

@ -6,6 +6,7 @@ import os
from typing import TYPE_CHECKING, Any
from lfx.base.models.model_utils import _to_str
from lfx.services.variable.request_scope import is_env_fallback_disabled
from .class_registry import EMBEDDING_PARAM_MAPPINGS, EMBEDDING_PROVIDER_CLASS_MAPPING
from .provider_queries import model_provider_metadata
@ -14,6 +15,18 @@ if TYPE_CHECKING:
from uuid import UUID
def _env_if_allowed(key: str) -> str | None:
"""Return ``os.environ.get(key)`` unless the active request disables env fallback.
Connection config (provider URLs, project IDs, attribution headers) falls back to
process env only when env fallback is allowed, so a served flow under
``no_env_fallback`` stays isolated from process-wide environment.
"""
if is_env_fallback_disabled():
return None
return os.environ.get(key)
def get_llm(
model,
user_id: UUID | str | None,
@ -180,12 +193,12 @@ def get_llm(
# Priority: component value > database value > env var
watsonx_url_value = (
watsonx_url if watsonx_url else provider_vars.get("WATSONX_URL") or os.environ.get("WATSONX_URL")
watsonx_url if watsonx_url else provider_vars.get("WATSONX_URL") or _env_if_allowed("WATSONX_URL")
)
watsonx_project_id_value = (
watsonx_project_id
if watsonx_project_id
else provider_vars.get("WATSONX_PROJECT_ID") or os.environ.get("WATSONX_PROJECT_ID")
else provider_vars.get("WATSONX_PROJECT_ID") or _env_if_allowed("WATSONX_PROJECT_ID")
)
has_url = bool(watsonx_url_value)
@ -217,7 +230,7 @@ def get_llm(
ollama_base_url_value = (
ollama_base_url
if ollama_base_url
else provider_vars.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_BASE_URL")
else provider_vars.get("OLLAMA_BASE_URL") or _env_if_allowed("OLLAMA_BASE_URL")
)
if ollama_base_url_value:
kwargs[base_url_param] = ollama_base_url_value
@ -240,7 +253,7 @@ def get_llm(
# KeyError on a misconfigured metadata entry beats silently
# skipping a header the operator expects to be sent.
variable_key = var["variable_key"]
value = provider_vars.get(variable_key) or os.environ.get(variable_key)
value = provider_vars.get(variable_key) or _env_if_allowed(variable_key)
if header_name and value:
default_headers[header_name] = value
if default_headers:
@ -380,11 +393,11 @@ def get_embeddings(
# Watson-specific parameters
if provider in {"IBM WatsonX", "IBM watsonx.ai"}:
watsonx_provider_vars = unified_models_module.get_all_variables_for_provider(user_id, provider)
url_value = watsonx_url or watsonx_provider_vars.get("WATSONX_URL") or os.environ.get("WATSONX_URL")
url_value = watsonx_url or watsonx_provider_vars.get("WATSONX_URL") or _env_if_allowed("WATSONX_URL")
pid_value = (
watsonx_project_id
or watsonx_provider_vars.get("WATSONX_PROJECT_ID")
or os.environ.get("WATSONX_PROJECT_ID")
or _env_if_allowed("WATSONX_PROJECT_ID")
)
has_url = bool(url_value)
@ -430,7 +443,7 @@ def get_embeddings(
base_url_value = (
ollama_base_url
or provider_vars.get("OLLAMA_BASE_URL")
or os.environ.get("OLLAMA_BASE_URL")
or _env_if_allowed("OLLAMA_BASE_URL")
or "http://localhost:11434"
)
kwargs[param_mapping["base_url"]] = base_url_value

View File

@ -708,7 +708,7 @@ def dev_command(
"external dev-server scripts."
),
),
extra_args: list[str] | None = typer.Argument( # noqa: B008 - Typer requires call-site default
extra_args: list[str] | None = typer.Argument(
None,
help="Extra arguments forwarded to ``langflow run`` (after a ``--`` separator).",
),

View File

@ -92,17 +92,24 @@ def register(app: typer.Typer) -> None:
session_id=session_id,
)
@app.command(name="serve", help="Serve a flow as an API", no_args_is_help=True, rich_help_panel="Running")
@app.command(name="serve", help="Serve a flow as an API", rich_help_panel="Running")
def serve_command_wrapper(
script_path: str | None = typer.Argument(
None,
script_paths: list[str] | None = typer.Argument(
default=None,
help=(
"Path to JSON flow (.json) or Python script (.py) file or stdin input. "
"Path(s) to JSON flow file(s) (.json), Python script(s) (.py), or a directory "
"containing .json files (top-level only, non-recursive). "
"Optional when using --flow-json or --stdin."
),
),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind the server to"),
port: int = typer.Option(8000, "--port", "-p", help="Port to bind the server to"),
workers: int = typer.Option(
1,
"--workers",
"-w",
help="Number of uvicorn worker processes. Use with --flow-dir for multi-worker flow sharing.",
),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show diagnostic output and execution details"),
env_file: str | None = typer.Option(
None,
@ -117,36 +124,58 @@ def register(app: typer.Typer) -> None:
flow_json: str | None = typer.Option(
None,
"--flow-json",
help="Inline JSON flow content as a string (alternative to script_path)",
help="Inline JSON flow content as a string (alternative to script_paths)",
),
flow_dir: str | None = typer.Option(
None,
"--flow-dir",
help=(
"Directory for filesystem-backed flow storage. "
"All uvicorn workers sharing this path will serve the same flows. "
"Use /tmp/lfx-flows for single-pod sharing or a PVC mount for cross-pod. "
"Defaults to in-memory only when omitted."
),
),
*,
stdin: bool = typer.Option(
False,
"--stdin",
help="Read JSON flow content from stdin (alternative to script_path)",
help="Read JSON flow content from stdin (alternative to script_paths)",
),
check_variables: bool = typer.Option(
True,
"--check-variables/--no-check-variables",
help="Check global variables for environment compatibility",
),
no_env_fallback: bool = typer.Option(
False,
"--no-env-fallback/--env-fallback",
help=(
"Disable os.environ fallback for credential variables. "
"Variables not supplied via global_vars on each request resolve to None "
"instead of reading from the process environment."
),
),
) -> None:
"""Serve LFX flows as a web API (lazy-loaded)."""
from pathlib import Path
from lfx.cli.commands import serve_command
# Convert env_file string to Path if provided
env_file_path = Path(env_file) if env_file else None
flow_dir_path = Path(flow_dir) if flow_dir else None
serve_command(
script_path=script_path,
script_paths=script_paths,
host=host,
port=port,
workers=workers,
verbose=verbose,
env_file=env_file_path,
log_level=log_level,
flow_json=flow_json,
flow_dir=flow_dir_path,
stdin=stdin,
check_variables=check_variables,
no_env_fallback=no_env_fallback,
)

View File

@ -2,16 +2,17 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import sys
import tempfile
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING
import typer
import uvicorn
from asyncer import syncify
from dotenv import load_dotenv
from rich.console import Console
from rich.panel import Panel
@ -23,9 +24,15 @@ from lfx.cli.common import (
get_best_access_host,
get_free_port,
is_port_in_use,
load_graph_from_path,
)
from lfx.cli.serve_app import FlowMeta, create_multi_serve_app
from lfx.cli.script_loader import find_graph_variable, load_graph_from_script
from lfx.cli.serve_app import FlowAlreadyRegisteredError, FlowMeta, FlowRegistry, create_multi_serve_app
from lfx.load import load_flow_from_json
if TYPE_CHECKING:
from collections.abc import Callable
from lfx.cli.flow_store import FlowStore
# Initialize console
console = Console()
@ -34,17 +41,119 @@ console = Console()
API_KEY_MASK_LENGTH = 8
@partial(syncify, raise_sync_error=False)
async def serve_command(
script_path: str | None = typer.Argument(
None,
async def _build_serve_registry(
*,
script_paths: list[str] | None,
flow_json: str | None,
stdin: bool,
check_variables: bool,
no_env_fallback: bool,
flow_store: FlowStore,
verbose_print: Callable[[str], None],
) -> tuple[FlowRegistry, str | None]:
"""Build the FlowRegistry from startup inputs.
Returns (registry, temp_file_path_or_None). Caller must unlink the temp
file if not None.
"""
temp_file_to_cleanup: str | None = None
if flow_json is not None or stdin:
if flow_json is not None:
try:
json_data = json.loads(flow_json)
except json.JSONDecodeError as e:
typer.echo(f"Error: Invalid JSON content: {e}", err=True)
raise typer.Exit(1) from e
else:
stdin_content = sys.stdin.read().strip()
if not stdin_content:
typer.echo("Error: No content received from stdin", err=True)
raise typer.Exit(1)
try:
json_data = json.loads(stdin_content)
except json.JSONDecodeError as e:
typer.echo(f"Error: Invalid JSON content from stdin: {e}", err=True)
raise typer.Exit(1) from e
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(json_data, tmp, indent=2)
temp_file_to_cleanup = tmp.name
try:
registry = await build_registry_from_paths(
[Path(temp_file_to_cleanup)],
verbose_print,
check_variables=check_variables,
no_env_fallback=no_env_fallback,
store=flow_store,
)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from e
elif script_paths:
resolved = [Path(p).resolve() for p in script_paths]
missing = [p for p in resolved if not p.exists()]
if missing:
for m in missing:
typer.echo(f"Error: Path '{m}' does not exist.", err=True)
raise typer.Exit(1)
if len(resolved) == 1 and resolved[0].is_dir():
dir_path = resolved[0]
try:
registry = await build_registry_from_directory(
dir_path,
verbose_print,
check_variables=check_variables,
no_env_fallback=no_env_fallback,
store=flow_store,
)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from e
verbose_print(f"Loaded {len(registry)} flow(s) from directory {dir_path}")
else:
non_supported = [p for p in resolved if p.suffix not in {".json", ".py"}]
if non_supported:
for p in non_supported:
typer.echo(f"Error: '{p}' must be a .json or .py file.", err=True)
raise typer.Exit(1)
try:
registry = await build_registry_from_paths(
resolved,
verbose_print,
check_variables=check_variables,
no_env_fallback=no_env_fallback,
store=flow_store,
)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from e
else:
registry = FlowRegistry(no_env_fallback=no_env_fallback, store=flow_store)
verbose_print("Starting with empty registry — flows can be uploaded at runtime")
return registry, temp_file_to_cleanup
def serve_command(
script_paths: list[str] | None = typer.Argument(
default=None,
help=(
"Path to JSON flow (.json) or Python script (.py) file or stdin input. "
"Path(s) to JSON flow file(s) (.json), Python script(s) (.py), or a directory "
"containing .json files (top-level only, non-recursive). "
"Optional when using --flow-json or --stdin."
),
),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind the server to"),
port: int = typer.Option(8000, "--port", "-p", help="Port to bind the server to"),
workers: int = typer.Option(
1,
"--workers",
"-w",
help="Number of uvicorn worker processes. Use with --flow-dir for multi-worker flow sharing.",
),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show diagnostic output and execution details"), # noqa: FBT001, FBT003
env_file: Path | None = typer.Option(
None,
@ -59,274 +168,356 @@ async def serve_command(
flow_json: str | None = typer.Option(
None,
"--flow-json",
help="Inline JSON flow content as a string (alternative to script_path)",
help="Inline JSON flow content as a string (alternative to script_paths)",
),
flow_dir: Path | None = typer.Option(
None,
"--flow-dir",
help=(
"Directory for filesystem-backed flow storage. "
"All uvicorn workers sharing this path will serve the same flows. "
"Use /tmp/lfx-flows for single-pod sharing or a PVC mount for cross-pod. "
"Defaults to in-memory only when omitted."
),
),
*,
stdin: bool = typer.Option(
False, # noqa: FBT003
"--stdin",
help="Read JSON flow content from stdin (alternative to script_path)",
help="Read JSON flow content from stdin (alternative to script_paths)",
),
check_variables: bool = typer.Option(
True, # noqa: FBT003
"--check-variables/--no-check-variables",
help="Check global variables for environment compatibility",
),
no_env_fallback: bool = typer.Option(
False, # noqa: FBT003
"--no-env-fallback/--env-fallback",
help=(
"Disable os.environ fallback for credential variables. "
"Variables not supplied via global_vars on each request resolve to None "
"instead of reading from the process environment."
),
),
) -> None:
"""Serve LFX flows as a web API.
Supports single files, inline JSON, and stdin input.
Examples:
# Serve from file
lfx serve my_flow.json
# Serve inline JSON
lfx serve --flow-json '{"nodes": [...], "edges": [...]}'
# Serve from stdin
cat my_flow.json | lfx serve --stdin
echo '{"nodes": [...]}' | lfx serve --stdin
Supports single files, inline JSON, stdin, multiple explicit files,
and a directory of JSON flows.
"""
# Configure logging with the specified level and import logger
from lfx.log.logger import configure, logger
configure(log_level=log_level)
verbose_print = create_verbose_printer(verbose=verbose)
# Validate input sources - exactly one must be provided
input_sources = [script_path is not None, flow_json is not None, stdin]
if sum(input_sources) != 1:
if sum(input_sources) == 0:
verbose_print("Error: Must provide either script_path, --flow-json, or --stdin")
else:
verbose_print("Error: Cannot use script_path, --flow-json, and --stdin together. Choose exactly one.")
raise typer.Exit(1)
# Load environment variables from .env file if provided
if env_file:
if not env_file.exists():
verbose_print(f"Error: Environment file '{env_file}' does not exist.")
raise typer.Exit(1)
verbose_print(f"Loading environment variables from: {env_file}")
load_dotenv(env_file)
# Validate API key
try:
api_key = get_api_key()
verbose_print("✓ LANGFLOW_API_KEY is configured")
except ValueError as e:
typer.echo(f"{e}", err=True)
typer.echo("Set the LANGFLOW_API_KEY environment variable before serving.", err=True)
raise typer.Exit(1) from e
# Validate log level
valid_log_levels = {"debug", "info", "warning", "error", "critical"}
if log_level.lower() not in valid_log_levels:
verbose_print(f"Error: Invalid log level '{log_level}'. Must be one of: {', '.join(sorted(valid_log_levels))}")
raise typer.Exit(1)
# Configure logging with the specified level
# Disable pretty logs for serve command to avoid ANSI codes in API responses
os.environ["LANGFLOW_PRETTY_LOGS"] = "false"
verbose_print(f"Configuring logging with level: {log_level}")
from lfx.log.logger import configure
configure(log_level=log_level)
verbose_print = create_verbose_printer(verbose=verbose)
# ------------------------------------------------------------------
# Handle inline JSON content or stdin input
# ------------------------------------------------------------------
temp_file_to_cleanup = None
# Validate at most one input source
has_paths = bool(script_paths)
input_sources = [has_paths, flow_json is not None, stdin]
if sum(input_sources) > 1:
typer.echo("Error: Cannot combine path(s), --flow-json, and --stdin. Choose exactly one.", err=True)
raise typer.Exit(1)
if flow_json is not None:
logger.info("Processing inline JSON content...")
try:
# Validate JSON syntax
json_data = json.loads(flow_json)
logger.info("JSON content is valid")
# Create a temporary file with the JSON content
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file:
json.dump(json_data, temp_file, indent=2)
temp_file_to_cleanup = temp_file.name
script_path = temp_file_to_cleanup
logger.info(f"Created temporary file: {script_path}")
except json.JSONDecodeError as e:
typer.echo(f"Error: Invalid JSON content: {e}", err=True)
raise typer.Exit(1) from e
except Exception as e:
verbose_print(f"Error processing JSON content: {e}")
raise typer.Exit(1) from e
elif stdin:
logger.info("Reading JSON content from stdin...")
try:
# Read all content from stdin
stdin_content = sys.stdin.read().strip()
if not stdin_content:
logger.error("No content received from stdin")
raise typer.Exit(1)
# Validate JSON syntax
json_data = json.loads(stdin_content)
logger.info("JSON content from stdin is valid")
# Create a temporary file with the JSON content
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file:
json.dump(json_data, temp_file, indent=2)
temp_file_to_cleanup = temp_file.name
script_path = temp_file_to_cleanup
logger.info(f"Created temporary file from stdin: {script_path}")
except json.JSONDecodeError as e:
verbose_print(f"Error: Invalid JSON content from stdin: {e}")
raise typer.Exit(1) from e
except Exception as e:
verbose_print(f"Error reading from stdin: {e}")
raise typer.Exit(1) from e
if env_file:
if not env_file.exists():
typer.echo(f"Error: Environment file '{env_file}' does not exist.", err=True)
raise typer.Exit(1)
verbose_print(f"Loading environment variables from: {env_file}")
load_dotenv(env_file)
try:
# Load the graph
if script_path is None:
verbose_print("Error: script_path is None after input validation")
raise typer.Exit(1)
api_key = get_api_key()
verbose_print("LANGFLOW_API_KEY is configured")
except ValueError as e:
typer.echo(str(e), err=True)
typer.echo("Set the LANGFLOW_API_KEY environment variable before serving.", err=True)
raise typer.Exit(1) from e
resolved_path = Path(script_path).resolve()
valid_log_levels = {"debug", "info", "warning", "error", "critical"}
if log_level.lower() not in valid_log_levels:
typer.echo(
f"Error: Invalid log level '{log_level}'. Must be one of: {', '.join(sorted(valid_log_levels))}",
err=True,
)
raise typer.Exit(1)
if not resolved_path.exists():
typer.echo(f"Error: File '{resolved_path}' does not exist.", err=True)
raise typer.Exit(1)
if workers < 1:
typer.echo("Error: --workers must be at least 1.", err=True)
raise typer.Exit(1)
if resolved_path.suffix == ".json":
graph = await load_graph_from_path(resolved_path, resolved_path.suffix, verbose_print, verbose=verbose)
elif resolved_path.suffix == ".py":
verbose_print("Loading graph from Python script...")
from lfx.cli.script_loader import load_graph_from_script
os.environ["LANGFLOW_PRETTY_LOGS"] = "false"
configure(log_level=log_level)
graph = await load_graph_from_script(resolved_path)
verbose_print("✓ Graph loaded from Python script")
else:
err_msg = "Error: Only JSON flow files (.json) or Python scripts (.py) are supported. "
err_msg += f"Got: {resolved_path.suffix}"
verbose_print(err_msg)
raise typer.Exit(1)
from lfx.cli.flow_store import FilesystemFlowStore, NullFlowStore
# Prepare the graph
logger.info("Preparing graph for serving...")
try:
graph.prepare()
logger.info("Graph prepared successfully")
flow_store = FilesystemFlowStore(flow_dir) if flow_dir else NullFlowStore()
# Validate global variables for environment compatibility
if check_variables:
from lfx.cli.validation import validate_global_variables_for_env
validation_errors = validate_global_variables_for_env(graph)
if validation_errors:
logger.error("Global variable validation failed:")
for error in validation_errors:
logger.error(f" - {error}")
raise typer.Exit(1)
else:
logger.info("Global variable validation skipped")
except Exception as e:
verbose_print(f"✗ Failed to prepare graph: {e}")
raise typer.Exit(1) from e
# Check if port is in use
if is_port_in_use(port, host):
available_port = get_free_port(port)
if verbose:
verbose_print(f"Port {port} is in use, using port {available_port} instead")
port = available_port
# Create single-flow metadata
flow_id = flow_id_from_path(resolved_path, resolved_path.parent)
graph.flow_id = flow_id # annotate graph for reference
title = resolved_path.stem
description = None
metas = {
flow_id: FlowMeta(
id=flow_id,
relative_path=str(resolved_path.name),
title=title,
description=description,
)
}
graphs = {flow_id: graph}
source_display = "inline JSON" if flow_json else "stdin" if stdin else str(resolved_path)
verbose_print(f"✓ Prepared single flow '{title}' from {source_display} (id={flow_id})")
# Create FastAPI app
serve_app = create_multi_serve_app(
root_dir=resolved_path.parent,
graphs=graphs,
metas=metas,
verbose_print=verbose_print,
if workers > 1 and flow_dir is None:
typer.echo(
"Warning: --workers > 1 without --flow-dir means each worker has an isolated "
"in-memory registry. Flows uploaded to one worker will not be visible to others. "
"Pass --flow-dir to enable shared flow storage across workers.",
err=True,
)
verbose_print("🚀 Starting single-flow server...")
if workers > 1 and flow_dir is not None and script_paths:
# With --flow-dir, workers skip LFX_SERVE_STARTUP_PATHS and rely on warm_from_store().
# .py files produce no raw_json so they can't be written to the store — workers would
# silently start without those flows. Without --flow-dir workers re-execute the .py
# file directly from LFX_SERVE_STARTUP_PATHS, which works fine.
py_paths = [p for p in script_paths if Path(p).suffix == ".py"]
if py_paths:
for p in py_paths:
typer.echo(
f"Error: '{Path(p).name}' (.py) cannot be used with --workers > 1 and --flow-dir. "
"Python graphs cannot be serialized to the store, so workers would start without "
"those flows. Use a .json export, omit --flow-dir, or use --workers 1.",
err=True,
)
raise typer.Exit(1)
# Determine display name for startup panel
if flow_json is not None:
source_display = "inline JSON"
elif stdin:
source_display = "stdin"
elif script_paths:
resolved_display = [Path(p).resolve() for p in script_paths]
if len(resolved_display) == 1 and resolved_display[0].is_dir():
source_display = str(resolved_display[0])
else:
source_display = ", ".join(Path(p).name for p in script_paths)
else:
source_display = "none (upload flows via POST /flows/upload/)"
temp_file_to_cleanup: str | None = None
try:
registry, temp_file_to_cleanup = asyncio.run(
_build_serve_registry(
script_paths=script_paths,
flow_json=flow_json,
stdin=stdin,
check_variables=check_variables,
no_env_fallback=no_env_fallback,
flow_store=flow_store,
verbose_print=verbose_print,
)
)
if flow_dir:
if workers == 1:
# Single-worker: warm now so the startup panel shows the real count and the
# first request to any pre-existing flow doesn't pay a cold-load penalty.
# Multi-worker: each worker warms inside create_serve_app(); the parent can't
# safely warm and pass graphs across processes, so we skip it here.
registry.warm_from_store()
verbose_print(f"Flow store at {flow_dir} ({len(registry)} flows available)")
if is_port_in_use(port, host):
port = get_free_port(port)
verbose_print(f"Port in use; using {port} instead")
verbose_print("Starting server...")
protocol = "http"
access_host = get_best_access_host(host)
masked_key = f"{api_key[:API_KEY_MASK_LENGTH]}..." if len(api_key) > API_KEY_MASK_LENGTH else "***"
server_line = f"{protocol}://{access_host}:{port}"
if access_host != host:
server_line += f" [dim](bound to {host}:{port})[/dim]"
console.print()
console.print(
Panel.fit(
f"[bold green]🎯 Single Flow Served Successfully![/bold green]\n\n"
f"[bold green]LFX Server Ready[/bold green]\n\n"
f"[bold]Source:[/bold] {source_display}\n"
f"[bold]Server:[/bold] {protocol}://{access_host}:{port}\n"
f"[bold]Flows:[/bold] {len(registry)}\n"
f"[bold]Workers:[/bold] {workers}\n"
f"[bold]Server:[/bold] {server_line}\n"
f"[bold]API Key:[/bold] {masked_key}\n\n"
f"[dim]Send POST requests to:[/dim]\n"
f"[blue]{protocol}://{access_host}:{port}/flows/{flow_id}/run[/blue]\n\n"
f"[dim]With headers:[/dim]\n"
f"[blue]x-api-key: {masked_key}[/blue]\n\n"
f"[dim]Or query parameter:[/dim]\n"
f"[blue]?x-api-key={masked_key}[/blue]\n\n"
f"[dim]Request body:[/dim]\n"
f"[blue]{{'input_value': 'Your input message'}}[/blue]",
f"[dim]List flows:[/dim]\n"
f"[blue]{protocol}://{access_host}:{port}/flows[/blue]\n\n"
f"[dim]Upload new flow:[/dim]\n"
f"[blue]POST {protocol}://{access_host}:{port}/flows/upload/[/blue]\n\n"
f"[dim]Run a flow:[/dim]\n"
f"[blue]POST {protocol}://{access_host}:{port}/flows/{{flow_id}}/run[/blue]",
title="[bold blue]LFX Server[/bold blue]",
border_style="blue",
)
)
console.print()
# Start the server
# Use uvicorn.Server to properly handle async context
# uvicorn.run() uses asyncio.run() internally which fails when
# an event loop is already running (due to syncify decorator)
try:
config = uvicorn.Config(
serve_app,
host=host,
port=port,
log_level=log_level,
)
server = uvicorn.Server(config)
await server.serve()
if workers > 1:
# uvicorn requires an import string (not an app object) for multi-worker mode.
# Set env vars so each worker's create_serve_app() factory can reconstruct config.
# The parent's in-memory app is never passed to workers — each worker calls
# create_serve_app() fresh, so we skip building the app here.
from lfx.cli.serve_app import (
_SERVE_FLOW_DIR_ENV,
_SERVE_NO_ENV_FALLBACK_ENV,
_SERVE_STARTUP_PATHS_ENV,
)
os.environ[_SERVE_FLOW_DIR_ENV] = str(flow_dir) if flow_dir else ""
os.environ[_SERVE_NO_ENV_FALLBACK_ENV] = "1" if no_env_fallback else "0"
# When flow_dir is set, startup flows are already in the store (written by
# _build_serve_registry above) so workers load them via warm_from_store().
# When flow_dir is NOT set, workers must re-read the original files.
startup_paths_for_workers: list[str] = []
if not flow_dir:
if script_paths:
startup_paths_for_workers = [str(Path(p).resolve()) for p in script_paths]
elif temp_file_to_cleanup:
startup_paths_for_workers = [temp_file_to_cleanup]
os.environ[_SERVE_STARTUP_PATHS_ENV] = json.dumps(startup_paths_for_workers)
try:
uvicorn.run(
"lfx.cli.serve_app:create_serve_app",
host=host,
port=port,
workers=workers,
log_level=log_level,
factory=True,
)
finally:
# Only remove the keys we set above — a prefix sweep would also delete
# any LFX_SERVE_* var the operator intentionally exported before launch.
for k in (_SERVE_FLOW_DIR_ENV, _SERVE_NO_ENV_FALLBACK_ENV, _SERVE_STARTUP_PATHS_ENV):
os.environ.pop(k, None)
else:
serve_app = create_multi_serve_app(registry=registry)
uvicorn.run(serve_app, host=host, port=port, workers=1, log_level=log_level)
except KeyboardInterrupt:
verbose_print("\n👋 Server stopped")
verbose_print("\nServer stopped")
raise typer.Exit(0) from None
except Exception as e:
verbose_print(f"Failed to start server: {e}")
verbose_print(f"Failed to start server: {e}")
raise typer.Exit(1) from e
finally:
# Clean up temporary file if created
if temp_file_to_cleanup:
try:
with contextlib.suppress(OSError):
Path(temp_file_to_cleanup).unlink()
verbose_print(f"✓ Cleaned up temporary file: {temp_file_to_cleanup}")
except OSError as e:
verbose_print(f"Warning: Failed to clean up temporary file {temp_file_to_cleanup}: {e}")
async def _load_graph_and_meta(
path: Path,
root_dir: Path,
*,
check_variables: bool,
) -> tuple:
"""Load and prepare one graph, returning (graph, FlowMeta, raw_json | None).
raw_json is the parsed flow dict for .json files; None for .py files
(which cannot be round-tripped to JSON for store persistence).
"""
raw_json: dict | None = None
try:
if path.suffix == ".py":
find_graph_variable(path) # validates a 'graph' variable exists
graph = await load_graph_from_script(path)
else:
raw_json = json.loads(path.read_text(encoding="utf-8"))
graph = load_flow_from_json(raw_json)
except Exception as exc:
msg = f"Failed to load {path.name}: {exc}"
raise ValueError(msg) from exc
graph.prepare()
if check_variables:
from lfx.cli.validation import validate_global_variables_for_env
errors = validate_global_variables_for_env(graph)
if errors:
msg = f"Global variable validation failed for {path.name}: {'; '.join(errors)}"
raise ValueError(msg)
path_flow_id = flow_id_from_path(path, root_dir)
# Prefer the JSON's own id so the startup ID matches what workers reconstruct
# from the store (which also prefers raw_json["id"]). Fall back to the
# path-derived UUID when the JSON has no id (e.g. hand-written flows).
flow_id = (raw_json.get("id") if raw_json else None) or path_flow_id
graph.flow_id = flow_id
meta = FlowMeta(
id=flow_id,
relative_path=str(path.relative_to(root_dir)),
title=(raw_json.get("name") or path.stem) if raw_json else path.stem,
description=(raw_json.get("description")) if raw_json else None,
)
return graph, meta, raw_json
async def _populate_registry(
paths: list[Path],
root_dir: Path,
registry: FlowRegistry,
verbose_print: Callable[[str], None],
*,
check_variables: bool,
) -> None:
"""Load each path into *registry*, collecting errors and raising at the end."""
errors: list[str] = []
for path in paths:
try:
graph, meta, raw_json = await _load_graph_and_meta(path, root_dir, check_variables=check_variables)
registry.add(graph, meta, raw_json=raw_json)
verbose_print(f"Loaded flow '{meta.title}' (id={meta.id})")
except FlowAlreadyRegisteredError:
verbose_print(f"Skipping duplicate flow id={meta.id} from {path.name}")
except Exception as exc: # noqa: BLE001
errors.append(f"{path.name}: {exc}")
if errors:
msg = "Failed to load flows:\n" + "\n".join(f" - {e}" for e in errors)
raise ValueError(msg)
async def build_registry_from_directory(
dir_path: Path,
verbose_print: Callable[[str], None],
*,
check_variables: bool,
no_env_fallback: bool = False,
store: FlowStore | None = None,
) -> FlowRegistry:
"""Build a FlowRegistry by scanning *dir_path* for ``*.json`` files (non-recursive).
Callers that want pre-existing store flows (e.g. from a prior run) to be
reachable are responsible for calling ``registry.warm_from_store()`` after
this returns. ``serve_command`` does this in the right place; calling it
here too would cause double-loading when ``store`` backs the same directory.
"""
from lfx.cli.flow_store import NullFlowStore
json_files = sorted(dir_path.glob("*.json"))
if not json_files:
msg = f"No .json files found in directory: {dir_path}"
raise ValueError(msg)
registry = FlowRegistry(no_env_fallback=no_env_fallback, store=store or NullFlowStore())
await _populate_registry(json_files, dir_path, registry, verbose_print, check_variables=check_variables)
return registry
async def build_registry_from_paths(
paths: list[Path],
verbose_print: Callable[[str], None],
*,
check_variables: bool,
no_env_fallback: bool = False,
store: FlowStore | None = None,
) -> FlowRegistry:
"""Build a FlowRegistry from an explicit list of ``.json`` or ``.py`` paths.
Callers that want pre-existing store flows to be reachable are responsible
for calling ``registry.warm_from_store()`` after this returns.
``serve_command`` does this in the right place.
"""
from lfx.cli.flow_store import NullFlowStore
# Use a shared root so same-named files in different directories get distinct IDs.
common_root = (
Path(os.path.commonpath([str(p) for p in paths])) if len(paths) > 1 else paths[0].parent if paths else Path()
)
registry = FlowRegistry(no_env_fallback=no_env_fallback, store=store or NullFlowStore())
await _populate_registry(paths, common_root, registry, verbose_print, check_variables=check_variables)
return registry

View File

@ -23,6 +23,7 @@ from urllib.parse import urlparse
import httpx
import typer
from lfx.cli.runtime_variables import build_request_variables_from_global_vars
from lfx.cli.script_loader import (
extract_structured_result,
find_graph_variable,
@ -31,6 +32,12 @@ from lfx.cli.script_loader import (
from lfx.load import load_flow_from_json
from lfx.run._defaults import apply_run_defaults, resolve_fallback_to_env_vars
from lfx.schema.schema import InputValueRequest
from lfx.services.variable.request_scope import (
activate_no_env_fallback,
activate_request_variables,
reset_no_env_fallback,
reset_request_variables,
)
if TYPE_CHECKING:
from types import ModuleType
@ -335,6 +342,10 @@ async def execute_graph_with_capture(graph, input_value: str | None, session_id:
fallback_to_env_vars = resolve_fallback_to_env_vars()
scope_vars = build_request_variables_from_global_vars(graph.context.get("request_variables"))
scope_token = activate_request_variables(scope_vars or None)
no_env_fallback_token = activate_no_env_fallback(disabled=bool(graph.context.get("no_env_fallback")))
try:
sys.stdout = captured_stdout
sys.stderr = captured_stderr
@ -347,6 +358,8 @@ async def execute_graph_with_capture(graph, input_value: str | None, session_id:
exc.args = (f"{exc.args[0] if exc.args else str(exc)}\n\nCaptured stderr:\n{error_output}",)
raise
finally:
reset_no_env_fallback(no_env_fallback_token)
reset_request_variables(scope_token)
# Restore original stdout/stderr
sys.stdout = original_stdout
sys.stderr = original_stderr

View File

@ -0,0 +1,149 @@
"""Flow persistence backends for lfx serve.
Three deployment modes — all use the same code path:
- ``NullFlowStore`` (default): in-memory only, no disk writes.
- ``FilesystemFlowStore(directory)``: writes each flow as ``{id}.json``.
Point at ``/tmp/lfx-flows`` for single-pod sharing across uvicorn workers,
or at a PVC mount path for cross-pod sharing.
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Protocol, runtime_checkable
from lfx.log.logger import logger
@runtime_checkable
class FlowStore(Protocol):
"""Persistence backend for flow JSON.
Implementations must be safe to call from multiple OS processes sharing
the same filesystem (i.e. all writes must be atomic).
"""
def write(self, flow_id: str, flow_json: dict) -> None:
"""Persist *flow_json* under *flow_id*. Overwrites if already present."""
...
def read(self, flow_id: str) -> dict | None:
"""Return the stored JSON dict for *flow_id*, or ``None`` if absent."""
...
def delete(self, flow_id: str) -> bool:
"""Remove *flow_id* from the store. Returns True if it existed."""
...
def list_ids(self) -> list[str]:
"""Return all stored flow IDs."""
...
def exists(self, flow_id: str) -> bool:
"""Return True if the store has a record for *flow_id*."""
...
@property
def is_persistent(self) -> bool:
"""True if writes are visible to other processes (e.g. filesystem store)."""
...
class NullFlowStore:
"""No-op store — flows live in worker memory only (default)."""
def write(self, _flow_id: str, _flow_json: dict) -> None:
pass
def read(self, _flow_id: str) -> dict | None:
return None
def delete(self, _flow_id: str) -> bool:
return False
def list_ids(self) -> list[str]:
return []
def exists(self, _flow_id: str) -> bool:
return False
@property
def is_persistent(self) -> bool:
return False
class FilesystemFlowStore:
"""Filesystem-backed store.
Each flow is persisted as ``{directory}/{flow_id}.json``. Writes are
atomic (write-to-tmp then replace) so concurrent readers in other workers
never see a partial file.
Use ``/tmp/lfx-flows`` for single-pod worker sharing or a PVC mount path
for cross-pod sharing — the implementation is identical either way.
"""
def __init__(self, directory: Path) -> None:
self._dir = Path(directory)
self._dir.mkdir(parents=True, exist_ok=True)
def _safe_path(self, flow_id: str) -> Path:
"""Return the path for *flow_id*, raising ValueError for unsafe IDs."""
if "/" in flow_id or "\\" in flow_id or flow_id.startswith("."):
msg = f"Invalid flow_id: {flow_id!r}"
raise ValueError(msg)
path = self._dir / f"{flow_id}.json"
try:
path.resolve().relative_to(self._dir.resolve())
except ValueError as exc:
msg = f"Invalid flow_id: {flow_id!r}"
raise ValueError(msg) from exc
return path
def write(self, flow_id: str, flow_json: dict) -> None:
target = self._safe_path(flow_id)
tmp = self._dir / f"{flow_id}.{uuid.uuid4().hex}.tmp"
try:
tmp.write_text(json.dumps(flow_json), encoding="utf-8")
tmp.replace(target)
except OSError:
# Don't leak the partial temp file (e.g. disk full mid-write); list_ids()
# only globs *.json so an orphaned *.tmp would silently accumulate.
tmp.unlink(missing_ok=True)
raise
def read(self, flow_id: str) -> dict | None:
path = self._safe_path(flow_id)
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
# A corrupted/unreadable file in a shared store (e.g. an external writer
# killed mid-write, or PVC corruption) must not take down the worker.
# Treat it as absent (logged) so warm_from_store/list_metas skip it and
# every other flow keeps serving, instead of raising into a 500/startup crash.
logger.warning("Skipping unreadable flow store file %s: %r", path.name, exc)
return None
def delete(self, flow_id: str) -> bool:
path = self._safe_path(flow_id)
try:
path.unlink()
except FileNotFoundError:
return False
else:
return True
def list_ids(self) -> list[str]:
return [p.stem for p in self._dir.glob("*.json")]
def exists(self, flow_id: str) -> bool:
return self._safe_path(flow_id).exists()
@property
def is_persistent(self) -> bool:
return True

View File

@ -49,7 +49,7 @@ def _check_langchain_version_compatibility(error_message: str) -> str | None:
@partial(syncify, raise_sync_error=False)
async def run(
script_path: Path | None = typer.Argument( # noqa: B008
script_path: Path | None = typer.Argument(
None, help="Path to the Python script (.py) or JSON flow (.json) containing a graph"
),
input_value: str | None = typer.Argument(None, help="Input value to pass to the graph"),

View File

@ -0,0 +1,70 @@
"""Normalize runtime ``global_vars`` payloads (WXO ADK / TRM contract)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from lfx.log.logger import logger
from lfx.services.variable.request_scope import normalize_parsed_variables
if TYPE_CHECKING:
from lfx.graph.graph.base import Graph
_LANGFLOW_REQUEST_VARIABLES_KEY = "LANGFLOW_REQUEST_VARIABLES"
def build_request_variables_from_global_vars(global_vars: dict[str, str] | None) -> dict[str, str]:
"""Flatten TRM-style ``global_vars`` into a single lookup map.
TRM sends:
- ``LANGFLOW_REQUEST_VARIABLES``: JSON blob of merged context + connection_details
- raw credential keys (e.g. ``access_token``, ``wxo_github_access_token``)
- ``x-langflow-global-var-*`` aliases
Parsed JSON is applied first; explicit keys in *global_vars* (except the JSON
blob key itself) override on collision. Under the TRM contract connection_details
is sent as raw keys and context inside the JSON blob, so connection_details wins.
"""
if not global_vars:
return {}
merged: dict[str, str] = {}
raw_json = global_vars.get(_LANGFLOW_REQUEST_VARIABLES_KEY)
if raw_json:
try:
parsed = json.loads(raw_json)
except json.JSONDecodeError as exc:
logger.warning(
f"Failed to parse {_LANGFLOW_REQUEST_VARIABLES_KEY} JSON ({exc}); "
"credentials carried only in this blob will be unavailable."
)
parsed = None
if isinstance(parsed, dict):
merged.update(normalize_parsed_variables(parsed))
elif parsed is not None:
logger.warning(
f"{_LANGFLOW_REQUEST_VARIABLES_KEY} must be a JSON object, got {type(parsed).__name__}; ignoring blob."
)
for key, value in global_vars.items():
if key == _LANGFLOW_REQUEST_VARIABLES_KEY:
continue
normalized = key.strip() if isinstance(key, str) else ""
if normalized:
merged[normalized] = str(value)
return merged
def apply_global_vars_to_graph(graph: Graph, global_vars: dict[str, str] | None) -> None:
"""Inject *global_vars* into ``graph.context['request_variables']``."""
if not global_vars:
return
if "request_variables" not in graph.context:
graph.context["request_variables"] = {}
# Flatten the LANGFLOW_REQUEST_VARIABLES blob here so the no-DB load_from_db path
# (load_from_env_vars reads graph.context) resolves the same names as the
# VariableService/ContextVar path (common.py) — otherwise a caller sending a credential
# only inside the JSON blob would resolve on one path and return None on the other.
graph.context["request_variables"].update(build_request_variables_from_global_vars(global_vars))

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
import os
from mem0 import Memory, MemoryClient
from lfx.base.memory.model import LCChatMemoryComponent
@ -87,12 +85,27 @@ class Mem0MemoryComponent(LCChatMemoryComponent):
"""Initializes a Mem0 memory instance based on provided configuration and API keys."""
# Check if we're in Astra cloud environment and raise an error if we are.
raise_error_if_astra_cloud_disable_component(disable_component_in_astra_cloud_msg)
# Pass the OpenAI key through mem0's config instead of writing os.environ.
# os.environ is process-global and persists across requests, so in a shared
# serving process (e.g. lfx serve) one caller's key would leak into other
# concurrent requests. mem0's OpenAI llm/embedder read config.api_key before
# falling back to OPENAI_API_KEY, so config injection keeps it request-scoped.
config = dict(self.mem0_config) if self.mem0_config else {}
if self.openai_api_key:
os.environ["OPENAI_API_KEY"] = self.openai_api_key
for section in ("llm", "embedder"):
sec = dict(config.get(section) or {})
if sec.get("provider", "openai") != "openai":
continue # the OpenAI key is irrelevant to non-OpenAI providers
sec["provider"] = "openai"
sec_config = dict(sec.get("config") or {})
sec_config.setdefault("api_key", self.openai_api_key)
sec["config"] = sec_config
config[section] = sec
try:
if not self.mem0_api_key:
return Memory.from_config(config_dict=dict(self.mem0_config)) if self.mem0_config else Memory()
return Memory.from_config(config_dict=config) if config else Memory()
if self.mem0_config:
return MemoryClient.from_config(api_key=self.mem0_api_key, config_dict=dict(self.mem0_config))
return MemoryClient(api_key=self.mem0_api_key)

View File

@ -437,8 +437,16 @@ class Component(CustomComponent):
kwargs = dict(config)
kwargs.update(inputs_raw)
new_component = type(self)(**kwargs)
# Register in memo before the recursive deepcopy calls below so reference
# cycles (e.g. components linked through _components) resolve to this same
# copy instead of producing a duplicate.
memo[id(self)] = new_component
new_component._code = self._code
new_component._outputs_map = self._outputs_map
# Must deep-copy so each graph_copy has independent Output objects.
# Output.cache=True by default, and output.value is set during execution.
# A shallow copy causes all concurrent requests to share the same Output objects,
# so the first request's cached output.value is returned to all subsequent requests.
new_component._outputs_map = deepcopy(self._outputs_map, memo)
# Safe deepcopy of inputs
new_inputs = {}
@ -471,13 +479,16 @@ class Component(CustomComponent):
new_inputs[k] = input_copy
new_component._inputs = new_inputs
new_component._edges = self._edges
new_component._components = self._components
# Must deep-copy so each graph_copy has independent component instances.
# Shallow copies here caused all concurrent requests to share the same
# intermediate component objects (e.g. the LLM node), producing identical
# responses for different inputs under concurrent load.
new_component._edges = deepcopy(self._edges, memo)
new_component._components = deepcopy(self._components, memo)
new_component._parameters = dict(self._parameters)
new_component._attributes = dict(self._attributes)
new_component._output_logs = self._output_logs
new_component._logs = self._logs # type: ignore[attr-defined]
memo[id(self)] = new_component
return new_component
def set_class_code(self) -> None:

View File

@ -114,13 +114,14 @@ def convert_kwargs(params):
def load_from_env_vars(params, load_from_db_fields, context=None):
no_env_fallback = bool(context and context.get("no_env_fallback"))
for field in load_from_db_fields:
if field not in params or not params[field]:
continue
variable_name = params[field]
key = None
# Check request_variables in context
# Check request_variables in context first
if context and "request_variables" in context:
request_variables = context["request_variables"]
if variable_name in request_variables:
@ -128,14 +129,20 @@ def load_from_env_vars(params, load_from_db_fields, context=None):
logger.debug(f"Found context override for variable '{variable_name}'")
if key is None:
key = os.getenv(variable_name)
if key:
logger.info(f"Using environment variable {variable_name} for {field}")
if no_env_fallback:
logger.warning(
f"Variable '{variable_name}' not found in request_variables and "
f"env fallback is disabled. Setting to None."
)
else:
logger.error(f"Environment variable {variable_name} is not set.")
params[field] = key if key is not None else None
if key is None:
logger.warning(f"Could not get value for {field}. Setting it to None.")
key = os.getenv(variable_name)
if key:
logger.info(f"Using environment variable {variable_name} for {field}")
else:
logger.error(f"Environment variable {variable_name} is not set.")
logger.warning(f"Could not get value for {field}. Setting it to None.")
params[field] = key
return params
@ -166,6 +173,9 @@ async def update_table_params_with_load_from_db_fields(
context = None
if hasattr(custom_component, "graph") and hasattr(custom_component.graph, "context"):
context = custom_component.graph.context
# Honor the same no-env-fallback contract as load_from_env_vars so a served flow under
# no_env_fallback never resolves table columns from process-wide os.environ.
no_env_fallback = bool(context and context.get("no_env_fallback"))
async with session_scope() as session:
settings_service = get_settings_service()
@ -208,7 +218,7 @@ async def update_table_params_with_load_from_db_fields(
key = request_variables[variable_name]
logger.debug(f"Found context override for variable '{variable_name}'")
if key is None:
if key is None and not no_env_fallback:
key = os.getenv(variable_name)
if key:
logger.info(
@ -229,7 +239,7 @@ async def update_table_params_with_load_from_db_fields(
key = None
# If we couldn't get from database and fallback is enabled, try environment
if fallback_to_env_vars and key is None:
if fallback_to_env_vars and key is None and not no_env_fallback:
key = os.getenv(variable_name)
if key:
logger.info(f"Using environment variable {variable_name} for table column {column_name}")

View File

@ -0,0 +1,75 @@
"""Request-scoped variables for lfx serve (and other in-process runners).
TRM/WXO inject credentials via ``global_vars`` on ``POST /flows/{id}/run`` without
mutating process-wide ``os.environ``. This module holds the active request's flat
lookup table in a ContextVar so :class:`~lfx.services.variable.service.VariableService`
can resolve the same names as the ``lfx run`` subprocess path.
A second ContextVar mirrors ``graph.context['no_env_fallback']`` so the service can
honor the same "do not read ``os.environ``" contract that ``load_from_env_vars``
enforces for ``load_from_db`` fields — keeping the two resolution paths consistent.
"""
from __future__ import annotations
import contextvars
import json
_request_variables: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
"lfx_request_variables",
default=None,
)
_no_env_fallback: contextvars.ContextVar[bool] = contextvars.ContextVar(
"lfx_no_env_fallback",
default=False,
)
def normalize_parsed_variables(parsed: dict) -> dict[str, str]:
"""Flatten a parsed JSON variable map to ``str`` values.
Drops ``None`` (so a JSON ``null`` never becomes the truthy string "None" and
masquerades as a credential) and serializes dict/list values as valid JSON
(round-trippable via ``json.loads``) rather than a lossy Python repr. Scalars use ``str``.
"""
return {
str(key): json.dumps(value) if isinstance(value, dict | list) else str(value)
for key, value in parsed.items()
if value is not None
}
def get_active_request_variables() -> dict[str, str] | None:
"""Return the current request's variable map, if any."""
return _request_variables.get()
def is_env_fallback_disabled() -> bool:
"""Return whether the current request forbids falling back to ``os.environ``."""
return _no_env_fallback.get()
def activate_no_env_fallback(*, disabled: bool) -> contextvars.Token[bool]:
"""Bind the no-env-fallback flag for the current async task / thread."""
return _no_env_fallback.set(disabled)
def reset_no_env_fallback(token: contextvars.Token[bool]) -> None:
"""Restore the previous no-env-fallback flag."""
_no_env_fallback.reset(token)
def activate_request_variables(variables: dict[str, str] | None) -> contextvars.Token[dict[str, str] | None]:
"""Bind *variables* for the current async task / thread.
Pass ``None`` to mean "no active scope" — lookups then fall back to the
``LANGFLOW_REQUEST_VARIABLES`` env var. An empty ``{}`` is an *active* empty
scope that suppresses that env fallback, so callers normalizing an empty
result should pass ``... or None`` to preserve it.
"""
return _request_variables.set(variables)
def reset_request_variables(token: contextvars.Token[dict[str, str] | None]) -> None:
"""Restore the previous request scope."""
_request_variables.reset(token)

View File

@ -5,6 +5,11 @@ import os
from lfx.log.logger import logger
from lfx.services.base import Service
from lfx.services.variable.request_scope import (
get_active_request_variables,
is_env_fallback_disabled,
normalize_parsed_variables,
)
class VariableService(Service):
@ -30,7 +35,17 @@ class VariableService(Service):
@staticmethod
def _get_request_variables() -> dict[str, str]:
"""Parse request-scoped variables from LANGFLOW_REQUEST_VARIABLES when available."""
"""Request-scoped variables from serve ContextVar or LANGFLOW_REQUEST_VARIABLES env."""
active = get_active_request_variables()
if active is not None:
return active
# LANGFLOW_REQUEST_VARIABLES is a process-wide env var, so reading it is an
# os.environ access. Honor the no-env-fallback contract and skip it when the
# request disables env fallback, keeping the "never reads os.environ" guarantee.
if is_env_fallback_disabled():
return {}
raw = os.getenv("LANGFLOW_REQUEST_VARIABLES")
if not raw:
return {}
@ -42,86 +57,68 @@ class VariableService(Service):
if not isinstance(parsed, dict):
logger.debug("LANGFLOW_REQUEST_VARIABLES must be a JSON object; skipping request-scoped lookup")
return {}
return {str(key): str(value) for key, value in parsed.items()}
return normalize_parsed_variables(parsed)
async def get_variable(self, name: str, **kwargs) -> str | None: # noqa: ARG002
"""Get a variable value.
First checks in-memory cache, then LANGFLOW_REQUEST_VARIABLES, then
environment variables, then ``x-langflow-global-var-*`` normalized keys.
Resolution order (first match wins):
1. In-memory cache (``set_variable``).
2. Request-scoped exact name (serve ContextVar or ``LANGFLOW_REQUEST_VARIABLES``).
3. Request-scoped ``x-langflow-global-var-*`` alias.
4. Environment variable (exact name).
5. Environment variable (``x-langflow-global-var-*`` alias).
Async to match the call signature in custom_component.get_variable
(`await variable_service.get_variable(...)`), which is the path used
by component variable resolution. The lookup itself is sync — no I/O —
but the coroutine wrapper is required so callers can `await` it
regardless of which variable service implementation is registered
(lfx env-fallback vs langflow DB-backed).
Both request-scoped lookups (2, 3) run *before* any process-env fallback (4, 5),
so a credential the caller supplies for this request — in either the exact-name
or the ``x-langflow-global-var-*`` form — always beats a same-named variable left
in the worker's environment, preventing one caller's request from running on an
ambient process credential.
Args:
name: Variable name
**kwargs: Additional arguments (ignored; user_id/field/session
from langflow's call signature are absorbed and not used,
since this implementation has no per-user scope).
Returns:
Variable value or None if not found
When the active request disables env fallback (``graph.context['no_env_fallback']``,
propagated via the request scope), steps 4 and 5 are skipped so resolution
never reads ``os.environ`` — matching ``load_from_env_vars`` for ``load_from_db``
fields and keeping served flows isolated from process-wide credentials.
"""
# Check in-memory first
if name in self._variables:
return self._variables[name]
# Contract-first: prefer request-scoped variables injected by runtime.
request_variables = self._get_request_variables()
if name in request_variables:
logger.debug(f"Variable '{name}' loaded from LANGFLOW_REQUEST_VARIABLES")
logger.debug(f"Variable '{name}' loaded from request-scoped variables")
return request_variables[name]
# Fall back to environment variable
value = os.getenv(name)
if value:
logger.debug(f"Variable '{name}' loaded from environment")
return value
# Fall back to x-langflow-global-var-* aliases
global_alias = self._normalize_global_var_key(name)
value = os.getenv(global_alias)
if value:
logger.debug(f"Variable '{name}' loaded from global alias '{global_alias}'")
return value
if global_alias in request_variables:
logger.debug(f"Variable '{name}' loaded from request-scoped alias '{global_alias}'")
return request_variables[global_alias]
if not is_env_fallback_disabled():
value = os.getenv(name)
if value:
logger.debug(f"Variable '{name}' loaded from environment")
return value
value = os.getenv(global_alias)
if value:
logger.debug(f"Variable '{name}' loaded from global alias '{global_alias}'")
return value
return None
def set_variable(self, name: str, value: str, **kwargs) -> None: # noqa: ARG002
"""Set a variable value (in-memory only).
Args:
name: Variable name
value: Variable value
**kwargs: Additional arguments (ignored in minimal implementation)
"""
"""Set a variable value (in-memory only)."""
self._variables[name] = value
logger.debug(f"Variable '{name}' set (in-memory only)")
def delete_variable(self, name: str, **kwargs) -> None: # noqa: ARG002
"""Delete a variable (from in-memory cache only).
Args:
name: Variable name
**kwargs: Additional arguments (ignored in minimal implementation)
"""
"""Delete a variable (from in-memory cache only)."""
if name in self._variables:
del self._variables[name]
logger.debug(f"Variable '{name}' deleted (from in-memory cache)")
def list_variables(self, **kwargs) -> list[str]: # noqa: ARG002
"""List all variables (in-memory only).
Args:
**kwargs: Additional arguments (ignored in minimal implementation)
Returns:
List of variable names
"""
"""List all variables (in-memory only)."""
return list(self._variables.keys())
async def teardown(self) -> None:

View File

@ -25,9 +25,15 @@ def run_until_complete(coro):
except RuntimeError:
# If there's no event loop, create a new one and run the coroutine
return asyncio.run(coro)
# If there's already a running event loop, we can't call run_until_complete on it
# Instead, we need to run the coroutine in a new thread with a new event loop
# If there's already a running event loop, we can't call run_until_complete on it.
# Instead, run the coroutine in a new thread with a new event loop. Propagate the
# caller's contextvars context so request-scoped state (e.g. lfx serve request
# variables and the no-env-fallback flag) stays visible inside the worker thread;
# a bare ThreadPoolExecutor would otherwise reset every ContextVar to its default.
import concurrent.futures
import contextvars
ctx = contextvars.copy_context()
def run_in_new_loop():
new_loop = asyncio.new_event_loop()
@ -38,5 +44,5 @@ def run_until_complete(coro):
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_new_loop)
future = executor.submit(ctx.run, run_in_new_loop)
return future.result()

View File

@ -0,0 +1,216 @@
"""Behavioral tests for the unified-model credential resolver.
Covers ``get_api_key_for_provider`` and ``get_all_variables_for_provider`` with a
focus on the no-env-fallback contract: when a served flow disables env fallback,
the resolver must not read process-wide ``os.environ`` credentials, while
request-scoped variables (injected via lfx serve ``global_vars``) must still
resolve through VariableService.
No behavior is mocked. Tests that exercise the VariableService path register the
real minimal ``VariableService`` on the global service manager; environment is
managed with pytest's ``monkeypatch``. The service-path tests are ``async`` so the
call runs inside a live event loop, exercising the ``run_until_complete``
worker-thread hop (and its contextvars propagation) end to end.
"""
from __future__ import annotations
from contextlib import contextmanager
import pytest
from lfx.base.models.unified_models import (
get_all_variables_for_provider,
get_api_key_for_provider,
get_model_provider_variable_mapping,
)
from lfx.services.variable.request_scope import (
activate_no_env_fallback,
activate_request_variables,
reset_no_env_fallback,
reset_request_variables,
)
_PROVIDER = "OpenAI"
_USER_ID = "11111111-1111-1111-1111-111111111111"
_MISSING = object()
def _primary_var_name() -> str:
mapping = get_model_provider_variable_mapping()
assert _PROVIDER in mapping, f"expected {_PROVIDER} in provider mapping: {sorted(mapping)}"
return mapping[_PROVIDER]
@contextmanager
def _no_env_fallback():
token = activate_no_env_fallback(disabled=True)
try:
yield
finally:
reset_no_env_fallback(token)
@contextmanager
def _request_scope(variables: dict[str, str] | None):
token = activate_request_variables(variables)
try:
yield
finally:
reset_request_variables(token)
@pytest.fixture
def variable_service_registered():
"""Register the real minimal VariableService on the global manager, then restore.
Standalone lfx has no VariableService factory, so ``get_variable_service()``
returns None by default. Registering the genuine service (not a mock) lets the
credential resolver exercise the request-scoped lookup path for real.
"""
from lfx.services.manager import get_service_manager
from lfx.services.schema import ServiceType
from lfx.services.variable.service import VariableService
manager = get_service_manager()
prev_class = manager.service_classes.get(ServiceType.VARIABLE_SERVICE, _MISSING)
prev_instance = manager.services.get(ServiceType.VARIABLE_SERVICE, _MISSING)
manager.register_service_class(ServiceType.VARIABLE_SERVICE, VariableService, override=True)
manager.services.pop(ServiceType.VARIABLE_SERVICE, None) # force a fresh instance
try:
yield
finally:
manager.services.pop(ServiceType.VARIABLE_SERVICE, None)
manager.service_classes.pop(ServiceType.VARIABLE_SERVICE, None)
if prev_class is not _MISSING:
manager.service_classes[ServiceType.VARIABLE_SERVICE] = prev_class
if prev_instance is not _MISSING:
manager.services[ServiceType.VARIABLE_SERVICE] = prev_instance
# ---------------------------------------------------------------------------
# get_api_key_for_provider — env gating (no VariableService, == standalone lfx)
# ---------------------------------------------------------------------------
def test_literal_key_returned_as_is_regardless_of_flag():
"""A literal key (not an env-var name) is returned verbatim, flag on or off."""
literal = "sk-literal-abc1234567890"
assert get_api_key_for_provider(None, _PROVIDER, literal) == literal
with _no_env_fallback():
assert get_api_key_for_provider(None, _PROVIDER, literal) == literal
def test_primary_key_resolves_from_env_by_default(monkeypatch):
"""No api_key + env fallback enabled: the canonical provider var resolves from os.environ."""
monkeypatch.setenv(_primary_var_name(), "sk-env")
assert get_api_key_for_provider(None, _PROVIDER, None) == "sk-env"
def test_primary_key_env_suppressed_under_no_env_fallback(monkeypatch):
"""No api_key + env fallback disabled: the os.getenv read is skipped -> None."""
monkeypatch.setenv(_primary_var_name(), "sk-env")
with _no_env_fallback():
assert get_api_key_for_provider(None, _PROVIDER, None) is None
def test_named_variable_resolves_from_env_by_default(monkeypatch):
"""api_key given as an env-var NAME resolves that name from os.environ."""
monkeypatch.setenv("MY_OPENAI_KEY", "sk-resolved")
assert get_api_key_for_provider(None, _PROVIDER, "MY_OPENAI_KEY") == "sk-resolved"
def test_named_variable_env_suppressed_under_no_env_fallback(monkeypatch):
"""Env-var-name api_key + fallback disabled + no user: env skipped, unresolved -> None."""
monkeypatch.setenv("MY_OPENAI_KEY", "sk-resolved")
with _no_env_fallback():
# No user_id -> service not consulted; all-caps name resolves to None when unresolved.
assert get_api_key_for_provider(None, _PROVIDER, "MY_OPENAI_KEY") is None
def test_unknown_provider_returns_none():
"""A provider with no variable mapping yields None (no env read attempted)."""
assert get_api_key_for_provider(None, "NoSuchProvider", None) is None
# ---------------------------------------------------------------------------
# get_all_variables_for_provider — env gating (no user_id branch)
# ---------------------------------------------------------------------------
def test_get_all_variables_resolves_env_by_default(monkeypatch):
"""No user_id + env fallback enabled: provider vars are read from os.environ."""
monkeypatch.setenv(_primary_var_name(), "sk-env")
result = get_all_variables_for_provider(None, _PROVIDER)
assert result.get(_primary_var_name()) == "sk-env"
def test_get_all_variables_unknown_provider_returns_empty():
"""A provider with no variables yields an empty dict."""
assert get_all_variables_for_provider(None, "NoSuchProvider") == {}
def test_env_if_allowed_gates_connection_config(monkeypatch):
"""instantiation._env_if_allowed (used for provider URLs/IDs/headers) honors the flag."""
from lfx.base.models.unified_models.instantiation import _env_if_allowed
monkeypatch.setenv("WATSONX_URL", "https://env.example")
assert _env_if_allowed("WATSONX_URL") == "https://env.example"
with _no_env_fallback():
assert _env_if_allowed("WATSONX_URL") is None
# ---------------------------------------------------------------------------
# VariableService path — request-scope resolution + precedence (real service)
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("variable_service_registered")
async def test_request_scope_resolves_under_no_env_fallback(monkeypatch):
"""Flag on + request scope set: the resolver returns the request credential, never env."""
var = _primary_var_name()
monkeypatch.setenv(var, "sk-env-must-not-win")
with _no_env_fallback(), _request_scope({var: "sk-from-request"}):
# Async context -> run_until_complete takes the thread-hop branch; the request
# scope must survive into the worker thread (contextvars propagation).
assert get_api_key_for_provider(_USER_ID, _PROVIDER, None) == "sk-from-request"
@pytest.mark.usefixtures("variable_service_registered")
async def test_request_scope_wins_over_env_on_primary_path(monkeypatch):
"""Primary path checks VariableService before os.environ, so request scope wins (flag off)."""
var = _primary_var_name()
monkeypatch.setenv(var, "sk-env")
with _request_scope({var: "sk-from-request"}):
assert get_api_key_for_provider(_USER_ID, _PROVIDER, None) == "sk-from-request"
@pytest.mark.usefixtures("variable_service_registered")
async def test_named_variable_resolves_from_request_scope_under_no_env_fallback(monkeypatch):
"""Named-variable path + flag on: env skipped, request scope resolves the name."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-must-not-win")
with _no_env_fallback(), _request_scope({"OPENAI_API_KEY": "sk-from-request"}):
assert get_api_key_for_provider(_USER_ID, _PROVIDER, "OPENAI_API_KEY") == "sk-from-request"
@pytest.mark.usefixtures("variable_service_registered")
async def test_named_variable_request_scope_precedes_env_when_fallback_enabled(monkeypatch):
"""Documents current precedence: for an explicitly-named var, VariableService is read first.
Mirrors the primary path — ``_resolve_var_name`` consults VariableService (where the
user's encrypted global variable, and the request scope it rides on, live) before
``os.environ``, so the request-scoped credential wins even with env fallback enabled.
"""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
with _request_scope({"OPENAI_API_KEY": "sk-from-request"}):
assert get_api_key_for_provider(_USER_ID, _PROVIDER, "OPENAI_API_KEY") == "sk-from-request"
@pytest.mark.usefixtures("variable_service_registered")
async def test_get_all_variables_resolves_request_scope_under_no_env_fallback(monkeypatch):
"""Flag on + user_id + request scope: provider vars come from the request, not env."""
var = _primary_var_name()
monkeypatch.setenv(var, "sk-env-must-not-win")
with _no_env_fallback(), _request_scope({var: "sk-from-request"}):
result = get_all_variables_for_provider(_USER_ID, _PROVIDER)
assert result == {var: "sk-from-request"}

View File

@ -212,6 +212,7 @@ class TestGraphExecution:
yield mock_result
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
results, logs = await execute_graph_with_capture(mock_graph, "test input")
@ -233,6 +234,7 @@ class TestGraphExecution:
yield mock_result
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
results, _ = await execute_graph_with_capture(mock_graph, "test input")
@ -240,6 +242,42 @@ class TestGraphExecution:
assert len(results) == 1
assert results[0].message.text == "Message text"
@pytest.mark.asyncio
async def test_execute_graph_activates_request_scope_from_context(self):
"""Activate the request scope + no_env_fallback from graph.context, then reset them.
The flag and variables must be live during execution and cleared afterward so
nothing leaks to the next request. Covers the wiring (graph.context -> ContextVars)
that the serve_app tests skip by mocking out execute_graph_with_capture.
"""
from lfx.services.variable.request_scope import (
get_active_request_variables,
is_env_fallback_disabled,
)
observed: dict[str, object] = {}
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
observed["scope"] = get_active_request_variables()
observed["no_env_fallback"] = is_env_fallback_disabled()
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {
"request_variables": {"access_token": "secret"},
"no_env_fallback": True,
}
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input")
# During execution the scope reflected this request's global_vars + flag.
assert observed["scope"] == {"access_token": "secret"}
assert observed["no_env_fallback"] is True
# After execution both ContextVars are reset (no bleed into the next request).
assert get_active_request_variables() is None
assert is_env_fallback_disabled() is False
@pytest.mark.asyncio
async def test_execute_graph_with_capture_error(self):
"""Test graph execution with error."""
@ -250,6 +288,7 @@ class TestGraphExecution:
yield # This line never executes but makes it an async generator
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start_error
with pytest.raises(RuntimeError, match="Execution failed"):
@ -267,6 +306,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input")
@ -282,6 +322,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input", session_id="fixed-session")
@ -301,6 +342,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
memory_vertex = MagicMock()
memory_vertex.raw_params = {}
@ -323,6 +365,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
pinned_vertex = MagicMock()
pinned_vertex.raw_params = {"session_id": "hardcoded-in-flow"}
@ -347,6 +390,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
mock_graph.user_id = None
@ -363,6 +407,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
mock_graph.user_id = "preset-user-uuid"
@ -384,6 +429,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input")
@ -400,6 +446,7 @@ class TestGraphExecution:
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.context = {}
mock_graph.async_start = mock_async_start
mock_settings = MagicMock()
mock_settings.settings.fallback_to_env_var = False

View File

@ -0,0 +1,143 @@
"""Tests for FlowStore implementations."""
from __future__ import annotations
import pytest
from lfx.cli.flow_store import FilesystemFlowStore, NullFlowStore
class TestNullFlowStore:
def test_write_is_noop(self):
store = NullFlowStore()
store.write("abc", {"name": "test"}) # should not raise
def test_read_always_returns_none(self):
store = NullFlowStore()
store.write("abc", {"name": "test"})
assert store.read("abc") is None
def test_delete_returns_false(self):
assert NullFlowStore().delete("abc") is False
def test_list_ids_empty(self):
assert NullFlowStore().list_ids() == []
class TestFilesystemFlowStore:
def test_write_and_read_roundtrip(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
data = {"name": "My Flow", "data": {"nodes": [], "edges": []}}
store.write("flow-1", data)
result = store.read("flow-1")
assert result == data
def test_read_missing_returns_none(self, tmp_path):
assert FilesystemFlowStore(tmp_path).read("nonexistent") is None
def test_read_corrupt_json_returns_none(self, tmp_path):
"""A corrupted store file must be treated as absent, not raise.
On a shared/PVC store a partially-written or corrupted ``{id}.json`` would
otherwise raise JSONDecodeError into a 500 (or crash warm_from_store at startup).
"""
store = FilesystemFlowStore(tmp_path)
(tmp_path / "broken.json").write_text("{ this is not valid json ", encoding="utf-8")
# list_ids still surfaces it (globs *.json), but read() must contain the damage.
assert "broken" in store.list_ids()
assert store.read("broken") is None
def test_write_leaves_no_temp_files(self, tmp_path):
"""Write must clean up its temp file after the rename."""
store = FilesystemFlowStore(tmp_path)
store.write("flow-1", {"name": "test"})
assert list(tmp_path.glob("*.tmp")) == [], "no temp files should remain after write"
assert (tmp_path / "flow-1.json").exists()
def test_write_is_atomic_under_concurrent_reads(self, tmp_path):
"""Concurrent readers must only ever observe a complete old or new payload.
A reader looping read() while a writer overwrites must never see a partial
file. A non-atomic write (writing into the target in place) would let a
reader see truncated JSON, which read() does not guard against, so
json.loads would raise and surface in the reader thread.
"""
import threading
store = FilesystemFlowStore(tmp_path)
# Large, distinct payloads so a torn write is detectable and a partial
# read would fail json.loads.
payload_a = {"name": "A", "data": {"nodes": [{"i": i} for i in range(2000)]}}
payload_b = {"name": "B", "data": {"edges": [{"j": j} for j in range(2000)]}}
valid = (payload_a, payload_b)
store.write("flow-1", payload_a)
errors: list[Exception] = []
observed: list[dict | None] = []
stop = threading.Event()
def reader() -> None:
try:
while not stop.is_set():
observed.append(store.read("flow-1"))
except Exception as exc:
errors.append(exc)
readers = [threading.Thread(target=reader) for _ in range(4)]
for t in readers:
t.start()
try:
for _ in range(200):
store.write("flow-1", payload_b)
store.write("flow-1", payload_a)
finally:
stop.set()
for t in readers:
t.join()
assert not errors, f"reader saw a corrupt/partial file: {errors[:1]}"
assert observed, "reader never managed to read"
assert all(r in valid for r in observed), "reader observed a non-atomic intermediate state"
def test_delete_existing_returns_true(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
store.write("flow-1", {"name": "test"})
assert store.delete("flow-1") is True
assert store.read("flow-1") is None
def test_delete_missing_returns_false(self, tmp_path):
assert FilesystemFlowStore(tmp_path).delete("nonexistent") is False
def test_list_ids_empty(self, tmp_path):
assert FilesystemFlowStore(tmp_path).list_ids() == []
def test_list_ids_after_write(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
store.write("flow-a", {"name": "A"})
store.write("flow-b", {"name": "B"})
assert sorted(store.list_ids()) == ["flow-a", "flow-b"]
def test_list_ids_excludes_tmp_files(self, tmp_path):
(tmp_path / "partial.json.tmp").write_text("{}")
assert FilesystemFlowStore(tmp_path).list_ids() == []
def test_overwrite_existing(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
store.write("flow-1", {"name": "original"})
store.write("flow-1", {"name": "updated"})
assert store.read("flow-1")["name"] == "updated"
def test_creates_directory_if_missing(self, tmp_path):
nested = tmp_path / "deep" / "nested"
FilesystemFlowStore(nested)
assert nested.is_dir()
def test_rejects_path_traversal_flow_id(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
with pytest.raises(ValueError, match="Invalid flow_id"):
store.write("../escape", {"name": "bad"})
def test_rejects_slash_in_flow_id(self, tmp_path):
store = FilesystemFlowStore(tmp_path)
with pytest.raises(ValueError, match="Invalid flow_id"):
store.write("a/b", {"name": "bad"})

View File

@ -0,0 +1,98 @@
import json
from lfx.cli.runtime_variables import build_request_variables_from_global_vars
from lfx.services.variable.service import VariableService
def test_build_request_variables_parses_langflow_request_variables():
merged = {"access_token": "from-json", "USER_ID": "u1"}
global_vars = {
"LANGFLOW_REQUEST_VARIABLES": json.dumps(merged),
"access_token": "from-raw-override",
"x-langflow-global-var-access-token": "alias-value",
}
flat = build_request_variables_from_global_vars(global_vars)
assert flat["access_token"] == "from-raw-override" # noqa: S105 # test value, not a credential
assert flat["USER_ID"] == "u1"
assert flat["x-langflow-global-var-access-token"] == "alias-value"
def test_build_request_variables_keeps_raw_keys_when_blob_is_malformed():
"""A malformed LANGFLOW_REQUEST_VARIABLES blob is skipped; raw keys still merge."""
global_vars = {
"LANGFLOW_REQUEST_VARIABLES": "{not valid json",
"access_token": "raw-tok",
}
flat = build_request_variables_from_global_vars(global_vars)
assert flat == {"access_token": "raw-tok"}
def test_build_request_variables_ignores_non_dict_blob():
"""A JSON blob that is not an object is ignored; raw keys still merge."""
global_vars = {
"LANGFLOW_REQUEST_VARIABLES": json.dumps(["a", "b"]),
"access_token": "raw-tok",
}
flat = build_request_variables_from_global_vars(global_vars)
assert flat == {"access_token": "raw-tok"}
def test_build_request_variables_coerces_structured_blob_values():
"""JSON null is dropped (never the truthy "None"); dict/list become valid JSON strings."""
global_vars = {
"LANGFLOW_REQUEST_VARIABLES": json.dumps(
{"good": "tok", "null_cred": None, "nested": {"a": 1}, "listy": [1, 2]}
),
}
flat = build_request_variables_from_global_vars(global_vars)
assert flat == {"good": "tok", "nested": '{"a": 1}', "listy": "[1, 2]"}
# Structured values round-trip back through json.loads (a Python repr would not).
assert json.loads(flat["nested"]) == {"a": 1}
def test_normalize_parsed_variables_coercion_rules():
"""Scalars -> str, None dropped, dict/list -> valid JSON (round-trippable)."""
from lfx.services.variable.request_scope import normalize_parsed_variables
out = normalize_parsed_variables({"s": "v", "n": 5, "f": 1.5, "b": True, "drop": None, "d": {"a": 1}, "l": [1, 2]})
assert out == {"s": "v", "n": "5", "f": "1.5", "b": "True", "d": '{"a": 1}', "l": "[1, 2]"}
assert "drop" not in out
assert json.loads(out["d"]) == {"a": 1}
def test_reset_request_variables_restores_previous_scope():
"""Nested activate/reset restores the prior scope, not None."""
from lfx.services.variable.request_scope import (
activate_request_variables,
get_active_request_variables,
reset_request_variables,
)
outer = activate_request_variables({"A": "1"})
try:
inner = activate_request_variables({"A": "2"})
try:
assert get_active_request_variables() == {"A": "2"}
finally:
reset_request_variables(inner)
assert get_active_request_variables() == {"A": "1"}
finally:
reset_request_variables(outer)
assert get_active_request_variables() is None
async def test_variable_service_reads_active_request_scope():
service = VariableService()
from lfx.services.variable.request_scope import activate_request_variables, reset_request_variables
token = activate_request_variables(
{
"wxo_github_access_token": "tok-123",
"x-langflow-global-var-wxo-github-access-token": "tok-123",
}
)
try:
assert await service.get_variable("wxo_github_access_token") == "tok-123"
assert await service.get_variable("access_token") is None
finally:
reset_request_variables(token)

View File

@ -97,78 +97,25 @@ def test_flow_meta():
)
def test_create_multi_serve_app_single_flow(mock_graph, test_flow_meta):
"""Test creating app for single flow."""
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}): # pragma: allowlist secret
app = create_multi_serve_app(
root_dir=Path("/tmp"),
graphs={"test-flow-id": mock_graph},
metas={"test-flow-id": test_flow_meta},
verbose_print=lambda x: None, # noqa: ARG005
)
def test_create_multi_serve_app_unknown_flow_id_returns_404(mock_graph, test_flow_meta):
from lfx.cli.serve_app import FlowRegistry
registry = FlowRegistry()
registry.add(mock_graph, test_flow_meta)
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}): # pragma: allowlist secret
app = create_multi_serve_app(registry=registry)
client = TestClient(app)
# Test health endpoint
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy", "flow_count": 1}
response = client.get("/flows/does-not-exist/info", headers={"x-api-key": "test-key"})
assert response.status_code == 404
# Test run endpoint without auth
response = client.post("/flows/test-flow-id/run", json={"input_value": "test"})
assert response.status_code == 401
# Test run endpoint with auth
response = client.post(
"/flows/test-flow-id/run",
"/flows/does-not-exist/run",
json={"input_value": "test"},
headers={"x-api-key": "test-key"},
)
assert response.status_code == 200
def test_create_multi_serve_app_multiple_flows(mock_graph, test_flow_meta):
"""Test creating app for multiple flows."""
meta2 = FlowMeta(
id="flow-2",
relative_path="flow2.json",
title="Flow 2",
description="Second flow",
)
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}): # pragma: allowlist secret
app = create_multi_serve_app(
root_dir=Path("/tmp"),
graphs={"test-flow-id": mock_graph, "flow-2": mock_graph},
metas={"test-flow-id": test_flow_meta, "flow-2": meta2},
verbose_print=lambda x: None, # noqa: ARG005
)
client = TestClient(app)
# Test flows listing
response = client.get("/flows")
assert response.status_code == 200
flows = response.json()
assert len(flows) == 2
assert any(f["id"] == "test-flow-id" for f in flows)
assert any(f["id"] == "flow-2" for f in flows)
# Test individual flow run
response = client.post(
"/flows/test-flow-id/run",
json={"input_value": "test"},
headers={"x-api-key": "test-key"},
)
assert response.status_code == 200
# Test flow info
response = client.get(
"/flows/test-flow-id/info",
headers={"x-api-key": "test-key"},
)
assert response.status_code == 200
assert response.json()["id"] == "test-flow-id"
assert response.status_code == 404
def test_serve_command_json_file():
@ -186,8 +133,8 @@ def test_serve_command_json_file():
try:
# Mock the necessary dependencies
with (
patch("lfx.cli.commands.load_graph_from_path") as mock_load,
patch("lfx.cli.commands.uvicorn.Server.serve", new=AsyncMock(return_value=None)) as mock_uvicorn,
patch("lfx.cli.commands.load_flow_from_json") as mock_load,
patch("lfx.cli.commands.uvicorn.run") as mock_uvicorn,
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
):
import typer
@ -227,13 +174,9 @@ def test_serve_command_json_file():
assert mock_uvicorn.called
mock_load.assert_called_once()
# Check that the mock was called with the correct arguments
args, kwargs = mock_load.call_args
assert args[0] == Path(temp_path).resolve()
assert args[1] == ".json"
# args[2] is the verbose_print function, which is harder to assert
assert "verbose" in kwargs
assert kwargs["verbose"] is True
# Check that the mock was called with the parsed JSON dict (not a path)
args, _kwargs = mock_load.call_args
assert isinstance(args[0], dict)
finally:
Path(temp_path).unlink()
@ -244,8 +187,8 @@ def test_serve_command_inline_json():
flow_json = '{"nodes": [], "edges": []}'
with (
patch("lfx.cli.commands.load_graph_from_path") as mock_load,
patch("lfx.cli.commands.uvicorn.Server.serve", new=AsyncMock(return_value=None)) as mock_uvicorn,
patch("lfx.cli.commands.load_flow_from_json") as mock_load,
patch("lfx.cli.commands.uvicorn.run") as mock_uvicorn,
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
):
import typer
@ -271,9 +214,791 @@ def test_serve_command_inline_json():
assert mock_uvicorn.called
mock_load.assert_called_once()
# Check that the mock was called with the correct arguments
args, kwargs = mock_load.call_args
assert args[0].suffix == ".json"
assert args[1] == ".json"
assert "verbose" in kwargs
assert kwargs["verbose"] is True
# Check that the mock was called with the parsed JSON dict (not a temp file path)
args, _kwargs = mock_load.call_args
assert isinstance(args[0], dict)
class TestBuildRegistryFromDirectory:
def test_loads_all_json_files(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_directory
flow_data = {"nodes": [], "edges": []}
(tmp_path / "a.json").write_text(json.dumps(flow_data))
(tmp_path / "b.json").write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_directory(tmp_path, lambda _: None, check_variables=False))
assert len(registry) == 2
def test_empty_directory_raises(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_directory
with pytest.raises(ValueError, match=r"No \.json files found"):
asyncio.run(build_registry_from_directory(tmp_path, lambda _: None, check_variables=False))
def test_non_json_files_ignored(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_directory
(tmp_path / "notes.txt").write_text("ignore me")
flow_data = {"nodes": [], "edges": []}
(tmp_path / "flow.json").write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_directory(tmp_path, lambda _: None, check_variables=False))
assert len(registry) == 1
def test_failed_file_raises_with_filename(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_directory
(tmp_path / "bad.json").write_text('{"nodes": [], "edges": []}')
with (
patch("lfx.cli.commands.load_flow_from_json", side_effect=ValueError("corrupt")),
pytest.raises(ValueError, match=r"bad\.json"),
):
asyncio.run(build_registry_from_directory(tmp_path, lambda _: None, check_variables=False))
class TestBuildRegistryFromPaths:
def test_loads_explicit_paths(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_paths
flow_data = {"nodes": [], "edges": []}
p1 = tmp_path / "flow1.json"
p2 = tmp_path / "flow2.json"
p1.write_text(json.dumps(flow_data))
p2.write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_paths([p1, p2], lambda _: None, check_variables=False))
assert len(registry) == 2
def test_failed_path_raises_with_filename(self, tmp_path):
import asyncio
from lfx.cli.commands import build_registry_from_paths
p = tmp_path / "bad.json"
p.write_text('{"nodes": [], "edges": []}')
with (
patch("lfx.cli.commands.load_flow_from_json", side_effect=ValueError("oops")),
pytest.raises(ValueError, match=r"bad\.json"),
):
asyncio.run(build_registry_from_paths([p], lambda _: None, check_variables=False))
def test_same_filename_in_different_dirs_gets_distinct_ids(self, tmp_path):
"""Regression: lfx serve a/flow.json b/flow.json must not collide on ID."""
import asyncio
from lfx.cli.commands import build_registry_from_paths
dir_a = tmp_path / "a"
dir_b = tmp_path / "b"
dir_a.mkdir()
dir_b.mkdir()
flow_data = {"nodes": [], "edges": []}
p1 = dir_a / "flow.json"
p2 = dir_b / "flow.json"
p1.write_text(json.dumps(flow_data))
p2.write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_paths([p1, p2], lambda _: None, check_variables=False))
assert len(registry) == 2, "both flows must be registered with distinct IDs"
class TestServeCommandMultiFlow:
def test_serve_command_with_directory(self, tmp_path):
from lfx.cli.commands import serve_command
flow_data = {"nodes": [], "edges": []}
(tmp_path / "flow1.json").write_text(json.dumps(flow_data))
(tmp_path / "flow2.json").write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.nodes = {}
mock_graph.edges = []
with (
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run"),
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
):
import typer
from typer.testing import CliRunner
app = typer.Typer()
app.command()(serve_command)
runner = CliRunner()
result = runner.invoke(app, [str(tmp_path)])
assert result.exit_code == 0, result.output
def test_serve_command_with_multiple_files(self, tmp_path):
from lfx.cli.commands import serve_command
flow_data = {"nodes": [], "edges": []}
p1 = tmp_path / "flow1.json"
p2 = tmp_path / "flow2.json"
p1.write_text(json.dumps(flow_data))
p2.write_text(json.dumps(flow_data))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.nodes = {}
mock_graph.edges = []
with (
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run"),
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
):
import typer
from typer.testing import CliRunner
app = typer.Typer()
app.command()(serve_command)
runner = CliRunner()
result = runner.invoke(app, [str(p1), str(p2)])
assert result.exit_code == 0, result.output
def test_serve_command_empty_directory_exits_nonzero(self, tmp_path):
from lfx.cli.commands import serve_command
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}): # pragma: allowlist secret
import typer
from typer.testing import CliRunner
app = typer.Typer()
app.command()(serve_command)
runner = CliRunner()
result = runner.invoke(app, [str(tmp_path)])
assert result.exit_code != 0
class TestPythonScriptServe:
def test_load_graph_and_meta_dispatches_to_script_loader_for_py(self, tmp_path):
"""_load_graph_and_meta must call load_graph_from_script for .py files, not load_flow_from_json."""
import asyncio
from lfx.cli.commands import _load_graph_and_meta
script = tmp_path / "my_flow.py"
script.write_text("graph = None")
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
# load_graph_from_script is lazily imported inside _load_graph_and_meta,
# so patch its module-level name directly.
with (
patch("lfx.cli.commands.load_graph_from_script", new=AsyncMock(return_value=mock_graph)) as mock_script,
patch("lfx.cli.commands.find_graph_variable", return_value={"source": "x", "type": "Graph", "line": 1}),
patch("lfx.cli.commands.load_flow_from_json") as mock_json,
):
_graph, meta, raw_json = asyncio.run(_load_graph_and_meta(script, tmp_path, check_variables=False))
mock_json.assert_not_called()
mock_script.assert_called_once_with(script)
assert raw_json is None
assert meta.title == "my_flow"
assert meta.relative_path == "my_flow.py"
def test_serve_command_accepts_py_file(self, tmp_path):
"""Lfx serve my_script.py must not be rejected as an unsupported file type."""
from lfx.cli.commands import serve_command
script = tmp_path / "my_flow.py"
script.write_text("graph = None")
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.nodes = {}
mock_graph.edges = []
with (
patch("lfx.cli.commands.load_graph_from_script", new=AsyncMock(return_value=mock_graph)),
patch("lfx.cli.commands.find_graph_variable", return_value={"type": "assignment", "line": 1}),
patch("lfx.cli.commands.uvicorn.run"),
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
):
import typer
from typer.testing import CliRunner
app = typer.Typer()
app.command()(serve_command)
result = CliRunner().invoke(app, [str(script)])
assert result.exit_code == 0, result.output
def test_serve_command_rejects_unsupported_extension(self, tmp_path):
"""Non-.json/.py files must exit with an error."""
from lfx.cli.commands import serve_command
bad = tmp_path / "flow.txt"
bad.write_text("not a flow")
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}): # pragma: allowlist secret
import typer
from typer.testing import CliRunner
app = typer.Typer()
app.command()(serve_command)
result = CliRunner().invoke(app, [str(bad)])
assert result.exit_code != 0
assert ".json or .py" in result.output
def test_build_registry_from_paths_no_env_fallback_stamps_graphs(tmp_path):
"""build_registry_from_paths with no_env_fallback=True must stamp each graph's context."""
import asyncio
from lfx.cli.commands import build_registry_from_paths
p = tmp_path / "flow.json"
p.write_text(json.dumps({"nodes": [], "edges": []}))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.context = {}
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(
build_registry_from_paths([p], lambda _: None, check_variables=False, no_env_fallback=True)
)
flow_id = registry.list_metas()[0].id
graph, _ = registry.get(flow_id)
assert graph.context.get("no_env_fallback") is True
def test_build_registry_from_paths_default_does_not_stamp(tmp_path):
"""build_registry_from_paths without no_env_fallback must not stamp the context."""
import asyncio
from lfx.cli.commands import build_registry_from_paths
p = tmp_path / "flow.json"
p.write_text(json.dumps({"nodes": [], "edges": []}))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.context = {}
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_paths([p], lambda _: None, check_variables=False))
flow_id = registry.list_metas()[0].id
graph, _ = registry.get(flow_id)
assert not graph.context.get("no_env_fallback")
def test_build_registry_from_directory_no_env_fallback_stamps_graphs(tmp_path):
"""build_registry_from_directory with no_env_fallback=True must stamp each graph's context."""
import asyncio
from lfx.cli.commands import build_registry_from_directory
p = tmp_path / "flow.json"
p.write_text(json.dumps({"nodes": [], "edges": []}))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.context = {}
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(
build_registry_from_directory(tmp_path, lambda _: None, check_variables=False, no_env_fallback=True)
)
flow_id = registry.list_metas()[0].id
graph, _ = registry.get(flow_id)
assert graph.context.get("no_env_fallback") is True
def test_build_registry_from_directory_default_does_not_stamp(tmp_path):
"""build_registry_from_directory without no_env_fallback must not stamp the context."""
import asyncio
from lfx.cli.commands import build_registry_from_directory
p = tmp_path / "flow.json"
p.write_text(json.dumps({"nodes": [], "edges": []}))
mock_graph = MagicMock()
mock_graph.prepare = MagicMock()
mock_graph.flow_id = None
mock_graph.context = {}
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_directory(tmp_path, lambda _: None, check_variables=False))
flow_id = registry.list_metas()[0].id
graph, _ = registry.get(flow_id)
assert not graph.context.get("no_env_fallback")
def test_build_registry_from_paths_passes_raw_json_to_store():
"""build_registry_from_paths must pass raw_json so JSON flows are written to the store."""
import asyncio
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from lfx.cli.commands import build_registry_from_paths
from lfx.cli.flow_store import NullFlowStore
written = {}
class SpyStore(NullFlowStore):
def write(self, flow_id, flow_json):
written[flow_id] = flow_json
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "flow.json"
p.write_text(json.dumps(flow_data))
with patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(
build_registry_from_paths([p], lambda _: None, check_variables=False, store=SpyStore())
)
assert len(written) == 1
flow_id = registry.list_metas()[0].id
assert written[flow_id]["name"] == "Test"
def test_build_registry_from_paths_py_file_skips_store():
""".py flows must not write to the store (no raw JSON round-trip)."""
import asyncio
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from lfx.cli.commands import build_registry_from_paths
from lfx.cli.flow_store import NullFlowStore
written = {}
class SpyStore(NullFlowStore):
def write(self, flow_id, flow_json):
written[flow_id] = flow_json
mock_graph = MagicMock()
mock_graph.context = {}
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "flow.py"
p.write_text("graph = None\n")
with (
patch("lfx.cli.commands.load_graph_from_script", new=AsyncMock(return_value=mock_graph)),
patch("lfx.cli.commands.find_graph_variable", return_value={"source": "x", "type": "Graph", "line": 1}),
):
asyncio.run(build_registry_from_paths([p], lambda _: None, check_variables=False, store=SpyStore()))
assert written == {}
def test_startup_scan_store_flows_accessible_lazily(tmp_path):
"""build_registry_from_paths() does NOT call warm_from_store() — that is serve_command's job.
Pre-existing store flows are NOT eagerly loaded by the builder but are counted
via list_metas() and accessible on first get(). serve_command adds the
warm_from_store() call for single-worker mode after build_registry_from_paths returns.
"""
import asyncio
from unittest.mock import MagicMock, patch
from lfx.cli.commands import build_registry_from_paths
from lfx.cli.flow_store import FilesystemFlowStore
store = FilesystemFlowStore(tmp_path)
raw = {"name": "Pre-existing", "description": None, "data": {"nodes": [], "edges": []}, "id": "pre-existing-id"}
store.write("pre-existing-id", raw)
mock_graph = MagicMock()
mock_graph.context = {}
with patch("lfx.cli.serve_app.load_flow_from_json", return_value=mock_graph):
registry = asyncio.run(build_registry_from_paths([], lambda _: None, check_variables=False, store=store))
# Builder does NOT eagerly pre-warm — that's serve_command's responsibility
assert "pre-existing-id" not in registry._flows
# Counted by len() / list_metas() even before warm_from_store() is called
assert len(registry) == 1
# Accessible via get() which triggers lazy load (simulating serve_command's warm_from_store)
with patch("lfx.cli.serve_app.load_flow_from_json", return_value=mock_graph):
result = registry.get("pre-existing-id")
assert result is not None
assert result[1].title == "Pre-existing"
def test_serve_command_passes_workers_to_uvicorn():
"""--workers N must be forwarded to uvicorn.run as the workers argument."""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from lfx.cli.commands import serve_command
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "flow.json"
p.write_text(json.dumps(flow_data))
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run") as mock_run,
):
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
port=9999,
workers=4,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=None,
stdin=False,
check_variables=False,
no_env_fallback=False,
)
mock_run.assert_called_once()
call_args = mock_run.call_args[0]
call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
assert call_kwargs.get("workers") == 4
# For workers > 1, the app must be the factory import string, not an object
assert call_args[0] == "lfx.cli.serve_app:create_serve_app"
# The factory=True flag is required so uvicorn calls create_serve_app() at
# worker startup, not at request time.
assert call_kwargs.get("factory") is True
def test_serve_command_sets_startup_paths_env_for_multi_worker(tmp_path):
"""serve_command must set LFX_SERVE_STARTUP_PATHS so each worker can reload flows."""
import json
import os
from unittest.mock import MagicMock, patch
from lfx.cli.commands import serve_command
from lfx.cli.serve_app import _SERVE_STARTUP_PATHS_ENV
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
captured_env: dict = {}
def capture_env(*_a, **_kw):
captured_env.update(os.environ)
p = tmp_path / "flow.json"
p.write_text(json.dumps(flow_data))
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run", side_effect=capture_env),
):
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=None,
stdin=False,
check_variables=False,
no_env_fallback=False,
)
assert _SERVE_STARTUP_PATHS_ENV in captured_env, "LFX_SERVE_STARTUP_PATHS must be set before uvicorn.run()"
paths = json.loads(captured_env[_SERVE_STARTUP_PATHS_ENV])
assert len(paths) == 1
assert paths[0].endswith("flow.json"), f"expected flow.json in paths, got {paths}"
# Must be cleaned up after uvicorn exits
assert _SERVE_STARTUP_PATHS_ENV not in os.environ, "LFX_SERVE_STARTUP_PATHS must be cleaned up after server exits"
def test_serve_command_does_not_set_startup_paths_when_flow_dir_set(tmp_path):
"""When --flow-dir is set, LFX_SERVE_STARTUP_PATHS must be empty.
Workers load startup flows via warm_from_store() (parent persisted them to
the store). Sending file paths would cause each worker to re-load and re-write
them redundantly, and would break the .py restriction logic.
"""
import json
import os
from unittest.mock import MagicMock, patch
from lfx.cli.commands import serve_command
from lfx.cli.serve_app import _SERVE_STARTUP_PATHS_ENV
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
captured_env: dict = {}
def capture_env(*_a, **_kw):
captured_env.update(os.environ)
p = tmp_path / "flow.json"
p.write_text(json.dumps(flow_data))
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run", side_effect=capture_env),
):
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=tmp_path / "store", # flow_dir is set
stdin=False,
check_variables=False,
no_env_fallback=False,
)
assert _SERVE_STARTUP_PATHS_ENV in captured_env, "env var must still be set (to empty list)"
paths = json.loads(captured_env[_SERVE_STARTUP_PATHS_ENV])
assert paths == [], f"startup paths must be empty when flow_dir is set, got {paths}"
def test_serve_command_warns_when_workers_gt1_without_flow_dir():
"""--workers > 1 without --flow-dir should emit a warning to stderr."""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from lfx.cli.commands import serve_command
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
stderr_output = []
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "flow.json"
p.write_text(json.dumps(flow_data))
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run"),
patch("typer.echo", side_effect=lambda msg, **kw: stderr_output.append(msg) if kw.get("err") else None),
):
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=None,
stdin=False,
check_variables=False,
no_env_fallback=False,
)
assert any("--flow-dir" in msg for msg in stderr_output), (
f"Expected a warning mentioning --flow-dir, got: {stderr_output}"
)
def test_serve_command_rejects_py_with_multiple_workers(tmp_path):
""".py startup files must be rejected when --workers > 1 (cannot persist to store)."""
import os
from unittest.mock import patch
from lfx.cli.commands import serve_command
script = tmp_path / "my_flow.py"
script.write_text("graph = None")
stderr_output = []
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.uvicorn.run"),
patch("typer.echo", side_effect=lambda msg, **kw: stderr_output.append(msg) if kw.get("err") else None),
):
from click.exceptions import Exit as ClickExit
with pytest.raises(ClickExit):
serve_command(
script_paths=[str(script)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=tmp_path / "flows",
stdin=False,
check_variables=False,
no_env_fallback=False,
)
assert any(".py" in msg and "cannot be used" in msg for msg in stderr_output), stderr_output
def test_serve_command_allows_py_with_multiple_workers_no_flow_dir(tmp_path):
""".py files + --workers > 1 + no --flow-dir must be allowed.
Each worker reloads the .py via LFX_SERVE_STARTUP_PATHS since there is no
store. The error only applies when --flow-dir is set (workers would skip
startup paths and warm_from_store, missing the .py flow entirely).
"""
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
from lfx.cli.commands import serve_command
from lfx.cli.serve_app import _SERVE_STARTUP_PATHS_ENV
script = tmp_path / "my_flow.py"
script.write_text("graph = None")
mock_graph = MagicMock()
mock_graph.context = {}
captured_env: dict = {}
def capture_env(*_a, **_kw):
captured_env.update(os.environ)
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_graph_from_script", new=AsyncMock(return_value=mock_graph)),
patch("lfx.cli.commands.find_graph_variable", return_value={"type": "assignment", "line": 1}),
patch("lfx.cli.commands.uvicorn.run", side_effect=capture_env),
):
# Must NOT raise — .py without flow_dir is allowed for multi-worker
serve_command(
script_paths=[str(script)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=None, # no flow_dir — .py is allowed here
stdin=False,
check_variables=False,
no_env_fallback=False,
)
# LFX_SERVE_STARTUP_PATHS must contain the .py path so workers can reload it
assert _SERVE_STARTUP_PATHS_ENV in captured_env, "LFX_SERVE_STARTUP_PATHS must be set"
paths = json.loads(captured_env[_SERVE_STARTUP_PATHS_ENV])
assert any("my_flow.py" in p for p in paths), f"expected .py path in STARTUP_PATHS, got {paths}"
def test_serve_command_no_warning_when_workers_gt1_with_flow_dir(tmp_path):
"""--workers > 1 WITH --flow-dir must not emit the missing-store warning."""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from lfx.cli.commands import serve_command
flow_data = {"name": "Test", "description": "", "data": {"nodes": [], "edges": []}}
mock_graph = MagicMock()
mock_graph.context = {}
stderr_output = []
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "flow.json"
p.write_text(json.dumps(flow_data))
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-key"}), # pragma: allowlist secret
patch("lfx.cli.commands.load_flow_from_json", return_value=mock_graph),
patch("lfx.cli.commands.uvicorn.run"),
patch("typer.echo", side_effect=lambda msg, **kw: stderr_output.append(msg) if kw.get("err") else None),
):
serve_command(
script_paths=[str(p)],
host="127.0.0.1",
port=9999,
workers=2,
verbose=False,
env_file=None,
log_level="warning",
flow_json=None,
flow_dir=tmp_path / "flows",
stdin=False,
check_variables=False,
no_env_fallback=False,
)
assert not any("--flow-dir" in msg for msg in stderr_output)

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,13 @@
import asyncio
import hashlib
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from lfx.cli.serve_app import FlowMeta, StreamRequest, create_multi_serve_app
from lfx.cli.serve_app import FlowMeta, FlowRegistry, StreamRequest, create_multi_serve_app
from lfx.interface.components import component_cache
@ -124,24 +122,24 @@ def multi_serve_app(mock_graphs, mock_metas, monkeypatch):
"Execution completed successfully",
)
with tempfile.TemporaryDirectory() as temp_dir:
app = create_multi_serve_app(
root_dir=Path(temp_dir), graphs=mock_graphs, metas=mock_metas, verbose_print=lambda _: None
)
registry = FlowRegistry()
for flow_id, graph in mock_graphs.items():
registry.add(graph, mock_metas[flow_id])
app = create_multi_serve_app(registry=registry)
# Override the dependency after app creation
def mock_verify_api_key(query_param: str | None = None, header_param: str | None = None) -> str: # noqa: ARG001
return "test-api-key"
# Override the dependency after app creation
def mock_verify_api_key(query_param: str | None = None, header_param: str | None = None) -> str: # noqa: ARG001
return "test-api-key"
# Import the original dependency
from lfx.cli.serve_app import verify_api_key
# Import the original dependency
from lfx.cli.serve_app import verify_api_key
app.dependency_overrides[verify_api_key] = mock_verify_api_key
app.dependency_overrides[verify_api_key] = mock_verify_api_key
yield app
yield app
# Clean up
app.dependency_overrides.clear()
# Clean up
app.dependency_overrides.clear()
@pytest.fixture
@ -431,12 +429,10 @@ class TestMultiServeStreaming:
monkeypatch.setenv("LANGFLOW_API_KEY", "test-api-key")
mock_graphs["flow1"].raw_graph_data = _blocked_raw_graph()
app = create_multi_serve_app(
root_dir=Path("/tmp"),
graphs=mock_graphs,
metas=mock_metas,
verbose_print=lambda _: None,
)
registry = FlowRegistry()
for flow_id, graph in mock_graphs.items():
registry.add(graph, mock_metas[flow_id])
app = create_multi_serve_app(registry=registry)
from lfx.cli.serve_app import verify_api_key
@ -481,3 +477,48 @@ class TestMultiServeStreaming:
assert response.status_code == 200
assert "custom components are not allowed" in response.text
mock_run_flow.assert_not_called()
@pytest.mark.asyncio
async def test_stream_setup_error_returns_valid_json(self, mock_graphs, mock_metas, monkeypatch):
"""Error messages with special characters must not break the SSE JSON payload."""
monkeypatch.setenv("LANGFLOW_API_KEY", "test-api-key")
registry = FlowRegistry()
for flow_id, graph in mock_graphs.items():
registry.add(graph, mock_metas[flow_id])
app = create_multi_serve_app(registry=registry)
from lfx.cli.serve_app import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-api-key"
# Exception message contains characters that would break hand-crafted JSON
nasty_message = 'failed: "quotes" and\nnewlines'
async with (
LifespanManager(app, startup_timeout=None, shutdown_timeout=None) as manager,
AsyncClient(
transport=ASGITransport(app=manager.app),
base_url="http://testserver/",
http2=True,
) as client,
):
with patch(
"lfx.cli.serve_app.validate_flow_for_current_settings",
side_effect=RuntimeError(nasty_message),
):
response = await client.post(
"/flows/flow1/stream",
json={"input_value": "test"},
headers={"x-api-key": "test-api-key"},
)
assert response.status_code == 200
# Strip the SSE framing and parse as JSON — must not raise
for line in response.text.splitlines():
if line.startswith("data: "):
import json
payload = json.loads(line[len("data: ") :])
assert payload["success"] is False
assert nasty_message in payload["error"]

View File

@ -12,10 +12,9 @@ from lfx.cli.common import flow_id_from_path, load_graph_from_path, validate_scr
from lfx.cli.serve_app import (
ErrorResponse,
FlowMeta,
FlowRegistry,
RunRequest,
RunResponse,
_analyze_graph_structure,
_generate_dynamic_run_description,
create_multi_serve_app,
)
from lfx.graph import Graph
@ -75,89 +74,6 @@ class TestDataModels:
assert error.success is False
class TestGraphAnalysis:
"""Test graph analysis functions."""
def test_analyze_graph_structure_basic(self):
"""Test basic graph structure analysis."""
# Create a mock graph that matches what _analyze_graph_structure expects
mock_graph = Mock()
# Create mock node objects with the expected structure
node1 = Mock()
node1.data = {
"type": "ChatInput",
"display_name": "Chat Input",
"description": "Input component",
"template": {"input_value": {"type": "str"}},
}
node2 = Mock()
node2.data = {
"type": "ChatOutput",
"display_name": "Chat Output",
"description": "Output component",
"template": {"output_value": {"type": "str"}},
}
mock_graph.nodes = {"input-1": node1, "output-1": node2}
# Create mock edges
edge = Mock()
edge.source = "input-1"
edge.target = "output-1"
mock_graph.edges = [edge]
analysis = _analyze_graph_structure(mock_graph)
assert analysis["node_count"] == 2
assert analysis["edge_count"] == 1
assert len(analysis["components"]) == 2
assert isinstance(analysis["input_types"], list)
assert isinstance(analysis["output_types"], list)
def test_analyze_graph_structure_error_handling(self):
"""Test graph analysis with malformed graph."""
mock_graph = Mock()
mock_graph.nodes = {}
mock_graph.edges = []
# Force an exception during analysis
mock_graph.nodes = None
analysis = _analyze_graph_structure(mock_graph)
# Should provide fallback values
assert len(analysis["components"]) == 1
assert analysis["components"][0]["type"] == "Unknown"
assert "text" in analysis["input_types"]
assert "text" in analysis["output_types"]
def test_generate_dynamic_run_description(self):
"""Test dynamic description generation."""
# Create a mock graph for _generate_dynamic_run_description
mock_graph = Mock()
# Mock the analyze function to return expected data
with patch("lfx.cli.serve_app._analyze_graph_structure") as mock_analyze:
mock_analyze.return_value = {
"node_count": 2,
"edge_count": 1,
"components": [{"type": "ChatInput"}, {"type": "ChatOutput"}],
"input_types": ["text"],
"output_types": ["text"],
"entry_points": [{"template": {"input_value": {"type": "str"}}}],
"exit_points": [{"template": {"output_value": {"type": "str"}}}],
}
description = _generate_dynamic_run_description(mock_graph)
assert "Execute the deployed LFX graph" in description
assert "Authentication Required" in description
assert "Example Request" in description
assert "Example Response" in description
class TestCommonFunctions:
"""Test common utility functions."""
@ -255,58 +171,39 @@ def create_real_graph():
class TestFastAPIAppCreation:
"""Test FastAPI application creation."""
def test_create_multi_serve_app_basic(self, tmp_path):
def test_create_multi_serve_app_basic(self):
"""Test basic multi-serve app creation."""
root_dir = tmp_path
graphs = {"test-flow": create_real_graph()}
metas = {"test-flow": FlowMeta(id="test-flow", relative_path="test.json", title="Test Flow")}
meta = FlowMeta(id="test-flow", relative_path="test.json", title="Test Flow")
registry = FlowRegistry()
registry.add(create_real_graph(), meta)
def verbose_print(msg):
pass # Real function
app = create_multi_serve_app(registry=registry)
with patch("lfx.cli.serve_app.verify_api_key"):
app = create_multi_serve_app(root_dir=root_dir, graphs=graphs, metas=metas, verbose_print=verbose_print)
assert app.title.startswith("LFX Multi-Flow Server")
assert "1" in app.title # Should show count
assert app.title.startswith("LFX Multi-Flow Server")
assert "1" in app.title # Should show count
def test_create_multi_serve_app_mismatched_keys(self):
"""Test app creation — registry never has mismatched keys by design; verify basic creation."""
meta = FlowMeta(id="test-flow", relative_path="test.json", title="Test Flow")
registry = FlowRegistry()
registry.add(create_real_graph(), meta)
def test_create_multi_serve_app_mismatched_keys(self, tmp_path):
"""Test app creation with mismatched graph/meta keys."""
root_dir = tmp_path
graphs = {"flow1": create_real_graph()}
metas = {"flow2": FlowMeta(id="flow2", relative_path="test.json", title="Test")}
def verbose_print(msg):
pass # Real function
with pytest.raises(ValueError, match="graphs and metas must contain the same keys"):
create_multi_serve_app(root_dir=root_dir, graphs=graphs, metas=metas, verbose_print=verbose_print)
# Registry always keeps graphs and metas in sync — no mismatch possible
app = create_multi_serve_app(registry=registry)
assert app is not None
class TestFastAPIEndpoints:
"""Test FastAPI endpoints using TestClient."""
def setup_method(self, tmp_path):
def setup_method(self, tmp_path=None): # noqa: ARG002
"""Set up test client with mock data."""
self.root_dir = tmp_path
self.real_graph = create_real_graph()
self.graphs = {"test-flow": self.real_graph}
self.metas = {
"test-flow": FlowMeta(
id="test-flow", relative_path="test.json", title="Test Flow", description="A test flow"
)
}
meta = FlowMeta(id="test-flow", relative_path="test.json", title="Test Flow", description="A test flow")
def verbose_print(msg):
pass # Real function
self.verbose_print = verbose_print
# Create the app first
with patch("lfx.cli.serve_app.verify_api_key"):
self.app = create_multi_serve_app(
root_dir=self.root_dir, graphs=self.graphs, metas=self.metas, verbose_print=self.verbose_print
)
registry = FlowRegistry()
registry.add(self.real_graph, meta)
self.app = create_multi_serve_app(registry=registry)
# Override the dependency for testing
def mock_verify_key():
@ -389,41 +286,41 @@ class TestFastAPIEndpoints:
class TestErrorHandling:
"""Test error handling in various components."""
def test_invalid_json_in_request(self, tmp_path):
def test_invalid_json_in_request(self):
"""Test handling of invalid JSON in requests."""
with patch("lfx.cli.serve_app.verify_api_key", return_value="test-key"):
app = create_multi_serve_app(
root_dir=tmp_path,
graphs={"test": create_real_graph()},
metas={"test": FlowMeta(id="test", relative_path="test.json", title="Test")},
verbose_print=lambda msg: None, # noqa: ARG005
)
client = TestClient(app)
from lfx.cli.serve_app import verify_api_key
response = client.post(
"/flows/test/run",
data="invalid json",
headers={"x-api-key": "test-key", "Content-Type": "application/json"},
)
meta = FlowMeta(id="test", relative_path="test.json", title="Test")
registry = FlowRegistry()
registry.add(create_real_graph(), meta)
app = create_multi_serve_app(registry=registry)
app.dependency_overrides[verify_api_key] = lambda: "test-key"
client = TestClient(app)
assert response.status_code == 422 # Validation error
response = client.post(
"/flows/test/run",
data="invalid json",
headers={"x-api-key": "test-key", "Content-Type": "application/json"},
)
def test_missing_flow_id(self, tmp_path):
assert response.status_code == 422 # Validation error
def test_missing_flow_id(self):
"""Test accessing non-existent flow."""
with patch("lfx.cli.serve_app.verify_api_key", return_value="test-key"):
app = create_multi_serve_app(
root_dir=tmp_path,
graphs={"test": create_real_graph()},
metas={"test": FlowMeta(id="test", relative_path="test.json", title="Test")},
verbose_print=lambda msg: None, # noqa: ARG005
)
client = TestClient(app)
from lfx.cli.serve_app import verify_api_key
response = client.post(
"/flows/nonexistent/run", json={"input_value": "test"}, headers={"x-api-key": "test-key"}
)
meta = FlowMeta(id="test", relative_path="test.json", title="Test")
registry = FlowRegistry()
registry.add(create_real_graph(), meta)
app = create_multi_serve_app(registry=registry)
app.dependency_overrides[verify_api_key] = lambda: "test-key"
client = TestClient(app)
assert response.status_code == 404
response = client.post(
"/flows/nonexistent/run", json={"input_value": "test"}, headers={"x-api-key": "test-key"}
)
assert response.status_code == 404
class TestIntegration:
@ -462,21 +359,19 @@ class TestIntegration:
)
# Test app creation
with patch("lfx.cli.serve_app.verify_api_key", return_value="test-key"):
app = create_multi_serve_app(
root_dir=flow_path.parent,
graphs={flow_id: loaded_graph},
metas={flow_id: meta},
verbose_print=mock_verbose_print,
)
from lfx.cli.serve_app import verify_api_key as _verify_api_key
client = TestClient(app)
registry = FlowRegistry()
registry.add(loaded_graph, meta)
app = create_multi_serve_app(registry=registry)
app.dependency_overrides[_verify_api_key] = lambda: "test-key"
client = TestClient(app)
# Test endpoints
flows_response = client.get("/flows")
assert flows_response.status_code == 200
assert len(flows_response.json()) == 1
# Test endpoints
flows_response = client.get("/flows")
assert flows_response.status_code == 200
assert len(flows_response.json()) == 1
health_response = client.get("/health")
assert health_response.status_code == 200
assert health_response.json()["flow_count"] == 1
health_response = client.get("/health")
assert health_response.status_code == 200
assert health_response.json()["flow_count"] == 1

View File

@ -220,6 +220,28 @@ def test_should_isolate_inputs_when_input_has_non_picklable_value():
assert product_ids == {"P1", "P2"}
def test_deepcopy_preserves_component_reference_cycles():
"""Component.__deepcopy__ must register itself in memo before copying _components.
A feedback loop links components through _components in a cycle (A -> B -> A).
If the memo entry is set only after the recursive deepcopy calls, the cycle is
duplicated into a second copy instead of preserved.
"""
a = SlowLabelComponent(_id="a")
b = SlowLabelComponent(_id="b")
a._components = [b]
b._components = [a]
a_copy = deepcopy(a)
b_copy = a_copy._components[0]
# The cycle must round-trip back to the same copied objects.
assert b_copy._components[0] is a_copy
# And the copies must be distinct from the originals.
assert a_copy is not a
assert b_copy is not b
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,59 @@
"""Tests for the no_env_fallback guard in load_from_env_vars."""
from __future__ import annotations
import os
from unittest.mock import patch
from lfx.interface.initialize.loading import load_from_env_vars
class TestLoadFromEnvVarsNoFallback:
def test_env_fallback_used_when_flag_absent(self):
"""Without the flag, missing request_variables falls back to os.environ."""
params = {"api_key": "MY_SECRET"}
with patch.dict(os.environ, {"MY_SECRET": "env-value"}):
result = load_from_env_vars(params, ["api_key"], context=None)
assert result["api_key"] == "env-value"
def test_env_fallback_used_when_flag_false(self):
"""Explicit no_env_fallback=False behaves the same as absent."""
params = {"api_key": "MY_SECRET"}
with patch.dict(os.environ, {"MY_SECRET": "env-value"}):
result = load_from_env_vars(params, ["api_key"], context={"no_env_fallback": False})
assert result["api_key"] == "env-value"
def test_env_fallback_skipped_when_flag_true(self):
"""With no_env_fallback=True, os.environ is never consulted even if the var is set."""
params = {"api_key": "MY_SECRET"}
with (
patch.dict(os.environ, {"MY_SECRET": "env-value"}),
patch("lfx.interface.initialize.loading.os.getenv") as mock_getenv,
):
result = load_from_env_vars(params, ["api_key"], context={"no_env_fallback": True})
assert result["api_key"] is None
# Only the credential variable must never be looked up — logger internals may call getenv
credential_lookups = [c for c in mock_getenv.call_args_list if c.args and c.args[0] == "MY_SECRET"]
assert not credential_lookups, f"os.getenv('MY_SECRET') must not be called, got: {credential_lookups}"
def test_request_variables_win_even_with_flag_true(self):
"""request_variables always takes priority, even when no_env_fallback=True."""
params = {"api_key": "MY_SECRET"}
context = {
"no_env_fallback": True,
"request_variables": {"MY_SECRET": "override-value"},
}
with patch.dict(os.environ, {"MY_SECRET": "env-value"}):
result = load_from_env_vars(params, ["api_key"], context=context)
assert result["api_key"] == "override-value"
def test_request_variables_win_when_flag_false(self):
"""request_variables takes priority over os.environ even when no_env_fallback=False."""
params = {"api_key": "MY_SECRET"}
context = {
"no_env_fallback": False,
"request_variables": {"MY_SECRET": "override-value"},
}
with patch.dict(os.environ, {"MY_SECRET": "env-value"}):
result = load_from_env_vars(params, ["api_key"], context=context)
assert result["api_key"] == "override-value"

View File

@ -299,6 +299,55 @@ class TestVariableService:
finally:
del os.environ["LANGFLOW_REQUEST_VARIABLES"]
async def test_global_alias_from_request_scope(self, variables):
"""x-langflow-global-var-* aliases resolve from request-scoped variables."""
from lfx.services.variable.request_scope import activate_request_variables, reset_request_variables
token = activate_request_variables({"x-langflow-global-var-access-token": "alias-token"})
try:
assert await variables.get_variable("access_token") == "alias-token"
finally:
reset_request_variables(token)
async def test_request_scope_overrides_env(self, variables):
"""A request-scoped variable wins over an env var of the same name (core feature)."""
from lfx.services.variable.request_scope import activate_request_variables, reset_request_variables
os.environ["SHARED_VAR"] = "env-value"
token = activate_request_variables({"SHARED_VAR": "request-value"})
try:
assert await variables.get_variable("SHARED_VAR") == "request-value"
finally:
reset_request_variables(token)
del os.environ["SHARED_VAR"]
async def test_request_scope_alias_overrides_env(self, variables):
"""A request-scoped x-langflow-global-var-* alias wins over an env var of the same name.
Ensures a caller's per-request credential is never shadowed by an ambient process
credential, regardless of which form (exact name or alias) the caller supplied.
"""
from lfx.services.variable.request_scope import activate_request_variables, reset_request_variables
os.environ["ACCESS_TOKEN"] = "env-token" # noqa: S105
token = activate_request_variables({"x-langflow-global-var-access-token": "alias-token"})
try:
assert await variables.get_variable("ACCESS_TOKEN") == "alias-token"
finally:
reset_request_variables(token)
del os.environ["ACCESS_TOKEN"]
async def test_in_memory_overrides_request_scope(self, variables):
"""An in-memory variable wins over a request-scoped variable of the same name."""
from lfx.services.variable.request_scope import activate_request_variables, reset_request_variables
variables.set_variable("SHARED_VAR", "memory-value")
token = activate_request_variables({"SHARED_VAR": "request-value"})
try:
assert await variables.get_variable("SHARED_VAR") == "memory-value"
finally:
reset_request_variables(token)
@pytest.mark.asyncio
async def test_langflow_request_variables_invalid_json_falls_back(self, variables):
"""Test invalid request variable JSON does not break env fallback."""

View File

@ -0,0 +1,216 @@
"""Isolation guarantees for request-scoped variables (lfx serve concurrency).
These tests verify the two properties that matter for running multiple flows in a
single ``lfx serve`` process:
1. **Per-request isolation** - one request's ``global_vars`` must never be visible
to a concurrently-running request. This is provided by the ContextVar in
:mod:`lfx.services.variable.request_scope`: each asyncio task gets its own
context copy, so activating a scope in one task cannot leak into another.
2. **No accidental env fallback for supplied credentials** - when a request scope
provides a variable, resolution must use it and must not consult ``os.environ``
for that name (so a stale/foreign env var can never shadow a request credential).
"""
from __future__ import annotations
import asyncio
import os
from unittest.mock import patch
from lfx.services.variable.request_scope import (
activate_no_env_fallback,
activate_request_variables,
get_active_request_variables,
reset_no_env_fallback,
reset_request_variables,
)
from lfx.services.variable.service import VariableService
async def test_concurrent_request_scopes_are_isolated():
"""Two concurrent requests each see only their own request-scoped variable."""
service = VariableService()
observed: dict[str, str | None] = {}
async def handle(request_id: str, secret: str) -> None:
token = activate_request_variables({"access_token": secret})
try:
# Yield so the sibling task activates its own scope before we read;
# if the ContextVar leaked across tasks this read would see it.
await asyncio.sleep(0.01)
observed[request_id] = await service.get_variable("access_token")
# Read again after another yield to catch any cross-task overwrite.
await asyncio.sleep(0.01)
observed[f"{request_id}-recheck"] = await service.get_variable("access_token")
finally:
reset_request_variables(token)
await asyncio.gather(handle("A", "secret-A"), handle("B", "secret-B"))
assert observed["A"] == "secret-A"
assert observed["A-recheck"] == "secret-A"
assert observed["B"] == "secret-B"
assert observed["B-recheck"] == "secret-B"
async def test_unscoped_request_does_not_see_a_concurrent_scope():
"""A request with no active scope must not observe another request's live scope."""
service = VariableService()
scope_active = asyncio.Event()
scope_may_exit = asyncio.Event()
observed: dict[str, str | None] = {}
async def scoped() -> None:
token = activate_request_variables({"access_token": "secret-A"})
try:
scope_active.set()
await scope_may_exit.wait() # hold the scope open while the other task reads
finally:
reset_request_variables(token)
async def unscoped() -> None:
await scope_active.wait() # read while scoped()'s scope is definitely active
observed["value"] = await service.get_variable("access_token")
scope_may_exit.set()
await asyncio.gather(scoped(), unscoped())
assert observed["value"] is None
async def test_scope_does_not_leak_after_reset():
"""After reset, the variable is no longer resolvable (request teardown is clean)."""
service = VariableService()
token = activate_request_variables({"access_token": "secret"})
assert await service.get_variable("access_token") == "secret"
reset_request_variables(token)
assert get_active_request_variables() is None
assert await service.get_variable("access_token") is None
async def test_scoped_value_used_without_consulting_env_for_that_name():
"""A request-supplied variable is returned without falling back to os.environ.
Guards against a regression where the env tier runs first (or in addition),
which would let a process env var shadow a per-request credential.
"""
service = VariableService()
token = activate_request_variables({"access_token": "from-request"})
with (
patch.dict(os.environ, {"access_token": "from-env"}),
patch("lfx.services.variable.service.os.getenv") as mock_getenv,
):
try:
assert await service.get_variable("access_token") == "from-request"
finally:
reset_request_variables(token)
name_lookups = [c for c in mock_getenv.call_args_list if c.args and c.args[0] == "access_token"]
assert not name_lookups, f"os.getenv('access_token') must not be consulted, got: {name_lookups}"
async def test_env_fallback_enabled_by_default():
"""Without the no-env-fallback flag, env resolution still works (default preserved)."""
service = VariableService()
with patch.dict(os.environ, {"DEFAULT_SECRET": "from-env"}):
assert await service.get_variable("DEFAULT_SECRET") == "from-env"
async def test_no_env_fallback_suppresses_env_in_variable_service():
"""With env fallback disabled, a variable only in os.environ resolves to None.
Closes the gap where VariableService (model/KB credential path) ignored
``no_env_fallback`` and leaked process env credentials even under --no-env-fallback.
"""
service = VariableService()
scope_token = activate_request_variables({"access_token": "from-request"})
flag_token = activate_no_env_fallback(disabled=True)
with (
patch.dict(os.environ, {"OTHER_SECRET": "from-env"}),
patch("lfx.services.variable.service.os.getenv") as mock_getenv,
):
try:
# Supplied via the request scope -> still resolves.
assert await service.get_variable("access_token") == "from-request"
# Only in process env -> must NOT resolve when fallback is disabled.
assert await service.get_variable("OTHER_SECRET") is None
finally:
reset_no_env_fallback(flag_token)
reset_request_variables(scope_token)
assert not mock_getenv.call_args_list, f"os.getenv must not be consulted, got: {mock_getenv.call_args_list}"
async def test_no_env_fallback_suppresses_langflow_request_variables_env():
"""Env fallback disabled + no active scope must not leak the process LANGFLOW_REQUEST_VARIABLES blob.
Regression: _get_request_variables() read os.getenv("LANGFLOW_REQUEST_VARIABLES")
before the no-env-fallback gate, so an empty-global_vars request under
--no-env-fallback (which binds the scope to None) still resolved credentials
from the process env blob, violating the "never reads os.environ" contract.
"""
service = VariableService()
scope_token = activate_request_variables(None) # None == empty global_vars in serve
flag_token = activate_no_env_fallback(disabled=True)
with patch.dict(os.environ, {"LANGFLOW_REQUEST_VARIABLES": '{"leaked_token": "SHOULD-NOT-LEAK"}'}):
try:
assert await service.get_variable("leaked_token") is None
finally:
reset_no_env_fallback(flag_token)
reset_request_variables(scope_token)
async def test_langflow_request_variables_env_blob_used_when_fallback_enabled():
"""Env blob resolves when fallback is enabled: null dropped, structured -> valid JSON string."""
service = VariableService()
scope_token = activate_request_variables(None)
blob = '{"shared_token": "from-env-blob", "null_cred": null, "nested": {"a": 1}}'
with patch.dict(os.environ, {"LANGFLOW_REQUEST_VARIABLES": blob}):
try:
assert await service.get_variable("shared_token") == "from-env-blob"
# null is dropped (not the truthy string "None"); env/None fallthrough -> None.
assert await service.get_variable("null_cred") is None
# Structured value serialized as valid JSON, round-trippable via json.loads.
assert await service.get_variable("nested") == '{"a": 1}'
finally:
reset_request_variables(scope_token)
async def test_no_env_fallback_flag_is_isolated_per_request():
"""The no-env-fallback flag is per-request.
One request disabling it must not affect a concurrent request that allows
env fallback.
"""
service = VariableService()
observed: dict[str, str | None] = {}
async def strict_request() -> None:
# Active (non-None) scope so resolution never reads LANGFLOW_REQUEST_VARIABLES.
scope = activate_request_variables({"unused": "x"})
flag = activate_no_env_fallback(disabled=True)
try:
await asyncio.sleep(0.01) # let the lenient request run concurrently
observed["strict"] = await service.get_variable("SHARED_ENV")
finally:
reset_no_env_fallback(flag)
reset_request_variables(scope)
async def lenient_request() -> None:
scope = activate_request_variables({"unused": "y"})
flag = activate_no_env_fallback(disabled=False)
try:
await asyncio.sleep(0.01)
observed["lenient"] = await service.get_variable("SHARED_ENV")
finally:
reset_no_env_fallback(flag)
reset_request_variables(scope)
with patch.dict(os.environ, {"SHARED_ENV": "from-env"}):
await asyncio.gather(strict_request(), lenient_request())
assert observed["strict"] is None # env fallback disabled for this request
assert observed["lenient"] == "from-env" # env fallback allowed for this request

View File

@ -0,0 +1,44 @@
"""Tests for ``run_until_complete`` contextvars propagation.
``run_until_complete`` has two branches: ``asyncio.run`` (no running loop) and a
worker-thread fallback (a loop is already running). The fallback must carry the
caller's contextvars into the new thread — lfx serve resolves request-scoped
variables and the no-env-fallback flag via ContextVars, and a bare
``ThreadPoolExecutor`` would otherwise reset them to their defaults.
"""
from __future__ import annotations
import contextvars
from lfx.utils.async_helpers import run_until_complete
_probe_var: contextvars.ContextVar[str] = contextvars.ContextVar("probe_var", default="default")
async def test_run_until_complete_propagates_contextvars_in_thread_fallback():
"""When a loop is already running, the worker-thread branch keeps the caller's ContextVars."""
async def _read() -> str:
return _probe_var.get()
token = _probe_var.set("propagated")
try:
# pytest-asyncio runs this test inside a loop, so run_until_complete takes the
# thread-hop branch. Before the fix this returned "default" (context reset).
assert run_until_complete(_read()) == "propagated"
finally:
_probe_var.reset(token)
def test_run_until_complete_propagates_contextvars_in_run_branch():
"""With no running loop, the asyncio.run branch also sees the caller's ContextVars."""
async def _read() -> str:
return _probe_var.get()
token = _probe_var.set("sync-path")
try:
assert run_until_complete(_read()) == "sync-path"
finally:
_probe_var.reset(token)