@numa-tech/numa 1.20.0 → 1.20.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 (107) hide show
  1. package/README.md +31 -2
  2. package/dist/ai-registry/bridge.js +3 -2
  3. package/dist/ai-registry/bridge.js.map +1 -1
  4. package/dist/ai-registry/client.d.ts +8 -1
  5. package/dist/ai-registry/client.js +11 -1
  6. package/dist/ai-registry/client.js.map +1 -1
  7. package/dist/ai-registry/commands.d.ts +3 -0
  8. package/dist/ai-registry/commands.js +48 -36
  9. package/dist/ai-registry/commands.js.map +1 -1
  10. package/dist/ai-registry/mcp-management.d.ts +2 -0
  11. package/dist/ai-registry/mcp-management.js +74 -0
  12. package/dist/ai-registry/mcp-management.js.map +1 -0
  13. package/dist/ai-registry/mcp-runtime-commands.d.ts +15 -0
  14. package/dist/ai-registry/mcp-runtime-commands.js +101 -0
  15. package/dist/ai-registry/mcp-runtime-commands.js.map +1 -0
  16. package/dist/ai-registry/mcp-runtime.d.ts +37 -0
  17. package/dist/ai-registry/mcp-runtime.js +134 -0
  18. package/dist/ai-registry/mcp-runtime.js.map +1 -0
  19. package/dist/ai-registry/mcp-stdio.d.ts +2 -0
  20. package/dist/ai-registry/mcp-stdio.js +18 -0
  21. package/dist/ai-registry/mcp-stdio.js.map +1 -0
  22. package/dist/authorization/application-client.d.ts +1271 -0
  23. package/dist/authorization/application-client.js +391 -0
  24. package/dist/authorization/application-client.js.map +1 -0
  25. package/dist/authorization/application-commands.d.ts +15 -0
  26. package/dist/authorization/application-commands.js +240 -0
  27. package/dist/authorization/application-commands.js.map +1 -0
  28. package/dist/authorization/application-schemas.d.ts +731 -0
  29. package/dist/authorization/application-schemas.js +521 -0
  30. package/dist/authorization/application-schemas.js.map +1 -0
  31. package/dist/authorization/{client.d.ts → bootstrap-client.d.ts} +5 -26
  32. package/dist/authorization/{client.js → bootstrap-client.js} +4 -27
  33. package/dist/authorization/bootstrap-client.js.map +1 -0
  34. package/dist/authorization/commands.d.ts +6 -21
  35. package/dist/authorization/commands.js +8 -130
  36. package/dist/authorization/commands.js.map +1 -1
  37. package/dist/authorization/enrollment-client.d.ts +40 -0
  38. package/dist/authorization/enrollment-client.js +56 -0
  39. package/dist/authorization/enrollment-client.js.map +1 -0
  40. package/dist/authorization/enrollment-commands.d.ts +5 -0
  41. package/dist/authorization/enrollment-commands.js +30 -0
  42. package/dist/authorization/enrollment-commands.js.map +1 -0
  43. package/dist/authorization/enrollment.d.ts +2 -2
  44. package/dist/authorization/enrollment.js +2 -2
  45. package/dist/authorization/enrollment.js.map +1 -1
  46. package/dist/authorization/schemas.d.ts +0 -90
  47. package/dist/authorization/schemas.js +0 -48
  48. package/dist/authorization/schemas.js.map +1 -1
  49. package/dist/backend.d.ts +1 -0
  50. package/dist/backend.js +4 -1
  51. package/dist/backend.js.map +1 -1
  52. package/dist/business-issuer.d.ts +4 -0
  53. package/dist/business-issuer.js +30 -0
  54. package/dist/business-issuer.js.map +1 -0
  55. package/dist/cli.js +61 -30
  56. package/dist/cli.js.map +1 -1
  57. package/dist/cloud/bootstrap.js +1 -1
  58. package/dist/cloud/bootstrap.js.map +1 -1
  59. package/dist/command-catalog.js +11 -36
  60. package/dist/command-catalog.js.map +1 -1
  61. package/dist/config.d.ts +1 -1
  62. package/dist/config.js +15 -6
  63. package/dist/config.js.map +1 -1
  64. package/dist/login-configuration.d.ts +8 -0
  65. package/dist/login-configuration.js +17 -0
  66. package/dist/login-configuration.js.map +1 -0
  67. package/dist/login-recovery.d.ts +2 -0
  68. package/dist/login-recovery.js +15 -0
  69. package/dist/login-recovery.js.map +1 -0
  70. package/dist/mcp/commands.js +12 -2
  71. package/dist/mcp/commands.js.map +1 -1
  72. package/dist/mcp-gateway/commands.d.ts +2 -0
  73. package/dist/mcp-gateway/commands.js +11 -0
  74. package/dist/mcp-gateway/commands.js.map +1 -1
  75. package/dist/oss/commands.js +15 -15
  76. package/dist/oss/commands.js.map +1 -1
  77. package/dist/oss/file-operations.d.ts +11 -1
  78. package/dist/oss/file-operations.js +61 -5
  79. package/dist/oss/file-operations.js.map +1 -1
  80. package/dist/oss/tools.js +1 -1
  81. package/dist/oss/tools.js.map +1 -1
  82. package/dist/oss/web-page.js +58 -14
  83. package/dist/oss/web-page.js.map +1 -1
  84. package/dist/oss/web.js +1 -1
  85. package/dist/oss/web.js.map +1 -1
  86. package/dist/platform-profile.d.ts +22 -2
  87. package/dist/platform-profile.js +67 -6
  88. package/dist/platform-profile.js.map +1 -1
  89. package/docs/application-default-groups.md +73 -0
  90. package/examples/cloud-bootstrap/README.md +1 -1
  91. package/examples/mcp/README.md +108 -0
  92. package/examples/mcp/management-smoke.py +154 -0
  93. package/examples/mcp/runtime-smoke.mjs +187 -0
  94. package/examples/mcp/server.json +17 -0
  95. package/examples/oss/README.md +78 -0
  96. package/examples/oss/__pycache__/roundtrip.cpython-314.pyc +0 -0
  97. package/examples/oss/__pycache__/test_roundtrip.cpython-314.pyc +0 -0
  98. package/examples/oss/roundtrip.py +84 -0
  99. package/examples/oss/test_roundtrip.py +66 -0
  100. package/package.json +5 -4
  101. package/dist/authorization/client.js.map +0 -1
  102. package/dist/authorization/group-client.d.ts +0 -169
  103. package/dist/authorization/group-client.js +0 -129
  104. package/dist/authorization/group-client.js.map +0 -1
  105. package/dist/authorization/group-commands.d.ts +0 -25
  106. package/dist/authorization/group-commands.js +0 -155
  107. package/dist/authorization/group-commands.js.map +0 -1
