Skip to content

Commit 95583d1

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 4a1489b commit 95583d1

4 files changed

Lines changed: 71 additions & 0 deletions

File tree

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

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

528+
# Conversation owner id forwarded by CAS via FpsProperties (conversationalService.syntheticUserId).
529+
# CAS validates it against conversation.user_id on the handshake, since the Robot token's own
530+
# subject is the robot account, not the conversation owner. Sent as a header (not a query param)
531+
# to keep it out of access / load-balancer logs.
532+
synthetic_user_id = getattr(context, "synthetic_user_id", None)
533+
if synthetic_user_id:
534+
headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id
535+
528536
return SocketIOChatBridge(
529537
websocket_url=websocket_url,
530538
websocket_path=websocket_path,

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

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

252+
# Conversation owner id forwarded by CAS via FpsProperties (conversationalService.syntheticUserId).
253+
# CAS validates it against conversation.user_id on the handshake, since the Robot token's own
254+
# subject is the robot account, not the conversation owner. Sent as a header (not a query param)
255+
# to keep it out of access / load-balancer logs.
256+
synthetic_user_id = getattr(context, "synthetic_user_id", None)
257+
if synthetic_user_id:
258+
headers["X-UiPath-Internal-SyntheticUserId"] = synthetic_user_id
259+
252260
return VoiceToolCallSession(
253261
url=url,
254262
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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,31 @@ 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", tenant_id="t", org_id="o",
146+
synthetic_user_id="owner-guid",
147+
)
148+
149+
bridge = get_voice_bridge(ctx, AsyncMock())
150+
151+
assert bridge._headers["X-UiPath-Internal-SyntheticUserId"] == "owner-guid"
152+
153+
def test_omits_synthetic_user_id_header_when_none(
154+
self, monkeypatch: pytest.MonkeyPatch
155+
) -> None:
156+
"""No header is sent when the runtime has no owner id (backward compatible)."""
157+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com")
158+
ctx = MagicMock(
159+
conversation_id="conv-1", tenant_id="t", org_id="o",
160+
synthetic_user_id=None,
161+
)
162+
163+
bridge = get_voice_bridge(ctx, AsyncMock())
164+
165+
assert "X-UiPath-Internal-SyntheticUserId" not in bridge._headers

0 commit comments

Comments
 (0)