agentvalet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentvalet
3
+ Version: 0.1.0
4
+ Summary: Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential.
5
+ Project-URL: Homepage, https://agentvalet.ai
6
+ Project-URL: Documentation, https://docs.agentvalet.ai
7
+ Project-URL: Source, https://github.com/MCSEdwin/agentvalet
8
+ Author: AgentValet
9
+ License: MIT
10
+ Keywords: agents,agentvalet,ai,authorization,credentials,mcp,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Security
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx>=0.24
24
+ Requires-Dist: pyjwt[crypto]>=2.6
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.8; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.4; extra == 'dev'
29
+ Requires-Dist: respx>=0.20; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # agentvalet
34
+
35
+ Call approved SaaS platforms from any Python agent. Your agent never holds the
36
+ downstream credential — AgentValet signs a short-lived identity assertion,
37
+ checks the call against the owner's grants and policy, injects the credential at
38
+ call time, and writes an audit record.
39
+
40
+ This is the Python port of
41
+ [`@agentvalet/client`](https://www.npmjs.com/package/@agentvalet/client). Same
42
+ endpoints, same approval semantics, same timings.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install agentvalet
48
+ ```
49
+
50
+ Python 3.9+. Two dependencies: `httpx` and `pyjwt[crypto]`.
51
+
52
+ ## Get an agent identity
53
+
54
+ ```bash
55
+ agentvalet register --code <invite-or-enrollment-code>
56
+ ```
57
+
58
+ The RSA keypair is generated **on your machine**. Only the public half is sent;
59
+ the private key is written to `~/.agentvalet/agent.key` (mode 0600) and never
60
+ crosses the wire. The code is either an invite's bind secret or the enrollment
61
+ code from the "Try it free" flow.
62
+
63
+ This writes the same files as `npx @agentvalet/register`, so the Node and Python
64
+ tooling are interchangeable — you do not need Node installed.
65
+
66
+ ## Use it
67
+
68
+ ```python
69
+ from agentvalet import AgentValet
70
+
71
+ av = AgentValet.from_env()
72
+
73
+ result = av.call(
74
+ platform="slack",
75
+ endpoint="/api/chat.postMessage",
76
+ method="POST",
77
+ scope="chat:write",
78
+ data={"channel": "#general", "text": "Deploy finished."},
79
+ )
80
+ ```
81
+
82
+ `from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
83
+ `AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
84
+ `AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
85
+ wire it explicitly:
86
+
87
+ ```python
88
+ av = AgentValet(
89
+ agent_id=os.environ["AGENT_ID"],
90
+ owner_id=os.environ["OWNER_ID"],
91
+ private_key=os.environ["AGENT_PRIVATE_KEY"],
92
+ proxy_url="https://api.agentvalet.ai", # default
93
+ )
94
+ ```
95
+
96
+ Use it as a context manager to close the HTTP client cleanly:
97
+
98
+ ```python
99
+ with AgentValet.from_env() as av:
100
+ av.call(platform="github", endpoint="/user", scope="read")
101
+ ```
102
+
103
+ ## Async
104
+
105
+ `AsyncAgentValet` is a method-for-method mirror. All decision logic is shared,
106
+ and the test suite runs the same behaviour table against both.
107
+
108
+ ```python
109
+ from agentvalet import AsyncAgentValet
110
+
111
+ async with AsyncAgentValet.from_env() as av:
112
+ result = await av.call(
113
+ platform="slack", endpoint="/api/chat.postMessage",
114
+ method="POST", scope="chat:write", data={...},
115
+ )
116
+ ```
117
+
118
+ ## Approvals are just a slower call
119
+
120
+ When the owner has marked a scope as approval-gated, the proxy holds the action
121
+ and `call()` waits. If the owner approves, the proxy re-runs the call and you
122
+ get the result — from your code's point of view it simply took longer.
123
+
124
+ ```python
125
+ av = AgentValet.from_env(
126
+ on_approval_pending=lambda i: print(f"waiting… {i['elapsed_s']:.0f}s")
127
+ )
128
+ ```
129
+
130
+ If nobody responds inside the budget (50s by default), you get an
131
+ `ApprovalTimeoutError` — **not a failure**. The action stays queued. Keep the
132
+ `approval_id` and resume whenever you like, in this process or another:
133
+
134
+ ```python
135
+ from agentvalet import ApprovalTimeoutError
136
+
137
+ try:
138
+ av.call(platform="stripe", endpoint="/v1/refunds", method="POST", scope="charge")
139
+ except ApprovalTimeoutError as err:
140
+ queue.put(err.approval_id) # hand off to a worker
141
+
142
+ # …later, elsewhere:
143
+ result = av.wait_for_approval(approval_id)
144
+ ```
145
+
146
+ Pass `approval_timeout_s=0` if you never want to block.
147
+
148
+ ## Errors you can branch on
149
+
150
+ Every exception is typed, so you never string-match an error envelope:
151
+
152
+ | Error | Means | What to do |
153
+ |---|---|---|
154
+ | `ConfigError` | Bad/missing identity or key | Fix config; raised before any network call |
155
+ | `NetworkError` | Transport failed | `.hint` diagnoses DNS / TLS / firewall / timeout |
156
+ | `AccessDeniedError` | No grant, or policy blocked it | `request_access()` — see below |
157
+ | `ApprovalDeniedError` | Owner said no | Terminal. Don't retry |
158
+ | `ApprovalExpiredError` | Aged out server-side | Re-issue the call |
159
+ | `ApprovalTimeoutError` | You stopped waiting | Resume via `wait_for_approval()` |
160
+ | `UpstreamError` | The SaaS returned non-2xx | `.status` / `.data` hold the upstream reply |
161
+ | `ProxyError` | Anything else from the broker | `.status` / `.body` |
162
+
163
+ ## Asking for access you don't have
164
+
165
+ Deny-by-default means a scope you were never granted raises
166
+ `AccessDeniedError`. Your agent can ask for it:
167
+
168
+ ```python
169
+ result = av.request_access(
170
+ platform="slack",
171
+ scope="chat:write",
172
+ reason="Post deploy notifications to #general",
173
+ )
174
+ if result["status"] == "approved":
175
+ ... # retry the original call
176
+ ```
177
+
178
+ ## Checking before you act
179
+
180
+ ```python
181
+ av.list_platforms() # what this agent is actually granted
182
+ av.pending_actions() # queued behind an approval
183
+ av.evaluate("stripe", "charge") # dry-run the decision, no side effect
184
+ ```
185
+
186
+ `evaluate()` is worth calling before anything destructive.
187
+
188
+ ## Self-hosting
189
+
190
+ Point `proxy_url` at your own deployment. Everything else is identical.
191
+
192
+ ## License
193
+
194
+ MIT
@@ -0,0 +1,162 @@
1
+ # agentvalet
2
+
3
+ Call approved SaaS platforms from any Python agent. Your agent never holds the
4
+ downstream credential — AgentValet signs a short-lived identity assertion,
5
+ checks the call against the owner's grants and policy, injects the credential at
6
+ call time, and writes an audit record.
7
+
8
+ This is the Python port of
9
+ [`@agentvalet/client`](https://www.npmjs.com/package/@agentvalet/client). Same
10
+ endpoints, same approval semantics, same timings.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install agentvalet
16
+ ```
17
+
18
+ Python 3.9+. Two dependencies: `httpx` and `pyjwt[crypto]`.
19
+
20
+ ## Get an agent identity
21
+
22
+ ```bash
23
+ agentvalet register --code <invite-or-enrollment-code>
24
+ ```
25
+
26
+ The RSA keypair is generated **on your machine**. Only the public half is sent;
27
+ the private key is written to `~/.agentvalet/agent.key` (mode 0600) and never
28
+ crosses the wire. The code is either an invite's bind secret or the enrollment
29
+ code from the "Try it free" flow.
30
+
31
+ This writes the same files as `npx @agentvalet/register`, so the Node and Python
32
+ tooling are interchangeable — you do not need Node installed.
33
+
34
+ ## Use it
35
+
36
+ ```python
37
+ from agentvalet import AgentValet
38
+
39
+ av = AgentValet.from_env()
40
+
41
+ result = av.call(
42
+ platform="slack",
43
+ endpoint="/api/chat.postMessage",
44
+ method="POST",
45
+ scope="chat:write",
46
+ data={"channel": "#general", "text": "Deploy finished."},
47
+ )
48
+ ```
49
+
50
+ `from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
51
+ `AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
52
+ `AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
53
+ wire it explicitly:
54
+
55
+ ```python
56
+ av = AgentValet(
57
+ agent_id=os.environ["AGENT_ID"],
58
+ owner_id=os.environ["OWNER_ID"],
59
+ private_key=os.environ["AGENT_PRIVATE_KEY"],
60
+ proxy_url="https://api.agentvalet.ai", # default
61
+ )
62
+ ```
63
+
64
+ Use it as a context manager to close the HTTP client cleanly:
65
+
66
+ ```python
67
+ with AgentValet.from_env() as av:
68
+ av.call(platform="github", endpoint="/user", scope="read")
69
+ ```
70
+
71
+ ## Async
72
+
73
+ `AsyncAgentValet` is a method-for-method mirror. All decision logic is shared,
74
+ and the test suite runs the same behaviour table against both.
75
+
76
+ ```python
77
+ from agentvalet import AsyncAgentValet
78
+
79
+ async with AsyncAgentValet.from_env() as av:
80
+ result = await av.call(
81
+ platform="slack", endpoint="/api/chat.postMessage",
82
+ method="POST", scope="chat:write", data={...},
83
+ )
84
+ ```
85
+
86
+ ## Approvals are just a slower call
87
+
88
+ When the owner has marked a scope as approval-gated, the proxy holds the action
89
+ and `call()` waits. If the owner approves, the proxy re-runs the call and you
90
+ get the result — from your code's point of view it simply took longer.
91
+
92
+ ```python
93
+ av = AgentValet.from_env(
94
+ on_approval_pending=lambda i: print(f"waiting… {i['elapsed_s']:.0f}s")
95
+ )
96
+ ```
97
+
98
+ If nobody responds inside the budget (50s by default), you get an
99
+ `ApprovalTimeoutError` — **not a failure**. The action stays queued. Keep the
100
+ `approval_id` and resume whenever you like, in this process or another:
101
+
102
+ ```python
103
+ from agentvalet import ApprovalTimeoutError
104
+
105
+ try:
106
+ av.call(platform="stripe", endpoint="/v1/refunds", method="POST", scope="charge")
107
+ except ApprovalTimeoutError as err:
108
+ queue.put(err.approval_id) # hand off to a worker
109
+
110
+ # …later, elsewhere:
111
+ result = av.wait_for_approval(approval_id)
112
+ ```
113
+
114
+ Pass `approval_timeout_s=0` if you never want to block.
115
+
116
+ ## Errors you can branch on
117
+
118
+ Every exception is typed, so you never string-match an error envelope:
119
+
120
+ | Error | Means | What to do |
121
+ |---|---|---|
122
+ | `ConfigError` | Bad/missing identity or key | Fix config; raised before any network call |
123
+ | `NetworkError` | Transport failed | `.hint` diagnoses DNS / TLS / firewall / timeout |
124
+ | `AccessDeniedError` | No grant, or policy blocked it | `request_access()` — see below |
125
+ | `ApprovalDeniedError` | Owner said no | Terminal. Don't retry |
126
+ | `ApprovalExpiredError` | Aged out server-side | Re-issue the call |
127
+ | `ApprovalTimeoutError` | You stopped waiting | Resume via `wait_for_approval()` |
128
+ | `UpstreamError` | The SaaS returned non-2xx | `.status` / `.data` hold the upstream reply |
129
+ | `ProxyError` | Anything else from the broker | `.status` / `.body` |
130
+
131
+ ## Asking for access you don't have
132
+
133
+ Deny-by-default means a scope you were never granted raises
134
+ `AccessDeniedError`. Your agent can ask for it:
135
+
136
+ ```python
137
+ result = av.request_access(
138
+ platform="slack",
139
+ scope="chat:write",
140
+ reason="Post deploy notifications to #general",
141
+ )
142
+ if result["status"] == "approved":
143
+ ... # retry the original call
144
+ ```
145
+
146
+ ## Checking before you act
147
+
148
+ ```python
149
+ av.list_platforms() # what this agent is actually granted
150
+ av.pending_actions() # queued behind an approval
151
+ av.evaluate("stripe", "charge") # dry-run the decision, no side effect
152
+ ```
153
+
154
+ `evaluate()` is worth calling before anything destructive.
155
+
156
+ ## Self-hosting
157
+
158
+ Point `proxy_url` at your own deployment. Everything else is identical.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentvalet"
7
+ version = "0.1.0"
8
+ description = "Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AgentValet" }]
13
+ keywords = ["ai", "agents", "agentvalet", "credentials", "authorization", "sdk", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Security",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.24",
29
+ # [crypto] pulls `cryptography`, which also gives us RSA keygen for
30
+ # `agentvalet register` at no extra dependency cost.
31
+ "pyjwt[crypto]>=2.6",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=7.4",
37
+ "pytest-asyncio>=0.21",
38
+ "respx>=0.20",
39
+ "mypy>=1.8",
40
+ "ruff>=0.4",
41
+ ]
42
+
43
+ [project.scripts]
44
+ agentvalet = "agentvalet.register:main"
45
+
46
+ [project.urls]
47
+ Homepage = "https://agentvalet.ai"
48
+ Documentation = "https://docs.agentvalet.ai"
49
+ Source = "https://github.com/MCSEdwin/agentvalet"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/agentvalet"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py39"
61
+
62
+ # Pin the rule set explicitly. Relying on ruff's defaults means the lint
63
+ # surface silently changes on a ruff upgrade.
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "UP", "B", "RUF"]
66
+
67
+ [tool.ruff.lint.per-file-ignores]
68
+ # Tests import their own conftest by name and shadow builtins freely.
69
+ "tests/*" = ["E402", "E501"] # long inline fixtures read better unwrapped
70
+
71
+ [tool.mypy]
72
+ # No python_version pin: recent mypy refuses 3.9 as a target, and the real
73
+ # floor is enforced by requires-python. Strict still applies.
74
+ strict = true
@@ -0,0 +1,55 @@
1
+ """agentvalet — call approved SaaS platforms from any Python agent.
2
+
3
+ Your agent never holds the downstream credential. This client signs a
4
+ short-lived RS256 identity assertion; the AgentValet broker checks it against
5
+ the owner's grants and policy, injects the credential at call time, and writes
6
+ an audit record.
7
+
8
+ from agentvalet import AgentValet
9
+
10
+ av = AgentValet.from_env()
11
+ av.call(
12
+ platform="slack",
13
+ endpoint="/api/chat.postMessage",
14
+ method="POST",
15
+ scope="chat:write",
16
+ data={"channel": "#general", "text": "Deploy finished."},
17
+ )
18
+
19
+ Use :class:`agentvalet.aio.AsyncAgentValet` for the async equivalent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from ._constants import DEFAULT_PROXY_URL
25
+ from .aio import AsyncAgentValet
26
+ from .client import AgentValet
27
+ from .errors import (
28
+ AccessDeniedError,
29
+ AgentValetError,
30
+ ApprovalDeniedError,
31
+ ApprovalExpiredError,
32
+ ApprovalTimeoutError,
33
+ ConfigError,
34
+ NetworkError,
35
+ ProxyError,
36
+ UpstreamError,
37
+ )
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "DEFAULT_PROXY_URL",
43
+ "AccessDeniedError",
44
+ "AgentValet",
45
+ "AgentValetError",
46
+ "ApprovalDeniedError",
47
+ "ApprovalExpiredError",
48
+ "ApprovalTimeoutError",
49
+ "AsyncAgentValet",
50
+ "ConfigError",
51
+ "NetworkError",
52
+ "ProxyError",
53
+ "UpstreamError",
54
+ "__version__",
55
+ ]
@@ -0,0 +1,31 @@
1
+ """Behavioural contract with the AgentValet proxy.
2
+
3
+ These are NOT preferences. Each value is matched by
4
+ ``packages/client/src/client.ts`` (TypeScript) and ``apps/mcp-server``. If one
5
+ of them changes here and not there, an operator debugging the Python SDK is no
6
+ longer debugging the same system as the Node one.
7
+
8
+ ``tests/test_constants.py`` asserts every value, so changing one is a
9
+ deliberate, reviewed act rather than a drive-by edit.
10
+
11
+ Note the unit: the TypeScript client uses milliseconds; Python uses **seconds**,
12
+ matching the httpx/requests convention. The underlying values are the same.
13
+ """
14
+
15
+ DEFAULT_PROXY_URL = "https://api.agentvalet.ai"
16
+
17
+ #: Approval long-poll budget. Sits under Claude Desktop's hardcoded 60s tool
18
+ #: timeout so the MCP path and the SDK path give up at the same moment.
19
+ APPROVAL_TIMEOUT_S = 50.0
20
+
21
+ #: How often we ask the proxy whether the owner has decided.
22
+ APPROVAL_POLL_S = 2.0
23
+
24
+ #: Per-request network timeout.
25
+ REQUEST_TIMEOUT_S = 15.0
26
+
27
+ #: Agent assertions are deliberately short-lived; the proxy rejects long-dated
28
+ #: ones. Seconds.
29
+ JWT_LIFETIME_S = 60
30
+
31
+ JWT_ALGORITHM = "RS256"
@@ -0,0 +1,168 @@
1
+ """Pure protocol logic — no I/O, no awaits.
2
+
3
+ Everything that decides *what* to send and *what a response means* lives here,
4
+ so the sync and async clients can share it verbatim. Only transport differs
5
+ between them. If you find yourself adding a branch to one client and not the
6
+ other, it belongs in this module instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
15
+ from .errors import (
16
+ AccessDeniedError,
17
+ ApprovalDeniedError,
18
+ ApprovalExpiredError,
19
+ ProxyError,
20
+ UpstreamError,
21
+ )
22
+
23
+
24
+ def build_action_body(
25
+ platform: str,
26
+ endpoint: str,
27
+ scope: str,
28
+ method: str = "GET",
29
+ data: Any = None,
30
+ connection_id: str | None = None,
31
+ reason: str | None = None,
32
+ ) -> dict[str, Any]:
33
+ """Wire body for ``POST /v1/actions``.
34
+
35
+ Optional fields are omitted entirely rather than sent as null — the proxy
36
+ distinguishes absent from null for ``data``.
37
+ """
38
+ body: dict[str, Any] = {
39
+ "platform": platform,
40
+ "endpoint": endpoint,
41
+ "method": method,
42
+ "scope": scope,
43
+ }
44
+ if data is not None:
45
+ body["data"] = data
46
+ if connection_id:
47
+ body["connection_id"] = connection_id
48
+ if reason:
49
+ body["reason"] = reason
50
+ return body
51
+
52
+
53
+ def authzen_body(agent_id: str, platform: str, scope: str) -> dict[str, Any]:
54
+ return {
55
+ "subject": {"type": "agent", "id": agent_id},
56
+ "action": {"name": "tool_call"},
57
+ "resource": {"type": "platform_scope", "id": f"{platform}:{scope}"},
58
+ }
59
+
60
+
61
+ def try_parse_json(text: str) -> Any:
62
+ """Parse only if it looks structured; otherwise hand back the raw text.
63
+
64
+ Mirrors the Node client so a non-JSON upstream body (a plain-text error,
65
+ an empty 204) surfaces identically in both SDKs.
66
+ """
67
+ trimmed = text.strip()
68
+ if not (trimmed.startswith("{") or trimmed.startswith("[")):
69
+ return text
70
+ try:
71
+ return json.loads(trimmed)
72
+ except ValueError:
73
+ return text
74
+
75
+
76
+ def approval_id_from_202(text: str) -> str | None:
77
+ parsed = try_parse_json(text)
78
+ if isinstance(parsed, dict):
79
+ value = parsed.get("approval_id")
80
+ if isinstance(value, str):
81
+ return value
82
+ return None
83
+
84
+
85
+ def interpret_action_response(
86
+ status: int, text: str, platform: str, scope: str
87
+ ) -> Any:
88
+ """Turn a ``/v1/actions`` reply into a value or the right exception.
89
+
90
+ Callers must handle the 202 branch *before* this — an approval is a
91
+ continuation, not a result.
92
+ """
93
+ if status == 403:
94
+ # The proxy's authorize pipeline denies with 403 for both "no grant"
95
+ # and "policy blocked". Its own type so callers can branch into
96
+ # request_access() instead of dead-ending on a generic failure.
97
+ raise AccessDeniedError(status, text, platform, scope)
98
+ if not 200 <= status < 300:
99
+ raise ProxyError(status, text)
100
+ return try_parse_json(text)
101
+
102
+
103
+ def interpret_approval_status(payload: Mapping[str, Any], approval_id: str) -> tuple[bool, Any]:
104
+ """Interpret one poll of ``GET /v1/approvals/:id``.
105
+
106
+ Returns ``(done, value)``. ``done=False`` means keep polling. Terminal
107
+ failures raise.
108
+ """
109
+ status = payload.get("status")
110
+
111
+ if status == "denied":
112
+ raise ApprovalDeniedError(approval_id, _as_str(payload.get("execution_error")))
113
+ if status == "expired":
114
+ raise ApprovalExpiredError(approval_id)
115
+
116
+ execution_error = payload.get("execution_error")
117
+ if execution_error:
118
+ # Approved, but re-execution failed (upstream down, agent revoked
119
+ # between approval and execution).
120
+ raise ProxyError(502, f"Approved but re-execution failed: {execution_error}")
121
+
122
+ if status == "approved" and payload.get("executed_at") and payload.get("result"):
123
+ result = payload["result"]
124
+ upstream_status = int(result.get("status", 0))
125
+ if not 200 <= upstream_status < 300:
126
+ raise UpstreamError(upstream_status, result.get("data"))
127
+ return True, result.get("data")
128
+
129
+ return False, None
130
+
131
+
132
+ def diagnose_network_error(err: BaseException, proxy_url: str) -> str:
133
+ """Turn a transport failure into something a developer can act on.
134
+
135
+ httpx's bare "Connection error" says nothing about whether it's DNS, a
136
+ corporate MITM proxy, or the broker being down.
137
+ """
138
+ raw = str(err)
139
+ lower = raw.lower()
140
+
141
+ if "getaddrinfo" in lower or "name or service not known" in lower or "nodename" in lower:
142
+ return (
143
+ f"Cannot resolve {proxy_url}. Check DNS / corporate proxy / VPN, or confirm "
144
+ f"proxy_url is correct. Raw: {raw}"
145
+ )
146
+ if "refused" in lower or "reset" in lower:
147
+ return (
148
+ f"Connection to {proxy_url} was refused or reset. The broker may be down — check "
149
+ f"https://status.agentvalet.ai — or a firewall is blocking it. Raw: {raw}"
150
+ )
151
+ if "timeout" in lower or "timed out" in lower:
152
+ return (
153
+ f"Request to {proxy_url} timed out. Likely VPN routing, corporate proxy buffering, "
154
+ f"or a slow network. Raw: {raw}"
155
+ )
156
+ if "certificate" in lower or "ssl" in lower or "tls" in lower:
157
+ return (
158
+ f"TLS / certificate problem talking to {proxy_url}. A corporate MITM proxy may be "
159
+ f"intercepting traffic. Raw: {raw}"
160
+ )
161
+ return (
162
+ f"Network error reaching {proxy_url}: {raw}. Check VPN, corporate proxy, and firewall "
163
+ f"rules for api.agentvalet.ai."
164
+ )
165
+
166
+
167
+ def _as_str(value: Any) -> str | None:
168
+ return value if isinstance(value, str) else None