feat: add secure personal key setup

This commit is contained in:
nac
2026-08-14 15:42:02 +00:00
parent 67d7ff6608
commit 9ad9474226
11 changed files with 434 additions and 59 deletions
+26 -24
View File
@@ -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 -10
View File
@@ -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
View File
@@ -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;不要为了绕过门禁而改用共享高权限账号。
+8
View File
@@ -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",
+1 -1
View File
@@ -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()
+4 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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))
+143
View File
@@ -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"])
+4 -3
View File
@@ -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]] = []
+2 -1
View File
@@ -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: