oed-cli 0.1.3__tar.gz → 0.1.4__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 (26) hide show
  1. {oed_cli-0.1.3/src/oed_cli.egg-info → oed_cli-0.1.4}/PKG-INFO +49 -4
  2. {oed_cli-0.1.3 → oed_cli-0.1.4}/README.md +48 -3
  3. {oed_cli-0.1.3 → oed_cli-0.1.4}/pyproject.toml +1 -1
  4. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/__init__.py +1 -1
  5. oed_cli-0.1.4/src/oed_cli/auth.py +176 -0
  6. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/invoke.py +35 -1
  7. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/main.py +233 -1
  8. {oed_cli-0.1.3 → oed_cli-0.1.4/src/oed_cli.egg-info}/PKG-INFO +49 -4
  9. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/SOURCES.txt +4 -1
  10. oed_cli-0.1.4/tests/test_auth.py +107 -0
  11. oed_cli-0.1.4/tests/test_invoke.py +200 -0
  12. {oed_cli-0.1.3 → oed_cli-0.1.4}/LICENSE +0 -0
  13. {oed_cli-0.1.3 → oed_cli-0.1.4}/setup.cfg +0 -0
  14. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/__main__.py +0 -0
  15. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/cli.py +0 -0
  16. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/discovery.py +0 -0
  17. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/dynamic.py +0 -0
  18. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/errors.py +0 -0
  19. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/http.py +0 -0
  20. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli/py.typed +0 -0
  21. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/dependency_links.txt +0 -0
  22. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/entry_points.txt +0 -0
  23. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/requires.txt +0 -0
  24. {oed_cli-0.1.3 → oed_cli-0.1.4}/src/oed_cli.egg-info/top_level.txt +0 -0
  25. {oed_cli-0.1.3 → oed_cli-0.1.4}/tests/test_cli.py +0 -0
  26. {oed_cli-0.1.3 → oed_cli-0.1.4}/tests/test_dynamic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -97,7 +97,7 @@ pip install -e .
97
97
  Verify:
98
98
 
99
99
  ```bash
100
- oed --version # → oed, version 0.1.3
100
+ oed --version # → oed, version 0.1.4
101
101
  ```
102
102
 
103
103
  ---
@@ -230,6 +230,49 @@ oed schema cve | jq '.paths | keys'
230
230
 
231
231
  ---
232
232
 
233
+ ## AtomGit (`ag`) authentication
234
+
235
+ AtomGit operations authenticate through the `access_token` query parameter
236
+ declared on their spec. Store a personal access token once, and every
237
+ `oed ag ...` call uses it automatically:
238
+
239
+ ```bash
240
+ # Interactive (prompts for the token, never echoes it back)
241
+ oed ag login
242
+
243
+ # Non-interactive — good for CI / scripts
244
+ oed ag login --token <pat>
245
+
246
+ # Skip validating the token against AtomGit before storing
247
+ oed ag login --token <pat> --no-verify
248
+
249
+ # Just report whether a token is configured (no network, no prompt)
250
+ oed ag login --status
251
+
252
+ # Forget the stored token
253
+ oed ag logout
254
+ ```
255
+
256
+ Token storage:
257
+
258
+ - Windows: encrypted at rest for the current user via DPAPI (no extra deps).
259
+ - Elsewhere: base64, which is documented obfuscation, **not** encryption.
260
+ - Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
261
+ touches credentials.
262
+
263
+ Auto-injection on real calls:
264
+
265
+ - If the operation declares `access_token` and you don't pass one, the stored
266
+ token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
267
+ works.
268
+ - An explicit `--access-token <pat>` always wins over the stored one.
269
+ - If the operation requires a token and none is available anywhere, you get a
270
+ clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
271
+ - `--dry-run` and request echo views mask the token as `<stored>` — the real
272
+ value only ever goes out on the wire.
273
+
274
+ ---
275
+
233
276
  ## Local development
234
277
 
235
278
  ### Clone and install (editable)
@@ -248,9 +291,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
248
291
  python -m pytest -q
249
292
  ```
250
293
 
251
- 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
294
+ 58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
252
295
  per-parameter flag coercion, the `API_`-prefix alias, the
253
- `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
296
+ `resolve_runtime_gateway` no-fallback semantics, every exit code path, and
297
+ v0.4's `ag` token store (DPAPI/base64) + auto-injection.
254
298
  They monkeypatch the discovery layer so no gateway access is needed.
255
299
 
256
300
  ### Smoke-test against the live gateway
@@ -328,6 +372,7 @@ to eyeball `oed services` output every week.
328
372
  | `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
329
373
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
330
374
  | `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
375
+ | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
331
376
  | A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
332
377
 
333
378
  ### Offline mode
@@ -63,7 +63,7 @@ pip install -e .
63
63
  Verify:
64
64
 
65
65
  ```bash
66
- oed --version # → oed, version 0.1.3
66
+ oed --version # → oed, version 0.1.4
67
67
  ```
68
68
 
69
69
  ---
@@ -196,6 +196,49 @@ oed schema cve | jq '.paths | keys'
196
196
 
197
197
  ---
198
198
 