@@ -0,0 +1,73 @@
1
+ # 应用默认组与 CLI
2
+
3
+ 这些命令需要部署支持应用受管组 API 的 DevOps 后端;安装旧 CLI 或旧后端不会自动具备该能力。不会修改 Keycloak realm 的 Default roles / Default groups。
4
+
5
+ ## 普通用户
6
+
7
+ ```sh
8
+ numa login --no-console --no-config
9
+ numa authorization enrollment status --json
10
+ ```
11
+
12
+ 用户完成 `numa login`、本地网页控制台登录或 MCP `numa_auth_complete_login` 并保存本机会话后,会显式调用 `POST /api/v1/me/enrollment`。服务端根据可信登录 Client 的默认组策略同步成员。机器身份不参加人员自动入组;组黑名单以及历史 SUSPENDED / REVOKED 限制不会因登录被重新激活。登录过程固定使用发起时的配置,核对回调和当前会话 subject,不把其他登录 Client 或服务的缓存当作本次身份。
13
+
14
+ `numa auth status`、profile、命令列表和 `authorization enrollment status` 不触发入组。已有会话可显式初始化,不必人工查询 subject:
15
+
16
+ ```sh
17
+ numa authorization enrollment initialize --json
18
+ ```
19
+
20
+ 登录成功和默认组同步是不同结果。同步失败/未知/待处理不会伪报已授权;黑名单显示 BLOCKED,策略未启用显示 DISABLED。组同步完成后,按服务端 `tokenRefreshRequired` 最多强制刷新本会话一次,再通过 GET enrollment 只读回读。只在 Client、组、策略版本一致,且服务端确认当前令牌已满足角色要求时返回 READY。新后端提供 `expectedClientRoles` 时还按精确 Client 校验动态角色;兼容旧后端回读契约,不依赖三个固定角色。
21
+
22
+ 默认组角色由平台管理,不预设为 Notion、Skills、OSS 三个名称。READY 只证明入组及当前会话核验完成,不证明每项业务均有权限;仍需要有效 API 角色功能授权、Notion 内容 Grant、OSS Grant 和 Skills namespace 策略。CLI 不自动恢复停用角色、给组增加角色或修改空间策略。
23
+
24
+ ## 管理员:应用草稿与统一发布
25
+
26
+ 管理命令统一为 `numa authorization application --app <positive-id>`,要求平台 Client Role `mci-devops-platform-ops-admin`。只读目录不创建用户;没有用户密码或账户管理命令。所有 JSON 输出为 `{ok:true,data:...}`,保留服务端字段和分页事实;分页使用 `--page`、`--size`(1–100)及可用时的 `--query`,不自动抓取全部数据。
27
+
28
+ 生产操作前用 `numa auth status --json` 和 `numa config --show` 确认 PRD。固定生产前端 `https://mci-devops-platform.mcisaas.com`,issuer `https://kc.mcisaas.com/auth/realms/numa-realm`,浏览器 Client `web-client`,CLI Client `mcp-client`,角色 Client `mci-devops-platform`,API `https://apps-gw-prd.mcisaas.com/mci-devops-platform`。应用的 `--env` 选择已登记的精确身份源;不能传任意 issuer URL,也不能借用浏览器会话。
29
+
30
+ ```sh
31
+ numa authorization application --app 42 identity sources --json
32
+ numa authorization application --app 42 definition show --json
33
+ numa authorization application --app 42 definition save --file definition.json --yes --json
34
+ numa authorization application --app 42 definition freeze --expected-draft-version 3 --yes --json
35
+ numa authorization application --app 42 environment list --page 1 --size 20 --json
36
+ numa authorization application --app 42 identity directory users --env <env-uuid> --query name@example.com --json
37
+ numa authorization application --app 42 identity client show <reference-uuid> --env <env-uuid> --json
38
+ numa authorization application --app 42 identity group registered <definition-uuid> --env <env-uuid> --json
39
+ numa authorization application --app 42 identity group blacklist <reference-uuid> --env <env-uuid> --instance <instance-uuid> --json
40
+ numa authorization application --app 42 identity relations save --env <env-uuid> --file relations.json --yes --json
41
+ numa authorization application --app 42 publication plan --file plan.json --yes --json
42
+ numa authorization application --app 42 publication prepare <job-uuid> --expected-version 0 --expected-digest <reviewed-plan-digest> --expected-step-digest <reviewed-step-digest> --yes --json
43
+ numa authorization application --app 42 publication apply <job-uuid> --expected-version 1 --expected-digest <reviewed-plan-digest> --expected-step-digest <reviewed-step-digest> --yes --json
44
+ ```
45
+
46
+ 文件只能包含对应 API 字段,不能把 app/env/file/yes 等控制参数塞入 body。定义保存 `{expectedDraftVersion,defaultRoleClientId,definition}`,完整保留 functions、业务授权、PAGE/API 等文档字段。Client/Group create/save 文件要求 mode、environmentId、全部四个 expected*Version 和对应的配置/需求字段;版本 0 表示实体不存在,不能用作忽略冲突。环境 create/update 保存平台配置,create 的 version 必须是 0。
47
+
48
+ relations 文件为 `{expectedRevision,changes}`。支持 ROLE_GRANT、GROUP_MEMBER、GROUP_BLOCK、BLOCK_SOURCE_RELEASE;USER 使用目录返回的稳定 subjectId,SERVICE_ACCOUNT 使用 clientInstanceId。GROUP_BLOCK ABSENT 仅撤销本应用的阻断贡献。释放历史来源必须独立提交 BLOCK_SOURCE_RELEASE、USER、ABSENT、subjectId、groupInstanceId、releaseSource(ADMIN_BLOCK 或 LEGACY_REMOVE)、expectedBlockVersion 及 scoped blacklist 返回的 expectedBlockGeneration;不会移除其他来源阻断。
49
+
50
+ LOGIN Client 的 enrollmentTrusted 和 expectedEnrollmentTrustVersion 必须同时填写;这是待发布意图,实际 admission 要等统一发布的激活与验证,不能把草稿当作已生效。共享定义的 configurationReadOnly 与精确 registered hydration 必须遵守,不把目录远端配置自动写回共享定义。
51
+
52
+ plan 文件为 `{requestId,definitionVersion,environmentIds,clientModes?}`,requestId 是新 UUID,clientModes 按 instance UUID 映射 CREATE/ADOPT/UPDATE/REPAIR。每次 prepare/apply 都只执行一个阶段,确认必须来自同一 reviewed response,不自动拉取新版本接受冲突。UNKNOWN 时显式 recheck,保留协作问题、receipt、历史、allowedActions 和 finalization;需要继续时另行确认 apply。terminate/cancel 不是回滚,repreview 需要新的 requestId,没有 resume/force 命令。
53
+
54
+ 完整命令可通过 `numa authorization application --app 42 --help` 和 `numa commands --json` 查看。registered、versions、history、members、blacklist、catalog、subject-access 均为当前应用/来源限定的只读视图;不使用旧 registry HTTP 路径。网关绑定历史使用 `gateway-binding history <binding-uuid>`,环境历史使用 `environment runs <env-uuid>`。
55
+
56
+ ## 凭证只写入文件
57
+
58
+ ```sh
59
+ numa authorization application --app 42 credential target --env <env-uuid> --reference <ref-uuid> --instance <instance-uuid> --json
60
+ numa authorization application --app 42 credential download --env <env-uuid> --reference <ref-uuid> --instance <instance-uuid> --request-id <new-uuid> --expected-instance-version 3 --expected-context-digest <reviewed-digest> --output ./credential.json --yes --json
61
+ ```
62
+
63
+ rotate 还需完整 `--confirm-client-id` 与 `--acknowledge-impact`。必须显式提供新的 `--output` 文件,拒绝 stdout 和覆盖已有文件;先排他创建 0600 文件,再发送一次请求,只在附件完整落盘后输出 requestId/outputPath。Secret 不进入 JSON、日志或异常。全部写请求包括 recheck 和附件都禁止 401 刷新重放、重定向及自动重试;本地写盘失败也不能重领同一个请求。
64
+
65
+ 轮换 A UNKNOWN 后,使用新请求下载 B 恢复当前凭证,再以新请求进行明确轮换 C;不需要等待 A 父作业 ACK。operation/recheck 仅查事实,不重新领取附件。operationRecovered 仅表示历史恢复证据,不代表当前目标凭证 READY。CREDENTIAL_ROTATION 是单阶段窄作业,与完整 IDENTITY_PUBLICATION 图分别解码。
66
+
67
+ ## 破坏性升级与发布
68
+
69
+ 旧 `authorization role list/create/provision/provisioning-run` 和 `authorization group list/members/users/directory/plan/status/items/impact/apply` 已删除,没有兼容 alias 或旧 HTTP fallback。enrollment、access-request、bootstrap 和独立业务 API 保留。使用既有 lockstep release 流程同时发布工作区包;不能只单独升级一个包。发布前执行 `npm run build` 和 `npm run check`,包括打包边界与编译后 enrollment 烟测。本文不表示已经发布或完成运行时验收。
70
+
71
+ Skills/MCP 业务新路径为 `/api/v1/skills`;实例管理和 namespace 写操作为 `/api/v1/admin/nacos/registry`。CLI 不在 401/403 或写超时后退回旧路径重发。发布顺序为:后端兼容 alias 和新路径 → 网关方法映射及权限发布 → CLI 包发布。旧 CLI 继续使用后端保留的 `/api/v1/ai-registry` alias;服务 profile 中的显式 capabilityUrl 仍予以兼容。
72
+
73
+ Codex 模块自己的登录使用独立服务 audience 与 token cache,不把该服务令牌转发给 DevOps enrollment;其应用入组应在该服务的可信登录回调完成。这里的自动入组只使用当前明确的 DevOps API 配置。
@@ -3,7 +3,7 @@
3
3
  发布包入口为 `npx -y @numa-tech/numa@latest`,下文用 `numa` 简写。
4
4
 
5
5
  ```sh
6
- numa login --no-console
6
+ numa login --platform --no-console
7
7
  numa cloud bootstrap run --account-uid 1960540977999327
8
8
  ```
9
9
 
@@ -0,0 +1,108 @@
1
+ # Numa MCP:管理、测试调用与本地代理
2
+
3
+ 使用本仓库构建产物,无需安装或发布 npm:
4
+
5
+ ```sh
6
+ npm run root:build
7
+ node dist/cli.js registry capabilities --json
8
+ node dist/cli.js mcp --help
9
+ ```
10
+
11
+ MCP 注册管理与 Skills 共用 `aiRegistry` profile 服务、Keycloak 登录缓存、namespace 选择和平台 API `/api/v1/skills`。namespace 优先级仍为命令参数、项目 `.numa/registry.json`、用户默认值、profile 默认值。同名 namespace 使用 `--instance` 消歧。Nacos 管理凭据始终保留在平台。
12
+
13
+ ## 管理注册记录
14
+
15
+ 下面的 `team-ai` 是占位 namespace,需要替换成 capabilities 中当前身份可见的 namespace。`server.json` 为本地、禁用的示例定义;提交前修改名称与实际服务地址。文件使用平台请求 envelope,`serverSpecification` 等字段为 JSON 对象;最多 1 MiB。凭据只允许 `${MCP_TOKEN}` 这样的占位符。
16
+
17
+ ```sh
18
+ node dist/cli.js mcp list --namespace team-ai --name cli-local-example --json
19
+ node dist/cli.js mcp create examples/mcp/server.json --namespace team-ai --json
20
+ node dist/cli.js mcp inspect cli-local-example --namespace team-ai --version 1.0.0 --json
21
+ # 修改 JSON 文件后更新同名服务
22
+ node dist/cli.js mcp update cli-local-example examples/mcp/server.json --namespace team-ai --json
23
+ node dist/cli.js mcp tool disable cli-local-example echo --namespace team-ai --version 1.0.0 --json
24
+ node dist/cli.js mcp delete cli-local-example --namespace team-ai --yes --json
25
+ ```
26
+
27
+ 工具开关的 `--version` 是 inspect 返回的 Nacos 服务版本(例如 `1.0.0`),不是整数修订号。更新和删除会先查询当前目标;服务端仍执行授权与版本校验。
28
+
29
+ | 操作 | capability 预检 | 服务端要求 |
30
+ |---|---|---|
31
+ | list、inspect | `read` | namespace 可读 |
32
+ | create、update、tags、tool enable/disable | `mcp:maintain` | namespace 维护权限 |
33
+ | online、offline | `release:publish` | 审核权限且版本已发布 |
34
+ | delete | `namespace:admin` | 平台 ops-admin |
35
+
36
+ `online`、`offline` 要求精确版本和已发布的审批记录,通过官方 Nacos MCP 更新接口修改 `enabled`,保留工具定义、endpoint 引用和 latest 指针,并读回验证。当前 Nacos 3.2 MCP Admin API 与服务 schema 不支持业务标签;`tags` 兼容入口明确返回 `AI_REGISTRY_MCP_TAGS_UNSUPPORTED`,不会假装保存成功。未发布版本不能通过 `online` 绕过发布流程。现有发布状态机将下线记录置为 `OFFLINE`;下线后恢复需要创建新版本并重新审核发布,不能直接再次上线原版本。注册记录本身也不会自动创建网关绑定、授予工具权限或保存后端密钥。
37
+
38
+ ## 测试工具调用
39
+
40
+ 针对已经完成网关绑定及统一授权的服务:
41
+
42
+ ```sh
43
+ node dist/cli.js mcp tools catalog-mcp --registry --namespace team-ai --json
44
+ node dist/cli.js mcp call catalog-mcp search_products --registry --namespace team-ai --args '{"query":"demo"}' --json
45
+ # 敏感参数优先使用权限受限的本地 JSON 文件
46
+ node dist/cli.js mcp call catalog-mcp search_products --registry --namespace team-ai --args-file ./arguments.json --json
47
+ ```
48
+
49
+ `--registry` 使用平台 Registry 的受治理入口;不指定时优先使用 Profile 网关。`--timeout` 统一以秒为单位(1–600,默认 60)。
50
+
51
+ `tools` 若返回 `nextCursor`,用 `--cursor` 查询下一页。`call` 的 `--args` 与 `--args-file` 互斥,参数必须是 JSON 对象,最多 1 MiB。工具返回 `isError` 时 CLI 以非零状态退出。
52
+
53
+ 调用使用平台下发的 `/mcp-connections` 与当前身份访问受治理网关。CLI 不直连注册定义中的 `backendEndpoints`,不获取 Nacos 管理凭据或后端服务密钥;具体工具权限由网关和 Keycloak 判定。调用跟随网关绑定的在线版本,因此 `tools`、`call`、`proxy` 没有 `--version` 参数。
54
+
55
+ ## 本地 stdio MCP
56
+
57
+ 将单个远端服务转换成宿主可启动的本地 MCP 进程:
58
+
59
+ ```sh
60
+ node dist/cli.js mcp proxy catalog-mcp --namespace team-ai
61
+ ```
62
+
63
+ 宿主 MCP 配置示例(替换绝对路径和 namespace):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "catalog": {
69
+ "command": "node",
70
+ "args": ["/absolute/path/to/numa/dist/cli.js", "mcp", "proxy", "catalog-mcp", "--namespace", "team-ai"]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ 先在该环境完成 `node dist/cli.js login`。代理复用缓存并按需刷新凭据;stdout 仅传输协议帧,错误写到 stderr。能力协商、通知、resources、prompts 等协议消息透传;EOF 或终止信号会关闭远端 session。原有 `mcp bridge` 仍是 namespace Router 聚合入口,`mcp install` 保留现有客户端安装流程。
77
+
78
+ ## 本地管理烟测
79
+
80
+ `management-smoke.py` 创建随机名称的禁用服务,验证查询、版本详情、更新读回、工具开关、删除读回、未发布上线拒绝、不支持标签的明确错误及只读身份越权拒绝,并清理自己创建的记录。默认不会上线服务或创建网关授权。传入 `--submitter-env-file` 时增加真实的两人发布流程:Registry 维护身份提交,admin 审核和发布,核查线上与下线状态;始终不创建网关授权,最后删除自建注册记录,平台审计与发布历史保留。
81
+
82
+ 统一 MVP runner 将两套独立的私有 CLI 环境写成 JSON 文件(键和值为环境变量字符串),包括各自缓存/profile 和明确的 loopback `NUMA_BACKEND_BASE_URL`。文件不可提交,脚本不输出其内容:
83
+
84
+ ```sh
85
+ python3 examples/mcp/management-smoke.py \
86
+ --namespace team-ai --instance local-mvp \
87
+ --admin-env-file /private/path/admin-env.json \
88
+ --reader-env-file /private/path/reader-env.json \
89
+ --submitter-env-file /private/path/maintainer-env.json
90
+ ```
91
+
92
+ 正式环境不要运行该烟测;脚本要求两套环境的 API 地址都显式指向 loopback。权限拒绝测试与 CRUD 都通过 CLI 调用平台,不直接访问 Nacos Admin API。
93
+
94
+ 协议依据:[Nacos MCP 管理 API](https://nacos.io/docs/latest/manual/admin/admin-api/#4-mcp-%E7%AE%A1%E7%90%86)、[Nacos 运维 SDK](https://nacos.io/docs/latest/manual/admin/maintainer-sdk/)。
95
+
96
+ 运行态端到端脚本使用普通 reader 身份,校验真实网关工具调用、越权拒绝和本地 stdio:
97
+
98
+ ```sh
99
+ node examples/mcp/runtime-smoke.mjs --extended \
100
+ --namespace mcp-lab-a --instance mcp-keycloak-lab \
101
+ --reader-env-file /private/path/reader-env.json \
102
+ --writer-env-file /private/path/writer-env.json \
103
+ --denied-env-file /private/path/denied-env.json
104
+ ```
105
+
106
+ 该脚本也要求显式 loopback `NUMA_BACKEND_BASE_URL`,默认使用本地 `catalog-mcp` fixture;不要使用 admin 身份执行拒绝验收。
107
+
108
+ 管理烟测的 `--submitter-env-file`(兼容别名 `--writer-env-file`) 必须对应 capabilities 具有 `release:submit` 的 Registry 维护身份。它和运行态烟测中仅能调用写工具的业务 writer 身份不同;不要为通过测试给普通业务调用者追加管理权限。
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise MCP management through local Numa CLI; fixture supplies private environments.
3
+
4
+ No Nacos administrator credentials or direct upstream API calls. The script creates
5
+ one random disabled MCP record and deletes it. It does not grant gateway access.
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ import subprocess
13
+ import tempfile
14
+ import uuid
15
+ from urllib.parse import urlparse
16
+
17
+
18
+ def read_environment(path):
19
+ value = json.loads(Path(path).read_text())
20
+ if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()):
21
+ raise ValueError("Fixture environment must be a JSON object of string values")
22
+ # The fixture owns the private file; never print its values.
23
+ return {**os.environ, **value}
24
+
25
+
26
+ def smoke(cli, namespace, instance, admin_env, reader_env, submitter_env=None):
27
+ for environment in [admin_env, reader_env] + ([submitter_env] if submitter_env else []):
28
+ target = urlparse(environment.get("NUMA_BACKEND_BASE_URL", ""))
29
+ if target.scheme not in ("http", "https") or target.hostname not in ("localhost", "127.0.0.1", "::1"):
30
+ raise RuntimeError("Smoke requires an explicit loopback NUMA_BACKEND_BASE_URL in both fixture environments")
31
+ passed = []
32
+ name = "numa-cli-smoke-" + uuid.uuid4().hex[:12]
33
+ common = ["--namespace", namespace, "--json"] + (["--instance", instance] if instance else [])
34
+ created = False
35
+
36
+ def command(args, env=admin_env, denied=False, expected_code=None, group="mcp", scoped=True):
37
+ result = subprocess.run(["node", str(cli), group, *args, *(common if scoped else ["--json"])], env=env,
38
+ text=True, capture_output=True, timeout=90)
39
+ try:
40
+ payload = json.loads(result.stdout or result.stderr)
41
+ except (ValueError, TypeError):
42
+ raise RuntimeError("CLI did not return structured JSON for " + args[0]) from None
43
+ if expected_code is not None:
44
+ code = (payload.get("error") or {}).get("code", "")
45
+ if result.returncode == 0 or code != expected_code:
46
+ raise RuntimeError("Expected " + expected_code + " for " + args[0] + ", got " + str(code))
47
+ elif denied:
48
+ code = (payload.get("error") or {}).get("code", "")
49
+ if result.returncode == 0 or not any(word in str(code).lower() for word in ("forbidden", "denied", "permission")):
50
+ raise RuntimeError("Expected authorization denial for " + args[0])
51
+ elif result.returncode != 0 or payload.get("ok") is not True:
52
+ code = (payload.get("error") or {}).get("code", "unknown")
53
+ status = re.search(r"HTTP (\d{3})", str((payload.get("error") or {}).get("message", "")))
54
+ raise RuntimeError("CLI " + " ".join(args[:2]) + " failed: " + str(code) + (" HTTP " + status[1] if status else ""))
55
+ return payload
56
+
57
+ with tempfile.TemporaryDirectory(prefix="numa-mcp-smoke-") as directory:
58
+ path = Path(directory) / "server.json"
59
+ spec = json.loads(Path(__file__).with_name("server.json").read_text())
60
+ spec["serverSpecification"]["name"] = name
61
+ path.write_text(json.dumps(spec))
62
+ try:
63
+ command(["create", str(path)], reader_env, denied=True)
64
+ passed.append("reader-create-denied")
65
+ command(["create", str(path)])
66
+ created = True
67
+ passed.append("create-disabled")
68
+ listing = command(["list", "--name", name])["mcpServers"]
69
+ if not any(item.get("name") == name for item in listing.get("pageItems", [])):
70
+ raise RuntimeError("Created MCP record absent from Nacos list")
71
+ passed.append("list")
72
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
73
+ if detail.get("name") != name or detail.get("versionDetail", {}).get("version") != "1.0.0":
74
+ raise RuntimeError("Nacos detail did not preserve name and selected version")
75
+ passed.append("inspect-version")
76
+ if detail.get("enabled") is not False:
77
+ raise RuntimeError("Smoke record was unexpectedly enabled")
78
+ spec["serverSpecification"]["description"] = "Updated CLI smoke record"
79
+ path.write_text(json.dumps(spec))
80
+ command(["update", name, str(path)])
81
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
82
+ if detail.get("description") != "Updated CLI smoke record":
83
+ raise RuntimeError("Nacos update was not visible")
84
+ passed.append("update-readback")
85
+ for operation, expected in (("disable", False), ("enable", True)):
86
+ command(["tool", operation, name, "echo", "--version", "1.0.0"])
87
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
88
+ if detail.get("toolSpec", {}).get("toolsMeta", {}).get("echo", {}).get("enabled") is not expected:
89
+ raise RuntimeError("Nacos tool state was not persisted")
90
+ passed.append("tool-" + operation)
91
+ command(["tags", name, "smoke"], expected_code="AI_REGISTRY_MCP_TAGS_UNSUPPORTED")
92
+ passed.append("tags-unsupported-explicit")
93
+ command(["online", name, "--version", "1.0.0"], expected_code="AI_REGISTRY_RELEASE_REQUEST_REQUIRED")
94
+ passed.append("unpublished-online-denied")
95
+ if submitter_env:
96
+ release = command(["approvals", "submit", "mcp", name, "--resource-version", "1.0.0",
97
+ "--reason", "Local MVP lifecycle verification"], submitter_env, group="registry")["result"]
98
+ passed.append("maintainer-release-submitted")
99
+ reviewed = command(["approvals", "review", release["requestId"], "--decision", "APPROVED",
100
+ "--reason", "Local disabled fixture reviewed", "--version", str(release["version"])],
101
+ group="registry", scoped=False)["result"]
102
+ passed.append("admin-release-reviewed")
103
+ published = command(["approvals", "publish", release["requestId"], "--version", str(reviewed["version"])],
104
+ group="registry", scoped=False)["result"]
105
+ if published.get("state") != "PUBLISHED":
106
+ raise RuntimeError("Release did not reach PUBLISHED")
107
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
108
+ if detail.get("enabled") is not True:
109
+ raise RuntimeError("Publishing did not enable selected MCP version")
110
+ passed.append("admin-publish-enabled-readback")
111
+ command(["online", name, "--version", "1.0.0"])
112
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
113
+ if detail.get("enabled") is not True:
114
+ raise RuntimeError("Online state did not persist")
115
+ passed.append("published-online-readback")
116
+ command(["offline", name, "--version", "1.0.0"])
117
+ detail = command(["inspect", name, "--version", "1.0.0"])["mcpServer"]
118
+ if detail.get("enabled") is not False:
119
+ raise RuntimeError("Offline state did not persist")
120
+ passed.append("offline-disabled-readback")
121
+ command(["delete", name, "--yes"], reader_env, denied=True)
122
+ passed.append("reader-delete-denied")
123
+ command(["delete", name, "--yes"])
124
+ created = False
125
+ listing = command(["list", "--name", name])["mcpServers"]
126
+ if any(item.get("name") == name for item in listing.get("pageItems", [])):
127
+ raise RuntimeError("Deleted MCP record still appears in Nacos list")
128
+ passed.append("delete-readback")
129
+ finally:
130
+ if created:
131
+ command(["delete", name, "--yes"])
132
+ return {"ok": True, "checks": passed, "count": len(passed)}
133
+
134
+
135
+ def main():
136
+ parser = argparse.ArgumentParser(description=__doc__)
137
+ parser.add_argument("--cli", type=Path, default=Path(__file__).resolve().parents[2] / "dist/cli.js")
138
+ parser.add_argument("--namespace", required=True)
139
+ parser.add_argument("--instance")
140
+ parser.add_argument("--admin-env-file", required=True, help="Private JSON environment for local MVP administrator")
141
+ parser.add_argument("--reader-env-file", required=True, help="Private JSON environment for read-only identity")
142
+ parser.add_argument("--submitter-env-file", "--writer-env-file", dest="submitter_env_file", help="Separate Registry maintainer identity for two-person publication lifecycle")
143
+ args = parser.parse_args()
144
+ try:
145
+ print(json.dumps(smoke(args.cli.resolve(), args.namespace, args.instance,
146
+ read_environment(args.admin_env_file), read_environment(args.reader_env_file),
147
+ read_environment(args.submitter_env_file) if args.submitter_env_file else None), indent=2))
148
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
149
+ # Exception messages are deliberately constructed without process output or secrets.
150
+ parser.exit(1, "MCP management smoke failed: " + (str(error) if isinstance(error, RuntimeError) else type(error).__name__) + "\n")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // Local MVP acceptance. Identity/profile/cache are supplied through the normal Numa environment.
3
+ // No credentials are printed or written by this runner.
4
+ import assert from "node:assert/strict";
5
+ import { spawn } from "node:child_process";
6
+ import { parseArgs } from "node:util";
7
+ import { readFile } from "node:fs/promises";
8
+ import { resolve } from "node:path";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
11
+
12
+ const { values } = parseArgs({ options: {
13
+ cli: { type: "string", default: "dist/cli.js" },
14
+ namespace: { type: "string", default: "mcp-lab-a" },
15
+ instance: { type: "string" },
16
+ server: { type: "string", default: "catalog-mcp" },
17
+ tool: { type: "string", default: "search_products" },
18
+ "denied-tool": { type: "string", default: "update_product" },
19
+ "error-tool": { type: "string", default: "search_failure" },
20
+ "reader-env-file": { type: "string" },
21
+ "writer-env-file": { type: "string" },
22
+ "denied-env-file": { type: "string" },
23
+ "other-namespace": { type: "string", default: "mcp-lab-b" },
24
+ extended: { type: "boolean", default: false }
25
+ } });
26
+ async function identityEnvironment(path) {
27
+ const provided = path ? JSON.parse(await readFile(path, "utf8")) : {};
28
+ if (!provided || typeof provided !== "object" || Array.isArray(provided)
29
+ || Object.values(provided).some(value => typeof value !== "string")) throw new Error("Identity environment must be a string map.");
30
+ const environment = { ...process.env, ...provided };
31
+ const base = environment.NUMA_BACKEND_BASE_URL;
32
+ const parsed = base ? new URL(base) : undefined;
33
+ if (!parsed || !["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash
34
+ || !["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) {
35
+ throw new Error("This runner requires NUMA_BACKEND_BASE_URL to explicitly select the local MVP.");
36
+ }
37
+ return environment;
38
+ }
39
+ const readerEnvironment = await identityEnvironment(values["reader-env-file"]);
40
+ const writerEnvironment = values["writer-env-file"] ? await identityEnvironment(values["writer-env-file"]) : undefined;
41
+ const deniedEnvironment = values["denied-env-file"] ? await identityEnvironment(values["denied-env-file"]) : undefined;
42
+ const cli = resolve(values.cli);
43
+ const namespaceOptions = (name = values.namespace) => ["--namespace", name, ...(values.instance ? ["--instance", values.instance] : [])];
44
+ const checks = [];
45
+ let lastOperation;
46
+ async function invoke(args, environment = readerEnvironment, targetNamespace = values.namespace) {
47
+ const child = spawn(process.execPath, [cli, ...args, ...namespaceOptions(targetNamespace), "--json"], {
48
+ env: environment, stdio: ["ignore", "pipe", "pipe"]
49
+ });
50
+ let stdout = "";
51
+ child.stdout.on("data", chunk => { stdout += chunk; });
52
+ child.stderr.resume();
53
+ const timer = setTimeout(() => child.kill("SIGTERM"), 45_000);
54
+ const code = await new Promise((resolveCode, reject) => { child.once("error", reject); child.once("close", resolveCode); });
55
+ clearTimeout(timer);
56
+ let body;
57
+ try { body = JSON.parse(stdout); } catch { throw new Error("CLI did not return valid JSON; inspect local diagnostics."); }
58
+ lastOperation = { command: args.slice(0, 4).join(" "), namespace: targetNamespace, exitCode: code,
59
+ ok: body.ok, errorCode: body.error?.code, httpStatus: body.error?.details?.status ?? body.error?.status };
60
+ return { code, body };
61
+ }
62
+ async function viaProxy(operation, environment = readerEnvironment, server = values.server, targetNamespace = values.namespace) {
63
+ lastOperation = { command: "mcp proxy", server, namespace: targetNamespace };
64
+ const transport = new StdioClientTransport({ command: process.execPath,
65
+ args: [cli, "mcp", "proxy", server, ...namespaceOptions(targetNamespace)],
66
+ env: Object.fromEntries(Object.entries(environment).filter(([, value]) => value !== undefined)), stderr: "pipe" });
67
+ transport.stderr?.resume();
68
+ const client = new Client({ name: "numa-local-mvp-acceptance", version: "1" }, { capabilities: {} });
69
+ try { await client.connect(transport, { timeout: 30_000 }); return await operation(client); }
70
+ finally { await client.close(); }
71
+ }
72
+ try {
73
+ const tools = await invoke(["mcp", "tools", values.server]);
74
+ assert.equal(tools.code, 0);
75
+ assert.equal(tools.body.ok, true);
76
+ const names = tools.body.tools.map(tool => tool.name);
77
+ assert(names.includes(values.tool));
78
+ assert(!names.includes(values["denied-tool"]));
79
+ checks.push("CLI tools uses gateway-filtered list");
80
+
81
+ const call = await invoke(["mcp", "call", values.server, values.tool, "--args", "{}"]);
82
+ assert.equal(call.code, 0);
83
+ assert.equal(call.body.ok, true);
84
+ assert(call.body.result.content?.length > 0);
85
+ checks.push("CLI tool call completes through gateway and upstream");
86
+
87
+ const forbidden = await invoke(["mcp", "call", values.server, values["denied-tool"], "--args", "{}"]);
88
+ assert.notEqual(forbidden.code, 0);
89
+ assert.equal(forbidden.body.ok, false);
90
+ assert.equal(forbidden.body.error.code, "ai_registry_mcp_forbidden");
91
+ checks.push("CLI forbidden tool is denied by gateway");
92
+
93
+ const error = await invoke(["mcp", "call", values.server, values["error-tool"], "--args", "{}"]);
94
+ assert.notEqual(error.code, 0);
95
+ assert.equal(error.body.ok, false);
96
+ assert.equal(error.body.result.isError, true);
97
+ checks.push("CLI preserves executed tool isError and exits nonzero");
98
+
99
+ await viaProxy(async client => {
100
+ assert(client.getServerCapabilities()?.tools);
101
+ const listed = await client.listTools();
102
+ const proxyNames = listed.tools.map(tool => tool.name);
103
+ assert(proxyNames.includes(values.tool));
104
+ assert(!proxyNames.includes(values["denied-tool"]));
105
+ checks.push("stdio proxy negotiates real server and retains filtered tools");
106
+ const result = await client.callTool({ name: values.tool, arguments: {} });
107
+ assert(!result.isError);
108
+ assert(result.content?.length > 0);
109
+ checks.push("stdio MCP client invokes real remote tool via CLI proxy");
110
+ const failed = await client.callTool({ name: values["error-tool"], arguments: {} });
111
+ assert.equal(failed.isError, true);
112
+ checks.push("stdio proxy preserves protocol tool errors");
113
+ });
114
+ let denied = false; let initialized = false;
115
+ try {
116
+ await viaProxy(client => { initialized = true; return client.callTool({ name: values["denied-tool"], arguments: {} }, undefined, { timeout: 15_000 }); });
117
+ } catch { denied = true; }
118
+ assert(initialized && denied);
119
+ checks.push("stdio proxy cannot bypass gateway tool authorization");
120
+ if (values.extended) {
121
+ for (const [server, tool, args, expected] of [
122
+ ["notes-mcp", "list_notes", {}, "credentialVerified"],
123
+ ["stdio-mcp", "calculate_total", { unit_price: 12.5, quantity: 2 }, '"total":25']
124
+ ]) {
125
+ const call = await invoke(["mcp", "call", server, tool, "--args", JSON.stringify(args)]);
126
+ assert.equal(call.code, 0);
127
+ assert.equal(call.body.ok, true);
128
+ const serialized = JSON.stringify(call.body.result).replaceAll(/\s/gu, "").replaceAll('\\"', '"');
129
+ assert(serialized.includes(expected));
130
+ checks.push(`CLI reaches ${server} real upstream adapter`);
131
+ await viaProxy(async client => {
132
+ const tools = await client.listTools();
133
+ assert(tools.tools.some(item => item.name === tool));
134
+ const result = await client.callTool({ name: tool, arguments: args });
135
+ assert(!result.isError);
136
+ const serialized = JSON.stringify(result).replaceAll(/\s/gu, "").replaceAll('\\"', '"');
137
+ assert(serialized.includes(expected));
138
+ }, readerEnvironment, server);
139
+ checks.push(`local stdio proxy reaches ${server} real upstream adapter`);
140
+ }
141
+ await viaProxy(async client => {
142
+ const first = await client.listTools();
143
+ const second = await client.callTool({ name: values.tool, arguments: {} });
144
+ assert(first.tools.some(tool => tool.name === values.tool));
145
+ assert(!second.isError);
146
+ }, readerEnvironment, "session-mcp");
147
+ checks.push("stdio proxy retains stateful upstream session across requests");
148
+ }
149
+ if (writerEnvironment) {
150
+ const write = await invoke(["mcp", "call", values.server, values["denied-tool"], "--args", '{"name":"numa-local-acceptance"}'], writerEnvironment);
151
+ assert.equal(write.code, 0);
152
+ assert.equal(write.body.ok, true);
153
+ checks.push("writer CLI executes authorized fixture write");
154
+ await viaProxy(async client => {
155
+ const tools = await client.listTools();
156
+ assert(tools.tools.some(tool => tool.name === values["denied-tool"]));
157
+ const written = await client.callTool({ name: values["denied-tool"], arguments: { name: "numa-stdio-acceptance" } });
158
+ assert(!written.isError);
159
+ }, writerEnvironment);
160
+ checks.push("writer stdio proxy executes authorized fixture write");
161
+ const unclassified = await invoke(["mcp", "call", values.server, "unclassified_operation", "--args", "{}"], writerEnvironment);
162
+ assert.notEqual(unclassified.code, 0);
163
+ assert.equal(unclassified.body.error.code, "ai_registry_mcp_forbidden");
164
+ checks.push("writer still cannot invoke unclassified tool");
165
+ const readOther = await invoke(["mcp", "call", values.server, values.tool, "--args", "{}"], readerEnvironment, values["other-namespace"]);
166
+ assert.notEqual(readOther.code, 0);
167
+ assert.equal(readOther.body.ok, false);
168
+ assert.match(readOther.body.error.code, /forbidden/u);
169
+ checks.push("reader cannot cross into another namespace");
170
+ const writerOther = await invoke(["mcp", "call", values.server, values.tool, "--args", "{}"], writerEnvironment, values["other-namespace"]);
171
+ assert.equal(writerOther.code, 0);
172
+ assert.equal(writerOther.body.ok, true);
173
+ checks.push("explicitly granted writer can call another namespace");
174
+ }
175
+ if (deniedEnvironment) {
176
+ const denied = await invoke(["mcp", "tools", values.server], deniedEnvironment);
177
+ assert.notEqual(denied.code, 0);
178
+ assert.equal(denied.body.ok, false);
179
+ assert(denied.body.error.details?.status === 403 || /forbidden/u.test(denied.body.error.code));
180
+ checks.push("ungranted identity cannot discover callable server");
181
+ }
182
+ process.stdout.write(JSON.stringify({ ok: true, checks }, null, 2) + "\n");
183
+ } catch {
184
+ process.stderr.write(JSON.stringify({ ok: false, completedChecks: checks, lastOperation,
185
+ message: "MCP runtime acceptance failed; credential-bearing responses were withheld." }, null, 2) + "\n");
186
+ process.exitCode = 1;
187
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "serverSpecification": {
3
+ "name": "cli-local-example",
4
+ "description": "Local CLI registration example; gateway access requires a separate binding and authorization.",
5
+ "protocol": "mcp-streamable",
6
+ "frontProtocol": "mcp-streamable",
7
+ "enabled": false,
8
+ "versionDetail": { "version": "1.0.0" },
9
+ "remoteServerConfig": { "exportPath": "/mcp" }
10
+ },
11
+ "toolSpecification": {
12
+ "tools": [{ "name": "echo", "description": "Example metadata only", "inputSchema": { "type": "object", "properties": {} } }],
13
+ "toolsMeta": {}
14
+ },
15
+ "endpointSpecification": { "type": "DIRECT", "data": { "address": "127.0.0.1", "port": "8080" } },
16
+ "latest": true
17
+ }
@@ -0,0 +1,78 @@
1
+ # OSS CLI list / write / read example
2
+
3
+ Use the local build only. Nothing here publishes to npm or changes a global installation.
4
+
5
+ ```bash
6
+ cd /Users/changhz/source/numa-business-issuers
7
+ npm run build
8
+ # A local binary still reads the global configuration unless this is set.
9
+ export NUMA_CONFIG_DIR="$PWD/tmp/issuer-mvp-cli"
10
+ export NUMA_PROFILE_URL='http://localhost:28080/public/v1/client-configurations/issuer-mvp'
11
+ node dist/cli.js config --set profileUrl="$NUMA_PROFILE_URL" profileId=issuer-mvp apiBaseUrl=http://localhost:28080 checkUpdates=false
12
+ node dist/cli.js login --no-console --no-config
13
+ node dist/cli.js oss grants --json
14
+ # Replace 123 with the exact ACTIVE, synced/verified read/write grant shown above.
15
+ python3 examples/oss/roundtrip.py --grant-id 123
16
+ ```
17
+
18
+ For interactive commands, `./scripts/mvp-cli.sh login --issuer cluster-b
19
+ --no-console --no-config` and `./scripts/mvp-cli.sh oss grants --issuer cluster-b`
20
+ set up the same isolated local directory automatically. They never change
21
+ `~/.config/numa`. Merely setting NUMA_PROFILE_URL with your global configuration
22
+ can still leave its pinned profileId pointing at numa-realm.
23
+
24
+ The local profile must first be created and published in DevOps. Its platform
25
+ issuer is fixed; `login` uses the business default. To explicitly select another
26
+ business issuer, use the same selector for login and the example:
27
+
28
+ ```bash
29
+ node dist/cli.js login --issuer cluster-b --no-console --no-config
30
+ node dist/cli.js oss grants --issuer cluster-b --json
31
+ python3 examples/oss/roundtrip.py --issuer cluster-b --grant-id 123
32
+ ```
33
+
34
+ `--cli /absolute/path/dist/cli.js` selects another local build. The script never
35
+ runs npx, downloads packages, chooses a grant automatically or changes permissions.
36
+ It queries Grant/STS APIs through Numa, uses the STS-backed OSS client to list up
37
+ to ten objects, uploads a small unique object, checks metadata, downloads it and
38
+ compares bytes. Both read and write permission are required. `--prefix` must be
39
+ inside the selected grant's scope. No existing object is overwritten. The remote
40
+ test object is retained and its key is printed; temporary local files are removed.
41
+ Credentials are handled internally by the CLI, never printed by the script.
42
+
43
+ Machine identities use the selected issuer too. Configure
44
+ `NUMA_KEYCLOAK_AUTH_MODE=client_credentials`, the dedicated
45
+ `NUMA_KEYCLOAK_CLIENT_ID`, and `NUMA_KEYCLOAK_CLIENT_SECRET_FILE` (owner-only file).
46
+ Do not run browser login in machine mode. The backend still requires the canonical
47
+ OSS role and a grant for this exact issuer/subject.
48
+
49
+ For the local acceptance run, the published `issuer-mvp` Profile defaults to
50
+ cluster-a. Its existing fixture grants are intentionally unpublished/read-only
51
+ and cannot run the write example. Select a separately verified read/write grant
52
+ before executing the example against real OSS.
53
+
54
+ `oss grants` lists authorization records, including records whose cloud policy is
55
+ not yet synced/verified. Its count is not a count of grants ready for object access.
56
+ The normal output shows each grant ID, bucket/prefix, permission and sync state.
57
+ For example, the local MVP fixture Grant 2 has `cloudPolicySynced=false` and
58
+ `permissionLevel=READ_ONLY`: it cannot run this roundtrip. The unified API returns
59
+ `actions` and `effect`; the CLI authorizes operations from those fields, never
60
+ from the template label. `oss ls` only requires Allow + `oss:ListObjects`; stat/get
61
+ require `oss:GetObject`, and upload requires `oss:PutObject`. The roundtrip requires
62
+ all three Actions. System ADMIN uses `oss:*`, which is broader than selecting all
63
+ currently editable object Actions.
64
+ The roundtrip reports all status/permission blockers before requesting STS or
65
+ performing any object operations; it never publishes policies or expands access.
66
+
67
+ If `oss ls` fails with `OSS_STS_BROKER_NOT_CONFIGURED` (HTTP 503), the platform
68
+ account lacks an active STS broker RAM-user AK. A synced OSS policy alone does
69
+ not make STS ready. A platform administrator must configure the dedicated broker
70
+ identity and its managed AK, permission to assume the selected RAM Role, and the
71
+ Role's trust policy. The default management AK is separate from the broker AK.
72
+ `OSS_STS_ASSUME_ROLE_FAILED` means the STS call failed after broker selection;
73
+ check broker credentials, trust, permissions and connectivity. Neither error is
74
+ fixed by logging in with another issuer or changing the grant's read/write level.
75
+
76
+ The imported OSS development branch had a hard-coded machine test bucket. This
77
+ build uses server-authorized Grant/STS scope for both user and machine identities;
78
+ SDK prefix and permission checks remain enabled.