From 9f3d3ad5fca3d9533da9a8416d621c428d455df7 Mon Sep 17 00:00:00 2001 From: nac Date: Fri, 28 Aug 2026 07:01:20 +0000 Subject: [PATCH] feat: harden install and resumable OAuth login --- .github/workflows/ci.yml | 8 +- INSTALL.md | 130 +++++++---- README.md | 42 ++-- docs/ARCHITECTURE.md | 6 +- docs/LOCAL_DELIVERY.md | 12 +- docs/REFERENCE.md | 7 +- install-manifest.json | 26 ++- src/auth-service.ts | 377 ++++++++++++++++++++++--------- src/authorization-store.ts | 111 +++++++++ src/cli.ts | 52 +++-- src/credentials.ts | 35 +-- src/crm-service.ts | 76 ++++--- src/oauth.ts | 267 ++++++++++++++++++---- src/private-json.ts | 61 +++++ src/tools.ts | 226 +++++++++---------- test/auth-service.test.ts | 449 +++++++++++++++++++++++++++++++++---- test/cli-package.test.ts | 88 +++++++- test/oauth.test.ts | 175 ++++++++++++++- test/tools.test.ts | 45 +++- 19 files changed, 1721 insertions(+), 472 deletions(-) create mode 100644 src/authorization-store.ts create mode 100644 src/private-json.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34224d0..f497880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,18 @@ name: CI on: pull_request: push: - branches: [master] + branches: [main, master] permissions: contents: read jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - uses: actions/checkout@v4 diff --git a/INSTALL.md b/INSTALL.md index 8c3404c..89f2e93 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,16 +1,19 @@ -# 安装 NocoBase CRM MCP +# 安装或更新 NocoBase CRM MCP -这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在浏览器中登录自己的 NocoBase 账号完成授权。 +这是一份供终端型 AI Agent 执行的通用安装与更新协议,不依赖任何特定 Agent 或 MCP 客户端。用户每次都可以提供同一个链接:Agent 必须先识别是首次安装、更新还是已经为最新版,再执行对应流程。只有确实需要认证时,用户才在浏览器中登录自己的 NocoBase 账号完成授权。 -如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更或等待用户在 NocoBase 网页登录授权时才中断用户。 +统一入口:`https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md`。执行 Agent 必须按下文将其固定到任务开始时的完整 Commit SHA。 + +如果你是执行 Agent,请直接完成流程,不要让用户逐条尝试命令。只有在确认系统工具变更或确实等待用户在 NocoBase 网页登录授权时才中断用户;不得仅因更新而要求用户重新登录。 ## 完成标准 -- 从 Gitea `main` 解析安装开始时的最新完整 Commit SHA,并将本次安装的文档、源码和构建全部固定到该 Commit; +- 从 Gitea `main` 解析任务开始时的最新完整 Commit SHA,并将本次任务的文档、源码和构建全部固定到该 Commit; - 只连接固定的 NocoBase API `https://justconn.top/api`; - MCP 在用户本机以 stdio 运行,默认只读; - 通过 NocoBase 原生 OAuth 2.1 认证当前用户,权限仍跟随本人的业务角色; - 当前 MCP 客户端已注册 `nocobase-crm`; +- 首次安装、更新或已是最新的判断有明确依据,更新保留原有 CRM 配置、个人凭据和只读/写入模式; - `crm_health` 返回 `ok=true`。 ## 安全规则 @@ -20,15 +23,28 @@ - OAuth Token 不得进入聊天、命令参数、环境变量、CRM 配置、剪贴板日志或 MCP 客户端配置。 - 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。 - 不静默安装 Git、Node.js、包管理器或其他系统工具。 -- 解析 `main` 后只使用带完整 Commit SHA 的不可变 URL;一次安装中不得再次读取移动中的 `main` 文件。 +- 解析 `main` 后只使用带完整 Commit SHA 的不可变 URL;一次任务中不得再次读取移动中的 `main` 文件。 - 最新 Commit 无法下载、检查、测试或构建时必须停止,不得静默退回旧 Release、旧 Commit 或其他来源。 - 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。 +- 更新不得读取、复制、覆盖或删除个人凭据,不得覆盖现有 CRM `config.json`,也不得擅自改变 `--enable-writes` 模式。 - 默认不启用写入、原始数据、后台服务或网络监听。 - 不询问或接受其他 NocoBase URL,也不允许通过参数、配置或凭证文件覆盖固定 API 地址。 -## 安装流程 +## 安装或更新流程 -### 1. 解析并固定最新 Commit +### 1. 判断首次安装、更新或已有最新版 + +先检查当前 MCP 客户端配置中的 `nocobase-crm` 条目、其启动命令和参数,以及推荐用户级安装目录中是否已有可执行文件。若存在 `/install-receipt.json`,可以读取其中不含秘密的安装记录;只有 `schemaVersion=1`、仓库 URL 完全匹配、`commitSha` 为 40 位十六进制、`tarballSha256` 为 64 位十六进制且其余字段类型正确时才视为有效。无效或缺失的记录不能作为跳过构建的依据。不得打开、解析或输出凭证文件内容,只能在必要时判断凭证文件是否存在。 + +按以下规则确定初始状态: + +- **首次安装:** 没有可用的 `nocobase-crm` 配置和已安装命令。默认只读,除非用户在本次任务中明确要求启用固定项目写入。 +- **更新:** 已有配置和可执行命令。沿用其安装前缀、CRM 配置路径及 `--enable-writes` 状态,除非用户明确要求变更;个人凭据始终原地保留。 +- **修复旧安装:** 只找到配置、命令或安装目录的一部分。说明检测到的不一致,然后按更新方式修复缺失部分,不删除仍可用的配置或凭据。 + +记录检测结果、现有安装目录、当前版本、原读写模式,以及安装记录中的旧完整 Commit SHA(若存在)。旧版本没有安装记录不是错误,第一次更新时正常重建并补写记录。不得仅凭 npm 包版本判断 Commit 是否相同,因为多个 Commit 可以使用同一包版本。 + +### 2. 解析并固定最新 Commit 确定当前 Agent/MCP 客户端及其配置路径,以及普通用户应用数据目录。NocoBase API 已固定为 `https://justconn.top/api`,不需要向用户询问地址。 @@ -38,7 +54,7 @@ - 分支:`main` - 分支 API:`https://git.justconn.top/api/v1/repos/gitea-admin/nocobase-mcp/branches/main` -通过当前用户已经授权的 Gitea API/连接读取分支 API,只解析一次 `commit.id`,并验证它是 40 位十六进制完整 SHA。记录为 `` 后,本次安装即使发现 `main` 又有新提交,也继续完成这个已固定快照;下一次安装才重新解析最新提交。 +通过当前用户已经授权的 Gitea API/连接读取分支 API,只解析一次 `commit.id`,并验证它是 40 位十六进制完整 SHA。记录为 `` 后,本次任务即使发现 `main` 又有新提交,也继续完成这个已固定快照;下一次安装或更新才重新解析最新提交。 立即从以下不可变地址重新读取安装协议,后续不再依赖最初的 `main` 页面内容: @@ -48,13 +64,15 @@ https://git.justconn.top/gitea-admin/nocobase-mcp/raw/commit//INSTAL 仓库访问受限时必须使用当前用户已有的授权连接;无权访问时停止,不得索取或接收用户在聊天中发送的 Gitea 访问令牌。 -### 2. 一次性说明本机变更 +若来源为本仓库的有效安装记录中的旧 SHA 等于 ``,还必须确认已安装命令的 `--version` 与记录一致、`--help` 正常、MCP 配置指向存在的入口。全部一致时将操作判定为**已有最新版**:跳过源码下载、构建和 npm 安装,直接进入 MCP 配置核对与 `crm_health` 验收。任何一项不一致都按更新/修复继续,不能因为包版本看起来相同而跳过。 -检查 `node --version` 和 `npm --version`。要求 Node.js `>=20.11`。 +### 3. 一次性说明本机变更 + +向用户一次性说明本次判定为首次安装、更新、修复还是已有最新版,以及目标 Commit、安装目录、将保留的内容和预计变更。检查 `node --version` 和 `npm --version`;需要构建或安装时要求 Node.js `>=20.11`。 如果 Node.js 缺失或版本过低,向用户说明需要安装或升级 Node.js、官方来源、目标位置和影响,取得明确同意后再使用操作系统的正规安装方式。不能因为 npm 不存在就直接尝试全局安装。还需确认 npm 能读取锁定依赖;不得改用未锁定的依赖版本。 -正常安装只会留下: +首次安装或更新只会留下: - 应用数据目录中的 npm 包; - 不含凭据的 CRM `config.json`; @@ -63,7 +81,9 @@ https://git.justconn.top/gitea-admin/nocobase-mcp/raw/commit//INSTAL 源码快照、构建期 `node_modules` 和本地 tarball 只放在新建的临时目录,安装完成后不作为持久文件保留。不会安装 Git、Python、uv、`nb CLI`、后台服务或本机监听端口。 -### 3. 下载、验证并构建固定 Commit +更新或修复前备份 MCP 客户端配置和非秘密 CRM `config.json`。凭据位于包目录之外,只原地保留,不为更新另行读取或复制。若判定为已有最新版且配置无需修改,不要为了形式上的更新创建多余备份或重写文件。 + +### 4. 下载、验证并构建固定 Commit 在新建的临时目录中下载 `` 的源码归档,不下载 Release 资产,也不从移动的 `main` URL 下载源码。Windows 使用 `.zip`,macOS/Linux 使用 `.tar.gz`;两种归档都必须包含同一个已解析的完整 Commit SHA: @@ -72,11 +92,18 @@ Windows: https://git.justconn.top/gitea-admin/nocobase-mcp/archive/.tar.gz ``` -不要让用户选择版本,也不要因为操作系统不同而改用 Release。每次新的安装任务都重新解析当时 `main` 的最新 Commit,随后只下载并使用该 SHA。 +不要让用户选择版本,也不要因为操作系统不同而改用 Release。每次新的安装或更新任务都重新解析当时 `main` 的最新 Commit,随后只下载并使用该 SHA。只有已按第 2 步确认安装记录与最新 SHA 相同且现有入口完整时,才跳过本节。 下载后记录归档 SHA-256。解包前拒绝绝对路径、`..` 路径穿越或逃出临时目录的链接;源码根目录必须包含 `package.json`、`package-lock.json`、`install-manifest.json`、`INSTALL.md` 和 `src/`。 -确认归档中的 `install-manifest.json` 声明 `distribution.branch` 为 `main`、`distribution.strategy` 为 `resolve-latest-commit-then-pin`。随后只在这个临时源码目录运行: +安全解包实现必须在写入文件前完成兼容性预检,不要把解包工具的 API 差异误报为源码构建失败,也不要为解包安装或升级 Python: + +- Python 不是安装前置条件。若 Agent 选择使用本机已有的 Python `tarfile`,不得无条件调用仅较新 Python 才支持的 `TarFile.extractall(..., filter="data")`;必须先用 `inspect.signature` 检查 `filter` 参数是否存在。 +- 支持 `filter` 时仍需使用 `data` 过滤器。旧版 Python 不支持时,必须在解包前验证完整成员列表,拒绝绝对路径、盘符或 UNC 路径、任何 `..` 分段、重复目标、符号链接、硬链接、设备文件和 FIFO;随后只逐项创建目录,并通过 `extractfile()` 将普通文件复制到已确认位于新建临时根目录内的目标。不得直接调用不带过滤器的 `extractall()`。 +- Windows `.zip` 也必须先验证所有条目并拒绝上述路径、链接和特殊文件,再逐项解包。解包目录必须是本次任务新建且仅当前用户可写的临时目录。 +- 这种预先选择兼容实现仍是在处理同一个已校验哈希的归档和同一个固定 Commit,不属于版本回退,不需要重新解析 `main`、重新下载归档或中断用户。只有当前环境没有任何可安全验证并解包归档的现有工具时才停止。 + +确认归档中的 `install-manifest.json` 声明 `distribution.branch` 为 `main`、`distribution.strategy` 为 `resolve-latest-commit-then-pin`、`installation.supportsInPlaceUpdate=true`、`installation.sameCommitBuildSkip=true`,并且 `installation.installReceipt` 声明 `schemaVersion=1`、`containsSecrets=false`。随后只在这个临时源码目录运行: ```bash npm ci @@ -88,7 +115,7 @@ npm pack --pack-destination 任何一步失败都停止并报告 `` 与失败步骤,不得改装旧 Release。记录本地生成 npm tarball 的 SHA-256;安装结束时只报告哈希,不发送或上传 tarball。 -选择用户级安装前缀,例如: +首次安装时选择用户级安装前缀;更新或修复时沿用检测到的原前缀。例如: - Windows:`%LOCALAPPDATA%\nocobase-crm-mcp\app` - macOS:`~/Library/Application Support/nocobase-crm-mcp/app` @@ -100,47 +127,64 @@ npm pack --pack-destination npm install --prefix --omit=dev ``` +`config.json` 和个人凭据必须位于应用数据目录而不是 npm 包目录内;不得在安装前清空 ``,也不得通过删除整个应用数据目录来“更新”。如果安装命令失败,报告失败并保留可恢复信息,不得删除原配置或凭据。 + 定位安装后的命令:POSIX 为 `/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。运行 `--version` 和 `--help`,确认版本等于源码 `package.json`,并且帮助中包含本 Commit 安装协议使用的 `setup --no-browser` 选项;不一致时停止,不得继续注册旧二进制。 -### 4. 在登录前注册 MCP +### 5. 注册或核对 MCP -先运行: +首次安装、更新或修复后运行;已有最新版时也可用它核对当前配置: ```bash - client-config --json + client-config [--config ] [--enable-writes] --json ``` +首次安装可省略 `--config` 使用默认位置,并默认省略 `--enable-writes`。更新或修复必须把原 MCP 条目中的 CRM 配置路径传给 `--config`,并保持原 `--enable-writes` 状态;只有用户本次明确要求改变时才增删该参数。 + 该命令会在需要时从包内模板准备默认 `config.json`,不要求凭据,也不会发起 OAuth。读取返回的 `clientConfig`: -1. 展示客户端配置路径和新增的 `nocobase-crm` 条目; -2. 创建可恢复备份; -3. 结构化合并该 server,保留所有其他配置; +1. 展示客户端配置路径和目标 `nocobase-crm` 条目; +2. 仅在条目需要修改时创建可恢复备份; +3. 仅当条目有变化时结构化合并该 server,保留所有其他配置; 4. 重载或重启 MCP 客户端,并在客户端要求时让用户信任该连接器。 没有凭据时 stdio MCP 仍应正常启动并响应 `tools/list`。此时业务工具会返回结构化的 `nocobase_auth_failed`,而不是导致整个 MCP 进程退出。 -### 5. 通过 MCP 把登录链接交给用户 +### 6. 先验收现有登录,必要时再把链接交给用户 -注册完成后调用: +注册或重载完成后,**先调用 `crm_health`**。若返回 `ok=true`,说明现有登录仍然有效,直接完成验收;不得为了首次安装或更新流程的形式完整而调用登录工具、生成新链接或要求用户再次授权。 + +只有 `crm_health` 明确返回 `nocobase_auth_failed` 时才进入登录:首次安装且没有可用凭据时调用 `nocobase_login({})`;更新/修复中已有凭据被 NocoBase 拒绝时调用 `nocobase_login({"force": true})`。如果 `crm_health` 返回 ACL、配置、网络或超时错误,处理对应错误,不能用重新登录掩盖它。 + +首次安装且没有可用凭据时调用: ```text nocobase_login({}) ``` +现有凭据已被 NocoBase 拒绝时改为调用: + +```text +nocobase_login({"force": true}) +``` + 该工具通过 NocoBase OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access`,并立即返回: - `login_url`:只指向 `https://justconn.top` 的完整授权链接; +- `verification_uri_complete`:与 `login_url` 相同的 OAuth 标准语义别名; - `user_code`:供用户在页面核对的确认码; - `expires_in_seconds`:剩余有效时间; - `browser_opened`:桌面浏览器是否已成功自动打开。 -无论 `browser_opened` 是真是假,都应立即把 `login_url`、`user_code` 和有效期发给用户。用户只在 NocoBase 网页输入账号信息、核对确认码并点击授权。随后调用 `nocobase_login_status({})`;授权仍在等待时它会安全地重复同一链接,成功时返回 `status=authenticated`。不要高频轮询;可在用户确认完成后或按合理间隔查询。 +返回值还必须明确标记 `authorization_type=oauth_device_user_verification`、`intended_recipient=requesting_user`、`delivery_channel=current_private_conversation`、`shareable_with_requesting_user=true`、`private_conversation_required=true`、`public_sharing_allowed=false`、`contains_bearer_credentials=false`、`contains_device_code=false` 和 `remote_desktop_required=false`。这些字段区分“应交给发起用户的短期验证信息”和绝不能离开本机的 OAuth 凭据。 -取得 `authenticated` 后调用 `crm_health`,要求 `ok=true`。如果已有凭据但 `crm_health` 明确返回认证失败,调用 `nocobase_login({"force": true})` 创建新的授权,不得换用共享高权限 Token 或 Key。 +无论 `browser_opened` 是真是假,都应立即把 `login_url`、`user_code` 和有效期原样交给发起登录的用户。无界面或远程环境必须通过当前一对一私聊发送;这是 OAuth Device Flow 规定的用户验证交接,不是密码、API Key、Bearer Token 或内部 `device_code`,不得以“链接包含一次性凭据”为由拒绝发送,也不得要求用户操作服务器远程桌面。当前渠道若是群聊或公开频道,先让用户转到私聊,不得把信息发给第三方。用户只在 NocoBase 网页输入账号信息、核对确认码并点击授权。随后调用 `nocobase_login_status({})`;授权仍在等待时它会安全地重复同一链接,成功时返回 `status=authenticated`。不要高频轮询;可在用户确认完成后或按合理间隔查询。 -这一流程同时适用于桌面 GUI、Hermes Agent、OpenClaw 和远程无界面服务器:桌面环境会尝试自动打开浏览器;远程环境直接把同一个 HTTPS 链接经当前 Messenger 或聊天通道交给用户。后台轮询、凭据验证和保存都由 MCP 进程完成,Agent 只能看到安全的链接、确认码和状态,看不到 OAuth Token 或设备码。一个 MCP 进程只能服务一个受信任的用户身份;多用户必须使用相互隔离的 Agent/MCP 实例和凭据路径。 +取得 `authenticated` 后再次调用 `crm_health`,要求 `ok=true`。不得换用共享高权限 Token 或 Key。 -设备授权约十分钟后失效。密码和浏览器会话始终留在 NocoBase,程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期。POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`;Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 不会出现在工具返回、命令参数、MCP 配置或日志中。 +这一流程同时适用于桌面 GUI、Hermes Agent、OpenClaw 和远程无界面服务器:桌面环境会尝试自动打开浏览器;远程环境直接把同一个 HTTPS 链接经当前一对一 Messenger 或私聊交给用户,用户可在自己的手机或电脑打开。待授权会话会写入凭证旁边的短期私有文件;即使 MCP stdio 进程在两条 Messenger 消息之间退出或重启,下一次 `nocobase_login_status` 也必须恢复同一个链接并继续兑换,不得生成新链接让用户重复授权。该文件包含不向 Agent 暴露的 OAuth 设备码,POSIX 权限必须为 `0600`,且在成功、取消或过期后立即删除。正式 Token 的验证和保存仍由 MCP 完成;Agent 只能看到应交给发起用户的链接、确认码和状态。一个 MCP 凭据路径只能服务一个受信任的用户身份;多用户必须使用相互隔离的 Agent/MCP 实例和凭据路径。 + +设备授权约十分钟后失效。密码和浏览器会话始终留在 NocoBase,程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期。POSIX 凭证目录使用 `0700`,正式凭证文件和短期待授权文件都使用 `0600`;Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 和设备码不会出现在工具返回、命令参数、环境变量、MCP 配置或日志中。 如果某个 MCP 客户端在登录前完全无法加载自定义连接器,才使用 CLI 备用入口: @@ -148,7 +192,7 @@ nocobase_login({}) setup --no-browser --json ``` -本机桌面终端也可省略 `--no-browser` 以尝试自动打开网页。CLI 会先验证或准备配置,再开始 OAuth,避免用户完成网页授权后才因配置路径不存在而丢失结果。Agent 必须持续读取该进程的输出、把链接和确认码发给用户,并让进程继续轮询。 +本机桌面终端也可省略 `--no-browser` 以尝试自动打开网页。CLI 会先验证或准备配置,再开始 OAuth,避免用户完成网页授权后才因配置路径不存在而丢失结果。使用 `--json` 时,CLI 会在 stderr 输出 `event=authorization_required` 的结构化事件和上述交付标记。Agent 必须持续读取该进程的输出、把链接和确认码原样发到当前私聊,并让进程继续轮询;不得关闭进程或要求远程桌面。 只有在 OAuth 暂时不可用、管理员明确要求且用户拥有个人 Authorize Key 时,才使用: @@ -158,7 +202,23 @@ nocobase_login({}) 该备用模式必须在用户可直接控制的安全 TTY 中隐藏输入 Key;仍不得让用户把 Key 发给 Agent。 -最终只报告完整 Commit SHA、源码版本、本地构建 tarball 的 SHA-256、安装目录、配置备份位置、认证方式和只读/写入模式,不显示任何 Token 或 Key。 +在 `crm_health` 成功后,首次安装、更新或修复必须以原子替换方式写入 `/install-receipt.json`,POSIX 权限使用 `0600`。使用以下固定字段,把占位值替换为本次实际结果: + +```json +{ + "schemaVersion": 1, + "repository": "https://git.justconn.top/gitea-admin/nocobase-mcp", + "commitSha": "<40-character-commit-sha>", + "packageVersion": "", + "tarballSha256": "<64-character-sha256>", + "installedAt": "", + "writesEnabled": false +} +``` + +`writesEnabled` 必须按最终实际模式写成 `true` 或 `false`,不能始终照抄示例。该记录不得包含 Token、Key、设备码或其他凭据。已有最新版且记录完整时不重写该文件。 + +最终报告本次结果是“已安装”“已更新”“已修复”还是“已是最新”,并报告旧/新完整 Commit SHA(旧 SHA 可用时)、源码版本、安装目录、配置备份位置、认证是否沿用以及只读/写入模式。只有本次实际构建了 tarball 才报告新 SHA-256;已有最新版不得谎称重新构建。任何情况下都不显示 Token、Key 或设备码。 ## 可选写入 @@ -166,18 +226,12 @@ nocobase_login({}) ## 故障处理 -- 浏览器未打开:把 `nocobase_login` 返回的 `login_url` 和 `user_code` 发给用户手动打开;不需要重启 MCP。 +- 浏览器未打开:把 `nocobase_login` 返回的 `login_url` 和 `user_code` 发到发起用户的当前私聊,让用户在自己的手机或电脑打开;不需要重启 MCP 或使用服务器远程桌面。 - 授权超时或被取消:再次调用 `nocobase_login` 生成新链接并重新核对确认码。 - `nocobase_auth_failed`:调用 `nocobase_login({"force": true})` 重新授权;不要换用共享高权限 Token 或 Key。 - `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。 - Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。 - `main` 或 Commit 归档无权访问:报告 Gitea 权限问题,不索取聊天中的访问令牌,也不退回旧 Release。 +- 解包工具兼容性:在写入前改用本节规定的安全兼容实现,继续处理同一归档;不得先调用已知不受支持的 `tarfile filter` 参数,也不得因此重新解析 Commit、安装 Python 或回退版本。 - 最新 Commit 的依赖安装、检查、测试或构建失败:报告 SHA 和原始失败步骤后停止;不得以“先装可用版本”为由降级。 -- 安装过程中 `main` 更新:不影响当前任务,继续完成开始时固定的 SHA;下次安装再取得新 SHA。 - -## 用户可发送给 Agent 的最短指令 - -```text -请按这个 INSTALL.md 帮我完成安装。每次先解析当前 Gitea 仓库 main 分支的最新完整 Commit SHA,并把本次安装固定在该 Commit;不要退回旧 Release。默认只读;请先注册 MCP,再调用 nocobase_login,把登录链接和确认码发给我,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health: -https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md -``` +- 任务过程中 `main` 更新:不影响当前任务,继续完成开始时固定的 SHA;下次安装或更新再取得新 SHA。 diff --git a/README.md b/README.md index 69c872e..911cb83 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # NocoBase CRM MCP -在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。安装时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。 +在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。首次需要认证时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。 -## 安装 +## 安装或更新 -推荐把安装交给支持终端操作的 AI Agent 完成。你不需要克隆源码,也不需要逐条执行安装命令。 +推荐把首次安装和后续更新都交给支持终端操作的 AI Agent 完成。同一段提示可以重复使用:Agent 会先检查本机状态,未安装时完成首次安装,已安装时保留个人配置和登录状态并更新到最新 Commit。你不需要克隆源码,也不需要逐条执行命令。 ### 安装前准备 @@ -13,39 +13,34 @@ - 你自己的 NocoBase 账号; - 能读取当前 Gitea 仓库 `main` 分支,并能访问 npm 软件源。 -如果 Node.js 尚未安装,安装 Agent 会先说明需要进行的系统变更,并在获得你确认后再继续。 +如果 Node.js 尚未安装,Agent 会先说明需要进行的系统变更,并在获得你确认后再继续。 -支持 Windows、macOS 和 Linux。三个平台都会安装 `main` 在任务开始时的最新 Commit;Windows 下载该 Commit 的 `.zip`,macOS/Linux 下载同一 Commit 的 `.tar.gz`。 +支持 Windows、macOS 和 Linux,三个平台都会使用任务开始时 `main` 的最新 Commit。 -不需要提前创建或复制 Authorize Key。MCP 注册后,Agent 会调用登录工具并把 NocoBase 官方网页链接发给你;桌面环境通常也会自动打开该页面。你在网页中确认授权后,Agent 会自动继续验收。 +不需要提前创建或复制 Authorize Key。MCP 注册或重载后,Agent 会先检查现有登录;只有确实需要认证时才调用登录工具并把 NocoBase 官方网页链接发给你。桌面环境通常也会自动打开该页面,你确认授权后 Agent 会自动继续验收。 -### 最快安装方式 +### 最快方式 -把下面整段直接复制给安装 Agent: +无论是第一次安装还是以后更新,都把下面整段直接复制给 Agent: ```text -请按这个 INSTALL.md 帮我完成 NocoBase CRM MCP 安装。 -每次先解析当前 Gitea 仓库 main 分支的最新完整 Commit SHA,并把本次安装固定在该 Commit;不要退回旧 Release。 -默认只读;请先注册 MCP,再调用 nocobase_login,把登录链接和确认码发给我,让我只在 NocoBase 官方网页完成授权。 -不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前请验证 crm_health 返回 ok=true。 - -安装协议: +请按以下 INSTALL.md 帮我安装或更新 NocoBase CRM MCP。自动判断首次安装、更新、修复或已是最新,使用 main 当前最新 Commit,并严格执行文档中的安全与验收规则;已有安装时保留配置、凭据和读写模式。只有确实需要登录时,才把 NocoBase 官方网页链接和确认码发给我。无界面或远程环境也要发到当前私聊;它们不是密码、Token、Key 或 device_code,不要要求远程桌面。安装系统工具前先征得我同意。完成前确认 crm_health 返回 ok=true。 https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md ``` -1. Agent 会读取 Gitea `main` 的最新 Commit,把源码快照、安装协议和构建过程固定到同一个 SHA,再在临时目录完成检查和打包。 -2. Agent 会先注册 MCP,再调用 `nocobase_login`。桌面环境通常自动打开浏览器;无界面或远程 Agent 会把同一个授权链接和确认码发到当前对话。 -3. 你在 `justconn.top` 登录、核对确认码并授权后,Agent 会轮询 `nocobase_login_status`,最后确认 `crm_health` 返回 `ok=true`。 +Agent 会固定任务开始时的最新 Commit,按本机状态执行安装、更新、修复或直接验收;已有安装会保留个人状态,相同 Commit 不会重复构建。注册后先检查 `crm_health`,只有认证确实缺失或失效时才发起登录。桌面环境通常自动打开浏览器;远程 Agent 会把同一授权链接发到当前对话,MCP 重启后也能继续该授权。 -完整的安装和验收规则见 [INSTALL.md](INSTALL.md)。该 `main` 链接是安装入口;Agent 必须先解析一次最新 Commit,然后重新读取该 Commit 中不可变的同一文件,不能在一次安装中混用不同提交。 +完整的安装、更新和验收规则见 [INSTALL.md](INSTALL.md)。该 `main` 链接是统一入口;Agent 必须先解析一次最新 Commit,然后重新读取该 Commit 中不可变的同一文件,不能在一次任务中混用不同提交。 ## 重要注意事项 - **只在 NocoBase 网页登录:** 安装程序不会读取浏览器 Cookie,也不会接触你的密码;OAuth 令牌只保存到权限受限的个人凭据文件,不会进入聊天、命令参数、环境变量、CRM 配置、MCP 客户端配置或日志。 - **默认只读:** 安装后默认只能查询 CRM,不会创建、更新或删除业务数据。 - **权限跟随本人:** MCP 不会绕过 NocoBase 的角色、字段权限和行级权限,也不会使用共享管理员账号。 +- **远程登录不需要远程桌面:** Hermes Agent、OpenClaw 或无界面服务器会把短期 OAuth Device Flow 验证链接和确认码发到发起用户的当前私聊;它们不能发到群聊、公开频道或第三方,但不是密码、Token、Key 或内部 `device_code`。 - **只连接公司环境:** 程序固定连接 `https://justconn.top/api`,不会要求你填写其他 NocoBase 地址。 -- **始终安装最新提交:** 每次安装只解析一次 `main` 的最新 Commit SHA,随后固定该快照完成构建;不会因为旧 Release 可用而安装旧代码。 +- **始终使用最新提交:** 每次安装或更新只解析一次 `main` 的最新 Commit SHA,随后固定该快照完成任务;不会因为旧 Release 可用而安装旧代码。 +- **更新保留个人状态:** 更新不会覆盖 CRM `config.json`、OAuth 凭据或现有只读/写入模式;已有登录有效时不会要求重新授权。 - **安装在用户目录:** 不使用 `sudo`,不会全局安装本项目,也不需要 Git、Python、uv 或 `nb CLI`。源码和构建依赖只存在于临时目录,安装后仅保留打包运行文件。 - **受限仓库需要授权:** 如果 Agent 无法读取 Gitea `main` 或对应 Commit 快照,请联系仓库管理员开通权限,不要把访问令牌发到聊天中。 @@ -77,20 +72,21 @@ https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md ## 遇到问题 - 尚未登录:让 Agent 调用 `nocobase_login`,把返回的 `login_url` 和 `user_code` 发给你;授权后调用 `nocobase_login_status`。 +- 只想检查更新:仍发送上面的统一提示;如果本机已经是 `main` 当前最新 Commit,Agent 会直接验证并报告“已是最新”,不重复构建或登录。 - 登录失效:让 Agent 调用 `nocobase_login` 并设置 `force=true`,重新在浏览器授权;正常情况下访问令牌会在后台自动刷新。 -- 浏览器没有打开:直接打开 `nocobase_login` 返回的 `justconn.top` 链接。即使桌面自动打开失败,该链接仍会返回给 Agent。 +- 浏览器没有打开:让 Agent 把 `nocobase_login` 返回的 `justconn.top` 链接和确认码发到当前私聊,然后在你的手机或电脑浏览器打开;不需要服务器远程桌面。若当前是群聊,请先转到与 Agent 的一对一私聊。 - MCP 客户端暂时无法调用登录工具:可在本人终端运行安装程序的 `setup --no-browser`,这是备用入口。 - 仅在 OAuth 暂时不可用且管理员明确要求时,才使用 `setup --manual-key` 的隐藏输入备用模式;不要把 Key 发到聊天中。 - 返回 `NO_PERMISSIONS`:请检查本人 NocoBase 角色和数据权限。 - 请求超时:确认可以访问 `https://justconn.top`,或缩小查询范围。 -- 最新 Commit 无法解析或构建失败:停止安装并报告 Commit SHA 和失败步骤,不要退回旧 Release。 +- 最新 Commit 无法解析或构建失败:停止本次任务并报告 Commit SHA 和失败步骤,不要退回旧 Release。 - 源码快照无法下载:确认当前账号或 Agent 已获得 Gitea 仓库的读取权限。 更多故障处理和更新、回滚说明见 [本机交付与权限验收](docs/LOCAL_DELIVERY.md)。 ## 更多文档 -- [完整安装协议](INSTALL.md):供安装 Agent 解析并固定最新 Commit、构建、安装和验收。 +- [完整安装/更新协议](INSTALL.md):供 Agent 识别本机状态、固定最新 Commit、构建、安装或更新并验收。 - [命令与工具参考](docs/REFERENCE.md):CLI 命令、MCP 工具和可选运行开关。 - [内部架构](docs/ARCHITECTURE.md):模块边界、身份与网络安全设计。 - [本机交付与权限验收](docs/LOCAL_DELIVERY.md):运维、权限验收、更新和回滚。 @@ -107,4 +103,4 @@ npm run build node dist/index.js --help ``` -本项目要求 Node.js 20.11 或更高版本。安装 Agent 会在临时目录从锁定依赖构建最新 Commit;最终运行包不含额外 npm 运行时依赖,用户不需要长期保留源码开发环境。 +本项目要求 Node.js 20.11 或更高版本。需要安装或更新时,Agent 会在临时目录从锁定依赖构建最新 Commit;最终运行包不含额外 npm 运行时依赖,用户不需要长期保留源码开发环境。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 777a1ad..7d01ad6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,8 +11,8 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien - `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。 - `src/nocobase-endpoints.ts` 集中固定 API、OAuth issuer、发现地址和 resource identifier。 - `src/oauth.ts` 实现 OAuth 发现、动态公共客户端注册、设备授权、浏览器启动、轮询和刷新;不读取浏览器 Cookie,也不监听本机端口。 -- `src/auth-service.ts` 把设备授权包装成 MCP 可调用的开始/状态会话;先返回安全的网页链接,再在后台验证和保存授权结果。 -- `src/credentials.ts` 管理用户级私有凭证文件与进程内缓存。默认保存 OAuth 访问/刷新令牌,兼容旧版个人 Key;任何凭据都不进入 argv、环境变量、CRM 配置或 MCP 配置。 +- `src/auth-service.ts` 把设备授权包装成 MCP 可调用的开始/状态会话;先返回安全的网页链接,再跨 stdio 进程恢复、验证和保存授权结果。 +- `src/authorization-store.ts` 管理短期待授权文件、跨进程独占锁、崩溃恢复和锁所有权;`src/credentials.ts` 管理用户级私有凭证及进程内缓存;两者共用 `src/private-json.ts` 的私有目录检查和原子 JSON 写入。默认保存 OAuth 访问/刷新令牌,兼容旧版个人 Key;任何凭据都不进入 argv、环境变量、CRM 配置或 MCP 配置。 - `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。 - `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。 - `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。 @@ -24,7 +24,7 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien - 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。 - OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。 - Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。 -- MCP 可以在无配置、无凭据状态下准备默认配置并启动;只有业务请求需要身份。`nocobase_login` 返回网页链接和确认码,后台完成的 Token 不经过 Agent。 +- MCP 可以在无配置、无凭据状态下准备默认配置并启动;只有业务请求需要身份。`nocobase_login` 返回应交给发起用户当前私聊的 OAuth Device Flow 网页链接、确认码和机器可读交付边界;链接不是 Bearer 凭据,无界面服务器不需要远程桌面。内部设备码只进入短期 `0600` 文件;`nocobase_login_status` 可在新进程中继续兑换,完成的 Token 不经过 Agent。 - Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。 - Token 会从 HTTP 错误中替换为 ``。 - stdio 是唯一默认传输,不创建后台服务或监听端口。 diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index fc03e1b..05905da 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -10,17 +10,17 @@ 项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。个人 Authorize Key 仅作为显式备用认证方式。 -## 最新 Commit 交付 +## 最新 Commit 安装与更新 -默认安装不等待 Release。Agent 在每次安装开始时读取 Gitea `main` 的最新完整 Commit SHA,然后把这次任务固定在该 SHA: +默认安装和更新都不等待 Release。用户可以每次发送同一个 INSTALL 链接;Agent 先检查现有 MCP 条目、安装入口和 `install-receipt.json`,再读取 Gitea `main` 的最新完整 Commit SHA,并把这次任务固定在该 SHA。需要首次安装、更新或修复时: - 从带 SHA 的不可变 URL 重新读取 `INSTALL.md`; - 下载同一 SHA 的源码归档; - 在临时目录执行锁定依赖安装、检查、测试、bundle 和 npm pack; -- 将本地生成的运行 tarball 安装到用户级隔离前缀; +- 首次安装到用户级隔离前缀,更新则沿用原前缀并保留配置、凭据和读写模式; - 报告完整 Commit SHA 和本地 tarball SHA-256。 -解析 SHA 后,即使 `main` 在安装期间更新,也不混入新文件;下一次安装才采用更新后的提交。任何检查或构建失败都停止,不回退旧 Release。正式 Release 可以继续用于归档和人工回滚,但不是默认安装来源。 +若有效安装记录已经是同一最新 SHA,并且二进制、帮助和 MCP 入口均完整,Agent 直接执行配置核对与 `crm_health`,不重复下载、构建或要求重新登录。解析 SHA 后,即使 `main` 在任务期间更新,也不混入新文件;下一次安装或更新才采用更新后的提交。任何检查或构建失败都停止,不回退旧 Release。正式 Release 可以继续用于归档和人工回滚,但不是默认安装来源。 Gitea 仓库访问受限时,用户或 Agent 必须具有源码和 Commit 归档读取权限。受限仓库链接本身不能成为任意用户可访问的一键安装链接,也不得通过聊天传递访问令牌。 @@ -48,7 +48,9 @@ Gitea 仓库访问受限时,用户或 Agent 必须具有源码和 Commit 归 ## 更新与回滚 -更新时,Agent 重新解析 `main` 的最新 Commit,在新临时目录完成全部检查和构建,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev `。安装前备份 MCP 客户端配置和非秘密 CRM 配置,并记录更新前后的完整 Commit SHA。 +更新时,Agent 重新解析 `main` 的最新 Commit;SHA 变化或现有入口不完整时,在新临时目录完成全部检查和构建,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev `。更新前备份 MCP 客户端配置和非秘密 CRM 配置,并记录更新前后的完整 Commit SHA;沿用现有 `--enable-writes` 状态,除非用户明确要求改变。 + +MCP 重载后先调用 `crm_health`。已有 OAuth 登录仍有效时直接完成更新;只有明确返回认证失败时才启动浏览器授权。成功后原子写入不含秘密的 `install-receipt.json`,供下次准确判断是否已经是同一 Commit。 回滚是唯一允许选择旧提交的安装操作:使用上次记录的完整 Commit SHA 重建并安装该快照,不通过移动分支名猜测版本。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 OAuth 凭据。 diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7ba6620..10cad14 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -16,7 +16,7 @@ nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] - `doctor`:验证凭据和 CRM 映射是否可用。 - `client-config`:准备默认 CRM 配置并输出可合并到 MCP 客户端的配置片段;不要求登录,也不包含任何 Token 或 Key。 - `--enable-writes`:显式增加受控项目写入工具;默认不启用。 -- `--no-browser`:不自动启动浏览器,只显示 NocoBase 授权链接和确认码。 +- `--no-browser`:不自动启动浏览器,只输出可交给发起用户的 NocoBase 授权链接和确认码;搭配 `--json` 时还会在 stderr 输出 `authorization_required` 结构化事件。 - `--manual-key`:仅在 OAuth 不可用时,通过安全 TTY 隐藏输入个人 Authorize Key。 ## 默认工具 @@ -33,7 +33,7 @@ nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] 查询结果默认返回经过整理的紧凑字段,而不是完整 NocoBase 原始记录。 -`nocobase_login` 会启动用户确认的 OAuth 授权,并在成功后写入本机私有凭据文件,因此不会被标记为只读;它不会修改任何 CRM 业务数据。该工具只返回可交给用户的 `login_url`、`user_code`、有效期和状态,不返回 Token 或设备码。 +`nocobase_login` 会启动用户确认的 OAuth 授权,并在成功后写入本机私有凭据文件,因此不会被标记为只读;它不会修改任何 CRM 业务数据。该工具返回 `login_url`、标准别名 `verification_uri_complete`、`user_code`、有效期、状态和机器可读交付边界。链接与确认码应原样发送给发起用户的当前一对一私聊,不返回 Token 或内部设备码,也不需要服务器远程桌面;不得发送到群聊、公开频道或第三方。 ## 可选工具 @@ -69,13 +69,14 @@ OAuth Token 和 Authorize Key 都不支持也不允许通过环境变量传入 ## OAuth 登录 -推荐在 MCP 注册后调用 `nocobase_login`,把返回的链接和确认码发给用户,再用 `nocobase_login_status` 查询结果。桌面环境会尝试自动打开浏览器;远程或无界面环境使用相同链接,不需要回调端口。已有凭据被 NocoBase 拒绝时,使用 `nocobase_login({"force": true})` 重新授权。CLI 的 `setup --no-browser` 保留为客户端无法在登录前加载连接器时的备用入口。 +推荐在 MCP 注册后调用 `nocobase_login`,把返回的链接和确认码发给发起用户的当前私聊,再用 `nocobase_login_status` 查询结果。它们属于 OAuth Device Flow 的短期用户验证信息,不是密码、Key、Bearer Token 或内部 `device_code`。待授权状态以短期私有文件保存,因此桌面、远程或无界面环境都不需要回调端口或服务器远程桌面,stdio 进程重启后也能恢复同一个授权。已有凭据被 NocoBase 拒绝时,使用 `nocobase_login({"force": true})` 重新授权。CLI 的 `setup --no-browser` 保留为客户端无法在登录前加载连接器时的持续等待备用入口;运行期间不得关闭该 CLI 进程。 - 发现地址:`https://justconn.top/api/.well-known/openid-configuration` - 授权模式:OAuth 2.1 Device Authorization - 客户端类型:动态注册的 native public client,不保存 client secret - 请求权限:`api offline_access` - 浏览器只访问 `https://justconn.top`;程序不读取 Cookie 或浏览器存储 +- 待授权设备码只保存于同一用户的短期私有文件,成功、取消或过期后立即删除 - 访问令牌临近过期时自动刷新;并发请求只触发一次刷新 - OAuth 端点、浏览器 URL 和 issuer 都必须匹配固定的 `justconn.top` 来源 diff --git a/install-manifest.json b/install-manifest.json index 5c1f0fe..75755a5 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -30,16 +30,27 @@ }, "installation": { "requiredTools": ["node", "npm"], + "operationDetection": ["fresh-install", "in-place-update", "repair", "already-current"], + "supportsInPlaceUpdate": true, + "sameCommitBuildSkip": true, + "installReceipt": { + "path": "/install-receipt.json", + "schemaVersion": 1, + "posixFileMode": "0600", + "containsSecrets": false + }, "sourceCheckout": false, "temporarySourceArchiveBuild": true, "fallbackToRelease": false, "globalPackageInstall": false, "persistentArtifacts": [ "isolated user-data npm prefix", + "non-secret install receipt", "non-secret CRM config", "private per-user credential file", "MCP client configuration and backup" ], + "preserveOnUpdate": ["CRM config", "per-user OAuth credentials", "read-only or fixed-project-write mode"], "defaultBackgroundService": false }, "authentication": { @@ -49,13 +60,26 @@ "dynamicClientRegistration": true, "browserSignIn": true, "automaticRefresh": true, + "processRestartResumable": true, + "pendingAuthorizationStorage": "short-lived per-user private local file", + "userVerificationDelivery": { + "fields": ["login_url", "verification_uri_complete", "user_code"], + "intendedRecipient": "requesting-user", + "channel": "current-private-conversation", + "shareableWithIntendedRecipient": true, + "publicSharingAllowed": false, + "containsBearerCredentials": false, + "containsDeviceCode": false, + "remoteDesktopRequired": false + }, "manualFallback": "user-controlled-hidden-tty-personal-authorize-key", "storage": "per-user private local credential file", "posixFileMode": "0600", "allowPassword": false, "allowAgentChatSecret": false, "allowCommandLineSecret": false, - "allowEnvironmentSecret": false + "allowEnvironmentSecret": false, + "allowDeviceCodeExposure": false }, "entrypoints": { "guide": "INSTALL.md", diff --git a/src/auth-service.ts b/src/auth-service.ts index cf13137..281e419 100644 --- a/src/auth-service.ts +++ b/src/auth-service.ts @@ -1,20 +1,41 @@ -import { existsSync } from "node:fs"; +import { AuthorizationStore, pendingAuthorizationPath } from "./authorization-store.js"; import { credentialsPath, loadStoredCredentials, NOCOBASE_API_BASE_URL, + type StoredCredentials, saveOAuthCredentials, } from "./credentials.js"; import { NocoBaseClient } from "./nocobase-client.js"; import { type BrowserLoginOptions, - type BrowserLoginSession, + type BrowserLoginPollResult, + beginBrowserLogin, type OAuthCredentials, - startBrowserLogin, + OAuthProtocolError, + type PendingOAuthAuthorization, + pollBrowserLogin, } from "./oauth.js"; export type AuthPayload = Record; +export function userAuthorizationFields(loginUrl: string, userCode: string): AuthPayload { + return { + authorization_type: "oauth_device_user_verification", + login_url: loginUrl, + verification_uri_complete: loginUrl, + user_code: userCode, + intended_recipient: "requesting_user", + delivery_channel: "current_private_conversation", + shareable_with_requesting_user: true, + private_conversation_required: true, + public_sharing_allowed: false, + contains_bearer_credentials: false, + contains_device_code: false, + remote_desktop_required: false, + }; +} + export interface AuthService { start(options?: { force?: boolean }): Promise; status(): Promise; @@ -22,150 +43,275 @@ export interface AuthService { export interface NocoBaseAuthServiceOptions { credentialsFile?: string; + pendingAuthorizationFile?: string; now?: () => number; openBrowser?: BrowserLoginOptions["openBrowser"]; - startLogin?: (options: BrowserLoginOptions) => Promise; + beginLogin?: (options: BrowserLoginOptions) => Promise; + pollLogin?: (authorization: PendingOAuthAuthorization) => Promise; verifyCredentials?: (credentials: OAuthCredentials) => Promise; onCredentialsSaved?: () => void; } -interface PendingAuthorization { - kind: "pending"; - prompt: BrowserLoginSession["prompt"]; - expiresAt: number; -} - -interface FailedAuthorization { - kind: "failed"; - message: string; -} - -interface AuthenticatedAuthorization { - kind: "authenticated"; -} - -type AuthorizationState = PendingAuthorization | FailedAuthorization | AuthenticatedAuthorization; - export class NocoBaseAuthService implements AuthService { private readonly credentialsFile: string; + private readonly authorizationStore: AuthorizationStore; private readonly now: () => number; private readonly openBrowser?: BrowserLoginOptions["openBrowser"]; - private readonly startLogin: (options: BrowserLoginOptions) => Promise; + private readonly beginLogin: (options: BrowserLoginOptions) => Promise; + private readonly pollLogin: (authorization: PendingOAuthAuthorization) => Promise; private readonly verifyCredentials: (credentials: OAuthCredentials) => Promise; private readonly onCredentialsSaved?: () => void; - private state?: AuthorizationState; + private lastFailure?: string; private startInFlight?: Promise; + private pollInFlight?: Promise; constructor(options: NocoBaseAuthServiceOptions = {}) { this.credentialsFile = options.credentialsFile ?? credentialsPath(); + this.authorizationStore = new AuthorizationStore( + options.pendingAuthorizationFile ?? pendingAuthorizationPath(this.credentialsFile), + ); this.now = options.now ?? Date.now; this.openBrowser = options.openBrowser; - this.startLogin = options.startLogin ?? startBrowserLogin; + this.beginLogin = options.beginLogin ?? beginBrowserLogin; + this.pollLogin = + options.pollLogin ?? ((authorization) => pollBrowserLogin(authorization, { now: this.now })); this.verifyCredentials = options.verifyCredentials ?? verifyOAuthCredentials; this.onCredentialsSaved = options.onCredentialsSaved; } async start(options: { force?: boolean } = {}): Promise { - if (this.startInFlight) return await this.startInFlight; - const pending = this.startInternal(options.force ?? false); - this.startInFlight = pending; - try { - return await pending; - } finally { - if (this.startInFlight === pending) this.startInFlight = undefined; - } + return await this.singleFlight("startInFlight", () => this.startInternal(options.force ?? false)); } async status(): Promise { - if (this.state?.kind === "pending") return this.pendingPayload(this.state); - if (this.state?.kind === "failed") { - return { - status: "authorization_failed", - error: { - code: "nocobase_authorization_failed", - message: this.state.message, - retryable: true, - }, - next_action: "Call nocobase_login again to create a new browser authorization link.", - }; + let pending: PendingOAuthAuthorization | undefined; + try { + pending = this.authorizationStore.read(); + } catch (error) { + return this.authorizationFailure(errorMessage(error)); } - if (this.state?.kind === "authenticated" || this.storedOAuthClientId() !== undefined) { + const stored = this.tryLoadStoredCredentials(); + if (pending) { + if (this.credentialsCompleted(pending, stored)) { + this.authorizationStore.remove(); + return this.credentialsSaved(); + } + if (pending.expiresAt <= this.now()) { + this.authorizationStore.remove(); + return this.authorizationFailure("NocoBase browser authorization expired; start a new login"); + } + return await this.pollPendingAuthorization(pending); + } + if (stored) { + this.lastFailure = undefined; return authenticatedPayload(); } - if (this.hasStoredPersonalKey()) return authenticatedPayload(); - return { - status: "login_required", - next_action: - "Call nocobase_login, send its login_url and user_code to the user, then poll nocobase_login_status.", - }; + if (this.lastFailure) return failedPayload(this.lastFailure); + return loginRequiredPayload(); } private async startInternal(force: boolean): Promise { - if (this.state?.kind === "pending") return this.pendingPayload(this.state); - if (!force) { - const current = await this.status(); - if (current.status === "authenticated") return current; + try { + const pending = this.authorizationStore.read(); + if (pending && pending.expiresAt > this.now()) return this.pendingPayload(pending); + if (pending) this.authorizationStore.remove(); + } catch (error) { + return this.authorizationFailure(errorMessage(error)); } - - const session = await this.startLogin({ - clientId: this.storedOAuthClientId(), - openBrowser: this.openBrowser, - sleep: backgroundSleep, - }); - const pending: PendingAuthorization = { - kind: "pending", - prompt: session.prompt, - expiresAt: this.now() + session.prompt.expiresIn * 1_000, - }; - this.state = pending; - void session.credentials - .then(async (credentials) => { - await this.verifyCredentials(credentials); - saveOAuthCredentials(credentials, this.credentialsFile); - this.onCredentialsSaved?.(); - if (this.state === pending) this.state = { kind: "authenticated" }; - }) - .catch((error: unknown) => { - if (this.state === pending) { - this.state = { - kind: "failed", - message: error instanceof Error ? error.message : String(error), - }; - } + const existing = this.tryLoadStoredCredentials(); + if (!force && existing) { + this.lastFailure = undefined; + return authenticatedPayload(); + } + const lock = this.authorizationStore.tryLock(this.now()); + if (!lock) return await this.waitForConcurrentAuthorizationStart(); + try { + const concurrent = this.authorizationStore.read(); + if (concurrent && concurrent.expiresAt > this.now()) return this.pendingPayload(concurrent); + if (concurrent) this.authorizationStore.remove(); + const stored = this.tryLoadStoredCredentials(); + if (!force && stored) { + this.lastFailure = undefined; + return authenticatedPayload(); + } + this.lastFailure = undefined; + const authorization = await this.beginLogin({ + clientId: stored?.type === "oauth" ? stored.clientId : undefined, + openBrowser: this.openBrowser, + now: this.now, }); - return this.pendingPayload(pending); + this.authorizationStore.save(authorization); + return this.pendingPayload(authorization); + } finally { + lock.release(); + } } - private pendingPayload(state: PendingAuthorization): AuthPayload { + private async waitForConcurrentAuthorizationStart(): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + await delay(100); + try { + const pending = this.authorizationStore.read(); + if (pending && pending.expiresAt > this.now()) return this.pendingPayload(pending); + } catch (error) { + return this.authorizationFailure(errorMessage(error)); + } + } + return { + status: "authorization_starting", + retry_after_seconds: 1, + next_action: + "Another MCP process is creating the browser authorization. Call nocobase_login again shortly; do not start a separate login flow.", + }; + } + + private async pollPendingAuthorization(authorization: PendingOAuthAuthorization): Promise { + return await this.singleFlight("pollInFlight", () => + this.pollPendingAuthorizationInternal(authorization), + ); + } + + private async singleFlight( + slot: "startInFlight" | "pollInFlight", + operation: () => Promise, + ): Promise { + const current = this[slot]; + if (current) return await current; + const pending = operation(); + this[slot] = pending; + try { + return await pending; + } finally { + if (this[slot] === pending) this[slot] = undefined; + } + } + + private async pollPendingAuthorizationInternal( + authorization: PendingOAuthAuthorization, + ): Promise { + if (authorization.nextPollAt > this.now()) return this.pendingPayload(authorization); + const lock = this.authorizationStore.tryLock(this.now()); + if (!lock) { + return { + ...this.pendingPayload(authorization), + retry_after_seconds: 1, + poll_in_progress: true, + next_action: + "Another MCP process is checking this same authorization. Wait 1 second, then call nocobase_login_status again; do not ask the user to log in again.", + }; + } + try { + const latest = this.authorizationStore.read(); + if (!latest) { + if (this.tryLoadStoredCredentials()) { + this.lastFailure = undefined; + return authenticatedPayload(); + } + return loginRequiredPayload(); + } + if (this.credentialsCompleted(latest, this.tryLoadStoredCredentials())) { + this.authorizationStore.remove(); + return this.credentialsSaved(); + } + if (latest.expiresAt <= this.now()) { + this.authorizationStore.remove(); + return this.authorizationFailure("NocoBase browser authorization expired; start a new login"); + } + let result: BrowserLoginPollResult; + try { + result = await this.pollLogin(latest); + } catch (error) { + if (isTerminalAuthorizationError(error)) { + this.authorizationStore.remove(); + return this.authorizationFailure(errorMessage(error)); + } + return { + ...this.pendingPayload(latest), + poll_error: { + code: "nocobase_authorization_poll_failed", + message: errorMessage(error), + retryable: true, + }, + next_action: "Retry nocobase_login_status; do not create a new login link yet.", + }; + } + if (result.status === "authorization_pending") { + this.authorizationStore.save(result.authorization); + return this.pendingPayload(result.authorization); + } + + const credentials = { + ...result.credentials, + authorizationSessionId: latest.sessionId, + }; + saveOAuthCredentials(credentials, this.credentialsFile); + this.authorizationStore.remove(); + const authenticated = this.credentialsSaved(); + try { + await this.verifyCredentials(credentials); + return authenticated; + } catch (error) { + return { + ...authenticated, + credential_verification: "failed", + verification_error: { + code: "nocobase_credential_verification_failed", + message: errorMessage(error), + retryable: true, + }, + next_action: + "Credentials were saved. Call crm_health; create a new login only if it reports an authentication failure.", + }; + } + } finally { + lock.release(); + } + } + + private pendingPayload(authorization: PendingOAuthAuthorization): AuthPayload { return { status: "authorization_pending", - login_url: state.prompt.url, - user_code: state.prompt.userCode, - expires_in_seconds: Math.max(0, Math.ceil((state.expiresAt - this.now()) / 1_000)), - browser_opened: state.prompt.opened, + ...userAuthorizationFields(authorization.prompt.url, authorization.prompt.userCode), + expires_in_seconds: Math.max(0, Math.ceil((authorization.expiresAt - this.now()) / 1_000)), + retry_after_seconds: Math.max(0, Math.ceil((authorization.nextPollAt - this.now()) / 1_000)), + browser_opened: authorization.prompt.opened, next_action: - "Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.", + "Deliver login_url and user_code verbatim to the requesting user once. On a headless or remote server, send them in the current private conversation: they are OAuth device user-verification data, not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party. Then wait retry_after_seconds and call nocobase_login_status. Reuse this authorization; do not ask the user to log in again.", }; } - private storedOAuthClientId(): string | undefined { - if (!existsSync(this.credentialsFile)) return undefined; + private tryLoadStoredCredentials(): StoredCredentials | undefined { try { - const stored = loadStoredCredentials(this.credentialsFile); - return stored.type === "oauth" ? stored.clientId : undefined; + return loadStoredCredentials(this.credentialsFile); } catch { return undefined; } } - private hasStoredPersonalKey(): boolean { - if (!existsSync(this.credentialsFile)) return false; - try { - return loadStoredCredentials(this.credentialsFile).type === "personal-authorize-key"; - } catch { - return false; - } + private credentialsCompleted( + authorization: PendingOAuthAuthorization, + stored: StoredCredentials | undefined, + ): boolean { + return stored?.type === "oauth" && stored.authorizationSessionId === authorization.sessionId; } + + private credentialsSaved(): AuthPayload { + this.lastFailure = undefined; + this.onCredentialsSaved?.(); + return authenticatedPayload(); + } + + private authorizationFailure(message: string): AuthPayload { + this.lastFailure = message; + return failedPayload(message); + } +} + +function isTerminalAuthorizationError(error: unknown): boolean { + return ( + error instanceof OAuthProtocolError && + ["access_denied", "expired_token", "invalid_client", "invalid_grant"].includes(error.code ?? "") + ); } async function verifyOAuthCredentials(credentials: OAuthCredentials): Promise { @@ -176,16 +322,37 @@ async function verifyOAuthCredentials(credentials: OAuthCredentials): Promise { - await new Promise((resolve) => { - const timer = setTimeout(resolve, milliseconds); - timer.unref?.(); - }); -} - function authenticatedPayload(): AuthPayload { return { status: "authenticated", next_action: "Call crm_health, then continue with the requested CRM operation.", }; } + +function failedPayload(message: string): AuthPayload { + return { + status: "authorization_failed", + error: { + code: "nocobase_authorization_failed", + message, + retryable: true, + }, + next_action: "Call nocobase_login again to create a new browser authorization link.", + }; +} + +function loginRequiredPayload(): AuthPayload { + return { + status: "login_required", + next_action: + "Call nocobase_login. Deliver its login_url and user_code to the requesting user exactly as instructed, including through the current private conversation on a headless server, then poll nocobase_login_status; do not require remote desktop.", + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function delay(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/authorization-store.ts b/src/authorization-store.ts new file mode 100644 index 0000000..51c1729 --- /dev/null +++ b/src/authorization-store.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "node:crypto"; +import { closeSync, fchmodSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { type PendingOAuthAuthorization, validatePendingOAuthAuthorization } from "./oauth.js"; +import { + ensurePrivateDirectory, + readPrivateJson, + removePrivateFile, + writePrivateJson, +} from "./private-json.js"; +import { isRecord } from "./types.js"; + +export interface AuthorizationLock { + release(): void; +} + +export function pendingAuthorizationPath(credentialsFile: string): string { + return `${credentialsFile}.pending-oauth`; +} + +export class AuthorizationStore { + constructor(readonly path: string) {} + + read(): PendingOAuthAuthorization | undefined { + try { + return validatePendingOAuthAuthorization(readPrivateJson(this.path, "Pending authorization")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + save(authorization: PendingOAuthAuthorization): void { + writePrivateJson(this.path, validatePendingOAuthAuthorization(authorization), "Authorization"); + } + + remove(): void { + removePrivateFile(this.path); + } + + tryLock(now: number): AuthorizationLock | undefined { + ensurePrivateDirectory(this.path, "Authorization"); + const lockPath = `${this.path}.lock`; + for (let attempt = 0; attempt < 2; attempt += 1) { + const token = randomUUID(); + try { + const descriptor = openSync(lockPath, "wx", 0o600); + try { + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: now, token }), "utf8"); + } finally { + closeSync(descriptor); + } + return ownedLock(lockPath, token); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (attempt === 0 && authorizationLockIsStale(lockPath, now)) { + try { + unlinkSync(lockPath); + continue; + } catch (unlinkError) { + if ((unlinkError as NodeJS.ErrnoException).code === "ENOENT") continue; + } + } + return undefined; + } + } + return undefined; + } +} + +const AUTHORIZATION_LOCK_STALE_MS = 120_000; + +function ownedLock(path: string, token: string): AuthorizationLock { + return { + release: () => { + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (isRecord(value) && value.token === token) unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; + } + }, + }; +} + +function authorizationLockIsStale(path: string, now: number): boolean { + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (isRecord(value) && typeof value.pid === "number" && typeof value.createdAt === "number") { + if (now - value.createdAt > AUTHORIZATION_LOCK_STALE_MS) return true; + return !processIsAlive(value.pid); + } + return now - statSync(path).mtimeMs > AUTHORIZATION_LOCK_STALE_MS; + } catch { + try { + return now - statSync(path).mtimeMs > AUTHORIZATION_LOCK_STALE_MS; + } catch { + return false; + } + } +} + +function processIsAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/src/cli.ts b/src/cli.ts index c2bea12..134cd2a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { NocoBaseAuthService } from "./auth-service.js"; +import { NocoBaseAuthService, userAuthorizationFields } from "./auth-service.js"; import { loadConfig } from "./config.js"; import { createCredentialProvider, @@ -15,7 +15,7 @@ import { } from "./credentials.js"; import { CrmService } from "./crm-service.js"; import { NocoBaseClient } from "./nocobase-client.js"; -import { loginWithBrowser } from "./oauth.js"; +import { type AuthorizationPrompt, loginWithBrowser, type OAuthCredentials } from "./oauth.js"; import { ProjectService } from "./project-service.js"; import { createServer } from "./tools.js"; import { VERSION } from "./version.js"; @@ -92,6 +92,7 @@ async function setup(args: ParsedArgs): Promise { let authorizeKey: string; let authentication: "oauth-browser" | "personal-authorize-key-fallback"; let configPath: string; + let oauthCredentials: OAuthCredentials | undefined; if (args.manualKey) { if (!process.stdin.isTTY || !process.stderr.isTTY) { throw new Error("Manual Key setup requires a user-controlled local terminal; no files were changed"); @@ -111,37 +112,52 @@ async function setup(args: ParsedArgs): Promise { `Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` + "Your password and browser session stay inside NocoBase.\n", ); - const oauthCredentials = await loginWithBrowser({ + oauthCredentials = await loginWithBrowser({ clientId: reusableOAuthClientId(), openBrowser: args.noBrowser ? async () => false : undefined, - onAuthorization: ({ opened, url, userCode, expiresIn }) => { - process.stderr.write( - `${opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${url}\n` + - `Confirmation code: ${userCode}\n` + - `Complete sign-in within ${Math.ceil(expiresIn / 60)} minutes. Waiting for approval...\n`, - ); + onAuthorization: (prompt) => { + if (args.json) { + process.stderr.write(`${JSON.stringify(setupAuthorizationEvent(prompt))}\n`); + return; + } + process.stderr.write(formatSetupAuthorizationPrompt(prompt)); }, }); authorizeKey = oauthCredentials.accessToken; authentication = "oauth-browser"; - const verificationClient = new NocoBaseClient(() => ({ - apiBaseUrl: NOCOBASE_API_BASE_URL, - authorizeKey, - })); - await verificationClient.verifyCredentials(); - saveOAuthCredentials(oauthCredentials); - await printSetupResult(args, configPath, authentication); - return; } const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey, })); await verificationClient.verifyCredentials(); - saveCredentials({ authorizeKey }); + if (oauthCredentials) saveOAuthCredentials(oauthCredentials); + else saveCredentials({ authorizeKey }); await printSetupResult(args, configPath, authentication); } +export function setupAuthorizationEvent(prompt: AuthorizationPrompt): Record { + return { + event: "authorization_required", + status: "authorization_pending", + ...userAuthorizationFields(prompt.url, prompt.userCode), + expires_in_seconds: prompt.expiresIn, + browser_opened: prompt.opened, + next_action: + "Send login_url and user_code verbatim to the requesting user in the current private conversation, then keep this setup process running. They are OAuth device user-verification data, not a password, API key, bearer token, or device_code; remote desktop is not required. Never post them publicly or send them to a third party.", + }; +} + +function formatSetupAuthorizationPrompt(prompt: AuthorizationPrompt): string { + return ( + "OAuth device sign-in information for the requesting user follows.\n" + + "For a headless or remote Agent, relay the URL and confirmation code verbatim in the current private conversation; they are not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party.\n" + + `${prompt.opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${prompt.url}\n` + + `Confirmation code: ${prompt.userCode}\n` + + `Complete sign-in within ${Math.ceil(prompt.expiresIn / 60)} minutes. Waiting for approval...\n` + ); +} + async function printSetupResult( args: ParsedArgs, configPath: string, diff --git a/src/credentials.ts b/src/credentials.ts index 7aa818a..d7218c4 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,8 +1,8 @@ -import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { NOCOBASE_API_BASE_URL, NOCOBASE_OAUTH_ISSUER } from "./nocobase-endpoints.js"; import { type OAuthCredentials, refreshOAuthCredentials } from "./oauth.js"; +import { readPrivateJson, writePrivateJson } from "./private-json.js"; import { isRecord } from "./types.js"; export interface Credentials { @@ -52,12 +52,12 @@ export function saveCredentials( ): void { const authorizeKey = credentials.authorizeKey.trim(); if (!authorizeKey) throw new Error("Authorize Key must not be blank"); - writePrivateCredentials(path, { authorizeKey }); + writePrivateJson(path, { authorizeKey }, "Credential"); } export function saveOAuthCredentials(credentials: OAuthCredentials, path = credentialsPath()): void { validateOAuthCredentials(credentials); - writePrivateCredentials(path, credentials); + writePrivateJson(path, credentials, "Credential"); } export function loadCredentials(path = credentialsPath()): Credentials { @@ -69,12 +69,7 @@ export function loadCredentials(path = credentialsPath()): Credentials { } export function loadStoredCredentials(path = credentialsPath()): StoredCredentials { - if (process.platform !== "win32") { - const mode = statSync(path).mode & 0o777; - if ((mode & 0o077) !== 0) - throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`); - } - const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + const parsed = readPrivateJson(path, "Credential"); if (!isRecord(parsed)) throw new Error("Credential file is invalid; sign in again"); if (parsed.type === "oauth") { const oauth = parsed as unknown as OAuthCredentials; @@ -203,24 +198,10 @@ function validateOAuthCredentials(credentials: OAuthCredentials): void { !credentials.scope.split(/\s+/).includes("api") || typeof credentials.expiresAt !== "number" || !Number.isFinite(credentials.expiresAt) || - credentials.expiresAt <= 0 + credentials.expiresAt <= 0 || + (credentials.authorizationSessionId !== undefined && + (typeof credentials.authorizationSessionId !== "string" || !credentials.authorizationSessionId.trim())) ) { throw new Error("OAuth credential file is invalid; sign in again"); } } - -function writePrivateCredentials(path: string, value: unknown): void { - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - if (process.platform !== "win32") { - const directoryMode = statSync(dirname(path)).mode & 0o777; - if ((directoryMode & 0o077) !== 0) - throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`); - } - const descriptor = openSync(path, "w", 0o600); - try { - if (process.platform !== "win32") fchmodSync(descriptor, 0o600); - writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - } finally { - closeSync(descriptor); - } -} diff --git a/src/crm-service.ts b/src/crm-service.ts index 387bcb0..d80b2f2 100644 --- a/src/crm-service.ts +++ b/src/crm-service.ts @@ -126,7 +126,6 @@ export class CrmService { includeRaw?: boolean; } = {}, ): Promise> { - const [limit, offset] = normalizePagination(options.limit, options.offset); const [ownerId, ownerError] = await this.resolveOwnerIdOrError(options.owner); if (ownerError) return { error: ownerError }; const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team); @@ -138,18 +137,14 @@ export class CrmService { buildEqCondition(this.config.customer.sourceField, options.source), buildSearchFilter(options.query, this.config.customer.searchFields), ]); - const result = await this.listWithProjectionFallback(this.config.customer.resource, { - limit, - offset, + return await this.listCompact(this.config.customer.resource, { + limit: options.limit, + offset: options.offset, filter, fields: options.includeRaw ? (this.config.customer.fields ?? []) : this.customerCompactFields(), sort: this.config.customer.sort, + compact: (row) => this.compactCustomer(row, false, options.includeRaw), }); - result.results = (result.results as RecordRow[]).map((row) => - this.compactCustomer(row, false, options.includeRaw), - ); - result.timezone = this.config.timezone; - return result; } async getCustomer( @@ -207,11 +202,10 @@ export class CrmService { includeRaw?: boolean; } = {}, ): Promise> { - const [limit, offset] = normalizePagination(options.limit, options.offset); const includeDetails = options.includeDetails ?? true; - const result = await this.listWithProjectionFallback(this.config.contact.resource, { - limit, - offset, + return await this.listCompact(this.config.contact.resource, { + limit: options.limit, + offset: options.offset, filter: buildAndFilter([ buildRelationIdCondition(this.config.contact.customerField, options.customerId), buildSearchFilter(options.query, this.config.contact.searchFields), @@ -220,12 +214,8 @@ export class CrmService { ? (this.config.contact.fields ?? []) : this.contactCompactFields(includeDetails), sort: this.config.contact.sort, + compact: (row) => this.compactContact(row, includeDetails, options.includeRaw), }); - result.results = (result.results as RecordRow[]).map((row) => - this.compactContact(row, includeDetails, options.includeRaw), - ); - result.timezone = this.config.timezone; - return result; } async listFollowups( @@ -244,14 +234,13 @@ export class CrmService { includeRaw?: boolean; } = {}, ): Promise> { - const [limit, offset] = normalizePagination(options.limit, options.offset); const [ownerId, ownerError] = await this.resolveOwnerIdOrError(options.owner); if (ownerError) return { error: ownerError }; const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team); if (teamError) return { error: teamError }; - const result = await this.listWithProjectionFallback(this.config.followup.resource, { - limit, - offset, + return await this.listCompact(this.config.followup.resource, { + limit: options.limit, + offset: options.offset, filter: buildAndFilter([ buildRelationIdCondition(this.config.followup.customerField, options.customerId), buildRelationIdCondition(this.config.followup.contactField, options.contactId), @@ -264,12 +253,8 @@ export class CrmService { ]), fields: options.includeRaw ? (this.config.followup.fields ?? []) : this.followupCompactFields(), sort: this.config.followup.sort, + compact: (row) => this.compactFollowup(row, options.includeRaw), }); - result.results = (result.results as RecordRow[]).map((row) => - this.compactFollowup(row, options.includeRaw), - ); - result.timezone = this.config.timezone; - return result; } async listProjects( @@ -282,7 +267,6 @@ export class CrmService { includeRaw?: boolean; } = {}, ): Promise> { - const [limit, offset] = normalizePagination(options.limit, options.offset); const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team); if (teamError) return { error: teamError }; let stageValue = options.stage; @@ -298,9 +282,9 @@ export class CrmService { }; } } - const result = await this.listWithProjectionFallback(this.config.project.resource, { - limit, - offset, + return await this.listCompact(this.config.project.resource, { + limit: options.limit, + offset: options.offset, filter: buildAndFilter([ buildEqCondition(this.config.project.customerField, options.customerId), buildRelationIdInCondition( @@ -311,12 +295,8 @@ export class CrmService { ]), fields: this.projectFields(), sort: ["-updatedAt"], + compact: (row) => this.compactProject(row, options.includeRaw), }); - result.results = (result.results as RecordRow[]).map((row) => - this.compactProject(row, options.includeRaw), - ); - result.timezone = this.config.timezone; - return result; } async getProject( @@ -986,6 +966,30 @@ export class CrmService { ); } + private async listCompact( + resource: string, + options: { + limit?: number; + offset?: number; + filter?: Filter; + fields: string[]; + sort?: string[] | null; + compact: (row: RecordRow) => Record; + }, + ): Promise> { + const [limit, offset] = normalizePagination(options.limit, options.offset); + const result = await this.listWithProjectionFallback(resource, { + limit, + offset, + filter: options.filter, + fields: options.fields, + sort: options.sort, + }); + result.results = (result.results as RecordRow[]).map(options.compact); + result.timezone = this.config.timezone; + return result; + } + private async listWithProjectionFallback( resource: string, options: { diff --git a/src/oauth.ts b/src/oauth.ts index 66f4445..530925e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { NOCOBASE_OAUTH_DISCOVERY_URL, NOCOBASE_OAUTH_ISSUER, @@ -19,6 +20,7 @@ export interface OAuthCredentials { tokenType: "Bearer"; scope: string; expiresAt: number; + authorizationSessionId?: string; } interface OAuthDiscovery { @@ -53,9 +55,26 @@ export interface BrowserLoginOptions { sleep?: (milliseconds: number) => Promise; } -export interface BrowserLoginSession { +export interface PendingOAuthAuthorization { + schemaVersion: 1; + type: "oauth-device-authorization"; + sessionId: string; + issuer: string; + clientId: string; + deviceCode: string; prompt: AuthorizationPrompt; - credentials: Promise; + expiresAt: number; + intervalSeconds: number; + nextPollAt: number; +} + +export type BrowserLoginPollResult = + | { status: "authorization_pending"; authorization: PendingOAuthAuthorization } + | { status: "authenticated"; credentials: OAuthCredentials }; + +export interface PollBrowserLoginOptions { + fetch?: typeof fetch; + now?: () => number; } export interface RefreshOptions { @@ -74,14 +93,124 @@ export class OAuthProtocolError extends Error { } export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promise { - const session = await startBrowserLogin(options); - return await session.credentials; + const started = await beginDeviceLogin(options); + return await pollForTokens(started.discovery, started.clientId, started.authorization, { + fetch: options.fetch ?? fetch, + now: options.now ?? Date.now, + sleep: options.sleep ?? defaultSleep, + }); } -export async function startBrowserLogin(options: BrowserLoginOptions = {}): Promise { +export async function beginBrowserLogin( + options: BrowserLoginOptions = {}, +): Promise { + const now = options.now ?? Date.now; + const started = await beginDeviceLogin(options); + const createdAt = now(); + return { + schemaVersion: 1, + type: "oauth-device-authorization", + sessionId: randomUUID(), + issuer: NOCOBASE_OAUTH_ISSUER, + clientId: started.clientId, + deviceCode: started.authorization.deviceCode, + prompt: started.prompt, + expiresAt: createdAt + started.authorization.expiresIn * 1_000, + intervalSeconds: started.authorization.interval, + nextPollAt: createdAt + started.authorization.interval * 1_000, + }; +} + +export async function pollBrowserLogin( + value: unknown, + options: PollBrowserLoginOptions = {}, +): Promise { + const authorization = validatePendingOAuthAuthorization(value); const fetchImpl = options.fetch ?? fetch; const now = options.now ?? Date.now; - const sleep = options.sleep ?? defaultSleep; + const currentTime = now(); + if (currentTime >= authorization.expiresAt) throw expiredAuthorization(); + if (currentTime < authorization.nextPollAt) { + return { status: "authorization_pending", authorization }; + } + const discovery = await discoverOAuth(fetchImpl); + const result = await pollDeviceTokenOnce( + discovery, + authorization.clientId, + authorization.deviceCode, + fetchImpl, + ); + if (result.status === "authenticated") { + return { + status: "authenticated", + credentials: tokenCredentials(result.payload, authorization.clientId, now, { + requireRefreshToken: true, + authorizationSessionId: authorization.sessionId, + }), + }; + } + if (result.status === "expired_token") throw expiredAuthorization(); + const intervalSeconds = + result.status === "slow_down" + ? Math.min(authorization.intervalSeconds + 5, 30) + : authorization.intervalSeconds; + return { + status: "authorization_pending", + authorization: nextAuthorizationPoll(authorization, now(), intervalSeconds), + }; +} + +export function validatePendingOAuthAuthorization(value: unknown): PendingOAuthAuthorization { + if (!isRecord(value) || !isRecord(value.prompt)) throw invalidPendingAuthorization(); + const prompt = value.prompt; + if ( + value.schemaVersion !== 1 || + value.type !== "oauth-device-authorization" || + value.issuer !== NOCOBASE_OAUTH_ISSUER || + !nonEmptyString(value.sessionId) || + !nonEmptyString(value.clientId) || + !nonEmptyString(value.deviceCode) || + typeof prompt.opened !== "boolean" || + !nonEmptyString(prompt.url) || + !nonEmptyString(prompt.userCode) || + !positiveFinite(prompt.expiresIn) || + !positiveFinite(value.expiresAt) || + !positiveFinite(value.intervalSeconds) || + value.intervalSeconds > 30 || + typeof value.nextPollAt !== "number" || + !Number.isFinite(value.nextPollAt) || + value.nextPollAt <= 0 + ) { + throw invalidPendingAuthorization(); + } + return { + schemaVersion: 1, + type: "oauth-device-authorization", + sessionId: value.sessionId.trim(), + issuer: NOCOBASE_OAUTH_ISSUER, + clientId: value.clientId.trim(), + deviceCode: value.deviceCode.trim(), + prompt: { + opened: prompt.opened, + url: trustedBrowserUrl(prompt.url).href, + userCode: prompt.userCode.trim(), + expiresIn: prompt.expiresIn, + }, + expiresAt: value.expiresAt, + intervalSeconds: value.intervalSeconds, + nextPollAt: value.nextPollAt, + }; +} + +interface StartedDeviceLogin { + discovery: OAuthDiscovery; + clientId: string; + authorization: DeviceAuthorization; + prompt: AuthorizationPrompt; +} + +async function beginDeviceLogin(options: BrowserLoginOptions): Promise { + const fetchImpl = options.fetch ?? fetch; const discovery = await discoverOAuth(fetchImpl); let clientId = options.clientId?.trim(); if (!clientId) clientId = await registerPublicClient(discovery, fetchImpl); @@ -110,12 +239,10 @@ export async function startBrowserLogin(options: BrowserLoginOptions = {}): Prom options.onAuthorization?.(prompt); return { + discovery, + clientId, + authorization, prompt, - credentials: pollForTokens(discovery, clientId, authorization, { - fetch: fetchImpl, - now, - sleep, - }), }; } @@ -147,6 +274,7 @@ export async function refreshOAuthCredentials( return tokenCredentials(payload, credentials.clientId, options.now ?? Date.now, { refreshToken: credentials.refreshToken, scope: credentials.scope, + authorizationSessionId: credentials.authorizationSessionId, }); } @@ -267,41 +395,75 @@ async function pollForTokens( let intervalSeconds = authorization.interval; while (options.now() < deadline) { await options.sleep(intervalSeconds * 1_000); - const form = new URLSearchParams({ - grant_type: DEVICE_GRANT, - client_id: clientId, - device_code: authorization.deviceCode, - resource: NOCOBASE_OAUTH_RESOURCE, - }); - try { - const payload = await requestJson( - discovery.tokenEndpoint, - { method: "POST", headers: formHeaders(), body: form }, - options.fetch, - [authorization.deviceCode], - ); - return tokenCredentials(payload, clientId, options.now, { requireRefreshToken: true }); - } catch (error) { - if (error instanceof OAuthProtocolError && error.code === "authorization_pending") continue; - if (error instanceof OAuthProtocolError && error.code === "slow_down") { - intervalSeconds = Math.min(intervalSeconds + 5, 30); - continue; - } - if (error instanceof OAuthProtocolError && error.code === "access_denied") { - throw new OAuthProtocolError("NocoBase browser authorization was cancelled", error.code); - } - if (error instanceof OAuthProtocolError && error.code === "expired_token") break; - throw error; + const result = await pollDeviceTokenOnce(discovery, clientId, authorization.deviceCode, options.fetch); + if (result.status === "authenticated") { + return tokenCredentials(result.payload, clientId, options.now, { requireRefreshToken: true }); } + if (result.status === "slow_down") intervalSeconds = Math.min(intervalSeconds + 5, 30); + if (result.status === "expired_token") break; } - throw new OAuthProtocolError("NocoBase browser authorization expired; start a new login", "expired_token"); + throw expiredAuthorization(); +} + +type DeviceTokenPollResult = + | { status: "authenticated"; payload: unknown } + | { status: "authorization_pending" | "slow_down" | "expired_token" }; + +async function pollDeviceTokenOnce( + discovery: OAuthDiscovery, + clientId: string, + deviceCode: string, + fetchImpl: typeof fetch, +): Promise { + try { + return { + status: "authenticated", + payload: await requestDeviceToken(discovery, clientId, deviceCode, fetchImpl), + }; + } catch (error) { + if ( + error instanceof OAuthProtocolError && + ["authorization_pending", "slow_down", "expired_token"].includes(error.code ?? "") + ) { + return { status: error.code as "authorization_pending" | "slow_down" | "expired_token" }; + } + if (error instanceof OAuthProtocolError && error.code === "access_denied") { + throw new OAuthProtocolError("NocoBase browser authorization was cancelled", error.code); + } + throw error; + } +} + +async function requestDeviceToken( + discovery: OAuthDiscovery, + clientId: string, + deviceCode: string, + fetchImpl: typeof fetch, +): Promise { + const form = new URLSearchParams({ + grant_type: DEVICE_GRANT, + client_id: clientId, + device_code: deviceCode, + resource: NOCOBASE_OAUTH_RESOURCE, + }); + return await requestJson( + discovery.tokenEndpoint, + { method: "POST", headers: formHeaders(), body: form }, + fetchImpl, + [deviceCode], + ); } function tokenCredentials( payload: unknown, clientId: string, now: () => number, - fallback: { refreshToken?: string; scope?: string; requireRefreshToken?: boolean }, + fallback: { + refreshToken?: string; + scope?: string; + requireRefreshToken?: boolean; + authorizationSessionId?: string; + }, ): OAuthCredentials { if (!isRecord(payload)) throw new OAuthProtocolError("OAuth token endpoint returned invalid JSON"); const accessToken = stringField(payload, "access_token"); @@ -331,6 +493,7 @@ function tokenCredentials( tokenType: "Bearer", scope, expiresAt: now() + positiveNumber(payload.expires_in, "expires_in") * 1_000, + ...(fallback.authorizationSessionId ? { authorizationSessionId: fallback.authorizationSessionId } : {}), }; } @@ -423,6 +586,34 @@ function positiveNumber(value: unknown, key: string): number { return value; } +function positiveFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && Boolean(value.trim()); +} + +function nextAuthorizationPoll( + authorization: PendingOAuthAuthorization, + currentTime: number, + intervalSeconds: number, +): PendingOAuthAuthorization { + return { + ...authorization, + intervalSeconds, + nextPollAt: currentTime + intervalSeconds * 1_000, + }; +} + +function invalidPendingAuthorization(): OAuthProtocolError { + return new OAuthProtocolError("Stored browser authorization is invalid; start a new login"); +} + +function expiredAuthorization(): OAuthProtocolError { + return new OAuthProtocolError("NocoBase browser authorization expired; start a new login", "expired_token"); +} + function requireArrayValue(payload: Record, key: string, expected: string): void { const values = payload[key]; if (!Array.isArray(values) || !values.includes(expected)) { diff --git a/src/private-json.ts b/src/private-json.ts new file mode 100644 index 0000000..852c465 --- /dev/null +++ b/src/private-json.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + fchmodSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +export function readPrivateJson(path: string, label: string): unknown { + if (process.platform !== "win32") { + const mode = statSync(path).mode & 0o777; + if ((mode & 0o077) !== 0) { + throw new Error(`${label} file must be private (0600); current mode is ${mode.toString(8)}`); + } + } + return JSON.parse(readFileSync(path, "utf8")); +} + +export function writePrivateJson(path: string, value: unknown, label: string): void { + ensurePrivateDirectory(path, label); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + try { + const descriptor = openSync(temporaryPath, "wx", 0o600); + try { + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + } finally { + closeSync(descriptor); + } + renameSync(temporaryPath, path); + } catch (error) { + try { + unlinkSync(temporaryPath); + } catch {} + throw error; + } +} + +export function ensurePrivateDirectory(path: string, label: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + const directoryMode = statSync(dirname(path)).mode & 0o777; + if ((directoryMode & 0o077) !== 0) { + throw new Error(`${label} directory permissions are too broad: ${directoryMode.toString(8)}`); + } + } +} + +export function removePrivateFile(path: string): void { + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} diff --git a/src/tools.ts b/src/tools.ts index fb86576..d1bf55d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -29,7 +29,7 @@ export function createServer(options: { register( server, "nocobase_login", - "Start NocoBase browser sign-in. Returns a safe login URL and confirmation code to send to the user; never returns OAuth tokens.", + "Start or reuse resumable NocoBase OAuth device sign-in. When authorization is pending, you MUST deliver login_url and user_code verbatim to the requesting user in the current private conversation, including on a headless server. They are user-verification data, not a password, API key, bearer token, or device_code; remote desktop is not required. Never post them publicly or send them to a third party.", { force: z .boolean() @@ -44,7 +44,7 @@ export function createServer(options: { register( server, "nocobase_login_status", - "Check browser sign-in status. While pending, repeat the safe login URL; after user approval, returns authenticated.", + "Poll or resume NocoBase device sign-in after MCP restarts. A pending result repeats the same user-facing login_url, user_code, and private-delivery policy; reuse it after retry_after_seconds instead of creating another login.", {}, () => auth.status(), ); @@ -159,18 +159,16 @@ export function createServer(options: { include_raw: z.boolean().default(false), }, (args) => - withRawPolicy(args.include_raw, () => - crm.listCustomers({ - query: args.query, - status: args.status, - owner: args.owner, - team: args.team, - source: args.source, - limit: args.limit, - offset: args.offset, - includeRaw: args.include_raw, - }), - ), + crm.listCustomers({ + query: args.query, + status: args.status, + owner: args.owner, + team: args.team, + source: args.source, + limit: args.limit, + offset: args.offset, + includeRaw: args.include_raw, + }), ); register( server, @@ -184,14 +182,12 @@ export function createServer(options: { include_raw: z.boolean().default(false), }, (args) => - withRawPolicy(args.include_raw, () => - crm.getCustomer(args.customer_id, { - includeContacts: args.include_contacts, - includeRecentFollowups: args.include_recent_followups, - followupLimit: args.followup_limit, - includeRaw: args.include_raw, - }), - ), + crm.getCustomer(args.customer_id, { + includeContacts: args.include_contacts, + includeRecentFollowups: args.include_recent_followups, + followupLimit: args.followup_limit, + includeRaw: args.include_raw, + }), ); register( server, @@ -205,16 +201,14 @@ export function createServer(options: { include_raw: z.boolean().default(false), }, (args) => - withRawPolicy(args.include_raw, () => - crm.listContacts({ - customerId: args.customer_id, - query: args.query, - limit: args.limit, - offset: args.offset, - includeDetails: args.include_details, - includeRaw: args.include_raw, - }), - ), + crm.listContacts({ + customerId: args.customer_id, + query: args.query, + limit: args.limit, + offset: args.offset, + includeDetails: args.include_details, + includeRaw: args.include_raw, + }), ); register( server, @@ -234,22 +228,20 @@ export function createServer(options: { include_raw: z.boolean().default(false), }, (args) => - withRawPolicy(args.include_raw, () => - crm.listFollowups({ - customerId: args.customer_id, - contactId: args.contact_id, - owner: args.owner, - team: args.team, - projectId: args.project_id, - followupType: args.followup_type, - recordStatus: args.record_status, - dateFrom: args.date_from, - dateTo: args.date_to, - limit: args.limit, - offset: args.offset, - includeRaw: args.include_raw, - }), - ), + crm.listFollowups({ + customerId: args.customer_id, + contactId: args.contact_id, + owner: args.owner, + team: args.team, + projectId: args.project_id, + followupType: args.followup_type, + recordStatus: args.record_status, + dateFrom: args.date_from, + dateTo: args.date_to, + limit: args.limit, + offset: args.offset, + includeRaw: args.include_raw, + }), ); register( server, @@ -263,16 +255,14 @@ export function createServer(options: { include_raw: z.boolean().default(false), }, (args) => - withRawPolicy(args.include_raw, () => - crm.listProjects({ - customerId: args.customer_id, - team: args.team, - stage: args.stage, - limit: args.limit, - offset: args.offset, - includeRaw: args.include_raw, - }), - ), + crm.listProjects({ + customerId: args.customer_id, + team: args.team, + stage: args.stage, + limit: args.limit, + offset: args.offset, + includeRaw: args.include_raw, + }), ); register( server, @@ -330,76 +320,66 @@ export function createServer(options: { } function registerWriteTools(server: McpServer, projects: ProjectService): void { - server.registerTool( + register( + server, "create_project", + "Create one fixed-schema project and optionally associate uploaded attachment IDs.", { - description: "Create one fixed-schema project and optionally associate uploaded attachment IDs.", - inputSchema: { - name: z.string().describe("Project name; must not be blank."), - customer_id: z.union([z.string(), z.number()]).optional(), - inquiry_date: z.string().optional(), - progress: z.string().optional(), - applications: z.string().optional(), - part_numbers: z.string().optional(), - period_usage: z.string().optional(), - details: z.string().optional(), - attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), - }, - annotations: writeAnnotations, + name: z.string().describe("Project name; must not be blank."), + customer_id: z.union([z.string(), z.number()]).optional(), + inquiry_date: z.string().optional(), + progress: z.string().optional(), + applications: z.string().optional(), + part_numbers: z.string().optional(), + period_usage: z.string().optional(), + details: z.string().optional(), + attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), }, - async (args) => - toolResult( - await safeTool(() => - projects.createProject({ - name: args.name, - customerId: args.customer_id, - inquiryDate: args.inquiry_date, - progress: args.progress, - applications: args.applications, - partNumbers: args.part_numbers, - periodUsage: args.period_usage, - details: args.details, - attachmentIds: args.attachment_ids, - }), - ), - ), + (args) => + projects.createProject({ + name: args.name, + customerId: args.customer_id, + inquiryDate: args.inquiry_date, + progress: args.progress, + applications: args.applications, + partNumbers: args.part_numbers, + periodUsage: args.period_usage, + details: args.details, + attachmentIds: args.attachment_ids, + }), + writeAnnotations, ); - server.registerTool( + register( + server, "update_project", + "Update fixed project fields and incrementally add or remove attachment IDs.", { - description: "Update fixed project fields and incrementally add or remove attachment IDs.", - inputSchema: { - project_id: z.union([z.string(), z.number()]), - name: z.string().optional(), - customer_id: z.union([z.string(), z.number()]).optional(), - inquiry_date: z.string().optional(), - progress: z.string().optional(), - applications: z.string().optional(), - part_numbers: z.string().optional(), - period_usage: z.string().optional(), - details: z.string().optional(), - add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), - remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), - }, - annotations: writeAnnotations, + project_id: z.union([z.string(), z.number()]), + name: z.string().optional(), + customer_id: z.union([z.string(), z.number()]).optional(), + inquiry_date: z.string().optional(), + progress: z.string().optional(), + applications: z.string().optional(), + part_numbers: z.string().optional(), + period_usage: z.string().optional(), + details: z.string().optional(), + add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), + remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(), }, - async (args) => - toolResult( - await safeTool(() => - projects.updateProject(args.project_id, { - name: args.name, - customerId: args.customer_id, - inquiryDate: args.inquiry_date, - progress: args.progress, - applications: args.applications, - partNumbers: args.part_numbers, - periodUsage: args.period_usage, - details: args.details, - addAttachmentIds: args.add_attachment_ids, - removeAttachmentIds: args.remove_attachment_ids, - }), - ), - ), + (args) => + projects.updateProject(args.project_id, { + name: args.name, + customerId: args.customer_id, + inquiryDate: args.inquiry_date, + progress: args.progress, + applications: args.applications, + partNumbers: args.part_numbers, + periodUsage: args.period_usage, + details: args.details, + addAttachmentIds: args.add_attachment_ids, + removeAttachmentIds: args.remove_attachment_ids, + }), + writeAnnotations, ); } @@ -412,7 +392,7 @@ function register( annotations = readAnnotations, ): void { server.registerTool(name, { description, inputSchema, annotations }, async (args: any) => - toolResult(await safeTool(() => operation(args))), + toolResult(await safeTool(() => withRawPolicy(args.include_raw === true, () => operation(args)))), ); } @@ -442,7 +422,7 @@ async function safeTool(operation: () => Promise): Promise { +test("returns a browser link, then polls and saves OAuth credentials without exposing secrets", async () => { const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-service-")); const path = join(directory, "private", "credentials.json"); - let resolveCredentials: (credentials: OAuthCredentials) => void = () => undefined; - const credentials = new Promise((resolve) => { - resolveCredentials = resolve; - }); + const pendingPath = pendingAuthorizationPath(path); + let currentTime = 1_000; + let polls = 0; let verified = 0; let reloaded = 0; const service = new NocoBaseAuthService({ credentialsFile: path, - now: () => 1_000, - startLogin: async () => ({ - prompt: { - opened: false, - url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", - userCode: "ABCD-EFGH", - expiresIn: 600, - }, - credentials, - }), + now: () => currentTime, + beginLogin: async () => pendingAuthorization(), + pollLogin: async (): Promise => { + polls += 1; + return { status: "authenticated", credentials: oauthCredentials }; + }, verifyCredentials: async (value) => { assert.equal(value.accessToken, oauthCredentials.accessToken); verified += 1; @@ -49,50 +55,145 @@ test("returns a browser link immediately, then verifies and saves OAuth credenti }); const started = await service.start(); - assert.deepEqual(started, { - status: "authorization_pending", - login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", - user_code: "ABCD-EFGH", - expires_in_seconds: 600, - browser_opened: false, - next_action: - "Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.", - }); - assert.doesNotMatch(JSON.stringify(started), /private-access-token|private-refresh-token/); + assert.deepEqual(started, pendingPayload()); + assert.doesNotMatch( + JSON.stringify(started), + /private-access-token|private-refresh-token|private-device-code/, + ); assert.equal(existsSync(path), false); + assert.equal(existsSync(pendingPath), true); + if (process.platform !== "win32") assert.equal(statSync(pendingPath).mode & 0o777, 0o600); assert.equal((await service.status()).status, "authorization_pending"); + assert.equal(polls, 0); - resolveCredentials(oauthCredentials); - for (let attempt = 0; attempt < 10 && !existsSync(path); attempt += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - + currentTime = 6_000; + assert.equal((await service.status()).status, "authenticated"); + assert.equal(polls, 1); assert.equal(verified, 1); assert.equal(reloaded, 1); - assert.equal((await service.status()).status, "authenticated"); + assert.equal(existsSync(pendingPath), false); const stored = loadStoredCredentials(path); assert.equal(stored.type, "oauth"); + if (stored.type === "oauth") { + assert.equal(stored.authorizationSessionId, "authorization-session"); + assert.equal(stored.accessToken, oauthCredentials.accessToken); + } if (process.platform !== "win32") assert.equal(statSync(path).mode & 0o777, 0o600); }); -test("reuses stored login unless a fresh authorization is explicitly requested", async () => { +test("a new service instance resumes and completes the same pending browser authorization", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-restart-")); + const path = join(directory, "private", "credentials.json"); + const first = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 1_000, + beginLogin: async () => pendingAuthorization(), + }); + const started = await first.start(); + assert.equal(started.status, "authorization_pending"); + + let starts = 0; + let polls = 0; + const restarted = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 6_000, + beginLogin: async () => { + starts += 1; + return pendingAuthorization(); + }, + pollLogin: async () => { + polls += 1; + return { status: "authenticated", credentials: oauthCredentials }; + }, + verifyCredentials: async () => undefined, + }); + const resumed = await restarted.status(); + assert.equal(resumed.status, "authenticated"); + assert.equal(starts, 0); + assert.equal(polls, 1); + assert.doesNotMatch(JSON.stringify(resumed), /private-device-code|private-access-token/); + assert.equal(existsSync(pendingAuthorizationPath(path)), false); + assert.equal(loadStoredCredentials(path).type, "oauth"); +}); + +test("a new OS process resumes and completes the same pending browser authorization", () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-child-restart-")); + const path = join(directory, "private", "credentials.json"); + const authServiceUrl = pathToFileURL(resolve("src/auth-service.ts")).href; + const first = runNodeModule(` + const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)}); + const service = new NocoBaseAuthService({ + credentialsFile: ${JSON.stringify(path)}, + now: () => 1000, + beginLogin: async () => (${JSON.stringify(pendingAuthorization())}) + }); + process.stdout.write(JSON.stringify(await service.start())); + `); + assert.equal(JSON.parse(first).status, "authorization_pending"); + assert.equal(existsSync(pendingAuthorizationPath(path)), true); + + const second = runNodeModule(` + const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)}); + const service = new NocoBaseAuthService({ + credentialsFile: ${JSON.stringify(path)}, + now: () => 6000, + pollLogin: async () => ({ + status: "authenticated", + credentials: ${JSON.stringify(oauthCredentials)} + }), + verifyCredentials: async () => undefined + }); + process.stdout.write(JSON.stringify(await service.status())); + `); + assert.equal(JSON.parse(second).status, "authenticated"); + assert.equal(existsSync(pendingAuthorizationPath(path)), false); + const stored = loadStoredCredentials(path); + assert.equal(stored.type, "oauth"); + if (stored.type === "oauth") { + assert.equal(stored.authorizationSessionId, "authorization-session"); + } +}); + +test("keeps exchanged credentials when post-login verification is temporarily unavailable", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-verify-failure-")); + const path = join(directory, "credentials.json"); + const pendingPath = pendingAuthorizationPath(path); + savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingPath); + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 1_000, + pollLogin: async () => ({ status: "authenticated", credentials: oauthCredentials }), + verifyCredentials: async () => { + throw new Error("temporary health check failure"); + }, + }); + + const result = await service.status(); + assert.equal(result.status, "authenticated"); + assert.equal(result.credential_verification, "failed"); + assert.match(String((result.verification_error as Record).message), /temporary/); + assert.doesNotMatch( + JSON.stringify(result), + /private-access-token|private-refresh-token|private-device-code/, + ); + assert.equal(existsSync(pendingPath), false); + assert.equal(loadStoredCredentials(path).type, "oauth"); + + const restarted = new NocoBaseAuthService({ credentialsFile: path }); + assert.equal((await restarted.status()).status, "authenticated"); +}); + +test("reuses stored login and an already pending forced login", async () => { const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-force-")); const path = join(directory, "private", "credentials.json"); saveCredentials({ authorizeKey: "personal-key" }, path); let starts = 0; const service = new NocoBaseAuthService({ credentialsFile: path, - startLogin: async () => { + now: () => 1_000, + beginLogin: async () => { starts += 1; - return { - prompt: { - opened: true, - url: "https://justconn.top/idpOAuth/device?user_code=FRESH-CODE", - userCode: "FRESH-CODE", - expiresIn: 600, - }, - credentials: new Promise(() => undefined), - }; + return pendingAuthorization(); }, }); @@ -103,3 +204,261 @@ test("reuses stored login unless a fresh authorization is explicitly requested", assert.equal((await service.start({ force: true })).status, "authorization_pending"); assert.equal(starts, 1); }); + +test("coalesces concurrent login starts across MCP service instances", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-start-concurrent-")); + const path = join(directory, "credentials.json"); + let starts = 0; + let resolveStart: (authorization: PendingOAuthAuthorization) => void = () => undefined; + const started = new Promise((resolve) => { + resolveStart = resolve; + }); + const options = { + credentialsFile: path, + now: () => 1_000, + beginLogin: async () => { + starts += 1; + return await started; + }, + }; + const firstService = new NocoBaseAuthService(options); + const secondService = new NocoBaseAuthService(options); + + const first = firstService.start(); + await new Promise((resolve) => setImmediate(resolve)); + const second = secondService.start(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(starts, 1); + resolveStart(pendingAuthorization()); + const results = await Promise.all([first, second]); + assert.deepEqual( + results.map((result) => result.login_url), + [ + "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", + "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", + ], + ); + assert.equal(starts, 1); +}); + +test("removes an expired pending authorization instead of creating another link", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-expired-")); + const path = join(directory, "credentials.json"); + const pendingPath = pendingAuthorizationPath(path); + savePendingAuthorization(pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }), pendingPath); + let polls = 0; + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 1_000, + pollLogin: async () => { + polls += 1; + return { status: "authorization_pending", authorization: pendingAuthorization() }; + }, + }); + + const result = await service.status(); + assert.equal(result.status, "authorization_failed"); + assert.equal(polls, 0); + assert.equal(existsSync(pendingPath), false); + assert.doesNotMatch(JSON.stringify(result), /private-device-code/); +}); + +test("stored credentials supersede an earlier in-memory authorization failure", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-disk-truth-")); + const path = join(directory, "credentials.json"); + savePendingAuthorization( + pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }), + pendingAuthorizationPath(path), + ); + const service = new NocoBaseAuthService({ credentialsFile: path, now: () => 1_000 }); + + assert.equal((await service.status()).status, "authorization_failed"); + saveCredentials({ authorizeKey: "new-personal-key" }, path); + assert.equal((await service.status()).status, "authenticated"); +}); + +test("coalesces concurrent status polls for one pending authorization", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-concurrent-")); + const path = join(directory, "credentials.json"); + savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path)); + let polls = 0; + let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined; + const poll = new Promise((resolve) => { + resolvePoll = resolve; + }); + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 1_000, + pollLogin: async () => { + polls += 1; + return await poll; + }, + verifyCredentials: async () => undefined, + }); + + const first = service.status(); + const second = service.status(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(polls, 1); + resolvePoll({ status: "authenticated", credentials: oauthCredentials }); + const results = await Promise.all([first, second]); + assert.deepEqual( + results.map((result) => result.status), + ["authenticated", "authenticated"], + ); +}); + +test("prevents duplicate status polls across MCP service instances", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-poll-concurrent-")); + const path = join(directory, "credentials.json"); + savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path)); + let polls = 0; + let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined; + const poll = new Promise((resolve) => { + resolvePoll = resolve; + }); + const options = { + credentialsFile: path, + now: () => 1_000, + pollLogin: async () => { + polls += 1; + return await poll; + }, + verifyCredentials: async () => undefined, + }; + const firstService = new NocoBaseAuthService(options); + const secondService = new NocoBaseAuthService(options); + + const first = firstService.status(); + await new Promise((resolve) => setImmediate(resolve)); + const second = await secondService.status(); + assert.equal(polls, 1); + assert.equal(second.status, "authorization_pending"); + assert.equal(second.poll_in_progress, true); + assert.doesNotMatch(JSON.stringify(second), /private-device-code|private-access-token/); + + resolvePoll({ status: "authenticated", credentials: oauthCredentials }); + assert.equal((await first).status, "authenticated"); + assert.equal((await secondService.status()).status, "authenticated"); + assert.equal(polls, 1); +}); + +test("reclaims an authorization lock owned by a dead process", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-dead-lock-")); + const path = join(directory, "private", "credentials.json"); + const pendingPath = pendingAuthorizationPath(path); + const lockPath = `${pendingPath}.lock`; + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + writeFileSync(lockPath, JSON.stringify({ pid: 2_147_483_647, createdAt: 1_000, token: "dead-owner" }), { + mode: 0o600, + }); + let starts = 0; + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 2_000, + beginLogin: async () => { + starts += 1; + return pendingAuthorization(); + }, + }); + + assert.equal((await service.start()).status, "authorization_pending"); + assert.equal(starts, 1); + assert.equal(existsSync(lockPath), false); +}); + +test("reclaims a malformed authorization lock only after it becomes stale", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-malformed-lock-")); + const path = join(directory, "private", "credentials.json"); + const pendingPath = pendingAuthorizationPath(path); + const lockPath = `${pendingPath}.lock`; + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + writeFileSync(lockPath, "not-json", { mode: 0o600 }); + utimesSync(lockPath, new Date(0), new Date(0)); + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 200_000, + beginLogin: async () => pendingAuthorization(), + }); + + assert.equal((await service.start()).status, "authorization_pending"); + assert.equal(existsSync(lockPath), false); +}); + +test("a lock holder never removes a replacement lock owned by another process", async () => { + const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-lock-owner-")); + const path = join(directory, "private", "credentials.json"); + const lockPath = `${pendingAuthorizationPath(path)}.lock`; + const service = new NocoBaseAuthService({ + credentialsFile: path, + now: () => 1_000, + beginLogin: async () => { + writeFileSync( + lockPath, + JSON.stringify({ pid: process.pid, createdAt: 1_000, token: "replacement-owner" }), + ); + return pendingAuthorization(); + }, + }); + + assert.equal((await service.start()).status, "authorization_pending"); + assert.equal(JSON.parse(readFileSync(lockPath, "utf8")).token, "replacement-owner"); +}); + +function pendingAuthorization(overrides: Partial = {}): PendingOAuthAuthorization { + return { + schemaVersion: 1, + type: "oauth-device-authorization", + sessionId: "authorization-session", + issuer: "https://justconn.top/api", + clientId: "desktop-client", + deviceCode: "private-device-code", + prompt: { + opened: false, + url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", + userCode: "ABCD-EFGH", + expiresIn: 600, + }, + expiresAt: 601_000, + intervalSeconds: 5, + nextPollAt: 6_000, + ...overrides, + }; +} + +function pendingPayload(): Record { + return { + status: "authorization_pending", + authorization_type: "oauth_device_user_verification", + login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", + verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH", + user_code: "ABCD-EFGH", + intended_recipient: "requesting_user", + delivery_channel: "current_private_conversation", + shareable_with_requesting_user: true, + private_conversation_required: true, + public_sharing_allowed: false, + contains_bearer_credentials: false, + contains_device_code: false, + remote_desktop_required: false, + expires_in_seconds: 600, + retry_after_seconds: 5, + browser_opened: false, + next_action: + "Deliver login_url and user_code verbatim to the requesting user once. On a headless or remote server, send them in the current private conversation: they are OAuth device user-verification data, not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party. Then wait retry_after_seconds and call nocobase_login_status. Reuse this authorization; do not ask the user to log in again.", + }; +} + +function runNodeModule(source: string): string { + const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], { + cwd: resolve("."), + encoding: "utf8", + timeout: 10_000, + }); + assert.equal(child.status, 0, child.stderr || child.error?.message); + return child.stdout; +} + +function savePendingAuthorization(authorization: PendingOAuthAuthorization, path: string): void { + new AuthorizationStore(path).save(authorization); +} diff --git a/test/cli-package.test.ts b/test/cli-package.test.ts index dd382bf..c1257f2 100644 --- a/test/cli-package.test.ts +++ b/test/cli-package.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { clientConfig, runCli } from "../src/cli.js"; +import { clientConfig, runCli, setupAuthorizationEvent } from "../src/cli.js"; import { VERSION } from "../src/version.js"; test("package, manifest, and runtime versions agree with commit-pinned installation", () => { @@ -19,33 +19,115 @@ test("package, manifest, and runtime versions agree with commit-pinned installat assert.equal(manifest.distribution.releaseRef, undefined); assert.equal(manifest.installation.temporarySourceArchiveBuild, true); assert.equal(manifest.installation.fallbackToRelease, false); + assert.equal(manifest.installation.supportsInPlaceUpdate, true); + assert.equal(manifest.installation.sameCommitBuildSkip, true); + assert.deepEqual(manifest.installation.installReceipt, { + path: "/install-receipt.json", + schemaVersion: 1, + posixFileMode: "0600", + containsSecrets: false, + }); + assert.deepEqual(manifest.installation.operationDetection, [ + "fresh-install", + "in-place-update", + "repair", + "already-current", + ]); assert.equal(manifest.entrypoints.guidedCommand, "client-config"); assert.equal(manifest.entrypoints.mcpLoginTool, "nocobase_login"); assert.equal(manifest.entrypoints.mcpLoginStatusTool, "nocobase_login_status"); + assert.equal(manifest.authentication.processRestartResumable, true); + assert.equal( + manifest.authentication.pendingAuthorizationStorage, + "short-lived per-user private local file", + ); + assert.equal(manifest.authentication.allowDeviceCodeExposure, false); + assert.deepEqual(manifest.authentication.userVerificationDelivery, { + fields: ["login_url", "verification_uri_complete", "user_code"], + intendedRecipient: "requesting-user", + channel: "current-private-conversation", + shareableWithIntendedRecipient: true, + publicSharingAllowed: false, + containsBearerCredentials: false, + containsDeviceCode: false, + remoteDesktopRequired: false, + }); assert.deepEqual(packageJson.dependencies, undefined); assert.equal(manifest.runtime.runtimeNpmDependencies, 0); }); -test("installation protocol resolves main once and never falls back to an old Release", () => { +test("installation and update protocol resolves main once and never falls back to an old Release", () => { const guide = readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8"); const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); const installUrl = "https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md"; assert.match(guide, /branches\/main/); assert.match(guide, /40 位十六进制完整 SHA/); assert.match(guide, /resolve-latest-commit-then-pin/); + assert.match(guide, /installation\.supportsInPlaceUpdate=true/); + assert.match(guide, /installation\.sameCommitBuildSkip=true/); assert.match(guide, /archive\/\.zip/); assert.match(guide, /archive\/\.tar\.gz/); assert.match(guide, /Windows 使用 `.zip`/); + assert.match(guide, /不得无条件调用.*TarFile\.extractall/); + assert.match(guide, /inspect\.signature/); + assert.match(guide, /extractfile\(\)/); + assert.match(guide, /不得直接调用不带过滤器的 `extractall\(\)`/); + assert.match(guide, /不需要重新解析 `main`、重新下载归档或中断用户/); assert.match(guide, /不得.*退回旧 Release/); - assert.match(guide, /client-config --json/); + assert.match(guide, /client-config \[--config \] \[--enable-writes\] --json/); assert.match(guide, /nocobase_login\(\{\}\)/); + assert.match(guide, /stdio 进程.*退出或重启/); + assert.match(guide, /下一次 `nocobase_login_status`.*恢复同一个链接/); + assert.match(guide, /判断首次安装、更新或已有最新版/); + assert.match(guide, /install-receipt\.json/); + assert.match(guide, /保留.*CRM.*配置.*个人凭据.*只读\/写入模式/); + assert.match(guide, /先调用 `crm_health`/); + assert.match(guide, /不得仅因更新而要求用户重新登录/); + assert.match(guide, /不得以“链接包含一次性凭据”为由拒绝发送/); + assert.match(guide, /不得要求用户操作服务器远程桌面/); assert.ok(guide.includes(installUrl)); assert.ok(readme.includes(installUrl)); + assert.match(readme, /帮我安装或更新 NocoBase CRM MCP/); + assert.match(readme, /相同 Commit 不会重复构建/); + assert.match(readme, /只有认证确实缺失或失效时才发起登录/); + assert.match(readme, /无界面或远程环境也要发到当前私聊/); + assert.match(readme, /不要要求远程桌面/); assert.match(readme, /nocobase_login/); + const promptStart = readme.indexOf("请按以下 INSTALL.md"); + const promptEnd = readme.indexOf(installUrl, promptStart) + installUrl.length; + assert.ok(promptStart >= 0 && promptEnd > promptStart); + assert.ok([...readme.slice(promptStart, promptEnd)].length < 350); assert.doesNotMatch(guide, /下载本文件所在的同一固定 Release/); assert.doesNotMatch(readme, /
/); }); +test("headless setup emits user-deliverable OAuth verification metadata", () => { + const event = setupAuthorizationEvent({ + opened: false, + url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", + userCode: "TEST-CODE", + expiresIn: 600, + }); + assert.equal(event.event, "authorization_required"); + assert.equal(event.authorization_type, "oauth_device_user_verification"); + assert.equal(event.login_url, "https://justconn.top/idpOAuth/device?user_code=TEST-CODE"); + assert.equal(event.verification_uri_complete, event.login_url); + assert.equal(event.user_code, "TEST-CODE"); + assert.equal(event.intended_recipient, "requesting_user"); + assert.equal(event.delivery_channel, "current_private_conversation"); + assert.equal(event.shareable_with_requesting_user, true); + assert.equal(event.private_conversation_required, true); + assert.equal(event.public_sharing_allowed, false); + assert.equal(event.contains_bearer_credentials, false); + assert.equal(event.contains_device_code, false); + assert.equal(event.remote_desktop_required, false); + assert.match(String(event.next_action), /current private conversation/); + assert.match(String(event.next_action), /remote desktop is not required/); + assert.equal(Object.hasOwn(event, "access_token"), false); + assert.equal(Object.hasOwn(event, "refresh_token"), false); + assert.equal(Object.hasOwn(event, "device_code"), false); +}); + test("generated MCP config contains no Key, token, or environment secret", () => { const config = clientConfig("/private/config.json", false); const serialized = JSON.stringify(config); diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 7b744ce..23a07bc 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { type AuthorizationPrompt, loginWithBrowser, refreshOAuthCredentials } from "../src/oauth.js"; +import { + type AuthorizationPrompt, + beginBrowserLogin, + loginWithBrowser, + type PendingOAuthAuthorization, + pollBrowserLogin, + refreshOAuthCredentials, + validatePendingOAuthAuthorization, +} from "../src/oauth.js"; const discovery = { issuer: "https://justconn.top/api", @@ -77,6 +85,7 @@ test("uses NocoBase dynamic registration and browser device authorization withou assert.equal(credentials.accessToken, "oauth-access-token"); assert.equal(credentials.refreshToken, "oauth-refresh-token"); assert.equal(credentials.expiresAt, 901_000); + assert.equal(calls.filter((call) => call.url.endsWith("/.well-known/openid-configuration")).length, 1); assert.doesNotMatch(JSON.stringify(credentials), /must-not-be-stored/); assert.deepEqual(prompt, { opened: true, @@ -98,6 +107,147 @@ test("uses NocoBase dynamic registration and browser device authorization withou assert.equal(deviceForm.get("resource"), "https://justconn.top/api/"); }); +test("resumes one persisted browser authorization without creating another link", async () => { + let currentTime = 1_000; + let deviceStarts = 0; + let tokenPolls = 0; + const fetchMock = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery); + if (url.endsWith("/idpOAuth/device/auth")) { + deviceStarts += 1; + return jsonResponse({ + device_code: "private-resumable-device-code", + user_code: "RESUME-CODE", + verification_uri: "https://justconn.top/idpOAuth/device", + verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE", + expires_in: 600, + interval: 5, + }); + } + if (url.endsWith("/idpOAuth/token")) { + tokenPolls += 1; + return jsonResponse({ + access_token: "resumed-access-token", + refresh_token: "resumed-refresh-token", + token_type: "Bearer", + scope: "api offline_access", + expires_in: 900, + }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + const authorization = await beginBrowserLogin({ + clientId: "desktop-client", + fetch: fetchMock, + now: () => currentTime, + openBrowser: async () => false, + }); + assert.equal(deviceStarts, 1); + assert.equal(authorization.deviceCode, "private-resumable-device-code"); + assert.equal(authorization.nextPollAt, 6_000); + + currentTime = 5_000; + const tooEarly = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime }); + assert.equal(tooEarly.status, "authorization_pending"); + assert.equal(tokenPolls, 0); + + currentTime = 6_000; + const resumed = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime }); + assert.equal(resumed.status, "authenticated"); + if (resumed.status === "authenticated") { + assert.equal(resumed.credentials.accessToken, "resumed-access-token"); + assert.equal(resumed.credentials.authorizationSessionId, authorization.sessionId); + } + assert.equal(tokenPolls, 1); + assert.equal(deviceStarts, 1); +}); + +test("normalizes pending and slow-down responses for resumable browser login", async () => { + for (const scenario of [ + { code: "authorization_pending", intervalSeconds: 5, nextPollAt: 11_000 }, + { code: "slow_down", intervalSeconds: 10, nextPollAt: 16_000 }, + ]) { + let discoveries = 0; + const fetchMock = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + discoveries += 1; + return jsonResponse(discovery); + } + if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400); + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + const result = await pollBrowserLogin(persistedAuthorization(), { + fetch: fetchMock, + now: () => 6_000, + }); + assert.equal(result.status, "authorization_pending"); + if (result.status === "authorization_pending") { + assert.equal(result.authorization.intervalSeconds, scenario.intervalSeconds); + assert.equal(result.authorization.nextPollAt, scenario.nextPollAt); + } + assert.equal(discoveries, 1); + } +}); + +test("maps cancelled and expired resumable browser login to stable errors", async () => { + for (const scenario of [ + { code: "access_denied", message: /cancelled/ }, + { code: "expired_token", message: /expired/ }, + ]) { + const fetchMock = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery); + if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400); + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + await assert.rejects( + pollBrowserLogin(persistedAuthorization(), { fetch: fetchMock, now: () => 6_000 }), + scenario.message, + ); + } + + let calls = 0; + await assert.rejects( + pollBrowserLogin(persistedAuthorization({ expiresAt: 6_000 }), { + fetch: (async () => { + calls += 1; + throw new Error("must not fetch"); + }) as typeof fetch, + now: () => 6_000, + }), + /expired/, + ); + assert.equal(calls, 0); +}); + +test("rejects a persisted browser authorization that was redirected off origin", () => { + assert.throws( + () => + validatePendingOAuthAuthorization({ + schemaVersion: 1, + type: "oauth-device-authorization", + sessionId: "session", + issuer: "https://justconn.top/api", + clientId: "client", + deviceCode: "device-code", + prompt: { + opened: false, + url: "https://evil.example/device", + userCode: "CODE", + expiresIn: 600, + }, + expiresAt: 601_000, + intervalSeconds: 5, + nextPollAt: 6_000, + }), + /untrusted browser URL/, + ); +}); + test("rejects OAuth discovery that points tokens at another origin", async () => { const fetchMock = (async () => jsonResponse({ ...discovery, token_endpoint: "https://evil.example/token" })) as typeof fetch; @@ -148,3 +298,26 @@ function jsonResponse(value: unknown, status = 200): Response { headers: { "content-type": "application/json" }, }); } + +function persistedAuthorization( + overrides: Partial = {}, +): PendingOAuthAuthorization { + return { + schemaVersion: 1, + type: "oauth-device-authorization", + sessionId: "persisted-session", + issuer: "https://justconn.top/api", + clientId: "desktop-client", + deviceCode: "private-device-code", + prompt: { + opened: false, + url: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE", + userCode: "RESUME-CODE", + expiresIn: 600, + }, + expiresAt: 601_000, + intervalSeconds: 5, + nextPollAt: 6_000, + ...overrides, + }; +} diff --git a/test/tools.test.ts b/test/tools.test.ts index b03f21d..501a3fc 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -23,8 +23,18 @@ function fakeAuthService(): AuthService { return { start: async () => ({ status: "authorization_pending", + authorization_type: "oauth_device_user_verification", login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", + verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", user_code: "TEST-CODE", + intended_recipient: "requesting_user", + delivery_channel: "current_private_conversation", + shareable_with_requesting_user: true, + private_conversation_required: true, + public_sharing_allowed: false, + contains_bearer_credentials: false, + contains_device_code: false, + remote_desktop_required: false, }), status: async () => ({ status: "login_required" }), }; @@ -85,6 +95,25 @@ test("read-only server exposes CRM tools plus browser login tools", async () => } }); +test("login tools require a private user handoff instead of headless remote desktop", async () => { + const connection = await connected(true); + try { + const tools = (await connection.client.listTools()).tools; + const login = tools.find((tool) => tool.name === "nocobase_login"); + const status = tools.find((tool) => tool.name === "nocobase_login_status"); + assert.ok(login?.description); + assert.ok(status?.description); + assert.match(login.description, /MUST deliver login_url and user_code/); + assert.match(login.description, /current private conversation/); + assert.match(login.description, /headless server/); + assert.match(login.description, /remote desktop is not required/); + assert.match(login.description, /Never post them publicly/); + assert.match(status.description, /private-delivery policy/); + } finally { + await connection.close(); + } +}); + test("authentication failures tell the Agent to use the MCP browser login flow", async () => { const service = { crmHealth: async () => { @@ -114,10 +143,24 @@ test("browser login tool returns only the safe user-facing authorization fields" }); assert.deepEqual(result.structuredContent, { status: "authorization_pending", + authorization_type: "oauth_device_user_verification", login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", + verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", user_code: "TEST-CODE", + intended_recipient: "requesting_user", + delivery_channel: "current_private_conversation", + shareable_with_requesting_user: true, + private_conversation_required: true, + public_sharing_allowed: false, + contains_bearer_credentials: false, + contains_device_code: false, + remote_desktop_required: false, }); - assert.doesNotMatch(JSON.stringify(result), /access.token|refresh.token|device.code/i); + const payload = result.structuredContent as Record; + assert.equal(Object.hasOwn(payload, "access_token"), false); + assert.equal(Object.hasOwn(payload, "refresh_token"), false); + assert.equal(Object.hasOwn(payload, "device_code"), false); + assert.equal(Object.hasOwn(payload, "password"), false); } finally { await connection.close(); }