aynigma 0.3.1__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.
- aynigma/__init__.py +61 -0
- aynigma/agent_errors.py +89 -0
- aynigma/agent_tokens.py +404 -0
- aynigma/agents.py +1366 -0
- aynigma/approval_checkpoints.py +170 -0
- aynigma/assess.py +94 -0
- aynigma/assess_plugins.py +42 -0
- aynigma/audit_verify.py +491 -0
- aynigma/client.py +529 -0
- aynigma/discover.py +41 -0
- aynigma/discover_azure.py +48 -0
- aynigma/discover_cloud.py +46 -0
- aynigma/discover_refresh.py +59 -0
- aynigma/discover_vertex.py +43 -0
- aynigma/mcp_oauth.py +61 -0
- aynigma/package_verify.py +369 -0
- aynigma/py.typed +0 -0
- aynigma-0.3.1.dist-info/METADATA +218 -0
- aynigma-0.3.1.dist-info/RECORD +21 -0
- aynigma-0.3.1.dist-info/WHEEL +4 -0
- aynigma-0.3.1.dist-info/entry_points.txt +12 -0
aynigma/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
APIError,
|
|
3
|
+
AsyncAynigma,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
Aynigma,
|
|
6
|
+
AynigmaError,
|
|
7
|
+
BlockedError,
|
|
8
|
+
QuotaExceededError,
|
|
9
|
+
ValidationResult,
|
|
10
|
+
aguard_openai_chat,
|
|
11
|
+
aguard_openai_chat_v2,
|
|
12
|
+
guard_openai_chat,
|
|
13
|
+
guard_openai_chat_v2,
|
|
14
|
+
)
|
|
15
|
+
from .agent_errors import MCPError, TokenError
|
|
16
|
+
from .agent_tokens import AgentTokenProvider, DelegatedToken
|
|
17
|
+
from .agents import AgentAuthorizer, ApprovalRequired, RunKilled, ClaudeAgentApprovalBridge, ExecutionReceipt, RemoteTool, ToolDenied, ToolExecutor, OpenAIAgentsApprovalBridge, SemanticKernelApprovalBridge, acting_for_user, as_langchain_tool, build_langgraph_approval_workflow, build_crewai_approval_flow, as_openai_agents_tool, as_crewai_tool, as_claude_agent_tool, as_google_adk_tool, as_google_adk_approval_tool, as_semantic_kernel_function
|
|
18
|
+
from .approval_checkpoints import PostgresApprovalCheckpointStore
|
|
19
|
+
|
|
20
|
+
async_guard_openai_chat = aguard_openai_chat
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"APIError",
|
|
24
|
+
"AgentAuthorizer",
|
|
25
|
+
"AgentTokenProvider",
|
|
26
|
+
"DelegatedToken",
|
|
27
|
+
"RunKilled",
|
|
28
|
+
"TokenError",
|
|
29
|
+
"MCPError",
|
|
30
|
+
"ApprovalRequired",
|
|
31
|
+
"ClaudeAgentApprovalBridge",
|
|
32
|
+
"AsyncAynigma",
|
|
33
|
+
"AuthenticationError",
|
|
34
|
+
"Aynigma",
|
|
35
|
+
"AynigmaError",
|
|
36
|
+
"BlockedError",
|
|
37
|
+
"ExecutionReceipt",
|
|
38
|
+
"OpenAIAgentsApprovalBridge",
|
|
39
|
+
"PostgresApprovalCheckpointStore",
|
|
40
|
+
"SemanticKernelApprovalBridge",
|
|
41
|
+
"QuotaExceededError",
|
|
42
|
+
"RemoteTool",
|
|
43
|
+
"ToolDenied",
|
|
44
|
+
"ToolExecutor",
|
|
45
|
+
"acting_for_user",
|
|
46
|
+
"as_langchain_tool",
|
|
47
|
+
"build_langgraph_approval_workflow",
|
|
48
|
+
"build_crewai_approval_flow",
|
|
49
|
+
"as_openai_agents_tool",
|
|
50
|
+
"as_crewai_tool",
|
|
51
|
+
"as_claude_agent_tool",
|
|
52
|
+
"as_google_adk_tool",
|
|
53
|
+
"as_google_adk_approval_tool",
|
|
54
|
+
"as_semantic_kernel_function",
|
|
55
|
+
"ValidationResult",
|
|
56
|
+
"aguard_openai_chat",
|
|
57
|
+
"aguard_openai_chat_v2",
|
|
58
|
+
"async_guard_openai_chat",
|
|
59
|
+
"guard_openai_chat",
|
|
60
|
+
"guard_openai_chat_v2",
|
|
61
|
+
]
|
aynigma/agent_errors.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Exceptions shared by the agent authorizer and the token provider."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .client import APIError, AuthenticationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Short next steps for the denial codes a developer meets most. The code stays first in the message so
|
|
10
|
+
# anything that parses "Agent tool call denied: <code>" keeps working.
|
|
11
|
+
DENIAL_HINTS = {
|
|
12
|
+
"no_policy": "No rule allows this tool for this agent. Add one on the agent page.",
|
|
13
|
+
"run_mismatch": "This approval belongs to another run. Resume with "
|
|
14
|
+
"AgentAuthorizer(..., run_id=<the run_id from ApprovalRequired>).",
|
|
15
|
+
"payload_mismatch": "The arguments differ from the ones that were approved.",
|
|
16
|
+
"approval_already_used": "This approval was already used. Each approval works once, so ask for a new one.",
|
|
17
|
+
"approval_expired": "This approval expired before it was used. Call the tool again to request a new one.",
|
|
18
|
+
"approval_not_approved": "A reviewer has not approved this call yet.",
|
|
19
|
+
"policy_version_changed": "The policy changed after this approval was requested. "
|
|
20
|
+
"Call the tool again to request a new one.",
|
|
21
|
+
"environment": "The matching rule is limited to other environments. "
|
|
22
|
+
"Check the rule's environments and the agent's environment on the agent page.",
|
|
23
|
+
"rate_limited": "The hourly limit on the matching rule is used up. Wait, or raise the limit on the agent page.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolDenied(Exception):
|
|
28
|
+
"""A tool call was refused. ``reason`` is the backend's reason code; ``hint`` is a short next step or None."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, reason: str):
|
|
31
|
+
hint = DENIAL_HINTS.get(reason)
|
|
32
|
+
super().__init__(f"Agent tool call denied: {reason}" + (f". {hint}" if hint else ""))
|
|
33
|
+
self.reason = reason
|
|
34
|
+
self.hint = hint
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RunKilled(ToolDenied):
|
|
38
|
+
"""The agent run was stopped by an administrator or an incident.
|
|
39
|
+
|
|
40
|
+
Cached tokens are dropped when this is raised. The next call starts a new
|
|
41
|
+
run, so catch this to stop the current task, not to retry it blindly.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, reason: str = "run_killed"):
|
|
45
|
+
super().__init__(reason)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApprovalRequired(Exception):
|
|
49
|
+
"""The call needs a human reviewer. Save ``approval_id`` and ``run_id``, then call ``run_tool`` again with
|
|
50
|
+
``approval_id`` from an authorizer that continues the same run (``AgentAuthorizer(..., run_id=run_id)``).
|
|
51
|
+
|
|
52
|
+
``run_id`` is the run the approval was raised in (None with the legacy ``X-AGENT-KEY`` header, which has no
|
|
53
|
+
runs). ``tool_name`` is the tool that was refused, when known.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, approval_id: int, call_id: str, run_id: Optional[str] = None,
|
|
57
|
+
tool_name: Optional[str] = None):
|
|
58
|
+
subject = f"Tool call '{tool_name}'" if tool_name else "Tool call"
|
|
59
|
+
message = (f"{subject} needs human approval (approval {approval_id}). "
|
|
60
|
+
f"A reviewer can approve it in Agent IAM > Activity. "
|
|
61
|
+
f"Then call run_tool again with approval_id={approval_id}")
|
|
62
|
+
if run_id:
|
|
63
|
+
message += f", from a worker started with run_id='{run_id}'"
|
|
64
|
+
super().__init__(message + ".")
|
|
65
|
+
self.approval_id = approval_id
|
|
66
|
+
self.call_id = call_id
|
|
67
|
+
self.run_id = run_id
|
|
68
|
+
self.tool_name = tool_name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TokenError(AuthenticationError):
|
|
72
|
+
"""The token endpoint refused a request (RFC 6749 error shape)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, error: str, description: str = "", status_code: Optional[int] = None):
|
|
75
|
+
super().__init__(f"Agent token request failed: {error}" + (f" ({description})" if description else ""))
|
|
76
|
+
self.error = error
|
|
77
|
+
self.error_description = description
|
|
78
|
+
self.status_code = status_code
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MCPError(APIError):
|
|
82
|
+
"""The MCP gateway refused a request (HTTP 400); ``payload`` is the JSON-RPC error body."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, payload: dict, status_code: Optional[int] = 400):
|
|
85
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
86
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
87
|
+
super().__init__(f"MCP request rejected: {message}" if message else "MCP request rejected", status_code)
|
|
88
|
+
self.payload = payload
|
|
89
|
+
self.error = error
|
aynigma/agent_tokens.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Short-lived agent tokens: client credentials, caching, refresh and delegation.
|
|
2
|
+
|
|
3
|
+
``AgentTokenProvider`` trades an agent key for a 15 minute bearer token and keeps
|
|
4
|
+
it fresh. ``exchange`` turns a human's token (or an already delegated token) into
|
|
5
|
+
a delegated token for this agent (RFC 8693). The key is sent only to the token
|
|
6
|
+
endpoint, never to the tool-call, receipt or MCP endpoints.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import Future
|
|
20
|
+
from typing import Any, Callable, Dict, Optional, TypeVar, Union
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .agent_errors import RunKilled, TokenError
|
|
25
|
+
from .client import APIError
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("aynigma.agents")
|
|
28
|
+
DEFAULT_BASE_URL = "https://api.aynigma.ai/api/v1"
|
|
29
|
+
JWT_TYPE = "urn:ietf:params:oauth:token-type:jwt"
|
|
30
|
+
EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
31
|
+
|
|
32
|
+
TokenLike = Union[str, "DelegatedToken", "AgentTokenProvider"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_base_url(base_url: Optional[str] = None) -> str:
|
|
36
|
+
return (base_url or os.getenv("AYNIGMA_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_claims(token: str) -> Dict[str, Any]:
|
|
40
|
+
"""Decode a JWT payload WITHOUT verifying it. For display and debugging only."""
|
|
41
|
+
try:
|
|
42
|
+
payload = token.split(".")[1]
|
|
43
|
+
payload += "=" * (-len(payload) % 4)
|
|
44
|
+
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
|
45
|
+
except (IndexError, ValueError, UnicodeError):
|
|
46
|
+
raise ValueError("Not a decodable JWT") from None
|
|
47
|
+
if not isinstance(claims, dict):
|
|
48
|
+
raise ValueError("Not a decodable JWT")
|
|
49
|
+
return claims
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _token_value(value: TokenLike) -> str:
|
|
53
|
+
return value if isinstance(value, str) else value.token()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
T = TypeVar("T")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _SingleFlight:
|
|
60
|
+
"""Run one network call for many concurrent callers; the lock is never held during the call."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, lock: "threading.RLock"):
|
|
63
|
+
self._lock = lock
|
|
64
|
+
self._future: Optional[Future] = None
|
|
65
|
+
|
|
66
|
+
def run(self, fresh: Callable[[], Optional[T]], fn: Callable[[], T]) -> T:
|
|
67
|
+
"""``fresh`` runs under the lock and returns a usable cached value, or None."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
hit = fresh()
|
|
70
|
+
if hit is not None:
|
|
71
|
+
return hit
|
|
72
|
+
future = self._future
|
|
73
|
+
if future is None:
|
|
74
|
+
future = Future()
|
|
75
|
+
self._future = future
|
|
76
|
+
leader = True
|
|
77
|
+
else:
|
|
78
|
+
leader = False
|
|
79
|
+
if not leader:
|
|
80
|
+
return future.result()
|
|
81
|
+
try:
|
|
82
|
+
result = fn()
|
|
83
|
+
except BaseException as exc:
|
|
84
|
+
future.set_exception(exc)
|
|
85
|
+
raise
|
|
86
|
+
else:
|
|
87
|
+
future.set_result(result)
|
|
88
|
+
return result
|
|
89
|
+
finally:
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._future = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Issued:
|
|
95
|
+
"""One issued token and the moment it should be replaced."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, response: Dict[str, Any], now: float, leeway: float):
|
|
98
|
+
token = response.get("access_token")
|
|
99
|
+
if not isinstance(token, str) or not token:
|
|
100
|
+
raise APIError("Malformed token response")
|
|
101
|
+
try:
|
|
102
|
+
expires_in = float(response.get("expires_in") or 0)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
raise APIError("Malformed token response") from None
|
|
105
|
+
if expires_in <= 0:
|
|
106
|
+
try:
|
|
107
|
+
expires_in = max(float(decode_claims(token)["exp"]) - now, 1.0)
|
|
108
|
+
except (KeyError, TypeError, ValueError):
|
|
109
|
+
raise APIError("Malformed token response") from None
|
|
110
|
+
self.token = token
|
|
111
|
+
self.scope = str(response.get("scope") or "")
|
|
112
|
+
self.run_id = response.get("run_id") if isinstance(response.get("run_id"), str) else None
|
|
113
|
+
self.expires_at = now + expires_in
|
|
114
|
+
self.refresh_at = now + expires_in * (1.0 - leeway)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DelegatedToken:
|
|
118
|
+
"""A human's authority delegated to one agent. Re-exchanges itself near expiry."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, provider: "AgentTokenProvider", subject: TokenLike, scope: Optional[str],
|
|
121
|
+
issued: _Issued):
|
|
122
|
+
self._provider = provider
|
|
123
|
+
self._subject = subject
|
|
124
|
+
self._requested_scope = scope
|
|
125
|
+
self._issued = issued
|
|
126
|
+
self._lock = threading.RLock()
|
|
127
|
+
self._flight = _SingleFlight(self._lock)
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str: # never show the token
|
|
130
|
+
return "DelegatedToken(scope=%r)" % self._issued.scope
|
|
131
|
+
|
|
132
|
+
def token(self) -> str:
|
|
133
|
+
def fresh() -> Optional[str]:
|
|
134
|
+
issued = self._issued
|
|
135
|
+
return issued.token if self._provider._clock() < issued.refresh_at else None
|
|
136
|
+
|
|
137
|
+
def exchange() -> str:
|
|
138
|
+
issued = self._provider._exchange(self._subject, self._requested_scope)
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._issued = issued
|
|
141
|
+
return issued.token
|
|
142
|
+
return self._flight.run(fresh, exchange)
|
|
143
|
+
|
|
144
|
+
def invalidate(self, failed_token: Optional[str] = None) -> None:
|
|
145
|
+
"""Force the next ``token()`` to exchange again (after a 401).
|
|
146
|
+
|
|
147
|
+
With ``failed_token``, only if that is still the current token.
|
|
148
|
+
"""
|
|
149
|
+
with self._lock:
|
|
150
|
+
if failed_token is None or self._issued.token == failed_token:
|
|
151
|
+
self._issued.refresh_at = 0.0
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def expires_at(self) -> float:
|
|
155
|
+
return self._issued.expires_at
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def scope(self) -> str:
|
|
159
|
+
return self._issued.scope
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def expired(self) -> bool:
|
|
163
|
+
return self._provider._clock() >= self._issued.expires_at
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def provider(self) -> "AgentTokenProvider":
|
|
167
|
+
return self._provider
|
|
168
|
+
|
|
169
|
+
def claims(self) -> Dict[str, Any]:
|
|
170
|
+
return decode_claims(self._issued.token)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_ACTOR_OR_RUN = re.compile(r"\b(actor|run)")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AgentTokenProvider:
|
|
177
|
+
"""Fetch, cache and refresh a client credentials token for one agent.
|
|
178
|
+
|
|
179
|
+
The token is fetched on first use, reused, and replaced once ``leeway`` (a
|
|
180
|
+
fraction, default 0.2) of its lifetime is left. Thread safe: concurrent
|
|
181
|
+
callers share one fetch, and no lock is held during network calls.
|
|
182
|
+
|
|
183
|
+
A run that ended (idle) is replaced by a new one transparently (logged at
|
|
184
|
+
INFO). A run that was killed raises ``RunKilled``.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, agent_id: str, agent_key: str, base_url: Optional[str] = None,
|
|
188
|
+
http_client: Optional[httpx.Client] = None, run_id: Optional[str] = None,
|
|
189
|
+
leeway: float = 0.2, timeout: float = 10.0,
|
|
190
|
+
clock: Callable[[], float] = time.time):
|
|
191
|
+
if not agent_id or not agent_key:
|
|
192
|
+
raise ValueError("agent_id and agent_key are required")
|
|
193
|
+
if not 0 <= leeway < 1:
|
|
194
|
+
raise ValueError("leeway must be at least 0 and below 1")
|
|
195
|
+
self.agent_id = str(agent_id)
|
|
196
|
+
self._key = agent_key
|
|
197
|
+
self.base_url = resolve_base_url(base_url)
|
|
198
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
199
|
+
self._owns_http = http_client is None
|
|
200
|
+
self._leeway = leeway
|
|
201
|
+
self._clock = clock
|
|
202
|
+
self._run_id = run_id
|
|
203
|
+
self._issued: Optional[_Issued] = None
|
|
204
|
+
self._lock = threading.RLock()
|
|
205
|
+
self._flight = _SingleFlight(self._lock)
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str: # never show the key
|
|
208
|
+
return f"AgentTokenProvider(agent_id={self.agent_id!r})"
|
|
209
|
+
|
|
210
|
+
# -- token endpoint -------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def _post_token(self, form: Dict[str, str]) -> Dict[str, Any]:
|
|
213
|
+
try:
|
|
214
|
+
response = self._http.post(f"{self.base_url}/agents/oauth/token", data=form,
|
|
215
|
+
headers={"Accept": "application/json"})
|
|
216
|
+
except httpx.HTTPError:
|
|
217
|
+
raise APIError("Agent token service unavailable") from None
|
|
218
|
+
try:
|
|
219
|
+
payload = response.json()
|
|
220
|
+
except (ValueError, UnicodeDecodeError):
|
|
221
|
+
raise APIError("Malformed token response", response.status_code) from None
|
|
222
|
+
if not isinstance(payload, dict):
|
|
223
|
+
raise APIError("Malformed token response", response.status_code)
|
|
224
|
+
if response.status_code != 200:
|
|
225
|
+
error = payload.get("error")
|
|
226
|
+
if isinstance(error, str) and error:
|
|
227
|
+
raise TokenError(error, str(payload.get("error_description") or ""), response.status_code)
|
|
228
|
+
raise APIError("Agent token request failed", response.status_code)
|
|
229
|
+
return payload
|
|
230
|
+
|
|
231
|
+
def _forget_run(self) -> None:
|
|
232
|
+
with self._lock:
|
|
233
|
+
self._run_id = None
|
|
234
|
+
self._issued = None
|
|
235
|
+
|
|
236
|
+
def _fetch(self, continue_run: bool = True) -> _Issued:
|
|
237
|
+
with self._lock:
|
|
238
|
+
previous = self._run_id
|
|
239
|
+
run_id = previous if continue_run else None
|
|
240
|
+
form = {"grant_type": "client_credentials", "client_id": self.agent_id,
|
|
241
|
+
"client_secret": self._key}
|
|
242
|
+
if run_id:
|
|
243
|
+
form["run_id"] = run_id
|
|
244
|
+
try:
|
|
245
|
+
payload = self._post_token(form)
|
|
246
|
+
except TokenError as exc:
|
|
247
|
+
if exc.error != "invalid_grant" or not run_id:
|
|
248
|
+
raise
|
|
249
|
+
self._forget_run()
|
|
250
|
+
if "run_killed" in exc.error_description:
|
|
251
|
+
raise RunKilled("run_killed") from None
|
|
252
|
+
# The run is over for another reason: start a new one.
|
|
253
|
+
del form["run_id"]
|
|
254
|
+
payload = self._post_token(form)
|
|
255
|
+
issued = _Issued(payload, self._clock(), self._leeway)
|
|
256
|
+
if previous and issued.run_id and issued.run_id != previous:
|
|
257
|
+
log.info("Agent run %s ended; continuing in new run %s", previous, issued.run_id)
|
|
258
|
+
with self._lock:
|
|
259
|
+
if issued.run_id:
|
|
260
|
+
self._run_id = issued.run_id
|
|
261
|
+
self._issued = issued
|
|
262
|
+
return issued
|
|
263
|
+
|
|
264
|
+
def _refresh(self, failed_token: Optional[str], continue_run: bool = True) -> _Issued:
|
|
265
|
+
def fresh() -> Optional[_Issued]:
|
|
266
|
+
issued = self._issued
|
|
267
|
+
if issued is None:
|
|
268
|
+
return None
|
|
269
|
+
if failed_token is None:
|
|
270
|
+
return issued if self._clock() < issued.refresh_at else None
|
|
271
|
+
return issued if issued.token != failed_token else None
|
|
272
|
+
return self._flight.run(fresh, lambda: self._fetch(continue_run))
|
|
273
|
+
|
|
274
|
+
def _current(self) -> _Issued:
|
|
275
|
+
return self._refresh(None)
|
|
276
|
+
|
|
277
|
+
# -- public API -----------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def token(self) -> str:
|
|
280
|
+
return self._current().token
|
|
281
|
+
|
|
282
|
+
def cached_token(self) -> Optional[str]:
|
|
283
|
+
"""The cached token, or None. Never touches the network."""
|
|
284
|
+
issued = self._issued
|
|
285
|
+
return issued.token if issued is not None else None
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def run_id(self) -> Optional[str]:
|
|
289
|
+
"""Run of the current token, or the run being continued. Never touches the network."""
|
|
290
|
+
issued = self._issued
|
|
291
|
+
return (issued.run_id if issued is not None else None) or self._run_id
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def scope(self) -> Optional[str]:
|
|
295
|
+
"""Scope of the current token, or None before the first fetch. Never touches the network."""
|
|
296
|
+
issued = self._issued
|
|
297
|
+
return issued.scope if issued is not None else None
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def expires_at(self) -> Optional[float]:
|
|
301
|
+
issued = self._issued
|
|
302
|
+
return issued.expires_at if issued is not None else None
|
|
303
|
+
|
|
304
|
+
def claims(self) -> Dict[str, Any]:
|
|
305
|
+
"""Decoded claims of the current token. Not verified; do not trust for decisions."""
|
|
306
|
+
return decode_claims(self.token())
|
|
307
|
+
|
|
308
|
+
def invalidate(self, run_killed: bool = False) -> None:
|
|
309
|
+
"""Drop the cached token. After a kill, also forget the run so the next token starts a new one."""
|
|
310
|
+
with self._lock:
|
|
311
|
+
self._issued = None
|
|
312
|
+
if run_killed:
|
|
313
|
+
self._run_id = None
|
|
314
|
+
|
|
315
|
+
def refresh_after_unauthorized(self, failed_token: Optional[str] = None,
|
|
316
|
+
new_run: bool = False) -> str:
|
|
317
|
+
"""Replace the token after the API answered 401.
|
|
318
|
+
|
|
319
|
+
Does nothing if ``failed_token`` is no longer the cached token (another
|
|
320
|
+
caller already refreshed), and concurrent callers share one fetch.
|
|
321
|
+
Continues the same run; if that run was killed, raises ``RunKilled``.
|
|
322
|
+
``new_run=True`` starts a new run (the old one ended).
|
|
323
|
+
"""
|
|
324
|
+
return self._refresh(failed_token if failed_token is not None else self.cached_token(),
|
|
325
|
+
continue_run=not new_run).token
|
|
326
|
+
|
|
327
|
+
def _exchange(self, subject: TokenLike, scope: Optional[str]) -> _Issued:
|
|
328
|
+
def attempt() -> Dict[str, Any]:
|
|
329
|
+
actor = self.token()
|
|
330
|
+
form = {
|
|
331
|
+
"grant_type": EXCHANGE_GRANT,
|
|
332
|
+
"subject_token": _token_value(subject), "subject_token_type": JWT_TYPE,
|
|
333
|
+
"actor_token": actor, "actor_token_type": JWT_TYPE,
|
|
334
|
+
}
|
|
335
|
+
if scope:
|
|
336
|
+
form["scope"] = scope
|
|
337
|
+
try:
|
|
338
|
+
return self._post_token(form)
|
|
339
|
+
except TokenError as exc:
|
|
340
|
+
exc.actor_token = actor # type: ignore[attr-defined]
|
|
341
|
+
raise
|
|
342
|
+
try:
|
|
343
|
+
payload = attempt()
|
|
344
|
+
except TokenError as exc:
|
|
345
|
+
description = exc.error_description.lower()
|
|
346
|
+
if exc.error != "invalid_grant":
|
|
347
|
+
raise
|
|
348
|
+
if "run_killed" in description:
|
|
349
|
+
self.invalidate(run_killed=True)
|
|
350
|
+
raise RunKilled("run_killed") from None
|
|
351
|
+
if not _ACTOR_OR_RUN.search(description):
|
|
352
|
+
raise # the user's token was refused: a new actor token would not help
|
|
353
|
+
self.refresh_after_unauthorized(getattr(exc, "actor_token", None))
|
|
354
|
+
payload = attempt()
|
|
355
|
+
return _Issued(payload, self._clock(), self._leeway)
|
|
356
|
+
|
|
357
|
+
def exchange(self, subject_token: TokenLike, scope: Optional[str] = None) -> DelegatedToken:
|
|
358
|
+
"""Delegate a user JWT (or another delegated token) to this agent.
|
|
359
|
+
|
|
360
|
+
``scope`` can only narrow what the user and the agent already share.
|
|
361
|
+
"""
|
|
362
|
+
if isinstance(subject_token, str) and not subject_token:
|
|
363
|
+
raise ValueError("subject_token is required")
|
|
364
|
+
return DelegatedToken(self, subject_token, scope, self._exchange(subject_token, scope))
|
|
365
|
+
|
|
366
|
+
def close(self) -> None:
|
|
367
|
+
if self._owns_http:
|
|
368
|
+
self._http.close()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _truthy(value: Optional[str]) -> bool:
|
|
372
|
+
return (value or "").strip().lower() in ("1", "true", "yes", "on")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main(argv=None) -> int:
|
|
376
|
+
parser = argparse.ArgumentParser(
|
|
377
|
+
prog="aynigma-agents-token",
|
|
378
|
+
description="Print an agent token for debugging. Prefer the AYNIGMA_AGENT_KEY variable over --key, "
|
|
379
|
+
"which is visible in the process list.")
|
|
380
|
+
parser.add_argument("--agent-id", default=os.getenv("AYNIGMA_AGENT_ID"))
|
|
381
|
+
parser.add_argument("--key", default=os.getenv("AYNIGMA_AGENT_KEY"))
|
|
382
|
+
parser.add_argument("--base-url", default=os.getenv("AYNIGMA_API_URL") or DEFAULT_BASE_URL)
|
|
383
|
+
parser.add_argument("--claims", action="store_true", help="print the decoded claims instead of the token")
|
|
384
|
+
args = parser.parse_args(argv)
|
|
385
|
+
if not args.agent_id:
|
|
386
|
+
parser.error("--agent-id (or AYNIGMA_AGENT_ID) is required")
|
|
387
|
+
if not args.key:
|
|
388
|
+
parser.error("--key (or AYNIGMA_AGENT_KEY) is required")
|
|
389
|
+
provider = AgentTokenProvider(args.agent_id, args.key, args.base_url)
|
|
390
|
+
try:
|
|
391
|
+
if args.claims:
|
|
392
|
+
print(json.dumps(provider.claims(), indent=2, sort_keys=True))
|
|
393
|
+
else:
|
|
394
|
+
print(provider.token())
|
|
395
|
+
except (TokenError, APIError) as exc:
|
|
396
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
397
|
+
return 1
|
|
398
|
+
finally:
|
|
399
|
+
provider.close()
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
raise SystemExit(main())
|