agentcap-cli 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.
- agentcap/__init__.py +7 -0
- agentcap/alerts.py +312 -0
- agentcap/clamp.py +174 -0
- agentcap/cli.py +373 -0
- agentcap/data/pricing.json +383 -0
- agentcap/devtools/__init__.py +1 -0
- agentcap/devtools/fake_upstream.py +412 -0
- agentcap/estimate.py +271 -0
- agentcap/ledger.py +387 -0
- agentcap/license.py +634 -0
- agentcap/loop.py +118 -0
- agentcap/pricing.py +620 -0
- agentcap/proxy.py +1181 -0
- agentcap/runner.py +456 -0
- agentcap/upstreams.py +132 -0
- agentcap/usage.py +428 -0
- agentcap_cli-0.1.0.dist-info/METADATA +304 -0
- agentcap_cli-0.1.0.dist-info/RECORD +21 -0
- agentcap_cli-0.1.0.dist-info/WHEEL +4 -0
- agentcap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentcap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcap/license.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""License validation and feature gating.
|
|
2
|
+
|
|
3
|
+
Pro licences are validated against Polar's public customer-portal endpoints.
|
|
4
|
+
Those endpoints require no API token, which keeps them safe to call from a
|
|
5
|
+
desktop application. Results are cached in a private JSON file and reused
|
|
6
|
+
without a network round-trip for seven days; after that the licence is
|
|
7
|
+
revalidated. If the licence server cannot be reached, a cached licence keeps
|
|
8
|
+
working for an additional offline grace window (fourteen days in total from
|
|
9
|
+
the last successful check). Definitive rejections fall back to the free tier
|
|
10
|
+
immediately.
|
|
11
|
+
|
|
12
|
+
Only status from the Polar validator is ever written to disk.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import getpass
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, Literal, Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
|
|
32
|
+
Tier = Literal["free", "pro"]
|
|
33
|
+
|
|
34
|
+
DAY = 24 * 60 * 60
|
|
35
|
+
REVALIDATE_AFTER_S = 7 * DAY
|
|
36
|
+
OFFLINE_GRACE_S = 14 * DAY
|
|
37
|
+
GRACE_SECONDS = REVALIDATE_AFTER_S
|
|
38
|
+
|
|
39
|
+
POLAR_API_BASE = "https://api.polar.sh"
|
|
40
|
+
POLAR_ORG_ID = "2cf7b646-95db-4581-bb30-bc9cd0dc7a95"
|
|
41
|
+
|
|
42
|
+
# Values that mean "no organization id configured". The literal placeholder is
|
|
43
|
+
# kept so an old environment file that still pins the sentinel stays disabled
|
|
44
|
+
# instead of sending it to the API.
|
|
45
|
+
_UNCONFIGURED_ORG_IDS = frozenset({"", "POLAR_ORG_ID_PLACEHOLDER"})
|
|
46
|
+
|
|
47
|
+
VALIDATE_PATH = "/v1/customer-portal/license-keys/validate"
|
|
48
|
+
ACTIVATE_PATH = "/v1/customer-portal/license-keys/activate"
|
|
49
|
+
|
|
50
|
+
NOT_CONFIGURED_REASON = "license server not configured (AGENTCAP_POLAR_ORG_ID)"
|
|
51
|
+
NOT_FOUND_REASON = "license key not found, revoked, disabled, or expired"
|
|
52
|
+
|
|
53
|
+
CACHE_SOURCE = "polar"
|
|
54
|
+
|
|
55
|
+
FREE_FEATURES = frozenset({"run_cap", "terminal_alerts"})
|
|
56
|
+
PRO_FEATURES = FREE_FEATURES | frozenset(
|
|
57
|
+
{
|
|
58
|
+
"hour_cap",
|
|
59
|
+
"day_cap",
|
|
60
|
+
"loop_detection",
|
|
61
|
+
"webhook_alerts",
|
|
62
|
+
"telegram_alerts",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
FEATURES: dict[str, frozenset[str]] = {
|
|
67
|
+
"free": FREE_FEATURES,
|
|
68
|
+
"pro": PRO_FEATURES,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def features(tier: str) -> set[str]:
|
|
73
|
+
"""Feature set for a tier. Unknown tiers fail closed to free."""
|
|
74
|
+
|
|
75
|
+
if tier == "pro":
|
|
76
|
+
return set(PRO_FEATURES)
|
|
77
|
+
return set(FREE_FEATURES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class LicenseStatus:
|
|
82
|
+
valid: bool
|
|
83
|
+
tier: Tier
|
|
84
|
+
reason: str
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
if self.tier not in ("free", "pro"):
|
|
88
|
+
raise ValueError(f"invalid license tier: {self.tier!r}")
|
|
89
|
+
if not self.valid and self.tier != "free":
|
|
90
|
+
raise ValueError("an invalid license must fall back to the free tier")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LicenseNetworkError(Exception):
|
|
94
|
+
"""The licence server could not give a definitive answer.
|
|
95
|
+
|
|
96
|
+
Raised for timeouts, connection failures, 5xx responses and malformed
|
|
97
|
+
(non-JSON) bodies. Callers may keep using a cached licence inside the
|
|
98
|
+
offline grace window.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@runtime_checkable
|
|
103
|
+
class LicenseValidator(Protocol):
|
|
104
|
+
persist: bool
|
|
105
|
+
|
|
106
|
+
def validate(self, key: str, activation_id: str | None = None) -> LicenseStatus: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------- config
|
|
110
|
+
def polar_api_base() -> str:
|
|
111
|
+
"""Polar API base URL, overridable for sandbox/testing."""
|
|
112
|
+
|
|
113
|
+
value = os.environ.get("AGENTCAP_POLAR_API_BASE", "").strip()
|
|
114
|
+
return value or POLAR_API_BASE
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def polar_organization_id() -> str:
|
|
118
|
+
"""Configured Polar organization id (defaults to the AgentCap org)."""
|
|
119
|
+
|
|
120
|
+
value = os.environ.get("AGENTCAP_POLAR_ORG_ID", "").strip()
|
|
121
|
+
return value or POLAR_ORG_ID
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- fingerprint
|
|
125
|
+
def _safe_user() -> str:
|
|
126
|
+
try:
|
|
127
|
+
return getpass.getuser()
|
|
128
|
+
except Exception: # pragma: no cover - defensive, platform dependent
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _safe_platform(part: Callable[[], str]) -> str:
|
|
133
|
+
try:
|
|
134
|
+
return part()
|
|
135
|
+
except Exception: # pragma: no cover - defensive, platform dependent
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def machine_fingerprint() -> str:
|
|
140
|
+
"""SHA-256 of local machine identity.
|
|
141
|
+
|
|
142
|
+
The raw hostname and username never leave the machine: only this digest
|
|
143
|
+
(and a short label derived from it) is sent to the licence server.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
parts = (
|
|
147
|
+
_safe_platform(platform.node),
|
|
148
|
+
_safe_user(),
|
|
149
|
+
_safe_platform(platform.system),
|
|
150
|
+
_safe_platform(platform.machine),
|
|
151
|
+
)
|
|
152
|
+
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def activation_label() -> str:
|
|
156
|
+
return "agentcap-" + machine_fingerprint()[:12]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def activation_meta() -> dict[str, str]:
|
|
160
|
+
return {
|
|
161
|
+
"fingerprint": machine_fingerprint(),
|
|
162
|
+
"agentcap_version": __version__,
|
|
163
|
+
"os": _safe_platform(platform.system),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------- helpers
|
|
168
|
+
def _parse_expiry(value: object) -> datetime | None:
|
|
169
|
+
if value is None or value == "":
|
|
170
|
+
return None
|
|
171
|
+
if isinstance(value, datetime):
|
|
172
|
+
parsed = value
|
|
173
|
+
elif isinstance(value, str):
|
|
174
|
+
text = value.strip()
|
|
175
|
+
if text.endswith(("Z", "z")):
|
|
176
|
+
text = text[:-1] + "+00:00"
|
|
177
|
+
try:
|
|
178
|
+
parsed = datetime.fromisoformat(text)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return None
|
|
181
|
+
else:
|
|
182
|
+
return None
|
|
183
|
+
if parsed.tzinfo is None:
|
|
184
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
185
|
+
return parsed.astimezone(timezone.utc)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _collapse(text: object, limit: int = 200) -> str:
|
|
189
|
+
if isinstance(text, (list, dict)):
|
|
190
|
+
try:
|
|
191
|
+
text = json.dumps(text, sort_keys=True)
|
|
192
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
193
|
+
text = str(text)
|
|
194
|
+
collapsed = " ".join(str(text).split())
|
|
195
|
+
return collapsed[:limit]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _redact(text: str, key: str) -> str:
|
|
199
|
+
if key and key in text:
|
|
200
|
+
return text.replace(key, "<redacted>")
|
|
201
|
+
return text
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _detail(data: dict[str, object] | None) -> str:
|
|
205
|
+
if not isinstance(data, dict):
|
|
206
|
+
return ""
|
|
207
|
+
for field in ("detail", "error", "message"):
|
|
208
|
+
if field in data and data[field] not in (None, ""):
|
|
209
|
+
return _collapse(data[field])
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _now_from_clock(clock: Callable[[], float]) -> datetime:
|
|
214
|
+
return datetime.fromtimestamp(clock(), tz=timezone.utc)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ validators
|
|
218
|
+
class PolarLicenseValidator:
|
|
219
|
+
"""Validate licence keys through Polar's public customer portal.
|
|
220
|
+
|
|
221
|
+
No API token is used. Every failure mode that is not a definitive server
|
|
222
|
+
answer is reported as :class:`LicenseNetworkError` so the caller can apply
|
|
223
|
+
offline grace.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
persist = True
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
organization_id: str | None = None,
|
|
231
|
+
*,
|
|
232
|
+
base_url: str | None = None,
|
|
233
|
+
client: httpx.Client | None = None,
|
|
234
|
+
timeout: float = 5.0,
|
|
235
|
+
clock: Callable[[], float] | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
self.organization_id = organization_id if organization_id is not None else polar_organization_id()
|
|
238
|
+
self.base_url = (base_url or polar_api_base()).rstrip("/")
|
|
239
|
+
self._client = client
|
|
240
|
+
self.timeout = float(timeout)
|
|
241
|
+
self._clock = clock or time.time
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------- configuration
|
|
244
|
+
def _configured_organization_id(self) -> str | None:
|
|
245
|
+
org = (self.organization_id or "").strip()
|
|
246
|
+
if org in _UNCONFIGURED_ORG_IDS:
|
|
247
|
+
return None
|
|
248
|
+
return org
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------- transport
|
|
251
|
+
def _post(self, path: str, payload: dict[str, object]):
|
|
252
|
+
url = self.base_url + path
|
|
253
|
+
client = self._client
|
|
254
|
+
owned = client is None
|
|
255
|
+
if owned:
|
|
256
|
+
client = httpx.Client(timeout=self.timeout, trust_env=False)
|
|
257
|
+
try:
|
|
258
|
+
try:
|
|
259
|
+
response = client.post(url, json=payload, timeout=self.timeout)
|
|
260
|
+
except httpx.TimeoutException as exc:
|
|
261
|
+
raise LicenseNetworkError("license server request timed out") from exc
|
|
262
|
+
except httpx.TransportError as exc:
|
|
263
|
+
raise LicenseNetworkError(
|
|
264
|
+
f"license server unreachable ({type(exc).__name__})"
|
|
265
|
+
) from exc
|
|
266
|
+
except httpx.HTTPError as exc: # pragma: no cover - defensive
|
|
267
|
+
raise LicenseNetworkError(
|
|
268
|
+
f"license request failed ({type(exc).__name__})"
|
|
269
|
+
) from exc
|
|
270
|
+
finally:
|
|
271
|
+
if owned:
|
|
272
|
+
client.close()
|
|
273
|
+
|
|
274
|
+
if response.status_code >= 500:
|
|
275
|
+
raise LicenseNetworkError(
|
|
276
|
+
f"license server error (HTTP {response.status_code})"
|
|
277
|
+
)
|
|
278
|
+
return response
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _json_body(response) -> dict[str, object]:
|
|
282
|
+
try:
|
|
283
|
+
data = response.json()
|
|
284
|
+
except ValueError as exc:
|
|
285
|
+
raise LicenseNetworkError(
|
|
286
|
+
"license server returned a non-JSON response"
|
|
287
|
+
) from exc
|
|
288
|
+
if not isinstance(data, dict):
|
|
289
|
+
raise LicenseNetworkError("license server returned an unexpected response")
|
|
290
|
+
return data
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------ status
|
|
293
|
+
def _status_from_license(
|
|
294
|
+
self, data: dict[str, object] | None, key: str
|
|
295
|
+
) -> LicenseStatus:
|
|
296
|
+
if not isinstance(data, dict):
|
|
297
|
+
raise LicenseNetworkError("license server returned an unexpected response")
|
|
298
|
+
state = data.get("status")
|
|
299
|
+
if state != "granted":
|
|
300
|
+
reason = "license is not granted"
|
|
301
|
+
if isinstance(state, str) and state.strip():
|
|
302
|
+
reason = f"license status is {_collapse(state, 40)}"
|
|
303
|
+
return LicenseStatus(False, "free", _redact(reason, key))
|
|
304
|
+
expires_at = _parse_expiry(data.get("expires_at"))
|
|
305
|
+
if data.get("expires_at") not in (None, "") and expires_at is None:
|
|
306
|
+
raise LicenseNetworkError(
|
|
307
|
+
"license server returned an unparseable expiration date"
|
|
308
|
+
)
|
|
309
|
+
if expires_at is not None and expires_at <= _now_from_clock(self._clock):
|
|
310
|
+
return LicenseStatus(False, "free", "license expired")
|
|
311
|
+
return LicenseStatus(True, "pro", "license valid")
|
|
312
|
+
|
|
313
|
+
def _reject(
|
|
314
|
+
self, status_code: int, data: dict[str, object] | None, key: str
|
|
315
|
+
) -> LicenseStatus:
|
|
316
|
+
detail = _detail(data)
|
|
317
|
+
reason = f"license rejected (HTTP {status_code})"
|
|
318
|
+
if detail:
|
|
319
|
+
reason = f"{reason}: {detail}"
|
|
320
|
+
return LicenseStatus(False, "free", _redact(reason, key))
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------- calls
|
|
323
|
+
def validate(
|
|
324
|
+
self, key: str, activation_id: str | None = None
|
|
325
|
+
) -> LicenseStatus:
|
|
326
|
+
org = self._configured_organization_id()
|
|
327
|
+
if org is None:
|
|
328
|
+
return LicenseStatus(False, "free", NOT_CONFIGURED_REASON)
|
|
329
|
+
|
|
330
|
+
payload: dict[str, object] = {"key": key, "organization_id": org}
|
|
331
|
+
if activation_id:
|
|
332
|
+
payload["activation_id"] = activation_id
|
|
333
|
+
|
|
334
|
+
response = self._post(VALIDATE_PATH, payload)
|
|
335
|
+
code = response.status_code
|
|
336
|
+
if code == 200:
|
|
337
|
+
return self._status_from_license(self._json_body(response), key)
|
|
338
|
+
if code == 404:
|
|
339
|
+
return LicenseStatus(False, "free", NOT_FOUND_REASON)
|
|
340
|
+
if code == 429:
|
|
341
|
+
raise LicenseNetworkError("license server rate limited the request")
|
|
342
|
+
if 400 <= code < 500:
|
|
343
|
+
try:
|
|
344
|
+
data = self._json_body(response)
|
|
345
|
+
except LicenseNetworkError:
|
|
346
|
+
data = None
|
|
347
|
+
return self._reject(code, data, key)
|
|
348
|
+
raise LicenseNetworkError(f"license server returned HTTP {code}")
|
|
349
|
+
|
|
350
|
+
def activate(self, key: str) -> tuple[LicenseStatus, str | None]:
|
|
351
|
+
org = self._configured_organization_id()
|
|
352
|
+
if org is None:
|
|
353
|
+
return LicenseStatus(False, "free", NOT_CONFIGURED_REASON), None
|
|
354
|
+
|
|
355
|
+
payload: dict[str, object] = {
|
|
356
|
+
"key": key,
|
|
357
|
+
"organization_id": org,
|
|
358
|
+
"label": activation_label(),
|
|
359
|
+
"meta": activation_meta(),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
response = self._post(ACTIVATE_PATH, payload)
|
|
363
|
+
code = response.status_code
|
|
364
|
+
if code == 200:
|
|
365
|
+
data = self._json_body(response)
|
|
366
|
+
nested = data.get("license_key")
|
|
367
|
+
status = self._status_from_license(nested, key)
|
|
368
|
+
activation_id = data.get("id")
|
|
369
|
+
if status.valid and not (isinstance(activation_id, str) and activation_id):
|
|
370
|
+
activation_id = None
|
|
371
|
+
return status, (activation_id if status.valid else None)
|
|
372
|
+
if code == 404:
|
|
373
|
+
return LicenseStatus(False, "free", NOT_FOUND_REASON), None
|
|
374
|
+
if code == 429:
|
|
375
|
+
raise LicenseNetworkError("license server rate limited the request")
|
|
376
|
+
if 400 <= code < 500:
|
|
377
|
+
try:
|
|
378
|
+
data = self._json_body(response)
|
|
379
|
+
except LicenseNetworkError:
|
|
380
|
+
data = None
|
|
381
|
+
if code == 403 and "does not support activation" in _detail(data).lower():
|
|
382
|
+
# Polar documents /validate as the fallback for keys without
|
|
383
|
+
# activations enabled.
|
|
384
|
+
return self.validate(key), None
|
|
385
|
+
return self._reject(code, data, key), None
|
|
386
|
+
raise LicenseNetworkError(f"license server returned HTTP {code}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def status_from_environment() -> LicenseStatus:
|
|
390
|
+
"""Resolve the licence the way the CLI and runner do.
|
|
391
|
+
|
|
392
|
+
Always uses the normal cached/Polar path: a cached licence inside its
|
|
393
|
+
revalidation window is reused, otherwise Polar is asked. Environment
|
|
394
|
+
variables only select the configuration (organisation id, API base and
|
|
395
|
+
cache path), never the validation result.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
return LicenseManager().check()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def default_license_path() -> Path:
|
|
402
|
+
env_path = os.environ.get("AGENTCAP_LICENSE_FILE")
|
|
403
|
+
if env_path:
|
|
404
|
+
return Path(env_path).expanduser()
|
|
405
|
+
return Path.home() / ".config" / "agentcap" / "license.json"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True)
|
|
409
|
+
class CachedLicense:
|
|
410
|
+
key: str
|
|
411
|
+
status: LicenseStatus
|
|
412
|
+
checked_at: float
|
|
413
|
+
source: str = CACHE_SOURCE
|
|
414
|
+
activation_id: str | None = None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class LicenseManager:
|
|
418
|
+
"""Store a licence key and a timestamped status in a private JSON file."""
|
|
419
|
+
|
|
420
|
+
def __init__(
|
|
421
|
+
self,
|
|
422
|
+
validator: LicenseValidator | None = None,
|
|
423
|
+
path: str | os.PathLike[str] | None = None,
|
|
424
|
+
*,
|
|
425
|
+
clock: Callable[[], float] | None = None,
|
|
426
|
+
revalidate_after_s: float = REVALIDATE_AFTER_S,
|
|
427
|
+
offline_grace_s: float = OFFLINE_GRACE_S,
|
|
428
|
+
) -> None:
|
|
429
|
+
self.validator = validator or PolarLicenseValidator()
|
|
430
|
+
self.path = Path(path).expanduser() if path is not None else default_license_path()
|
|
431
|
+
self._clock = clock or time.time
|
|
432
|
+
self.revalidate_after_s = float(revalidate_after_s)
|
|
433
|
+
self.offline_grace_s = float(offline_grace_s)
|
|
434
|
+
|
|
435
|
+
# ------------------------------------------------------------ persistence
|
|
436
|
+
@property
|
|
437
|
+
def persists(self) -> bool:
|
|
438
|
+
return bool(getattr(self.validator, "persist", False))
|
|
439
|
+
|
|
440
|
+
def _read(self) -> CachedLicense | None:
|
|
441
|
+
try:
|
|
442
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
443
|
+
payload = json.load(handle)
|
|
444
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
445
|
+
return None
|
|
446
|
+
if not isinstance(payload, dict):
|
|
447
|
+
return None
|
|
448
|
+
# Cache files written without a source are ignored.
|
|
449
|
+
if payload.get("source") != CACHE_SOURCE:
|
|
450
|
+
return None
|
|
451
|
+
key = payload.get("key")
|
|
452
|
+
tier = payload.get("tier")
|
|
453
|
+
valid = payload.get("valid")
|
|
454
|
+
reason = payload.get("reason", "")
|
|
455
|
+
checked_at = payload.get("checked_at")
|
|
456
|
+
activation_id = payload.get("activation_id")
|
|
457
|
+
if (
|
|
458
|
+
not isinstance(key, str)
|
|
459
|
+
or not key
|
|
460
|
+
or tier not in ("free", "pro")
|
|
461
|
+
or not isinstance(valid, bool)
|
|
462
|
+
or not valid
|
|
463
|
+
or isinstance(checked_at, bool)
|
|
464
|
+
or not isinstance(checked_at, (int, float))
|
|
465
|
+
):
|
|
466
|
+
return None
|
|
467
|
+
if activation_id is not None and not isinstance(activation_id, str):
|
|
468
|
+
activation_id = None
|
|
469
|
+
return CachedLicense(
|
|
470
|
+
key=key,
|
|
471
|
+
status=LicenseStatus(valid=True, tier=tier, reason=str(reason)),
|
|
472
|
+
checked_at=float(checked_at),
|
|
473
|
+
source=CACHE_SOURCE,
|
|
474
|
+
activation_id=activation_id,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _write(
|
|
478
|
+
self,
|
|
479
|
+
key: str,
|
|
480
|
+
status: LicenseStatus,
|
|
481
|
+
checked_at: float,
|
|
482
|
+
activation_id: str | None = None,
|
|
483
|
+
) -> None:
|
|
484
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
payload = {
|
|
486
|
+
"key": key,
|
|
487
|
+
"tier": status.tier,
|
|
488
|
+
"valid": status.valid,
|
|
489
|
+
"reason": status.reason,
|
|
490
|
+
"checked_at": checked_at,
|
|
491
|
+
"source": CACHE_SOURCE,
|
|
492
|
+
"activation_id": activation_id,
|
|
493
|
+
}
|
|
494
|
+
temporary = self.path.with_name(self.path.name + ".tmp")
|
|
495
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
496
|
+
try:
|
|
497
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
498
|
+
json.dump(payload, handle, sort_keys=True)
|
|
499
|
+
handle.write("\n")
|
|
500
|
+
except BaseException:
|
|
501
|
+
try:
|
|
502
|
+
os.unlink(temporary)
|
|
503
|
+
except OSError:
|
|
504
|
+
pass
|
|
505
|
+
raise
|
|
506
|
+
os.replace(temporary, self.path)
|
|
507
|
+
try:
|
|
508
|
+
os.chmod(self.path, 0o600)
|
|
509
|
+
except OSError:
|
|
510
|
+
pass
|
|
511
|
+
|
|
512
|
+
# --------------------------------------------------------------- checking
|
|
513
|
+
def _age(self, cached: CachedLicense, now: float) -> float:
|
|
514
|
+
return now - cached.checked_at
|
|
515
|
+
|
|
516
|
+
def check(self, key: str | None = None, *, force_refresh: bool = False) -> LicenseStatus:
|
|
517
|
+
"""Validate (or reuse a cached) licence, falling back to free."""
|
|
518
|
+
|
|
519
|
+
cached = self._read()
|
|
520
|
+
effective_key = key if key is not None else (cached.key if cached else None)
|
|
521
|
+
if not effective_key:
|
|
522
|
+
return LicenseStatus(False, "free", "no license key configured")
|
|
523
|
+
|
|
524
|
+
now = self._clock()
|
|
525
|
+
same_key = cached if cached is not None and cached.key == effective_key else None
|
|
526
|
+
age = self._age(same_key, now) if same_key is not None else None
|
|
527
|
+
|
|
528
|
+
if same_key is not None and not force_refresh and age is not None:
|
|
529
|
+
if 0 <= age <= self.revalidate_after_s:
|
|
530
|
+
return same_key.status
|
|
531
|
+
|
|
532
|
+
activation_id = same_key.activation_id if same_key is not None else None
|
|
533
|
+
try:
|
|
534
|
+
if activation_id:
|
|
535
|
+
status = self.validator.validate(effective_key, activation_id=activation_id)
|
|
536
|
+
else:
|
|
537
|
+
status = self.validator.validate(effective_key)
|
|
538
|
+
except LicenseNetworkError as exc:
|
|
539
|
+
return self._offline(same_key, age, type(exc).__name__)
|
|
540
|
+
except Exception as exc: # defensive: never let a validator crash the proxy
|
|
541
|
+
return self._offline(same_key, age, type(exc).__name__)
|
|
542
|
+
|
|
543
|
+
if status.valid:
|
|
544
|
+
if self.persists:
|
|
545
|
+
self._write(effective_key, status, now, activation_id)
|
|
546
|
+
elif same_key is not None and self.persists:
|
|
547
|
+
# Definitive rejection: make sure a stale Pro cache cannot survive.
|
|
548
|
+
self.clear()
|
|
549
|
+
return status
|
|
550
|
+
|
|
551
|
+
def _offline(
|
|
552
|
+
self,
|
|
553
|
+
cached: CachedLicense | None,
|
|
554
|
+
age: float | None,
|
|
555
|
+
error_name: str,
|
|
556
|
+
) -> LicenseStatus:
|
|
557
|
+
if cached is not None and age is not None and 0 <= age <= self.offline_grace_s:
|
|
558
|
+
return LicenseStatus(
|
|
559
|
+
True,
|
|
560
|
+
"pro",
|
|
561
|
+
"offline: using cached license inside the offline grace period",
|
|
562
|
+
)
|
|
563
|
+
return LicenseStatus(
|
|
564
|
+
False,
|
|
565
|
+
"free",
|
|
566
|
+
f"license validation unavailable ({error_name}) and cache is missing or expired",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def activate(self, key: str) -> LicenseStatus:
|
|
570
|
+
"""Validate a key immediately and cache the result when allowed."""
|
|
571
|
+
|
|
572
|
+
activator = getattr(self.validator, "activate", None)
|
|
573
|
+
try:
|
|
574
|
+
if callable(activator):
|
|
575
|
+
status, activation_id = activator(key)
|
|
576
|
+
else:
|
|
577
|
+
# Validators that only expose validate() never provide an
|
|
578
|
+
# activation id.
|
|
579
|
+
status, activation_id = self.validator.validate(key), None
|
|
580
|
+
except Exception as exc: # never let a transport failure crash the CLI
|
|
581
|
+
# The cache is deliberately left untouched so a licence that is
|
|
582
|
+
# still inside its offline grace window keeps working.
|
|
583
|
+
return LicenseStatus(
|
|
584
|
+
False,
|
|
585
|
+
"free",
|
|
586
|
+
"could not reach the license server "
|
|
587
|
+
f"({type(exc).__name__}); check your connection and try again",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if status.valid:
|
|
591
|
+
if self.persists:
|
|
592
|
+
self._write(key, status, self._clock(), activation_id)
|
|
593
|
+
return status
|
|
594
|
+
|
|
595
|
+
cached = self._read()
|
|
596
|
+
if self.persists and cached is not None and cached.key == key:
|
|
597
|
+
# Definitive rejection for the cached key: make sure a stale Pro
|
|
598
|
+
# cache cannot survive (same rule as check()).
|
|
599
|
+
self.clear()
|
|
600
|
+
return status
|
|
601
|
+
|
|
602
|
+
def tier(self) -> Tier:
|
|
603
|
+
"""Current effective tier without contacting a validator."""
|
|
604
|
+
|
|
605
|
+
cached = self._read()
|
|
606
|
+
if cached is None or not cached.status.valid:
|
|
607
|
+
return "free"
|
|
608
|
+
age = self._age(cached, self._clock())
|
|
609
|
+
if 0 <= age <= self.offline_grace_s:
|
|
610
|
+
return cached.status.tier
|
|
611
|
+
return "free"
|
|
612
|
+
|
|
613
|
+
def cached_status(self) -> LicenseStatus | None:
|
|
614
|
+
cached = self._read()
|
|
615
|
+
if cached is None:
|
|
616
|
+
return None
|
|
617
|
+
return cached.status
|
|
618
|
+
|
|
619
|
+
def clear(self) -> None:
|
|
620
|
+
try:
|
|
621
|
+
self.path.unlink()
|
|
622
|
+
except FileNotFoundError:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
# Friendly aliases used by embedders.
|
|
626
|
+
def get_status(self, key: str | None = None) -> LicenseStatus:
|
|
627
|
+
return self.check(key)
|
|
628
|
+
|
|
629
|
+
validate = check
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# Alias spellings for the cache manager.
|
|
633
|
+
LicenseStore = LicenseManager
|
|
634
|
+
LicenseCache = LicenseManager
|