@numa-tech/numa 1.11.14 → 1.12.2

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 (102) hide show
  1. package/README.md +197 -25
  2. package/dist/ai-registry/client.d.ts +65 -0
  3. package/dist/ai-registry/client.js +260 -0
  4. package/dist/ai-registry/client.js.map +1 -0
  5. package/dist/ai-registry/commands.d.ts +2 -0
  6. package/dist/ai-registry/commands.js +438 -0
  7. package/dist/ai-registry/commands.js.map +1 -0
  8. package/dist/ai-registry/errors.d.ts +8 -0
  9. package/dist/ai-registry/errors.js +40 -0
  10. package/dist/ai-registry/errors.js.map +1 -0
  11. package/dist/ai-registry/mcp-installer.d.ts +37 -0
  12. package/dist/ai-registry/mcp-installer.js +273 -0
  13. package/dist/ai-registry/mcp-installer.js.map +1 -0
  14. package/dist/ai-registry/schemas.d.ts +86 -0
  15. package/dist/ai-registry/schemas.js +39 -0
  16. package/dist/ai-registry/schemas.js.map +1 -0
  17. package/dist/ai-registry/skill-installer.d.ts +21 -0
  18. package/dist/ai-registry/skill-installer.js +262 -0
  19. package/dist/ai-registry/skill-installer.js.map +1 -0
  20. package/dist/app-config.d.ts +4 -2
  21. package/dist/app-config.js +15 -3
  22. package/dist/app-config.js.map +1 -1
  23. package/dist/cli.js +40 -12
  24. package/dist/cli.js.map +1 -1
  25. package/dist/codex-module.d.ts +8 -0
  26. package/dist/codex-module.js +152 -0
  27. package/dist/codex-module.js.map +1 -0
  28. package/dist/command-catalog.js +170 -64
  29. package/dist/command-catalog.js.map +1 -1
  30. package/dist/config-page.js +17 -0
  31. package/dist/config-page.js.map +1 -1
  32. package/dist/config.d.ts +4 -1
  33. package/dist/config.js +26 -7
  34. package/dist/config.js.map +1 -1
  35. package/dist/media/commands.js +12 -11
  36. package/dist/media/commands.js.map +1 -1
  37. package/dist/oauth.d.ts +2 -30
  38. package/dist/oauth.js +1 -294
  39. package/dist/oauth.js.map +1 -1
  40. package/dist/pipeline/client.d.ts +126 -0
  41. package/dist/pipeline/client.js +105 -0
  42. package/dist/pipeline/client.js.map +1 -0
  43. package/dist/pipeline/commands.d.ts +17 -0
  44. package/dist/pipeline/commands.js +283 -0
  45. package/dist/pipeline/commands.js.map +1 -0
  46. package/dist/pipeline/errors.d.ts +8 -0
  47. package/dist/pipeline/errors.js +37 -0
  48. package/dist/pipeline/errors.js.map +1 -0
  49. package/dist/pipeline/schemas.d.ts +243 -0
  50. package/dist/pipeline/schemas.js +60 -0
  51. package/dist/pipeline/schemas.js.map +1 -0
  52. package/dist/platform-profile.d.ts +75 -0
  53. package/dist/platform-profile.js +169 -0
  54. package/dist/platform-profile.js.map +1 -0
  55. package/dist/types.d.ts +1 -40
  56. package/dist/web-console-page.d.ts +2 -2
  57. package/dist/web-console-page.js +21 -7
  58. package/dist/web-console-page.js.map +1 -1
  59. package/dist/web-console.d.ts +17 -3
  60. package/dist/web-console.js +70 -5
  61. package/dist/web-console.js.map +1 -1
  62. package/examples/claude-desktop-npx.json +1 -1
  63. package/examples/codex-npx.json +1 -1
  64. package/package.json +30 -14
  65. package/skills/numa-ai-registry/SKILL.md +47 -0
  66. package/skills/numa-ai-registry/evals/evals.json +35 -0
  67. package/skills/numa-ai-registry/references/command-contract.md +53 -0
  68. package/assets/codex-shortcut-icon.icns +0 -0
  69. package/assets/codex-shortcut-icon.ico +0 -0
  70. package/dist/chatgpt-console.d.ts +0 -53
  71. package/dist/chatgpt-console.js +0 -105
  72. package/dist/chatgpt-console.js.map +0 -1
  73. package/dist/codex-auth-agent.d.ts +0 -60
  74. package/dist/codex-auth-agent.js +0 -331
  75. package/dist/codex-auth-agent.js.map +0 -1
  76. package/dist/codex-commands.d.ts +0 -13
  77. package/dist/codex-commands.js +0 -571
  78. package/dist/codex-commands.js.map +0 -1
  79. package/dist/codex-legacy-cleanup.d.ts +0 -5
  80. package/dist/codex-legacy-cleanup.js +0 -77
  81. package/dist/codex-legacy-cleanup.js.map +0 -1
  82. package/dist/codex-process.d.ts +0 -13
  83. package/dist/codex-process.js +0 -193
  84. package/dist/codex-process.js.map +0 -1
  85. package/dist/codex-protocol.d.ts +0 -352
  86. package/dist/codex-protocol.js +0 -116
  87. package/dist/codex-protocol.js.map +0 -1
  88. package/dist/codex-quota-cache.d.ts +0 -20
  89. package/dist/codex-quota-cache.js +0 -82
  90. package/dist/codex-quota-cache.js.map +0 -1
  91. package/dist/codex-selection.d.ts +0 -18
  92. package/dist/codex-selection.js +0 -47
  93. package/dist/codex-selection.js.map +0 -1
  94. package/dist/codex-service-client.d.ts +0 -11
  95. package/dist/codex-service-client.js +0 -91
  96. package/dist/codex-service-client.js.map +0 -1
  97. package/dist/codex-shortcuts.d.ts +0 -43
  98. package/dist/codex-shortcuts.js +0 -429
  99. package/dist/codex-shortcuts.js.map +0 -1
  100. package/dist/codex-tui.d.ts +0 -15
  101. package/dist/codex-tui.js +0 -235
  102. package/dist/codex-tui.js.map +0 -1
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - 网页控制台提供身份概览、基础配置、命令发现和非交互命令执行;
10
10
  - 包含 Realm 角色、Client 角色和用户组的个人信息页;
