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..3310920ee 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -518,13 +518,22 @@ 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, } + # 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..31b50e7df 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -206,6 +206,53 @@ 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: + """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 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" },