oed-cli 0.3.0rc2__tar.gz → 0.3.1__tar.gz

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.
Files changed (33) hide show
  1. {oed_cli-0.3.0rc2/src/oed_cli.egg-info → oed_cli-0.3.1}/PKG-INFO +11 -2
  2. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/README.md +10 -1
  3. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/pyproject.toml +1 -1
  4. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/__init__.py +1 -1
  5. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/auth.py +214 -132
  6. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/cli.py +4 -0
  7. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/discovery.py +5 -0
  8. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/http.py +7 -1
  9. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/invoke.py +10 -0
  10. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/main.py +108 -0
  11. {oed_cli-0.3.0rc2 → oed_cli-0.3.1/src/oed_cli.egg-info}/PKG-INFO +11 -2
  12. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/SOURCES.txt +1 -0
  13. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_allowlist.py +1 -1
  14. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_auth.py +23 -9
  15. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_cli.py +38 -15
  16. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_invoke.py +2 -2
  17. oed_cli-0.3.1/tests/test_logging.py +237 -0
  18. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_secure_storage.py +212 -6
  19. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_spec_integrity.py +5 -2
  20. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/LICENSE +0 -0
  21. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/setup.cfg +0 -0
  22. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/__main__.py +0 -0
  23. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/defaults.toml +0 -0
  24. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/dynamic.py +0 -0
  25. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/errors.py +0 -0
  26. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/py.typed +0 -0
  27. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/dependency_links.txt +0 -0
  28. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/entry_points.txt +0 -0
  29. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/requires.txt +0 -0
  30. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/top_level.txt +0 -0
  31. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_ag_auth_cli.py +0 -0
  32. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_dynamic.py +0 -0
  33. {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_http_socks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.3.0rc2
3
+ Version: 0.3.1
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -82,7 +82,7 @@ pip install -e .
82
82
  Verify:
83
83
 
84
84
  ```
85
- oed --version # → oed, version 0.3.0rc2
85
+ oed --version # → oed, version 0.3.1
86
86
  ```
87
87
 
88
88
  ## Quick start: install, then call
@@ -131,6 +131,15 @@ oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082 \
131
131
  | jq '.response.result[0] | {cveId, affectedProduct, affectedComponent}'
132
132
  ```
133
133
 
134
+ By default `oed` is quiet: only the result JSON reaches stdout, and errors reach stderr — so the pipe above is always clean. Diagnostics and human-progress prompts are level-gated:
135
+
136
+ - `oed --log-level info <…>` — also show human prompts (e.g. the device-flow "visit URL" line).
137
+ - `oed --log-level debug <…>` — also show HTTP request/response, discovery cache, and token-refresh diagnostics.
138
+ - `-v` = `--log-level debug`, `-q` = `--log-level warning` (the default).
139
+ - `OED_LOG_LEVEL=debug|info|warning` sets it without a flag.
140
+
141
+ The flag may appear anywhere in the command line and applies to both built-in commands (`oed info`, `oed auth login`) and dynamic dispatch (`oed <service> <method>`).
142
+
134
143
  ```
135
144
  {
136
145
  "cveId": "CVE-2019-10082",
@@ -37,7 +37,7 @@ pip install -e .
37
37
  Verify:
38
38
 
39
39
  ```
40
- oed --version # → oed, version 0.3.0rc2
40
+ oed --version # → oed, version 0.3.1
41
41
  ```
42
42
 
43
43
  ## Quick start: install, then call
@@ -86,6 +86,15 @@ oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082 \
86
86
  | jq '.response.result[0] | {cveId, affectedProduct, affectedComponent}'
87
87
  ```
88
88
 
89
+ By default `oed` is quiet: only the result JSON reaches stdout, and errors reach stderr — so the pipe above is always clean. Diagnostics and human-progress prompts are level-gated:
90
+
91
+ - `oed --log-level info <…>` — also show human prompts (e.g. the device-flow "visit URL" line).
92
+ - `oed --log-level debug <…>` — also show HTTP request/response, discovery cache, and token-refresh diagnostics.
93
+ - `-v` = `--log-level debug`, `-q` = `--log-level warning` (the default).
94
+ - `OED_LOG_LEVEL=debug|info|warning` sets it without a flag.
95
+
96
+ The flag may appear anywhere in the command line and applies to both built-in commands (`oed info`, `oed auth login`) and dynamic dispatch (`oed <service> <method>`).
97
+
89
98
  ```
90
99
  {
91
100
  "cveId": "CVE-2019-10082",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.3.0rc2"
7
+ version = "0.3.1"
8
8
  description = "oed — openEuler Infra command line. Auto-discovered, AI-friendly."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.0rc2"
5
+ __version__ = "0.3.1"
6
6
  __all__ = ["__version__"]
@@ -11,6 +11,12 @@ Storage:
11
11
  cache contains ``access_token`` + ``refresh_token`` + account metadata,
12
12
  which is what enables silent refresh on subsequent calls.
13
13
 
14
+ On Windows, when the OS keyring backend is unavailable (the default install
15
+ lacks the ``[os-keyring-windows]`` extra), the plaintext fallback is
16
+ transparently DPAPI-encrypted (bound to the current Windows user) before
17
+ being written, because NTFS ignores ``0600``. macOS / Linux write plaintext
18
+ JSON unchanged. See :func:`_dpapi_enabled`.
19
+
14
20
  Public surface:
15
21
  - :func:`save_auth` / :func:`load_auth` / :func:`clear_auth` — manual token
16
22
  persistence (also synthesizes a minimal MSAL cache for backward compat)
@@ -32,12 +38,13 @@ from __future__ import annotations
32
38
 
33
39
  import contextlib
34
40
  import json
41
+ import logging
35
42
  import os
36
43
  import subprocess
37
44
  import sys
38
45
  import time
39
46
  import webbrowser
40
- from base64 import b64decode
47
+ from base64 import b64decode, b64encode
41
48
  from collections.abc import Callable
42
49
  from importlib.resources import files
43
50
  from pathlib import Path
@@ -50,6 +57,8 @@ import tomllib
50
57
 
51
58
  from .discovery import ag_token_path, auth_token_path
52
59
 
60
+ log = logging.getLogger("oed")
61
+
53
62
  # Best-effort token lifetime — the openEuler gateway does not publish a TTL.
54
63
  TOKEN_MAX_AGE = 24 * 60 * 60
55
64
 
@@ -215,8 +224,9 @@ class _SecureStore:
215
224
  def save(self, payload: dict[str, Any]) -> str:
216
225
  """Persist ``payload`` via the best available backend.
217
226
 
218
- Returns the backend label (``"keyring"`` or ``"plaintext"``) so the
219
- caller can surface it (``oed auth status``).
227
+ Returns the backend label (``"keyring"``, ``"plaintext-dpapi"`` on
228
+ Windows, or ``"plaintext"`` elsewhere) so the caller can surface it
229
+ (``oed auth status``).
220
230
  """
221
231
 
222
232
  path = self._fallback_path()
@@ -239,8 +249,8 @@ class _SecureStore:
239
249
  self._available = False
240
250
  path.parent.mkdir(parents=True, exist_ok=True)
241
251
  _atomic_write_json_plaintext(path, payload)
242
- self._last_backend = "plaintext"
243
- return "plaintext"
252
+ self._last_backend = "plaintext-dpapi" if _dpapi_enabled() else "plaintext"
253
+ return self._last_backend
244
254
 
245
255
  def load(self) -> dict[str, Any] | None:
246
256
  """Read ``payload`` from the best available backend.
@@ -269,7 +279,10 @@ class _SecureStore:
269
279
  self._keyring.delete_password(_KEYRING_SERVICE, self._username) # type: ignore[union-attr]
270
280
  # Fallback: plaintext file (migration or no-keyring envs).
271
281
  existing = _read_existing(self._fallback_path())
272
- self._last_backend = "plaintext" if existing else "unprobed"
282
+ if existing:
283
+ self._last_backend = "plaintext-dpapi" if _dpapi_enabled() else "plaintext"
284
+ else:
285
+ self._last_backend = "unprobed"
273
286
  return existing or None
274
287
 
275
288
  def clear(self) -> None:
@@ -283,7 +296,12 @@ class _SecureStore:
283
296
  self._last_backend = "unprobed"
284
297
 
285
298
  def backend_label(self) -> str:
286
- """Last backend actually used (``"keyring"`` / ``"plaintext"`` / ``"unprobed"``)."""
299
+ """Last backend actually used.
300
+
301
+ One of ``"keyring"`` (OS keystore), ``"plaintext-dpapi"`` (Windows
302
+ DPAPI-encrypted fallback), ``"plaintext"`` (macOS/Linux fallback), or
303
+ ``"unprobed"``.
304
+ """
287
305
 
288
306
  return self._last_backend
289
307
 
@@ -430,6 +448,7 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
430
448
  with contextlib.suppress(Exception):
431
449
  rt_entries = list(cache.search("RefreshToken"))
432
450
  if not rt_entries:
451
+ log.debug("silent refresh: no refresh_token in cache, skipping")
433
452
  return None
434
453
 
435
454
  client_id = get_client_id()
@@ -444,8 +463,10 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
444
463
 
445
464
  accounts = app.get_accounts()
446
465
  if not accounts:
466
+ log.debug("silent refresh: no account in cache, skipping")
447
467
  return None
448
468
 
469
+ log.debug("silent refresh: attempting acquire_token_silent")
449
470
  with contextlib.suppress(Exception):
450
471
  result = app.acquire_token_silent(
451
472
  scopes=[], # reserved scopes (openid/profile/offline_access) added by MSAL
@@ -454,7 +475,9 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
454
475
  if result and "access_token" in result:
455
476
  # Persist the refreshed cache back to disk.
456
477
  save_msal_auth(cache)
478
+ log.debug("silent refresh: succeeded, cache persisted")
457
479
  return result["access_token"]
480
+ log.debug("silent refresh: no token returned")
458
481
  return None
459
482
 
460
483
 
@@ -689,9 +712,11 @@ def device_login(
689
712
  """Run RFC 8628 device authorization grant.
690
713
 
691
714
  Returns dict with ``access_token`` (and optional ``refresh_token``) on
692
- success; ``None`` on failure or cancellation. Prints stage JSON to stdout
693
- and human prompts to stderr. The caller is responsible for persisting the
694
- resulting MSAL token cache via :func:`save_msal_auth`.
715
+ success; ``None`` on failure or cancellation. Prints human prompts and
716
+ machine-readable stage JSON to **stderr** — stdout stays reserved for the
717
+ caller's single success envelope (the ``| jq`` contract). The caller is
718
+ responsible for persisting the resulting MSAL token cache via
719
+ :func:`save_msal_auth`.
695
720
  """
696
721
 
697
722
  client_id = get_client_id()
@@ -747,36 +772,39 @@ def device_login(
747
772
 
748
773
  user_code = flow["user_code"]
749
774
  verification_uri = flow.get("verification_uri", "")
750
- expires_in = int(flow.get("expires_in", 1800))
751
- interval = int(flow.get("interval", 5))
775
+ # The code-embedded URL is the one-paste path — the bare device page would
776
+ # make the user retype the code by hand.
777
+ verify_url = flow.get("verification_uri_complete") or verification_uri
752
778
 
779
+ # stderr human prompts first — clear, actionable, ready-to-open URL front
780
+ # and center. The machine-readable stage JSON stays on stderr too, so
781
+ # agents keep stdout reserved for the caller's single JSON envelope.
753
782
  click.echo(
754
- json.dumps(
755
- {
756
- "ok": True,
757
- "stage": "device_code_issued",
758
- "user_code": user_code,
759
- "verification_uri": verification_uri,
760
- "verification_uri_complete": flow.get("verification_uri_complete"),
761
- "expires_in": expires_in,
762
- "interval": interval,
763
- },
764
- ensure_ascii=False,
765
- )
783
+ "1. Please open the following link in your browser and complete the "
784
+ f"authorization: {verification_uri}",
785
+ err=True,
766
786
  )
787
+ if user_code:
788
+ click.echo(
789
+ f"2. Please enter the following code in the browser: "
790
+ f"{click.style(user_code, fg='cyan', bold=True)}",
791
+ err=True,
792
+ )
767
793
 
768
- # stderr human prompts
769
- click.echo(
794
+ # Human prompt (stderr, level-gated): the stage JSON above already carries
795
+ # user_code + verification_uri for agents; this line is the friendlier
796
+ # interactive nudge. Default level (warning) hides it — ``--log-level info``
797
+ # shows it, ``--log-level debug`` shows it plus the HTTP diagnostics.
798
+ log.info(
770
799
  flow.get("message")
771
- or f"Visit {verification_uri} and enter code: {user_code}",
772
- err=True,
800
+ or f"Visit {verification_uri} and enter code: {user_code}"
773
801
  )
774
802
  if copy_code and _copy_to_clipboard(user_code):
775
- click.echo("(user_code copied to clipboard)", err=True)
803
+ log.info("(user_code copied to clipboard)")
776
804
 
777
- # Best-effort auto-open the verification URL.
778
- if open_browser and verification_uri:
779
- _try_open_browser(verification_uri)
805
+ # Best-effort auto-open the verification URL (code pre-filled when possible).
806
+ if open_browser and verify_url:
807
+ _try_open_browser(verify_url)
780
808
 
781
809
  # MSAL handles slow_down + anti-fast-poll + 5xx-keep-polling internally.
782
810
  # Its default exit_condition uses flow["expires_at"], set in
@@ -845,18 +873,15 @@ def _fetch_user_allowlist(
845
873
  The endpoint URL comes from ``defaults.toml [oauth].cli_user_data_url``
846
874
  with an ``OED_USER_DATA_URL`` env override (useful for fork builds).
847
875
 
848
- Each branch emits a one-line diagnostic to stderr (keyed by ``stage``)
849
- so an operator running ``oed auth login`` can see what the server
850
- returned without grepping the source. Stdout stays reserved for the
851
- success-JSON envelope — these prints never break ``| jq``.
876
+ Each branch emits a human-readable diagnostic to stderr so an operator
877
+ running ``oed auth login`` can see what happened without grepping the
878
+ source. Stdout stays reserved for the success-JSON envelope — these
879
+ prints never break ``| jq``.
852
880
  """
853
881
  url = os.environ.get("OED_USER_DATA_URL") or _get_user_data_url()
854
882
  if not url:
855
883
  click.echo(
856
- json.dumps(
857
- {"ok": True, "stage": "user_data_skipped", "reason": "no_user_data_url"},
858
- ensure_ascii=False,
859
- ),
884
+ "No user-data URL configured; skipping per-user service allowlist fetch.",
860
885
  err=True,
861
886
  )
862
887
  return None
@@ -868,6 +893,7 @@ def _fetch_user_allowlist(
868
893
  params: dict[str, str] = {}
869
894
  if user_code:
870
895
  params["user_code"] = user_code
896
+ log.debug("user-data fetch %s params=%s", url, params)
871
897
  try:
872
898
  resp = httpx.get(
873
899
  url,
@@ -881,46 +907,29 @@ def _fetch_user_allowlist(
881
907
  # an operator can tell apart auth (401/403) from missing endpoint
882
908
  # (404) from upstream errors (5xx) without re-running the curl.
883
909
  click.echo(
884
- json.dumps(
885
- {
886
- "ok": False,
887
- "stage": "user_data_fetch_failed",
888
- "url": url,
889
- "status": exc.response.status_code,
890
- "reason": exc.response.reason_phrase,
891
- "body_preview": exc.response.text[:512],
892
- },
893
- ensure_ascii=False,
894
- ),
910
+ f"Failed to fetch user service allowlist: HTTP {exc.response.status_code} "
911
+ f"{exc.response.reason_phrase} from {url}\n"
912
+ f" body: {exc.response.text[:512]}",
895
913
  err=True,
896
914
  )
897
915
  return None
898
916
  except httpx.HTTPError:
899
917
  # Connect / read / DNS / timeout — no response object.
900
918
  click.echo(
901
- json.dumps(
902
- {"ok": False, "stage": "user_data_fetch_failed", "url": url},
903
- ensure_ascii=False,
904
- ),
919
+ f"Failed to fetch user service allowlist (network error): {url}",
905
920
  err=True,
906
921
  )
907
922
  return None
908
923
 
909
924
  raw_body = resp.text
925
+ log.debug("user-data %s -> %d", url, resp.status_code)
910
926
  try:
911
927
  body = resp.json()
912
928
  except ValueError:
913
929
  click.echo(
914
- json.dumps(
915
- {
916
- "ok": False,
917
- "stage": "user_data_parse_failed",
918
- "url": url,
919
- "status": resp.status_code,
920
- "body_preview": raw_body[:512],
921
- },
922
- ensure_ascii=False,
923
- ),
930
+ f"Failed to parse user service allowlist response (HTTP {resp.status_code}) "
931
+ f"from {url}\n"
932
+ f" body: {raw_body[:512]}",
924
933
  err=True,
925
934
  )
926
935
  return None
@@ -940,35 +949,16 @@ def _fetch_user_allowlist(
940
949
  data = inner # enveloped shape
941
950
  if not isinstance(data, str):
942
951
  click.echo(
943
- json.dumps(
944
- {
945
- "ok": False,
946
- "stage": "user_data_unexpected_shape",
947
- "url": url,
948
- "status": resp.status_code,
949
- "body": body,
950
- },
951
- ensure_ascii=False,
952
- ),
952
+ f"Unexpected response shape from user service allowlist endpoint "
953
+ f"(HTTP {resp.status_code}) from {url}\n"
954
+ f" body: {json.dumps(body, ensure_ascii=False)[:512]}",
953
955
  err=True,
954
956
  )
955
957
  return None
956
958
  parsed = [s.strip() for s in data.split(",") if s.strip()]
957
959
  click.echo(
958
- json.dumps(
959
- {
960
- "ok": True,
961
- "stage": "user_data_fetched",
962
- "url": url,
963
- "status": resp.status_code,
964
- "raw_data_field": data,
965
- "parsed_allowlist": parsed,
966
- "count": len(parsed),
967
- "sent_user_code_param": bool(user_code),
968
- "response_envelope": "wrapped" if isinstance(body.get("data"), dict) else "flat",
969
- },
970
- ensure_ascii=False,
971
- ),
960
+ f"Successfully granted {len(parsed)} services:"
961
+ f"({', '.join(parsed) if parsed else 'none'})",
972
962
  err=True,
973
963
  )
974
964
  # Empty list is preserved (caller treats [] the same as None — fail-open).
@@ -1012,12 +1002,6 @@ def _finalize_device_result(
1012
1002
  save_msal_auth(cache, allowlist=allowlist)
1013
1003
  summary = _summarize_token_response(result)
1014
1004
  summary["allowlist"] = allowlist
1015
- click.echo(
1016
- json.dumps(
1017
- summary,
1018
- ensure_ascii=False,
1019
- )
1020
- )
1021
1005
  return result
1022
1006
 
1023
1007
  err = result.get("error") or "unknown"
@@ -1080,13 +1064,111 @@ def manual_login() -> dict[str, Any] | None:
1080
1064
  # ---------------------------------------------------------------------------
1081
1065
 
1082
1066
 
1067
+ def _dpapi_enabled() -> bool:
1068
+ """Whether the on-disk plaintext fallback should be DPAPI-encrypted.
1069
+
1070
+ True only on Windows. macOS / Linux keep writing plaintext JSON (0600 is
1071
+ a real permission there, and they almost always reach the keyring backend
1072
+ anyway). The :func:`_dpapi_protect` / :func:`_dpapi_unprotect` ctypes calls
1073
+ sit behind this gate, so they are never invoked — and ``ctypes.windll`` is
1074
+ never touched — on non-Windows.
1075
+ """
1076
+
1077
+ return sys.platform == "win32"
1078
+
1079
+
1080
+ def _dpapi_protect(data: bytes) -> bytes:
1081
+ """Encrypt ``data`` with Windows DPAPI (``crypt32.CryptProtectData``).
1082
+
1083
+ Bound to the current Windows user (no entropy), so the ciphertext can only
1084
+ be decrypted by this user on this machine. Raises :class:`UserError` on any
1085
+ failure — we deliberately do NOT fall back to plaintext, since avoiding
1086
+ plaintext on Windows is the whole point.
1087
+ """
1088
+
1089
+ import ctypes
1090
+ from ctypes import wintypes
1091
+
1092
+ from .errors import UserError
1093
+
1094
+ class _DATA_BLOB(ctypes.Structure):
1095
+ _fields_ = [
1096
+ ("cbData", wintypes.DWORD),
1097
+ ("pbData", ctypes.POINTER(ctypes.c_char)),
1098
+ ]
1099
+
1100
+ buf = ctypes.create_string_buffer(data, len(data))
1101
+ ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))
1102
+ blob_in = _DATA_BLOB(len(data), ptr) # type: ignore[arg-type]
1103
+ blob_out = _DATA_BLOB()
1104
+ try:
1105
+ ok = ctypes.windll.crypt32.CryptProtectData( # type: ignore[attr-defined]
1106
+ ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
1107
+ )
1108
+ if not ok:
1109
+ raise OSError(f"CryptProtectData returned {ok}")
1110
+ size = blob_out.cbData
1111
+ ciphertext = ctypes.string_at(blob_out.pbData, size)
1112
+ except Exception as exc: # noqa: BLE001 — ctypes/OS error → readable UserError
1113
+ raise UserError(
1114
+ f"DPAPI encryption failed: {exc}",
1115
+ kind="dpapi_protect_failed",
1116
+ hint="Could not encrypt the token cache with Windows DPAPI. "
1117
+ "Run `oed auth login` again, or install `oed-cli[os-keyring-windows]` "
1118
+ "to use the Credential Manager backend instead.",
1119
+ ) from exc
1120
+ finally:
1121
+ with contextlib.suppress(Exception):
1122
+ ctypes.windll.kernel32.LocalFree(blob_out.pbData) # type: ignore[attr-defined]
1123
+ return ciphertext
1124
+
1125
+
1126
+ def _dpapi_unprotect(data: bytes) -> bytes:
1127
+ """Decrypt DPAPI ciphertext (``crypt32.CryptUnprotectData``).
1128
+
1129
+ Raises on failure (wrong user / corrupt). The caller
1130
+ (:func:`_read_existing`) treats any exception as a corrupt file.
1131
+ """
1132
+
1133
+ import ctypes
1134
+ from ctypes import wintypes
1135
+
1136
+ class _DATA_BLOB(ctypes.Structure):
1137
+ _fields_ = [
1138
+ ("cbData", wintypes.DWORD),
1139
+ ("pbData", ctypes.POINTER(ctypes.c_char)),
1140
+ ]
1141
+
1142
+ buf = ctypes.create_string_buffer(data, len(data))
1143
+ ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))
1144
+ blob_in = _DATA_BLOB(len(data), ptr) # type: ignore[arg-type]
1145
+ blob_out = _DATA_BLOB()
1146
+ ok = ctypes.windll.crypt32.CryptUnprotectData( # type: ignore[attr-defined]
1147
+ ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
1148
+ )
1149
+ if not ok:
1150
+ raise OSError(f"CryptUnprotectData returned {ok}")
1151
+ try:
1152
+ size = blob_out.cbData
1153
+ return ctypes.string_at(blob_out.pbData, size)
1154
+ finally:
1155
+ ctypes.windll.kernel32.LocalFree(blob_out.pbData) # type: ignore[attr-defined]
1156
+
1157
+
1083
1158
  def _read_existing(path: Path) -> dict[str, Any]:
1084
- """Read existing ``auth.json``; return empty dict if missing or corrupt."""
1159
+ """Read existing ``auth.json``; return empty dict if missing or corrupt.
1160
+
1161
+ On Windows an encrypted envelope ``{"dpapi_v1": "<base64 ciphertext>"}``
1162
+ is transparently decrypted. A legacy plaintext file (no ``dpapi_v1`` key)
1163
+ is returned as-is — it migrates to the encrypted form on the next
1164
+ :func:`_atomic_write_json_plaintext` write. Decrypt failure (wrong user /
1165
+ corrupt) is treated like any corrupt file: unlink + return ``{}``.
1166
+ """
1085
1167
 
1086
1168
  if not path.is_file():
1087
1169
  return {}
1088
1170
  try:
1089
- return json.loads(path.read_text(encoding="utf-8"))
1171
+ parsed = json.loads(path.read_text(encoding="utf-8"))
1090
1172
  except (OSError, json.JSONDecodeError, UnicodeDecodeError):
1091
1173
  # Corrupt file — wrong encoding / truncated / half-written. Drop it
1092
1174
  # rather than crash the login flow. Caller treats {} as
@@ -1095,6 +1177,17 @@ def _read_existing(path: Path) -> dict[str, Any]:
1095
1177
  path.unlink()
1096
1178
  return {}
1097
1179
 
1180
+ if _dpapi_enabled() and isinstance(parsed, dict) and "dpapi_v1" in parsed:
1181
+ try:
1182
+ ciphertext = b64decode(parsed["dpapi_v1"])
1183
+ plaintext = _dpapi_unprotect(ciphertext)
1184
+ return json.loads(plaintext.decode("utf-8"))
1185
+ except Exception: # noqa: BLE001 — wrong user / corrupt ciphertext
1186
+ with contextlib.suppress(OSError):
1187
+ path.unlink()
1188
+ return {}
1189
+ return parsed
1190
+
1098
1191
 
1099
1192
  def _atomic_write_json_plaintext(path: Path, data: dict[str, Any]) -> None:
1100
1193
  """Write ``data`` as JSON to ``path`` atomically with 0600 perms.
@@ -1103,10 +1196,24 @@ def _atomic_write_json_plaintext(path: Path, data: dict[str, Any]) -> None:
1103
1196
  is unreachable. Production callers go through
1104
1197
  :func:`save_auth` / :func:`save_msal_auth` / :func:`update_auth_from_response_headers`,
1105
1198
  not this helper directly.
1199
+
1200
+ On Windows the plaintext payload is DPAPI-encrypted (bound to the current
1201
+ Windows user) before being written, so a default install without the
1202
+ ``[os-keyring-windows]`` extra still never lands an unencrypted token on
1203
+ disk — NTFS ignores ``0600``, so the app-layer encryption is the real
1204
+ protection. macOS / Linux write plaintext JSON unchanged (0600 is real
1205
+ there). See :func:`_dpapi_enabled`.
1106
1206
  """
1107
1207
 
1108
1208
  tmp = path.with_suffix(path.suffix + ".tmp")
1109
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
1209
+ if _dpapi_enabled():
1210
+ blob = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
1211
+ ciphertext = _dpapi_protect(blob)
1212
+ payload = {"dpapi_v1": b64encode(ciphertext).decode("ascii")}
1213
+ body = json.dumps(payload, indent=2)
1214
+ else:
1215
+ body = json.dumps(data, ensure_ascii=False, indent=2)
1216
+ tmp.write_text(body, encoding="utf-8")
1110
1217
  with contextlib.suppress(OSError):
1111
1218
  os.chmod(tmp, 0o600)
1112
1219
  tmp.replace(path)
@@ -1383,7 +1490,6 @@ def auth_logout_cmd() -> None:
1383
1490
  )
1384
1491
  )
1385
1492
 
1386
-
1387
1493
  def run_login(manual: bool) -> None:
1388
1494
  """Public entry for the top-level ``oed login`` shortcut.
1389
1495
 
@@ -1403,7 +1509,6 @@ def _run_login(manual: bool) -> None:
1403
1509
  """
1404
1510
 
1405
1511
  from .errors import UserError
1406
-
1407
1512
  if manual:
1408
1513
  try:
1409
1514
  creds = manual_login()
@@ -1417,9 +1522,6 @@ def _run_login(manual: bool) -> None:
1417
1522
  )
1418
1523
  raise SystemExit(1)
1419
1524
  token = creds.get("token")
1420
- cookie = creds.get("cookie")
1421
- refresh_token = None
1422
- id_token = None
1423
1525
  else:
1424
1526
  # device_login() reads get_scopes() internally; pass nothing and let
1425
1527
  # it fall back to _BASE_SCOPES. Passing scope=[] would override the
@@ -1437,9 +1539,6 @@ def _run_login(manual: bool) -> None:
1437
1539
  )
