From 9a95200e681e8361a04fade4b50b7f633f943a64 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Thu, 20 Aug 2026 11:25:18 -0700 Subject: [PATCH 1/3] fix: coerce stringified booleans in fpsProperties from_config assigns fpsProperties onto the context with setattr, which bypasses pydantic validation. Producers that deliver fpsProperties as a string->string map therefore land "false" on a bool-typed field, where it stays a truthy non-empty string and silently inverts the guards reading it. conversationalService.endExchange is the visible case: "false" made the runtime emit the exchange-end event anyway, closing an exchange the caller asked to keep open. endExchange, enableOutputs and runAsMe all map onto bool fields and share the failure mode; booleans meant to be true were unaffected, so it went unnoticed. Parse stringified booleans when the target field is annotated bool, and warn and keep the default for anything unrecognizable rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- src/uipath/runtime/context.py | 39 +++++++++++- tests/test_context.py | 112 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 84af178..8b5e5dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.13.1" +version = "0.13.2" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 575db03..e57b2dd 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -25,6 +25,22 @@ OUTPUT_ARGUMENTS_SUFFIX = ".args" +# Recognized spellings for a stringified boolean. Some producers deliver +# fpsProperties as a string->string map, so a JSON boolean can arrive as text. +_FALSY_STRINGS = frozenset({"false", "0", "no", "off", ""}) +_TRUTHY_STRINGS = frozenset({"true", "1", "yes", "on"}) + + +def _parse_bool_like(value: str) -> bool | None: + """Parse a stringified boolean, returning None when it isn't one.""" + token = value.strip().lower() + if token in _FALSY_STRINGS: + return False + if token in _TRUTHY_STRINGS: + return True + return None + + _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", "debug": "playground", @@ -486,8 +502,29 @@ def from_config( # Handle fpsProperties mapping for config_key, attr_name in fps_mappings.items(): if config_key in fps_config and hasattr(instance, attr_name): + value = fps_config[config_key] + field = cls.model_fields.get(attr_name) + # setattr bypasses validation, so a stringified boolean would be + # stored as-is. "false" is a truthy non-empty string, which + # silently inverts every guard reading the field. + if ( + isinstance(value, str) + and field is not None + and field.annotation is bool + ): + parsed = _parse_bool_like(value) + if parsed is None: + logger.warning( + "Ignoring fpsProperties[%s]=%r: not a recognizable " + "boolean for %s; keeping the default.", + config_key, + value, + attr_name, + ) + continue + value = parsed attributes_set.add(attr_name) - setattr(instance, attr_name, fps_config[config_key]) + setattr(instance, attr_name, value) for _, attr_name in mapping.items(): if attr_name in kwargs and hasattr(instance, attr_name): diff --git a/tests/test_context.py b/tests/test_context.py index d04de55..1cd9501 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -316,6 +316,118 @@ def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> assert ctx.end_exchange is True +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("false", False), + ("False", False), + ("FALSE", False), + ("0", False), + ("no", False), + ("off", False), + ("", False), + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + ("on", True), + ], +) +def test_from_config_coerces_stringified_bool_fps_property( + tmp_path: Path, raw: str, expected: bool +) -> None: + """Stringified booleans must be parsed, not stored raw. + + Some producers deliver fpsProperties as a string->string map, so a boolean + false arrives as "false". Stored raw on a bool field it stays a non-empty + string, which is truthy — silently inverting every guard that reads it. + """ + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + "conversationalService.endExchange": raw, + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is expected + + +def test_from_config_coerces_every_stringified_bool_fps_property( + tmp_path: Path, +) -> None: + """The coercion covers all bool-typed fps keys, not just endExchange.""" + cfg = { + "fpsProperties": { + "conversationalService.endExchange": "false", + "conversationalService.enableOutputs": "false", + "conversationalService.runAsMe": "false", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is False + assert ctx.conversational_outputs_enabled is False + assert ctx.conversational_run_as_me is False + + +def test_from_config_leaves_non_bool_fps_properties_untouched(tmp_path: Path) -> None: + """Only bool-typed targets are coerced; str fields keep their raw value.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "false", + "conversationalService.exchangeId": "0", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.conversation_id == "false" + assert ctx.exchange_id == "0" + + +def test_from_config_keeps_default_for_unparseable_bool_fps_property( + tmp_path: Path, +) -> None: + """An uninterpretable value keeps the field default rather than guessing.""" + cfg = { + "fpsProperties": { + "conversationalService.endExchange": "banana", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is True + + +def test_from_config_still_accepts_real_bool_fps_property(tmp_path: Path) -> None: + """A genuine JSON boolean keeps working unchanged.""" + cfg = { + "fpsProperties": { + "conversationalService.endExchange": False, + "conversationalService.enableOutputs": True, + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is False + assert ctx.conversational_outputs_enabled is True + + def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None: runtime_dir = tmp_path / "runtime" ctx = UiPathRuntimeContext( From 2ce74a22a0839221f589784b1a6a9e8055bc4165 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Thu, 20 Aug 2026 11:29:33 -0700 Subject: [PATCH 2/3] chore: sync uv.lock with the version bump Co-Authored-By: Claude Opus 5 (1M context) --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 1350d74..4aff675 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-08-18T18:26:47.92988Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.1" +version = "0.13.2" source = { editable = "." } dependencies = [ { name = "chardet" }, From 387f70e5c62c3eb5f8fdd741c377476c00277518 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Thu, 20 Aug 2026 11:37:18 -0700 Subject: [PATCH 3/3] fix: narrow the fps bool parser to serializer output Only "true", "false" and "" are parsed. "0"/"1"/"yes"/"no"/"on"/"off" are spellings a JSON serializer never emits for a boolean, so coercing them was guessing at intent; unrecognized values now pass through with the behavior they have always had instead. Also drop the fps value from the warning. It is external input, and SonarCloud flagged interpolating it as a log-injection risk. The key and target attribute come from the internal mapping table and are enough to diagnose. Co-Authored-By: Claude Opus 5 (1M context) --- src/uipath/runtime/context.py | 27 ++++++++++++++++----------- tests/test_context.py | 22 +++++++++++----------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index e57b2dd..f2610e8 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -25,18 +25,20 @@ OUTPUT_ARGUMENTS_SUFFIX = ".args" -# Recognized spellings for a stringified boolean. Some producers deliver -# fpsProperties as a string->string map, so a JSON boolean can arrive as text. -_FALSY_STRINGS = frozenset({"false", "0", "no", "off", ""}) -_TRUTHY_STRINGS = frozenset({"true", "1", "yes", "on"}) +# Spellings a stringified boolean actually arrives as. Some producers deliver +# fpsProperties as a string->string map, so a JSON boolean becomes text. Kept +# deliberately narrow -- only what a serializer emits for a bool, plus the +# empty string -- so nothing else gets second-guessed. +_FALSE_STRINGS = frozenset({"false", ""}) +_TRUE_STRINGS = frozenset({"true"}) def _parse_bool_like(value: str) -> bool | None: """Parse a stringified boolean, returning None when it isn't one.""" token = value.strip().lower() - if token in _FALSY_STRINGS: + if token in _FALSE_STRINGS: return False - if token in _TRUTHY_STRINGS: + if token in _TRUE_STRINGS: return True return None @@ -514,15 +516,18 @@ def from_config( ): parsed = _parse_bool_like(value) if parsed is None: + # Not a spelling we recognize. Pass it through + # untouched rather than guessing at intent. The + # value is external input, so it is kept out of + # the log. logger.warning( - "Ignoring fpsProperties[%s]=%r: not a recognizable " - "boolean for %s; keeping the default.", + "fpsProperties[%s] is not a recognizable boolean " + "for %s; leaving it unchanged.", config_key, - value, attr_name, ) - continue - value = parsed + else: + value = parsed attributes_set.add(attr_name) setattr(instance, attr_name, value) diff --git a/tests/test_context.py b/tests/test_context.py index 1cd9501..e437091 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -322,15 +322,10 @@ def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> ("false", False), ("False", False), ("FALSE", False), - ("0", False), - ("no", False), - ("off", False), ("", False), ("true", True), ("True", True), - ("1", True), - ("yes", True), - ("on", True), + ("TRUE", True), ], ) def test_from_config_coerces_stringified_bool_fps_property( @@ -394,13 +389,18 @@ def test_from_config_leaves_non_bool_fps_properties_untouched(tmp_path: Path) -> assert ctx.exchange_id == "0" -def test_from_config_keeps_default_for_unparseable_bool_fps_property( - tmp_path: Path, +@pytest.mark.parametrize("raw", ["banana", "0", "1", "yes", "no", "off", "on"]) +def test_from_config_passes_through_unrecognized_bool_fps_property( + tmp_path: Path, raw: str ) -> None: - """An uninterpretable value keeps the field default rather than guessing.""" + """Only "true"/"false"/"" are parsed; anything else is left untouched. + + Coercing spellings a serializer never emits for a boolean would be guessing + at intent, so unrecognized values keep the behavior they have always had. + """ cfg = { "fpsProperties": { - "conversationalService.endExchange": "banana", + "conversationalService.endExchange": raw, } } config_path = tmp_path / "uipath.json" @@ -408,7 +408,7 @@ def test_from_config_keeps_default_for_unparseable_bool_fps_property( ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) - assert ctx.end_exchange is True + assert ctx.end_exchange == raw def test_from_config_still_accepts_real_bool_fps_property(tmp_path: Path) -> None: