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`、开发分支或其他未固定版本。
|
1. 只安装 [`install-manifest.json`](install-manifest.json) 指定的仓库和 `releaseRef`,不得默认安装 `master`、`main`、开发分支或其他未固定版本。
|
||||||
2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。
|
2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。
|
||||||
3. 不使用 `sudo`,不删除或覆盖未知目录,不丢弃已有 Git 改动。目标目录已存在时先识别它;不是本项目或工作区不干净时停止覆盖并向用户说明。
|
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`、附件上传或原始数据输出。
|
5. 默认只读。除非用户明确要求且管理员已确认 NocoBase ACL,否则不得启用 `--enable-writes`、附件上传或原始数据输出。
|
||||||
6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。
|
6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。
|
||||||
7. 不把本机 MCP 端口暴露到局域网或公网。通用安装默认使用 stdio,不创建后台服务,也不生成共享业务凭证。
|
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 配置位置。
|
- 当前 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 账号
|
### 3. 连接用户自己的 NocoBase 账号
|
||||||
|
|
||||||
先直接执行:
|
先引导用户在目标 NocoBase 网页中打开头像或个人设置,寻找当前版本中名为 `API Keys`、`Access Tokens`、`访问令牌` 或类似名称的入口。让用户创建一个:
|
||||||
|
|
||||||
|
- 绑定用户本人,而不是共享服务账号;
|
||||||
|
- 只使用其正常业务角色,不选择管理员/root 角色;
|
||||||
|
- 有合理过期时间、可单独撤销;
|
||||||
|
- 仅用于这台电脑上的 MCP。
|
||||||
|
|
||||||
|
如果用户看不到个人 Key 功能,Agent 应提示联系 NocoBase 管理员开通或代为签发绑定该用户普通业务角色的 Key;不得改用账号密码或共享管理员 Key。
|
||||||
|
|
||||||
|
取得 Key 后,Agent 必须在**用户能直接控制输入的本机终端**中启动以下流程,而不是把命令交给用户手动尝试:
|
||||||
|
|
||||||
```bash
|
```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
|
1. 规范化并验证 HTTPS URL;
|
||||||
nb env add nb-mcp --api-base-url <normalized-url-ending-in-/api> --auth-type oauth
|
2. 脱敏检查已有环境,拒绝覆盖指向其他服务器的同名环境;
|
||||||
nb env list
|
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
|
Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐私提示中输入新 Key。不得退回共享凭证。
|
||||||
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。
|
|
||||||
|
|
||||||
### 4. 注册到当前 Agent/MCP 客户端
|
### 4. 注册到当前 Agent/MCP 客户端
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py
|
|||||||
然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成:
|
然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成:
|
||||||
|
|
||||||
- `doctor` 返回 `ok: true`;
|
- `doctor` 返回 `ok: true`;
|
||||||
- `nb env info <configured-env>` 成功且目标 URL 正确;
|
- `connect-key` 返回 `ok: true`、`authType: token`,且目标 URL 正确;
|
||||||
- 客户端已发现 `nocobase-crm`;
|
- 客户端已发现 `nocobase-crm`;
|
||||||
- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误;
|
- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误;
|
||||||
- 默认状态仍是只读、uploads disabled、raw data disabled。
|
- 默认状态仍是只读、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,并说:
|
普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;不要索取或显示任何 Token;需要全局安装依赖、写客户端配置或启用写功能前先告诉我。安装后必须通过 doctor 和 crm_health:
|
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health:
|
||||||
<version-pinned INSTALL.md URL>
|
<version-pinned INSTALL.md URL>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,14 @@ The default stdio server is read-only. Add `--enable-writes` for fixed-schema pr
|
|||||||
|
|
||||||
## Configure
|
## 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
|
```bash
|
||||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
uv run --no-project --python 3.12 python scripts/agent_install.py \
|
||||||
nb env auth nb-mcp
|
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
|
||||||
nb env info nb-mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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:
|
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.
|
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.
|
`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:
|
First-install flow:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Complete browser login as the same operating-system user that runs the MCP.
|
# The Agent first runs connect-key in a user-controlled TTY. The user pastes a
|
||||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
# personal NocoBase API Key only into the hidden prompt; it never enters argv.
|
||||||
nb env auth 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
|
||||||
|
|
||||||
# Create and start the private, loopback-only user service.
|
# Create and start the private, loopback-only user service.
|
||||||
bash scripts/mcp_service.sh start
|
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
|
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:
|
Show the saved URL/header/client config:
|
||||||
|
|
||||||
|
|||||||
+12
-17
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 交付模型
|
## 交付模型
|
||||||
|
|
||||||
每位用户在自己的电脑运行 MCP,并使用自己的 NocoBase 账号登录 `nb` CLI:
|
每位用户在自己的电脑运行 MCP,并使用绑定本人普通业务身份/角色的个人 API Key 登录 `nb` CLI:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
本机 AI 客户端 → 本机 NocoBase CRM MCP → 本机 nb CLI 登录态 → 远程 NocoBase ACL
|
本机 AI 客户端 → 本机 NocoBase CRM MCP → 本机 nb CLI 登录态 → 远程 NocoBase ACL
|
||||||
@@ -20,7 +20,7 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程
|
|||||||
|
|
||||||
## 推荐:链接驱动的 Agent 安装
|
## 推荐:链接驱动的 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 可认证访问的分发渠道。不要把开发分支链接当作交付链接。
|
发布方必须保证安装 Agent 能读取链接和相同版本的代码。当前仓库为 private 时,用户需要已经连接有仓库权限的 GitHub 账号;面向普通外部用户的一条链接安装,则必须公开该版本的 `INSTALL.md` 与发布物,或放到用户 Agent 可认证访问的分发渠道。不要把开发分支链接当作交付链接。
|
||||||
|
|
||||||
@@ -35,15 +35,14 @@ uv sync --locked
|
|||||||
cp config.example.json config.json
|
cp config.example.json config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`config.json` 中的 `env` 默认是 `nb-mcp`。用自己的账号创建并登录同名环境:
|
`config.json` 中的 `env` 默认是 `nb-mcp`。正式终端用户不应手动尝试 `nb env add/auth`;安装 Agent 在用户可直接控制的本机终端中运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nb env add nb-mcp --api-base-url https://nocobase.example/api --auth-type oauth
|
uv run --no-project --python 3.12 python scripts/agent_install.py \
|
||||||
nb env auth nb-mcp
|
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
|
||||||
nb env info nb-mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
uv run nocobase-crm-mcp --config config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,重新执行:
|
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,由 Agent 重新启动 `connect-key`,用户在新的本机隐藏提示中输入新 Key;不要让用户手动尝试 `nb` 命令。
|
||||||
|
|
||||||
```bash
|
|
||||||
nb env auth nb-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
## MCP 客户端配置
|
## 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`。
|
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups` 和 `list_projects`。
|
||||||
3. 确认各自只能看到 NocoBase UI 中本人有权查看的数据。
|
3. 确认各自只能看到 NocoBase UI 中本人有权查看的数据。
|
||||||
4. 对无项目写权限的账号调用 `create_project`,确认 NocoBase 拒绝。
|
4. 对无项目写权限的账号调用 `create_project`,确认 NocoBase 拒绝。
|
||||||
@@ -106,11 +101,11 @@ Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级
|
|||||||
6. 对有写权限的账号创建测试项目并完成上传、关联和移除,确认审计字段记录的是该用户,而不是共享服务账号。
|
6. 对有写权限的账号创建测试项目并完成上传、关联和移除,确认审计字段记录的是该用户,而不是共享服务账号。
|
||||||
7. 退出或使 Token 过期后,确认 MCP 返回 `nb_auth_failed`,且不会降级使用其他凭证。
|
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 只存在于进程内存,不进入子进程参数和错误日志。
|
- MCP 获取附件请求凭证时,Token 只存在于进程内存,不进入子进程参数和错误日志。
|
||||||
- `.runtime/` 只保存本机 HTTP MCP 的独立 Bearer Token,并已被 Git 忽略。
|
- `.runtime/` 只保存本机 HTTP MCP 的独立 Bearer Token,并已被 Git 忽略。
|
||||||
- 不要共享操作系统账号运行 MCP;本机权限隔离依赖每位用户拥有独立的 `nb` 登录存储。
|
- 不要共享操作系统账号运行 MCP;本机权限隔离依赖每位用户拥有独立的 `nb` 登录存储。
|
||||||
@@ -147,7 +142,7 @@ bash scripts/mcp_service.sh start
|
|||||||
|
|
||||||
发布交付必须来自工作区干净、CI 全绿的带注释 Git Tag;不要从开发分支任意提交直接打包给用户。
|
发布交付必须来自工作区干净、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_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 管理员调整该用户的角色、字段或行级数据权限。
|
- `NO_PERMISSIONS`:由 NocoBase 管理员调整该用户的角色、字段或行级数据权限。
|
||||||
- 附件返回 `nb_upload_unauthorized`:重新登录后重试;不要把其他用户的 Token 写入环境变量。
|
- 附件返回 `nb_upload_unauthorized`:重新登录后重试;不要把其他用户的 Token 写入环境变量。
|
||||||
- 附件返回 `nb_upload_forbidden`:当前角色没有项目 create/update;不要为了绕过门禁而改用共享高权限账号。
|
- 附件返回 `nb_upload_forbidden`:当前角色没有项目 create/update;不要为了绕过门禁而改用共享高权限账号。
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
"packageManager": "uv",
|
"packageManager": "uv",
|
||||||
"nocobaseCli": ">=2.1"
|
"nocobaseCli": ">=2.1"
|
||||||
},
|
},
|
||||||
|
"authentication": {
|
||||||
|
"type": "personal-api-key",
|
||||||
|
"cliAuthType": "token",
|
||||||
|
"secretEntry": "user-controlled-private-tty",
|
||||||
|
"allowPassword": false,
|
||||||
|
"allowAgentChatSecret": false,
|
||||||
|
"allowCommandLineSecret": false
|
||||||
|
},
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"guide": "INSTALL.md",
|
"guide": "INSTALL.md",
|
||||||
"helper": "scripts/agent_install.py",
|
"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 {"-", "********"}:
|
if not isinstance(access_token, str) or not access_token.strip() or access_token in {"-", "********"}:
|
||||||
raise NbAuthenticationError(
|
raise NbAuthenticationError(
|
||||||
f"nb environment {self.env_name!r} is not authenticated. "
|
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()
|
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(
|
return _tool_error(
|
||||||
code="nb_auth_failed",
|
code="nb_auth_failed",
|
||||||
message=str(error),
|
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,
|
retryable=False,
|
||||||
)
|
)
|
||||||
except NbCommandError as error:
|
except NbCommandError as error:
|
||||||
|
|||||||
+223
-1
@@ -21,12 +21,14 @@ from collections.abc import Callable, Sequence
|
|||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
MIN_PYTHON = (3, 12)
|
MIN_PYTHON = (3, 12)
|
||||||
DEFAULT_ENV_NAME = "nb-mcp"
|
DEFAULT_ENV_NAME = "nb-mcp"
|
||||||
SERVER_NAME = "nocobase-crm"
|
SERVER_NAME = "nocobase-crm"
|
||||||
REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json")
|
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}$")
|
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]
|
Which = Callable[[str], str | None]
|
||||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
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(
|
def render_client_config(
|
||||||
project_root: str | Path | None = None,
|
project_root: str | Path | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -298,7 +507,7 @@ def doctor_project(
|
|||||||
"projectRoot": str(root),
|
"projectRoot": str(root),
|
||||||
"checks": [asdict(check) for check in checks],
|
"checks": [asdict(check) for check in checks],
|
||||||
"remainingVerification": [
|
"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.",
|
"Register the generated stdio MCP configuration in the user's client.",
|
||||||
"Call the MCP tool crm_health and require ok=true.",
|
"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("env_name")
|
||||||
set_env.add_argument("--json", action="store_true", dest="as_json")
|
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 = subparsers.add_parser("client-config")
|
||||||
client_config.add_argument("--enable-writes", action="store_true")
|
client_config.add_argument("--enable-writes", action="store_true")
|
||||||
return parser
|
return parser
|
||||||
@@ -351,6 +565,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
payload = set_config_env(args.project_root, args.env_name)
|
payload = set_config_env(args.project_root, args.env_name)
|
||||||
_print_result(payload, as_json=args.as_json)
|
_print_result(payload, as_json=args.as_json)
|
||||||
return 0
|
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":
|
if args.command == "client-config":
|
||||||
print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2))
|
print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2))
|
||||||
return 0
|
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
|
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"MCP URL: http://{display_host}:{port}{path}")
|
||||||
lines.append(f"nb binary: {nb_bin}")
|
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"
|
uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1"
|
||||||
lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}")
|
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))
|
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):
|
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:
|
def test_inspect_reports_required_tools_and_config_as_optional_before_prepare(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -81,6 +92,134 @@ class AgentInstallTests(unittest.TestCase):
|
|||||||
self.assertEqual(changed["env"], "nb-mcp-acme")
|
self.assertEqual(changed["env"], "nb-mcp-acme")
|
||||||
self.assertEqual(backup, original)
|
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:
|
def test_client_config_uses_absolute_paths_and_is_read_only_by_default(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -126,6 +265,10 @@ class AgentInstallTests(unittest.TestCase):
|
|||||||
version = package["project"]["version"]
|
version = package["project"]["version"]
|
||||||
self.assertEqual(manifest["version"], version)
|
self.assertEqual(manifest["version"], version)
|
||||||
self.assertEqual(manifest["distribution"]["releaseRef"], f"v{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.assertTrue(manifest["securityDefaults"]["readOnly"])
|
||||||
self.assertFalse(manifest["securityDefaults"]["uploads"])
|
self.assertFalse(manifest["securityDefaults"]["uploads"])
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class NbClientTests(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"api": {
|
"api": {
|
||||||
"apiBaseUrl": "https://nb.example/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": {
|
"api": {
|
||||||
"apiBaseUrl": "https://nb.example/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:
|
with self.assertRaises(NbAuthenticationError) as caught:
|
||||||
NbClient(env_name="customer-prod", runner=runner).environment_credentials()
|
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:
|
def test_get_record_and_query_records_use_expected_commands(self) -> None:
|
||||||
commands: list[list[str]] = []
|
commands: list[list[str]] = []
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ class ServerTests(unittest.TestCase):
|
|||||||
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
|
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
|
||||||
|
|
||||||
self.assertEqual(structured["error"]["code"], "nb_auth_failed")
|
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"])
|
self.assertFalse(structured["error"]["retryable"])
|
||||||
|
|
||||||
def test_tools_return_specific_error_when_nb_is_not_found(self) -> None:
|
def test_tools_return_specific_error_when_nb_is_not_found(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user