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/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