oed-cli 0.1.2__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.
- {oed_cli-0.1.2/src/oed_cli.egg-info → oed_cli-0.1.4}/PKG-INFO +49 -4
- {oed_cli-0.1.2 → oed_cli-0.1.4}/README.md +48 -3
- {oed_cli-0.1.2 → oed_cli-0.1.4}/pyproject.toml +1 -1
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/__init__.py +6 -6
- oed_cli-0.1.4/src/oed_cli/auth.py +176 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/invoke.py +43 -2
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/main.py +233 -1
- {oed_cli-0.1.2 → oed_cli-0.1.4/src/oed_cli.egg-info}/PKG-INFO +49 -4
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/SOURCES.txt +4 -1
- oed_cli-0.1.4/tests/test_auth.py +107 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/tests/test_cli.py +1 -1
- {oed_cli-0.1.2 → oed_cli-0.1.4}/tests/test_dynamic.py +137 -15
- oed_cli-0.1.4/tests/test_invoke.py +200 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/LICENSE +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/setup.cfg +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/__main__.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/cli.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/discovery.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/dynamic.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/errors.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/http.py +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/py.typed +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/dependency_links.txt +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/entry_points.txt +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/requires.txt +0 -0
- {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""oed-cli — openEuler Infra command line, auto-discovered, AI-friendly."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
__version__ = "0.1.
|
|
6
|
-
__all__ = ["__version__"]
|
|
1
|
+
"""oed-cli — openEuler Infra command line, auto-discovered, AI-friendly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.4"
|
|
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,16 +186,28 @@ 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
|
|
|
192
|
+
# Discourse (forum) authenticates via Api-Key/Api-Username; inert sentinel
|
|
193
|
+
# until login lands, so the request shape stays visible without leaking a real credential.
|
|
194
|
+
api_headers: dict[str, str] = {}
|
|
195
|
+
if op.service_name == "forum":
|
|
196
|
+
api_headers = {"Api-Key": "oed-placeholder", "Api-Username": "oed-placeholder"}
|
|
197
|
+
|
|
163
198
|
request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
|
|
164
199
|
if body is not None:
|
|
165
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.
|
|
166
204
|
request_view: dict[str, Any] = {
|
|
167
205
|
"method": op.backend.method,
|
|
168
206
|
"url": url,
|
|
169
|
-
"query":
|
|
170
|
-
|
|
207
|
+
"query": {
|
|
208
|
+
k: ("<stored>" if k == "access_token" else v) for k, v in query_params.items()
|
|
209
|
+
},
|
|
210
|
+
"headers": {**request_headers, **api_headers},
|
|
171
211
|
"body": body,
|
|
172
212
|
}
|
|
173
213
|
|
|
@@ -191,6 +231,7 @@ def call_operation(
|
|
|
191
231
|
url,
|
|
192
232
|
params=query_params if query_params else None,
|
|
193
233
|
body=body,
|
|
234
|
+
headers=api_headers,
|
|
194
235
|
timeout=timeout,
|
|
195
236
|
user_agent=user_agent,
|
|
196
237
|
)
|