1438
1540
  raise SystemExit(1)
1439
1541
  token = result.get("access_token")
1440
- cookie = None
1441
- refresh_token = result.get("refresh_token")
1442
- id_token = result.get("id_token")
1443
1542
 
1444
1543
  if not token:
1445
1544
  click.echo(
@@ -1448,25 +1547,7 @@ def _run_login(manual: bool) -> None:
1448
1547
  )
1449
1548
  raise SystemExit(1)
1450
1549
 
1451
- # Both branches already persisted to auth.json; this is informational.
1452
- path = auth_token_path()
1453
-
1454
- click.echo(
1455
- json.dumps(
1456
- {
1457
- "ok": True,
1458
- "stage": "saved",
1459
- "path": str(path),
1460
- "has_cookie": bool(cookie),
1461
- "has_refresh_token": bool(refresh_token),
1462
- "has_id_token": bool(id_token),
1463
- "token_fingerprint": _token_fingerprint(token),
1464
- },
1465
- ensure_ascii=False,
1466
- indent=2,
1467
- )
1468
- )
1469
-
1550
+ click.echo(click.style("Login succeeded.", fg="green", bold=True))
1470
1551
 
1471
1552
  @auth_group.command("login")
1472
1553
  @click.option(
@@ -1593,10 +1674,11 @@ def token_backend(service: str = "ag") -> str:
1593
1674
  """The storage backend actually used for ``service``'s last write/read.
1594
1675
 
1595
1676
  Reflects the cached verdict from the most recent :meth:`_SecureStore.save`
1596
- / :meth:`_SecureStore.load` (``"keyring"`` / ``"plaintext"`` / ``"unprobed"``)
1597
- without re-probing. Use it right after :func:`store_token` to tell the user
1598
- *where* their token landed — the OS credential manager (no on-disk file) or
1599
- the 0600 plaintext fallback (the file at :func:`token_path`).
1677
+ / :meth:`_SecureStore.load` (``"keyring"`` / ``"plaintext-dpapi"`` /
1678
+ ``"plaintext"`` / ``"unprobed"``) without re-probing. Use it right after
1679
+ :func:`store_token` to tell the user *where* their token landed — the OS
1680
+ credential manager (no on-disk file), the DPAPI-encrypted fallback
1681
+ (Windows), or the 0600 plaintext fallback (the file at :func:`token_path`).
1600
1682
  """
1601
1683
 
1602
1684
  return _ag_store(service).backend_label()
@@ -1605,9 +1687,9 @@ def token_backend(service: str = "ag") -> str:
1605
1687
  def token_info(service: str = "ag") -> dict[str, Any] | None:
1606
1688
  """Return ``{encryption, created_at}`` metadata for ``service``'s token.
1607
1689
 
1608
- ``encryption`` reports the backend actually used (``"keyring"`` or
1609
- ``"plaintext"``), not a hand-rolled cipher marker. Returns ``None`` when
1610
- no token is stored.
1690
+ ``encryption`` reports the backend actually used (``"keyring"``,
1691
+ ``"plaintext-dpapi"`` on Windows, or ``"plaintext"`` on macOS/Linux), not
1692
+ a hand-rolled cipher marker. Returns ``None`` when no token is stored.
1611
1693
  """
1612
1694
 
1613
1695
  store = _ag_store(service)
@@ -137,6 +137,10 @@ def cli(ctx: click.Context, no_color: bool) -> None:
137
137
  """oed — openEuler Infra command line. Auto-discovered, AI-friendly."""
138
138
  ctx.ensure_object(dict)
139
139
  ctx.obj["no_color"] = no_color
140
+ # Bare `oed` (no subcommand) renders the same help as `oed --help`
141
+ # (including the auto-discovered services section from OedCli.get_help).
142
+ if ctx.invoked_subcommand is None:
143
+ click.echo(ctx.get_help())
140
144
 
141
145
 
142
146
  # Register reserved sub-groups from sibling modules.