llmt-cli 0.2.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.
@@ -0,0 +1,252 @@
1
+ """Read torrents from a BitTorrent client for a telemetry heartbeat.
2
+
3
+ Additive, read-only wrappers over the frozen ``clients.py`` adapters (mirrors
4
+ the doctor sources). The CRITICAL correctness requirement is HYBRID KEYING
5
+ (H3): qBittorrent 4.6 keys a hybrid (v1+v2) torrent by its TRUNCATED v2
6
+ infohash. A v1-only report silently credits nothing. We therefore read BOTH
7
+ ``infohash_v1`` and ``infohash_v2`` and send BOTH, so the server matches
8
+ whichever it keyed on.
9
+
10
+ Only infohashes / name / state / progress ever leave the machine. Local file
11
+ paths (``content_path``/``files``) are collected solely to answer possession
12
+ challenges locally and are NEVER put on the wire.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Optional
21
+
22
+ import requests
23
+
24
+ from ..clients import ClientError, QBittorrentClient, TransmissionClient
25
+
26
+ _HEX40_RE = re.compile(r"^[0-9a-f]{40}$")
27
+ _HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
28
+
29
+ #: The frozen wire state vocabulary (contract (b)1).
30
+ WIRE_STATES = ("seeding", "downloading", "stalled", "paused", "error")
31
+
32
+ #: Bound on files enumerated per torrent when resolving local content (defence
33
+ #: against a pathological content dir); mirrors doctor's MAX_LAYOUT_WALK.
34
+ MAX_CONTENT_FILES = 256
35
+
36
+ #: Bound on a torrent name from an untrusted local client (anti-OOM).
37
+ MAX_NAME_LEN = 1024
38
+
39
+ # qBittorrent raw state -> frozen wire state. Unmapped -> "stalled" (never
40
+ # fabricates "seeding"): honest "present but not clearly progressing".
41
+ _QBT_STATE = {
42
+ "uploading": "seeding", "forcedUP": "seeding",
43
+ "stalledUP": "stalled", "stalledDL": "stalled",
44
+ "queuedUP": "stalled", "queuedDL": "stalled",
45
+ "pausedUP": "paused", "pausedDL": "paused",
46
+ "stoppedUP": "paused", "stoppedDL": "paused",
47
+ "downloading": "downloading", "forcedDL": "downloading",
48
+ "metaDL": "downloading", "forcedMetaDL": "downloading",
49
+ "allocating": "downloading", "checkingUP": "downloading",
50
+ "checkingDL": "downloading", "checkingResumeData": "downloading",
51
+ "moving": "downloading",
52
+ "error": "error", "missingFiles": "error",
53
+ }
54
+
55
+ # Transmission status int -> frozen wire state.
56
+ _TR_STATE = {0: "paused", 1: "downloading", 2: "downloading",
57
+ 3: "stalled", 4: "downloading", 5: "stalled", 6: "seeding"}
58
+
59
+
60
+ @dataclass
61
+ class CollectedTorrent:
62
+ """One torrent as observed locally. ``content_path``/``local_files`` are
63
+ for LOCAL challenge resolution only and are never serialized to the wire."""
64
+ infohash_v1: Optional[str]
65
+ infohash_v2: Optional[str]
66
+ name: Optional[str]
67
+ state: str
68
+ progress: float
69
+ content_path: str = ""
70
+ save_path: str = ""
71
+ local_files: list = field(default_factory=list)
72
+
73
+ def has_key(self) -> bool:
74
+ return bool(self.infohash_v1 or self.infohash_v2 or self.name)
75
+
76
+
77
+ def _bounded_name(value: object) -> Optional[str]:
78
+ if not isinstance(value, str):
79
+ return None
80
+ return value[:MAX_NAME_LEN]
81
+
82
+
83
+ def _hex(value: object, *, length: int) -> Optional[str]:
84
+ if not isinstance(value, str):
85
+ return None
86
+ v = value.strip().lower()
87
+ if length == 40 and _HEX40_RE.match(v):
88
+ return v
89
+ if length == 64 and _HEX64_RE.match(v):
90
+ return v
91
+ return None
92
+
93
+
94
+ def _clamp_progress(value: object) -> float:
95
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
96
+ return 0.0
97
+ p = float(value)
98
+ if p < 0.0:
99
+ return 0.0
100
+ if p > 1.0:
101
+ return 1.0
102
+ return p
103
+
104
+
105
+ def _local_files(content_path: str, cap: int = MAX_CONTENT_FILES) -> list:
106
+ """Enumerate regular files under ``content_path`` (a local path). Symlinks
107
+ are never followed. Returns [] if the path is not present locally."""
108
+ if not content_path:
109
+ return []
110
+ try:
111
+ if os.path.islink(content_path):
112
+ return []
113
+ if os.path.isfile(content_path):
114
+ return [content_path]
115
+ if not os.path.isdir(content_path):
116
+ return []
117
+ except OSError:
118
+ return []
119
+ out: list = []
120
+ for dirpath, dirnames, filenames in os.walk(content_path, followlinks=False):
121
+ for fn in sorted(filenames):
122
+ fp = os.path.join(dirpath, fn)
123
+ try:
124
+ if os.path.islink(fp) or not os.path.isfile(fp):
125
+ continue
126
+ except OSError:
127
+ continue
128
+ out.append(fp)
129
+ if len(out) >= cap:
130
+ return out
131
+ return out
132
+
133
+
134
+ # --------------------------------------------------------------------------- #
135
+ # qBittorrent
136
+ # --------------------------------------------------------------------------- #
137
+
138
+ class QbtTelemetrySource:
139
+ kind = "qbittorrent"
140
+
141
+ def __init__(self, client: QBittorrentClient):
142
+ self.client = client
143
+
144
+ def _get(self, path: str, params: Optional[dict] = None):
145
+ c = self.client
146
+ r = c.session.get(f"{c.url}{path}", params=params or {},
147
+ headers=c._headers(), timeout=c.timeout)
148
+ if r.status_code != 200:
149
+ raise ClientError(f"qBittorrent GET {path} failed "
150
+ f"(HTTP {r.status_code}).")
151
+ return r
152
+
153
+ def collect(self, max_torrents: Optional[int] = None) -> list:
154
+ self.client._login()
155
+ rows = self._get("/api/v2/torrents/info").json()
156
+ if not isinstance(rows, list):
157
+ return []
158
+ out: list = []
159
+ for t in rows:
160
+ if not isinstance(t, dict):
161
+ continue
162
+ # Cap DURING collection: a hostile client returning 10^6 rows must
163
+ # not trigger 10^6 os.walk()s or unbounded memory. Stop at the cap.
164
+ if max_torrents is not None and len(out) >= max_torrents:
165
+ break
166
+ out.append(self._one(t))
167
+ return out
168
+
169
+ def _one(self, t: dict) -> CollectedTorrent:
170
+ # Prefer the explicit v1/v2 fields (qBt 4.4+). Both are populated for a
171
+ # HYBRID torrent -> we report BOTH (the H3 fix). Fall back to the legacy
172
+ # `hash` (client key) only if neither explicit field is a valid hex.
173
+ v1 = _hex(t.get("infohash_v1"), length=40)
174
+ v2 = _hex(t.get("infohash_v2"), length=64)
175
+ if v1 is None and v2 is None:
176
+ legacy = _hex(t.get("hash"), length=40) or _hex(t.get("hash"),
177
+ length=64)
178
+ if legacy and len(legacy) == 40:
179
+ v1 = legacy
180
+ elif legacy:
181
+ v2 = legacy
182
+ raw = t.get("state") if isinstance(t.get("state"), str) else ""
183
+ content_path = str(t.get("content_path") or "")
184
+ save_path = str(t.get("save_path") or "")
185
+ return CollectedTorrent(
186
+ infohash_v1=v1, infohash_v2=v2,
187
+ name=_bounded_name(t.get("name")),
188
+ state=_QBT_STATE.get(raw, "stalled"),
189
+ progress=_clamp_progress(t.get("progress")),
190
+ content_path=content_path, save_path=save_path,
191
+ local_files=_local_files(content_path),
192
+ )
193
+
194
+
195
+ # --------------------------------------------------------------------------- #
196
+ # Transmission
197
+ # --------------------------------------------------------------------------- #
198
+
199
+ class TransmissionTelemetrySource:
200
+ kind = "transmission"
201
+
202
+ _FIELDS = ["name", "hashString", "status", "error", "errorString",
203
+ "percentDone", "downloadDir", "files"]
204
+
205
+ def __init__(self, client: TransmissionClient):
206
+ self.client = client
207
+
208
+ def collect(self, max_torrents: Optional[int] = None) -> list:
209
+ args = self.client._rpc("torrent-get", {"fields": self._FIELDS}) \
210
+ .get("arguments") or {}
211
+ tor = args.get("torrents") or []
212
+ out: list = []
213
+ for t in tor:
214
+ if not isinstance(t, dict):
215
+ continue
216
+ if max_torrents is not None and len(out) >= max_torrents:
217
+ break
218
+ out.append(self._one(t))
219
+ return out
220
+
221
+ def _one(self, t: dict) -> CollectedTorrent:
222
+ # Transmission exposes the v1 infohash as `hashString`. It does not
223
+ # surface a v2 infohash over the standard RPC, so v2 stays None (we
224
+ # never fabricate one); the name is still sent as a match key.
225
+ v1 = _hex(t.get("hashString"), length=40)
226
+ status = t.get("status")
227
+ state = _TR_STATE.get(status, "stalled") if isinstance(status, int) \
228
+ else "stalled"
229
+ err = t.get("error")
230
+ if isinstance(err, int) and err != 0:
231
+ state = "error"
232
+ name = str(t.get("name") or "")[:MAX_NAME_LEN]
233
+ ddir = str(t.get("downloadDir") or "")
234
+ content_path = os.path.join(ddir, name) if (ddir and name) else ""
235
+ return CollectedTorrent(
236
+ infohash_v1=v1, infohash_v2=None,
237
+ name=(name or None),
238
+ state=state,
239
+ progress=_clamp_progress(t.get("percentDone")),
240
+ content_path=content_path, save_path=ddir,
241
+ local_files=_local_files(content_path),
242
+ )
243
+
244
+
245
+ def build_source(client):
246
+ """Wrap a frozen client adapter in a read-only telemetry source, or return
247
+ None when no client is configured (a Fallback/None selection)."""
248
+ if isinstance(client, QBittorrentClient):
249
+ return QbtTelemetrySource(client)
250
+ if isinstance(client, TransmissionClient):
251
+ return TransmissionTelemetrySource(client)
252
+ return None
@@ -0,0 +1,237 @@
1
+ """Telemetry config + token storage under the llmt home dir.
2
+
3
+ Precedent: the existing CLI has no config file (see docs/PERMISSIONS.md); this
4
+ module introduces the first one, kept under ``~/.llmt/`` (overridable with the
5
+ ``LLMT_HOME`` env var, which the test-suite uses to stay hermetic). All keys
6
+ live under a single ``telemetry`` table with the frozen defaults below.
7
+
8
+ The token is stored in its OWN file (never in config.json), mode 0600, and is
9
+ never logged or printed back after it is saved.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import stat
17
+ import sys
18
+ from typing import Any, Optional
19
+
20
+ # --------------------------------------------------------------------------- #
21
+ # Frozen defaults (WP-14.4-T contract). Every value is config-driven.
22
+ # --------------------------------------------------------------------------- #
23
+
24
+ #: Site base (NOT the /api base): the agent namespace is endpoint + /agent/v1.
25
+ DEFAULT_ENDPOINT = "https://llmtorrents.com"
26
+ DEFAULT_INTERVAL_MINUTES = 15
27
+ DEFAULT_MAX_TORRENTS = 500
28
+ DEFAULT_TOKEN_FILENAME = "agent-token"
29
+ DEFAULT_CHALLENGE_CHUNK = 1 * 1024 * 1024 # 1 MiB streaming read for range hashing
30
+ DEFAULT_MAX_RETRY_AFTER = 3600 # clamp a hostile 429 retry_after (seconds)
31
+
32
+ TELEMETRY_DEFAULTS: dict[str, Any] = {
33
+ "enabled": False,
34
+ "endpoint": DEFAULT_ENDPOINT,
35
+ "interval_minutes": DEFAULT_INTERVAL_MINUTES,
36
+ "max_torrents_per_heartbeat": DEFAULT_MAX_TORRENTS,
37
+ "token_path": None, # None -> <home>/agent-token (resolved lazily)
38
+ "challenge_chunk_size": DEFAULT_CHALLENGE_CHUNK,
39
+ "max_retry_after_seconds": DEFAULT_MAX_RETRY_AFTER,
40
+ "report_artifacts": True,
41
+ "last_beat": None,
42
+ }
43
+
44
+
45
+ def llmt_home() -> str:
46
+ """Resolve the llmt home dir. ``LLMT_HOME`` overrides (tests use this)."""
47
+ override = os.environ.get("LLMT_HOME")
48
+ if override:
49
+ return os.path.abspath(os.path.expanduser(override))
50
+ return os.path.join(os.path.expanduser("~"), ".llmt")
51
+
52
+
53
+ def config_path() -> str:
54
+ return os.path.join(llmt_home(), "config.json")
55
+
56
+
57
+ def _ensure_home() -> str:
58
+ home = llmt_home()
59
+ os.makedirs(home, exist_ok=True)
60
+ try:
61
+ os.chmod(home, 0o700)
62
+ except OSError:
63
+ pass
64
+ return home
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # Config load / save (JSON; stdlib only, py3.9-safe)
69
+ # --------------------------------------------------------------------------- #
70
+
71
+ def load_config() -> dict[str, Any]:
72
+ """Return the full config dict with telemetry defaults merged in.
73
+
74
+ A missing or unreadable/corrupt config file yields defaults (never fatal).
75
+ """
76
+ cfg: dict[str, Any] = {}
77
+ try:
78
+ with open(config_path(), "r", encoding="utf-8") as fh:
79
+ loaded = json.load(fh)
80
+ if isinstance(loaded, dict):
81
+ cfg = loaded
82
+ except (OSError, ValueError):
83
+ cfg = {}
84
+ tele = cfg.get("telemetry")
85
+ merged = dict(TELEMETRY_DEFAULTS)
86
+ if isinstance(tele, dict):
87
+ for k, v in tele.items():
88
+ merged[k] = v
89
+ # Env overrides (never persisted): endpoint only, following the api-base
90
+ # precedent of --api-base / LLMT_API_BASE.
91
+ env_ep = os.environ.get("LLMT_TELEMETRY_ENDPOINT")
92
+ if env_ep:
93
+ merged["endpoint"] = env_ep
94
+ cfg["telemetry"] = merged
95
+ return cfg
96
+
97
+
98
+ def save_config(cfg: dict[str, Any]) -> None:
99
+ _ensure_home()
100
+ path = config_path()
101
+ tmp = path + ".tmp"
102
+ # Never persist an env-only endpoint override back to disk.
103
+ to_write = dict(cfg)
104
+ tele = dict(to_write.get("telemetry") or {})
105
+ if os.environ.get("LLMT_TELEMETRY_ENDPOINT") and \
106
+ tele.get("endpoint") == os.environ["LLMT_TELEMETRY_ENDPOINT"]:
107
+ # Restore whatever was on disk (or the default) rather than pinning env.
108
+ tele["endpoint"] = _disk_endpoint()
109
+ to_write["telemetry"] = tele
110
+ with open(tmp, "w", encoding="utf-8") as fh:
111
+ json.dump(to_write, fh, indent=2, sort_keys=True)
112
+ os.replace(tmp, path)
113
+ try:
114
+ os.chmod(path, 0o600)
115
+ except OSError:
116
+ pass
117
+
118
+
119
+ def _disk_endpoint() -> str:
120
+ try:
121
+ with open(config_path(), "r", encoding="utf-8") as fh:
122
+ loaded = json.load(fh)
123
+ ep = (loaded.get("telemetry") or {}).get("endpoint")
124
+ if isinstance(ep, str) and ep:
125
+ return ep
126
+ except (OSError, ValueError, AttributeError):
127
+ pass
128
+ return DEFAULT_ENDPOINT
129
+
130
+
131
+ def telemetry_cfg() -> dict[str, Any]:
132
+ return load_config()["telemetry"]
133
+
134
+
135
+ def set_enabled(enabled: bool) -> None:
136
+ cfg = load_config()
137
+ cfg["telemetry"]["enabled"] = bool(enabled)
138
+ save_config(cfg)
139
+
140
+
141
+ def record_last_beat(result: dict[str, Any]) -> None:
142
+ cfg = load_config()
143
+ cfg["telemetry"]["last_beat"] = result
144
+ save_config(cfg)
145
+
146
+
147
+ # --------------------------------------------------------------------------- #
148
+ # Token storage (own file, 0600, never logged / never printed)
149
+ # --------------------------------------------------------------------------- #
150
+
151
+ def token_path(cfg: Optional[dict[str, Any]] = None) -> str:
152
+ tele = (cfg or load_config())["telemetry"]
153
+ tp = tele.get("token_path")
154
+ if isinstance(tp, str) and tp:
155
+ return os.path.abspath(os.path.expanduser(tp))
156
+ return os.path.join(llmt_home(), DEFAULT_TOKEN_FILENAME)
157
+
158
+
159
+ def save_token(token: str, cfg: Optional[dict[str, Any]] = None) -> str:
160
+ """Persist the token to its file with mode 0600. Returns the path.
161
+
162
+ Uses O_CREAT|O_WRONLY|O_TRUNC with mode 0600 so the secret is never briefly
163
+ world-readable between create and chmod.
164
+ """
165
+ token = (token or "").strip()
166
+ if not token:
167
+ raise ValueError("refusing to store an empty telemetry token")
168
+ _ensure_home()
169
+ path = token_path(cfg)
170
+ os.makedirs(os.path.dirname(path), exist_ok=True)
171
+ fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
172
+ try:
173
+ os.write(fd, token.encode("utf-8"))
174
+ finally:
175
+ os.close(fd)
176
+ try:
177
+ os.chmod(path, 0o600) # tighten even if the file pre-existed
178
+ except OSError:
179
+ pass
180
+ return path
181
+
182
+
183
+ def _enforce_token_mode(path: str) -> None:
184
+ """The token file must be 0600 on READ as well as write (contract). A
185
+ pre-existing loose-perm file is repaired to 0600 (with a one-line stderr
186
+ warning); if it cannot be tightened, refuse to use it."""
187
+ try:
188
+ st = os.stat(path)
189
+ except OSError:
190
+ return
191
+ if stat.S_IMODE(st.st_mode) & 0o077:
192
+ try:
193
+ os.chmod(path, 0o600)
194
+ except OSError as exc:
195
+ from .errors import TelemetryConfigError
196
+ raise TelemetryConfigError(
197
+ f"telemetry token file {path} has insecure permissions and "
198
+ f"could not be tightened to 0600: {exc}. Fix it "
199
+ f"(chmod 600) or re-run `llmt telemetry enable`.") from exc
200
+ print(f"warning: tightened telemetry token file {path} to mode 0600 "
201
+ f"(it was group/world-accessible).", file=sys.stderr)
202
+
203
+
204
+ def load_token(cfg: Optional[dict[str, Any]] = None) -> Optional[str]:
205
+ path = token_path(cfg)
206
+ if not os.path.exists(path):
207
+ return None
208
+ _enforce_token_mode(path) # verify/repair 0600 before reading the secret
209
+ try:
210
+ with open(path, "r", encoding="utf-8") as fh:
211
+ tok = fh.read().strip()
212
+ return tok or None
213
+ except OSError:
214
+ return None
215
+
216
+
217
+ def token_present(cfg: Optional[dict[str, Any]] = None) -> bool:
218
+ # Presence only (does not trigger perm-repair / raise) so `status` is safe.
219
+ return os.path.exists(token_path(cfg))
220
+
221
+
222
+ def delete_token(cfg: Optional[dict[str, Any]] = None) -> bool:
223
+ """Remove the stored token. Returns True if a file was deleted, False if
224
+ none existed. A real removal failure is NEVER swallowed (it would falsely
225
+ report the token as gone while it persists) — it raises so the caller can
226
+ report it and exit non-zero."""
227
+ path = token_path(cfg)
228
+ try:
229
+ os.remove(path)
230
+ return True
231
+ except FileNotFoundError:
232
+ return False
233
+ except OSError as exc:
234
+ from .errors import TelemetryConfigError
235
+ raise TelemetryConfigError(
236
+ f"could not delete telemetry token file {path}: {exc}. "
237
+ f"The token may still be present — remove it manually.") from exc
@@ -0,0 +1,57 @@
1
+ """The exact, user-visible consent statement for telemetry (DR-27).
2
+
3
+ ``llmt telemetry enable`` prints this and requires explicit confirmation
4
+ (interactive y/N, or ``--yes`` for scripts) before any token is stored or any
5
+ heartbeat is ever sent. It states EXACTLY what is sent and what is NEVER sent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ #: What a heartbeat / challenge answer contains — enumerated for the user.
11
+ SENDS = [
12
+ "For each torrent your client is seeding/downloading: its BitTorrent "
13
+ "infohash(es) (v1 and/or v2), the torrent name, its state "
14
+ "(seeding/downloading/stalled/paused/error) and progress (0..1).",
15
+ "The SHA-256 of artifacts you are seeding (the same public digests the "
16
+ "catalog already publishes).",
17
+ "When the server issues a possession challenge: the SHA-256 of a "
18
+ "server-chosen byte range of one artifact file (salt || bytes), proving "
19
+ "you hold the bytes without uploading them.",
20
+ "Your telemetry token, as an Authorization: Bearer header.",
21
+ ]
22
+
23
+ #: What is NEVER sent — the privacy floor.
24
+ NEVER = [
25
+ "Local file paths, filenames, or directory listings.",
26
+ "Anything from `llmt scan` (scan roots, scanned paths, or scan results).",
27
+ "Hardware, OS, network, username, or any machine fingerprint.",
28
+ "The raw contents of your files (only fixed-size range hashes leave).",
29
+ ]
30
+
31
+ STATEMENT_VERSION = 1
32
+
33
+
34
+ def consent_text() -> str:
35
+ out = [
36
+ "llmt telemetry — explicit opt-in consent",
37
+ "=" * 40,
38
+ "",
39
+ "Telemetry is OFF by default. Enabling it lets the LLM Torrents site "
40
+ "credit you for the artifacts you seed. It is fully revocable at any "
41
+ "time with `llmt telemetry disable` (which also deletes the stored "
42
+ "token).",
43
+ "",
44
+ "WHAT IS SENT (only while enabled, only to your configured endpoint):",
45
+ ]
46
+ for s in SENDS:
47
+ out.append(f" * {s}")
48
+ out.append("")
49
+ out.append("WHAT IS NEVER SENT:")
50
+ for s in NEVER:
51
+ out.append(f" * {s}")
52
+ out.append("")
53
+ out.append("View or delete the data the site holds about you on the "
54
+ "website (Account -> Telemetry); the CLI only sends and can "
55
+ "stop sending — it cannot delete server-side history.")
56
+ out.append("")
57
+ return "\n".join(out)
@@ -0,0 +1,41 @@
1
+ """Typed telemetry errors — telemetry-local so ``errors.py`` stays untouched.
2
+
3
+ All subclass :class:`llmt.errors.LlmtError` so ``cli.main`` prints one clean
4
+ line and exits non-zero (never a traceback). 429 rate-limiting deliberately
5
+ REUSES the existing :class:`llmt.errors.RateLimitedError` (exit 5) so the CLI's
6
+ single Retry-After path handles it identically to the read API.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..errors import LlmtError
12
+
13
+
14
+ class TelemetryError(LlmtError):
15
+ """Base for telemetry failures that are not one of the specific kinds."""
16
+
17
+ exit_code = 20
18
+
19
+
20
+ class TelemetryConsentError(TelemetryError):
21
+ """Telemetry is disabled / no consent — NEVER touches the network."""
22
+
23
+ exit_code = 20
24
+
25
+
26
+ class TelemetryConfigError(TelemetryError):
27
+ """Missing token / bad endpoint / unusable local config."""
28
+
29
+ exit_code = 23
30
+
31
+
32
+ class TelemetryAuthError(TelemetryError):
33
+ """401 — missing/unknown/revoked token."""
34
+
35
+ exit_code = 21
36
+
37
+
38
+ class TelemetryScopeError(TelemetryError):
39
+ """403 — the token lacks the ``telemetry:write`` scope."""
40
+
41
+ exit_code = 22