oed-cli 设计文档
openEuler development command line tool — AI-friendly、自动发现、pip 一键安装。 受
googleworkspace/cli (gws)启发,本文对照 gws 的设计哲学。 设计日期:2026-07-25
1. 目标 & 解决的问题
oed-cli 是面向 openEuler Infra 网关下一组在线服务的统一命令行入口。开发、CI、LLM Agent 不再需要手写 curl、--data、x-apigateway-* 字段,就能调用每个服务的 OpenAPI 端点。
核心理念与 gws 一致:
- 运行时不预置命令清单。
oed启动时(或首次需要时)拉取一次discovery/apis,按规格动态构建整张命令表。服务增删,网关规则变,CLI 自适应——下一版本无感。 - 为 LLM 而生。默认输出就是结构化 JSON、可
jq流式管道输出;附带 Claude / Cursor Skill,让 Agent 无需特殊 prompt 也能像人一样调用。 - 为人类可读。
--help全程有、shell 补全全平台(bash/zsh/fish/PowerShell)有、--dry-run永远可见地预览。
2. 对照表(oed vs gws)
| 维度 | gws | oed |
|---|---|---|
| 语言 | Rust(前端)+ Node 包 | Python 3.10+ |
| 安装 | npm / brew / 二进制 / cargo / nix | pip install oed-cli / pipx / uv |
| 二进制名 | gws |
oed |
| 数据源 | Google Discovery Service | https://api-gateway.osinfra.cn/discovery/apis |
| 资源定位 | <service> <resource> <method>(多段嵌套) |
<service> <method>(更扁平) |
| 鉴权 | OAuth + Service Account + 预签 token | MVP 阶段透传(网关侧策略由调用方注入) |
| 输出 | JSON | JSON(默认)+ --human 可选美化 |
| Agent Skill | .agent/ + .claude/ + .gemini/ |
.claude/skills/oed-cli/ |
3. 命令形态
3.1 顶层命令树
oed --help
oed --version
oed info # 网关连通性 + 当前社区/服务一览
oed services [--community X] [--refresh] # 当前社区下服务清单(JSON)
oed schema <service>[.<method>] # 打印 OpenAPI 3.0.3 规格
oed cache {show,clear,refresh} # 缓存管理
oed auth {status,token,logout,login,login --manual} # 鉴权管理 (v0.4)
oed login [--manual] # 顶层快捷方式 = oed auth login
oed <service> # 列服务的全部 operation
oed <service> <method> [--params JSON] [--json BODY] [--dry-run]
# 未来: --page-all / --human (v0.4, 未开始), shell completion (v1.0, 未开始)
<service> 命中已注册服务后,整条三段命令进入动态分发通道;保留命令与发现结果冲突时优先保留命令。
3.2 调用示例
# 看网关状态
oed info
# [stdout]: {"ok": true, "gateway": "https://api-gateway.osinfra.cn", "community": "openeuler",
# "services_total": 8, "cache": {"ttl_seconds": 600, "stale": false, ...}}
# 列出 openEuler 社区下所有服务
oed services | jq '[.[] | {name, title, version}]'
# 看 service 的全部 operation(动态;不依赖 click 注册子命令)
oed software-package-server
# 单接口的请求/响应 schema
oed schema software-package-server.listSoftwarePackages
# 真实调用:列出 phase=accepted 的前 5 条软件包
oed software-package-server listSoftwarePackages \
--phase accepted --page-num 1 --count-per-page 5
# 提交软件包申请
oed software-package-server applyNewSoftwarePackage \
--json '{"pkg_name":"demo","version":"1.0.0","community":"openEuler"}'
# 预览(不发请求,把构造的请求晒出来)
oed software-package-server listSoftwarePackages \
--phase accepted --dry-run
# 流式分页,NDJSON 输出(v0.4 计划中,未实现)
oed software-package-server listSoftwarePackages \
--count 100 --page-all | jq -c '.'
v0.3.0 已实现:
oed <service> [<method>] [--params ...] [--json ...] [--dry-run]全部可用;每个声明参数自动展开为--<kebab-case>强类型 flag(如cveId→--cve-id),operation 级--help,APIGAPI_前缀自动剥离并保留为别名。0.3.0 新增:
- oneid RFC 8628 device flow 登录(
oed login/oed auth login),无需本地 HTTP server,headless / SSH / CI 可用,客户端client_id经OED_APP_ID可覆盖;- OS-native credential storage(
_SecureStore):macOS Keychain / Windows DPAPI / Linux SecretService 可达时直接走 keystore,否则auth.json0600 fallback;keyring>=24由pip install oed-cli自动拉;- Per-user service allow-list:设备流登录后一次性拉
/oneid/oidc/device/user-data,存auth.json.allowlist,后续每次 dispatch 只查本地,缺/空/类型异常 fail-open,非空命中外kind="service_blocked_by_allowlist";- HMAC 签名头移除:CLI 二进制分发的对称密钥无法保密,
x-secret-tokenHMAC 改为明文x-oed-source: oed-cli+x-oed-target: <service>身份/路由头;gateway custom-auth 直接从 Bearer JWT 解 username + 角色,不再依赖 CLI 端加密;- spec fixture 对齐生产:
software-package-server→pkgcontrib(在线改名),operationId 不再带API_前缀,test_dynamic全面对齐;- omapi 走生产域:
omapi.osinfra.cn替换omapi.test.osinfra.cn(omapi 已上线生产)。
3.3 参数约定(已实现)
| Flag / 形式 | 含义 |
|---|---|
--<kebab-case> <value> |
每个声明的 query / path 参数自动展开成 --<kebab-case> 强类型 flag;原始 spec 名(--cveId)与 kebab 形式(--cve-id)都接受,整数 / 数字类型自动强转 |
--params '{...}' |
批量传 query/path 参数的 JSON 字典(不含 request body);与 per-param flag 共存时 flag 覆盖同名键;path 中的 {id} 自动从 params.id 提取。若调用没传 --json 而 --params 里的键命中 requestBody 字段名,报 body_fields_via_params + 提示改用 --json |
--json '{...}' |
request body(同时隐式声明 Content-Type: application/json)。只要 spec 声明了 requestBody(无论是否标记 required),schema 就会被内联解析后写到 oed <service> <op> --help 的 body_schema + body_required_fields 里,usage/examples 也会给出 --json 示例;Agent 可据此自动构造 body。body_required 仅反映 spec 的 requestBody.required 标记 |
--dry-run |
仅打印待执行的 HTTP 详情,不发请求 |
参数解析容错:JSON 解析失败时给出行号、可粘贴的修复建议。未知 --<flag> 与声明参数对齐失败时报 unknown_flag + 提示该 operation 声明了哪些参数。
计划中(未实现):--page-all NDJSON 流式分页、--human 美化输出(v0.4)、-H 自定义 header、-o/--output 写文件、--quiet 关闭 stderr(v0.4+)。
4. 架构
┌─────────────────────┐
│ oed (CLI) │ click-based entry, dispatch table
└─────────┬───────────┘
│
├──► discovery.py 拉取 /discovery/apis,缓存到 ~/.cache/oed-cli/(Windows: %LOCALAPPDATA%\oed-cli\cache\)
│
├──► http.py WAF-safe HTTP client(带伪装 UA/Referer)
│
├──► dynamic.py 把 OpenAPI paths → 调度表,per-param flag 推导
│
├──► invoke.py 实际调用:拼 URL → 发请求 → 封 JSON
│
├──► auth.py 本地 token 存储(`_SecureStore` keyring + oneid device flow)+ ag 凭证自动注入
│
├──► main.py 顶层 dispatcher(保留命令 vs 动态分发)
│
└──► errors.py 退出码(0/1/2/3/4)+ 友好异常
4.1 包结构(最终态)
oed-cli/
├── pyproject.toml # PEP 621,entry: oed = oed_cli.main:main
├── README.md
├── LICENSE
├── context/discoverAPI.md # 人类可读的发现服务使用说明(已存在)
├── docs/cli-design.md # 本文档
├── .claude/skills/oed-cli/ # Claude Code Skill 包
│ ├── SKILL.md
│ └── references/discovery-api.md # 引用 context/discoverAPI.md
├── src/
│ └── oed_cli/
│ ├── __init__.py
│ ├── __main__.py # 支持 python -m oed_cli
│ ├── py.typed # PEP 561 类型标记
│ ├── main.py # 顶层 dispatcher(v0.2+)
│ ├── cli.py # click 命令(保留命令 + --help 装饰)
│ ├── http.py # WAF-safe client
│ ├── discovery.py # 拉取 + 缓存
│ ├── dynamic.py # OpenAPI → 调度表 + per-param flag 推导(v0.2+)
│ ├── invoke.py # 实际调用 / 描述 service & operation(v0.2+)
│ ├── auth.py # 本地 token 存储(`_SecureStore` keyring)+ oneid device flow(v0.5+)+ ag 自动注入(v0.4+)
│ └── errors.py # 退出码 + OedError 体系
└── tests/
├── test_cli.py # v0.1 保留命令 + --help 装饰
├── test_discovery.py # discovery 缓存
├── test_dynamic.py # v0.2 调度 + per-param flag + API_ 前缀
├── test_invoke.py # ag access_token 注入 + body 可见性 + result_root 修正
└── test_auth.py # ag/oneid token 存储(`_SecureStore`)单元测试
最小脚手架阶段先交付:pyproject + init + main + cli + http + discovery + errors。main / dynamic / invoke 在 v0.2 PR 完成。
4.2 关键模块职责
http.py
- 强制带浏览器头:
User-Agent: oed/<version>、Accept: application/json, text/plain, */*、Accept-Language: zh-CN,zh;q=0.9,en;q=0.8。 - 不带默认
Referer:openEuler APIG 在easysearch等端点上对Referer: https://api-gateway.osinfra.cn/返回 HTTP 401(实测 2026-07-28);不带时/discovery/apis也仍能过 CloudWAF。调用方仍可通过headers={"Referer": ...}显式注入。 - 默认 30s 超时、自动遵循重定向(
follow_redirects=True)。 - 暴露
get_json()/request_json()与通用get_request(method, url, *, params, body, headers, timeout, user_agent, token, cookie, service_name)(全 keyword-only),后者允许任意动词 + body。token/cookie注入Authorization: Bearer <token>/Cookie: <cookie>副标头;token+service_name同时存在时额外发明文身份/路由头x-oed-source: oed-cli/x-oed-target: <service>(供 APIG custom-auth 做 per-service 头转发,非机密,见 §7「认证」行)。 _decode统一校验:5xx →UpstreamError;CloudWAF HTML 块 →NetworkError(kind="waf_block");空 body 在spec_endpoint=True(get_json)时 →NotFoundError(kind="spec_missing"),否则视为成功空 payload(null)。
discovery.py
fetch_discovery(*, community=None, force_refresh=False) -> DiscoveryFeed:先看本地缓存的 discovery 文件(TTL 10 分钟),过期再拉。缓存根目录:POSIX~/.cache/oed-cli/,Windows%LOCALAPPDATA%\oed-cli\cache\;OED_CACHE_DIR可整体覆盖。- 模型(dataclass):
ServiceMeta(name, service_name, community, title, version, description, base_url)(frozen)、DiscoveryFeed(fetched_at, raw, services)。spec 本身不建模,以原始 dict 传递。 - 凭证路径:
auth_token_path()→<OED_CACHE_DIR>/auth.json;ag_token_path(service)→<OED_CACHE_DIR>/tokens/<service>.json。oed cache clear只删 discovery 缓存文件,不会误清 auth.json / tokens/。 - 缓存防毒化(issue #22):
_materialize对__oed_fetched_at未来时间戳(超过now + _CLOCK_SKEW_SECONDS=300,容忍 NTP 漂移)直接丢弃重拉;写入用_atomic_write_json(tmp + replace),崩溃半写入不留 corrupt 文件。
dynamic.py (v0.2 新增)
- 把 OpenAPI 文档编成一张
OperationsTable:- 每个 (path, verb) 抽成
Operation(scheme, address, path, method, parameters, body_required, request_body, body_schema, operation_id, base_url)。body_schema是requestBody里application/json的 schema,对$ref: #/components/schemas/X做内联解析(带 cycle 防护);oed <service> <op> --help把字段名 / 类型 / 是否必填透传给 Agent,方便自动构造--jsonbody。 - 表的主键是
operationId;别名是"<VERB> <path>"。 - 如果
operationId带 APIG 自动加的API_前缀(目前只有software-package-server命中),还会注册一个去掉前缀的别名(API_xxx↔xxx),方便用户少敲四个字符。Operation.display_name是剥掉前缀后的形式,Operation.operation_id保留 spec 原文。 Operation.path来自 OpenAPIpaths块的 key;Operation.backend.{scheme, address, path, method}来自x-apigateway-backend.httpEndpoints。该块可选 —— 缺失(或type != "HTTP")时合成Backend(scheme="https", address="", path=<OpenAPI path>, method=<OpenAPI verb>),operation 照常进命令表。网关重发布的 spec(cve、pkgcontrib等)已不带这个块,按它过滤会让整个服务的命令消失。Operation.base_url是该服务解析后的运行时 base URL(在collect_operations时烘焙进去;invoke.py直接读op.base_url拼 URL)。
- 每个 (path, verb) 抽成
- 路径占位符自动替换 + 类型强转(如
page_num: integer接string自动int(...))。 to_flag(name)把 spec 里的参数名转成 kebab-case CLI flag(cveId→cve-id)。param_flag_index(op)把Operation.parameters编成{flag_stem: param_def},让每个声明参数都能直接当--<flag>用。resolve_runtime_gateway(service)决定每个服务的运行时 base URL:读ServiceMeta.base_url(来自 discovery),去掉尾部/、空白,原样返回。无 fallback —— 若 gateway 返回空或$APIG_GROUP_ENTRY_URL占位符,HTTP 调用会直接报错而不是被静默重写。Operation.base_url在collect_operations/operations_table时一次性烘焙进去,invoke.py直接读op.base_url拼 URL。- 单服务 spec 在
<OED_CACHE_DIR>/specs/<community>/<service>.json内 10 分钟缓存。
invoke.py (v0.2 新增,v0.4 扩展鉴权)
call_operation(op, *, params, body, dry_run, timeout, include_request, user_agent, token, cookie)(全 keyword-only):拼 URL →http.get_request→ 封装ok / status / response / requestJSON。- URL 规则:每个
Operation.base_url(在dynamic.py由resolve_runtime_gateway(service)一次性填好) +op.path。不信任x-apigateway-backend.httpEndpoints.address(spec 里常填测试域如cvesa.test.osinfra.cn,会被 CloudWAF 拦截)。该块只用于推断 method / scheme。拼好后_reject_insecure_base_url校验 scheme:非空且非https抛UserError(kind="insecure_base_url")(issue #22 缓存防毒化,hintoed cache clear)。 - 参数三分类与预检:
_select_params把params拆成 path / query / unused;缺 path 占位符抛kind="missing_path_param";--params命中 requestBody 字段且未传 body 抛kind="body_fields_via_params"(hint 指向--json)。 - forum 占位符鉴权:
GATEWAY_MANAGED_PARAMS = {"Api-Key","Api-Username"}。forum 调用时 oed 自动填oed-placeholder头(网关边缘 header 转换替换成真实凭证);调用方自行传这些键 →UserError(kind="gateway_managed_param")。--dry-run/ 回显视图可见占位符,这是 oed 的自动填充,不是缺口。 describe_service(service, ops):oed <service>单参时打印的清单,包含url(真实调用地址,已带服务自身的base_url)和backend_declared(spec 写的后端,仅供诊断)。describe_operation(op):oed <service>列表里单个 operation 的摘要(path / url / path_params / query_params / has_body / body_required_fields)。describe_operation_help(op, service):oed <service> <op> --help输出的 JSON,含每个参数的--<flag>形式 + 可粘贴的 usage 行。- ag 凭证自动注入:
ag(AtomGit)的每个请求通过 spec 声明的access_tokenquery 参数鉴权。call_operation在调用前调用_inject_ag_token:若操作声明了access_token且调用方没有显式传,就从本地存储(auth.read_token("ag"),由oed ag login写入)自动填充;声明为必填但没有任何 token 时抛kind="ag_token_missing"的UserError(hint 提示oed ag login或--access-token)。任何回显的request视图把access_token掩码成<stored>,真实 token 只出现在实际发出的请求里。 eulermaker getJobLog的result_root自动修正:jobs 搜索 API 返回的result_root是相对路径,spec 要求以/dmesg结尾。Agent 常传原始值(缺尾部/dmesg,且常带多余前导/)。call_operation通过_normalize_result_root只对这个已知 path 参数做修正(去前导/、补/dmesg),并在输出里加param_corrections数组说明每处修改。其他任何调用都不被静默改写。- 非 JSON 响应:
_render_response对非 JSON body(HTML WAF 页、明文 traceback 等)原样包成{"_non_json_body": ..., "_content_type": ...},stdout 保持单个 JSON 对象,| jq管道不破。 - 回显与条件字段:
request视图由include_request开关控制(main.py真实调用总是True,故 dry-run 与真实调用都会出现);unused_params在传了多余参数时出现;operation_id_raw仅在剥过API_前缀时出现;param_corrections仅在修正过参数时出现。 - Cookie 轮转:2xx/3xx 且带 token 时捕获后端
Set-Cookie(update_auth_from_response_headers)持久化;best-effort,失败不阻断调用。 - WAF 拦截被识别为
kind="waf_block"的NetworkError。 - 401 响应被识别为
kind="unauthorized"的UpstreamError(exit 3),hint 引导用户跑oed auth status/oed auth login。
auth.py (v0.4 新增,v0.6.2 存储重写)
- AtomGit(
ag)PAT 通过第二个_SecureStore实例持久化:username="ag:ag"命名空间,与 oneid 的"default"条目物理隔离;明文 fallback 落<OED_CACHE_DIR>/tokens/<service>.json(继承OED_CACHE_DIR;oed cache clear只删 discovery.json,不会误清凭证)。 - 存储语义与 oneid 凭证一致:OS keystore 可达(macOS Keychain / Windows DPAPI / Linux SecretService)→ 走 keyring;否则 0600 明文 fallback。
keyring>=24依赖由pip install oed-cli自动安装。 - API:
store_token(token, service)/read_token(service)/clear_token(service)/token_info(service)(返回{"encryption": "keyring"|"plaintext", "created_at"})/token_path(service)。读取失败(缺文件 / 坏 JSON / 无 keyring 条目)一律静默返回None,让调用方落到 missing-token 路径。
main.py — 顶层 dispatcher(v0.2 起为入口)
main()是oed的 entry point(pyproject 的oed = oed_cli.main:main);__main__.py把python -m oed_cli也指过来。- 顶层 dispatch:
_looks_like_reserved把保留命令(info/services/schema/cache/auth/login/completion/help/--version/--help)路由到 click;其他进_dispatch_dynamic。RESERVED_FIRST_TOKENS还含空串""(裸oed无参数时直接进 click 帮助);保留 token 在列即不进动态分发通道(避免被当成oed <service=login>)。 _check_allowlist(service_name)—— 本地 per-user service allow-list 闸门(v0.6)。读auth.json.allowlist,service 不在列则抛UserError(kind="service_blocked_by_allowlist")。fail-open 语义:无 auth.json / 缺allowlist字段 /allowlist=[]/ 字段类型异常 全部放行(详见 §4.3)。_split_dispatch_argv第一遍解析:把--<flag> value、裸--<flag>、--help/-h、positional 分桶;未知--<flag>暂存等操作解析后再校验。- 操作解析(service → spec → operation)完成后,未知
--<flag>通过param_flag_index跟声明参数对齐;匹配不上且不在_VALUE_FLAGS={"params","json","path","user-agent"}/_BOOL_FLAGS={"dry-run"}内则报unknown_flag+ 提示声明了哪些参数。 - per-param flag 与
--paramsJSON 共存:flag 覆盖--params同名字段;类型由coerce_flag_value+coerce_param_types双层强转。 - 真实调用:
_resolve_auth()解析 token/cookie(envOED_TOKEN/OED_COOKIE优先,其次 auth.json),然后以include_request=True调call_operation—— 所以真实调用也会返回request回显视图(凭证已掩码)。 - 保留子命令:
service_name == "ag"且 method 为login/logout时走本地凭证管理,不进动态分发 ——oed ag login(交互式--token或静默提示,默认向 AtomGit 校验 token,--no-verify跳过,--status只查不写)、oed ag logout。见 §4.2auth.py。 - 操作级
--help短路在 flag 校验之前:未知 flag +--help仍展示帮助,方便探索。 - 任何
OedError都被 catch 后以{"ok":false,"code":N,"error":...}形式写到 stderr 并返回对应退出码;非 2xx/3xx 时 stdout 也写响应体但退出码仍非零。
cli.py
- 用
click解析保留命令;OedCli自定义 group 在标准--help之外追加「Auto-discovered services」section(拉 discovery 失败时静默降级)。 - 顶层保留命令:
info/services [--community X] [--refresh]/schema <service>[.method] [--refresh]/cache {show,clear,refresh}/auth {status,token,logout,login,login --manual}/--version。 auth子组从oed_cli.auth注册;入口auth_group被cli.add_command(auth_group)联入主树。shell completion子命令计划在 v1.0 由 click 原生支持,当前未实现(main.py已在RESERVED_FIRST_TOKENS里保留completion,避免被当成服务名;键入会落到 click 树报未知命令)。
auth.py (v0.5 重写,v0.6 扩展 allowlist + 平台原生加密)
save_auth()/load_auth()/clear_auth()—— 通过_SecureStorefacade 写入或读取<OED_CACHE_DIR>/auth.json(POSIX 0600 fallback);当 OS keystore 可达时走 Keychain / DPAPI / SecretService。Schema:{msal_cache, created_at, cookie?, allowlist?};msal_cache是 MSAL 序列化的 token 缓存(包含access_token+refresh_token+ account)。_SecureStore—— 内部 facade,save()/load()/clear()三方法 + lazy 探测 keyring + 单 backend 标签_last_backend。keyring 写成功后删 plaintext;plaintext 仅作迁移期临时 / 无 keystore fallback。auth_status输出里的backend字段读backend_label()("keyring"或"plaintext")。save_msal_auth(cache, cookie=, allowlist=)—— 设备流使用;cache.has_data_changed时持久化。allowlist=None不写字段(兼容老 auth.json);显式传 list 时覆盖。_fetch_user_allowlist(access_token)—— 一次性 HTTP GET/oneid/oidc/device/user-data,带Authorization: Bearer <token>。返回list[str]或None(fail-open:网络/5xx/JSON 解析/字段缺失/URL 未配置 全部None)。URL 从defaults.toml [oauth].cli_user_data_url读,envOED_USER_DATA_URL可覆盖。仅在_finalize_device_resulttoken 拿到之后调一次,结果写入 auth.json 缓存。auth_headers_from_storage()—— 拼Authorization: Bearer <token>/ 可选Cookie头;OED_TOKEN/OED_COOKIE环境变量优先级高于文件。is_token_expired()—— 基于created_at + 24h的最佳估算(网关侧可能提前失效,401 是权威信号)。device_login()—— RFC 8628 Device Authorization Grant,通过 MSAL Python 实现。调用msal.PublicClientApplication.initiate_device_flow()拿user_code+verification_uri,打印到 stderr,自动尝试开浏览器(best-effort),自动复制user_code到剪贴板(best-effort),然后acquire_token_by_device_flow()轮询拿 token。MSAL 内部处理slow_down/authorization_pending/ 网络错误重试。token 拿到后自动调用_fetch_user_allowlist()把 allowlist 写入 auth.json。manual_login()—— TTY 粘贴流(与 v0.4 兼容)。非 TTY 抛UserError(kind="not_tty")。不触发 allowlist fetch(用户主动 paste 的 token 通常跨身份复用,allowlist 留给下一次设备流登录刷新)。load_defaults()/get_client_id()/get_device_url()/_get_user_data_url()—— 从 wheel 内defaults.toml读[oauth],envOED_APP_ID/OED_DEVICE_URL/OED_USER_DATA_URL可覆盖。extract_set_cookie()/update_auth_from_response_headers()—— 业务请求返回Set-Cookie时轮转 cookie(保留 MSAL 缓存 + allowlist)。encrypt_api_data(data)—— HMAC 签名 + base64 编码,给 APIG custom-auth 拼x-secret-token头。_try_open_browser()/_has_display()/_copy_to_clipboard()—— 辅助;后两个 stdlib only(subprocess调用wl-copy/xclip/xsel/pbcopy/clip)。auth_groupclick 子组:status(JSON,含token_fingerprint/username/allowlist)、token <value> [--cookie](粘贴)、logout、login(设备流)、login --manual(TTY 粘贴)。
errors.py — 退出码
| Code | 含义 |
|---|---|
| 0 | 成功(HTTP 2xx/3xx) |
| 1 | 用户错误(参数无效、JSON 解析失败、未知 flag) |
| 2 | 网络 / WAF 拦截 |
| 3 | 上游 API 错误(含 4xx/5xx) |
| 4 | 未发现(service/method 不在 gateway 中 / 空 spec) |
4.3 Per-user service allow-list(v0.6)
oed <service> ... 每次执行前查 auth.json.allowlist(一个 list[str]),service_name 不在则抛 UserError(kind="service_blocked_by_allowlist") 退出码 1。关键设计:
- 不在每次 CLI 调用时 fetch。只在
oed auth login设备流程轮询到成功那一刻,发一次GET /oneid/oidc/device/user-data(带Authorization: Bearer <access_token>)把 allowlist 拉下来,写到 auth.json 的allowlist字段。 - 本地校验:之后每次 CLI 调用只看
auth.json本地这份,不再发请求。 - 保留命令不受影响:
oed login/oed auth login/oed auth status/oed services/oed info等走 click 树,根本不进 dispatcher,所以 allowlist 不会拦它们。 ag豁免:ag(AtomGit)用自有 PAT 鉴权(oed ag login),与 oneid 无关,不归 oneid allow-list 管。_check_allowlist对service_name == "ag"直接放行;ag login/ag logout更在 gate 之前短路。
Fail-open 矩阵(auth.json 状态 → 行为):
auth.json |
allowlist 字段 |
行为 |
|---|---|---|
| 不存在 | — | fail-open:放行 |
| 存在 | 缺字段 | fail-open:兼容老 auth.json |
| 存在 | null |
fail-open |
| 存在 | [] |
fail-open:用户主动清空(保守) |
| 存在 | ["a","b"] |
service_name 在 → 放行;不在 → UserError |
allowlist=[] 选 fail-open 而不是 fail-closed:网络/服务异常可能也会返回空,严格 fail-closed 会把 CLI 锁死。需要 fail-closed 时把 main._check_allowlist 里的 if isinstance(allowlist, list) and allowlist: 改为 if isinstance(allowlist, list): 即可(one-liner 改动)。
数据流:
oed login
↓
POST /oidc/device/code → user_code
↓ user approves in browser
POST /oidc/token (polling) → access_token
↓
GET /oidc/device/user-data → {"data": "a,b,c"}
↓ parse + sort
save auth.json with allowlist field
↓
oed <service> <op> ... → load_auth() → check service_name
- 在:放行
- 不在:UserError(kind="service_blocked_by_allowlist")
URL 配置:defaults.toml [oauth].cli_user_data_url,env OED_USER_DATA_URL 可覆盖。
5. 安装 & 发布
5.1 用户侧
# 推荐(与 gws 单条命令一致)
pip install oed-cli
# 想要隔离环境(CI 更稳)
pipx install oed-cli
# 使用 uv 时
uv tool install oed-cli
打包边界:
Python >= 3.10- 仅强依赖:
click>=8.1,httpx>=0.27 - 无本地编译;wheel only。
5.2 开发侧
git clone https://atomgit.com/openeuler/oed-cli
cd oed-cli
pip install -e ".[dev]"
pytest
5.3 发布流水线(计划)
git tag vX.Y.Z && git push --tags- GitHub Actions:跑
pytest+python -m build→ 上传 PyPI(OIDC trusted publisher) - 自动生成 release notes(changesets-style,由
.changeset/*.md驱动)
6. AI 友好
6.1 默认 JSON、确定性 stdout
- 永远 JSON 到 stdout(
ensure_ascii=False,中文友好),stderr 单独承载错误和日志。 - 颜色/交互提示关闭(
NO_COLOR=1且click.echo(..., err=True, color=False))。 - 成功调用返回字段稳定:
ok、status、url、method、service、operation、response;request仅在 dry-run 或被显式开启include_request时出现;operation_id_raw仅在 spec 的operationId被剥过API_前缀时出现;param_corrections仅在本次调用修正过参数时出现(如eulermaker getJobLog的result_root自动补/dmesg)。 - 错误也是 JSON(写到 stderr):
{"ok": false, "code": 4, "error": "method_not_found", "message": "service 'xxx' has no method 'yyy'", "available_methods": [...], "hint": "..."}
6.2 Claude Code Skill
.claude/skills/oed-cli/SKILL.md 内容:
- 简介
oed,告诉 Agent 该用它来做什么。 - 列出
oed info / services / schema / <service> <method>三段式。 - 给 Agent 「先
oed info探测 →oed schema取规格 →oed <svc> <method>调用」的标准工作流。 - 引用
context/discoverAPI.md作为参数语义权威源。
6.3 可移植到其它 Agent
- 同时保留
.agent/skills/oed-cli/与.gemini/extensions/oed-cli/的占位结构,未来补齐即可。 - 命令文法本身用 BNF-like 块写在 SKILL.md,避免 Agent 误解。
7. 已知风险 & 决策记录
| 主题 | 决策 | 备注 |
|---|---|---|
| WAF 拦截 | 内置浏览器请求头(User-Agent、Accept、Accept-Language);不带默认 Referer(easysearch 等端点对其返回 401) |
详见 context/discoverAPI.md 第 6 节;具体头由 http.py 维护 |
$APIG_GROUP_ENTRY_URL 占位 |
每个服务的运行时 base URL = resolve_runtime_gateway(service) + op.path。解析器读 ServiceMeta.base_url(来自 discovery feed),去掉尾部 / 和空白后原样返回;无 fallback。spec 里的 servers[0].url 不参与。 |
当下所有 openeuler 服务在 feed 里都返回真实的 base_url(如 https://apig.osinfra.cn);若 gateway 在过渡期返回空或占位符,HTTP 调用会直接失败暴露问题,而不是被静默重写到旧常量。x-apigateway-backend.httpEndpoints.address 常指向测试域 *.test.osinfra.cn,会被 CloudWAF 拦截,因此该字段仅用于推断 method/scheme,不用于 host。 |
| 多社区支持 | MVP 默认 openeuler;OED_COMMUNITY 环境变量切换;oed services --community X 限定一个社区 |
与 gws 的 project 选择类似 |
| 缓存失效 | discovery + 单服务 spec 都 TTL 10 分钟;oed cache refresh 强制刷 discovery;oed cache show/clear 看 / 清 |
避免动态命令表抖动,详情见 README "Keeping in sync with the gateway" |
| 缓存防毒化(v0.6.3,issue #22) | 三层纵深防御,全环境生效、无密钥管理面:(1) 未来时间戳拒绝 —— discovery._materialize 与 dynamic._read_spec_cache 见 fetched_at > now + 300s(_CLOCK_SKEW_SECONDS,容忍 NTP 漂移)即视为损坏、丢弃重拉,堵住「把 __oed_fetched_at 改成未来值让 now - fetched_at 恒负、TTL 永不过期」的绕过;(2) base_url 仅 https —— invoke.call_operation 在拼 URL 后、附加 token 前用 urlparse 校验 scheme,非空且非 https 抛 UserError(kind="insecure_base_url") + hint「oed cache clear」,堵住把 base_url 改成 http://evil.com 让 Bearer token/ag PAT 明文出网。不拦 scheme 为空的占位符/空串($APIG_GROUP_ENTRY_URL、empty feed),保留 resolve_runtime_gateway 原样穿透到 HTTP 层失败的契约;(3) 原子写 —— discovery._atomic_write_json(tmp+replace)供 discovery 与 spec 缓存共用,崩溃半写入不再留 corrupt 文件。不做 HMAC 完整性签名:plaintext-fallback(常见 CI/headless)下密钥与缓存同目录、可被同一攻击者读写,边际价值低;issue 自承同用户进程本可读 keyring,属纵深防御而非闭合新洞。三层不闭合 https://evil.com 外泄(需 HMAC/hostname 白名单),hostname 白名单因现有测试用 https://custom.example.com 等非 osinfra 主机而过脆,故未采用 |
缓存是公开数据,加密不是正确工具(issue 明确反对);host 必须来自 feed 的约束不变(scheme 校验是 hard-fail 而非 silent rewrite,不违反「无 fallback」) |
| 认证(v0.5+) | oed login/oed auth login 走 RFC 8628 device flow(MSAL),token 经 _SecureStore 持久化(见下「平台原生加密」行);调用时自动注入 Authorization: Bearer <token> + x-secret-token HMAC 头。ag(AtomGit)走独立鉴权:spec 声明的 access_token query 参数,oed ag login/logout/status 管理本地 PAT,真实调用自动注入已存 token、回显视图掩码为 <stored>,显式 --access-token 优先。其他服务暂无本地凭证 |
v0.4 的 oed login + 自动注入已落地;OED_TOKEN/OED_COOKIE 环境变量覆盖仍未做 |
| forum 占位符鉴权 | forum(Discourse)的 Api-Key / Api-Username 由 oed 在请求时自动填充占位符头(oed-placeholder),网关 APIG 的 header 转换规则在边缘把它们替换成真实凭证 —— 因此 oed 必须发送这些头,否则网关直接拒绝。spec 仍把它们声明为 required header 参数,但 oed 从 flag/help 面剔除;调用方若通过 --params 或 --api-key 传入会报 gateway_managed_param(exit 1)+ hint「oed 自动填,别传」 |
目标是「让 agent 相信 oed 会自动填占位符、不去检查」——--dry-run 的 headers 里会显示 Api-Key: oed-placeholder,这是 oed 的自动填充,不是缺口 |
| body 可见性 | help 里 body 信息(body_schema / body_required_fields / usage 的 --json)以「存在 requestBody」为准展示,而非 body_required——网关常只写 schema 级 required,requestBody.required 缺失会误判「无 body」。--params 示例仅在操作声明了 query/path 参数时才给出;body 型操作给 --json 示例 |
修正了 forum createTopicPostPM 事件:按 help 用 --params 传 body 字段 → 静默落入 unused、body: null → 网关 400。现在调用时若 --params 命中 body 字段名且未传 --json,直接报 body_fields_via_params |
| shell 补全 | 计划由 click 原生支持(oed completion {bash,zsh,fish,powershell}),尚未实现 |
v1.0 路线图 |
| 错误处理 | 任何 OedError 被顶层 catch 后以 JSON 形式写 stderr 并返回对应退出码(0/1/2/3/4);stdout 在出错时仍为空,方便 | jq 安全管道 |
详见 §4.2 错误码表 |
| Per-user allow-list(v0.6) | 设备流登录成功后,一次性 GET /oneid/oidc/device/user-data(带 Bearer token)把 user 勾选过的服务列表拉下来,存进 auth.json.allowlist。后续每次 oed <service> ... 只查本地,不发 HTTP。Fail-open(无 auth.json / 缺字段 / [] 全部放行);非空列表里没命中 → UserError(kind="service_blocked_by_allowlist")(exit 1)。URL 配置在 defaults.toml [oauth].cli_user_data_url,env OED_USER_DATA_URL 可覆盖 |
详见 §4.3。手动粘贴 (auth token) 不触发 fetch(粘的 token 通常跨身份复用,allowlist 留给下一次设备流登录刷新);token 静默刷新 (acquire_token_silent) 也不重 fetch(allowlist 与 user identity 绑定,不与 token lifetime 绑定) |
| 平台原生加密(v0.6) | auth.json 通过 _SecureStore facade 写入。优先级:OS keystore 可达 → macOS Keychain / Windows DPAPI / Linux SecretService;否则 fallback <OED_CACHE_DIR>/auth.json + 0600。Runtime dep keyring>=24,由 pip install oed-cli 自动安装;用户无手动步骤。oed auth status 输出 backend 字段标识当前后端 |
详见 docs/oed-cli-token-lifecycle-and-storage.md §5.3 |
8. 路线图
| 版本 | 范围 | 状态 |
|---|---|---|
| v0.1 | 设计文档 + 最小脚手架 + Claude Skill + oed info/services/schema/cache |
✅ 已发布 |
| v0.2 | 动态方法分发:oed <service> <method> --params ... --json ... --dry-run,输出 request 视图,禁用 OedError 转 JSON 返回 |
✅ 已发布 |
| v0.2.3 | Per-parameter flag 表面:每个声明参数自动转 --<kebab-case>(--cve-id 而非 --params '{"cveId":…}'),operation 级 --help,APIG API_ 前缀自动剥离并保留为别名 |
✅ 已发布 |
| v0.3.0 | oneid RFC 8628 device flow 登录(oed login / oed auth login,headless/SSH/CI 可用);OS-native credential storage(_SecureStore → macOS Keychain / Windows DPAPI / Linux SecretService,0600 fallback);Per-user service allow-list(设备流登录后一次性拉 /oneid/oidc/device/user-data,本地拦截未授权 service,fail-open 兼容老 auth.json);x-secret-token HMAC 移除改用明文 x-oed-source / x-oed-target 身份头(CLI 二进制分发对称密钥无法保密);gateway custom-auth 从 Bearer JWT 解 username + 角色;spec fixture 与生产 pkgcontrib 对齐(software-package-server 在线改名,operationId 不再带 API_ 前缀);omapi 切生产域 omapi.osinfra.cn |
✅ 已发布 |
| v0.4 | --page-all、NDJSON 流式分页、--human 美化输出 |
未开始 |
| v0.6.1 | auth.json 平台原生加密:macOS Keychain / Windows DPAPI / Linux SecretService(keyring>=24 依赖),无 keystore 时回退 0600;oed auth status 增加 backend 字段 |
✅ 已发布 |
| v0.6.2 | AtomGit(ag)PAT:oed ag login/logout/status + ag 请求自动注入 access_token(显式传参优先、回显掩码);PAT 存储复用 _SecureStore(独立 keyring 条目),与 oneid 凭证隔离 |
部分落地(仅 ag 服务) |
| v0.6.3 | 缓存防毒化(issue #22):未来时间戳拒绝、base_url 仅 https、原子写缓存;oed auth status 的 has_refresh_token 从 MSAL cache 读取(修历史 bug) |
✅ 已发布 |
| v1.0 | PyPI 首发 + 完整测试 + 跨平台 shell 补全 + 文档站 | 未开始 |
v0.2 解锁了直接调用云服务的核心承诺(
oed <service> <method> ...)。 用户从此oed一下就能调 openEuler 任意已挂载到网关的服务,无需手写curl也无需手填 WAF 头。
9. 参考
googleworkspace/cli(gws,本工具的设计灵感源)context/discoverAPI.md(网关发现服务使用文档,本 CLI 的数据源)- OpenAPI 3.0.3 规范(
https://spec.openapis.org/oas/v3.0.3)