199
+ ## AtomGit (`ag`) authentication
200
+
201
+ AtomGit operations authenticate through the `access_token` query parameter
202
+ declared on their spec. Store a personal access token once, and every
203
+ `oed ag ...` call uses it automatically:
204
+
205
+ ```bash
206
+ # Interactive (prompts for the token, never echoes it back)
207
+ oed ag login
208
+
209
+ # Non-interactive — good for CI / scripts
210
+ oed ag login --token <pat>
211
+
212
+ # Skip validating the token against AtomGit before storing
213
+ oed ag login --token <pat> --no-verify
214
+
215
+ # Just report whether a token is configured (no network, no prompt)
216
+ oed ag login --status
217
+
218
+ # Forget the stored token
219
+ oed ag logout
220
+ ```
221
+
222
+ Token storage:
223
+
224
+ - Windows: encrypted at rest for the current user via DPAPI (no extra deps).
225
+ - Elsewhere: base64, which is documented obfuscation, **not** encryption.
226
+ - Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
227
+ touches credentials.
228
+
229
+ Auto-injection on real calls:
230
+
231
+ - If the operation declares `access_token` and you don't pass one, the stored
232
+ token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
233
+ works.
234
+ - An explicit `--access-token <pat>` always wins over the stored one.
235
+ - If the operation requires a token and none is available anywhere, you get a
236
+ clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
237
+ - `--dry-run` and request echo views mask the token as `<stored>` — the real
238
+ value only ever goes out on the wire.
239
+
240
+ ---
241
+
199
242
  ## Local development
200
243
 
201
244
  ### Clone and install (editable)
@@ -214,9 +257,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
214
257
  python -m pytest -q
215
258
  ```
216
259
 
217
- 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
260
+ 58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
218
261
  per-parameter flag coercion, the `API_`-prefix alias, the
219
- `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
262
+ `resolve_runtime_gateway` no-fallback semantics, every exit code path, and
263
+ v0.4's `ag` token store (DPAPI/base64) + auto-injection.
220
264
  They monkeypatch the discovery layer so no gateway access is needed.
221
265
 
222
266
  ### Smoke-test against the live gateway
@@ -294,6 +338,7 @@ to eyeball `oed services` output every week.
294
338
  | `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
295
339
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
296
340
  | `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
341
+ | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
297
342
  | A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
298
343
 
299
344
  ### Offline mode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.1.3"
7
+ version = "0.1.4"
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.1.3"
5
+ __version__ = "0.1.4"
6
6
  __all__ = ["__version__"]
