agentvalet 0.1.0__py3-none-any.whl
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.
- agentvalet/__init__.py +55 -0
- agentvalet/_constants.py +31 -0
- agentvalet/_protocol.py +168 -0
- agentvalet/_signing.py +33 -0
- agentvalet/aio.py +267 -0
- agentvalet/client.py +315 -0
- agentvalet/env.py +109 -0
- agentvalet/errors.py +111 -0
- agentvalet/py.typed +0 -0
- agentvalet/register.py +183 -0
- agentvalet-0.1.0.dist-info/METADATA +194 -0
- agentvalet-0.1.0.dist-info/RECORD +14 -0
- agentvalet-0.1.0.dist-info/WHEEL +4 -0
- agentvalet-0.1.0.dist-info/entry_points.txt +2 -0
agentvalet/__init__.py
ADDED
|
@@ -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
|
+
]
|
agentvalet/_constants.py
ADDED
|
@@ -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"
|
agentvalet/_protocol.py
ADDED
|
@@ -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
|
agentvalet/_signing.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""RS256 agent assertion minting.
|
|
2
|
+
|
|
3
|
+
Split out so both clients share it and so the failure mode is one place: a
|
|
4
|
+
malformed PEM must surface as :class:`ConfigError`, never as a raw PyJWT or
|
|
5
|
+
cryptography traceback that sends the reader looking in the wrong library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
|
|
15
|
+
from ._constants import JWT_ALGORITHM, JWT_LIFETIME_S
|
|
16
|
+
from .errors import ConfigError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sign_jwt(agent_id: str, owner_id: str, private_key_pem: str) -> str:
|
|
20
|
+
now = int(time.time())
|
|
21
|
+
claims: dict[str, Any] = {
|
|
22
|
+
"agent_id": agent_id,
|
|
23
|
+
"owner_id": owner_id,
|
|
24
|
+
"iat": now,
|
|
25
|
+
"exp": now + JWT_LIFETIME_S,
|
|
26
|
+
}
|
|
27
|
+
try:
|
|
28
|
+
return jwt.encode(claims, private_key_pem, algorithm=JWT_ALGORITHM)
|
|
29
|
+
except Exception as err:
|
|
30
|
+
raise ConfigError(
|
|
31
|
+
f"Private key is not a valid RS256 PEM: {err}. Expected a PKCS#8 private key — "
|
|
32
|
+
"run `agentvalet register` to generate one."
|
|
33
|
+
) from err
|
agentvalet/aio.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Asynchronous AgentValet client.
|
|
2
|
+
|
|
3
|
+
A method-for-method mirror of :class:`agentvalet.client.AgentValet`. All the
|
|
4
|
+
decision logic lives in :mod:`agentvalet._protocol`, which both clients share,
|
|
5
|
+
so the only thing that differs here is the transport and the awaits. The test
|
|
6
|
+
suite runs the same behaviour table against both classes to keep it that way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable
|
|
14
|
+
from typing import Any, Callable, Union
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._constants import (
|
|
19
|
+
APPROVAL_POLL_S,
|
|
20
|
+
APPROVAL_TIMEOUT_S,
|
|
21
|
+
DEFAULT_PROXY_URL,
|
|
22
|
+
REQUEST_TIMEOUT_S,
|
|
23
|
+
)
|
|
24
|
+
from ._protocol import (
|
|
25
|
+
approval_id_from_202,
|
|
26
|
+
authzen_body,
|
|
27
|
+
build_action_body,
|
|
28
|
+
diagnose_network_error,
|
|
29
|
+
interpret_action_response,
|
|
30
|
+
interpret_approval_status,
|
|
31
|
+
try_parse_json,
|
|
32
|
+
)
|
|
33
|
+
from ._signing import sign_jwt
|
|
34
|
+
from .env import agent_id_from_env, owner_id_from_env, private_key_from_env, proxy_url_from_env
|
|
35
|
+
from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
|
|
36
|
+
|
|
37
|
+
__all__ = ["AsyncAgentValet"]
|
|
38
|
+
|
|
39
|
+
AsyncProgressCallback = Callable[[dict[str, Any]], Union[Awaitable[None], None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncAgentValet:
|
|
43
|
+
"""Async twin of :class:`agentvalet.client.AgentValet`."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
agent_id: str,
|
|
48
|
+
owner_id: str,
|
|
49
|
+
private_key: str,
|
|
50
|
+
proxy_url: str = DEFAULT_PROXY_URL,
|
|
51
|
+
timeout_s: float = REQUEST_TIMEOUT_S,
|
|
52
|
+
approval_timeout_s: float = APPROVAL_TIMEOUT_S,
|
|
53
|
+
approval_poll_s: float = APPROVAL_POLL_S,
|
|
54
|
+
on_approval_pending: AsyncProgressCallback | None = None,
|
|
55
|
+
transport: httpx.AsyncClient | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not agent_id:
|
|
58
|
+
raise ConfigError("agent_id is required")
|
|
59
|
+
if not owner_id:
|
|
60
|
+
raise ConfigError("owner_id is required")
|
|
61
|
+
if not private_key:
|
|
62
|
+
raise ConfigError("private_key is required")
|
|
63
|
+
|
|
64
|
+
self.agent_id = agent_id
|
|
65
|
+
self.owner_id = owner_id
|
|
66
|
+
self.proxy_url = proxy_url.rstrip("/")
|
|
67
|
+
self._private_key = private_key
|
|
68
|
+
self._timeout_s = timeout_s
|
|
69
|
+
self._approval_timeout_s = approval_timeout_s
|
|
70
|
+
self._approval_poll_s = approval_poll_s
|
|
71
|
+
self._on_approval_pending = on_approval_pending
|
|
72
|
+
self._owns_client = transport is None
|
|
73
|
+
self._http = transport or httpx.AsyncClient(timeout=timeout_s)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_env(cls, **overrides: Any) -> AsyncAgentValet:
|
|
77
|
+
agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
|
|
78
|
+
owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
|
|
79
|
+
if not agent_id or not owner_id:
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
"AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
|
|
82
|
+
"AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
|
|
83
|
+
)
|
|
84
|
+
return cls(
|
|
85
|
+
agent_id=agent_id,
|
|
86
|
+
owner_id=owner_id,
|
|
87
|
+
private_key=overrides.pop("private_key", None) or private_key_from_env(),
|
|
88
|
+
proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
|
|
89
|
+
**overrides,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def __aenter__(self) -> AsyncAgentValet:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
96
|
+
await self.aclose()
|
|
97
|
+
|
|
98
|
+
async def aclose(self) -> None:
|
|
99
|
+
if self._owns_client:
|
|
100
|
+
await self._http.aclose()
|
|
101
|
+
|
|
102
|
+
# -- core --------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
async def call(
|
|
105
|
+
self,
|
|
106
|
+
platform: str,
|
|
107
|
+
endpoint: str,
|
|
108
|
+
scope: str,
|
|
109
|
+
method: str = "GET",
|
|
110
|
+
data: Any = None,
|
|
111
|
+
connection_id: str | None = None,
|
|
112
|
+
reason: str | None = None,
|
|
113
|
+
approval_timeout_s: float | None = None,
|
|
114
|
+
) -> Any:
|
|
115
|
+
"""See :meth:`agentvalet.client.AgentValet.call`."""
|
|
116
|
+
body = build_action_body(
|
|
117
|
+
platform=platform,
|
|
118
|
+
endpoint=endpoint,
|
|
119
|
+
scope=scope,
|
|
120
|
+
method=method,
|
|
121
|
+
data=data,
|
|
122
|
+
connection_id=connection_id,
|
|
123
|
+
reason=reason,
|
|
124
|
+
)
|
|
125
|
+
response = await self._authed_request("POST", "/v1/actions", json=body)
|
|
126
|
+
text = response.text
|
|
127
|
+
|
|
128
|
+
if response.status_code == 202:
|
|
129
|
+
approval_id = approval_id_from_202(text)
|
|
130
|
+
if approval_id:
|
|
131
|
+
return await self.wait_for_approval(
|
|
132
|
+
approval_id,
|
|
133
|
+
platform=platform,
|
|
134
|
+
scope=scope,
|
|
135
|
+
endpoint=endpoint,
|
|
136
|
+
timeout_s=approval_timeout_s,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return interpret_action_response(response.status_code, text, platform, scope)
|
|
140
|
+
|
|
141
|
+
async def wait_for_approval(
|
|
142
|
+
self,
|
|
143
|
+
approval_id: str,
|
|
144
|
+
platform: str = "",
|
|
145
|
+
scope: str = "",
|
|
146
|
+
endpoint: str = "",
|
|
147
|
+
timeout_s: float | None = None,
|
|
148
|
+
) -> Any:
|
|
149
|
+
"""See :meth:`agentvalet.client.AgentValet.wait_for_approval`."""
|
|
150
|
+
budget = self._approval_timeout_s if timeout_s is None else timeout_s
|
|
151
|
+
started = time.monotonic()
|
|
152
|
+
|
|
153
|
+
while time.monotonic() - started < budget:
|
|
154
|
+
await asyncio.sleep(self._approval_poll_s)
|
|
155
|
+
elapsed = time.monotonic() - started
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
response = await self._authed_request("GET", f"/v1/approvals/{approval_id}")
|
|
159
|
+
except NetworkError:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if response.status_code >= 400:
|
|
163
|
+
raise ProxyError(response.status_code, response.text)
|
|
164
|
+
|
|
165
|
+
done, value = interpret_approval_status(response.json(), approval_id)
|
|
166
|
+
if done:
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
if self._on_approval_pending:
|
|
170
|
+
maybe = self._on_approval_pending(
|
|
171
|
+
{
|
|
172
|
+
"approval_id": approval_id,
|
|
173
|
+
"elapsed_s": elapsed,
|
|
174
|
+
"platform": platform,
|
|
175
|
+
"scope": scope,
|
|
176
|
+
"endpoint": endpoint,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
# Accept either a plain callback or a coroutine function.
|
|
180
|
+
if asyncio.iscoroutine(maybe):
|
|
181
|
+
await maybe
|
|
182
|
+
|
|
183
|
+
raise ApprovalTimeoutError(approval_id, budget)
|
|
184
|
+
|
|
185
|
+
# -- discovery / governance -------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def list_platforms(self) -> Any:
|
|
188
|
+
return await self._get_json("/v1/agent/permissions")
|
|
189
|
+
|
|
190
|
+
async def pending_actions(self) -> Any:
|
|
191
|
+
return await self._get_json("/v1/agents/me/pending-actions")
|
|
192
|
+
|
|
193
|
+
async def evaluate(self, platform: str, scope: str) -> Any:
|
|
194
|
+
response = await self._authed_request(
|
|
195
|
+
"POST", "/v1/authzen/access", json=authzen_body(self.agent_id, platform, scope)
|
|
196
|
+
)
|
|
197
|
+
if response.status_code >= 400:
|
|
198
|
+
raise ProxyError(response.status_code, response.text)
|
|
199
|
+
return try_parse_json(response.text)
|
|
200
|
+
|
|
201
|
+
async def request_access(
|
|
202
|
+
self,
|
|
203
|
+
platform: str,
|
|
204
|
+
scope: str | None = None,
|
|
205
|
+
reason: str | None = None,
|
|
206
|
+
timeout_s: float | None = None,
|
|
207
|
+
) -> dict[str, str]:
|
|
208
|
+
submit = await self._authed_request(
|
|
209
|
+
"POST",
|
|
210
|
+
"/v1/access-request",
|
|
211
|
+
json={"platform": platform, "scope": scope, "reason": reason},
|
|
212
|
+
)
|
|
213
|
+
if submit.status_code >= 400:
|
|
214
|
+
raise ProxyError(submit.status_code, submit.text)
|
|
215
|
+
|
|
216
|
+
token = submit.json().get("request_token")
|
|
217
|
+
if not token:
|
|
218
|
+
raise ProxyError(submit.status_code, "Access request did not return a request_token.")
|
|
219
|
+
|
|
220
|
+
budget = self._approval_timeout_s if timeout_s is None else timeout_s
|
|
221
|
+
deadline = time.monotonic() + budget
|
|
222
|
+
while time.monotonic() < deadline:
|
|
223
|
+
await asyncio.sleep(self._approval_poll_s)
|
|
224
|
+
try:
|
|
225
|
+
status_res = await self._authed_request("GET", f"/v1/access-request/status/{token}")
|
|
226
|
+
except NetworkError:
|
|
227
|
+
continue
|
|
228
|
+
if status_res.status_code >= 400:
|
|
229
|
+
continue
|
|
230
|
+
status = status_res.json().get("status")
|
|
231
|
+
if status in ("approved", "denied"):
|
|
232
|
+
return {"status": status, "request_token": token}
|
|
233
|
+
|
|
234
|
+
return {"status": "pending", "request_token": token}
|
|
235
|
+
|
|
236
|
+
# -- transport ---------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def sign_jwt(self) -> str:
|
|
239
|
+
return sign_jwt(self.agent_id, self.owner_id, self._private_key)
|
|
240
|
+
|
|
241
|
+
async def _authed_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
242
|
+
url = f"{self.proxy_url}{path}"
|
|
243
|
+
|
|
244
|
+
async def once() -> httpx.Response:
|
|
245
|
+
token = self.sign_jwt()
|
|
246
|
+
try:
|
|
247
|
+
return await self._http.request(
|
|
248
|
+
method,
|
|
249
|
+
url,
|
|
250
|
+
headers={
|
|
251
|
+
"Authorization": f"Bearer {token}",
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
},
|
|
254
|
+
timeout=self._timeout_s,
|
|
255
|
+
**kwargs,
|
|
256
|
+
)
|
|
257
|
+
except httpx.HTTPError as err:
|
|
258
|
+
raise NetworkError(diagnose_network_error(err, self.proxy_url), err) from err
|
|
259
|
+
|
|
260
|
+
response = await once()
|
|
261
|
+
return await once() if response.status_code == 401 else response
|
|
262
|
+
|
|
263
|
+
async def _get_json(self, path: str) -> Any:
|
|
264
|
+
response = await self._authed_request("GET", path)
|
|
265
|
+
if response.status_code >= 400:
|
|
266
|
+
raise ProxyError(response.status_code, response.text)
|
|
267
|
+
return try_parse_json(response.text)
|