11
11
  - 使用服务端托管的 Codex 账号启动官方 Codex CLI,客户端不保存 refresh token;
12
+ - 通过平台授权代理发现、治理并安装私有 Nacos Skills 与 MCP Servers;
12
13
  - 通过领域化 `media` 命令安全调用平台托管的 TTS、视频、资产和素材库服务;
13
14
  - 兼容原有 stdio MCP 服务。
14
15
 
@@ -23,9 +24,10 @@ npm config set @numa-tech:registry=https://registry.npmjs.org/
23
24
  ```
24
25
 
25
26
  公司私有 registry 仍受支持,可通过 `numa config --set registryUrl=<url>` 或 shortcut
26
- 的 `--registry-url <url>` 显式选择。发布人员使用 `npm run publish:all`,脚本默认先把
27
- patch 版本加一,再将同一 tarball 依次推送到公共 npm 和公司私有 registry;脚本会跳过
28
- 已存在的同版本。若其中一个仓库失败,使用 `--resume` 以当前版本补推,不会再次修改版本号。
27
+ 的 `--registry-url <url>` 显式选择。发布人员先使用 `npm run release -- patch --tag=candidate`;脚本锁步更新
28
+ 所有 workspace 的版本与内部精确依赖,并按依赖顺序将每个包的同一份 tarball 依次推送到
29
+ 公共 npm 和公司私有 registry 的 `candidate` tag,不改动 `latest`。已存在且 integrity/tag 一致的目标会被跳过;部分失败时使用
30
+ `npm run release:resume` 从持久化状态补推,不会再次修改版本或重新生成 tarball。
29
31
  可用 `NUMA_PUBLIC_NPM_REGISTRY`、`NUMA_PRIVATE_NPM_REGISTRY` 覆盖两个发布目标。旧版配置
30
32
  中作为默认值保存的私有 registry 会自动迁移到公共 npm;迁移后显式设置的私有地址会保留。
31
33
 
@@ -36,8 +38,9 @@ patch 版本加一,再将同一 tarball 依次推送到公共 npm 和公司私
36
38
  //packages.aliyun.com/60371955c4393f5ed3b7baa8/npm/npm-registry/:_authToken=${NPM_PRIVATE_TOKEN}
37
39
  ```
38
40
 
39
- 脚本会在修改版本号之前运行公共 npm `whoami` 和 `@numa-tech` scope 权限检查。使用验证器
40
- App 的本地交互发布会在公共 npm 推送前隐藏输入 6 OTP,并显式传给 `npm publish`;
41
+ 脚本会先检查 Git 工作区干净、当前分支与 upstream 完全同步,再于修改版本号之前检查两个
42
+ registry 的身份以及公共 npm `@tokensrc`、`@numa-tech` scope 权限。使用验证器 App
43
+ 本地交互发布会在公共 npm 推送前隐藏输入 6 位 OTP,并显式传给 `npm publish`;
41
44
  使用带 bypass 2FA 权限的 granular token 时直接回车即可。非交互环境可以临时提供
42
45
  `NUMA_PUBLIC_NPM_OTP`,更推荐使用 bypass 2FA granular token 或 npm trusted publishing。
43
46
 
@@ -47,31 +50,34 @@ App 的本地交互发布会在公共 npm 推送前隐藏输入 6 位 OTP,并
47
50
  npm login --registry=https://registry.npmjs.org/
48
51
  npm whoami --registry=https://registry.npmjs.org/
49
52
  npm org ls numa-tech --registry=https://registry.npmjs.org/
53
+ npm org ls tokensrc --registry=https://registry.npmjs.org/
50
54
  ```
51
55
 
52
56
  双仓库发布命令:
53
57
 
54
58
  ```bash
55
- # 仅预览:1.11.9 -> 1.11.10,不修改文件、不访问 registry
56
- npm run publish:all -- --dry-run
59
+ # 仅预览:1.12.0 -> 1.12.1,默认 candidate,不修改文件、不访问 registry
60
+ npm run release -- patch --dry-run
57
61
 
58
- # 默认 patch:自动修改 package.json package-lock.json,然后推送两个仓库
59
- npm run publish:all
62
+ # 候选 patch:全量验证、双发到 candidate,成功后提交、打 tag 并原子推送
63
+ npm run release -- patch --tag=candidate
60
64
 
61
- # minor / major / 指定完整版本
62
- npm run publish:all -- minor
63
- npm run publish:all -- major
64
- npm run publish:all -- 2.0.0
65
+ # 线上验收通过后,显式把同一候选版本晋级为 latest
66
+ npm run release:promote -- 1.12.1 --from-tag=candidate
65
67
 
66
- # 某个仓库失败后,用当前版本补推;不会再次升级版本
67
- npm run publish:resume
68
+ # 候选 minor / 指定完整版本(也可显式附加 --tag=next)
69
+ npm run release -- minor
70
+ npm run release -- 2.0.0
68
71
 
69
- # 等价写法(注意中间必须有独立的 --)
70
- npm run publish:all -- --resume
72
+ # 某个目标失败后恢复;查询两个 registry 并复用原 tarball/integrity
73
+ npm run release:resume
71
74
  ```
72
75
 
73
- 脚本不会自动创建 Git commit tag;发布成功后应提交版本文件。可先运行
74
- `npm whoami --registry=<registry-url>` 分别检查两个仓库的发布身份。
76
+ 候选发布只有在全部包的两个 registry integrity candidate tag 都校验成功后,脚本才会创建
77
+ `chore(release): vX.Y.Z` commit、附注 tag,并以一次 atomic push 推送分支和 tag。部分失败
78
+ 不会 commit 或打 tag,也不会尝试回滚 npm 的不可变版本。晋级命令会先验证干净且同步的
79
+ release commit/tag、两个 registry 的候选 tag 及跨 registry integrity,再幂等更新 `latest`;
80
+ 它不修改源码或 Git 历史。
75
81
 
76
82
  macOS / Windows 日常启动 Codex 的推荐方式:
77
83
 
@@ -106,6 +112,11 @@ npm link
106
112
  numa help
107
113
  ```
108
114
 
115
+ 仓库使用 npm workspaces 与 Turborepo。`npm run build`、`npm run typecheck`、`npm test`、
116
+ `npm run pack:check` 会按 workspace 依赖图执行并使用本地缓存;CI 可运行
117
+ `npm run check:affected` 只验证受变更影响的任务。跨包代码只能通过 package exports 导入,
118
+ `npm run boundaries` 会拒绝直接引用其他 workspace 的 `src` 路径。
119
+
109
120
  ## 快速开始
110
121
 
111
122
  ```bash
@@ -154,6 +165,11 @@ numa config \
154
165
  | `numa request <path>` | 需登录 | 携带令牌调用平台 API |
155
166
  | `numa request <path> --require-role <role...>` | 需角色 | 调用前检查指定角色之一 |
156
167
  | `numa admin context` | 需角色 | 要求 `platform-admin` 或 `numa-admin` |
168
+ | `numa registry instances list/create/update/delete` | 需登录 | 管理数据库中的 Nacos 实例和 OIDC 配置;写操作需要 Registry admin |
169
+ | `numa registry namespaces list/resolve/check/create/update/delete` | 需登录 | 管理多实例 namespace 路由与跨集群默认项;写操作需要 Registry admin |
170
+ | `numa registry skill list/inspect/install/upload/...` | 需登录 | 发现、安装和治理 Nacos Skills;发布类操作需要 Registry admin |
171
+ | `numa registry mcp list/inspect/install/create/update/delete` | 需登录 | 发现、安装和维护 Nacos MCP Servers;写操作需要 Registry admin |
172
+ | `numa registry tui` | 需登录 | 在终端交互浏览、安装和治理 Skill/MCP |
157
173
  | `numa media capabilities` | 需登录 | 查询媒体 API、Provider 和版本能力 |
158
174
  | `numa media tts submit` | 需登录 | 提交 TTS 异步任务 |
159
175
  | `numa media video submit` | 需登录 | 提交视频生成异步任务 |
@@ -193,6 +209,9 @@ CLI 的本地角色检查用于快速反馈和隐藏无权限入口,不能替
193
209
  - `output`:`text` 或 `json`;
194
210
  - `apiBaseUrl`:内部研发平台 API 根地址,默认
195
211
  `https://apps-gw-prd.mcisaas.com/mci-devops-platform`;
212
+ - `profileUrl`:完整的公开 client-configuration URL;设置后各模块只使用该
213
+ profile 声明的 service 与 authority;
214
+ - `profileId`:可选的 profile 标识锁定;远端返回其他标识时拒绝启用;
196
215
  - `registryUrl`:npm registry,默认公共 npm,也可配置公司私有 registry;
197
216
  - `checkUpdates`:是否检查 CLI 更新。
198
217
 
@@ -202,6 +221,7 @@ CLI 的本地角色检查用于快速反馈和隐藏无权限入口,不能替
202
221
  numa config --show
203
222
  numa config --show --json
204
223
  numa config --set output=json --set checkUpdates=false
224
+ numa config --set profileUrl=https://apps-gw-prd.mcisaas.com/mci-devops-platform/public/v1/client-configurations/numa-realm
205
225
  numa config --reset
206
226
  ```
207
227
 
@@ -227,7 +247,10 @@ numa console --no-open
227
247
  numa login --no-console --no-config --json
228
248
  ```
229
249
 
230
- 控制台展示当前身份、Realm/Client 角色、ChatGPT 账号、基础配置和机器可读命令目录。
250
+ 控制台展示当前身份、Realm/Client 角色、AI Registry、ChatGPT 账号、基础配置和机器可读命令目录。
251
+ AI Registry 页按“namespace → Skill/MCP → 详情/动作”组织,支持搜索、详情、安装以及 Skill
252
+ 提交/发布/上下线。发布按钮是否可用只是界面提示,实际权限始终由平台 Keycloak Policy
253
+ Enforcer 决定。
231
254
  ChatGPT 账号页先读取 `codex-multi-auth` 的本地账号与额度缓存并立即展示,再使用当前
232
255
  Keycloak 会话连接平台补充当前用户可用账号、剩余 limit 和同步状态。平台不可用时页面
233
256
  保留本地结果并明确显示“远程同步失败”,不会把 access token 或 refresh token 返回给页面。
@@ -242,6 +265,132 @@ JSON argv 调用同一版 CLI,不经过 shell,并自动要求结构化 `--js
242
265
  CSP。OIDC access/refresh token 始终保留在 CLI 进程及私有缓存中,不会返回给浏览器脚本;
243
266
  实际平台请求仍由服务端 Keycloak 授权决定。
244
267
 
268
+ ## 私有 Nacos AI Registry
269
+
270
+ `numa registry` 对接 `mci-devops-platform` 的 `/api/v1/ai-registry` 类型化 API。客户端只携带
271
+ 当前用户 Keycloak Bearer Token;平台从自己的数据库解析 Nacos 实例、namespace 和该实例的
272
+ OIDC client-credentials,再访问目标 Nacos。Nacos token、OIDC client secret 和 endpoint
273
+ credential 不写入 Numa 配置,也不由读取 API 返回。
274
+
275
+ 权限契约:
276
+
277
+ - `mci-devops-platform-ai-registry-reader`:`view`,可列出、检查、下载和安装;
278
+ - `mci-devops-platform-ai-registry-admin`:`view` + `edit`,作为操作管理员管理实例、namespace、Skill 和 MCP;
279
+ - 平台 operations admin 同时拥有 `view` + `edit`。
280
+
281
+ Nacos 的 `baseUrl`、token URI、client ID、client secret 和 scope 全部按实例保存在
282
+ `mci-devops-platform` 数据库。client secret 使用 AES-256-GCM 密文存储,读取接口只返回
283
+ `credentialConfigured`,不会返回明文或密文。平台部署只需从 Secret/KMS 注入数据库密文的
284
+ 主密钥环 `AI_REGISTRY_SECRET_ENCRYPTION_KEYS` 与当前 key ID
285
+ `AI_REGISTRY_SECRET_CURRENT_KEY_ID`;这两项是密钥管理材料,不是某个 Nacos 实例的配置。
286
+ 密钥环格式为逗号分隔的 `keyId=Base64(32-byte-key)`;轮换时先加入新 key 并切换当前 key ID,
287
+ 旧 key 必须保留到相关实例 secret 完成重写。
288
+ SSRF 安全策略仍由 `AI_REGISTRY_ALLOWED_UPSTREAM_HOSTS`、
289
+ `AI_REGISTRY_ALLOWED_TOKEN_HOSTS` 控制,allow-list 为空时 fail closed;HTTP 只可通过
290
+ `AI_REGISTRY_ALLOW_HTTP=true` 用于开发环境。
291
+
292
+ Nacos 实例文件示例(`clientSecret` 仅写入):
293
+
294
+ ```json
295
+ {
296
+ "code": "cluster-a",
297
+ "displayName": "Nacos Cluster A",
298
+ "baseUrl": "https://nacos.internal.example:8848",
299
+ "authenticationType": "OIDC_CLIENT_CREDENTIALS",
300
+ "tokenUri": "https://sso.internal.example/realms/mci/protocol/openid-connect/token",
301
+ "clientId": "nacos-registry-broker",
302
+ "clientSecret": "write-only-secret",
303
+ "scope": "openid",
304
+ "description": "A 集群",
305
+ "status": "ACTIVE"
306
+ }
307
+ ```
308
+
309
+ `instances update` 必须带列表结果中的当前 `version`,用于乐观锁保护;省略 `clientSecret`
310
+ 表示保留已有密文。建议让包含明文 secret 的输入文件仅对当前用户可读,并在写入后安全删除。
311
+
312
+ namespace 文件示例:
313
+
314
+ ```json
315
+ {
316
+ "namespaceKey": "team-ai",
317
+ "namespaceId": "upstream-namespace-id",
318
+ "displayName": "Team AI Registry",
319
+ "description": "面向客户端的稳定 namespace",
320
+ "isDefault": true,
321
+ "status": "ACTIVE"
322
+ }
323
+ ```
324
+
325
+ `namespaceKey` 是客户端使用的稳定名称,`namespaceId` 是该 Nacos 实例中的真实 ID。同一个
326
+ `namespaceKey` 可以存在于多个实例;其中最多一个 ACTIVE 路由可设为 `isDefault=true`。
327
+ 客户端只传 `--namespace team-ai` 时,平台会选择唯一匹配项或默认项;同名且没有默认项时返回
328
+ `AI_REGISTRY_NAMESPACE_AMBIGUOUS`,此时必须加 `--instance cluster-b` 消歧。
329
+
330
+ V34 会把旧服务行拆成实例和 namespace,但旧 `credentialRef` 无法自动转换为数据库密文,
331
+ 所以迁移后的实例会先置为 `DISABLED`。管理员写入完整 OIDC 配置和 `clientSecret` 后再启用;
332
+ 跨集群重复的旧 namespace 不会自动猜测默认项。
333
+
334
+ 先发现实例和 namespace,再通过 namespace 操作资源:
335
+
336
+ ```bash
337
+ numa registry instances list --json
338
+ numa registry namespaces list --namespace team-ai --json
339
+ numa registry namespaces resolve team-ai --json
340
+ numa registry namespaces check team-ai --json
341
+
342
+ numa registry skill list --namespace team-ai --search deploy --json
343
+ numa registry skill inspect release-helper --namespace team-ai --json
344
+ numa registry skill install release-helper \
345
+ --namespace team-ai --agent cursor --scope project --label latest --json
346
+
347
+ numa registry mcp list --namespace team-ai --json
348
+ numa registry mcp inspect internal-docs --namespace team-ai --json
349
+ numa registry mcp install internal-docs \
350
+ --namespace team-ai --instance cluster-b --agent claude --scope project --json
351
+
352
+ # 也可使用终端交互界面
353
+ numa registry tui
354
+ ```
355
+
356
+ Skill 发布生命周期:
357
+
358
+ ```bash
359
+ numa registry skill upload ./release-helper.zip \
360
+ --namespace team-ai --target-version 1.0.0 --json
361
+ numa registry skill submit release-helper --namespace team-ai --json
362
+ numa registry skill publish release-helper \
363
+ --namespace team-ai --version 1.0.0 --json
364
+ numa registry skill online release-helper --namespace team-ai --json
365
+ numa registry skill scope release-helper PRIVATE --namespace team-ai --json
366
+ numa registry skill tags release-helper deploy java --namespace team-ai --json
367
+ ```
368
+
369
+ 远端删除必须带 `--yes`。本地同名 Skill/MCP 默认拒绝覆盖;用户明确同意后才使用
370
+ `--force`,支持原子替换的适配器会先保留时间戳备份。Skill 下载必须通过平台返回的
371
+ SHA-256 校验,ZIP 在解压前会检查根 `SKILL.md`、路径穿越、符号链接、重复路径、文件数和
372
+ 膨胀大小。MCP 定义中的敏感 env/header 只能使用 `${ENV_VAR}` 占位符,不能携带明文密钥。
373
+
374
+ ### 客户端安装适配
375
+
376
+ | 客户端 | Skill | MCP Server |
377
+ | --- | --- | --- |
378
+ | ChatGPT / Codex coding workspace | `.codex/skills` | ChatGPT 仅接受远程 HTTPS MCP App;Numa 返回 Developer mode/App 创建指引,不会冒充修改工作区设置 |
379
+ | Codex | `.codex/skills` | 调用官方 `codex mcp add`,仅 user scope |
380
+ | Claude | `.claude/skills` | 项目 `.mcp.json`;user scope 使用 `~/.claude.json` |
381
+ | Cursor | `.cursor/skills` | `.cursor/mcp.json` |
382
+ | WorkBuddy | `.workbuddy/skills` | `.workbuddy/mcp.json`;目录可通过 custom adapter 调整 |
383
+ | OpenClaw | `.openclaw/skills` | 调用官方 `openclaw mcp set`,仅 user scope |
384
+
385
+ ChatGPT 本身不能直接连接本地 stdio MCP。Numa 对 `--agent chatgpt` 只为远程 HTTPS endpoint
386
+ 生成受控安装指引;私网或本机 MCP 需要先发布为远程服务,或使用 ChatGPT 支持的 Secure
387
+ MCP Tunnel。ChatGPT workspace 还需管理员按其计划和 RBAC 开启 Developer mode。
388
+
389
+ 仓库内置的自用 Skill 位于 `skills/numa-ai-registry/SKILL.md`,并随 npm 包发布。它指导 AI
390
+ 按“认证 → 服务解析 → inspect → 安装/治理 → 复查”的顺序使用 Registry,同时明确禁止索取
391
+ Nacos token、绕过 403 或未经确认覆盖本地配置。对应场景评测在
392
+ `skills/numa-ai-registry/evals/evals.json`。
393
+
245
394
  ## 平台媒体服务
246
395
 
247
396
  自 1.8.0 起,`numa media` 通过当前 Keycloak 会话调用 `/api/v1/media`。第三方
@@ -323,11 +472,11 @@ Numa 会清理项目更名前旧版本写入的自定义 `model_provider = "mcex
323
472
 
324
473
  自 1.7.0 起,职责进一步收敛:numa 负责服务端账号分发、选择和短效凭据同步,`codex-multi-auth/cli` 负责把凭据应用到官方文件登录并返回脱敏状态。消费端不会调用 `codex-multi-auth` 的 OAuth 登录、本地账号池切换或 refresh token 刷新功能;真实 refresh token 仍只存在于服务端。升级期间 2.8.x 保留兼容写入路径,安装 `codex-multi-auth` 2.9.0 及以上后自动使用新的公共接口。
325
474
 
326
- 默认连接生产网关;也可通过以下任一种方式显式覆盖:
475
+ 默认连接已授权的 Active 集成服务;也可通过以下任一种方式显式覆盖:
327
476
 
328
477
  ```bash
329
- numa codex list --server https://apps-gw-prd.mcisaas.com/mci-devops-platform/api
330
- export NUMA_CODEX_SERVICE_URL=https://apps-gw-prd.mcisaas.com/mci-devops-platform/api
478
+ numa codex list --server https://mail.tokensource.top --tenant-id numa-realm
479
+ export NUMA_CODEX_SERVICE_URL=https://mail.tokensource.top/api
331
480
  numa config --set apiBaseUrl=https://apps-gw-prd.mcisaas.com/mci-devops-platform
332
481
  ```
333
482
 
@@ -410,14 +559,15 @@ npx -y --prefer-online --registry=https://registry.npmjs.org/ @numa-tech/numa@la
410
559
  refresh token 时整批终止,避免服务端形成不完整快照。令牌只通过带 Keycloak Bearer
411
560
  认证的 HTTPS 请求发送,不写入 CLI 输出。
412
561
 
413
- `@numa-tech/numa` 直接依赖官方 `@openai/codex`,因此发布到 npm 后,新主机可以用一条命令安装并启动:
562
+ `@numa-tech/numa` 精确依赖同版本的 `@tokensrc/codex`;该模块封装官方
563
+ `@openai/codex` runtime,因此发布到 npm 后,新主机仍可用一条命令安装并启动:
414
564
 
415
565
  ```bash
416
566
  npx -y --prefer-online --registry=https://registry.npmjs.org/ @numa-tech/numa@latest codex run --server https://platform.example/api
417
567
  ```
418
568
 
419
569
  启动时会先运行 `codex --version` 做健康检查,依次使用 `NUMA_CODEX_BIN` 指定的入口、
420
- PATH 中已安装且可用的 Codex CLI、最后才使用 `@numa-tech/numa` 包内版本。这样已全局安装
570
+ PATH 中已安装且可用的 Codex CLI、最后才使用 `@tokensrc/codex` 依赖的包内版本。这样已全局安装
421
571
  Codex 的主机不会受包内平台可选依赖缺失影响;外部版本不可用时会自动回退。设置
422
572
  `NUMA_USE_BUNDLED_CODEX=1` 可跳过 PATH 探测并强制使用包内版本。
423
573
 
@@ -541,6 +691,28 @@ http://localhost:8765
541
691
 
542
692
  如需长期会话,还要允许 `offline_access`。
543
693
 
694
+ Numa 主 CLI 与可独立运行的 `@tokensrc/codex` 通过
695
+ `@numa-tech/cli-auth` 共用同一套 OIDC discovery、PKCE、私有缓存和 refresh
696
+ token 轮换实现。该共享包只负责认证会话;租户角色和业务权限仍由各资源服务校验,CLI
697
+ 中的角色展示或预检查不能替代服务端授权。
698
+
699
+ ### 登录失效恢复
700
+
701
+ 访问令牌到期后,CLI 会在本地令牌缓存上加跨进程锁,再重新读取缓存并执行 refresh
702
+ grant。多个 CLI 进程同时刷新时只有一个进程使用当前 refresh token,其他进程复用已经
703
+ 写入的新凭据。刷新成功后先原子保存新的 refresh token,再进行非关键的用户资料读取。
704
+
705
+ 如果身份服务明确返回 `invalid_grant`(包括 refresh token 已过期、撤销或超过最大复用
706
+ 次数),CLI 会将其分类为 `login_required`,而不是账号服务不可用:
707
+
708
+ - 交互式 `numa codex` 命令会询问是否打开浏览器重新登录;确认后仅重试原命令一次;
709
+ - 文本模式会给出 `numa login --no-console --no-config`;
710
+ - JSON 模式在 `error.recovery_command` 返回同一恢复命令并使用退出码 `3`;
711
+ - 身份服务网络错误或 5xx 不会删除仍可能恢复的本地会话,也不会误触发重新登录。
712
+
713
+ 独立 `tokensrc-codex` 遇到同类错误时返回 `login_required` 并提示运行
714
+ `tokensrc-codex login`。重新登录和首次重试都不会再次强制刷新刚签发的会话。
715
+
544
716
  ### 认证环境变量
545
717
 
546
718
  | 变量 | 默认值 | 用途 |
@@ -0,0 +1,65 @@
1
+ import type { AuthConfig } from "../config.js";
2
+ import { type KeycloakTokenProvider } from "../oauth.js";
3
+ import { type JsonObject, type RegistryInstance, type RegistryNamespace } from "./schemas.js";
4
+ export interface AiRegistryClientDependencies {
5
+ fetch?: typeof fetch;
6
+ tokenProvider?: KeycloakTokenProvider;
7
+ }
8
+ export interface SkillDownload {
9
+ bytes: Buffer;
10
+ fileName: string;
11
+ sha256: string;
12
+ }
13
+ export declare class AiRegistryClient {
14
+ private readonly config;
15
+ private readonly fetchImpl;
16
+ private readonly tokenProvider;
17
+ constructor(config: AuthConfig, dependencies?: AiRegistryClientDependencies);
18
+ private authenticatedFetch;
19
+ private json;
20
+ listInstances(): Promise<RegistryInstance[]>;
21
+ createInstance(input: JsonObject): Promise<RegistryInstance>;
22
+ updateInstance(instanceId: number, input: JsonObject): Promise<RegistryInstance>;
23
+ deleteInstance(instanceId: number): Promise<void>;
24
+ listNamespaces(options?: {
25
+ instance?: string;
26
+ namespace?: string;
27
+ }): Promise<RegistryNamespace[]>;
28
+ resolveNamespace(namespace: string, instance?: string): Promise<RegistryNamespace>;
29
+ createNamespace(instanceId: number, input: JsonObject): Promise<RegistryNamespace>;
30
+ updateNamespace(namespaceId: number, input: JsonObject): Promise<RegistryNamespace>;
31
+ deleteNamespace(namespaceId: number): Promise<void>;
32
+ checkNamespace(namespaceId: number): Promise<JsonObject>;
33
+ listSkills(namespaceId: number, options?: {
34
+ search?: string;
35
+ skillName?: string;
36
+ scope?: "PUBLIC" | "PRIVATE";
37
+ pageNo?: number;
38
+ pageSize?: number;
39
+ }): Promise<JsonObject>;
40
+ getSkill(namespaceId: number, skillName: string): Promise<JsonObject>;
41
+ downloadSkill(namespaceId: number, skillName: string, options?: {
42
+ version?: string;
43
+ label?: string;
44
+ }): Promise<SkillDownload>;
45
+ uploadSkill(namespaceId: number, filePath: string, options?: {
46
+ overwrite?: boolean;
47
+ targetVersion?: string;
48
+ commitMsg?: string;
49
+ uploadAction?: string;
50
+ }): Promise<unknown>;
51
+ skillAction(namespaceId: number, skillName: string, action: "submit" | "publish" | "online" | "offline" | "scope" | "biz-tags" | "delete", body?: Record<string, unknown>): Promise<unknown>;
52
+ listMcpServers(namespaceId: number, options?: {
53
+ search?: string;
54
+ mcpName?: string;
55
+ pageNo?: number;
56
+ pageSize?: number;
57
+ }): Promise<JsonObject>;
58
+ getMcpServer(namespaceId: number, mcpName: string, version?: string): Promise<JsonObject>;
59
+ createMcpServer(namespaceId: number, specification: JsonObject): Promise<unknown>;
60
+ updateMcpServer(namespaceId: number, mcpName: string, specification: JsonObject): Promise<unknown>;
61
+ deleteMcpServer(namespaceId: number, mcpName: string, version?: string): Promise<unknown>;
62
+ private object;
63
+ private instanceResponse;
64
+ private namespaceResponse;
65
+ }
@@ -0,0 +1,260 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { resolveBackendUrl } from "../backend.js";
5
+ import { ensureAccessToken, fetchWithKeycloakIdentity } from "../oauth.js";
6
+ import { AiRegistryError, redactRegistryText } from "./errors.js";
7
+ import { ApiErrorSchema, JsonObjectSchema, RegistryInstancesSchema, RegistryNamespacesSchema } from "./schemas.js";
8
+ const API_ROOT = "/api/v1/ai-registry";
9
+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
10
+ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
11
+ async function responseBody(response) {
12
+ const text = await response.text();
13
+ if (!text)
14
+ return {};
15
+ try {
16
+ return JSON.parse(text);
17
+ }
18
+ catch {
19
+ return text;
20
+ }
21
+ }
22
+ async function boundedResponseBytes(response, maximum) {
23
+ if (!response.body)
24
+ return Buffer.alloc(0);
25
+ const reader = response.body.getReader();
26
+ const chunks = [];
27
+ let size = 0;
28
+ try {
29
+ while (true) {
30
+ const { done, value } = await reader.read();
31
+ if (done)
32
+ break;
33
+ size += value.byteLength;
34
+ if (size > maximum) {
35
+ await reader.cancel().catch(() => undefined);
36
+ throw new AiRegistryError("Skill archive exceeds 50 MiB.", "ai_registry_download_too_large");
37
+ }
38
+ chunks.push(Buffer.from(value));
39
+ }
40
+ }
41
+ finally {
42
+ reader.releaseLock();
43
+ }
44
+ return Buffer.concat(chunks, size);
45
+ }
46
+ function apiError(response, body) {
47
+ const parsed = ApiErrorSchema.safeParse(body);
48
+ const message = parsed.success
49
+ ? parsed.data.message ?? response.statusText
50
+ : typeof body === "string"
51
+ ? body
52
+ : response.statusText;
53
+ return new AiRegistryError(`AI Registry HTTP ${response.status}: ${redactRegistryText(message || "request failed")}`, parsed.success ? parsed.data.code ?? "ai_registry_api_error" : "ai_registry_api_error", response.status, parsed.success ? parsed.data.requestId : response.headers.get("x-request-id") ?? undefined);
54
+ }
55
+ function safeIdentifier(value, label) {
56
+ if (!value || value.length > 160 || /[\\/?#%\u0000-\u001f\u007f]/u.test(value)) {
57
+ throw new AiRegistryError(`${label} is invalid.`, "ai_registry_input_invalid");
58
+ }
59
+ return value;
60
+ }
61
+ export class AiRegistryClient {
62
+ config;
63
+ fetchImpl;
64
+ tokenProvider;
65
+ constructor(config, dependencies = {}) {
66
+ this.config = config;
67
+ this.fetchImpl = dependencies.fetch ?? fetch;
68
+ this.tokenProvider = dependencies.tokenProvider ?? ensureAccessToken;
69
+ }
70
+ async authenticatedFetch(path, init = {}) {
71
+ const url = resolveBackendUrl(this.config, { method: "GET", path });
72
+ const headers = new Headers(init.headers);
73
+ if (!headers.has("Accept"))
74
+ headers.set("Accept", "application/json");
75
+ return fetchWithKeycloakIdentity(this.config, url, () => ({ ...init, headers }), { fetch: this.fetchImpl, tokenProvider: this.tokenProvider });
76
+ }
77
+ async json(path, init = {}) {
78
+ const headers = new Headers(init.headers);
79
+ if (init.body != null && typeof init.body === "string")
80
+ headers.set("Content-Type", "application/json");
81
+ const response = await this.authenticatedFetch(path, { ...init, headers });
82
+ const body = await responseBody(response);
83
+ if (!response.ok)
84
+ throw apiError(response, body);
85
+ return body;
86
+ }
87
+ async listInstances() {
88
+ const parsed = RegistryInstancesSchema.safeParse(await this.json(`${API_ROOT}/instances`));
89
+ if (!parsed.success) {
90
+ throw new AiRegistryError(`Instance response is invalid: ${parsed.error.message}`, "ai_registry_invalid_response");
91
+ }
92
+ return parsed.data;
93
+ }
94
+ async createInstance(input) {
95
+ return this.instanceResponse(await this.json(`${API_ROOT}/instances`, { method: "POST", body: JSON.stringify(input) }));
96
+ }
97
+ async updateInstance(instanceId, input) {
98
+ return this.instanceResponse(await this.json(`${API_ROOT}/instances/${instanceId}`, { method: "PUT", body: JSON.stringify(input) }));
99
+ }
100
+ async deleteInstance(instanceId) {
101
+ await this.json(`${API_ROOT}/instances/${instanceId}`, { method: "DELETE" });
102
+ }
103
+ async listNamespaces(options = {}) {
104
+ const query = new URLSearchParams();
105
+ if (options.instance)
106
+ query.set("instance", safeIdentifier(options.instance, "instance"));
107
+ if (options.namespace)
108
+ query.set("namespace", safeIdentifier(options.namespace, "namespace"));
109
+ const suffix = query.size ? `?${query}` : "";
110
+ const parsed = RegistryNamespacesSchema.safeParse(await this.json(`${API_ROOT}/namespaces${suffix}`));
111
+ if (!parsed.success) {
112
+ throw new AiRegistryError(`Namespace response is invalid: ${parsed.error.message}`, "ai_registry_invalid_response");
113
+ }
114
+ return parsed.data;
115
+ }
116
+ async resolveNamespace(namespace, instance) {
117
+ const query = new URLSearchParams({ namespace: safeIdentifier(namespace, "namespace") });
118
+ if (instance)
119
+ query.set("instance", safeIdentifier(instance, "instance"));
120
+ return this.namespaceResponse(await this.json(`${API_ROOT}/namespaces/resolve?${query}`));
121
+ }
122
+ async createNamespace(instanceId, input) {
123
+ return this.namespaceResponse(await this.json(`${API_ROOT}/instances/${instanceId}/namespaces`, {
124
+ method: "POST",
125
+ body: JSON.stringify(input)
126
+ }));
127
+ }
128
+ async updateNamespace(namespaceId, input) {
129
+ return this.namespaceResponse(await this.json(`${API_ROOT}/namespaces/${namespaceId}`, {
130
+ method: "PUT",
131
+ body: JSON.stringify(input)
132
+ }));
133
+ }
134
+ async deleteNamespace(namespaceId) {
135
+ await this.json(`${API_ROOT}/namespaces/${namespaceId}`, { method: "DELETE" });
136
+ }
137
+ async checkNamespace(namespaceId) {
138
+ return this.object(`${API_ROOT}/namespaces/${namespaceId}/health`);
139
+ }
140
+ async listSkills(namespaceId, options = {}) {
141
+ const query = new URLSearchParams();
142
+ if (options.search)
143
+ query.set("search", options.search);
144
+ if (options.skillName)
145
+ query.set("skillName", safeIdentifier(options.skillName, "skillName"));
146
+ if (options.scope)
147
+ query.set("scope", options.scope);
148
+ query.set("pageNo", String(options.pageNo ?? 1));
149
+ query.set("pageSize", String(options.pageSize ?? 20));
150
+ return this.object(`${API_ROOT}/namespaces/${namespaceId}/skills?${query}`);
151
+ }
152
+ async getSkill(namespaceId, skillName) {
153
+ return this.object(`${API_ROOT}/namespaces/${namespaceId}/skills/${encodeURIComponent(safeIdentifier(skillName, "skillName"))}`);
154
+ }
155
+ async downloadSkill(namespaceId, skillName, options = {}) {
156
+ if (options.version && options.label) {
157
+ throw new AiRegistryError("Specify version or label, not both.", "ai_registry_input_invalid");
158
+ }
159
+ const query = new URLSearchParams();
160
+ if (options.version)
161
+ query.set("version", options.version);
162
+ if (options.label)
163
+ query.set("label", options.label);
164
+ const suffix = query.size ? `?${query}` : "";
165
+ const response = await this.authenticatedFetch(`${API_ROOT}/namespaces/${namespaceId}/skills/${encodeURIComponent(safeIdentifier(skillName, "skillName"))}/download${suffix}`, { headers: { Accept: "application/zip" } });
166
+ if (!response.ok)
167
+ throw apiError(response, await responseBody(response));
168
+ const lengthHeader = response.headers.get("content-length");
169
+ const declaredLength = lengthHeader == null ? undefined : Number(lengthHeader);
170
+ if (declaredLength != null && (!Number.isSafeInteger(declaredLength) || declaredLength < 0 || declaredLength > MAX_DOWNLOAD_BYTES)) {
171
+ throw new AiRegistryError("Skill archive exceeds 50 MiB.", "ai_registry_download_too_large");
172
+ }
173
+ const bytes = await boundedResponseBytes(response, MAX_DOWNLOAD_BYTES);
174
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
175
+ const expected = response.headers.get("x-content-sha256")?.toLowerCase();
176
+ if (!expected || !/^[a-f0-9]{64}$/u.test(expected) || expected !== sha256) {
177
+ throw new AiRegistryError("Skill archive SHA-256 verification failed.", "ai_registry_integrity_failed");
178
+ }
179
+ const disposition = response.headers.get("content-disposition") ?? "";
180
+ const matched = /filename="?([^";]+)"?/iu.exec(disposition)?.[1];
181
+ const fileName = matched && matched === basename(matched) ? matched : `${safeIdentifier(skillName, "skillName")}.zip`;
182
+ return { bytes, fileName, sha256 };
183
+ }
184
+ async uploadSkill(namespaceId, filePath, options = {}) {
185
+ const info = await stat(filePath);
186
+ if (!info.isFile() || info.size < 1 || info.size > MAX_UPLOAD_BYTES) {
187
+ throw new AiRegistryError("Skill ZIP must be a file between 1 byte and 20 MiB.", "ai_registry_upload_invalid");
188
+ }
189
+ const bytes = await readFile(filePath);
190
+ const form = new FormData();
191
+ form.append("file", new Blob([bytes], { type: "application/zip" }), basename(filePath));
192
+ const query = new URLSearchParams({ overwrite: String(Boolean(options.overwrite)) });
193
+ if (options.targetVersion)
194
+ query.set("targetVersion", options.targetVersion);
195
+ if (options.commitMsg)
196
+ query.set("commitMsg", options.commitMsg);
197
+ if (options.uploadAction)
198
+ query.set("uploadAction", options.uploadAction);
199
+ return this.json(`${API_ROOT}/namespaces/${namespaceId}/skills/upload?${query}`, { method: "POST", body: form });
200
+ }
201
+ async skillAction(namespaceId, skillName, action, body = {}) {
202
+ const path = `${API_ROOT}/namespaces/${namespaceId}/skills/${encodeURIComponent(safeIdentifier(skillName, "skillName"))}`;
203
+ if (action === "delete")
204
+ return this.json(path, { method: "DELETE" });
205
+ const method = action === "scope" || action === "biz-tags" ? "PUT" : "POST";
206
+ return this.json(`${path}/${action}`, { method, body: JSON.stringify(body) });
207
+ }
208
+ async listMcpServers(namespaceId, options = {}) {
209
+ const query = new URLSearchParams();
210
+ if (options.search)
211
+ query.set("search", options.search);
212
+ if (options.mcpName)
213
+ query.set("mcpName", safeIdentifier(options.mcpName, "mcpName"));
214
+ query.set("pageNo", String(options.pageNo ?? 1));
215
+ query.set("pageSize", String(options.pageSize ?? 20));
216
+ return this.object(`${API_ROOT}/namespaces/${namespaceId}/mcp-servers?${query}`);
217
+ }
218
+ async getMcpServer(namespaceId, mcpName, version) {
219
+ const query = new URLSearchParams();
220
+ if (version)
221
+ query.set("version", version);
222
+ const suffix = query.size ? `?${query}` : "";
223
+ return this.object(`${API_ROOT}/namespaces/${namespaceId}/mcp-servers/${encodeURIComponent(safeIdentifier(mcpName, "mcpName"))}${suffix}`);
224
+ }
225
+ async createMcpServer(namespaceId, specification) {
226
+ return this.json(`${API_ROOT}/namespaces/${namespaceId}/mcp-servers`, { method: "POST", body: JSON.stringify(specification) });
227
+ }
228
+ async updateMcpServer(namespaceId, mcpName, specification) {
229
+ return this.json(`${API_ROOT}/namespaces/${namespaceId}/mcp-servers/${encodeURIComponent(safeIdentifier(mcpName, "mcpName"))}`, { method: "PUT", body: JSON.stringify(specification) });
230
+ }
231
+ async deleteMcpServer(namespaceId, mcpName, version) {
232
+ const query = new URLSearchParams();
233
+ if (version)
234
+ query.set("version", version);
235
+ const suffix = query.size ? `?${query}` : "";
236
+ return this.json(`${API_ROOT}/namespaces/${namespaceId}/mcp-servers/${encodeURIComponent(safeIdentifier(mcpName, "mcpName"))}${suffix}`, { method: "DELETE" });
237
+ }
238
+ async object(path) {
239
+ const parsed = JsonObjectSchema.safeParse(await this.json(path));
240
+ if (!parsed.success) {
241
+ throw new AiRegistryError(`Registry response is invalid: ${parsed.error.message}`, "ai_registry_invalid_response");
242
+ }
243
+ return parsed.data;
244
+ }
245
+ instanceResponse(value) {
246
+ const parsed = RegistryInstancesSchema.element.safeParse(value);
247
+ if (!parsed.success) {
248
+ throw new AiRegistryError(`Instance response is invalid: ${parsed.error.message}`, "ai_registry_invalid_response");
249
+ }
250
+ return parsed.data;
251
+ }
252
+ namespaceResponse(value) {
253
+ const parsed = RegistryNamespacesSchema.element.safeParse(value);
254
+ if (!parsed.success) {
255
+ throw new AiRegistryError(`Namespace response is invalid: ${parsed.error.message}`, "ai_registry_invalid_response");
256
+ }
257
+ return parsed.data;
258
+ }
259
+ }
260
+ //# sourceMappingURL=client.js.map