@@ -0,0 +1,176 @@
1
+ """Local secret storage for ``oed`` authentication tokens.
2
+
3
+ On Windows, tokens are encrypted at rest for the current Windows user via
4
+ DPAPI (``CryptProtectData`` / ``CryptUnprotectData`` through ctypes) — a real
5
+ encryption boundary with zero runtime dependencies. Non-Windows platforms fall
6
+ back to base64, which is documented obfuscation and NOT encryption.
7
+
8
+ The on-disk schema is versioned and keyed by ``service`` so a future general
9
+ ``oed login`` can share this store with the ``ag``-specific token here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import contextlib
16
+ import ctypes
17
+ import datetime as _dt
18
+ import json
19
+ import platform
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .discovery import _cache_dir
24
+ from .errors import UserError
25
+
26
+ _SCHEMA_VERSION = 1
27
+ _DPAPI_ENTROPY = b"oed-cli:ag:v1"
28
+ _CRYPTPROTECT_UI_FORBIDDEN = 0x1
29
+
30
+
31
+ class _DATA_BLOB(ctypes.Structure):
32
+ _fields_ = [("cbData", ctypes.c_ulong), ("pbData", ctypes.POINTER(ctypes.c_char))]
33
+
34
+
35
+ def _is_windows() -> bool:
36
+ return platform.system() == "Windows"
37
+
38
+
39
+ def token_path(service: str = "ag") -> Path:
40
+ """Return the on-disk path for ``service``'s encrypted token.
41
+
42
+ Lives under the shared cache dir (honors ``OED_CACHE_DIR``) in a ``tokens/``
43
+ subdirectory so ``oed cache clear`` (which only unlinks ``discovery.json``)
44
+ can never wipe credentials.
45
+ """
46
+
47
+ return _cache_dir() / "tokens" / f"{service}.json"
48
+
49
+
50
+ def _win32_crypt(data: bytes, *, protect: bool) -> bytes:
51
+ """Protect/unprotect ``data`` with DPAPI scoped to the current Windows user."""
52
+
53
+ windll = ctypes.windll
54
+ func = windll.crypt32.CryptProtectData if protect else windll.crypt32.CryptUnprotectData
55
+ func.argtypes = [
56
+ ctypes.POINTER(_DATA_BLOB), # pDataIn
57
+ ctypes.c_wchar_p, # szDataDescr
58
+ ctypes.POINTER(_DATA_BLOB), # pOptionalEntropy
59
+ ctypes.c_void_p, # pvReserved
60
+ ctypes.c_void_p, # pPromptStruct
61
+ ctypes.c_ulong, # dwFlags
62
+ ctypes.POINTER(_DATA_BLOB), # pDataOut
63
+ ]
64
+ func.restype = ctypes.c_int
65
+
66
+ # keep the backing buffers referenced for the duration of the call
67
+ inp = ctypes.create_string_buffer(data, len(data))
68
+ in_blob = _DATA_BLOB(len(data), ctypes.cast(inp, ctypes.POINTER(ctypes.c_char)))
69
+ ent = ctypes.create_string_buffer(_DPAPI_ENTROPY, len(_DPAPI_ENTROPY))
70
+ ent_blob = _DATA_BLOB(len(_DPAPI_ENTROPY), ctypes.cast(ent, ctypes.POINTER(ctypes.c_char)))
71
+ out_blob = _DATA_BLOB()
72
+
73
+ ok = func(
74
+ ctypes.byref(in_blob),
75
+ None,
76
+ ctypes.byref(ent_blob),
77
+ None,
78
+ None,
79
+ _CRYPTPROTECT_UI_FORBIDDEN,
80
+ ctypes.byref(out_blob),
81
+ )
82
+ if not ok:
83
+ side = "protect" if protect else "unprotect"
84
+ raise UserError(
85
+ f"Windows DPAPI could not {side} the token",
86
+ kind="dpapi_failed",
87
+ hint="Retry, or file an issue if it persists.",
88
+ )
89
+ try:
90
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
91
+ finally:
92
+ windll.kernel32.LocalFree(out_blob.pbData)
93
+
94
+
95
+ def _encrypt(plain: str) -> tuple[str, str]:
96
+ """Return ``(base64 blob, schema encryption marker)`` for ``plain``."""
97
+
98
+ raw = plain.encode("utf-8")
99
+ if _is_windows():
100
+ return base64.b64encode(_win32_crypt(raw, protect=True)).decode("ascii"), "dpapi"
101
+ return base64.b64encode(raw).decode("ascii"), "base64"
102
+
103
+
104
+ def store_token(token: str, *, service: str = "ag") -> Path:
105
+ """Encrypt ``token`` and write it to disk for ``service``.
106
+
107
+ Never stores plaintext; raises :class:`UserError` if the write fails.
108
+ """
109
+
110
+ blob, encryption = _encrypt(token)
111
+ payload: dict[str, Any] = {
112
+ "version": _SCHEMA_VERSION,
113
+ "service": service,
114
+ "encryption": encryption,
115
+ "secret": blob,
116
+ "created_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
117
+ }
118
+ path = token_path(service)
119
+ try:
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
122
+ except OSError as exc:
123
+ raise UserError(
124
+ f"could not write token store {path}: {exc}",
125
+ kind="token_store_write_failed",
126
+ hint="Check the directory is writable, or set OED_CACHE_DIR.",
127
+ ) from exc
128
+ return path
129
+
130
+
131
+ def read_token(service: str = "ag") -> str | None:
132
+ """Return the decrypted token for ``service``, or ``None``.
133
+
134
+ Any read failure (missing file, corrupt JSON, undecryptable blob) degrades
135
+ silently to ``None`` so callers fall through to the missing-token path.
136
+ """
137
+
138
+ try:
139
+ raw = json.loads(token_path(service).read_text(encoding="utf-8"))
140
+ if raw.get("version") != _SCHEMA_VERSION or raw.get("service") != service:
141
+ return None
142
+ secret = base64.b64decode(raw["secret"])
143
+ if raw.get("encryption") == "dpapi" and _is_windows():
144
+ return _win32_crypt(secret, protect=False).decode("utf-8")
145
+ if raw.get("encryption") == "base64":
146
+ return secret.decode("utf-8")
147
+ return None
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def token_info(service: str = "ag") -> dict[str, Any] | None:
153
+ """Return ``{encryption, created_at}`` metadata for the stored token, or ``None``."""
154
+
155
+ try:
156
+ raw = json.loads(token_path(service).read_text(encoding="utf-8"))
157
+ return {
158
+ "encryption": raw.get("encryption"),
159
+ "created_at": raw.get("created_at"),
160
+ }
161
+ except Exception:
162
+ return None
163
+
164
+
165
+ def clear_token(service: str = "ag") -> bool:
166
+ """Delete ``service``'s stored token; return whether one existed."""
167
+
168
+ path = token_path(service)
169
+ if not path.is_file():
170
+ return False
171
+ with contextlib.suppress(OSError):
172
+ path.unlink()
173
+ return True
174
+
175
+
176
+ __all__ = ["clear_token", "read_token", "store_token", "token_info", "token_path"]
@@ -24,6 +24,7 @@ from typing import Any
24
24
  import httpx
25
25
 
26
26
  from . import http as http_mod
27
+ from .auth import read_token
27
28
  from .dynamic import Operation, coerce_param_types, to_flag
28
29
  from .errors import NetworkError, UserError
29
30
  from .http import _is_waf_block, _resolve_user_agent
@@ -126,6 +127,33 @@ def _render_response(resp: httpx.Response) -> Any:
126
127
  }
127
128
 
128
129
 
