diff --git a/INSTALL.md b/INSTALL.md index c58775d..97ae281 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 /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 /scripts/agent_install.py \ + --project-root connect-key \ + --api-base-url --env-name nb-mcp --json ``` -如果 `nb-mcp` 不存在,为规范化后的目标 URL 创建 OAuth 环境,并在写操作后读回列表: +安装器会自动: -```bash -nb env add nb-mcp --api-base-url --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 /scripts/agent_install.py --project-root set-env --json -``` - -此命令修改前会保留 `config.json.pre-agent-install.bak`。然后启动登录: - -```bash -nb env auth -``` - -让用户亲自在浏览器完成登录;Agent 只负责保持命令运行、提示用户返回以及继续读取 CLI 的成功或错误信息。最后执行 `nb env info ` 验证,不得附加 `--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 /scripts/agent_install.py 然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成: - `doctor` 返回 `ok: true`; -- `nb env info ` 成功且目标 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 /scripts/agent_install.py 普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说: ```text -请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;不要索取或显示任何 Token;需要全局安装依赖、写客户端配置或启用写功能前先告诉我。安装后必须通过 doctor 和 crm_health: +请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health: ``` diff --git a/README.md b/README.md index f029be5..bcd741c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index 2b31a64..9af1b92 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -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;不要为了绕过门禁而改用共享高权限账号。 diff --git a/install-manifest.json b/install-manifest.json index aef3129..3037068 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -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", diff --git a/nocobase_crm_mcp/nb_client.py b/nocobase_crm_mcp/nb_client.py index 26974d9..bef8e4f 100644 --- a/nocobase_crm_mcp/nb_client.py +++ b/nocobase_crm_mcp/nb_client.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() diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 6584aa2..961e7ec 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -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 ` 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: diff --git a/scripts/agent_install.py b/scripts/agent_install.py index 39db0be..aefccff 100644 --- a/scripts/agent_install.py +++ b/scripts/agent_install.py @@ -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 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 diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 3a29b8a..a0420a3 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -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)) diff --git a/tests/test_agent_install.py b/tests/test_agent_install.py index d3fd575..7e67da6 100644 --- a/tests/test_agent_install.py +++ b/tests/test_agent_install.py @@ -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"]) diff --git a/tests/test_nb_client.py b/tests/test_nb_client.py index de06824..81be3ee 100644 --- a/tests/test_nb_client.py +++ b/tests/test_nb_client.py @@ -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]] = [] diff --git a/tests/test_server.py b/tests/test_server.py index 20bc348..efea53b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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: