diff --git a/README.md b/README.md index 67bc099..cac3649 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ DETA 领域能力在各类 Agent harness 上的分发仓:**一份产物,多 ## 安装(Codex) ```bash +# 先登录(一次性,需要 deta.city 账号):换取 DETA_TOKEN +node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs +source ~/.deta/env # 让 DETA_TOKEN 进入当前 shell 环境 + # 从 Git(分发用) codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git @@ -26,6 +30,9 @@ codex plugin list codex plugin add deta@deta ``` +未登录(环境里没有 `DETA_TOKEN`)时,MCP 端点会返回 **401**——含义是 +「当前环境没有可用的 deta.city 登录态」,重新执行上面的登录两步即可。 + ## 内容 | 插件 | 技能 | 能力 | 依赖 | @@ -34,22 +41,37 @@ codex plugin add deta@deta ## 前置条件 -`deta` 插件的 MCP 后端是领域服务的无头 MCP 服务: +| 项 | 值 | +|----|-----| +| MCP 端点 | `https://deta.city/mcp-api/mcp`(MCP Streamable HTTP) | +| 凭据来源 | **deta.city 账号**;`bin/deta-login.mjs` 登录取令牌,经环境变量 `DETA_TOKEN` 传给 harness | +| 凭据文件 | `~/.deta/credentials.json`(令牌 + 账号 + 端点)、`~/.deta/env`(`export DETA_TOKEN=...`),均 0600 | + +端点除探活接口(服务端 `GET /health`,经 Nginx 前缀对外为 `/mcp-api/health`)外全部需要鉴权: + +- 未带凭据 / 凭据失效 → `401`(提示重新登录,不回显凭据); +- 服务端认证服务不可用 → `500`,同样不放行; +- 各账号在服务端有独立工作区(`<工作区根>/<账号 ID>`),互不可见。 + +### 本地 stdio 形态(开发用,默认关闭) + +本机开发时也可以直连领域服务的无头 MCP 服务(`bin/deta-mcp.sh`,stdio), +它**不校验 deta.city 账号、不隔离工作区**,仅供本机迭代领域能力: ```bash cd ~/Dev/ant/ant-copilot && npm run build node dist/src/headless/mcp-server.js # 冒烟:应等待 stdin/stdout ``` -环境变量: +该形态的环境变量: | 变量 | 缺省 | 说明 | |------|------|------| | `DETA_SERVER_ROOT` | `~/Dev/ant/ant-copilot` | 领域服务仓库根目录(内部路径) | | `ANT_HEADLESS_WORKSPACE` | `/workspace` | AML 工作目录(`aml.xml` / `building.json` 落盘处,名称由底层服务决定) | -编译与校验类工具需要编译服务在线(`localhost:8000`);未在线时返回明确错误, -读写类工具可离线工作。 +编译与校验类工具需要编译服务在线(服务端为 `DETA_GSA_URL`,缺省 `localhost:8000`); +未在线时返回明确错误,读写类工具可离线工作。 ## 本地迭代 @@ -117,8 +139,9 @@ node apps/cli/lib/bin.js --profile headless --dump-config \ # 输出中应出现 `# == .../plugins/deta/cordis.patch.yml` 与 deta-mcp 行 ``` -`cordis.patch.yml` 里远程端点那段已按公开形态写好(`streamable-http` + Bearer 头), -当前默认启用的是本地 stdio 行;服务端化完成后注释切换即可。 +`cordis.patch.yml` 当前默认启用**远程端点**(`streamable-http` + +`Authorization: Bearer ${DETA_TOKEN}`,指向 `https://deta.city/mcp-api/mcp`); +本地 stdio 段保留为注释、标注「本机开发用,默认关闭」,需要时手工切换。 ## 已知缺陷(实测,2026-09-10) @@ -160,9 +183,11 @@ Duplicate namespace name 'deta'; Namespace names must be unique. .agents/plugins/api_marketplace.json # Codex API key 登录用户市场 plugins/deta/ # 同一目录 = Codex 插件根 = dsh 组合包根 ├── .codex-plugin/plugin.json # Codex 插件清单 -├── .mcp.json # Codex MCP 声明(含 Codex 私有键) +├── .mcp.json # Codex MCP 声明(远程 streamable-http) ├── package.json # dsh 组合包声明(dsh.bundle.patch) ├── cordis.patch.yml # dsh 配置层(挂载 MCP 服务) -├── bin/deta-mcp.sh # 本地 MCP 启动器(开发用) +├── bin/deta-login.mjs # 登录助手(~/.deta/credentials.json + ~/.deta/env) +├── bin/deta-mcp.sh # 本地 stdio 启动器(开发用,默认关闭) +├── README.md # 插件说明(安装/凭据/401 语义) └── skills/aml-design/SKILL.md # 领域技能(两宿主共享) ``` diff --git a/plugins/deta/.mcp.json b/plugins/deta/.mcp.json index f34debe..1731cac 100644 --- a/plugins/deta/.mcp.json +++ b/plugins/deta/.mcp.json @@ -1,15 +1,9 @@ { "mcpServers": { "deta": { - "command": "./bin/deta-mcp.sh", - "args": [], - "cwd": ".", - "env_vars": [ - "DETA_SERVER_ROOT", - "ANT_HEADLESS_WORKSPACE" - ], - "default_tools_approval_mode": "approve", - "supports_parallel_tool_calls": false + "type": "streamable-http", + "url": "https://deta.city/mcp-api/mcp", + "bearer_token_env_var": "DETA_TOKEN" } } } diff --git a/plugins/deta/README.md b/plugins/deta/README.md new file mode 100644 index 0000000..d0b4016 --- /dev/null +++ b/plugins/deta/README.md @@ -0,0 +1,74 @@ +# deta 插件(Codex 插件 / dsh 组合包) + +DETA 领域能力(AML 建筑模型读写、编译校验、围护结构统计、空间邻接查询) +以**远程 MCP 服务**形态挂进 Agent harness:同一个目录既是 Codex 插件根, +也是 dsh 组合包根,两端共享 `skills/` 与同一份 MCP 后端。 + +## 端点与凭据 + +| 项 | 值 | +|----|-----| +| 端点 | `https://deta.city/mcp-api/mcp`(MCP Streamable HTTP) | +| 凭据来源 | **deta.city 账号**(令牌经 `POST https://deta.city/prod-api/login` 换取) | +| 传参方式 | 环境变量 `DETA_TOKEN`(Codex 侧 `bearer_token_env_var`;dsh 侧 `Authorization: Bearer ${DETA_TOKEN}`) | + +端点除探活接口(服务端 `GET /health`,经 Nginx 前缀对外为 `/mcp-api/health`)外**全部需要鉴权**: + +- 请求未带凭据、凭据失效/过期 → **401**(响应体为可读提示,不回显凭据)。 + 401 的含义是「当前环境没有可用的 deta.city 登录态」,不是工具或模型出错; + 重新执行 `node bin/deta-login.mjs` 并 `source ~/.deta/env` 即可。 +- 服务端认证服务不可用(`ANT_ADMIN_SERVER` 未配置或不可达)→ 500,同样不放行。 + +账号的工作区在服务端按账号隔离(`<工作区根>/<账号 ID>`), +不同账号的设计源码互不可见;同一账号的新会话继续使用自己的工作区。 + +## 安装(Codex) + +```bash +# 1. 先登录(一次性;需要 deta.city 账号) +node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs +# → 写入 ~/.deta/credentials.json 与 ~/.deta/env(均 0600) + +# 2. 让 DETA_TOKEN 进入当前 shell 环境 +source ~/.deta/env + +# 3. 安装插件 +codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git +codex plugin add deta@deta +``` + +登录助手也支持自建/预发环境:`node bin/deta-login.mjs --base-url https://`。 + +## 安装(dsh) + +```bash +node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs +source ~/.deta/env +dsh plugin --profile add ~/Dev/ant/deta-agent-plugins/plugins/deta +``` + +`cordis.patch.yml` 默认启用远程 `streamable-http` 段(Bearer 取自 `DETA_TOKEN`)。 + +## 本地 stdio 形态(开发用) + +`bin/deta-mcp.sh` 走本机 stdio(`node dist/src/headless/mcp-server.js`), +**仅本机开发用,默认关闭**:不校验 deta.city 账号、不隔离工作区。 +需要在本机迭代领域能力时,打开 `cordis.patch.yml` 里注释掉的 stdio 段 +(并注释掉远程段),设置 `DETA_SERVER_ROOT` 指向已构建的领域服务仓库: + +```bash +cd ~/Dev/ant/ant-copilot && npm run build +node dist/src/headless/mcp-server.js # 冒烟:应等待 stdin/stdout +``` + +## 文件 + +``` +.codex-plugin/plugin.json Codex 插件清单 +.mcp.json Codex MCP 声明(远程 streamable-http) +package.json dsh 组合包声明(dsh.bundle.patch) +cordis.patch.yml dsh 配置层(挂载 MCP 服务) +bin/deta-login.mjs 登录助手(写 ~/.deta/credentials.json + ~/.deta/env) +bin/deta-mcp.sh 本地 stdio 启动器(开发用) +skills/aml-design/SKILL.md 领域技能 +``` diff --git a/plugins/deta/bin/deta-login.mjs b/plugins/deta/bin/deta-login.mjs new file mode 100755 index 0000000..c76898e --- /dev/null +++ b/plugins/deta/bin/deta-login.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * DETA 登录助手(Node ESM,零第三方依赖) + * + * 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用: + * + * node bin/deta-login.mjs [--base-url https://deta.city] + * + * 流程: + * 1. 交互式读取用户名/密码(密码不回显); + * 2. POST /prod-api/login { username, password }; + * 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600); + * 4. 失败 → 打印服务端 msg,退出码非 0。 + * + * 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里, + * 不写日志、不写 stderr、不写其它位置。 + */ + +import { mkdir, writeFile, chmod } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const DEFAULT_BASE_URL = 'https://deta.city'; +const REQUEST_TIMEOUT_MS = 20_000; + +function fail(message) { + console.error(`[deta-login] ${message}`); + process.exit(1); +} + +/** `--base-url ` / `--base-url=`;缺省 https://deta.city,末尾斜杠去掉 */ +function parseBaseUrl(argv) { + let baseUrl = process.env.DETA_BASE_URL || DEFAULT_BASE_URL; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--base-url') { + const next = argv[i + 1]; + if (!next) fail('--base-url 缺少取值'); + baseUrl = next; + i++; + } else if (arg.startsWith('--base-url=')) { + baseUrl = arg.slice('--base-url='.length); + } else if (arg === '-h' || arg === '--help') { + console.log('用法:node bin/deta-login.mjs [--base-url https://deta.city]'); + process.exit(0); + } else { + fail(`未知参数:${arg}`); + } + } + return baseUrl.trim().replace(/\/+$/, ''); +} + +/** + * 读一行输入。hidden=true 时不回显(密码); + * 非 TTY(管道/CI)时同样按行读取,便于自动化。 + */ +let pendingInput = ''; + +/** 从暂存缓冲里切出一行(跨 chunk 保留剩余数据),无完整行返回 null */ +function takeLine() { + const index = pendingInput.search(/[\r\n]/); + if (index < 0) return null; + const line = pendingInput.slice(0, index); + const skip = pendingInput[index] === '\r' && pendingInput[index + 1] === '\n' ? 2 : 1; + pendingInput = pendingInput.slice(index + skip); + return line; +} + +function readLine({ hidden }) { + return new Promise((resolve, reject) => { + // 上一次读取可能已经缓冲了后续行(管道一次性写入多行) + const buffered = takeLine(); + if (buffered !== null) { + resolve(buffered); + return; + } + + const stdin = process.stdin; + const isTTY = Boolean(stdin.isTTY); + let buffer = ''; + let settled = false; + + const cleanup = () => { + stdin.removeListener('data', onData); + stdin.removeListener('end', onEnd); + stdin.removeListener('error', onError); + if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false); + stdin.pause(); + }; + const finish = (value) => { + if (settled) return; + settled = true; + cleanup(); + // TTY 下 raw mode 不回显回车,这里补一个换行;非 TTY(管道)不额外输出 + if (isTTY) process.stdout.write('\n'); + resolve(value); + }; + const onData = (chunk) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (!isTTY) { + // 非交互:不回显,按行切分,剩余数据留给下一次读取 + pendingInput += text; + const line = takeLine(); + if (line !== null) finish(line); + return; + } + for (const ch of [...text]) { + if (ch === '\n' || ch === '\r') return finish(buffer); + if (ch === '\u0003') { + // Ctrl-C + process.stdout.write('\n'); + process.exit(130); + } + if (ch === '\u0004') return finish(buffer); // Ctrl-D + if (ch === '\u007f' || ch === '\b') { + if (buffer && !hidden) process.stdout.write('\b \b'); + buffer = buffer.slice(0, -1); + continue; + } + buffer += ch; + if (!hidden) process.stdout.write(ch); + } + }; + // 输入结束:非 TTY 时把残留(末行无换行)也交出去 + const onEnd = () => { + if (!isTTY && pendingInput) { + const rest = pendingInput; + pendingInput = ''; + finish(rest); + return; + } + finish(buffer); + }; + const onError = (err) => { + if (settled) return; + settled = true; + cleanup(); + reject(err); + }; + + if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true); + stdin.resume(); + stdin.on('data', onData); + stdin.on('end', onEnd); + stdin.on('error', onError); + }); +} + +/** 单引号安全转义,供 `export DETA_TOKEN='...'` 使用 */ +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +async function writePrivateFile(filePath, content) { + await writeFile(filePath, content, { mode: 0o600 }); + // 已存在的文件不被 mode 改写,显式收紧 + await chmod(filePath, 0o600).catch(() => undefined); +} + +async function main() { + const baseUrl = parseBaseUrl(process.argv.slice(2)); + + process.stdout.write(`DETA 登录(${baseUrl})\n`); + process.stdout.write('用户名:'); + const username = (await readLine({ hidden: false })).trim(); + if (!username) fail('用户名不能为空'); + + process.stdout.write('密码:'); + const password = await readLine({ hidden: true }); + if (!password) fail('密码不能为空'); + + const loginUrl = `${baseUrl}/prod-api/login`; + let response; + try { + response = await fetch(loginUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + fail(`无法连接 ${loginUrl}(${err instanceof Error ? err.message : String(err)})`); + } + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + const token = typeof payload?.token === 'string' ? payload.token : ''; + if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) { + const msg = payload?.msg || `登录失败(HTTP ${response.status})`; + fail(msg); + } + + const loginUsername = typeof payload.username === 'string' && payload.username ? payload.username : username; + const dir = join(homedir(), '.deta'); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700).catch(() => undefined); + + const credentials = { + token, + username: loginUsername, + baseUrl, + loginAt: new Date().toISOString(), + }; + const credentialsPath = join(dir, 'credentials.json'); + const envPath = join(dir, 'env'); + await writePrivateFile(credentialsPath, `${JSON.stringify(credentials, null, 2)}\n`); + await writePrivateFile( + envPath, + `# DETA MCP 凭据(由 bin/deta-login.mjs 生成,勿提交版本库)\n` + + `# 用法:source ~/.deta/env\n` + + `export DETA_TOKEN=${shellSingleQuote(token)}\n`, + ); + + // token 只在这里(stdout)出现一次;stderr 与其它文件均不含凭据 + console.log(`DETA_TOKEN=${token}`); + console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`); + console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`); + console.log(`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`); +} + +main().catch((err) => { + fail(err instanceof Error ? err.message : String(err)); +}); diff --git a/plugins/deta/cordis.patch.yml b/plugins/deta/cordis.patch.yml index 3c76fd9..5bc5cb7 100644 --- a/plugins/deta/cordis.patch.yml +++ b/plugins/deta/cordis.patch.yml @@ -4,29 +4,37 @@ # 本层只做一件事:把 DETA 领域能力作为 MCP 服务挂进 harness。 # 工具以 mcp__deta__ 暴露(与 Codex 侧同名)。 # -# ── 公开发布形态(远程端点,推荐)───────────────────────────── -# 服务端化完成后启用本节,并删除下面的本地 stdio 节。 +# 凭据:deta.city 账号。先 `node bin/deta-login.mjs` 登录(写入 ~/.deta/credentials.json +# 与 ~/.deta/env),再 `source ~/.deta/env` 让 DETA_TOKEN 进入环境。 +# 端点 https://deta.city/mcp-api/mcp 除 /health 外都要鉴权,无凭据一律 401。 +# +# ── 远程端点形态(Streamable HTTP,默认启用)─────────────────── +# 服务端:deta.city 上的无头 MCP 服务(ant-copilot src/headless/mcp-http-server.ts), +# 经 Nginx 反代暴露;各账号的工作区在服务端按账号隔离。 +# +- insert: + - id: deta-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: deta + transport: streamable-http + url: https://deta.city/mcp-api/mcp + headers: + Authorization: !!js '`Bearer ${process.env.DETA_TOKEN ?? ''}`' +# +# ── 本地形态(本机开发用,默认关闭)─────────────────────────── +# 走 stdio 直连本机已构建的领域服务,不校验 deta.city 账号、不隔离工作区, +# 只在迭代领域能力时临时启用:把下面整节取消注释,同时注释掉上面的远程节。 +# 需要 DETA_SERVER_ROOT 指向已构建的领域服务仓库(缺省见启动器脚本)。 # # - insert: # - id: deta-mcp # name: '@deepseek-ai/dsh-mcp-client' # config: # serverName: deta -# transport: streamable-http -# url: https://deta.city/workspace-api/mcp -# headers: -# Authorization: !!js '`Bearer ${process.env.DETA_TOKEN ?? ''}`' -# -# ── 本地形态(当前可用:本机已构建的领域服务,stdio)───────── -# 需要 DETA_SERVER_ROOT 指向已构建的领域服务仓库(缺省见启动器脚本)。 -- insert: - - id: deta-mcp - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: deta - transport: stdio - command: node - args: - - !!js '`${process.env.DETA_SERVER_ROOT ?? process.env.HOME + "/Dev/ant/ant-copilot"}/dist/src/headless/mcp-server.js`' - env: - ANT_HEADLESS_WORKSPACE: !!js process.env.ANT_HEADLESS_WORKSPACE ?? '' +# transport: stdio +# command: node +# args: +# - !!js '`${process.env.DETA_SERVER_ROOT ?? process.env.HOME + "/Dev/ant/ant-copilot"}/dist/src/headless/mcp-server.js`' +# env: +# ANT_HEADLESS_WORKSPACE: !!js process.env.ANT_HEADLESS_WORKSPACE ?? ''