feat: add secure personal key setup
This commit is contained in:
+26
-24
@@ -9,7 +9,7 @@
|
||||
1. 只安装 [`install-manifest.json`](install-manifest.json) 指定的仓库和 `releaseRef`,不得默认安装 `master`、`main`、开发分支或其他未固定版本。
|
||||
2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。
|
||||
3. 不使用 `sudo`,不删除或覆盖未知目录,不丢弃已有 Git 改动。目标目录已存在时先识别它;不是本项目或工作区不干净时停止覆盖并向用户说明。
|
||||
4. 用户必须亲自在浏览器完成 NocoBase OAuth 登录。不得索取、复制、打印或保存用户的 Access Token、Refresh Token、Cookie、密码或 `~/.nocobase` 目录;不得执行带 `--show-secrets` 的命令。
|
||||
4. 认证默认使用用户自己的个人 API Key(Authorize Key),不使用账号密码。Agent 可以引导用户在 NocoBase 页面创建 Key,但不得要求用户把 Key 发到聊天;Key 只能由用户粘贴到本机、用户可控、隐藏回显的安全输入提示中。
|
||||
5. 默认只读。除非用户明确要求且管理员已确认 NocoBase ACL,否则不得启用 `--enable-writes`、附件上传或原始数据输出。
|
||||
6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。
|
||||
7. 不把本机 MCP 端口暴露到局域网或公网。通用安装默认使用 stdio,不创建后台服务,也不生成共享业务凭证。
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
从用户或当前上下文确定以下内容;已有明确答案时不要重复提问:
|
||||
|
||||
- NocoBase 应用 URL,例如 `https://nocobase.example`。执行 `nb env add` 前规范化为以 `/api` 结尾。
|
||||
- NocoBase 应用 URL,例如 `https://nocobase.example`。安装器会规范化为以 `/api` 结尾;远程环境必须使用 HTTPS。
|
||||
- 安装目录。使用该操作系统的普通用户应用/数据目录作为默认值,并在写入前告知用户;不要默认放在系统目录。
|
||||
- 当前 Agent/MCP 客户端的名称及其本机 MCP 配置位置。
|
||||
- 功能模式。未明确选择时必须使用“只读”。
|
||||
|
||||
不要要求用户提供 NocoBase Token。OAuth 登录之外没有必填秘密输入。
|
||||
不要要求用户在 Agent 对话中提供 NocoBase Key、密码、Token、Cookie 或 `~/.nocobase` 目录。Key 只进入 `nb` CLI 自己的本机隐藏提示。
|
||||
|
||||
## 执行协议
|
||||
|
||||
@@ -67,32 +67,34 @@ uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py
|
||||
|
||||
### 3. 连接用户自己的 NocoBase 账号
|
||||
|
||||
先直接执行:
|
||||
先引导用户在目标 NocoBase 网页中打开头像或个人设置,寻找当前版本中名为 `API Keys`、`Access Tokens`、`访问令牌` 或类似名称的入口。让用户创建一个:
|
||||
|
||||
- 绑定用户本人,而不是共享服务账号;
|
||||
- 只使用其正常业务角色,不选择管理员/root 角色;
|
||||
- 有合理过期时间、可单独撤销;
|
||||
- 仅用于这台电脑上的 MCP。
|
||||
|
||||
如果用户看不到个人 Key 功能,Agent 应提示联系 NocoBase 管理员开通或代为签发绑定该用户普通业务角色的 Key;不得改用账号密码或共享管理员 Key。
|
||||
|
||||
取得 Key 后,Agent 必须在**用户能直接控制输入的本机终端**中启动以下流程,而不是把命令交给用户手动尝试:
|
||||
|
||||
```bash
|
||||
nb env list
|
||||
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py \
|
||||
--project-root <install-dir> connect-key \
|
||||
--api-base-url <nocobase-url> --env-name nb-mcp --json
|
||||
```
|
||||
|
||||
如果 `nb-mcp` 不存在,为规范化后的目标 URL 创建 OAuth 环境,并在写操作后读回列表:
|
||||
安装器会自动:
|
||||
|
||||
```bash
|
||||
nb env add nb-mcp --api-base-url <normalized-url-ending-in-/api> --auth-type oauth
|
||||
nb env list
|
||||
```
|
||||
1. 规范化并验证 HTTPS URL;
|
||||
2. 脱敏检查已有环境,拒绝覆盖指向其他服务器的同名环境;
|
||||
3. 调用 `nb env add/auth --auth-type token`,但故意不传 `--access-token`,让 `nb` 在 TTY 内隐藏提示;
|
||||
4. 由用户本人把 Key 粘贴到这个本机提示中,Agent 不读取输入;
|
||||
5. 自动执行环境读回、把选定环境写入 `config.json`,并在修改前保存 `config.json.pre-agent-install.bak`。
|
||||
|
||||
如果 `nb-mcp` 已存在,先用不显示秘密的 `nb env info nb-mcp` 核对 URL。URL 相同则复用;URL 不同则不要覆盖,选择清楚且未占用的新名称(例如 `nb-mcp-example`),创建后同步更新项目配置:
|
||||
若环境名冲突,安装器会给出新名称建议;Agent 使用建议名称重启同一流程,不覆盖旧环境。若当前 Agent 无法提供用户可直接输入的本机 TTY,必须停止并报告“缺少安全 Key 输入通道”;不得要求用户把 Key 粘到聊天、命令参数、环境变量或临时文件中,也不得把 `nb` 命令交给用户反复尝试。
|
||||
|
||||
```bash
|
||||
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> set-env <new-env-name> --json
|
||||
```
|
||||
|
||||
此命令修改前会保留 `config.json.pre-agent-install.bak`。然后启动登录:
|
||||
|
||||
```bash
|
||||
nb env auth <configured-env>
|
||||
```
|
||||
|
||||
让用户亲自在浏览器完成登录;Agent 只负责保持命令运行、提示用户返回以及继续读取 CLI 的成功或错误信息。最后执行 `nb env info <configured-env>` 验证,不得附加 `--show-secrets`。如果登录失败或过期,重新运行同一个 `nb env auth`,不得换用共享 Token。
|
||||
Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐私提示中输入新 Key。不得退回共享凭证。
|
||||
|
||||
### 4. 注册到当前 Agent/MCP 客户端
|
||||
|
||||
@@ -130,7 +132,7 @@ uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py
|
||||
然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成:
|
||||
|
||||
- `doctor` 返回 `ok: true`;
|
||||
- `nb env info <configured-env>` 成功且目标 URL 正确;
|
||||
- `connect-key` 返回 `ok: true`、`authType: token`,且目标 URL 正确;
|
||||
- 客户端已发现 `nocobase-crm`;
|
||||
- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误;
|
||||
- 默认状态仍是只读、uploads disabled、raw data disabled。
|
||||
@@ -142,7 +144,7 @@ uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py
|
||||
普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说:
|
||||
|
||||
```text
|
||||
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;不要索取或显示任何 Token;需要全局安装依赖、写客户端配置或启用写功能前先告诉我。安装后必须通过 doctor 和 crm_health:
|
||||
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health:
|
||||
<version-pinned INSTALL.md URL>
|
||||
```
|
||||
|
||||
|
||||
@@ -10,15 +10,14 @@ The default stdio server is read-only. Add `--enable-writes` for fixed-schema pr
|
||||
|
||||
## Configure
|
||||
|
||||
First save the remote NocoBase environment and sign in with your own account:
|
||||
The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then starts this helper in a user-controlled local terminal:
|
||||
|
||||
```bash
|
||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
||||
nb env auth nb-mcp
|
||||
nb env info nb-mcp
|
||||
uv run --no-project --python 3.12 python scripts/agent_install.py \
|
||||
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
|
||||
```
|
||||
|
||||
`nb env auth` opens the NocoBase login flow in the user's browser. Do not distribute an API key or copy another user's `nb` configuration directory. The MCP invokes `nb --env nb-mcp`, so NocoBase reapplies the logged-in user's roles, row-level filters, and field permissions to every operation.
|
||||
The user pastes the Key only into `nb` CLI's private TTY prompt. The helper never accepts the Key as an argument, environment variable, chat value, file, captured output, or return value. Do not copy another user's Key or `nb` configuration directory. The Key must represent that user's normal business identity/role, so NocoBase can apply the intended roles, row-level filters, and field permissions to every operation.
|
||||
|
||||
This repository includes a `config.json` generated for the current CRM. To regenerate it from the example:
|
||||
|
||||
@@ -180,16 +179,17 @@ Attachment upload is disabled by default. After an administrator confirms that t
|
||||
|
||||
Every upload first calls NocoBase `roles:check` with the current user's credential. The MCP follows NocoBase's global and collection-specific ACL resolution and requires `t_projects:create` or `t_projects:update` before it calls `attachments:create`. A role without project write permission receives HTTP `403` with `nb_upload_forbidden`, even if that NocoBase installation otherwise allows the generic attachment-create endpoint.
|
||||
|
||||
Queries and writes use the saved `nb-mcp` login. For multipart uploads and attachment associations, the MCP asks `nb env info nb-mcp --json --show-secrets` for the current credential at request time. The credential is held only in memory: it is not placed in `config.json`, the MCP client configuration, logs, or subprocess arguments. If login is missing or expired, run `nb env auth nb-mcp` again.
|
||||
Queries and writes use the saved `nb-mcp` personal Key login. For multipart uploads and attachment associations, the MCP asks `nb env info nb-mcp --json --show-secrets` for the current credential at request time. The credential is held only in memory: it is not placed in `config.json`, the MCP client configuration, logs, or subprocess arguments. If the Key is missing, expired, or revoked, ask the local Agent to rerun the secure `connect-key` flow and enter a newly issued personal Key in the private prompt.
|
||||
|
||||
`scripts/mcp_service.sh start` creates `.runtime/nocobase-crm-mcp.env` with mode `0600`, its parent directory with mode `0700`, a random local MCP bearer token, and only non-secret runtime settings. The legacy `NOCOBASE_MCP_API_BASE_URL` / `NOCOBASE_MCP_API_TOKEN` override remains accepted for existing operator deployments, but it is not part of the end-user installation path.
|
||||
|
||||
First-install flow:
|
||||
|
||||
```bash
|
||||
# Complete browser login as the same operating-system user that runs the MCP.
|
||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
||||
nb env auth nb-mcp
|
||||
# The Agent first runs connect-key in a user-controlled TTY. The user pastes a
|
||||
# personal NocoBase API Key only into the hidden prompt; it never enters argv.
|
||||
uv run --no-project --python 3.12 python scripts/agent_install.py \
|
||||
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
|
||||
|
||||
# Create and start the private, loopback-only user service.
|
||||
bash scripts/mcp_service.sh start
|
||||
@@ -261,7 +261,7 @@ If `nb` is installed somewhere unusual, set its absolute path before starting:
|
||||
NOCOBASE_MCP_NB_BIN=/absolute/path/to/nb bash scripts/mcp_service.sh start
|
||||
```
|
||||
|
||||
The generated env file also includes the non-secret 25 MiB upload limit. NocoBase access comes from the same operating-system user's saved `nb-mcp` login; run `nb env auth nb-mcp` again if the MCP reports `nb_auth_failed` or `nb_upload_unauthorized`.
|
||||
The generated env file also includes the non-secret 25 MiB upload limit. NocoBase access comes from the same operating-system user's saved `nb-mcp` personal Key. If the MCP reports `nb_auth_failed` or `nb_upload_unauthorized`, ask the local Agent to rerun the secure `connect-key` flow; never paste the Key into chat or a command argument.
|
||||
|
||||
Show the saved URL/header/client config:
|
||||
|
||||
|
||||
+12
-17
@@ -2,7 +2,7 @@
|
||||
|
||||
## 交付模型
|
||||
|
||||
每位用户在自己的电脑运行 MCP,并使用自己的 NocoBase 账号登录 `nb` CLI:
|
||||
每位用户在自己的电脑运行 MCP,并使用绑定本人普通业务身份/角色的个人 API Key 登录 `nb` CLI:
|
||||
|
||||
```text
|
||||
本机 AI 客户端 → 本机 NocoBase CRM MCP → 本机 nb CLI 登录态 → 远程 NocoBase ACL
|
||||
@@ -20,7 +20,7 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程
|
||||
|
||||
## 推荐:链接驱动的 Agent 安装
|
||||
|
||||
正式发布后,优先把对应 Release Tag 下的 [`INSTALL.md`](../INSTALL.md) 不可变链接交给用户。该入口不是 Codex 专用插件:任何能够读取链接、执行本机终端并结构化修改 MCP 客户端配置的 Agent,都可以按协议完成安装。确定性工作由 `scripts/agent_install.py` 处理;Agent 负责识别操作系统与客户端、解释变更、备份并合并客户端配置;用户本人完成 OAuth 登录。
|
||||
正式发布后,优先把对应 Release Tag 下的 [`INSTALL.md`](../INSTALL.md) 不可变链接交给用户。该入口不是 Codex 专用插件:任何能够读取链接、执行本机终端并结构化修改 MCP 客户端配置的 Agent,都可以按协议完成安装。确定性工作由 `scripts/agent_install.py` 处理;Agent 负责识别操作系统与客户端、解释变更、备份并合并客户端配置;用户只在 NocoBase 获取个人 Key,并把它粘贴到本机隐藏提示。
|
||||
|
||||
发布方必须保证安装 Agent 能读取链接和相同版本的代码。当前仓库为 private 时,用户需要已经连接有仓库权限的 GitHub 账号;面向普通外部用户的一条链接安装,则必须公开该版本的 `INSTALL.md` 与发布物,或放到用户 Agent 可认证访问的分发渠道。不要把开发分支链接当作交付链接。
|
||||
|
||||
@@ -35,15 +35,14 @@ uv sync --locked
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
`config.json` 中的 `env` 默认是 `nb-mcp`。用自己的账号创建并登录同名环境:
|
||||
`config.json` 中的 `env` 默认是 `nb-mcp`。正式终端用户不应手动尝试 `nb env add/auth`;安装 Agent 在用户可直接控制的本机终端中运行:
|
||||
|
||||
```bash
|
||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
||||
nb env auth nb-mcp
|
||||
nb env info nb-mcp
|
||||
uv run --no-project --python 3.12 python scripts/agent_install.py \
|
||||
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
|
||||
```
|
||||
|
||||
`nb env auth` 会打开浏览器。用户必须亲自完成登录;不要向用户分发共享 API Key,也不要复制其他人的 `nb` 配置目录。
|
||||
Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
|
||||
|
||||
先进行健康检查:
|
||||
|
||||
@@ -51,11 +50,7 @@ nb env info nb-mcp
|
||||
uv run nocobase-crm-mcp --config config.json
|
||||
```
|
||||
|
||||
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,重新执行:
|
||||
|
||||
```bash
|
||||
nb env auth nb-mcp
|
||||
```
|
||||
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,由 Agent 重新启动 `connect-key`,用户在新的本机隐藏提示中输入新 Key;不要让用户手动尝试 `nb` 命令。
|
||||
|
||||
## MCP 客户端配置
|
||||
|
||||
@@ -98,7 +93,7 @@ Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级
|
||||
|
||||
交付前至少使用两个权限不同的测试账号验证:
|
||||
|
||||
1. 两台电脑或两个隔离的操作系统账号分别执行 `nb env auth nb-mcp`。
|
||||
1. 两台电脑或两个隔离的操作系统账号分别通过 Agent 的 `connect-key` 流程录入各自个人 Key。
|
||||
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups` 和 `list_projects`。
|
||||
3. 确认各自只能看到 NocoBase UI 中本人有权查看的数据。
|
||||
4. 对无项目写权限的账号调用 `create_project`,确认 NocoBase 拒绝。
|
||||
@@ -106,11 +101,11 @@ Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级
|
||||
6. 对有写权限的账号创建测试项目并完成上传、关联和移除,确认审计字段记录的是该用户,而不是共享服务账号。
|
||||
7. 退出或使 Token 过期后,确认 MCP 返回 `nb_auth_failed`,且不会降级使用其他凭证。
|
||||
|
||||
修改测试账号角色后,如果 `roles:check` 仍返回旧角色,先由管理员执行 `nb api app clear-cache`,再重新执行 `nb env auth <环境名>`。这是服务端角色缓存刷新,不应通过共享 Token 绕过。
|
||||
修改测试账号角色后,如果 `roles:check` 仍返回旧角色,先由管理员清理服务端角色缓存,再由 Agent 使用新签发的个人 Key 重新运行 `connect-key`。不应通过共享 Token 绕过。
|
||||
|
||||
## 凭证安全
|
||||
|
||||
- NocoBase Token 由 `nb` CLI 保存,不能写入 `config.json` 或 MCP 客户端配置。
|
||||
- 个人 API Key 由 `nb` CLI 保存,不能写入 `config.json`、MCP 客户端配置、聊天、Shell 历史或命令参数。
|
||||
- MCP 获取附件请求凭证时,Token 只存在于进程内存,不进入子进程参数和错误日志。
|
||||
- `.runtime/` 只保存本机 HTTP MCP 的独立 Bearer Token,并已被 Git 忽略。
|
||||
- 不要共享操作系统账号运行 MCP;本机权限隔离依赖每位用户拥有独立的 `nb` 登录存储。
|
||||
@@ -147,7 +142,7 @@ bash scripts/mcp_service.sh start
|
||||
|
||||
发布交付必须来自工作区干净、CI 全绿的带注释 Git Tag;不要从开发分支任意提交直接打包给用户。
|
||||
|
||||
需要备份的业务配置是 `config.json`。它不应包含 Token,可以进入受控版本库或企业配置备份。`.runtime/nocobase-crm-mcp.env` 只包含本机 HTTP Token 和运行参数;丢失后运行 `bash scripts/mcp_service.sh new-token` 重新生成即可。不要备份、复制或分发用户的 `nb` OAuth Token;恢复电脑后重新执行 `nb env add` 和 `nb env auth`。
|
||||
需要备份的业务配置是 `config.json`。它不应包含 Token,可以进入受控版本库或企业配置备份。`.runtime/nocobase-crm-mcp.env` 只包含本机 HTTP Token 和运行参数;丢失后运行 `bash scripts/mcp_service.sh new-token` 重新生成即可。不要备份、复制或分发用户的个人 Key;恢复电脑后重新签发 Key,并由 Agent 运行 `connect-key`。
|
||||
|
||||
建议监控:
|
||||
|
||||
@@ -160,7 +155,7 @@ bash scripts/mcp_service.sh start
|
||||
常见故障:
|
||||
|
||||
- `nb_not_found`:确认 `nb` 在 PATH 中,或设置 `NOCOBASE_MCP_NB_BIN` 为绝对路径。
|
||||
- `nb_auth_failed`:执行 `nb env auth nb-mcp`。
|
||||
- `nb_auth_failed`:由 Agent 重启 `connect-key`,用户只在本机隐藏提示中输入新 Key。
|
||||
- `NO_PERMISSIONS`:由 NocoBase 管理员调整该用户的角色、字段或行级数据权限。
|
||||
- 附件返回 `nb_upload_unauthorized`:重新登录后重试;不要把其他用户的 Token 写入环境变量。
|
||||
- 附件返回 `nb_upload_forbidden`:当前角色没有项目 create/update;不要为了绕过门禁而改用共享高权限账号。
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
"packageManager": "uv",
|
||||
"nocobaseCli": ">=2.1"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "personal-api-key",
|
||||
"cliAuthType": "token",
|
||||
"secretEntry": "user-controlled-private-tty",
|
||||
"allowPassword": false,
|
||||
"allowAgentChatSecret": false,
|
||||
"allowCommandLineSecret": false
|
||||
},
|
||||
"entrypoints": {
|
||||
"guide": "INSTALL.md",
|
||||
"helper": "scripts/agent_install.py",
|
||||
|
||||
@@ -89,7 +89,7 @@ class NbClient:
|
||||
if not isinstance(access_token, str) or not access_token.strip() or access_token in {"-", "********"}:
|
||||
raise NbAuthenticationError(
|
||||
f"nb environment {self.env_name!r} is not authenticated. "
|
||||
f"Run `nb env auth {self.env_name}` and complete login in your browser."
|
||||
"Ask the local installation Agent to reconnect it with your personal NocoBase API Key."
|
||||
)
|
||||
return api_base_url.strip().rstrip("/"), access_token.strip()
|
||||
|
||||
|
||||
@@ -127,7 +127,10 @@ def _safe_tool_result(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]
|
||||
return _tool_error(
|
||||
code="nb_auth_failed",
|
||||
message=str(error),
|
||||
hint="Run `nb env auth <configured-env>` on this computer and complete login with your own NocoBase account.",
|
||||
hint=(
|
||||
"Ask the local installation Agent to run the secure connect-key flow, then enter your personal "
|
||||
"NocoBase API Key only in the private local prompt."
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
except NbCommandError as error:
|
||||
|
||||
+223
-1
@@ -21,12 +21,14 @@ from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
MIN_PYTHON = (3, 12)
|
||||
DEFAULT_ENV_NAME = "nb-mcp"
|
||||
SERVER_NAME = "nocobase-crm"
|
||||
REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json")
|
||||
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
Which = Callable[[str], str | None]
|
||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||
@@ -200,6 +202,213 @@ def set_config_env(project_root: str | Path | None, env_name: str) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
def normalize_api_base_url(value: str) -> str:
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
raise InstallerError("NocoBase API base URL is required")
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise InstallerError("NocoBase URL must be an absolute http:// or https:// URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise InstallerError("NocoBase URL must not contain credentials, a query string, or a fragment")
|
||||
hostname = parsed.hostname.lower()
|
||||
is_local = hostname == "localhost" or hostname == "::1" or hostname.startswith("127.")
|
||||
if parsed.scheme != "https" and not is_local:
|
||||
raise InstallerError("A remote NocoBase API Key may only be sent to an https:// endpoint")
|
||||
path = parsed.path.rstrip("/")
|
||||
if not path.endswith("/api"):
|
||||
path = f"{path}/api" if path else "/api"
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||
|
||||
|
||||
def _parse_cli_json(output: str) -> dict[str, Any]:
|
||||
decoder = json.JSONDecoder()
|
||||
candidates: list[tuple[int, int, dict[str, Any]]] = []
|
||||
for start, character in enumerate(output):
|
||||
if character != "{":
|
||||
continue
|
||||
try:
|
||||
value, end = decoder.raw_decode(output, start)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
candidates.append((start, end, value))
|
||||
outer_candidates = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if not any(
|
||||
other_start <= candidate[0]
|
||||
and other_end >= candidate[1]
|
||||
and (other_start, other_end) != (candidate[0], candidate[1])
|
||||
for other_start, other_end, _value in candidates
|
||||
)
|
||||
]
|
||||
if len(outer_candidates) != 1:
|
||||
raise InstallerError("nb env info did not return one JSON object")
|
||||
return outer_candidates[0][2]
|
||||
|
||||
|
||||
def _nb_command_environment(nb_bin: str) -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
environment["NB_CLI_LOG_DISABLED"] = "1"
|
||||
environment["NB_SKIP_STARTUP_UPDATE"] = "1"
|
||||
nb_path = Path(nb_bin)
|
||||
if nb_path.is_absolute():
|
||||
nb_dir = str(nb_path.parent)
|
||||
existing_path = environment.get("PATH", "")
|
||||
parts = [part for part in existing_path.split(os.pathsep) if part]
|
||||
if nb_dir not in parts:
|
||||
environment["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "")
|
||||
return environment
|
||||
|
||||
|
||||
def _read_nb_environment(
|
||||
root: Path,
|
||||
env_name: str,
|
||||
*,
|
||||
nb_bin: str,
|
||||
runner: Runner,
|
||||
) -> dict[str, Any] | None:
|
||||
command = [nb_bin, "env", "info", env_name, "--json"]
|
||||
try:
|
||||
result = runner(
|
||||
command,
|
||||
cwd=str(root),
|
||||
env=_nb_command_environment(nb_bin),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as error:
|
||||
raise InstallerError(f"Could not execute nb env info: {error}") from error
|
||||
if result.returncode == 0:
|
||||
return _parse_cli_json(result.stdout)
|
||||
diagnostic = ANSI_ESCAPE_PATTERN.sub("", f"{result.stdout}\n{result.stderr}")
|
||||
if "is not configured" in diagnostic:
|
||||
return None
|
||||
raise InstallerError(
|
||||
f"nb env info failed with exit code {result.returncode}; inspect the nb CLI diagnostic locally"
|
||||
)
|
||||
|
||||
|
||||
def _api_details(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
api = payload.get("api")
|
||||
auth = api.get("auth") if isinstance(api, dict) else None
|
||||
api_base_url = api.get("apiBaseUrl") if isinstance(api, dict) else None
|
||||
auth_type = auth.get("type") if isinstance(auth, dict) else None
|
||||
normalized_url = api_base_url.strip().rstrip("/") if isinstance(api_base_url, str) else None
|
||||
normalized_type = auth_type.strip() if isinstance(auth_type, str) else None
|
||||
return normalized_url, normalized_type
|
||||
|
||||
|
||||
def _suggest_env_name(api_base_url: str) -> str:
|
||||
hostname = urlsplit(api_base_url).hostname or "server"
|
||||
suffix = re.sub(r"[^a-z0-9]+", "-", hostname.lower()).strip("-")[:32] or "server"
|
||||
return f"{DEFAULT_ENV_NAME}-{suffix}"
|
||||
|
||||
|
||||
def connect_with_personal_key(
|
||||
project_root: str | Path | None,
|
||||
api_base_url: str,
|
||||
*,
|
||||
env_name: str = DEFAULT_ENV_NAME,
|
||||
runner: Runner = subprocess.run,
|
||||
which: Which = shutil.which,
|
||||
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
|
||||
) -> dict[str, Any]:
|
||||
"""Bind a personal API Key through nb's private TTY prompt.
|
||||
|
||||
The key is never accepted by this function, placed in argv, inherited from
|
||||
the process environment, captured, printed, or returned. The child nb CLI
|
||||
reads it directly from the user-controlled terminal.
|
||||
"""
|
||||
|
||||
if not ENV_NAME_PATTERN.fullmatch(env_name):
|
||||
raise InstallerError(
|
||||
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
|
||||
)
|
||||
if not stdin_isatty():
|
||||
raise InstallerError(
|
||||
"Secure API Key entry requires a user-controlled local terminal. "
|
||||
"Do not paste the Key into Agent chat or pass it with --access-token."
|
||||
)
|
||||
root = _project_root(project_root)
|
||||
normalized_url = normalize_api_base_url(api_base_url)
|
||||
discovered_nb = which("nb")
|
||||
if not discovered_nb:
|
||||
raise InstallerError("nb was not found on PATH")
|
||||
nb_bin = os.path.abspath(discovered_nb)
|
||||
existing = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
|
||||
|
||||
if existing is None:
|
||||
interactive_command = [
|
||||
nb_bin,
|
||||
"env",
|
||||
"add",
|
||||
env_name,
|
||||
"--api-base-url",
|
||||
normalized_url,
|
||||
"--auth-type",
|
||||
"token",
|
||||
]
|
||||
action = "created"
|
||||
else:
|
||||
existing_url, _auth_type = _api_details(existing)
|
||||
if existing_url != normalized_url.rstrip("/"):
|
||||
suggestion = _suggest_env_name(normalized_url)
|
||||
raise InstallerError(
|
||||
f"Environment {env_name!r} already targets {existing_url or 'an unconfigured endpoint'!r}; "
|
||||
f"use a new name such as {suggestion!r} instead of overwriting it"
|
||||
)
|
||||
interactive_command = [nb_bin, "env", "auth", env_name, "--auth-type", "token"]
|
||||
action = "reauthenticated"
|
||||
|
||||
try:
|
||||
result = runner(
|
||||
interactive_command,
|
||||
cwd=str(root),
|
||||
env=_nb_command_environment(nb_bin),
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as error:
|
||||
raise InstallerError(f"Could not execute the interactive nb Key prompt: {error}") from error
|
||||
if result.returncode != 0:
|
||||
raise InstallerError(
|
||||
f"Interactive API Key authentication failed with exit code {result.returncode}; "
|
||||
"revoke the exposed or invalid Key if necessary and retry through the installer"
|
||||
)
|
||||
|
||||
verified = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
|
||||
if verified is None:
|
||||
raise InstallerError("nb reported success but the saved environment could not be read back")
|
||||
verified_url, auth_type = _api_details(verified)
|
||||
if verified_url != normalized_url.rstrip("/") or auth_type != "token":
|
||||
raise InstallerError("The saved nb environment did not verify as token authentication for the requested URL")
|
||||
|
||||
list_result = runner(
|
||||
[nb_bin, "env", "list"],
|
||||
cwd=str(root),
|
||||
env=_nb_command_environment(nb_bin),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if list_result.returncode != 0:
|
||||
raise InstallerError("API Key was saved, but nb env list readback failed")
|
||||
config_result = set_config_env(root, env_name)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"env": env_name,
|
||||
"apiBaseUrl": normalized_url,
|
||||
"authType": "token",
|
||||
"keyExposure": "private-tty-only",
|
||||
"configChanged": config_result["changed"],
|
||||
"configBackupPath": config_result["backupPath"],
|
||||
}
|
||||
|
||||
|
||||
def render_client_config(
|
||||
project_root: str | Path | None = None,
|
||||
*,
|
||||
@@ -298,7 +507,7 @@ def doctor_project(
|
||||
"projectRoot": str(root),
|
||||
"checks": [asdict(check) for check in checks],
|
||||
"remainingVerification": [
|
||||
"Run nb env info <configured-env> in its default redacted mode.",
|
||||
"Have the installation Agent verify the saved env through nb CLI's default redacted info output.",
|
||||
"Register the generated stdio MCP configuration in the user's client.",
|
||||
"Call the MCP tool crm_health and require ok=true.",
|
||||
],
|
||||
@@ -330,6 +539,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
set_env.add_argument("env_name")
|
||||
set_env.add_argument("--json", action="store_true", dest="as_json")
|
||||
|
||||
connect_key = subparsers.add_parser("connect-key")
|
||||
connect_key.add_argument("--api-base-url", required=True)
|
||||
connect_key.add_argument("--env-name", default=DEFAULT_ENV_NAME)
|
||||
connect_key.add_argument("--json", action="store_true", dest="as_json")
|
||||
|
||||
client_config = subparsers.add_parser("client-config")
|
||||
client_config.add_argument("--enable-writes", action="store_true")
|
||||
return parser
|
||||
@@ -351,6 +565,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
payload = set_config_env(args.project_root, args.env_name)
|
||||
_print_result(payload, as_json=args.as_json)
|
||||
return 0
|
||||
if args.command == "connect-key":
|
||||
payload = connect_with_personal_key(
|
||||
args.project_root,
|
||||
args.api_base_url,
|
||||
env_name=args.env_name,
|
||||
)
|
||||
_print_result(payload, as_json=args.as_json)
|
||||
return 0
|
||||
if args.command == "client-config":
|
||||
print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2))
|
||||
return 0
|
||||
|
||||
@@ -469,7 +469,7 @@ def service_status(*, project_root: Path) -> str:
|
||||
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
|
||||
lines.append(f"MCP URL: http://{display_host}:{port}{path}")
|
||||
lines.append(f"nb binary: {nb_bin}")
|
||||
lines.append(f"NocoBase CLI env: {nb_env} (authenticate with `nb env auth {nb_env}`)")
|
||||
lines.append(f"NocoBase CLI env: {nb_env} (personal API Key managed by the Agent installer)")
|
||||
uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1"
|
||||
lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}")
|
||||
canonical_configured = bool(values.get(API_BASE_URL_ENV) and values.get(API_TOKEN_ENV))
|
||||
|
||||
@@ -28,6 +28,17 @@ def fake_which(name: str) -> str | None:
|
||||
|
||||
|
||||
class AgentInstallTests(unittest.TestCase):
|
||||
def test_normalize_api_base_url_requires_tls_for_remote_keys(self) -> None:
|
||||
self.assertEqual(
|
||||
agent_install.normalize_api_base_url("https://nb.example/nocobase/"),
|
||||
"https://nb.example/nocobase/api",
|
||||
)
|
||||
self.assertEqual(agent_install.normalize_api_base_url("http://127.0.0.1:13000"), "http://127.0.0.1:13000/api")
|
||||
with self.assertRaisesRegex(agent_install.InstallerError, "https"):
|
||||
agent_install.normalize_api_base_url("http://nb.example")
|
||||
with self.assertRaisesRegex(agent_install.InstallerError, "must not contain credentials"):
|
||||
agent_install.normalize_api_base_url("https://user:secret@nb.example")
|
||||
|
||||
def test_inspect_reports_required_tools_and_config_as_optional_before_prepare(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -81,6 +92,134 @@ class AgentInstallTests(unittest.TestCase):
|
||||
self.assertEqual(changed["env"], "nb-mcp-acme")
|
||||
self.assertEqual(backup, original)
|
||||
|
||||
def test_connect_key_creates_env_through_private_tty_without_key_in_argv(self) -> None:
|
||||
calls = []
|
||||
info_reads = 0
|
||||
|
||||
def runner(command, **kwargs):
|
||||
nonlocal info_reads
|
||||
calls.append((command, kwargs))
|
||||
if command[1:4] == ["env", "info", "nb-mcp"]:
|
||||
info_reads += 1
|
||||
if info_reads == 1:
|
||||
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=json.dumps(
|
||||
{
|
||||
"api": {
|
||||
"apiBaseUrl": "https://nb.example/api",
|
||||
"auth": {"type": "token", "accessToken": "-"},
|
||||
}
|
||||
}
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
make_project(root)
|
||||
(root / "config.json").write_text(json.dumps({"env": "old-env"}), encoding="utf-8")
|
||||
|
||||
result = agent_install.connect_with_personal_key(
|
||||
root,
|
||||
"https://nb.example",
|
||||
runner=runner,
|
||||
which=fake_which,
|
||||
stdin_isatty=lambda: True,
|
||||
)
|
||||
|
||||
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
|
||||
|
||||
commands = [command for command, _kwargs in calls]
|
||||
flattened = [item for command in commands for item in command]
|
||||
self.assertEqual(result["action"], "created")
|
||||
self.assertEqual(result["keyExposure"], "private-tty-only")
|
||||
self.assertEqual(configured["env"], "nb-mcp")
|
||||
self.assertNotIn("--access-token", flattened)
|
||||
self.assertIn(
|
||||
[
|
||||
"/opt/bin/nb",
|
||||
"env",
|
||||
"add",
|
||||
"nb-mcp",
|
||||
"--api-base-url",
|
||||
"https://nb.example/api",
|
||||
"--auth-type",
|
||||
"token",
|
||||
],
|
||||
commands,
|
||||
)
|
||||
interactive_kwargs = calls[1][1]
|
||||
self.assertNotIn("capture_output", interactive_kwargs)
|
||||
|
||||
def test_connect_key_reauthenticates_matching_env_and_refuses_name_collision(self) -> None:
|
||||
calls = []
|
||||
auth_complete = False
|
||||
|
||||
def matching_runner(command, **kwargs):
|
||||
nonlocal auth_complete
|
||||
calls.append(command)
|
||||
if command[1:3] == ["env", "info"]:
|
||||
auth_type = "token" if auth_complete else "oauth"
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": auth_type}}}),
|
||||
stderr="",
|
||||
)
|
||||
if command[1:3] == ["env", "auth"]:
|
||||
auth_complete = True
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
make_project(root)
|
||||
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
|
||||
result = agent_install.connect_with_personal_key(
|
||||
root,
|
||||
"https://nb.example/api",
|
||||
runner=matching_runner,
|
||||
which=fake_which,
|
||||
stdin_isatty=lambda: True,
|
||||
)
|
||||
|
||||
self.assertEqual(result["action"], "reauthenticated")
|
||||
self.assertIn(["/opt/bin/nb", "env", "auth", "nb-mcp", "--auth-type", "token"], calls)
|
||||
|
||||
def conflicting_runner(command, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=json.dumps({"api": {"apiBaseUrl": "https://other.example/api", "auth": {"type": "token"}}}),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
make_project(root)
|
||||
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
|
||||
with self.assertRaisesRegex(agent_install.InstallerError, "instead of overwriting"):
|
||||
agent_install.connect_with_personal_key(
|
||||
root,
|
||||
"https://nb.example",
|
||||
runner=conflicting_runner,
|
||||
which=fake_which,
|
||||
stdin_isatty=lambda: True,
|
||||
)
|
||||
|
||||
def test_connect_key_requires_user_controlled_tty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
|
||||
agent_install.connect_with_personal_key(
|
||||
Path(tmp),
|
||||
"https://nb.example",
|
||||
which=fake_which,
|
||||
stdin_isatty=lambda: False,
|
||||
)
|
||||
|
||||
def test_client_config_uses_absolute_paths_and_is_read_only_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -126,6 +265,10 @@ class AgentInstallTests(unittest.TestCase):
|
||||
version = package["project"]["version"]
|
||||
self.assertEqual(manifest["version"], version)
|
||||
self.assertEqual(manifest["distribution"]["releaseRef"], f"v{version}")
|
||||
self.assertEqual(manifest["authentication"]["type"], "personal-api-key")
|
||||
self.assertEqual(manifest["authentication"]["secretEntry"], "user-controlled-private-tty")
|
||||
self.assertFalse(manifest["authentication"]["allowPassword"])
|
||||
self.assertFalse(manifest["authentication"]["allowCommandLineSecret"])
|
||||
self.assertTrue(manifest["securityDefaults"]["readOnly"])
|
||||
self.assertFalse(manifest["securityDefaults"]["uploads"])
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class NbClientTests(unittest.TestCase):
|
||||
{
|
||||
"api": {
|
||||
"apiBaseUrl": "https://nb.example/api/",
|
||||
"auth": {"type": "oauth", "accessToken": "user-access-token"},
|
||||
"auth": {"type": "token", "accessToken": "user-access-token"},
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -132,7 +132,7 @@ class NbClientTests(unittest.TestCase):
|
||||
{
|
||||
"api": {
|
||||
"apiBaseUrl": "https://nb.example/api",
|
||||
"auth": {"type": "oauth", "accessToken": "-"},
|
||||
"auth": {"type": "token", "accessToken": "-"},
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -142,7 +142,8 @@ class NbClientTests(unittest.TestCase):
|
||||
with self.assertRaises(NbAuthenticationError) as caught:
|
||||
NbClient(env_name="customer-prod", runner=runner).environment_credentials()
|
||||
|
||||
self.assertIn("nb env auth customer-prod", str(caught.exception))
|
||||
self.assertIn("personal NocoBase API Key", str(caught.exception))
|
||||
self.assertNotIn("nb env auth", str(caught.exception))
|
||||
|
||||
def test_get_record_and_query_records_use_expected_commands(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
@@ -234,7 +234,8 @@ class ServerTests(unittest.TestCase):
|
||||
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
|
||||
|
||||
self.assertEqual(structured["error"]["code"], "nb_auth_failed")
|
||||
self.assertIn("nb env auth", structured["error"]["hint"])
|
||||
self.assertIn("secure connect-key flow", structured["error"]["hint"])
|
||||
self.assertIn("private local prompt", structured["error"]["hint"])
|
||||
self.assertFalse(structured["error"]["retryable"])
|
||||
|
||||
def test_tools_return_specific_error_when_nb_is_not_found(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user