xmemcli 0.0.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.
- xmemcli/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
xmemcli/_login.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""The browser handoff behind ``xmem auth login`` (PKCE + loopback callback).
|
|
2
|
+
|
|
3
|
+
The CLI mints a PKCE ``verifier`` / ``challenge`` and opens ``/xmemcli/<challenge>``.
|
|
4
|
+
It listens on ``127.0.0.1:9636`` while the human creates a key and clicks **Use this key
|
|
5
|
+
in the CLI**. The Console POSTs the key to ``/cli/handoff``, receives a signed short-lived
|
|
6
|
+
``code``, then redirects the browser to the local callback with ``code`` + ``state``.
|
|
7
|
+
The CLI redeems with ``verifier`` + ``code`` over TLS; only it holds the verifier.
|
|
8
|
+
|
|
9
|
+
This loopback callback is a conscious multi-node design choice: the API remains stateless
|
|
10
|
+
for sign-in, because any node can mint/verify the signed code and no shared relay table,
|
|
11
|
+
cache, sticky session, or cross-node pub/sub is required.
|
|
12
|
+
Standard-library only, like the rest of the CLI.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import select
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
27
|
+
from typing import IO, Any, Callable, NamedTuple, cast, override
|
|
28
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import termios
|
|
32
|
+
import tty
|
|
33
|
+
|
|
34
|
+
_TERMIOS_AVAILABLE = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
termios = None # type: ignore[assignment]
|
|
37
|
+
tty = None # type: ignore[assignment]
|
|
38
|
+
_TERMIOS_AVAILABLE = False
|
|
39
|
+
|
|
40
|
+
from ._envelope import batch_envelope_error
|
|
41
|
+
from ._version import CLI_NAME
|
|
42
|
+
|
|
43
|
+
# Must match ``xmem.ENV_CONSOLE_URL`` / ``xmem.ENV_API_URL``.
|
|
44
|
+
# Duplicated here so ``_login`` stays importable without loading ``xmem``.
|
|
45
|
+
_ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
|
|
46
|
+
_ENV_API_URL = "XMEM_API_URL"
|
|
47
|
+
DEFAULT_CONSOLE_URL = "https://console.xmemory.ai"
|
|
48
|
+
|
|
49
|
+
CALLBACK_PORT = 9636
|
|
50
|
+
CALLBACK_PATH = "/callback"
|
|
51
|
+
CLI_LOGIN_PATH = "/xmemcli"
|
|
52
|
+
CLI_TOKEN_PROXY_PATH = "/api/cli/token"
|
|
53
|
+
CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
|
|
54
|
+
|
|
55
|
+
API_KEY_PREFIX = "xmem_"
|
|
56
|
+
|
|
57
|
+
_DONE_PAGE = (
|
|
58
|
+
f"<!doctype html><html><head><meta charset='utf-8'><title>{CLI_NAME}</title></head>"
|
|
59
|
+
"<body style='font-family:system-ui;margin:3rem'>"
|
|
60
|
+
"<h2>You're all set.</h2><p>You can close this tab and return to your terminal.</p>"
|
|
61
|
+
"</body></html>"
|
|
62
|
+
).encode("utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LoginError(Exception):
|
|
66
|
+
"""A login flow failed (bind error, missing code, bad ``state``)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LoginTimeout(LoginError):
|
|
70
|
+
"""The human did not complete the browser step before the deadline."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LoginAborted(Exception):
|
|
74
|
+
"""The user cancelled login (e.g. Ctrl+C)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HandoffResult(NamedTuple):
|
|
78
|
+
"""Outcome of a successful PKCE login — carries the secret and validated account email."""
|
|
79
|
+
|
|
80
|
+
api_key: str
|
|
81
|
+
key_prefix: str
|
|
82
|
+
name: str
|
|
83
|
+
email: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def redirect_uri_for_port(port: int) -> str:
|
|
87
|
+
return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def redirect_uri() -> str:
|
|
91
|
+
return redirect_uri_for_port(CALLBACK_PORT)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def new_verifier() -> str:
|
|
95
|
+
return secrets.token_urlsafe(32)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def challenge_for(verifier: str) -> str:
|
|
99
|
+
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
|
|
100
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _console_url_from_api_url(api_url: str) -> str | None:
|
|
104
|
+
"""Local dev convention: API on :8000, Console on :8002 (same host)."""
|
|
105
|
+
parsed = urlparse(api_url.rstrip("/"))
|
|
106
|
+
if parsed.hostname not in ("localhost", "127.0.0.1"):
|
|
107
|
+
return None
|
|
108
|
+
if parsed.port == 8000:
|
|
109
|
+
return f"{parsed.scheme}://{parsed.hostname}:8002"
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def console_url_for_api(*, console_url: str | None = None) -> str:
|
|
114
|
+
"""Resolve the Console origin for PKCE login and token/whoami proxies."""
|
|
115
|
+
if console_url:
|
|
116
|
+
return console_url.rstrip("/")
|
|
117
|
+
from_env = os.environ.get(_ENV_CONSOLE_URL, "").strip()
|
|
118
|
+
if from_env:
|
|
119
|
+
return from_env.rstrip("/")
|
|
120
|
+
api_from_env = os.environ.get(_ENV_API_URL, "").strip()
|
|
121
|
+
if api_from_env:
|
|
122
|
+
inferred = _console_url_from_api_url(api_from_env)
|
|
123
|
+
if inferred:
|
|
124
|
+
return inferred
|
|
125
|
+
return DEFAULT_CONSOLE_URL
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_login_page_url(console_url: str, *, challenge: str, callback_url: str | None = None) -> str:
|
|
129
|
+
"""Console CLI login URL: ``/xmemcli/<challenge>`` (PKCE S256, also the callback state)."""
|
|
130
|
+
segment = quote(challenge, safe="-_")
|
|
131
|
+
url = f"{console_url.rstrip('/')}{CLI_LOGIN_PATH}/{segment}"
|
|
132
|
+
if callback_url and callback_url != redirect_uri_for_port(CALLBACK_PORT):
|
|
133
|
+
url += "?callback_url=" + quote(callback_url, safe=":/?&=-_.")
|
|
134
|
+
return url
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _login_prompt_line(label: str, body: str) -> str:
|
|
138
|
+
return f" {label:<10}{body}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_login_handoff_prompt(
|
|
142
|
+
login_url: str,
|
|
143
|
+
*,
|
|
144
|
+
offer_paste: bool,
|
|
145
|
+
stream: IO[str] | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Human-facing instructions while ``auth login`` waits for a credential."""
|
|
148
|
+
out = stream or sys.stderr
|
|
149
|
+
body_indent = " "
|
|
150
|
+
lines = [
|
|
151
|
+
"",
|
|
152
|
+
f"Authorize {CLI_NAME} on this machine.",
|
|
153
|
+
"",
|
|
154
|
+
_login_prompt_line("Browser", "Open this URL and click \"Use this key in the CLI\":"),
|
|
155
|
+
f"{body_indent}{login_url}",
|
|
156
|
+
]
|
|
157
|
+
if offer_paste:
|
|
158
|
+
lines.extend(
|
|
159
|
+
[
|
|
160
|
+
"",
|
|
161
|
+
_login_prompt_line(
|
|
162
|
+
"Paste",
|
|
163
|
+
"Paste your API key below (input hidden), then press Enter.",
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
lines.extend(
|
|
168
|
+
[
|
|
169
|
+
"",
|
|
170
|
+
_login_prompt_line("Manual", "Set $XMEM_API_KEY or `.xmemrc.json`"),
|
|
171
|
+
f"{body_indent}See AUTH-FLOW.md.",
|
|
172
|
+
"",
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
out.write("\n".join(lines))
|
|
176
|
+
out.flush()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def token_redeem_urls(*, api_url: str, console_url: str | None) -> list[str]:
|
|
180
|
+
"""Candidate ``POST /cli/token`` URLs, most likely first."""
|
|
181
|
+
console = console_url_for_api(console_url=console_url)
|
|
182
|
+
if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
|
|
183
|
+
return [f"{console.rstrip('/')}{CLI_TOKEN_PROXY_PATH}"]
|
|
184
|
+
base = api_url.rstrip("/")
|
|
185
|
+
return [f"{base}/cli/token", f"{base}/api/cli/token"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def whoami_urls(*, api_url: str, console_url: str | None) -> list[str]:
|
|
189
|
+
"""Candidate ``GET /whoami`` URLs for bearer API-key lookup, most likely first."""
|
|
190
|
+
console = console_url_for_api(console_url=console_url)
|
|
191
|
+
if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
|
|
192
|
+
return [f"{console.rstrip('/')}{CLI_WHOAMI_PROXY_PATH}"]
|
|
193
|
+
base = api_url.rstrip("/")
|
|
194
|
+
return [f"{base}/whoami", f"{base}/api/whoami"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _LoopbackServer(HTTPServer):
|
|
198
|
+
captured: dict[str, str] | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
202
|
+
def do_GET(self) -> None: # noqa: N802
|
|
203
|
+
parsed = urlparse(self.path)
|
|
204
|
+
if parsed.path != CALLBACK_PATH:
|
|
205
|
+
self.send_response(404)
|
|
206
|
+
self.end_headers()
|
|
207
|
+
return
|
|
208
|
+
params = {k: v[0] for k, v in parse_qs(parsed.query).items() if v}
|
|
209
|
+
cast(_LoopbackServer, self.server).captured = params
|
|
210
|
+
self.send_response(200)
|
|
211
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
212
|
+
self.end_headers()
|
|
213
|
+
self.wfile.write(_DONE_PAGE)
|
|
214
|
+
|
|
215
|
+
@override
|
|
216
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def wait_for_callback(
|
|
221
|
+
state: str,
|
|
222
|
+
build_url: Callable[[str], str],
|
|
223
|
+
on_url: Callable[[str], None],
|
|
224
|
+
timeout_s: float,
|
|
225
|
+
*,
|
|
226
|
+
callback_port: int = CALLBACK_PORT,
|
|
227
|
+
stop: threading.Event | None = None,
|
|
228
|
+
) -> dict[str, str]:
|
|
229
|
+
"""Listen for the browser's local callback carrying the signed code.
|
|
230
|
+
|
|
231
|
+
This is intentionally the production transport. It keeps CLI sign-in friendly to multi-node API deployments:
|
|
232
|
+
the server only mints/verifies stateless JWT codes, while the browser delivers the code directly to the waiting CLI.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
server = _LoopbackServer(("127.0.0.1", callback_port), _CallbackHandler)
|
|
236
|
+
except OSError as e:
|
|
237
|
+
label = callback_port if callback_port != 0 else CALLBACK_PORT
|
|
238
|
+
raise LoginError(
|
|
239
|
+
f"could not bind 127.0.0.1:{label} — is another `{CLI_NAME} auth login` in progress? ({e})"
|
|
240
|
+
) from None
|
|
241
|
+
bound_port = server.server_address[1]
|
|
242
|
+
server.timeout = 0.5
|
|
243
|
+
try:
|
|
244
|
+
on_url(build_url(redirect_uri_for_port(bound_port)))
|
|
245
|
+
deadline = time.monotonic() + timeout_s
|
|
246
|
+
while server.captured is None and not (stop and stop.is_set()):
|
|
247
|
+
if time.monotonic() >= deadline:
|
|
248
|
+
raise LoginTimeout(
|
|
249
|
+
f"timed out after {timeout_s:g}s waiting for the browser callback on port {bound_port}"
|
|
250
|
+
)
|
|
251
|
+
server.handle_request()
|
|
252
|
+
finally:
|
|
253
|
+
server.server_close()
|
|
254
|
+
params = server.captured
|
|
255
|
+
if params is None: # pragma: no cover
|
|
256
|
+
raise LoginError("callback listener stopped")
|
|
257
|
+
if params.get("state") != state:
|
|
258
|
+
raise LoginError("callback `state` did not match — rejecting (possible CSRF / stale tab)")
|
|
259
|
+
return params
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _response_looks_like_html(raw: str) -> bool:
|
|
263
|
+
head = raw.lstrip()[:256].lower()
|
|
264
|
+
return head.startswith("<!doctype") or head.startswith("<html")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_token_envelope(raw: str, *, url: str) -> HandoffResult:
|
|
268
|
+
if _response_looks_like_html(raw):
|
|
269
|
+
raise LoginError(f"token redeem got a web page instead of JSON from {url}")
|
|
270
|
+
try:
|
|
271
|
+
envelope = json.loads(raw)
|
|
272
|
+
except json.JSONDecodeError as e:
|
|
273
|
+
raise LoginError(f"token redeem returned non-JSON from {url}: {raw[:200]}") from e
|
|
274
|
+
err = batch_envelope_error(envelope)
|
|
275
|
+
if err:
|
|
276
|
+
raise LoginError(f"token redeem failed: {err}")
|
|
277
|
+
items = envelope.get("items") if isinstance(envelope, dict) else None
|
|
278
|
+
if not isinstance(items, list) or not items or not isinstance(items[0], dict):
|
|
279
|
+
raise LoginError("token redeem response missing items[0]")
|
|
280
|
+
item = items[0]
|
|
281
|
+
key = item.get("key")
|
|
282
|
+
if not isinstance(key, str) or not key:
|
|
283
|
+
raise LoginError("token redeem response missing key")
|
|
284
|
+
key_prefix_raw = item.get("key_prefix")
|
|
285
|
+
key_prefix = key_prefix_raw if isinstance(key_prefix_raw, str) and key_prefix_raw else key[:8]
|
|
286
|
+
name_raw = item.get("name")
|
|
287
|
+
name = name_raw if isinstance(name_raw, str) and name_raw else "cli"
|
|
288
|
+
email_raw = item.get("email")
|
|
289
|
+
if not isinstance(email_raw, str) or not email_raw.strip() or "@" not in email_raw:
|
|
290
|
+
raise LoginError("token redeem response missing validated email")
|
|
291
|
+
email = email_raw.strip().lower()
|
|
292
|
+
return HandoffResult(api_key=key, key_prefix=key_prefix, name=name, email=email)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def redeem_token(
|
|
296
|
+
api_url: str,
|
|
297
|
+
*,
|
|
298
|
+
code: str,
|
|
299
|
+
verifier: str,
|
|
300
|
+
console_url: str | None = None,
|
|
301
|
+
timeout_s: float = 30.0,
|
|
302
|
+
) -> HandoffResult:
|
|
303
|
+
"""Exchange ``verifier`` + signed ``code`` for the issued key."""
|
|
304
|
+
payload: dict[str, str] = {"verifier": verifier}
|
|
305
|
+
if not code:
|
|
306
|
+
raise LoginError("internal error: redeem requires code")
|
|
307
|
+
payload["code"] = code
|
|
308
|
+
body = json.dumps(payload).encode("utf-8")
|
|
309
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
310
|
+
errors: list[str] = []
|
|
311
|
+
for url in token_redeem_urls(api_url=api_url, console_url=console_url):
|
|
312
|
+
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
|
|
313
|
+
try:
|
|
314
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
315
|
+
raw = resp.read().decode("utf-8")
|
|
316
|
+
except urllib.error.HTTPError as e:
|
|
317
|
+
if e.code == 401:
|
|
318
|
+
raise LoginError(
|
|
319
|
+
f"The login code expired or is invalid — run `{CLI_NAME} auth login` again."
|
|
320
|
+
) from None
|
|
321
|
+
detail = e.read().decode("utf-8", errors="replace")[:200]
|
|
322
|
+
errors.append(f"{url}: HTTP {e.code} ({detail})")
|
|
323
|
+
continue
|
|
324
|
+
except urllib.error.URLError as e:
|
|
325
|
+
errors.append(f"{url}: network error ({e.reason})")
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
return _parse_token_envelope(raw, url=url)
|
|
329
|
+
except LoginError as e:
|
|
330
|
+
errors.append(str(e))
|
|
331
|
+
continue
|
|
332
|
+
hint = (
|
|
333
|
+
"Could not reach the accounts API token endpoint. "
|
|
334
|
+
"Set $XMEM_CONSOLE_URL to your Console (e.g. dk-web…) and ensure it can reach the API "
|
|
335
|
+
"(ACCOUNTS_API_URL on the Console server)."
|
|
336
|
+
)
|
|
337
|
+
detail = "; ".join(errors[-3:]) if errors else "no endpoints tried"
|
|
338
|
+
raise LoginError(f"token redeem failed: {detail}. {hint}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def stdin_is_interactive() -> bool:
|
|
342
|
+
"""True when stdin is a TTY (interactive paste is meaningful)."""
|
|
343
|
+
try:
|
|
344
|
+
return sys.stdin.isatty()
|
|
345
|
+
except (AttributeError, ValueError, OSError):
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def paste_from_stdin_supported() -> bool:
|
|
350
|
+
"""True when hidden stdin paste can be offered (Unix TTY with termios)."""
|
|
351
|
+
return stdin_is_interactive() and _TERMIOS_AVAILABLE
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_pasted_api_key_no_echo(*, stop: threading.Event) -> str | None:
|
|
355
|
+
"""Read one line from stdin without echo. Returns None if *stop* is set first."""
|
|
356
|
+
if not _TERMIOS_AVAILABLE or termios is None or tty is None:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
fd = sys.stdin.fileno()
|
|
360
|
+
old_settings = termios.tcgetattr(fd)
|
|
361
|
+
chars: list[str] = []
|
|
362
|
+
try:
|
|
363
|
+
tty.setraw(fd)
|
|
364
|
+
while not stop.is_set():
|
|
365
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.15)
|
|
366
|
+
if not ready:
|
|
367
|
+
continue
|
|
368
|
+
ch = sys.stdin.read(1)
|
|
369
|
+
if ch in ("\n", "\r"):
|
|
370
|
+
return "".join(chars).strip()
|
|
371
|
+
if ch == "\x03":
|
|
372
|
+
raise LoginAborted()
|
|
373
|
+
if ch in ("\x7f", "\b"):
|
|
374
|
+
if chars:
|
|
375
|
+
chars.pop()
|
|
376
|
+
continue
|
|
377
|
+
if ch == "\x04":
|
|
378
|
+
return "".join(chars).strip()
|
|
379
|
+
chars.append(ch)
|
|
380
|
+
return None
|
|
381
|
+
finally:
|
|
382
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
383
|
+
# DELIBERATELY print: newline after raw-mode paste must not touch stdout (machine JSON).
|
|
384
|
+
sys.stderr.write("\n")
|
|
385
|
+
sys.stderr.flush()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def fetch_whoami_email(
|
|
389
|
+
api_url: str,
|
|
390
|
+
api_key: str,
|
|
391
|
+
*,
|
|
392
|
+
console_url: str | None = None,
|
|
393
|
+
timeout_s: float = 10.0,
|
|
394
|
+
) -> str:
|
|
395
|
+
"""Resolve an ``xmem_`` API key to its owner email via ``GET /whoami``."""
|
|
396
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"}
|
|
397
|
+
errors: list[str] = []
|
|
398
|
+
for url in whoami_urls(api_url=api_url, console_url=console_url):
|
|
399
|
+
req = urllib.request.Request(url, method="GET", headers=headers)
|
|
400
|
+
try:
|
|
401
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
402
|
+
raw = resp.read().decode("utf-8")
|
|
403
|
+
except urllib.error.HTTPError as e:
|
|
404
|
+
detail = e.read().decode("utf-8", errors="replace")[:200]
|
|
405
|
+
errors.append(f"{url}: HTTP {e.code} ({detail})")
|
|
406
|
+
continue
|
|
407
|
+
except urllib.error.URLError as e:
|
|
408
|
+
raise LoginError(f"whoami network error at {url}: {e.reason}") from None
|
|
409
|
+
try:
|
|
410
|
+
body = json.loads(raw)
|
|
411
|
+
except json.JSONDecodeError as e:
|
|
412
|
+
raise LoginError(f"whoami returned non-JSON from {url}") from e
|
|
413
|
+
auth = body.get("AuthApiKey") if isinstance(body, dict) else None
|
|
414
|
+
if isinstance(auth, dict):
|
|
415
|
+
email_raw = auth.get("email")
|
|
416
|
+
if isinstance(email_raw, str) and email_raw.strip() and "@" in email_raw:
|
|
417
|
+
return email_raw.strip().lower()
|
|
418
|
+
errors.append(f"{url}: key did not resolve to an active account")
|
|
419
|
+
detail = "; ".join(errors[-2:]) if errors else "no whoami endpoints tried"
|
|
420
|
+
raise LoginError(f"pasted key is not valid or not active ({detail})")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def handoff_from_pasted_key(
|
|
424
|
+
api_url: str,
|
|
425
|
+
api_key: str,
|
|
426
|
+
*,
|
|
427
|
+
console_url: str | None = None,
|
|
428
|
+
timeout_s: float = 10.0,
|
|
429
|
+
) -> HandoffResult:
|
|
430
|
+
"""Build a handoff result from a human-pasted plaintext key."""
|
|
431
|
+
key = api_key.strip()
|
|
432
|
+
if not key.startswith(API_KEY_PREFIX):
|
|
433
|
+
raise LoginError(f"pasted key must start with {API_KEY_PREFIX}")
|
|
434
|
+
if len(key) < len(API_KEY_PREFIX) + 8:
|
|
435
|
+
raise LoginError("pasted key looks too short")
|
|
436
|
+
email = fetch_whoami_email(api_url, key, console_url=console_url, timeout_s=timeout_s)
|
|
437
|
+
return HandoffResult(api_key=key, key_prefix=key[:8], name="pasted", email=email)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def wait_for_handoff(
|
|
441
|
+
api_url: str,
|
|
442
|
+
*,
|
|
443
|
+
console_url: str | None,
|
|
444
|
+
challenge: str,
|
|
445
|
+
verifier: str,
|
|
446
|
+
build_url: Callable[[str], str],
|
|
447
|
+
on_url: Callable[[str], None],
|
|
448
|
+
timeout_s: float,
|
|
449
|
+
callback_port: int = CALLBACK_PORT,
|
|
450
|
+
interactive: bool = False,
|
|
451
|
+
notify: Callable[[str], None] | None = None,
|
|
452
|
+
) -> HandoffResult:
|
|
453
|
+
"""Wait for a key via loopback callback and, when interactive, hidden stdin paste in parallel."""
|
|
454
|
+
notify = notify or (lambda _msg: None)
|
|
455
|
+
stop = threading.Event()
|
|
456
|
+
abort_requested = threading.Event()
|
|
457
|
+
result_box: list[HandoffResult] = []
|
|
458
|
+
errors: list[Exception] = []
|
|
459
|
+
offer_paste = interactive and paste_from_stdin_supported()
|
|
460
|
+
prompt_ready = threading.Event()
|
|
461
|
+
|
|
462
|
+
def request_abort() -> None:
|
|
463
|
+
abort_requested.set()
|
|
464
|
+
stop.set()
|
|
465
|
+
|
|
466
|
+
def callback_worker() -> None:
|
|
467
|
+
def opened(url: str) -> None:
|
|
468
|
+
on_url(url)
|
|
469
|
+
prompt_ready.set()
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
params = wait_for_callback(
|
|
473
|
+
challenge,
|
|
474
|
+
build_url,
|
|
475
|
+
opened,
|
|
476
|
+
timeout_s,
|
|
477
|
+
callback_port=callback_port,
|
|
478
|
+
stop=stop,
|
|
479
|
+
)
|
|
480
|
+
code = params.get("code")
|
|
481
|
+
if not code:
|
|
482
|
+
raise LoginError("callback missing code")
|
|
483
|
+
result = redeem_token(
|
|
484
|
+
api_url,
|
|
485
|
+
code=code,
|
|
486
|
+
console_url=console_url,
|
|
487
|
+
verifier=verifier,
|
|
488
|
+
timeout_s=min(10.0, timeout_s),
|
|
489
|
+
)
|
|
490
|
+
if not result_box:
|
|
491
|
+
result_box.append(result)
|
|
492
|
+
notify("Key received from the browser callback; paste prompt closed.")
|
|
493
|
+
stop.set()
|
|
494
|
+
except LoginTimeout as e:
|
|
495
|
+
if not stop.is_set():
|
|
496
|
+
errors.append(e)
|
|
497
|
+
except LoginError as e:
|
|
498
|
+
if not stop.is_set():
|
|
499
|
+
errors.append(e)
|
|
500
|
+
stop.set()
|
|
501
|
+
|
|
502
|
+
def paste_worker() -> None:
|
|
503
|
+
while not stop.is_set() and not prompt_ready.wait(timeout=0.1):
|
|
504
|
+
pass
|
|
505
|
+
if stop.is_set():
|
|
506
|
+
return
|
|
507
|
+
try:
|
|
508
|
+
while not stop.is_set():
|
|
509
|
+
try:
|
|
510
|
+
pasted = _read_pasted_api_key_no_echo(stop=stop)
|
|
511
|
+
except LoginAborted:
|
|
512
|
+
errors.append(LoginAborted())
|
|
513
|
+
request_abort()
|
|
514
|
+
return
|
|
515
|
+
if stop.is_set():
|
|
516
|
+
return
|
|
517
|
+
if not pasted:
|
|
518
|
+
continue
|
|
519
|
+
try:
|
|
520
|
+
result = handoff_from_pasted_key(
|
|
521
|
+
api_url, pasted, console_url=console_url, timeout_s=min(10.0, timeout_s)
|
|
522
|
+
)
|
|
523
|
+
except LoginError as e:
|
|
524
|
+
notify(f"Could not use pasted key: {e}")
|
|
525
|
+
continue
|
|
526
|
+
if not result_box:
|
|
527
|
+
result_box.append(result)
|
|
528
|
+
notify("Using pasted key; stopped waiting for browser callback.")
|
|
529
|
+
stop.set()
|
|
530
|
+
return
|
|
531
|
+
except LoginAborted:
|
|
532
|
+
errors.append(LoginAborted())
|
|
533
|
+
request_abort()
|
|
534
|
+
|
|
535
|
+
threads = [threading.Thread(target=callback_worker, daemon=True)]
|
|
536
|
+
if offer_paste:
|
|
537
|
+
threads.append(threading.Thread(target=paste_worker, daemon=True))
|
|
538
|
+
for thread in threads:
|
|
539
|
+
thread.start()
|
|
540
|
+
|
|
541
|
+
deadline = time.monotonic() + timeout_s
|
|
542
|
+
try:
|
|
543
|
+
while time.monotonic() < deadline and not result_box:
|
|
544
|
+
if abort_requested.is_set():
|
|
545
|
+
raise LoginAborted()
|
|
546
|
+
if stop.is_set() and errors:
|
|
547
|
+
break
|
|
548
|
+
time.sleep(0.05)
|
|
549
|
+
except KeyboardInterrupt:
|
|
550
|
+
request_abort()
|
|
551
|
+
raise LoginAborted() from None
|
|
552
|
+
finally:
|
|
553
|
+
stop.set()
|
|
554
|
+
for thread in threads:
|
|
555
|
+
thread.join(timeout=1.0)
|
|
556
|
+
|
|
557
|
+
if result_box:
|
|
558
|
+
return result_box[0]
|
|
559
|
+
if any(isinstance(e, LoginAborted) for e in errors):
|
|
560
|
+
raise LoginAborted()
|
|
561
|
+
if errors:
|
|
562
|
+
raise errors[0]
|
|
563
|
+
raise LoginTimeout(
|
|
564
|
+
f"timed out after {timeout_s:g}s waiting for a key from the browser callback or stdin"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def run_pkce_login(
|
|
569
|
+
api_url: str,
|
|
570
|
+
*,
|
|
571
|
+
console_url: str | None,
|
|
572
|
+
verifier: str,
|
|
573
|
+
on_url: Callable[[str], None],
|
|
574
|
+
timeout_s: float,
|
|
575
|
+
callback_port: int = CALLBACK_PORT,
|
|
576
|
+
interactive: bool = False,
|
|
577
|
+
notify: Callable[[str], None] | None = None,
|
|
578
|
+
) -> HandoffResult:
|
|
579
|
+
"""Open the Console login page and wait for loopback callback and/or hidden stdin paste."""
|
|
580
|
+
console = console_url_for_api(console_url=console_url)
|
|
581
|
+
challenge = challenge_for(verifier)
|
|
582
|
+
return wait_for_handoff(
|
|
583
|
+
api_url,
|
|
584
|
+
console_url=console_url,
|
|
585
|
+
challenge=challenge,
|
|
586
|
+
verifier=verifier,
|
|
587
|
+
build_url=lambda redirect_uri: build_login_page_url(console, challenge=challenge, callback_url=redirect_uri),
|
|
588
|
+
on_url=on_url,
|
|
589
|
+
timeout_s=timeout_s,
|
|
590
|
+
callback_port=callback_port,
|
|
591
|
+
interactive=interactive,
|
|
592
|
+
notify=notify,
|
|
593
|
+
)
|
xmemcli/_org_cluster.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Org-scoped inventory commands for ``xmemcli``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._envelope import batch_envelope_error
|
|
7
|
+
from ._output import CliOutput, OutputKind, make_error
|
|
8
|
+
from ._xmem_bridge import curate_instance, http_to_exit, xmem_module
|
|
9
|
+
|
|
10
|
+
_CLUSTER_FIELDS = ("id", "name", "description")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _curate_cluster(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
|
|
14
|
+
m = xmem_module()
|
|
15
|
+
curated = {k: item[k] for k in _CLUSTER_FIELDS if k in item}
|
|
16
|
+
return m._add_verbose_extras(curated, item, verbose)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_org_list_clusters(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
20
|
+
"""``org list clusters`` — clusters visible to this credential."""
|
|
21
|
+
m = xmem_module()
|
|
22
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
23
|
+
base = m._api_url(args)
|
|
24
|
+
try:
|
|
25
|
+
envelope = m._get(f"{base}/clusters?verbose=true", api_key)
|
|
26
|
+
except m.ApiError as e:
|
|
27
|
+
return (make_error(f"/clusters failed: {e}", status=e.status), http_to_exit(e.status))
|
|
28
|
+
err = batch_envelope_error(envelope)
|
|
29
|
+
if err:
|
|
30
|
+
return (make_error(f"/clusters failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
|
|
31
|
+
items = [i for i in (envelope.get("items") or []) if isinstance(i, dict)]
|
|
32
|
+
curated = [_curate_cluster(i, args.verbose) for i in items]
|
|
33
|
+
return (CliOutput(OutputKind.CLUSTER_LIST, curated), m.EXIT_OK)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_org_list_instances(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
37
|
+
"""``org list instances`` — instances visible to this credential (``GET /instances``)."""
|
|
38
|
+
m = xmem_module()
|
|
39
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
40
|
+
base = m._api_url(args)
|
|
41
|
+
cluster_filter = getattr(args, "cluster", None)
|
|
42
|
+
try:
|
|
43
|
+
envelope = m._get(f"{base}/instances?verbose=true", api_key)
|
|
44
|
+
except m.ApiError as e:
|
|
45
|
+
return (make_error(f"/instances failed: {e}", status=e.status), http_to_exit(e.status))
|
|
46
|
+
err = batch_envelope_error(envelope)
|
|
47
|
+
if err:
|
|
48
|
+
return (make_error(f"/instances failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
|
|
49
|
+
items = [i for i in (envelope.get("items") or []) if isinstance(i, dict)]
|
|
50
|
+
if cluster_filter:
|
|
51
|
+
items = [i for i in items if i.get("cluster_id") == cluster_filter]
|
|
52
|
+
curated = [curate_instance(i, args.verbose) for i in items]
|
|
53
|
+
return (CliOutput(OutputKind.INSTANCE_LIST, curated), m.EXIT_OK)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_org_list_keys(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
57
|
+
"""``org list keys`` — API keys visible to this credential."""
|
|
58
|
+
m = xmem_module()
|
|
59
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
60
|
+
base = m._api_url(args)
|
|
61
|
+
try:
|
|
62
|
+
envelope = m._get(f"{base}/api-keys?verbose=true", api_key)
|
|
63
|
+
except m.ApiError as e:
|
|
64
|
+
return (make_error(f"/api-keys failed: {e}", status=e.status), http_to_exit(e.status))
|
|
65
|
+
err = batch_envelope_error(envelope)
|
|
66
|
+
if err:
|
|
67
|
+
return (make_error(f"/api-keys failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
|
|
68
|
+
keys = envelope.get("items") or []
|
|
69
|
+
curated = [m._curate_key(k, args.verbose) for k in keys if isinstance(k, dict)]
|
|
70
|
+
return (CliOutput(OutputKind.KEY_LIST, curated), m.EXIT_OK)
|