From 604534e1b76200fcac7579226f52651aee353490 Mon Sep 17 00:00:00 2001 From: Nick Hollon Date: Fri, 22 May 2026 12:51:37 -0400 Subject: [PATCH] fix(sdk-py): percent-encode caller-supplied identifiers in URL paths (#7893) ## Summary Wraps every caller-supplied identifier (thread_id, assistant_id, run_id, cron_id, checkpoint_id, namespace) interpolated into request URL paths with `urllib.parse.quote(safe="")` via a new `_quote_path_param` helper. All-dot strings are encoded as `%2E` triplets so they aren't collapsed by httpx's client-side path normalization. Helper raises `TypeError` for None/bytes inputs. ## Test plan - [x] `make format` / `make lint` / `make test` from `libs/sdk-py` --- .../sdk-py/langgraph_sdk/_async/assistants.py | 25 +- libs/sdk-py/langgraph_sdk/_async/cron.py | 10 +- libs/sdk-py/langgraph_sdk/_async/runs.py | 29 +- libs/sdk-py/langgraph_sdk/_async/threads.py | 29 +- .../sdk-py/langgraph_sdk/_shared/utilities.py | 35 +- libs/sdk-py/langgraph_sdk/_sync/assistants.py | 25 +- libs/sdk-py/langgraph_sdk/_sync/cron.py | 10 +- libs/sdk-py/langgraph_sdk/_sync/runs.py | 29 +- libs/sdk-py/langgraph_sdk/_sync/threads.py | 29 +- libs/sdk-py/tests/test_path_encoding.py | 448 ++++++++++++++++++ 10 files changed, 602 insertions(+), 67 deletions(-) create mode 100644 libs/sdk-py/tests/test_path_encoding.py diff --git a/libs/sdk-py/langgraph_sdk/_async/assistants.py b/libs/sdk-py/langgraph_sdk/_async/assistants.py index 3620401cb..24d3cfa99 100644 --- a/libs/sdk-py/langgraph_sdk/_async/assistants.py +++ b/libs/sdk-py/langgraph_sdk/_async/assistants.py @@ -8,6 +8,7 @@ from typing import Any, Literal, cast, overload import httpx from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import _quote_path_param from langgraph_sdk.schema import ( Assistant, AssistantSelectField, @@ -84,7 +85,9 @@ class AssistantsClient: ``` """ return await self.http.get( - f"/assistants/{assistant_id}", headers=headers, params=params + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=params, ) async def get_graph( @@ -142,7 +145,9 @@ class AssistantsClient: query_params.update(params) return await self.http.get( - f"/assistants/{assistant_id}/graph", params=query_params, headers=headers + f"/assistants/{_quote_path_param(assistant_id)}/graph", + params=query_params, + headers=headers, ) async def get_schemas( @@ -263,7 +268,9 @@ class AssistantsClient: """ return await self.http.get( - f"/assistants/{assistant_id}/schemas", headers=headers, params=params + f"/assistants/{_quote_path_param(assistant_id)}/schemas", + headers=headers, + params=params, ) async def get_subgraphs( @@ -293,13 +300,13 @@ class AssistantsClient: get_params = {**get_params, **dict(params)} if namespace is not None: return await self.http.get( - f"/assistants/{assistant_id}/subgraphs/{namespace}", + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}", params=get_params, headers=headers, ) else: return await self.http.get( - f"/assistants/{assistant_id}/subgraphs", + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", params=get_params, headers=headers, ) @@ -436,7 +443,7 @@ class AssistantsClient: if description: payload["description"] = description return await self.http.patch( - f"/assistants/{assistant_id}", + f"/assistants/{_quote_path_param(assistant_id)}", json=payload, headers=headers, params=params, @@ -479,7 +486,7 @@ class AssistantsClient: if params: query_params.update(params) await self.http.delete( - f"/assistants/{assistant_id}", + f"/assistants/{_quote_path_param(assistant_id)}", headers=headers, params=query_params or None, ) @@ -686,7 +693,7 @@ class AssistantsClient: if metadata: payload["metadata"] = metadata return await self.http.post( - f"/assistants/{assistant_id}/versions", + f"/assistants/{_quote_path_param(assistant_id)}/versions", json=payload, headers=headers, params=params, @@ -726,7 +733,7 @@ class AssistantsClient: payload: dict[str, Any] = {"version": version} return await self.http.post( - f"/assistants/{assistant_id}/latest", + f"/assistants/{_quote_path_param(assistant_id)}/latest", json=payload, headers=headers, params=params, diff --git a/libs/sdk-py/langgraph_sdk/_async/cron.py b/libs/sdk-py/langgraph_sdk/_async/cron.py index db64a44af..792fba5b0 100644 --- a/libs/sdk-py/langgraph_sdk/_async/cron.py +++ b/libs/sdk-py/langgraph_sdk/_async/cron.py @@ -8,7 +8,7 @@ from datetime import datetime, tzinfo from typing import Any from langgraph_sdk._async.http import HttpClient -from langgraph_sdk._shared.utilities import _resolve_timezone +from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone from langgraph_sdk.schema import ( All, Config, @@ -166,7 +166,7 @@ class CronClient: payload["multitask_strategy"] = multitask_strategy payload = {k: v for k, v in payload.items() if v is not None} return await self.http.post( - f"/threads/{thread_id}/runs/crons", + f"/threads/{_quote_path_param(thread_id)}/runs/crons", json=payload, headers=headers, params=params, @@ -315,7 +315,9 @@ class CronClient: ``` """ - await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params) + await self.http.delete( + f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params + ) async def update( self, @@ -402,7 +404,7 @@ class CronClient: } payload = {k: v for k, v in payload.items() if v is not None} return await self.http.patch( - f"/runs/crons/{cron_id}", + f"/runs/crons/{_quote_path_param(cron_id)}", json=payload, headers=headers, params=params, diff --git a/libs/sdk-py/langgraph_sdk/_async/runs.py b/libs/sdk-py/langgraph_sdk/_async/runs.py index 96f155b53..76782b36c 100644 --- a/libs/sdk-py/langgraph_sdk/_async/runs.py +++ b/libs/sdk-py/langgraph_sdk/_async/runs.py @@ -12,6 +12,7 @@ import httpx from langgraph_sdk._async.http import HttpClient from langgraph_sdk._shared.utilities import ( _get_run_metadata_from_response, + _quote_path_param, _sse_to_v2_dict, ) from langgraph_sdk.schema import ( @@ -337,7 +338,7 @@ class RunsClient: "langsmith_tracer": langsmith_tracing, } endpoint = ( - f"/threads/{thread_id}/runs/stream" + f"/threads/{_quote_path_param(thread_id)}/runs/stream" if thread_id is not None else "/runs/stream" ) @@ -596,7 +597,7 @@ class RunsClient: on_run_created(metadata) return await self.http.post( - f"/threads/{thread_id}/runs" if thread_id else "/runs", + f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs", json=payload, params=params, headers=headers, @@ -821,7 +822,9 @@ class RunsClient: "langsmith_tracer": langsmith_tracing, } endpoint = ( - f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait" + f"/threads/{_quote_path_param(thread_id)}/runs/wait" + if thread_id is not None + else "/runs/wait" ) def on_response(res: httpx.Response): @@ -895,7 +898,9 @@ class RunsClient: if params: query_params.update(params) return await self.http.get( - f"/threads/{thread_id}/runs", params=query_params, headers=headers + f"/threads/{_quote_path_param(thread_id)}/runs", + params=query_params, + headers=headers, ) async def get( @@ -930,7 +935,9 @@ class RunsClient: """ return await self.http.get( - f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, ) async def cancel( @@ -978,14 +985,14 @@ class RunsClient: query_params.update(params) if wait: return await self.http.request_reconnect( - f"/threads/{thread_id}/runs/{run_id}/cancel", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", "POST", params=query_params, headers=headers, ) else: return await self.http.post( - f"/threads/{thread_id}/runs/{run_id}/cancel", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", json=None, params=query_params, headers=headers, @@ -1081,7 +1088,7 @@ class RunsClient: """ return await self.http.request_reconnect( - f"/threads/{thread_id}/runs/{run_id}/join", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join", "GET", headers=headers, params=params, @@ -1136,7 +1143,7 @@ class RunsClient: if params: query_params.update(params) return self.http.stream( - f"/threads/{thread_id}/runs/{run_id}/stream", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream", "GET", params=query_params, headers={ @@ -1177,5 +1184,7 @@ class RunsClient: """ await self.http.delete( - f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, ) diff --git a/libs/sdk-py/langgraph_sdk/_async/threads.py b/libs/sdk-py/langgraph_sdk/_async/threads.py index d43d352a4..7dbe003c9 100644 --- a/libs/sdk-py/langgraph_sdk/_async/threads.py +++ b/libs/sdk-py/langgraph_sdk/_async/threads.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence from typing import Any, Literal, overload from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import _quote_path_param from langgraph_sdk.schema import ( Checkpoint, Json, @@ -90,7 +91,7 @@ class ThreadsClient: if params: query_params.update(params) return await self.http.get( - f"/threads/{thread_id}", + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=query_params or None, ) @@ -254,7 +255,7 @@ class ThreadsClient: if return_minimal: request_headers["Prefer"] = "return=minimal" return await self.http.patch( - f"/threads/{thread_id}", + f"/threads/{_quote_path_param(thread_id)}", json=payload, headers=request_headers or None, params=params, @@ -287,7 +288,9 @@ class ThreadsClient: ``` """ - await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params) + await self.http.delete( + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params + ) async def search( self, @@ -430,7 +433,10 @@ class ThreadsClient: """ return await self.http.post( - f"/threads/{thread_id}/copy", json=None, headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/copy", + json=None, + headers=headers, + params=params, ) async def prune( @@ -586,7 +592,7 @@ class ThreadsClient: """ if checkpoint: return await self.http.post( - f"/threads/{thread_id}/state/checkpoint", + f"/threads/{_quote_path_param(thread_id)}/state/checkpoint", json={"checkpoint": checkpoint, "subgraphs": subgraphs}, headers=headers, params=params, @@ -596,7 +602,7 @@ class ThreadsClient: if params: get_params = {**get_params, **dict(params)} return await self.http.get( - f"/threads/{thread_id}/state/{checkpoint_id}", + f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}", params=get_params, headers=headers, ) @@ -605,7 +611,7 @@ class ThreadsClient: if params: get_params = {**get_params, **dict(params)} return await self.http.get( - f"/threads/{thread_id}/state", + f"/threads/{_quote_path_param(thread_id)}/state", params=get_params, headers=headers, ) @@ -670,7 +676,10 @@ class ThreadsClient: if as_node: payload["as_node"] = as_node return await self.http.post( - f"/threads/{thread_id}/state", json=payload, headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/state", + json=payload, + headers=headers, + params=params, ) async def get_history( @@ -719,7 +728,7 @@ class ThreadsClient: if checkpoint: payload["checkpoint"] = checkpoint return await self.http.post( - f"/threads/{thread_id}/history", + f"/threads/{_quote_path_param(thread_id)}/history", json=payload, headers=headers, params=params, @@ -763,7 +772,7 @@ class ThreadsClient: if params: query_params.update(params) return self.http.stream( - f"/threads/{thread_id}/stream", + f"/threads/{_quote_path_param(thread_id)}/stream", "GET", headers={ **({"Last-Event-ID": last_event_id} if last_event_id else {}), diff --git a/libs/sdk-py/langgraph_sdk/_shared/utilities.py b/libs/sdk-py/langgraph_sdk/_shared/utilities.py index e65c3b237..da2db6437 100644 --- a/libs/sdk-py/langgraph_sdk/_shared/utilities.py +++ b/libs/sdk-py/langgraph_sdk/_shared/utilities.py @@ -8,7 +8,7 @@ import re from collections.abc import Mapping from datetime import tzinfo from typing import TYPE_CHECKING, Any, cast -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx @@ -198,6 +198,39 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]: return {k: v for k, v in d.items() if v is not None} +def _quote_path_param(value: Any) -> str: + """Encode a value for safe interpolation into a request path segment. + + Path segments are encoded with ``safe=""`` so that ``/`` and other reserved + characters are escaped. Standalone dot-segments (``.`` and ``..``) are also + encoded because some URL-handling stacks (including ``httpx``) collapse + them client-side as relative-path traversal before transmission. The value + is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types + directly without changing call sites. + + A properly formed identifier (for example, a standard UUID, which contains + no dots or reserved characters) round-trips through this function + unchanged. + + Raises: + TypeError: If `value` is `None` or a `bytes`/`bytearray` instance. + Coercing those would produce misleading paths (e.g. `/threads/None`), + so surface the caller bug instead. + """ + if value is None: + raise TypeError("path parameter must not be None") + if isinstance(value, (bytes, bytearray)): + raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID") + quoted = quote(str(value), safe="") + # Bare "." or ".." (or any all-dot string) acts as a relative-path segment + # that some HTTP stacks (including ``httpx``) collapse client-side before + # transmission. Encode the dots so the segment becomes opaque to that + # logic. Mixed values like "agent.v1" are unaffected. + if quoted and all(c == "." for c in quoted): + quoted = "%2E" * len(quoted) + return quoted + + _registered_transports: list[httpx.ASGITransport] = [] diff --git a/libs/sdk-py/langgraph_sdk/_sync/assistants.py b/libs/sdk-py/langgraph_sdk/_sync/assistants.py index 272fd306b..af3a63965 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/assistants.py +++ b/libs/sdk-py/langgraph_sdk/_sync/assistants.py @@ -7,6 +7,7 @@ from typing import Any, Literal, cast, overload import httpx +from langgraph_sdk._shared.utilities import _quote_path_param from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk.schema import ( Assistant, @@ -83,7 +84,9 @@ class SyncAssistantsClient: """ return self.http.get( - f"/assistants/{assistant_id}", headers=headers, params=params + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=params, ) def get_graph( @@ -136,7 +139,9 @@ class SyncAssistantsClient: if params: query_params.update(params) return self.http.get( - f"/assistants/{assistant_id}/graph", params=query_params, headers=headers + f"/assistants/{_quote_path_param(assistant_id)}/graph", + params=query_params, + headers=headers, ) def get_schemas( @@ -269,7 +274,9 @@ class SyncAssistantsClient: """ return self.http.get( - f"/assistants/{assistant_id}/schemas", headers=headers, params=params + f"/assistants/{_quote_path_param(assistant_id)}/schemas", + headers=headers, + params=params, ) def get_subgraphs( @@ -297,13 +304,13 @@ class SyncAssistantsClient: get_params = {**get_params, **dict(params)} if namespace is not None: return self.http.get( - f"/assistants/{assistant_id}/subgraphs/{namespace}", + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}", params=get_params, headers=headers, ) else: return self.http.get( - f"/assistants/{assistant_id}/subgraphs", + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", params=get_params, headers=headers, ) @@ -438,7 +445,7 @@ class SyncAssistantsClient: if description: payload["description"] = description return self.http.patch( - f"/assistants/{assistant_id}", + f"/assistants/{_quote_path_param(assistant_id)}", json=payload, headers=headers, params=params, @@ -481,7 +488,7 @@ class SyncAssistantsClient: if params: query_params.update(params) self.http.delete( - f"/assistants/{assistant_id}", + f"/assistants/{_quote_path_param(assistant_id)}", headers=headers, params=query_params or None, ) @@ -685,7 +692,7 @@ class SyncAssistantsClient: if metadata: payload["metadata"] = metadata return self.http.post( - f"/assistants/{assistant_id}/versions", + f"/assistants/{_quote_path_param(assistant_id)}/versions", json=payload, headers=headers, params=params, @@ -724,7 +731,7 @@ class SyncAssistantsClient: payload: dict[str, Any] = {"version": version} return self.http.post( - f"/assistants/{assistant_id}/latest", + f"/assistants/{_quote_path_param(assistant_id)}/latest", json=payload, headers=headers, params=params, diff --git a/libs/sdk-py/langgraph_sdk/_sync/cron.py b/libs/sdk-py/langgraph_sdk/_sync/cron.py index 51310f0e9..24acae5bd 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/cron.py +++ b/libs/sdk-py/langgraph_sdk/_sync/cron.py @@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, tzinfo from typing import Any -from langgraph_sdk._shared.utilities import _resolve_timezone +from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk.schema import ( All, @@ -156,7 +156,7 @@ class SyncCronClient: } payload = {k: v for k, v in payload.items() if v is not None} return self.http.post( - f"/threads/{thread_id}/runs/crons", + f"/threads/{_quote_path_param(thread_id)}/runs/crons", json=payload, headers=headers, params=params, @@ -304,7 +304,9 @@ class SyncCronClient: ``` """ - self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params) + self.http.delete( + f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params + ) def update( self, @@ -391,7 +393,7 @@ class SyncCronClient: } payload = {k: v for k, v in payload.items() if v is not None} return self.http.patch( - f"/runs/crons/{cron_id}", + f"/runs/crons/{_quote_path_param(cron_id)}", json=payload, headers=headers, params=params, diff --git a/libs/sdk-py/langgraph_sdk/_sync/runs.py b/libs/sdk-py/langgraph_sdk/_sync/runs.py index 66323d31b..5d2d17086 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/runs.py +++ b/libs/sdk-py/langgraph_sdk/_sync/runs.py @@ -11,6 +11,7 @@ import httpx from langgraph_sdk._shared.utilities import ( _get_run_metadata_from_response, + _quote_path_param, _sse_to_v2_dict, ) from langgraph_sdk._sync.http import SyncHttpClient @@ -332,7 +333,7 @@ class SyncRunsClient: "langsmith_tracer": langsmith_tracing, } endpoint = ( - f"/threads/{thread_id}/runs/stream" + f"/threads/{_quote_path_param(thread_id)}/runs/stream" if thread_id is not None else "/runs/stream" ) @@ -591,7 +592,7 @@ class SyncRunsClient: on_run_created(metadata) return self.http.post( - f"/threads/{thread_id}/runs" if thread_id else "/runs", + f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs", json=payload, params=params, headers=headers, @@ -825,7 +826,9 @@ class SyncRunsClient: on_run_created(metadata) endpoint = ( - f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait" + f"/threads/{_quote_path_param(thread_id)}/runs/wait" + if thread_id is not None + else "/runs/wait" ) return self.http.request_reconnect( endpoint, @@ -879,7 +882,9 @@ class SyncRunsClient: if params: query_params.update(params) return self.http.get( - f"/threads/{thread_id}/runs", params=query_params, headers=headers + f"/threads/{_quote_path_param(thread_id)}/runs", + params=query_params, + headers=headers, ) def get( @@ -912,7 +917,9 @@ class SyncRunsClient: """ return self.http.get( - f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, ) def cancel( @@ -960,14 +967,14 @@ class SyncRunsClient: query_params.update(params) if wait: return self.http.request_reconnect( - f"/threads/{thread_id}/runs/{run_id}/cancel", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", "POST", json=None, params=query_params, headers=headers, ) return self.http.post( - f"/threads/{thread_id}/runs/{run_id}/cancel", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", json=None, params=query_params, headers=headers, @@ -1063,7 +1070,7 @@ class SyncRunsClient: """ return self.http.request_reconnect( - f"/threads/{thread_id}/runs/{run_id}/join", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join", "GET", headers=headers, params=params, @@ -1117,7 +1124,7 @@ class SyncRunsClient: if params: query_params.update(params) return self.http.stream( - f"/threads/{thread_id}/runs/{run_id}/stream", + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream", "GET", params=query_params, headers={ @@ -1158,5 +1165,7 @@ class SyncRunsClient: """ self.http.delete( - f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/threads.py b/libs/sdk-py/langgraph_sdk/_sync/threads.py index d18aabecb..b7c2ddf5b 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/threads.py +++ b/libs/sdk-py/langgraph_sdk/_sync/threads.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Iterator, Mapping, Sequence from typing import Any, Literal, overload +from langgraph_sdk._shared.utilities import _quote_path_param from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk.schema import ( Checkpoint, @@ -88,7 +89,7 @@ class SyncThreadsClient: if params: query_params.update(params) return self.http.get( - f"/threads/{thread_id}", + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=query_params or None, ) @@ -250,7 +251,7 @@ class SyncThreadsClient: if return_minimal: request_headers["Prefer"] = "return=minimal" return self.http.patch( - f"/threads/{thread_id}", + f"/threads/{_quote_path_param(thread_id)}", json=payload, headers=request_headers or None, params=params, @@ -282,7 +283,9 @@ class SyncThreadsClient: ``` """ - self.http.delete(f"/threads/{thread_id}", headers=headers, params=params) + self.http.delete( + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params + ) def search( self, @@ -421,7 +424,10 @@ class SyncThreadsClient: """ return self.http.post( - f"/threads/{thread_id}/copy", json=None, headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/copy", + json=None, + headers=headers, + params=params, ) def prune( @@ -576,7 +582,7 @@ class SyncThreadsClient: """ if checkpoint: return self.http.post( - f"/threads/{thread_id}/state/checkpoint", + f"/threads/{_quote_path_param(thread_id)}/state/checkpoint", json={"checkpoint": checkpoint, "subgraphs": subgraphs}, headers=headers, params=params, @@ -586,7 +592,7 @@ class SyncThreadsClient: if params: get_params = {**get_params, **dict(params)} return self.http.get( - f"/threads/{thread_id}/state/{checkpoint_id}", + f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}", params=get_params, headers=headers, ) @@ -595,7 +601,7 @@ class SyncThreadsClient: if params: get_params = {**get_params, **dict(params)} return self.http.get( - f"/threads/{thread_id}/state", + f"/threads/{_quote_path_param(thread_id)}/state", params=get_params, headers=headers, ) @@ -657,7 +663,10 @@ class SyncThreadsClient: if as_node: payload["as_node"] = as_node return self.http.post( - f"/threads/{thread_id}/state", json=payload, headers=headers, params=params + f"/threads/{_quote_path_param(thread_id)}/state", + json=payload, + headers=headers, + params=params, ) def get_history( @@ -707,7 +716,7 @@ class SyncThreadsClient: if checkpoint: payload["checkpoint"] = checkpoint return self.http.post( - f"/threads/{thread_id}/history", + f"/threads/{_quote_path_param(thread_id)}/history", json=payload, headers=headers, params=params, @@ -752,7 +761,7 @@ class SyncThreadsClient: if params: query_params.update(params) return self.http.stream( - f"/threads/{thread_id}/stream", + f"/threads/{_quote_path_param(thread_id)}/stream", "GET", headers={ **({"Last-Event-ID": last_event_id} if last_event_id else {}), diff --git a/libs/sdk-py/tests/test_path_encoding.py b/libs/sdk-py/tests/test_path_encoding.py new file mode 100644 index 000000000..4c5fc0d18 --- /dev/null +++ b/libs/sdk-py/tests/test_path_encoding.py @@ -0,0 +1,448 @@ +"""Regression tests for path-segment encoding of caller-supplied identifiers. + +Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths +are encoded so the resulting request addresses the resource the SDK method +indicates, even if the identifier contains characters with special meaning in +URL paths. +""" + +from __future__ import annotations + +import httpx +import pytest + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.client import ( + AssistantsClient, + CronClient, + HttpClient, + RunsClient, + SyncAssistantsClient, + SyncCronClient, + SyncHttpClient, + SyncRunsClient, + SyncThreadsClient, + ThreadsClient, +) + + +class TestQuotePathParam: + """Unit tests for the encoding helper itself.""" + + def test_uuid_round_trips_unchanged(self) -> None: + uuid_value = "550e8400-e29b-41d4-a716-446655440000" + assert _quote_path_param(uuid_value) == uuid_value + + def test_simple_opaque_id_round_trips_unchanged(self) -> None: + assert _quote_path_param("thread_123") == "thread_123" + assert _quote_path_param("asst_abc") == "asst_abc" + + def test_slash_is_encoded(self) -> None: + assert _quote_path_param("foo/bar") == "foo%2Fbar" + + def test_bare_dot_segments_are_encoded(self) -> None: + # All-dot strings are encoded to make them opaque to HTTP stacks that + # collapse "./.." path segments client-side. + assert _quote_path_param(".") == "%2E" + assert _quote_path_param("..") == "%2E%2E" + assert _quote_path_param("...") == "%2E%2E%2E" + # Mixed values that happen to contain dots are not affected. + assert _quote_path_param("agent.v1") == "agent.v1" + # Subsequent ``/`` characters are encoded regardless. + assert _quote_path_param("../bar") == "..%2Fbar" + + def test_full_pivot_payload_is_encoded(self) -> None: + # A caller-supplied identifier that, if interpolated raw, would route + # the request to a different resource type. + payload = "../assistants/abc-123" + encoded = _quote_path_param(payload) + assert encoded == "..%2Fassistants%2Fabc-123" + assert "/" not in encoded + + def test_non_string_values_are_coerced_to_str(self) -> None: + import uuid + + uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert _quote_path_param(uid) == str(uid) + assert _quote_path_param(42) == "42" + + def test_none_value_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="must not be None"): + _quote_path_param(None) + + def test_bytes_value_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="must not be bytes"): + _quote_path_param(b"bytes") + with pytest.raises(TypeError, match="must not be bytes"): + _quote_path_param(bytearray(b"bytes")) + + +def _wire_path(request: httpx.Request) -> str: + """Return the path as it goes on the wire (preserves percent-encoding).""" + return request.url.raw_path.decode("ascii") + + +@pytest.mark.asyncio +class TestAsyncPathEncoding: + """Async-client tests that verify the encoded path actually lands on the wire. + + Note: ``request.url.path`` is the percent-decoded display form. The bytes + that actually go on the wire are in ``request.url.raw_path``; that is what + the server's router sees and what these tests inspect. + """ + + async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("../assistants/abc-123") + + assert len(captured) == 1 + wire = captured[0] + # The identifier is encoded so the wire path stays inside `/threads/...`. + # The encoded segment must not contain literal slashes that could let + # the server re-route to a different resource type. + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + assert "%2F" in segment + assert segment == "..%2Fassistants%2Fabc-123" + + async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.update("../assistants/abc-123", metadata={"x": 1}) + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "PATCH" + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + + async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.delete("../runs/crons/some-cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + + async def test_assistants_get_with_pivot_payload_stays_on_assistants( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"assistant_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = AssistantsClient(HttpClient(client)) + await assistants_client.get("../threads/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/assistants/") + segment = wire[len("/assistants/") :] + assert "/" not in segment + + async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + runs_client = RunsClient(HttpClient(client)) + # Both identifier values supplied as path-traversal payloads. + await runs_client.delete("..", "../runs/crons/cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + # The path should match `/threads/{quoted_thread}/runs/{quoted_run}` + # exactly. Neither segment should contain literal slashes. + assert wire.startswith("/threads/") + assert "/runs/crons/" not in wire + parts = wire.split("/") + # Expected shape: ['', 'threads', '', 'runs', ''] + assert len(parts) == 5 + assert parts[1] == "threads" + assert parts[3] == "runs" + # Encoded thread_id and run_id are between literal slashes. + assert parts[2] == "%2E%2E" + assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id" + + async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + crons_client = CronClient(HttpClient(client)) + await crons_client.delete("../../assistants/abc-123") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/runs/crons/") + segment = wire[len("/runs/crons/") :] + assert "/" not in segment + + async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs") + + assert len(captured) == 1 + wire = captured[0] + # Wire path must stay on `/threads/{tid}/state/...`, not pivot to + # `/threads/tid-1/runs`. + assert wire.startswith("/threads/tid-1/state/") + # Strip query string before checking the checkpoint segment. + path_only = wire.split("?", 1)[0] + segment = path_only[len("/threads/tid-1/state/") :] + assert "/" not in segment + assert segment == "..%2Fruns" + + async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = AssistantsClient(HttpClient(client)) + await assistants_client.get_subgraphs("aid-1", namespace="../foo") + + assert len(captured) == 1 + wire = captured[0] + # Wire path must stay on `/assistants/{aid}/subgraphs/...`. + assert wire.startswith("/assistants/aid-1/subgraphs/") + # Strip query string before checking the namespace segment. + path_only = wire.split("?", 1)[0] + segment = path_only[len("/assistants/aid-1/subgraphs/") :] + assert "/" not in segment + assert segment == "..%2Ffoo" + + async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None: + """The all-dot encoding branch must survive httpx's relative-path collapse.""" + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("..") + + assert len(captured) == 1 + # The all-dot identifier is fully percent-encoded so httpx does NOT + # collapse it client-side as a relative-path traversal. + assert captured[0].endswith("/threads/%2E%2E") + + async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get(".") + + assert len(captured) == 1 + assert captured[0].endswith("/threads/%2E") + + async def test_uuid_identifier_lands_on_intended_path(self) -> None: + """Legitimate UUID identifiers round-trip without encoding artifacts.""" + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("550e8400-e29b-41d4-a716-446655440000") + + assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"] + + +class TestSyncPathEncoding: + """Sync-client tests that mirror the async coverage on a representative subset.""" + + def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = SyncThreadsClient(SyncHttpClient(client)) + threads_client.get("../assistants/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + assert segment == "..%2Fassistants%2Fabc-123" + + def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"assistant_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = SyncAssistantsClient(SyncHttpClient(client)) + assistants_client.get("../threads/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/assistants/") + segment = wire[len("/assistants/") :] + assert "/" not in segment + + def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None: + captured: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + runs_client = SyncRunsClient(SyncHttpClient(client)) + runs_client.delete("..", "../runs/crons/cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/threads/") + assert "/runs/crons/" not in wire + parts = wire.split("/") + assert len(parts) == 5 + assert parts[1] == "threads" + assert parts[3] == "runs" + assert parts[2] == "%2E%2E" + assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id" + + def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None: + captured: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + crons_client = SyncCronClient(SyncHttpClient(client)) + crons_client.delete("../../assistants/abc-123") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/runs/crons/") + segment = wire[len("/runs/crons/") :] + assert "/" not in segment + + def test_uuid_identifier_lands_on_intended_path(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = SyncThreadsClient(SyncHttpClient(client)) + threads_client.get("550e8400-e29b-41d4-a716-446655440000") + + assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]