fde2e-bu 0.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin.js +13 -0
- package/package.json +30 -6
- package/src/args.mjs +92 -0
- package/src/cli.mjs +445 -0
- package/src/configdir.mjs +16 -0
- package/src/mcp/bridge.mjs +208 -0
- package/src/mcp/commands-snapshot.json +340 -0
- package/src/mcp/config-cli.mjs +172 -0
- package/src/mcp/config.mjs +257 -0
- package/src/mcp/http.mjs +250 -0
- package/src/mcp/schema.mjs +175 -0
- package/src/mcp/serve.mjs +203 -0
- package/src/misc.mjs +229 -0
- package/src/nm/bind.mjs +229 -0
- package/src/nm/host.mjs +429 -0
- package/src/nm/update.mjs +727 -0
- package/src/skills/frontmatter.mjs +150 -0
- package/src/skills/skills.mjs +323 -0
- package/index.js +0 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// MCP 工具面编译(task 3.1)——schema 源=契约层 commands.json 快照
|
|
2
|
+
// (cli/src/mcp/commands-snapshot.json,只读拷贝自 plugins/contracts/browser-bridge/
|
|
3
|
+
// commands.json;对表测试保证与真源一致,真源再生成命令见该文件 $doc)。
|
|
4
|
+
//
|
|
5
|
+
// 工具面=调研 C 节裁决的混合粒度:6 个意图级(open/click/fill/read/screenshot/
|
|
6
|
+
// cmd 透传)+窗口三件套;bu_cmd 透传枚举=云端可执行 21 条(CLOUD_EXECUTABLE_
|
|
7
|
+
// COMMANDS;7 条保留命令经 omni 云端派发必被拒 E_INVALID_COMMAND——conformance C7,
|
|
8
|
+
// 故不进枚举也不暴露)。inputSchema 的 required/optional 逐字来自快照;字段类型
|
|
9
|
+
// 是 CLI 侧展示提示(快照只给字段名,类型面以 packages/protocol/src/validate.js 为准)。
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
/** BU 插件保留命令(真源=packages/protocol/src/constants.js RESERVED_COMMANDS;
|
|
15
|
+
* 此处内嵌副本供独立分发,对表测试校准)。经云端(omni /api/cmd)派发会被拒。 */
|
|
16
|
+
export const RESERVED_COMMANDS = [
|
|
17
|
+
'windowFocus',
|
|
18
|
+
'windowBounds',
|
|
19
|
+
'windowUpdateMeta',
|
|
20
|
+
'mailboxSend',
|
|
21
|
+
'mailboxPoll',
|
|
22
|
+
'mailboxClear',
|
|
23
|
+
'mailboxMarkRead',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
/** 契约快照(单例缓存)。 */
|
|
29
|
+
export function loadContract() {
|
|
30
|
+
return JSON.parse(readFileSync(join(here, 'commands-snapshot.json'), 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** cmd → 工具意图映射(window 三件套直连契约命令;cmd=自由透传)。 */
|
|
34
|
+
const TOOL_DEFS = [
|
|
35
|
+
{ tool: 'bu_open', cmd: 'open' },
|
|
36
|
+
{ tool: 'bu_click', cmd: 'click' },
|
|
37
|
+
{ tool: 'bu_fill', cmd: 'fill' },
|
|
38
|
+
{ tool: 'bu_read', cmd: 'read' },
|
|
39
|
+
{ tool: 'bu_screenshot', cmd: 'screenshot' },
|
|
40
|
+
{ tool: 'bu_window_list', cmd: 'windowList' },
|
|
41
|
+
{ tool: 'bu_window_create', cmd: 'windowCreate' },
|
|
42
|
+
{ tool: 'bu_window_close', cmd: 'windowClose' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** 字段类型提示(契约快照只有字段名;按协议 validate.js 语义标注展示类型)。 */
|
|
46
|
+
const FIELD_TYPES = {
|
|
47
|
+
url: { type: 'string' }, selector: { type: 'string' }, target: { type: 'string' },
|
|
48
|
+
waitSelector: { type: 'string' }, text: { type: 'string' }, inputMode: { type: 'string' },
|
|
49
|
+
timeoutMs: { type: 'number' }, targetTabId: { type: 'number' }, targetWindowId: { type: 'number' },
|
|
50
|
+
quality: { type: 'number' }, maxWidth: { type: 'number' },
|
|
51
|
+
name: { type: 'string' }, width: { type: 'number' }, height: { type: 'number' },
|
|
52
|
+
status: { type: 'string' }, taskId: { type: 'string' }, sessionId: { type: 'string' },
|
|
53
|
+
friendlyName: { type: 'string' }, windowId: { type: 'string' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 编译 MCP tools 数组(tools/list 的 tools 字段)。
|
|
58
|
+
* @param {object} [contract] 缺省载入快照
|
|
59
|
+
*/
|
|
60
|
+
export function buildTools(contract = loadContract()) {
|
|
61
|
+
const byCmd = new Map(contract.commands.map((c) => [c.cmd, c]));
|
|
62
|
+
const tools = [];
|
|
63
|
+
for (const def of TOOL_DEFS) {
|
|
64
|
+
const c = byCmd.get(def.cmd);
|
|
65
|
+
if (!c) throw new Error(`契约快照缺命令 ${def.cmd}`);
|
|
66
|
+
const fields = [...c.required, ...c.optional];
|
|
67
|
+
const properties = {};
|
|
68
|
+
for (const f of fields) {
|
|
69
|
+
properties[f] = FIELD_TYPES[f] ?? { type: 'string' };
|
|
70
|
+
}
|
|
71
|
+
tools.push({
|
|
72
|
+
name: def.tool,
|
|
73
|
+
description: `(契约命令 ${c.cmd})${c.summary}`,
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties,
|
|
77
|
+
...(c.required.length > 0 ? { required: [...c.required] } : {}),
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// bu_cmd:任意契约命令透传(枚举=云端可执行 21=全集 28 − 保留 7)
|
|
83
|
+
const cloudExecutable = contract.commands.map((c) => c.cmd).filter((c) => !RESERVED_COMMANDS.includes(c));
|
|
84
|
+
tools.push({
|
|
85
|
+
name: 'bu_cmd',
|
|
86
|
+
description: '(透传)一条完整 om-adapter/0.7 命令直达云端可执行白名单(21 条);保留命令(窗口聚焦/尺寸/元数据与留言板族)经云端派发会被拒,不在此面。命令语义见契约层 commands.json。',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
cmd: { type: 'string', enum: cloudExecutable, description: '契约命令名(cloud-executable 白名单)' },
|
|
91
|
+
},
|
|
92
|
+
required: ['cmd'],
|
|
93
|
+
additionalProperties: true, // 其余字段=命令参数(字段面见契约 required/optional)
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
return tools;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 工具调用 → 协议命令对象(意图工具参数即命令字段;bu_cmd 参数即命令本体)。 */
|
|
100
|
+
export function commandForCall(toolName, args) {
|
|
101
|
+
if (toolName === 'bu_cmd') {
|
|
102
|
+
if (!args || typeof args !== 'object' || typeof args.cmd !== 'string') {
|
|
103
|
+
throw new Error('bu_cmd 参数须为含 cmd 字段的对象');
|
|
104
|
+
}
|
|
105
|
+
return { ...args };
|
|
106
|
+
}
|
|
107
|
+
const def = TOOL_DEFS.find((d) => d.tool === toolName);
|
|
108
|
+
if (!def) return null;
|
|
109
|
+
const command = { cmd: def.cmd };
|
|
110
|
+
for (const [k, v] of Object.entries(args ?? {})) {
|
|
111
|
+
if (v !== undefined && v !== null) command[k] = v;
|
|
112
|
+
}
|
|
113
|
+
return command;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- r1 P1-3:tools/call 前置输入校验(手写零依赖 JSON-Schema 子集) ----------
|
|
117
|
+
// MCP 规范 MUST:server 必须按 inputSchema 校验。此处覆盖 required / 未知属性
|
|
118
|
+
// (additionalProperties:false)/ enum(bu_cmd.cmd ∈ 云端可执行 21 白名单)/
|
|
119
|
+
// 宽松基本类型(FIELD_TYPES 面:string/number/boolean/array)。校验不过 →
|
|
120
|
+
// -32602,不派发(不发 POST)。
|
|
121
|
+
|
|
122
|
+
/** 基本类型判定(宽松:schema 未标类型或非基本类型名即放行)。 */
|
|
123
|
+
function matchesType(value, type) {
|
|
124
|
+
switch (type) {
|
|
125
|
+
case 'string': return typeof value === 'string';
|
|
126
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
127
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value);
|
|
128
|
+
case 'boolean': return typeof value === 'boolean';
|
|
129
|
+
case 'array': return Array.isArray(value);
|
|
130
|
+
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
131
|
+
default: return true; // 未知类型名=宽松放行(契约字段面只标基本类型)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const typeName = (v) => (Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 校验工具输入参数。
|
|
139
|
+
* @param {string} toolName
|
|
140
|
+
* @param {unknown} args tools/call 的 params.arguments
|
|
141
|
+
* @param {object[]} [tools] 缺省 buildTools()
|
|
142
|
+
* @returns {{ ok: true } | { ok: false, message: string }} 失败信息可直接入 -32602
|
|
143
|
+
*/
|
|
144
|
+
export function validateToolInput(toolName, args, tools = buildTools()) {
|
|
145
|
+
const tool = tools.find((t) => t.name === toolName);
|
|
146
|
+
if (!tool) return { ok: false, message: `未知工具 ${toolName}(可用:${tools.map((t) => t.name).join('、')})` };
|
|
147
|
+
const schema = tool.inputSchema;
|
|
148
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
149
|
+
return { ok: false, message: 'arguments 须为对象' };
|
|
150
|
+
}
|
|
151
|
+
const errors = [];
|
|
152
|
+
for (const req of schema.required ?? []) {
|
|
153
|
+
if (!(req in args) || args[req] === undefined || args[req] === null) {
|
|
154
|
+
errors.push(`缺必填字段 ${req}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const props = schema.properties ?? {};
|
|
158
|
+
for (const [key, value] of Object.entries(args)) {
|
|
159
|
+
const prop = props[key];
|
|
160
|
+
if (!prop) {
|
|
161
|
+
if (schema.additionalProperties === false) errors.push(`未知属性 ${key}(${toolName} 只接受:${Object.keys(props).join('、')})`);
|
|
162
|
+
continue; // bu_cmd(additionalProperties:true):其余字段=命令参数,透传
|
|
163
|
+
}
|
|
164
|
+
if (value === undefined || value === null) continue; // null 占位=按缺省剔除(commandForCall 同语义)
|
|
165
|
+
if (prop.type && !matchesType(value, prop.type)) {
|
|
166
|
+
errors.push(`字段 ${key} 期望 ${prop.type},实得 ${typeName(value)}`);
|
|
167
|
+
}
|
|
168
|
+
if (prop.enum && !prop.enum.includes(value)) {
|
|
169
|
+
errors.push(`字段 ${key} 取值 ${JSON.stringify(value)} 不在枚举白名单(${prop.enum.length} 项)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return errors.length > 0 ? { ok: false, message: errors.join(';') } : { ok: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const TOOL_NAMES = [...TOOL_DEFS.map((d) => d.tool), 'bu_cmd'];
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// fde2e-bu mcp serve —— stdio MCP server(task 3.1/3.4)。
|
|
2
|
+
// 手写最小 JSON-RPC 2.0 实现(MCP 规范 2025-06-18,不引 MCP SDK——零依赖原则,
|
|
3
|
+
// server 侧必要面只有四个方法):
|
|
4
|
+
// 请求:initialize / tools/list / tools/call / ping
|
|
5
|
+
// 通知:notifications/initialized / notifications/cancelled(静默忽略)
|
|
6
|
+
// 错误码(JSON-RPC 2.0 标准 + MCP 约定):
|
|
7
|
+
// -32700 parse error(id:null);-32600 invalid request(含批量数组——
|
|
8
|
+
// 2025-06-18 已移除 JSON-RPC batching,收到数组即本码);
|
|
9
|
+
// -32601 method not found(请求未知方法;未知通知静默);
|
|
10
|
+
// -32602 invalid params(未知工具名/输入不过 inputSchema——r1 P1-3 前置校验,零派发);
|
|
11
|
+
// -32002 server not initialized(initialize 之前收到其他请求;ping 除外——r1 P2)。
|
|
12
|
+
// 纪律:stdout 只写 MCP 消息(每行一个 JSON 对象,UTF-8,\n 结尾);一切日志走
|
|
13
|
+
// stderr——stdio transport 下 stdout 被客户端解析,混入非协议文本即破坏通道。
|
|
14
|
+
// stdin 关闭(客户端退出)→ 进程正常退出 0。
|
|
15
|
+
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { buildTools, commandForCall, validateToolInput } from './schema.mjs';
|
|
18
|
+
import { bridgeEnv, dispatchCommand } from './bridge.mjs';
|
|
19
|
+
import { pairingDefaultProfileId } from '../nm/host.mjs';
|
|
20
|
+
import { resolveConfigDir } from '../configdir.mjs';
|
|
21
|
+
import pkg from '../../package.json' with { type: 'json' }; // r1 P2:serverInfo 版本真值(package.json 单源)
|
|
22
|
+
|
|
23
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
24
|
+
const SERVER_NAME = 'fde2e-bu';
|
|
25
|
+
|
|
26
|
+
function rpcResult(id, result) {
|
|
27
|
+
return { jsonrpc: '2.0', id, result };
|
|
28
|
+
}
|
|
29
|
+
export function rpcError(id, code, message, data) {
|
|
30
|
+
const err = { code, message };
|
|
31
|
+
if (data !== undefined) err.data = data;
|
|
32
|
+
return { jsonrpc: '2.0', id, result: undefined, error: err };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 单条入站消息处理(纯函数核心,便于测试)。
|
|
37
|
+
* @param {unknown} msg 解析后的 JSON 值
|
|
38
|
+
* @param {{ initialized: boolean, version: string, env: object }} state
|
|
39
|
+
* @returns {{ responses: object[] }} 待写回的响应(通知=空数组)
|
|
40
|
+
*/
|
|
41
|
+
export function handleMessage(msg, state) {
|
|
42
|
+
if (Array.isArray(msg)) {
|
|
43
|
+
return { responses: [rpcError(null, -32600, 'Invalid Request: JSON-RPC batching 已在 MCP 2025-06-18 移除')] };
|
|
44
|
+
}
|
|
45
|
+
if (typeof msg !== 'object' || msg === null || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
46
|
+
return { responses: [rpcError(null, -32600, 'Invalid Request: 缺 jsonrpc:"2.0" 或 method 字段')] };
|
|
47
|
+
}
|
|
48
|
+
const isRequest = 'id' in msg && msg.id !== undefined; // 通知无 id
|
|
49
|
+
const { method } = msg;
|
|
50
|
+
const params = msg.params ?? {};
|
|
51
|
+
|
|
52
|
+
// r1 P2:ping 无状态、initialize 前即可应答({} result)——MCP 规范 ping 不依赖会话
|
|
53
|
+
if (method === 'ping') {
|
|
54
|
+
return isRequest ? { responses: [rpcResult(msg.id, {})] } : { responses: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (method === 'initialize') {
|
|
58
|
+
// r1 P2:initialize 必须是 request(规范);通知形态不置位 initialized——通知不产生副作用
|
|
59
|
+
if (!isRequest) return { responses: [] };
|
|
60
|
+
state.initialized = true;
|
|
61
|
+
return {
|
|
62
|
+
responses: [
|
|
63
|
+
rpcResult(msg.id, {
|
|
64
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
65
|
+
capabilities: { tools: { listChanged: false } },
|
|
66
|
+
serverInfo: { name: SERVER_NAME, version: state.version, title: 'FDE2E Browser Use' },
|
|
67
|
+
instructions: 'FDE2E Browser Use 桥:意图级工具(bu_open/bu_click/bu_fill/bu_read/bu_screenshot)+ 窗口三件套 + bu_cmd 透传;命令经客户本机浏览器插件执行,登录态不出本机。',
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!state.initialized) {
|
|
74
|
+
return isRequest
|
|
75
|
+
? { responses: [rpcError(msg.id, -32002, 'Server not initialized: 先发 initialize 请求')] }
|
|
76
|
+
: { responses: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
switch (method) {
|
|
80
|
+
case 'notifications/initialized':
|
|
81
|
+
case 'notifications/cancelled':
|
|
82
|
+
return { responses: [] };
|
|
83
|
+
case 'tools/list':
|
|
84
|
+
if (!isRequest) return { responses: [] };
|
|
85
|
+
return { responses: [rpcResult(msg.id, { tools: buildTools() })] };
|
|
86
|
+
case 'tools/call': {
|
|
87
|
+
if (!isRequest) return { responses: [] };
|
|
88
|
+
const name = params.name;
|
|
89
|
+
const args = params.arguments ?? {};
|
|
90
|
+
// r1 P1-3:inputSchema 前置校验(required/未知属性/enum/基本类型)——
|
|
91
|
+
// 不合法即 -32602 且不派发(零 POST),失败不外溢到桥服务
|
|
92
|
+
const check = validateToolInput(name, args);
|
|
93
|
+
if (!check.ok) {
|
|
94
|
+
return { responses: [rpcError(msg.id, -32602, `Invalid params: ${check.message}`)] };
|
|
95
|
+
}
|
|
96
|
+
let command;
|
|
97
|
+
try {
|
|
98
|
+
command = commandForCall(name, args);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { responses: [rpcError(msg.id, -32602, `Invalid params: ${err.message}`)] };
|
|
101
|
+
}
|
|
102
|
+
if (command === null) {
|
|
103
|
+
return { responses: [rpcError(msg.id, -32602, `Invalid params: 未知工具 ${name}`)] };
|
|
104
|
+
}
|
|
105
|
+
// 派发(异步由 caller await;此处返回 promise 形状由 handleAsyncMessage 承接)
|
|
106
|
+
return { responses: [], deferred: { id: msg.id, command } };
|
|
107
|
+
}
|
|
108
|
+
default:
|
|
109
|
+
return isRequest
|
|
110
|
+
? { responses: [rpcError(msg.id, -32601, `Method not found: ${method}`)] }
|
|
111
|
+
: { responses: [] }; // 未知通知按 JSON-RPC 惯例静默
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** tools/call 的异步执行(HTTP 派发 + MCP content 包装)。 */
|
|
116
|
+
async function executeToolCall(id, command, env, write) {
|
|
117
|
+
if (!env.profileId) {
|
|
118
|
+
write(rpcResult(id, {
|
|
119
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, code: 'E_AUTH', message: '未配置 BU_BRIDGE_PROFILE_ID——tools/call 需要 profileId(fde2e-bu mcp add --profile-id <id> 可写入客户端配置 env)', retryable: false }) }],
|
|
120
|
+
isError: true,
|
|
121
|
+
}));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const envelope = await dispatchCommand(env, command);
|
|
125
|
+
write(rpcResult(id, {
|
|
126
|
+
content: [{ type: 'text', text: JSON.stringify(envelope) }],
|
|
127
|
+
isError: envelope.ok !== true,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 单条消息的完整异步处理(A4:stdio 与 http 两传输共用)——同步应答 + tools/call
|
|
133
|
+
* 延迟执行合并为最终应答数组(单条消息至多产出一条应答;通知/未知通知=空数组)。
|
|
134
|
+
* @param {unknown} msg
|
|
135
|
+
* @param {{ initialized: boolean, version: string, env: object }} state
|
|
136
|
+
* @param {object} env bridgeEnv 产物(execute 派发用)
|
|
137
|
+
* @returns {Promise<object[]>} 待写回的响应
|
|
138
|
+
*/
|
|
139
|
+
export async function handleAsyncMessage(msg, state, env) {
|
|
140
|
+
const out = handleMessage(msg, state);
|
|
141
|
+
if (out.deferred) {
|
|
142
|
+
const { id, command } = out.deferred;
|
|
143
|
+
try {
|
|
144
|
+
await executeToolCall(id, command, env, (r) => { out.responses.push(r); });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
out.responses.push(rpcError(id, -32603, `Internal error: ${err?.message ?? err}`));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out.responses;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** serve 主入口(stdio)。r1 P2:version 缺省=package.json 真值(import 断言单源)。 */
|
|
153
|
+
export async function runServe({ schemaOnly = false, version = pkg.version, env = bridgeEnv(), stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
154
|
+
if (schemaOnly) {
|
|
155
|
+
stdout.write(`${JSON.stringify({ server: SERVER_NAME, protocolVersion: PROTOCOL_VERSION, tools: buildTools() }, null, 2)}\n`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
// P1-1:BU_BRIDGE_PROFILE_ID 未设置时回退本机配对(readPairing→defaultProfileId),
|
|
159
|
+
// stderr 一行说明来源。回退发生在 run* 层——bridgeEnv 纯函数语义不变(缺省空串)。
|
|
160
|
+
let effectiveEnv = env;
|
|
161
|
+
if (!env.profileId) {
|
|
162
|
+
const fallback = pairingDefaultProfileId(resolveConfigDir());
|
|
163
|
+
if (fallback) {
|
|
164
|
+
effectiveEnv = { ...env, profileId: fallback };
|
|
165
|
+
stderr.write(`fde2e-bu: BU_BRIDGE_PROFILE_ID 未设置——profileId 取自本机配对(fde2e-bu bind):${fallback}\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const state = { initialized: false, version, env: effectiveEnv };
|
|
169
|
+
const write = (msg) => stdout.write(`${JSON.stringify(msg)}\n`);
|
|
170
|
+
|
|
171
|
+
await new Promise((resolve) => {
|
|
172
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
173
|
+
let settled = false;
|
|
174
|
+
const finish = (code) => {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true;
|
|
177
|
+
rl.close();
|
|
178
|
+
resolve(code);
|
|
179
|
+
};
|
|
180
|
+
rl.on('line', async (line) => {
|
|
181
|
+
const text = line.trim();
|
|
182
|
+
if (text === '') return;
|
|
183
|
+
let msg;
|
|
184
|
+
try {
|
|
185
|
+
msg = JSON.parse(text);
|
|
186
|
+
} catch {
|
|
187
|
+
write(rpcError(null, -32700, 'Parse error: 行不是合法 JSON'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
// A4:与 http 传输共用 handleAsyncMessage(同步应答+tools/call 延迟执行);
|
|
192
|
+
// 派发用 state.env(含 P1-1 的 pairing 回退结果——不得用外层原始 env)
|
|
193
|
+
for (const r of await handleAsyncMessage(msg, state, state.env)) write(r);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
write(rpcError(msg && !Array.isArray(msg) && 'id' in msg ? msg.id : null, -32603, `Internal error: ${err?.message ?? err}`));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
rl.on('close', () => finish(0)); // 客户端断开 → 干净退出
|
|
199
|
+
process.on('SIGTERM', () => finish(0));
|
|
200
|
+
process.on('SIGINT', () => finish(0));
|
|
201
|
+
});
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
package/src/misc.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// status / doctor(tasks 1.2 / 1.3;C2 P2-7;A5=pairing 消费)。
|
|
2
|
+
// (update 组自 bu-online-selfupdate A3 起改走 src/nm/update.mjs 的 cmdSelfUpdate。)
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { bridgeEnv, fetchHealthz, DEFAULT_SERVER } from './mcp/bridge.mjs';
|
|
7
|
+
import { allTargets, readTargetEntry } from './mcp/config.mjs';
|
|
8
|
+
import { findSkillsWorkspace } from './skills/skills.mjs';
|
|
9
|
+
import { resolveConfigDir } from './configdir.mjs';
|
|
10
|
+
import { readPairing, defaultProfileId } from './nm/host.mjs';
|
|
11
|
+
import { manifestTargets, readManifest, hostScriptExecutable } from './nm/bind.mjs';
|
|
12
|
+
|
|
13
|
+
// ---------- status(task 1.2 + C2):/healthz 探活+绑定配置面+六落点注册清单 ----------
|
|
14
|
+
|
|
15
|
+
/** profileId 脱敏:与 mcp list 同口径(***尾4;短值全遮)。 */
|
|
16
|
+
function maskProfileId(id) {
|
|
17
|
+
if (!id) return null;
|
|
18
|
+
const v = String(id);
|
|
19
|
+
return v.length > 4 ? `***${v.slice(-4)}` : '***';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 从落点条目抽 profileId(JSON 条目取 env;codex TOML 块解析经验行)。 */
|
|
23
|
+
function entryProfileId(entry) {
|
|
24
|
+
if (!entry) return null;
|
|
25
|
+
if (typeof entry === 'string') {
|
|
26
|
+
// codex TOML 块:env 为行内表(env = { BU_BRIDGE_PROFILE_ID = "p1" }),全文匹配
|
|
27
|
+
const m = /BU_BRIDGE_PROFILE_ID\s*=\s*"([^"]*)"/.exec(entry);
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
return entry?.env?.BU_BRIDGE_PROFILE_ID ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 六落点注册清单(C2:client/scope/路径/是否注册/脱敏 profileId)。 */
|
|
34
|
+
export function registrationRows(home = homedir(), dir = process.cwd()) {
|
|
35
|
+
return allTargets(home, dir).map((t) => {
|
|
36
|
+
const entry = readTargetEntry(t);
|
|
37
|
+
return {
|
|
38
|
+
client: t.client,
|
|
39
|
+
scope: t.kind,
|
|
40
|
+
path: t.path,
|
|
41
|
+
registered: Boolean(entry),
|
|
42
|
+
profileId: maskProfileId(entryProfileId(entry)),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function cmdStatus({ server, home, dir, json, configDir } = {}) {
|
|
48
|
+
const env = bridgeEnv();
|
|
49
|
+
const url = server ?? env.server;
|
|
50
|
+
const health = await fetchHealthz(url);
|
|
51
|
+
// A5:pairing 消费——配置根可注入(--config-dir > BU_CONFIG_DIR > 缺省)
|
|
52
|
+
const cfgDir = resolveConfigDir({ ...(configDir ? { configDirFlag: configDir } : {}) });
|
|
53
|
+
const pairing = readPairing(cfgDir);
|
|
54
|
+
const config = {
|
|
55
|
+
server: url,
|
|
56
|
+
profileId: env.profileId || null,
|
|
57
|
+
internalToken: env.internalToken ? '(已配置)' : null,
|
|
58
|
+
boundProfileId: env.boundProfileId ? '(已配置)' : null,
|
|
59
|
+
};
|
|
60
|
+
const registrations = registrationRows(home ?? homedir(), dir ?? process.cwd());
|
|
61
|
+
if (json) {
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
health: { ok: health.ok, status: health.status, body: health.body },
|
|
64
|
+
pairing: pairing
|
|
65
|
+
? { count: pairing.bindings.length, defaultProfileId: defaultProfileId(pairing), bindings: pairing.bindings }
|
|
66
|
+
: null,
|
|
67
|
+
config,
|
|
68
|
+
registrations,
|
|
69
|
+
}, null, 2));
|
|
70
|
+
} else if (health.ok) {
|
|
71
|
+
console.log(`桥服务:健康(${url} → HTTP ${health.status})`);
|
|
72
|
+
if (health.body) console.log(` ${JSON.stringify(health.body)}`);
|
|
73
|
+
} else if (health.status > 0) {
|
|
74
|
+
console.error(`桥服务:异常(${url} → HTTP ${health.status})`);
|
|
75
|
+
} else {
|
|
76
|
+
// 离线降级:如实提示,不堆栈不惊吓
|
|
77
|
+
console.error(`桥服务:不可达(${url})——离线降级:`);
|
|
78
|
+
console.error(' 1. 确认 omni 服务端已启动(缺省 http://127.0.0.1:8788)');
|
|
79
|
+
console.error(' 2. BU_BRIDGE_SERVER 指向正确派发入口时重试 fde2e-bu status');
|
|
80
|
+
console.error(' 3. skills 管理不受影响(纯本地文件面);mcp tools/call 需要桥服务在线');
|
|
81
|
+
}
|
|
82
|
+
if (!json) {
|
|
83
|
+
// A5:顶部配对面(health 之后、绑定面之前)——已配对执行器与缺省 profileId
|
|
84
|
+
if (pairing) {
|
|
85
|
+
console.log(`已配对执行器:${pairing.bindings.length} 条(缺省 profileId=${defaultProfileId(pairing)},优先 master)`);
|
|
86
|
+
} else {
|
|
87
|
+
console.log('已配对执行器:未配对(fde2e-bu bind install 后在扩展 options 点「CLI 配对」)');
|
|
88
|
+
}
|
|
89
|
+
console.log(`绑定面:profileId=${config.profileId ?? '(未配置——tools/call 需要)'} internalToken=${config.internalToken ?? '(未配置)'} boundProfileId=${config.boundProfileId ?? '(未配置)'}`);
|
|
90
|
+
console.log('注册清单(六落点):');
|
|
91
|
+
for (const r of registrations) {
|
|
92
|
+
console.log(` ${r.registered ? '✓' : '✗'} ${r.client.padEnd(6)} ${r.scope.padEnd(7)} ${r.path}${r.profileId ? ` profileId=${r.profileId}` : ''}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return health.ok ? 0 : 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- doctor(task 1.3):node 版本/配置路径/环境变量/zip 存在性自检 ----------
|
|
99
|
+
|
|
100
|
+
/** 从 dir 逐级向上找 dist-pack/*.zip(扩展安装包)。 */
|
|
101
|
+
function findExtensionZip(dir) {
|
|
102
|
+
let d = resolve(dir);
|
|
103
|
+
for (;;) {
|
|
104
|
+
const packDir = join(d, 'dist-pack');
|
|
105
|
+
if (existsSync(packDir)) {
|
|
106
|
+
const zips = readdirSync(packDir).filter((f) => f.endsWith('.zip'));
|
|
107
|
+
if (zips.length > 0) return join(packDir, zips[0]);
|
|
108
|
+
}
|
|
109
|
+
const parent = dirname(d);
|
|
110
|
+
if (parent === d) return null;
|
|
111
|
+
d = parent;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 自检项(C2:/healthz 网络探测;A5:NM manifest/pairing 两检查项)。
|
|
117
|
+
* @param {{ nodeVersion?: string, home?: string, cwd?: string, env?: object,
|
|
118
|
+
* fetchImpl?: Function, healthzTimeoutMs?: number,
|
|
119
|
+
* configDir?: string, manifestsDir?: string }} [opts]
|
|
120
|
+
* healthzTimeoutMs 仅供测试注入(缺省 4s 预算);
|
|
121
|
+
* configDir/manifestsDir 注入 pairing/NM manifest 落点(缺省 resolveConfigDir()/
|
|
122
|
+
* 浏览器缺省目录——不碰真实家目录的测试一律显式注入)。
|
|
123
|
+
* @returns {Promise<{ checks: Array<{name:string, level:'ok'|'warn'|'fail', detail:string}> }>}
|
|
124
|
+
*/
|
|
125
|
+
export async function doctorChecks({
|
|
126
|
+
nodeVersion = process.version,
|
|
127
|
+
home = homedir(),
|
|
128
|
+
cwd = process.cwd(),
|
|
129
|
+
env = process.env,
|
|
130
|
+
fetchImpl,
|
|
131
|
+
healthzTimeoutMs = 4000,
|
|
132
|
+
configDir,
|
|
133
|
+
manifestsDir,
|
|
134
|
+
} = {}) {
|
|
135
|
+
const checks = [];
|
|
136
|
+
const major = Number(/v(\d+)/.exec(nodeVersion)?.[1] ?? 0);
|
|
137
|
+
checks.push(major >= 22
|
|
138
|
+
? { name: 'node 版本', level: 'ok', detail: `${nodeVersion}(≥22)` }
|
|
139
|
+
: { name: 'node 版本', level: 'fail', detail: `${nodeVersion} < 22(engines 要求 node>=22)` });
|
|
140
|
+
|
|
141
|
+
const buEnv = bridgeEnv(env);
|
|
142
|
+
checks.push(buEnv.profileId
|
|
143
|
+
? { name: 'BU_BRIDGE_PROFILE_ID', level: 'ok', detail: '已配置(tools/call 可用)' }
|
|
144
|
+
: { name: 'BU_BRIDGE_PROFILE_ID', level: 'warn', detail: '未配置——mcp tools/call 与 status 绑定面需要 profileId' });
|
|
145
|
+
checks.push({ name: 'BU_BRIDGE_SERVER', level: 'ok', detail: buEnv.server === DEFAULT_SERVER ? `${DEFAULT_SERVER}(缺省)` : buEnv.server });
|
|
146
|
+
|
|
147
|
+
// C2:网络探测(4s 预算)——离线只 warn,不参与退出码
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
const health = await fetchHealthz(buEnv.server, { timeoutMs: healthzTimeoutMs, fetchImpl });
|
|
150
|
+
const elapsed = Date.now() - startedAt;
|
|
151
|
+
checks.push(health.ok
|
|
152
|
+
? { name: '桥服务 /healthz', level: 'ok', detail: `可达:${health.url}(HTTP ${health.status},${elapsed}ms)` }
|
|
153
|
+
: {
|
|
154
|
+
name: '桥服务 /healthz',
|
|
155
|
+
level: 'warn',
|
|
156
|
+
detail: health.status > 0
|
|
157
|
+
? `HTTP ${health.status}(${elapsed}ms)——服务在线但回执异常,tools/call 可能不可用`
|
|
158
|
+
: `不可达:${health.url}(${elapsed}ms)——离线是合法状态(status 会降级提示,不判失败)`,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
checks.push(buEnv.internalToken
|
|
162
|
+
? { name: 'BU_INTERNAL_TOKEN', level: 'ok', detail: '已配置(x-omni-internal,仅回环来源生效)' }
|
|
163
|
+
: { name: 'BU_INTERNAL_TOKEN', level: 'warn', detail: '未配置——omni 开启内部令牌面时需要' });
|
|
164
|
+
|
|
165
|
+
for (const p of [join(home, '.claude.json'), join(home, '.codex', 'config.toml'), join(cwd, '.mcp.json'), join(cwd, '.gemini', 'settings.json')]) {
|
|
166
|
+
checks.push({ name: '配置路径', level: existsSync(p) ? 'ok' : 'warn', detail: `${existsSync(p) ? '存在' : '不存在'}:${p}` });
|
|
167
|
+
}
|
|
168
|
+
const zip = findExtensionZip(cwd);
|
|
169
|
+
checks.push(zip
|
|
170
|
+
? { name: '扩展 zip', level: 'ok', detail: `存在:${zip}` }
|
|
171
|
+
: { name: '扩展 zip', level: 'warn', detail: `自 ${cwd} 向上未找到 dist-pack/*.zip(浏览器插件安装包)` });
|
|
172
|
+
const { workspace } = findSkillsWorkspace(cwd);
|
|
173
|
+
checks.push(workspace
|
|
174
|
+
? { name: '技能工作区', level: 'ok', detail: `找到:${workspace}` }
|
|
175
|
+
: { name: '技能工作区', level: 'warn', detail: `自 ${cwd} 向上无 .agents/skills / .claude/skills(skills add 会创建)` });
|
|
176
|
+
|
|
177
|
+
// A5:NM manifest 与 pairing 两检查项(未装/未配对=warn 合法状态,不判 fail)。
|
|
178
|
+
// P2-9:manifest 在场还不够——其 path 指向的 host 脚本必须存在且可执行,
|
|
179
|
+
// 否则 manifest 是「空挂」(点配对必断开),warn 并给重装指引。
|
|
180
|
+
const nmTargets = manifestTargets({ browser: 'both', manifestsDir, home });
|
|
181
|
+
const nmFound = nmTargets.filter((t) => existsSync(t.path));
|
|
182
|
+
if (nmFound.length === 0) {
|
|
183
|
+
checks.push({ name: 'NM manifest', level: 'warn', detail: `未安装(fde2e-bu bind install;查找:${nmTargets.map((t) => t.path).join(';')})` });
|
|
184
|
+
} else {
|
|
185
|
+
const missingScripts = nmFound.filter((t) => {
|
|
186
|
+
const m = readManifest(t.path);
|
|
187
|
+
return !m || typeof m.path !== 'string' || !hostScriptExecutable(m.path);
|
|
188
|
+
});
|
|
189
|
+
checks.push(missingScripts.length === 0
|
|
190
|
+
? { name: 'NM manifest', level: 'ok', detail: `已安装:${nmFound.map((t) => t.path).join(';')}` }
|
|
191
|
+
: {
|
|
192
|
+
name: 'NM manifest',
|
|
193
|
+
level: 'warn',
|
|
194
|
+
detail: `已安装但 host 脚本缺失/不可执行(重装:fde2e-bu bind install):${missingScripts.map((t) => {
|
|
195
|
+
const m = readManifest(t.path);
|
|
196
|
+
return `${t.path} → ${m?.path ?? '(manifest 坏,无 path)'}`;
|
|
197
|
+
}).join(';')}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const cfgDir = resolveConfigDir({ ...(configDir ? { configDirFlag: configDir } : {}) });
|
|
201
|
+
const pairing = readPairing(cfgDir);
|
|
202
|
+
checks.push(pairing
|
|
203
|
+
? { name: 'pairing 配对', level: 'ok', detail: `${pairing.bindings.length} 条(缺省 profileId=${defaultProfileId(pairing)},优先 master):${cfgDir}` }
|
|
204
|
+
: { name: 'pairing 配对', level: 'warn', detail: `未配对(${cfgDir}——bind install 后在扩展 options 点「CLI 配对」)` });
|
|
205
|
+
return { checks };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function cmdDoctor({ home, dir, json, fetchImpl, healthzTimeoutMs, configDir, manifestsDir } = {}) {
|
|
209
|
+
const { checks } = await doctorChecks({
|
|
210
|
+
home: home ?? homedir(),
|
|
211
|
+
cwd: dir ?? process.cwd(),
|
|
212
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
213
|
+
...(healthzTimeoutMs ? { healthzTimeoutMs } : {}),
|
|
214
|
+
...(configDir ? { configDir } : {}),
|
|
215
|
+
...(manifestsDir ? { manifestsDir } : {}),
|
|
216
|
+
});
|
|
217
|
+
const counts = { ok: 0, warn: 0, fail: 0 };
|
|
218
|
+
for (const c of checks) counts[c.level]++;
|
|
219
|
+
if (json) {
|
|
220
|
+
console.log(JSON.stringify({ checks, counts }, null, 2));
|
|
221
|
+
} else {
|
|
222
|
+
for (const c of checks) {
|
|
223
|
+
const tag = { ok: '✓', warn: '⚠', fail: '✗' }[c.level];
|
|
224
|
+
console.log(`${tag} ${c.name}:${c.detail}`);
|
|
225
|
+
}
|
|
226
|
+
console.log(`自检:${counts.ok} 通过 / ${counts.warn} 提示 / ${counts.fail} 失败`);
|
|
227
|
+
}
|
|
228
|
+
return counts.fail > 0 ? 1 : 0;
|
|
229
|
+
}
|