130
+ def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
131
+ """Auto-fill the ``access_token`` query param for AtomGit operations.
132
+
133
+ ``ag`` authenticates every request through the ``access_token`` query
134
+ parameter declared on its spec (用户授权码). When the caller didn't
135
+ pass one explicitly, fall back to the locally stored token from
136
+ ``oed ag login``. A required token that isn't available anywhere
137
+ raises a hint instead of failing inside the gateway; optional ones
138
+ simply go without.
139
+ """
140
+
141
+ declared = next(
142
+ (p for p in op.query_params if p.get("name") == "access_token"), None
143
+ )
144
+ if op.service_name != "ag" or declared is None or "access_token" in query_params:
145
+ return
146
+ token = read_token("ag")
147
+ if token:
148
+ query_params["access_token"] = token
149
+ elif declared.get("required"):
150
+ raise UserError(
151
+ "this AtomGit operation requires an access_token",
152
+ kind="ag_token_missing",
153
+ hint="Store one with `oed ag login`, or pass `--access-token <pat>`.",
154
+ )
155
+
156
+
129
157
  def call_operation(
130
158
  op: Operation,
131
159
  *,
@@ -158,6 +186,7 @@ def call_operation(
158
186
  )
159
187
 
160
188
  query_params = {k: v for k, v in query_params.items() if v is not None}
189
+ _inject_ag_token(op, query_params)
161
190
  url = f"{op.base_url}{filled_path}"
162
191
 
163
192
  # Discourse (forum) authenticates via Api-Key/Api-Username; inert sentinel
@@ -169,10 +198,15 @@ def call_operation(
169
198
  request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
170
199
  if body is not None:
171
200
  request_headers["Content-Type"] = "application/json"
201
+ # Mask credentials in any echoed request view (dry-run / include_request):
202
+ # the real token still goes out on the wire, it just never round-trips
203
+ # back to the terminal.
172
204
  request_view: dict[str, Any] = {
173
205
  "method": op.backend.method,
174
206
  "url": url,
175
- "query": query_params,
207
+ "query": {
208
+ k: ("<stored>" if k == "access_token" else v) for k, v in query_params.items()
209
+ },
176
210
  "headers": {**request_headers, **api_headers},
177
211
  "body": body,
178
212
  }
@@ -34,6 +34,7 @@ from collections.abc import Sequence
34
34
 
35
35
  import click
36
36
 
37
+ from .auth import clear_token, read_token, store_token, token_info, token_path
37
38
  from .cli import cli as click_cli
38
39
  from .dynamic import (
39
40
  coerce_flag_value,
@@ -47,7 +48,7 @@ from .dynamic import (
47
48
  resolve_runtime_gateway,
48
49
  resolve_service_by_name,
49
50
  )
50
- from .errors import OedError
51
+ from .errors import NotFoundError, OedError
51
52
  from .invoke import (
52
53
  call_operation,
53
54
  describe_operation_help,
@@ -200,6 +201,217 @@ def _merge_params(
200
201
  return params, body, 0
201
202
 
202
203
 
204
+ _AG_LOGIN_INSTRUCTIONS = (
205
+ "AtomGit login\n"
206
+ "=============\n"
207
+ "1. Open https://atomgit.com and sign in.\n"
208
+ "2. Create a personal access token under Settings https://atomgit.com/setting > Access Tokens https://atomgit.com/setting/token-classic/create "
209
+ "3. Paste the token below. It is stored encrypted for this Windows/Linux user "
210
+ "and is never echoed back.\n"
211
+ )
212
+
213
+
214
+ def _ag_login_help() -> int:
215
+ click.echo(
216
+ json.dumps(
217
+ {
218
+ "ok": True,
219
+ "help_for": "ag login",
220
+ "usage": "oed ag login [--token <pat>] [--no-verify] [--status]",
221
+ "flags": {
222
+ "--token": "provide the AtomGit personal access token non-interactively",
223
+ "--no-verify": "skip validating the token against AtomGit before storing",
224
+ "--status": "report whether a token is configured, without prompting",
225
+ },
226
+ "examples": [
227
+ "oed ag login",
228
+ "oed ag login --token <pat> --no-verify",
229
+ "oed ag login --status",
230
+ "oed ag logout",
231
+ ],
232
+ },
233
+ ensure_ascii=False,
234
+ indent=2,
235
+ )
236
+ )
237
+ return 0
238
+
239
+
240
+ def _ag_login(raw_flags: dict[str, str], bool_flags: set[str], help_requested: bool) -> int:
241
+ """Configure the AtomGit access token used by ``oed ag`` requests."""
242
+
243
+ if help_requested:
244
+ return _ag_login_help()
245
+
246
+ if "status" in bool_flags:
247
+ return _ag_status()
248
+
249
+ token = raw_flags.pop("token", None)
250
+ if token == "":
251
+ click.echo(
252
+ json.dumps(
253
+ {
254
+ "ok": False,
255
+ "code": 1,
256
+ "error": "missing_flag_value",
257
+ "message": "--token requires a non-empty value",
258
+ },
259
+ ensure_ascii=False,
260
+ ),
261
+ err=True,
262
+ )
263
+ return 1
264
+
265
+ if token is None:
266
+ click.echo(_AG_LOGIN_INSTRUCTIONS, err=True)
267
+ try:
268
+ token = click.prompt("AtomGit personal access token", hide_input=True, err=True)
269
+ except click.Abort:
270
+ click.echo(
271
+ json.dumps(
272
+ {"ok": False, "code": 1, "error": "aborted", "message": "login cancelled"},
273
+ ensure_ascii=False,
274
+ ),
275
+ err=True,
276
+ )
277
+ return 1
278
+
279
+ token = token.strip()
280
+ if not token:
281
+ click.echo(
282
+ json.dumps(
283
+ {
284
+ "ok": False,
285
+ "code": 1,
286
+ "error": "missing_token",
287
+ "message": "no token provided",
288
+ },
289
+ ensure_ascii=False,
290
+ ),
291
+ err=True,
292
+ )
293
+ return 1
294
+
295
+ if "no-verify" not in bool_flags:
296
+ verify_code = _verify_ag_token(token)
297
+ if verify_code != 0:
298
+ return verify_code
299
+
300
+ path = store_token(token)
301
+ click.echo(
302
+ json.dumps(
303
+ {
304
+ "ok": True,
305
+ "service": "ag",
306
+ "configured": True,
307
+ "token_path": str(path),
308
+ "note": "token stored locally; it is never echoed back",
309
+ },
310
+ ensure_ascii=False,
311
+ indent=2,
312
+ )
313
+ )
314
+ return 0
315
+
316
+
317
+ def _verify_ag_token(token: str) -> int:
318
+ """Best-effort validation: hit an authenticated ``ag`` endpoint with ``token``.
319
+
320
+ Returns ``0`` when the token looks valid (or validation is unavailable for
321
+ the current spec); a nonzero exit code when the token was rejected or the
322
+ check itself failed.
323
+ """
324
+
325
+ try:
326
+ service = resolve_service_by_name("ag")
327
+ spec = fetch_service_spec(service)
328
+ base_url = resolve_runtime_gateway(service)
329
+ table = operations_table(spec, service.service_name, base_url=base_url)
330
+ op = resolve_operation(table, "listAuthenticatedUserIssues")
331
+ except NotFoundError:
332
+ return 0
333
+ except OedError as exc:
334
+ click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
335
+ return exc.code
336
+
337
+ try:
338
+ result = call_operation(op, params={"access_token": token}, include_request=False)
339
+ except OedError as exc:
340
+ click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
341
+ return exc.code
342
+
343
+ if not result.get("ok"):
344
+ click.echo(
345
+ json.dumps(
346
+ {
347
+ "ok": False,
348
+ "code": 1,
349
+ "error": "invalid_token",
350
+ "message": "AtomGit rejected the token",
351
+ "hint": (
352
+ "Check the token is active, or create a new one at "
353
+ "AtomGit > Settings > Access Tokens, then run `oed ag login` again."
354
+ ),
355
+ },
356
+ ensure_ascii=False,
357
+ ),
358
+ err=True,
359
+ )
360
+ return 1
361
+ return 0
362
+
363
+
364
+ def _ag_status() -> int:
365
+ """Report whether a token is configured, without prompting or network access."""
366
+
367
+ configured = read_token("ag") is not None
368
+ meta = token_info("ag") or {}
369
+ click.echo(
370
+ json.dumps(
371
+ {
372
+ "ok": True,
373
+ "service": "ag",
374
+ "configured": configured,
375
+ "token_path": str(token_path("ag")),
376
+ "encryption": meta.get("encryption"),
377
+ "created_at": meta.get("created_at"),
378
+ "token": None,
379
+ },
380
+ ensure_ascii=False,
381
+ indent=2,
382
+ )
383
+ )
384
+ return 0
385
+
386
+
387
+ def _ag_logout(help_requested: bool) -> int:
388
+ """Delete the stored ``ag`` token."""
389
+
390
+ if help_requested:
391
+ click.echo(
392
+ json.dumps(
393
+ {
394
+ "ok": True,
395
+ "help_for": "ag logout",
396
+ "usage": "oed ag logout",
397
+ "examples": ["oed ag logout"],
398
+ },
399
+ ensure_ascii=False,
400
+ indent=2,
401
+ )
402
+ )
403
+ return 0
404
+ was_set = clear_token("ag")
405
+ click.echo(
406
+ json.dumps(
407
+ {"ok": True, "service": "ag", "configured": False, "was_set": was_set},
408
+ ensure_ascii=False,
409
+ indent=2,
410
+ )
411
+ )
412
+ return 0
413
+
414
+
203
415
  def _dispatch_dynamic(argv: Sequence[str]) -> int:
204
416
  """Parse ``oed <service> [<method>] [flags]`` and run the resolved call."""
205
417
 
@@ -219,6 +431,26 @@ def _dispatch_dynamic(argv: Sequence[str]) -> int:
219
431
  user_agent = raw_flags.pop("user-agent", None)
220
432
  method = positional[0] if positional else None
221
433
 
434
+ if service_name == "ag" and method in ("login", "logout"):
435
+ if len(positional) > 1:
436
+ click.echo(
437
+ json.dumps(
438
+ {
439
+ "ok": False,
440
+ "code": 1,
441
+ "error": "too_many_positional",
442
+ "message": "`oed ag login` takes flags, not extra positionals",
443
+ "extra_args": positional[1:],
444
+ },
445
+ ensure_ascii=False,
446
+ ),
447
+ err=True,
448
+ )
449
+ return 1
450
+ if method == "login":
451
+ return _ag_login(raw_flags, bool_flags, help_requested)
452
+ return _ag_logout(help_requested)
453
+
222
454
  try:
223
455
  service = resolve_service_by_name(service_name)
224
456
  except OedError as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -97,7 +97,7 @@ pip install -e .
97
97
  Verify:
98
98
 
99
99
  ```bash
100
- oed --version # → oed, version 0.1.3
100
+ oed --version # → oed, version 0.1.4
101
101
  ```
102
102
 
103
103
  ---
@@ -230,6 +230,49 @@ oed schema cve | jq '.paths | keys'
230
230
 
231
231
  ---
232
232
 
233
+ ## AtomGit (`ag`) authentication
234
+
235
+ AtomGit operations authenticate through the `access_token` query parameter
236
+ declared on their spec. Store a personal access token once, and every
237
+ `oed ag ...` call uses it automatically:
238
+
239
+ ```bash
240
+ # Interactive (prompts for the token, never echoes it back)
241
+ oed ag login
242
+
243
+ # Non-interactive — good for CI / scripts
244
+ oed ag login --token <pat>
245
+
246
+ # Skip validating the token against AtomGit before storing
247
+ oed ag login --token <pat> --no-verify
248
+
249
+ # Just report whether a token is configured (no network, no prompt)
250
+ oed ag login --status
251
+
252
+ # Forget the stored token
253
+ oed ag logout
254
+ ```
255
+
256
+ Token storage:
257
+
258
+ - Windows: encrypted at rest for the current user via DPAPI (no extra deps).
259
+ - Elsewhere: base64, which is documented obfuscation, **not** encryption.
260
+ - Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
261
+ touches credentials.
262
+
263
+ Auto-injection on real calls:
264
+
265
+ - If the operation declares `access_token` and you don't pass one, the stored
266
+ token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
267
+ works.
268
+ - An explicit `--access-token <pat>` always wins over the stored one.
269
+ - If the operation requires a token and none is available anywhere, you get a
270
+ clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
271
+ - `--dry-run` and request echo views mask the token as `<stored>` — the real
272
+ value only ever goes out on the wire.
273
+
274
+ ---
275
+
233
276
  ## Local development
234
277
 
235
278
  ### Clone and install (editable)
@@ -248,9 +291,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
248
291
  python -m pytest -q
249
292
  ```
250
293
 
251
- 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
294
+ 58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
252
295
  per-parameter flag coercion, the `API_`-prefix alias, the
253
- `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
296
+ `resolve_runtime_gateway` no-fallback semantics, every exit code path, and
297
+ v0.4's `ag` token store (DPAPI/base64) + auto-injection.
254
298
  They monkeypatch the discovery layer so no gateway access is needed.
255
299
 
256
300
  ### Smoke-test against the live gateway
@@ -328,6 +372,7 @@ to eyeball `oed services` output every week.
328
372
  | `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
329
373
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
330
374
  | `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
375
+ | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
331
376
  | A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
332
377
 
333
378
  ### Offline mode
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/oed_cli/__init__.py
5
5
  src/oed_cli/__main__.py
6
+ src/oed_cli/auth.py
6
7
  src/oed_cli/cli.py
7
8
  src/oed_cli/discovery.py
8
9
  src/oed_cli/dynamic.py
@@ -17,5 +18,7 @@ src/oed_cli.egg-info/dependency_links.txt
17
18
  src/oed_cli.egg-info/entry_points.txt
18
19
  src/oed_cli.egg-info/requires.txt
19
20
  src/oed_cli.egg-info/top_level.txt
21
+ tests/test_auth.py
20
22
  tests/test_cli.py
21
- tests/test_dynamic.py
23
+ tests/test_dynamic.py
24
+ tests/test_invoke.py
@@ -0,0 +1,107 @@
1
+ """Tests for oed_cli.auth — encrypted local token storage (DPAPI / base64)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ SRC = ROOT / "src"
13
+ if str(SRC) not in sys.path:
14
+ sys.path.insert(0, str(SRC))
15
+
16
+ from oed_cli.errors import UserError # noqa: E402
17
+
18
+
19
+ @pytest.fixture()
20
+ def isolated(monkeypatch, tmp_path):
21
+ """Point the token store at a throwaway dir and return the auth module."""
22
+
23
+ monkeypatch.setenv("OED_CACHE_DIR", str(tmp_path))
24
+ from oed_cli import auth
25
+
26
+ return auth
27
+
28
+
29
+ def test_dpapi_roundtrip_on_windows(isolated):
30
+ if not isolated._is_windows():
31
+ pytest.skip("DPAPI is Windows-only")
32
+
33
+ isolated.store_token("tok-abc", service="ag")
34
+ assert isolated.read_token("ag") == "tok-abc"
35
+
36
+
37
+ def test_base64_fallback_roundtrip(isolated, monkeypatch):
38
+ monkeypatch.setattr(isolated, "_is_windows", lambda: False)
39
+
40
+ path = isolated.store_token("tok-secret", service="ag")
41
+ assert isolated.read_token("ag") == "tok-secret"
42
+ raw = json.loads(path.read_text(encoding="utf-8"))
43
+ assert raw["encryption"] == "base64"
44
+
45
+
46
+ def test_no_plaintext_secret_on_disk(isolated):
47
+ isolated.store_token("supersecretvalue", service="ag")
48
+ text = isolated.token_path("ag").read_text(encoding="utf-8")
49
+ assert "supersecretvalue" not in text
50
+
51
+
52
+ def test_corrupt_file_reads_none(isolated):
53
+ p = isolated.token_path("ag")
54
+ p.parent.mkdir(parents=True, exist_ok=True)
55
+ p.write_text("{not json", encoding="utf-8")
56
+ assert isolated.read_token("ag") is None
57
+
58
+
59
+ def test_missing_file_and_clear(isolated):
60
+ assert isolated.read_token("ag") is None
61
+ assert isolated.clear_token("ag") is False
62
+ isolated.store_token("t", service="ag")
63
+ assert isolated.clear_token("ag") is True
64
+ assert isolated.read_token("ag") is None
65
+
66
+
67
+ def test_schema_is_versioned_and_scoped(isolated):
68
+ p = isolated.store_token("t", service="ag")
69
+ raw = json.loads(p.read_text(encoding="utf-8"))
70
+ assert raw["version"] == 1
71
+ assert raw["service"] == "ag"
72
+ assert raw["encryption"] in {"dpapi", "base64"}
73
+
74
+
75
+ def test_storage_respects_cache_dir(isolated, tmp_path):
76
+ p = isolated.store_token("t", service="ag")
77
+ assert p == tmp_path / "tokens" / "ag.json"
78
+ assert p.is_file()
79
+
80
+
81
+ def test_store_is_service_keyed(isolated):
82
+ isolated.store_token("ag-tok", service="ag")
83
+ isolated.store_token("other-tok", service="other")
84
+ assert isolated.read_token("ag") == "ag-tok"
85
+ assert isolated.read_token("other") == "other-tok"
86
+ assert isolated.read_token("notstored") is None
87
+
88
+
89
+ def test_store_io_failure_raises(isolated):
90
+ block = isolated.token_path("ag")
91
+ block.parent.mkdir(parents=True, exist_ok=True)
92
+ block.mkdir() # directory where the file should go → write fails
93
+ with pytest.raises(UserError):
94
+ isolated.store_token("t", service="ag")
95
+
96
+
97
+ def test_dpapi_marker_unreadable_off_windows(isolated, monkeypatch):
98
+ p = isolated.token_path("ag")
99
+ p.parent.mkdir(parents=True, exist_ok=True)
100
+ p.write_text(
101
+ json.dumps(
102
+ {"version": 1, "service": "ag", "encryption": "dpapi", "secret": "x"}
103
+ ),
104
+ encoding="utf-8",
105
+ )
106
+ monkeypatch.setattr(isolated, "_is_windows", lambda: False)
107
+ assert isolated.read_token("ag") is None
@@ -0,0 +1,200 @@
1
+ """Tests for per-service auth injection in :func:`oed_cli.invoke.call_operation`.
2
+
3
+ AtomGit (``ag``) authenticates through the ``access_token`` query parameter
4
+ declared on its spec. These tests run offline: the stored token is monkeypatched
5
+ and HTTP is stubbed so we only assert on the request that *would* go out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ SRC = ROOT / "src"
17
+ if str(SRC) not in sys.path:
18
+ sys.path.insert(0, str(SRC))
19
+
20
+ AG_SPEC = {
21
+ "openapi": "3.0.3",
22
+ "info": {"title": "openeuler/ag", "version": "1.0.0"},
23
+ "paths": {
24
+ "/api/v5/user/issues": {
25
+ "get": {
26
+ "summary": "list the authenticated user's issues",
27
+ "operationId": "listAuthenticatedUserIssues",
28
+ "parameters": [
29
+ {
30
+ "name": "access_token",
31
+ "in": "query",
32
+ "required": True,
33
+ "schema": {"type": "string"},
34
+ },
35
+ {
36
+ "name": "filter",
37
+ "in": "query",
38
+ "required": False,
39
+ "schema": {"type": "string"},
40
+ },
41
+ ],
42
+ }
43
+ },
44
+ "/api/v5/repos/{owner}/{repo}/git/trees/{sha}": {
45
+ "get": {
46
+ "summary": "list repo tree",
47
+ "operationId": "getRepoTree",
48
+ "parameters": [
49
+ {"name": "owner", "in": "path", "required": True, "schema": {"type": "string"}},
50
+ {"name": "repo", "in": "path", "required": True, "schema": {"type": "string"}},
51
+ {"name": "sha", "in": "path", "required": True, "schema": {"type": "string"}},
52
+ {
53
+ "name": "access_token",
54
+ "in": "query",
55
+ "required": False,
56
+ "schema": {"type": "string"},
57
+ },
58
+ ],
59
+ }
60
+ },
61
+ "/public/health": {
62
+ "get": {
63
+ "summary": "unauthenticated health check",
64
+ "operationId": "getHealth",
65
+ "parameters": [],
66
+ }
67
+ },
68
+ },
69
+ }
70
+
71
+
72
+ @pytest.fixture()
73
+ def captured(monkeypatch):
74
+ """Stub HTTP + the stored token, capturing the outgoing request."""
75
+
76
+ from oed_cli import http as http_mod
77
+ from oed_cli import invoke as invoke_mod
78
+
79
+ box = {}
80
+
81
+ def _fake_do_call(
82
+ method, url, params=None, body=None, timeout=30.0, headers=None, user_agent=None
83
+ ):
84
+ class _R:
85
+ status_code = 200
86
+ content = b'{"ok":true}'
87
+ text = content.decode()
88
+ headers = {"content-type": "application/json"}
89
+
90
+ def json(self):
91
+ return {"ok": True}
92
+
93
+ box["method"] = method
94
+ box["url"] = url
95
+ box["params"] = params or {}
96
+ box["body"] = body
97
+ box["headers"] = headers or {}
98
+ return _R()
99
+
100
+ monkeypatch.setattr(http_mod, "get_request", _fake_do_call)
101
+ monkeypatch.setattr(http_mod, "_is_waf_block", lambda text: False, raising=False)
102
+
103
+ def _set_token(value: str | None):
104
+ monkeypatch.setattr(invoke_mod, "read_token", lambda service: value)
105
+
106
+ box["set_token"] = _set_token
107
+ box["invoke"] = invoke_mod
108
+ return box
109
+
110
+
111
+ def _op(name: str):
112
+ from oed_cli.dynamic import operations_table
113
+
114
+ return operations_table(AG_SPEC, "ag", base_url="https://apig.osinfra.cn")[name]
115
+
116
+
117
+ def test_ag_required_token_injected_from_store(captured):
118
+ from oed_cli.invoke import call_operation
119
+
120
+ captured["set_token"]("tok-abc")
121
+ op = _op("listAuthenticatedUserIssues")
122
+ call_operation(op, params={"filter": "all"})
123
+ assert captured["params"]["access_token"] == "tok-abc"
124
+
125
+
126
+ def test_ag_explicit_token_wins_over_store(captured):
127
+ from oed_cli.invoke import call_operation
128
+
129
+ captured["set_token"]("stored-token")
130
+ op = _op("listAuthenticatedUserIssues")
131
+ call_operation(op, params={"access_token": "explicit-token"})
132
+ assert captured["params"]["access_token"] == "explicit-token"
133
+
134
+
135
+ def test_ag_required_token_missing_raises_hint(captured):
136
+ from oed_cli.errors import UserError
137
+ from oed_cli.invoke import call_operation
138
+
139
+ captured["set_token"](None)
140
+ op = _op("listAuthenticatedUserIssues")
141
+ with pytest.raises(UserError) as exc:
142
+ call_operation(op)
143
+ assert exc.value.kind == "ag_token_missing"
144
+
145
+
146
+ def test_ag_optional_token_missing_goes_without(captured):
147
+ from oed_cli.invoke import call_operation
148
+
149
+ captured["set_token"](None)
150
+ op = _op("getRepoTree")
151
+ call_operation(op, params={"owner": "o", "repo": "r", "sha": "s"})
152
+ assert "access_token" not in captured["params"]
153
+
154
+
155
+ def test_ag_public_operation_gets_no_token(captured):
156
+ from oed_cli.invoke import call_operation
157
+
158
+ captured["set_token"]("tok-abc")
159
+ op = _op("getHealth")
160
+ call_operation(op)
161
+ assert "access_token" not in captured["params"]
162
+
163
+
164
+ def test_non_ag_service_gets_no_token(captured):
165
+ from oed_cli.dynamic import operations_table
166
+ from oed_cli.invoke import call_operation
167
+
168
+ spec = {
169
+ "openapi": "3.0.3",
170
+ "info": {"title": "x", "version": "1.0.0"},
171
+ "paths": {
172
+ "/v1/foo": {
173
+ "get": {
174
+ "operationId": "getFoo",
175
+ "parameters": [
176
+ {
177
+ "name": "access_token",
178
+ "in": "query",
179
+ "required": True,
180
+ "schema": {"type": "string"},
181
+ }
182
+ ],
183
+ }
184
+ }
185
+ },
186
+ }
187
+ captured["set_token"]("tok-abc")
188
+ op = operations_table(spec, "other", base_url="https://apig.osinfra.cn")["getFoo"]
189
+ call_operation(op)
190
+ assert "access_token" not in captured["params"]
191
+
192
+
193
+ def test_ag_dry_run_masks_stored_token(captured):
194
+ from oed_cli.invoke import call_operation
195
+
196
+ captured["set_token"]("super-secret-token")
197
+ op = _op("listAuthenticatedUserIssues")
198
+ dry = call_operation(op, dry_run=True)
199
+ assert dry["request"]["query"]["access_token"] == "<stored>"
200
+ assert "super-secret-token" not in str(dry)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes