From 19413836281dc9a9f321aa0d92cbd0b33e4f3a2b Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:47:03 -0700 Subject: [PATCH 1/3] feat: send conversation owner id header on CAS websocket handshake get_chat_bridge / get_voice_bridge forward context.synthetic_user_id as the X-UiPath-Internal-SyntheticUserId handshake header so CAS can validate Unified Runtime Robot connections for RunAsMe=false conversations. No-op when the context field is unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_chat/_bridge.py | 9 ++++++ .../src/uipath/_cli/_chat/_voice_bridge.py | 9 ++++++ packages/uipath/tests/cli/chat/test_bridge.py | 27 ++++++++++++++++ .../tests/cli/chat/test_voice_bridge.py | 32 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ffaa7f881..5b63bd838 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.81" +version = "2.10.82" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 96566e898..698a75ee6 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -525,6 +525,15 @@ def get_chat_bridge( "X-UiPath-ConversationId": context.conversation_id, } + # Conversation owner id (conversationalService.syntheticUserId) that CAS forwards via FpsProperties; + # always sent when present. It's there for RunAsMe=false, where the unattended robot's token + # subject is the robot account rather than the conversation owner, so CAS validates this presented + # id against conversation.user_id on the handshake instead of the token subject. Sent as a header + # (not a query param) to keep it out of access / load-balancer logs. + synthetic_user_id = getattr(context, "synthetic_user_id", None) + if synthetic_user_id: + headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id + return SocketIOChatBridge( websocket_url=websocket_url, websocket_path=websocket_path, diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index 6164b9f3d..1e32f5b45 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -249,6 +249,15 @@ def get_voice_bridge( "X-UiPath-ConversationId": context.conversation_id, } + # Conversation owner id (conversationalService.syntheticUserId) that CAS forwards via FpsProperties; + # always sent when present. It's there for RunAsMe=false, where the unattended robot's token + # subject is the robot account rather than the conversation owner, so CAS validates this presented + # id against conversation.user_id on the handshake instead of the token subject. Sent as a header + # (not a query param) to keep it out of access / load-balancer logs. + synthetic_user_id = getattr(context, "synthetic_user_id", None) + if synthetic_user_id: + headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id + return VoiceToolCallSession( url=url, socketio_path=socketio_path, diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index bbd385def..c2f908c49 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -206,6 +206,33 @@ def test_get_chat_bridge_constructs_correct_headers( assert "X-UiPath-ConversationId" in bridge.headers assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" + def test_get_chat_bridge_includes_synthetic_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + context.synthetic_user_id = "owner-guid" # type: ignore[attr-defined] + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-SyntheticUserId"] == "owner-guid" + + def test_get_chat_bridge_omits_synthetic_user_id_header_when_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext(conversation_id="conv-789") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "X-UiPath-Internal-SyntheticUserId" not in bridge.headers + def test_get_chat_bridge_raises_without_uipath_url( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index b945fb6e3..32c44af1e 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -135,3 +135,35 @@ def test_headers_fall_back_to_env_when_context_ids_are_none( assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant" assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org" + + def test_includes_synthetic_user_id_header_when_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + synthetic_user_id="owner-guid", + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert bridge._headers["X-UiPath-Internal-SyntheticUserId"] == "owner-guid" + + def test_omits_synthetic_user_id_header_when_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No header is sent when the runtime has no owner id (backward compatible).""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + ctx = MagicMock( + conversation_id="conv-1", + tenant_id="t", + org_id="o", + synthetic_user_id=None, + ) + + bridge = get_voice_bridge(ctx, AsyncMock()) + + assert "X-UiPath-Internal-SyntheticUserId" not in bridge._headers From d8bdc97a7df5e4983adb624c6ede4aed49675f70 Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:29:40 -0700 Subject: [PATCH 2/3] chore: update uv.lock for version bump to 2.10.82 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 62ecc13a0..773d25d8a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.81" +version = "2.10.82" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 948cfa3ad9001be67922cfd22d903bfd8f7dcb4f Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:41:22 -0700 Subject: [PATCH 3/3] fix: drop f-string wrapper on chat bridge tenant/account headers f"{context.tenant_id}" yields the truthy string "None" when tenant_id is None, so the `or os.environ.get(...)` env fallback was dead code and the handshake sent a literal "None" id. Use the bare value like the voice bridge already does, and add a regression test for the env fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../uipath/src/uipath/_cli/_chat/_bridge.py | 4 ++-- packages/uipath/tests/cli/chat/test_bridge.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 698a75ee6..3310920ee 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -518,9 +518,9 @@ def get_chat_bridge( # Build headers from context headers = { "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", - "X-UiPath-Internal-TenantId": f"{context.tenant_id}" + "X-UiPath-Internal-TenantId": context.tenant_id or os.environ.get("UIPATH_TENANT_ID", ""), - "X-UiPath-Internal-AccountId": f"{context.org_id}" + "X-UiPath-Internal-AccountId": context.org_id or os.environ.get("UIPATH_ORGANIZATION_ID", ""), "X-UiPath-ConversationId": context.conversation_id, } diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index c2f908c49..31b50e7df 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -206,6 +206,26 @@ def test_get_chat_bridge_constructs_correct_headers( assert "X-UiPath-ConversationId" in bridge.headers assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" + def test_get_chat_bridge_falls_back_to_env_when_tenant_and_org_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Tenant/account headers fall back to env vars when context values are None.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + monkeypatch.setenv("UIPATH_TENANT_ID", "env-tenant") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "env-org") + + context = MockRuntimeContext( + tenant_id=None, # type: ignore[arg-type] + org_id=None, # type: ignore[arg-type] + conversation_id="conv-789", + ) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.headers["X-UiPath-Internal-TenantId"] == "env-tenant" + assert bridge.headers["X-UiPath-Internal-AccountId"] == "env-org" + def test_get_chat_bridge_includes_synthetic_user_id_header_when_set( self, monkeypatch: pytest.MonkeyPatch ) -> None: