diff --git a/python-sdk/README.md b/python-sdk/README.md index 4d969cf1..36659eba 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -349,7 +349,7 @@ async def create_graph(): - `graph_name` (str): Name of the graph to create/update - `graph_nodes` (list[GraphNodeModel]): List of graph node models defining the workflow (beta) -- `secrets` (dict[str, str]): Key/value secrets available to all nodes +- `secrets` (dict[str, str], optional): Key/value secrets available to all nodes. Omit it for a graph that needs none. The state manager replaces stored secrets on every upsert, so omitting it when updating a graph that has secrets clears them - `retry_policy` (RetryPolicyModel | None): Optional retry policy configuration (beta) - `store_config` (StoreConfigModel | None): Graph-level store configuration (beta) - `triggers` (list[CronTrigger] | None): Optional list of cron triggers for automatic graph execution (beta: SDK version 0.0.3b1) diff --git a/python-sdk/exospherehost/statemanager.py b/python-sdk/exospherehost/statemanager.py index 13cf6890..33f72003 100644 --- a/python-sdk/exospherehost/statemanager.py +++ b/python-sdk/exospherehost/statemanager.py @@ -125,7 +125,7 @@ async def get_graph(self, graph_name: str): raise Exception(f"Failed to get graph: {response.status} {await response.text()}") return await response.json() - async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], secrets: dict[str, str], retry_policy: RetryPolicyModel | None = None, store_config: StoreConfigModel | None = None, triggers: list[CronTrigger] | None = None, validation_timeout: int = 60, polling_interval: int = 1): + async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], secrets: dict[str, str] | None = None, retry_policy: RetryPolicyModel | None = None, store_config: StoreConfigModel | None = None, triggers: list[CronTrigger] | None = None, validation_timeout: int = 60, polling_interval: int = 1): """ Create or update a graph definition. @@ -139,7 +139,11 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], Args: graph_name (str): Graph identifier. graph_nodes (list[GraphNodeModel]): List of graph node models defining the workflow. - secrets (dict[str, str]): Secrets available to all nodes. + secrets (dict[str, str] | None): Secrets available to all nodes. Optional: a graph + that needs none can omit it. The state manager replaces a graph's stored + secrets on every upsert, so omitting (or passing an empty mapping) when + updating a graph that already has secrets clears them; pass the full + mapping to keep them. The mapping you pass is copied, never mutated. retry_policy (RetryPolicyModel | None): Optional per-node retry policy configuration. store_config (StoreConfigModel | None): Beta configuration for the graph-level store (schema is subject to change). @@ -160,7 +164,8 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], "x-api-key": self._key } body = { - "secrets": secrets, + # The request model requires the field; an omitted argument is an empty mapping. + "secrets": dict(secrets) if secrets else {}, "nodes": [node.model_dump() for node in graph_nodes] } diff --git a/python-sdk/tests/test_statemanager_optional_secrets.py b/python-sdk/tests/test_statemanager_optional_secrets.py new file mode 100644 index 00000000..9e8c010a --- /dev/null +++ b/python-sdk/tests/test_statemanager_optional_secrets.py @@ -0,0 +1,80 @@ +""" +Regression for https://github.com/FailproofAI/runtime/issues/631. + +A graph that needs no secrets should be creatable without the caller passing an empty +mapping. The state manager's upsert request requires the `secrets` field, and +`GraphTemplate.set_secrets` replaces the stored secrets on every upsert, so the SDK sends +an empty mapping when the argument is omitted and never mutates a caller's own mapping. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from exospherehost.models import GraphNodeModel +from exospherehost.statemanager import StateManager + + +NODES = [GraphNodeModel(node_name="n", namespace="demo", identifier="n", inputs={}, next_nodes=None, unites=None)] + + +def _session(status=201): + session = AsyncMock() + response = AsyncMock() + response.status = status + response.json = AsyncMock(return_value={"name": "g", "validation_status": "VALID"}) + context = AsyncMock() + context.__aenter__.return_value = response + context.__aexit__.return_value = None + session.put = MagicMock(return_value=context) + session.__aenter__.return_value = session + session.__aexit__.return_value = None + return session + + +def _sent_body(session): + return session.put.call_args.kwargs["json"] + + +@pytest.mark.asyncio +async def test_secrets_can_be_omitted_and_an_empty_mapping_is_sent(): + session = _session() + with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session): + sm = StateManager("demo", state_manager_uri="http://sm", key="k") + result = await sm.upsert_graph("g", NODES) + assert result["validation_status"] == "VALID" + assert _sent_body(session)["secrets"] == {} + + +@pytest.mark.asyncio +async def test_explicit_secrets_are_sent_unchanged_and_the_caller_mapping_is_not_mutated(): + session = _session() + secrets = {"TOKEN": "synthetic-value"} + snapshot = dict(secrets) + with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session): + sm = StateManager("demo", state_manager_uri="http://sm", key="k") + await sm.upsert_graph("g", NODES, secrets) + assert _sent_body(session)["secrets"] == snapshot + assert _sent_body(session)["secrets"] is not secrets + assert secrets == snapshot + + +@pytest.mark.asyncio +async def test_repeated_calls_without_secrets_share_no_mutable_default(): + session = _session() + with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session): + sm = StateManager("demo", state_manager_uri="http://sm", key="k") + await sm.upsert_graph("g", NODES) + first = _sent_body(session)["secrets"] + first["LEAKED"] = "x" + await sm.upsert_graph("g", NODES) + second = _sent_body(session)["secrets"] + assert second == {} + assert second is not first + + +@pytest.mark.asyncio +async def test_positional_secrets_still_bind_to_the_third_parameter(): + session = _session() + with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session): + sm = StateManager("demo", state_manager_uri="http://sm", key="k") + await sm.upsert_graph("g", NODES, {"A": "1"}, None, None, None, 5, 1) + assert _sent_body(session)["secrets"] == {"A": "1"}