Skip to content

Commit 1941383

Browse files
scottcmgclaude
andcommitted
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) <noreply@anthropic.com>
1 parent a93a2ce commit 1941383

5 files changed

Lines changed: 78 additions & 1 deletion

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.81"
3+
version = "2.10.82"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/_cli/_chat/_bridge.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,15 @@ def get_chat_bridge(
525525
"X-UiPath-ConversationId": context.conversation_id,
526526
}
527527

528+
# Conversation owner id (conversationalService.syntheticUserId) that CAS forwards via FpsProperties;
529+
# always sent when present. It's there for RunAsMe=false, where the unattended robot's token
530+
# subject is the robot account rather than the conversation owner, so CAS validates this presented
531+
# id against conversation.user_id on the handshake instead of the token subject. Sent as a header
532+
# (not a query param) to keep it out of access / load-balancer logs.
533+
synthetic_user_id = getattr(context, "synthetic_user_id", None)
534+
if synthetic_user_id:
535+
headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id
536+
528537
return SocketIOChatBridge(
529538
websocket_url=websocket_url,
530539
websocket_path=websocket_path,

packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ def get_voice_bridge(
249249
"X-UiPath-ConversationId": context.conversation_id,
250250
}
251251

252+
# Conversation owner id (conversationalService.syntheticUserId) that CAS forwards via FpsProperties;
253+
# always sent when present. It's there for RunAsMe=false, where the unattended robot's token
254+
# subject is the robot account rather than the conversation owner, so CAS validates this presented
255+
# id against conversation.user_id on the handshake instead of the token subject. Sent as a header
256+
# (not a query param) to keep it out of access / load-balancer logs.
257+
synthetic_user_id = getattr(context, "synthetic_user_id", None)
258+
if synthetic_user_id:
259+
headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id
260+
252261
return VoiceToolCallSession(
253262
url=url,
254263
socketio_path=socketio_path,

packages/uipath/tests/cli/chat/test_bridge.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,33 @@ def test_get_chat_bridge_constructs_correct_headers(
206206
assert "X-UiPath-ConversationId" in bridge.headers
207207
assert bridge.headers["X-UiPath-ConversationId"] == "conv-789"
208208

209+
def test_get_chat_bridge_includes_synthetic_user_id_header_when_set(
210+
self, monkeypatch: pytest.MonkeyPatch
211+
) -> None:
212+
"""Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate."""
213+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant")
214+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token")
215+
216+
context = MockRuntimeContext(conversation_id="conv-789")
217+
context.synthetic_user_id = "owner-guid" # type: ignore[attr-defined]
218+
219+
bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context)))
220+
221+
assert bridge.headers["X-UiPath-Internal-SyntheticUserId"] == "owner-guid"
222+
223+
def test_get_chat_bridge_omits_synthetic_user_id_header_when_absent(
224+
self, monkeypatch: pytest.MonkeyPatch
225+
) -> None:
226+
"""No header is sent when the runtime has no owner id (backward compatible)."""
227+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant")
228+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token")
229+
230+
context = MockRuntimeContext(conversation_id="conv-789")
231+
232+
bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context)))
233+
234+
assert "X-UiPath-Internal-SyntheticUserId" not in bridge.headers
235+
209236
def test_get_chat_bridge_raises_without_uipath_url(
210237
self, monkeypatch: pytest.MonkeyPatch
211238
) -> None:

packages/uipath/tests/cli/chat/test_voice_bridge.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,35 @@ def test_headers_fall_back_to_env_when_context_ids_are_none(
135135

136136
assert bridge._headers["X-UiPath-Internal-TenantId"] == "env-tenant"
137137
assert bridge._headers["X-UiPath-Internal-AccountId"] == "env-org"
138+
139+
def test_includes_synthetic_user_id_header_when_set(
140+
self, monkeypatch: pytest.MonkeyPatch
141+
) -> None:
142+
"""Conversation owner id (from FpsProperties) is sent on the handshake for CAS to validate."""
143+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com")
144+
ctx = MagicMock(
145+
conversation_id="conv-1",
146+
tenant_id="t",
147+
org_id="o",
148+
synthetic_user_id="owner-guid",
149+
)
150+
151+
bridge = get_voice_bridge(ctx, AsyncMock())
152+
153+
assert bridge._headers["X-UiPath-Internal-SyntheticUserId"] == "owner-guid"
154+
155+
def test_omits_synthetic_user_id_header_when_none(
156+
self, monkeypatch: pytest.MonkeyPatch
157+
) -> None:
158+
"""No header is sent when the runtime has no owner id (backward compatible)."""
159+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com")
160+
ctx = MagicMock(
161+
conversation_id="conv-1",
162+
tenant_id="t",
163+
org_id="o",
164+
synthetic_user_id=None,
165+
)
166+
167+
bridge = get_voice_bridge(ctx, AsyncMock())
168+
169+
assert "X-UiPath-Internal-SyntheticUserId" not in bridge._headers

0 commit comments

Comments
 (0)