From dfa2403491691a900a037be27e19553e69695a1e Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 27 Oct 2026 17:48:24 -0700 Subject: [PATCH 1/4] feat(agent-vault): add Agent Vault proxies and sessions Co-Authored-By: Claude Opus 5.5 --- infisical_sdk/__init__.py | 2 +- infisical_sdk/api_types.py | 164 +++++++++++++ infisical_sdk/client.py | 2 + infisical_sdk/infisical_requests.py | 7 +- infisical_sdk/resources/__init__.py | 3 +- infisical_sdk/resources/agent_vault.py | 328 +++++++++++++++++++++++++ 6 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 infisical_sdk/resources/agent_vault.py diff --git a/infisical_sdk/__init__.py b/infisical_sdk/__init__.py index 08c9101..865d1e5 100644 --- a/infisical_sdk/__init__.py +++ b/infisical_sdk/__init__.py @@ -1,3 +1,3 @@ from .client import InfisicalSDKClient # noqa from .infisical_requests import InfisicalError # noqa -from .api_types import SingleSecretResponse, ListSecretsResponse, BaseSecret, SymmetricEncryption, DynamicSecretProviders # noqa \ No newline at end of file +from .api_types import SingleSecretResponse, ListSecretsResponse, BaseSecret, SymmetricEncryption, DynamicSecretProviders, AgentVaultTrafficPolicy, AgentVaultSessionStatus, AgentVaultSessionScope # noqa \ No newline at end of file diff --git a/infisical_sdk/api_types.py b/infisical_sdk/api_types.py index 73f1084..f98f28a 100644 --- a/infisical_sdk/api_types.py +++ b/infisical_sdk/api_types.py @@ -448,3 +448,167 @@ def from_dict(cls, data: Dict) -> 'SingleLeaseResponse': return cls( lease=DynamicSecretLease.from_dict(data['lease']), ) + +class AgentVaultTrafficPolicy(str, Enum): + """Enum for which hosts an agent may reach through an Agent Vault proxy""" + ANY_HOST = "any-host" + BUNDLE_HOSTS = "bundle-hosts" + +class AgentVaultSessionStatus(str, Enum): + """Enum for Agent Vault session status""" + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" + +class AgentVaultSessionScope(str, Enum): + """Enum for whose Agent Vault sessions to list""" + MINE = "mine" + ALL = "all" + +@dataclass +class AgentVaultProxy(BaseModel): + """Infisical Agent Vault Proxy. Only admins see the settings fields, so they are None for members.""" + id: str + name: str + heartbeat: Optional[str] = None + isHealthy: Optional[bool] = None + rootCaFingerprint: Optional[str] = None + rootCaExpiresAt: Optional[str] = None + trafficPolicy: Optional[str] = None + allowedHosts: Optional[str] = None + pollInterval: Optional[int] = None + createdAt: Optional[str] = None + +@dataclass +class SingleAgentVaultProxyResponse(BaseModel): + """Response model for update/delete/revoke Agent Vault proxy API""" + proxy: AgentVaultProxy + + @classmethod + def from_dict(cls, data: Dict) -> 'SingleAgentVaultProxyResponse': + return cls( + proxy=AgentVaultProxy.from_dict(data['proxy']), + ) + +@dataclass +class AgentVaultProxyWithTokenResponse(BaseModel): + """Response model for create Agent Vault proxy and rotate proxy token API. The token is only returned once.""" + proxy: AgentVaultProxy + proxyToken: str + + @classmethod + def from_dict(cls, data: Dict) -> 'AgentVaultProxyWithTokenResponse': + return cls( + proxy=AgentVaultProxy.from_dict(data['proxy']), + proxyToken=data['proxyToken'], + ) + +@dataclass +class ListAgentVaultProxiesResponse(BaseModel): + """Response model for list Agent Vault proxies API""" + proxies: List[AgentVaultProxy] + totalCount: int + + @classmethod + def from_dict(cls, data: Dict) -> 'ListAgentVaultProxiesResponse': + return cls( + proxies=[AgentVaultProxy.from_dict(proxy) for proxy in data['proxies']], + totalCount=data['totalCount'], + ) + +@dataclass +class AgentVaultSessionActor(BaseModel): + """The user or machine identity an Agent Vault session belongs to""" + type: str + id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + firstName: Optional[str] = None + lastName: Optional[str] = None + name: Optional[str] = None + +@dataclass +class AgentVaultSessionAccessBundle(BaseModel): + """An access bundle carried by an Agent Vault session""" + name: str + position: int + id: Optional[str] = None + +@dataclass +class AgentVaultSession(BaseModel): + """Infisical Agent Vault Session""" + id: str + status: str + createdAt: str + label: Optional[str] = None + actor: Optional[AgentVaultSessionActor] = None + expiresAt: Optional[str] = None + revokedAt: Optional[str] = None + accessBundles: List[AgentVaultSessionAccessBundle] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict) -> 'AgentVaultSession': + """Create model from dictionary with nested actor and access bundles""" + session_data = data.copy() + if data.get('actor') is not None: + session_data['actor'] = AgentVaultSessionActor.from_dict(data['actor']) + session_data['accessBundles'] = [ + AgentVaultSessionAccessBundle.from_dict(bundle) for bundle in data.get('accessBundles', []) + ] + + return super().from_dict(session_data) + +@dataclass +class CreatedAgentVaultSession(BaseModel): + """Infisical Agent Vault Session as returned at creation. The token is only returned once.""" + id: str + token: str + createdAt: str + label: Optional[str] = None + expiresAt: Optional[str] = None + accessBundles: List[AgentVaultSessionAccessBundle] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict) -> 'CreatedAgentVaultSession': + """Create model from dictionary with nested access bundles""" + session_data = data.copy() + session_data['accessBundles'] = [ + AgentVaultSessionAccessBundle.from_dict(bundle) for bundle in data.get('accessBundles', []) + ] + + return super().from_dict(session_data) + +@dataclass +class SingleAgentVaultSessionResponse(BaseModel): + """Response model for get/revoke Agent Vault session API""" + session: AgentVaultSession + + @classmethod + def from_dict(cls, data: Dict) -> 'SingleAgentVaultSessionResponse': + return cls( + session=AgentVaultSession.from_dict(data['session']), + ) + +@dataclass +class CreateAgentVaultSessionResponse(BaseModel): + """Response model for create Agent Vault session API""" + session: CreatedAgentVaultSession + + @classmethod + def from_dict(cls, data: Dict) -> 'CreateAgentVaultSessionResponse': + return cls( + session=CreatedAgentVaultSession.from_dict(data['session']), + ) + +@dataclass +class ListAgentVaultSessionsResponse(BaseModel): + """Response model for list Agent Vault sessions API""" + sessions: List[AgentVaultSession] + totalCount: int + + @classmethod + def from_dict(cls, data: Dict) -> 'ListAgentVaultSessionsResponse': + return cls( + sessions=[AgentVaultSession.from_dict(session) for session in data['sessions']], + totalCount=data['totalCount'], + ) diff --git a/infisical_sdk/client.py b/infisical_sdk/client.py index ce21277..9889d3e 100644 --- a/infisical_sdk/client.py +++ b/infisical_sdk/client.py @@ -5,6 +5,7 @@ from infisical_sdk.resources import KMS from infisical_sdk.resources import V2Folders from infisical_sdk.resources import DynamicSecrets +from infisical_sdk.resources import AgentVault from infisical_sdk.util import SecretsCache @@ -28,6 +29,7 @@ def __init__(self, host: str, token: str = None, cache_ttl: int = 60): self.kms = KMS(self.api) self.folders = V2Folders(self.api) self.dynamic_secrets = DynamicSecrets(self.api) + self.agent_vault = AgentVault(self.api) def set_token(self, token: str): """ diff --git a/infisical_sdk/infisical_requests.py b/infisical_sdk/infisical_requests.py index 0b91516..ea8037c 100644 --- a/infisical_sdk/infisical_requests.py +++ b/infisical_sdk/infisical_requests.py @@ -204,14 +204,15 @@ def patch( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + nullable_keys: Optional[List[str]] = None ) -> APIResponse[T]: - """Make a PATCH request with JSON data""" + """Make a PATCH request with JSON data. Keys in nullable_keys are sent even when None, to clear a value.""" if json is not None: # Filter out None values - json = {k: v for k, v in json.items() if v is not None} + json = {k: v for k, v in json.items() if v is not None or k in (nullable_keys or [])} response = self.session.patch(self._build_url(path), json=json) data = self._handle_response(response) diff --git a/infisical_sdk/resources/__init__.py b/infisical_sdk/resources/__init__.py index 9f32cb9..f93881c 100644 --- a/infisical_sdk/resources/__init__.py +++ b/infisical_sdk/resources/__init__.py @@ -2,4 +2,5 @@ from .kms import KMS from .auth import Auth from .folders import V2Folders -from .dynamic_secrets import DynamicSecrets \ No newline at end of file +from .dynamic_secrets import DynamicSecrets +from .agent_vault import AgentVault \ No newline at end of file diff --git a/infisical_sdk/resources/agent_vault.py b/infisical_sdk/resources/agent_vault.py new file mode 100644 index 0000000..bcbbb1b --- /dev/null +++ b/infisical_sdk/resources/agent_vault.py @@ -0,0 +1,328 @@ +from typing import Optional, List, Union + +from infisical_sdk.infisical_requests import InfisicalRequests +from infisical_sdk.api_types import ( + AgentVaultProxy, + AgentVaultSession, + AgentVaultSessionScope, + AgentVaultSessionStatus, + AgentVaultTrafficPolicy, + AgentVaultProxyWithTokenResponse, + CreateAgentVaultSessionResponse, + CreatedAgentVaultSession, + ListAgentVaultProxiesResponse, + ListAgentVaultSessionsResponse, + SingleAgentVaultProxyResponse, + SingleAgentVaultSessionResponse, +) + + +def _join_hosts(allowed_hosts: Optional[Union[str, List[str]]]) -> Optional[str]: + """The API takes host patterns as one comma-separated string.""" + if isinstance(allowed_hosts, list): + return ",".join(allowed_hosts) + return allowed_hosts + + +class AgentVaultProxies: + """Manages Agent Vault proxies.""" + + def __init__(self, requests: InfisicalRequests) -> None: + self.requests = requests + + def list( + self, + search: Optional[str] = None, + order_by: str = "createdAt", + order_direction: str = "desc", + limit: int = 20, + offset: int = 0) -> ListAgentVaultProxiesResponse: + """List the organization's Agent Vault proxies. + + Args: + search: Match proxies by name. + order_by: What to sort proxies by: "name" or "createdAt". Defaults to "createdAt". + order_direction: Which way to sort: "asc" or "desc". Defaults to "desc". + limit: The maximum number of proxies to return (1-100). Defaults to 20. + offset: How many proxies to skip. Defaults to 0. + + Returns: + ListAgentVaultProxiesResponse containing proxies and totalCount. Settings fields are only set for admins. + """ + params = { + "search": search, + "orderBy": order_by, + "orderDirection": order_direction, + "limit": limit, + "offset": offset, + } + + result = self.requests.get( + path="/api/v1/agent-vault/proxies", + params=params, + model=ListAgentVaultProxiesResponse + ) + + return result.data + + def create( + self, + name: str, + traffic_policy: Union[AgentVaultTrafficPolicy, str] = AgentVaultTrafficPolicy.ANY_HOST, + allowed_hosts: Optional[Union[str, List[str]]] = None, + poll_interval: int = 60) -> AgentVaultProxyWithTokenResponse: + """Register an Agent Vault proxy and issue its proxy token. + + Args: + name: The name of the proxy (a slug, at most 64 characters). + traffic_policy: Which hosts an agent may reach through this proxy. Defaults to "any-host". + allowed_hosts: Hosts that stay reachable under the "bundle-hosts" traffic policy even though no access bundle covers them. A list or a comma-separated string. + poll_interval: How often, in seconds, the proxy refreshes its sessions and settings (10-300). Defaults to 60. + + Returns: + AgentVaultProxyWithTokenResponse containing the proxy and its proxyToken. The token is only returned once. + """ + request_body = { + "name": name, + "trafficPolicy": traffic_policy.value if isinstance(traffic_policy, AgentVaultTrafficPolicy) else traffic_policy, + "allowedHosts": _join_hosts(allowed_hosts), + "pollInterval": poll_interval, + } + + result = self.requests.post( + path="/api/v1/agent-vault/proxies", + json=request_body, + model=AgentVaultProxyWithTokenResponse + ) + + return result.data + + def update( + self, + proxy_id: str, + name: Optional[str] = None, + traffic_policy: Optional[Union[AgentVaultTrafficPolicy, str]] = None, + allowed_hosts: Optional[Union[str, List[str]]] = None, + poll_interval: Optional[int] = None, + clear_allowed_hosts: bool = False) -> AgentVaultProxy: + """Update an Agent Vault proxy's name or settings. Only the values you pass are changed. + + Args: + proxy_id: The ID of the proxy to update. + name: The new name of the proxy. + traffic_policy: Which hosts an agent may reach through this proxy. + allowed_hosts: Hosts that stay reachable under the "bundle-hosts" traffic policy. A list or a comma-separated string. + poll_interval: How often, in seconds, the proxy refreshes its sessions and settings (10-300). + clear_allowed_hosts: Remove all allowed hosts. Can't be combined with allowed_hosts. + + Returns: + The updated proxy. + """ + if clear_allowed_hosts and allowed_hosts is not None: + raise ValueError("Pass either allowed_hosts or clear_allowed_hosts, not both") + + if isinstance(traffic_policy, AgentVaultTrafficPolicy): + traffic_policy = traffic_policy.value + + request_body = { + "name": name, + "trafficPolicy": traffic_policy, + "allowedHosts": _join_hosts(allowed_hosts), + "pollInterval": poll_interval, + } + + result = self.requests.patch( + path=f"/api/v1/agent-vault/proxies/{proxy_id}", + json=request_body, + model=SingleAgentVaultProxyResponse, + nullable_keys=["allowedHosts"] if clear_allowed_hosts else None + ) + + return result.data.proxy + + def delete(self, proxy_id: str) -> AgentVaultProxy: + """Delete an Agent Vault proxy. + + Args: + proxy_id: The ID of the proxy to delete. + + Returns: + The deleted proxy. Only id and name are set. + """ + result = self.requests.delete( + path=f"/api/v1/agent-vault/proxies/{proxy_id}", + model=SingleAgentVaultProxyResponse + ) + + return result.data.proxy + + def rotate_token(self, proxy_id: str) -> AgentVaultProxyWithTokenResponse: + """Issue a new proxy token. The proxy's earlier tokens stop working and it gets a new certificate authority. + + Args: + proxy_id: The ID of the proxy to rotate the token for. + + Returns: + AgentVaultProxyWithTokenResponse containing the proxy and its new proxyToken. The token is only returned once. + """ + result = self.requests.post( + path=f"/api/v1/agent-vault/proxies/{proxy_id}/token-auth/proxy-token", + model=AgentVaultProxyWithTokenResponse + ) + + return result.data + + def revoke(self, proxy_id: str) -> AgentVaultProxy: + """Revoke an Agent Vault proxy's token. Rotate its token to bring it back. + + Args: + proxy_id: The ID of the proxy to revoke. + + Returns: + The revoked proxy. + """ + result = self.requests.post( + path=f"/api/v1/agent-vault/proxies/{proxy_id}/revoke", + model=SingleAgentVaultProxyResponse + ) + + return result.data.proxy + + +class AgentVaultSessions: + """Manages Agent Vault sessions.""" + + def __init__(self, requests: InfisicalRequests) -> None: + self.requests = requests + + def list( + self, + scope: Union[AgentVaultSessionScope, str] = AgentVaultSessionScope.MINE, + statuses: Optional[List[Union[AgentVaultSessionStatus, str]]] = None, + search: Optional[str] = None, + limit: int = 20, + offset: int = 0) -> ListAgentVaultSessionsResponse: + """List Agent Vault sessions. + + Args: + scope: Whose sessions to list: your own ("mine") or everyone's ("all", administrators only). Defaults to "mine". + statuses: Only return sessions with any of these statuses: "active", "revoked" or "expired". + search: Match sessions by label, actor name, actor email or access bundle name. + limit: The maximum number of sessions to return (1-100). Defaults to 20. + offset: How many sessions to skip. Defaults to 0. + + Returns: + ListAgentVaultSessionsResponse containing sessions and totalCount. + """ + status = None + if statuses: + status = ",".join(s.value if isinstance(s, AgentVaultSessionStatus) else s for s in statuses) + + params = { + "scope": scope.value if isinstance(scope, AgentVaultSessionScope) else scope, + "status": status, + "search": search, + "limit": limit, + "offset": offset, + } + + result = self.requests.get( + path="/api/v1/agent-vault/sessions", + params=params, + model=ListAgentVaultSessionsResponse + ) + + return result.data + + def get(self, session_id: str) -> AgentVaultSession: + """Get an Agent Vault session. + + Args: + session_id: The ID of the session to retrieve. + + Returns: + The session. + """ + result = self.requests.get( + path=f"/api/v1/agent-vault/sessions/{session_id}", + model=SingleAgentVaultSessionResponse + ) + + return result.data.session + + def create( + self, + access_bundle: str, + ttl: str = "7d", + label: Optional[str] = None) -> CreatedAgentVaultSession: + """Create an Agent Vault session with an access bundle you can reach. + + Args: + access_bundle: The name of the access bundle the session carries. + ttl: How long the session lasts: a duration such as "30m", "8h" or "7d" (at least 1m), or "never". Defaults to "7d". + label: A short description of what the session is for, such as "nightly deploy". Up to 128 characters, without line breaks, tabs, or invisible characters. + + Returns: + The created session, including its token. The token is only returned once. + """ + request_body = { + "accessBundles": [access_bundle], + "ttl": ttl, + "label": label, + } + + result = self.requests.post( + path="/api/v1/agent-vault/sessions", + json=request_body, + model=CreateAgentVaultSessionResponse + ) + + return result.data.session + + def update(self, session_id: str, label: Optional[str]) -> AgentVaultSession: + """Update the label of an Agent Vault session. This also works after the session expires or is revoked. + + Args: + session_id: The ID of the session to update. + label: The new label for the session, up to 128 characters, without line breaks, tabs, or invisible characters. Pass None or an empty string to remove the label. You can't change the label once the session's owner is deleted. + + Returns: + The updated session. + """ + request_body = { + "label": label, + } + + result = self.requests.patch( + path=f"/api/v1/agent-vault/sessions/{session_id}", + json=request_body, + model=SingleAgentVaultSessionResponse, + nullable_keys=["label"] + ) + + return result.data.session + + def revoke(self, session_id: str) -> AgentVaultSession: + """Revoke an Agent Vault session. Revoking an already revoked session is a no-op. + + Args: + session_id: The ID of the session to revoke. + + Returns: + The revoked session. + """ + result = self.requests.post( + path=f"/api/v1/agent-vault/sessions/{session_id}/revoke", + model=SingleAgentVaultSessionResponse + ) + + return result.data.session + + +class AgentVault: + """Manages Infisical Agent Vault.""" + + def __init__(self, requests: InfisicalRequests) -> None: + self.requests = requests + self.proxies = AgentVaultProxies(requests) + self.sessions = AgentVaultSessions(requests) From 882a00158862a2ee393f5199cce1d463748133c6 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 27 Oct 2026 18:01:06 -0700 Subject: [PATCH 2/4] fix(agent-vault): don't retry creates once the request may have reached the server Co-Authored-By: Claude Opus 5.5 --- infisical_sdk/infisical_requests.py | 31 ++++++++++++++++++++++++++ infisical_sdk/resources/agent_vault.py | 4 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/infisical_sdk/infisical_requests.py b/infisical_sdk/infisical_requests.py index ea8037c..b50bfc0 100644 --- a/infisical_sdk/infisical_requests.py +++ b/infisical_sdk/infisical_requests.py @@ -22,6 +22,11 @@ ConnectionAbortedError, ] +# Raised before a request reaches the server, so retrying can't make the server handle it twice +CONNECT_ERRORS = [ + requests.exceptions.ConnectTimeout, +] + def join_url(base: str, path: str) -> str: """ Join base URL and path properly, handling slashes appropriately. @@ -199,6 +204,32 @@ def post( headers=dict(response.headers) ) + @with_retry(max_retries=4, base_delay=1.0, network_errors=CONNECT_ERRORS) + def post_once( + self, + path: str, + model: Type[T], + json: Optional[Dict[str, Any]] = None + ) -> APIResponse[T]: + + """Make a POST request that must not run twice, such as one that creates something. + It's only retried if the connection never opened, so a lost response can't run it again.""" + + if json is not None: + # Filter out None values + json = {k: v for k, v in json.items() if v is not None} + + response = self.session.post(self._build_url(path), json=json) + data = self._handle_response(response) + + parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data + + return APIResponse( + data=parsed_data, + status_code=response.status_code, + headers=dict(response.headers) + ) + @with_retry(max_retries=4, base_delay=1.0) def patch( self, diff --git a/infisical_sdk/resources/agent_vault.py b/infisical_sdk/resources/agent_vault.py index bcbbb1b..3773b0a 100644 --- a/infisical_sdk/resources/agent_vault.py +++ b/infisical_sdk/resources/agent_vault.py @@ -89,7 +89,7 @@ def create( "pollInterval": poll_interval, } - result = self.requests.post( + result = self.requests.post_once( path="/api/v1/agent-vault/proxies", json=request_body, model=AgentVaultProxyWithTokenResponse @@ -271,7 +271,7 @@ def create( "label": label, } - result = self.requests.post( + result = self.requests.post_once( path="/api/v1/agent-vault/sessions", json=request_body, model=CreateAgentVaultSessionResponse From 979454a3f2874e4bde63a76a2ebe32763f9f6809 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 27 Oct 2026 18:01:06 -0700 Subject: [PATCH 3/4] test: cover null clearing and create retries, and run pytest in CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python.yml | 8 ++--- tests/conftest.py | 30 ++++++++++++++++++ tests/test_agent_vault.py | 54 ++++++++++++++++++++++++++++++++ tests/test_infisical_requests.py | 44 ++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_agent_vault.py create mode 100644 tests/test_infisical_requests.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 27a2b66..8362599 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -34,7 +34,7 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - # ? Currently no tests are available - #- name: Test with pytest - # run: | - # pytest \ No newline at end of file + # Only tests/: the scripts in sink/ call a live Infisical instance + - name: Test with pytest + run: | + python -m pytest tests \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..06020b1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import json +from unittest import mock + +import pytest +import requests + +from infisical_sdk.infisical_requests import InfisicalRequests + + +@pytest.fixture(autouse=True) +def no_retry_delay(): + with mock.patch("infisical_sdk.infisical_requests.time.sleep"): + yield + + +@pytest.fixture +def api(): + api = InfisicalRequests(host="https://infisical.example.com", token="token") + api.session = mock.Mock() + return api + + +@pytest.fixture +def json_response(): + def make(payload): + response = requests.Response() + response.status_code = 200 + response._content = json.dumps(payload).encode() + return response + return make diff --git a/tests/test_agent_vault.py b/tests/test_agent_vault.py new file mode 100644 index 0000000..2f71e76 --- /dev/null +++ b/tests/test_agent_vault.py @@ -0,0 +1,54 @@ +import pytest +import requests + +from infisical_sdk.resources.agent_vault import AgentVault + +PROXY = {"id": "a2b5e0d1-6f3e-4c55-9a43-3f0c1f0a2b7e", "name": "edge-proxy"} +SESSION = { + "id": "6f1c2a9e-3b7d-4e8a-9c41-2d5f7b8e0a13", + "label": None, + "status": "active", + "createdAt": "2026-10-27T00:00:00.000Z", + "accessBundles": [], +} + + +@pytest.fixture +def agent_vault(api): + return AgentVault(api) + + +def test_proxies_update_sends_null_to_clear_allowed_hosts(api, agent_vault, json_response): + api.session.patch.return_value = json_response({"proxy": PROXY}) + agent_vault.proxies.update(PROXY["id"], clear_allowed_hosts=True) + assert api.session.patch.call_args.kwargs["json"] == {"allowedHosts": None} + + +def test_proxies_update_leaves_out_values_you_do_not_pass(api, agent_vault, json_response): + api.session.patch.return_value = json_response({"proxy": PROXY}) + agent_vault.proxies.update(PROXY["id"], name="edge-proxy") + assert api.session.patch.call_args.kwargs["json"] == {"name": "edge-proxy"} + + +def test_proxies_update_rejects_allowed_hosts_with_clear_allowed_hosts(api, agent_vault): + with pytest.raises(ValueError): + agent_vault.proxies.update(PROXY["id"], allowed_hosts=["pypi.org"], clear_allowed_hosts=True) + api.session.patch.assert_not_called() + + +@pytest.mark.parametrize("label", ["nightly deploy", None]) +def test_sessions_update_always_sends_the_label(api, agent_vault, json_response, label): + api.session.patch.return_value = json_response({"session": dict(SESSION, label=label)}) + assert agent_vault.sessions.update(SESSION["id"], label).label == label + assert api.session.patch.call_args.kwargs["json"] == {"label": label} + + +@pytest.mark.parametrize("create", [ + lambda agent_vault: agent_vault.sessions.create(access_bundle="release"), + lambda agent_vault: agent_vault.proxies.create(name="edge-proxy"), +], ids=["sessions.create", "proxies.create"]) +def test_creates_are_not_retried_once_the_request_may_have_reached_the_server(api, agent_vault, create): + api.session.post.side_effect = requests.exceptions.ReadTimeout() + with pytest.raises(requests.exceptions.ReadTimeout): + create(agent_vault) + assert api.session.post.call_count == 1 diff --git a/tests/test_infisical_requests.py b/tests/test_infisical_requests.py new file mode 100644 index 0000000..248679a --- /dev/null +++ b/tests/test_infisical_requests.py @@ -0,0 +1,44 @@ +import pytest +import requests + + +def test_patch_leaves_out_none_values(api, json_response): + api.session.patch.return_value = json_response({}) + api.patch(path="/api/v1/example", model=dict, json={"name": "edge", "description": None}) + assert api.session.patch.call_args.kwargs["json"] == {"name": "edge"} + + +def test_patch_sends_null_for_nullable_keys(api, json_response): + api.session.patch.return_value = json_response({}) + api.patch(path="/api/v1/example", model=dict, json={"label": None, "description": None}, nullable_keys=["label"]) + assert api.session.patch.call_args.kwargs["json"] == {"label": None} + + +def test_patch_keeps_false_zero_and_empty_string(api, json_response): + api.session.patch.return_value = json_response({}) + api.patch(path="/api/v1/example", model=dict, json={"enabled": False, "count": 0, "note": ""}) + assert api.session.patch.call_args.kwargs["json"] == {"enabled": False, "count": 0, "note": ""} + + +def test_post_retries_a_read_timeout(api, json_response): + api.session.post.side_effect = [requests.exceptions.ReadTimeout(), json_response({"ok": True})] + assert api.post(path="/api/v1/example", model=dict).data == {"ok": True} + assert api.session.post.call_count == 2 + + +def test_post_once_retries_when_the_connection_never_opened(api, json_response): + api.session.post.side_effect = [requests.exceptions.ConnectTimeout(), json_response({"ok": True})] + assert api.post_once(path="/api/v1/example", model=dict).data == {"ok": True} + assert api.session.post.call_count == 2 + + +@pytest.mark.parametrize("error", [ + requests.exceptions.ReadTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ChunkedEncodingError, +]) +def test_post_once_does_not_retry_once_the_request_may_have_reached_the_server(api, error): + api.session.post.side_effect = error() + with pytest.raises(error): + api.post_once(path="/api/v1/example", model=dict) + assert api.session.post.call_count == 1 From 5eea1acc829be6d0cedbb24488c7a58068e7f3c7 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 29 Oct 2026 15:14:08 -0700 Subject: [PATCH 4/4] fix(agent-vault): address review on hosts, token rotation, and revoked sessions Co-Authored-By: Claude Opus 5.5 --- infisical_sdk/api_types.py | 11 +++++----- infisical_sdk/resources/agent_vault.py | 10 +++++---- tests/test_agent_vault.py | 30 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/infisical_sdk/api_types.py b/infisical_sdk/api_types.py index f98f28a..f6896c2 100644 --- a/infisical_sdk/api_types.py +++ b/infisical_sdk/api_types.py @@ -544,17 +544,18 @@ class AgentVaultSession(BaseModel): actor: Optional[AgentVaultSessionActor] = None expiresAt: Optional[str] = None revokedAt: Optional[str] = None - accessBundles: List[AgentVaultSessionAccessBundle] = field(default_factory=list) + accessBundles: Optional[List[AgentVaultSessionAccessBundle]] = None @classmethod def from_dict(cls, data: Dict) -> 'AgentVaultSession': - """Create model from dictionary with nested actor and access bundles""" + """Create model from dictionary with nested actor and access bundles. accessBundles is None if the response leaves it out.""" session_data = data.copy() if data.get('actor') is not None: session_data['actor'] = AgentVaultSessionActor.from_dict(data['actor']) - session_data['accessBundles'] = [ - AgentVaultSessionAccessBundle.from_dict(bundle) for bundle in data.get('accessBundles', []) - ] + if data.get('accessBundles') is not None: + session_data['accessBundles'] = [ + AgentVaultSessionAccessBundle.from_dict(bundle) for bundle in data['accessBundles'] + ] return super().from_dict(session_data) diff --git a/infisical_sdk/resources/agent_vault.py b/infisical_sdk/resources/agent_vault.py index 3773b0a..f0938d3 100644 --- a/infisical_sdk/resources/agent_vault.py +++ b/infisical_sdk/resources/agent_vault.py @@ -18,9 +18,9 @@ def _join_hosts(allowed_hosts: Optional[Union[str, List[str]]]) -> Optional[str]: - """The API takes host patterns as one comma-separated string.""" + """The API takes host patterns as one comma-separated string, and rejects an empty one.""" if isinstance(allowed_hosts, list): - return ",".join(allowed_hosts) + return ",".join(allowed_hosts) or None return allowed_hosts @@ -120,6 +120,8 @@ def update( """ if clear_allowed_hosts and allowed_hosts is not None: raise ValueError("Pass either allowed_hosts or clear_allowed_hosts, not both") + if allowed_hosts == []: + raise ValueError("allowed_hosts can't be empty. To remove every allowed host, pass clear_allowed_hosts=True") if isinstance(traffic_policy, AgentVaultTrafficPolicy): traffic_policy = traffic_policy.value @@ -165,7 +167,7 @@ def rotate_token(self, proxy_id: str) -> AgentVaultProxyWithTokenResponse: Returns: AgentVaultProxyWithTokenResponse containing the proxy and its new proxyToken. The token is only returned once. """ - result = self.requests.post( + result = self.requests.post_once( path=f"/api/v1/agent-vault/proxies/{proxy_id}/token-auth/proxy-token", model=AgentVaultProxyWithTokenResponse ) @@ -309,7 +311,7 @@ def revoke(self, session_id: str) -> AgentVaultSession: session_id: The ID of the session to revoke. Returns: - The revoked session. + The revoked session. Its accessBundles is None, because the response leaves the bundles out; call get for them. """ result = self.requests.post( path=f"/api/v1/agent-vault/sessions/{session_id}/revoke", diff --git a/tests/test_agent_vault.py b/tests/test_agent_vault.py index 2f71e76..7adad32 100644 --- a/tests/test_agent_vault.py +++ b/tests/test_agent_vault.py @@ -52,3 +52,33 @@ def test_creates_are_not_retried_once_the_request_may_have_reached_the_server(ap with pytest.raises(requests.exceptions.ReadTimeout): create(agent_vault) assert api.session.post.call_count == 1 + + +def test_proxies_create_leaves_out_an_empty_allowed_hosts_list(api, agent_vault, json_response): + api.session.post.return_value = json_response({"proxy": PROXY, "proxyToken": "token"}) + agent_vault.proxies.create(name="edge-proxy", allowed_hosts=[]) + assert "allowedHosts" not in api.session.post.call_args.kwargs["json"] + + +def test_proxies_update_rejects_an_empty_allowed_hosts_list(api, agent_vault): + with pytest.raises(ValueError, match="clear_allowed_hosts"): + agent_vault.proxies.update(PROXY["id"], allowed_hosts=[]) + api.session.patch.assert_not_called() + + +def test_proxies_rotate_token_is_not_retried_once_the_request_may_have_reached_the_server(api, agent_vault): + api.session.post.side_effect = requests.exceptions.ReadTimeout() + with pytest.raises(requests.exceptions.ReadTimeout): + agent_vault.proxies.rotate_token(PROXY["id"]) + assert api.session.post.call_count == 1 + + +def test_sessions_revoke_leaves_access_bundles_unset_when_the_response_omits_them(api, agent_vault, json_response): + revoked = {key: value for key, value in SESSION.items() if key != "accessBundles"} + api.session.post.return_value = json_response({"session": dict(revoked, status="revoked")}) + assert agent_vault.sessions.revoke(SESSION["id"]).accessBundles is None + + +def test_sessions_get_keeps_an_empty_access_bundles_list(api, agent_vault, json_response): + api.session.get.return_value = json_response({"session": SESSION}) + assert agent_vault.sessions.get(SESSION["id"]).accessBundles == []