hostpad 0.2.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 +64 -0
- package/bin/hostpad.js +208 -0
- package/lib/bridge.js +408 -0
- package/lib/crypto.js +81 -0
- package/lib/mcp.js +92 -0
- package/lib/ratelimit.js +55 -0
- package/lib/tools.js +116 -0
- package/package.json +24 -0
- package/server.js +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# hostpad
|
|
2
|
+
|
|
3
|
+
HostPad 的 Mac 端伴侣:**MCP 服务器 + iPhone 加密同步桥**,让 AI agent(ZCode / Claude 等)直接在你的 iPhone 上开发、调试、迭代 iOS 工具(HTML/JS 小工具,跑在 [HostPad App] 里)。
|
|
4
|
+
|
|
5
|
+
单进程双面:
|
|
6
|
+
|
|
7
|
+
- **对 AI agent**:MCP stdio 服务器(JSON-RPC 2.0),9 个工具
|
|
8
|
+
- **对 iPhone**:WebSocket 同步桥(协议 v1.3,X25519 + HKDF + AES-256-GCM 全程加密)
|
|
9
|
+
|
|
10
|
+
## 快速开始
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 1. 启动(打印局域网 ws 地址)
|
|
14
|
+
npx hostpad
|
|
15
|
+
|
|
16
|
+
# 2. iPhone 上安装 HostPad App,首页「服务器」填 ws://<Mac 的 IP>:8787
|
|
17
|
+
# 3. 首次连接手机屏显 6 位配对码,让 agent 调 MCP pair 工具确认
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
接进 MCP 客户端:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"hostpad": { "command": "npx", "args": ["-y", "hostpad"] }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## MCP 工具(9 个)
|
|
31
|
+
|
|
32
|
+
| 工具 | 作用 |
|
|
33
|
+
|------|------|
|
|
34
|
+
| `list_devices` | 列出连接的 iPhone(配对状态 / 设备名 / 地址) |
|
|
35
|
+
| `pair` | 用手机屏显的 6 位配对码确认配对 |
|
|
36
|
+
| `create_tool` / `update_tool` / `list_tools` / `delete_tool` | 管理工具(update 为覆盖合并:只传改动文件,推送即热重载) |
|
|
37
|
+
| `reload` | 重推工具(激活) |
|
|
38
|
+
| `get_logs` | 读手机回流的日志环形缓冲(工具 console.* 与系统事件,游标分页) |
|
|
39
|
+
| `rpc_call` | 调用手机上运行中工具的 `onMessage` 处理器,返回值回传 |
|
|
40
|
+
|
|
41
|
+
典型闭环:`create_tool`(写)→ 手机热加载 → `get_logs` 读 `loaded` / console 输出 → `update_tool` 增量迭代 → `rpc_call` 驱动交互。
|
|
42
|
+
|
|
43
|
+
## 命令行
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
npx hostpad [--port 8787] [--tools ./tools] [--insecure] [--pair-code 123456] [--state <path>]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `--insecure`:明文模式(本地快迭代;默认加密 + 配对)
|
|
50
|
+
- `--pair-code`:固定配对码(自动化测试)
|
|
51
|
+
- 防暴力:同 IP 配对失败 5 次锁 60s;配对码 60s 过期、一次性
|
|
52
|
+
|
|
53
|
+
## 安全模型
|
|
54
|
+
|
|
55
|
+
- 全程应用层加密:auth token、配对码、日志、工具源码不落明文到局域网
|
|
56
|
+
- 防被动嗅探与重放;主动 MITM 仅在配对窗口理论可行(6 位码人工比对缓解)
|
|
57
|
+
- 配对 token 双端持久(手机 Keychain / Mac `--state` 文件),一次配对长期免证
|
|
58
|
+
|
|
59
|
+
## 环境要求
|
|
60
|
+
|
|
61
|
+
- Node ≥ 18(零第三方依赖之外仅 `ws`)
|
|
62
|
+
- iPhone 端需 HostPad App
|
|
63
|
+
|
|
64
|
+
License: 目前未授权发布(UNLICENSED),供 HostPad 项目配套使用。
|
package/bin/hostpad.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hostpad — MCP 服务器 + WebSocket 桥(M2.3)
|
|
3
|
+
// AI agent 经 MCP stdio 驱动;iPhone App 连 ws://<Mac IP>:<port>(协议 v1.3,默认加密)。
|
|
4
|
+
'use strict';
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const readline = require('node:readline');
|
|
7
|
+
const { createBridge } = require('../lib/bridge.js');
|
|
8
|
+
const { createMcpServer } = require('../lib/mcp.js');
|
|
9
|
+
|
|
10
|
+
// ---------- 参数 ----------
|
|
11
|
+
function arg(name, def) {
|
|
12
|
+
const i = process.argv.indexOf('--' + name);
|
|
13
|
+
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
|
14
|
+
? process.argv[i + 1] : def;
|
|
15
|
+
}
|
|
16
|
+
const PORT = parseInt(arg('port', '8787'), 10);
|
|
17
|
+
const TOOLS = arg('tools', './tools');
|
|
18
|
+
const STATE = arg('state', null);
|
|
19
|
+
const INSECURE = process.argv.includes('--insecure');
|
|
20
|
+
const PAIR_CODE = arg('pair-code', null);
|
|
21
|
+
const AUTO_CONFIRM = parseInt(arg('auto-confirm', '0'), 10);
|
|
22
|
+
|
|
23
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
24
|
+
console.log(`hostpad — HostPad 的 MCP 服务器 + 手机同步桥
|
|
25
|
+
|
|
26
|
+
用法:npx hostpad [--port 8787] [--tools ./tools] [--insecure] [--pair-code 123456] [--auto-confirm 800]
|
|
27
|
+
|
|
28
|
+
--port WebSocket 桥端口(默认 8787)
|
|
29
|
+
--tools 工具目录(默认 ./tools)
|
|
30
|
+
--insecure 明文模式(本地快迭代/soak 用;默认加密 + 配对)
|
|
31
|
+
--pair-code 固定配对码(自动化用)
|
|
32
|
+
--auto-confirm <ms> 出现待配对设备后 ms 毫秒自动确认(需 --pair-code;自动化用)
|
|
33
|
+
|
|
34
|
+
MCP 客户端(ZCode/Claude 等)配置:
|
|
35
|
+
{ "mcpServers": { "hostpad": { "command": "npx", "args": ["-y", "hostpad"] } } }
|
|
36
|
+
|
|
37
|
+
手机端:App 连接 ws://<本机局域网IP>:<port>`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------- 桥 ----------
|
|
42
|
+
const bridge = createBridge({
|
|
43
|
+
port: PORT,
|
|
44
|
+
toolsDir: TOOLS,
|
|
45
|
+
statePath: STATE ?? require('node:path').join(__dirname, '..', '.hostpad-state.json'),
|
|
46
|
+
secure: !INSECURE,
|
|
47
|
+
pairCode: PAIR_CODE,
|
|
48
|
+
log: (...a) => console.error('[bridge]', ...a), // stdout 留给 MCP 协议
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (AUTO_CONFIRM > 0 && PAIR_CODE) {
|
|
52
|
+
setInterval(() => {
|
|
53
|
+
if (bridge.listDevices().some((d) => d.pairing)) {
|
|
54
|
+
bridge.pairConfirm(PAIR_CODE).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
}, AUTO_CONFIRM);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- MCP 工具面 ----------
|
|
60
|
+
const fileSchema = {
|
|
61
|
+
type: 'array',
|
|
62
|
+
description: '工具文件(path 相对工具根,不得含 .. 或绝对路径)',
|
|
63
|
+
items: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
66
|
+
required: ['path', 'content'],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const tools = [
|
|
70
|
+
{
|
|
71
|
+
name: 'list_devices',
|
|
72
|
+
description: '列出当前连接的 iPhone 设备(配对状态、设备名、地址)',
|
|
73
|
+
inputSchema: { type: 'object', properties: {} },
|
|
74
|
+
handler: async () => ({ devices: bridge.listDevices() }),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'pair',
|
|
78
|
+
description: '确认配对:输入手机屏幕上显示的 6 位配对码',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { code: { type: 'string', description: '6 位配对码' } },
|
|
82
|
+
required: ['code'],
|
|
83
|
+
},
|
|
84
|
+
handler: async ({ code }) => bridge.pairConfirm(String(code)),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'create_tool',
|
|
88
|
+
description: '创建工具并推送到手机(推送即激活)。manifest: {id,name,version,entry,permissions?}',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
manifest: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
id: { type: 'string' }, name: { type: 'string' },
|
|
96
|
+
version: { type: 'string' }, entry: { type: 'string' },
|
|
97
|
+
permissions: { type: 'array', items: { type: 'string' } },
|
|
98
|
+
},
|
|
99
|
+
required: ['id', 'name', 'version', 'entry'],
|
|
100
|
+
},
|
|
101
|
+
files: fileSchema,
|
|
102
|
+
},
|
|
103
|
+
required: ['manifest', 'files'],
|
|
104
|
+
},
|
|
105
|
+
handler: async ({ manifest, files }) => {
|
|
106
|
+
const tool = bridge.createTool(manifest, files);
|
|
107
|
+
return { id: tool.manifest.id, files: tool.files.length };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'update_tool',
|
|
112
|
+
description: '更新工具并推送(覆盖合并:只传要改的文件)。可只改 manifest 或只改 files',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
toolId: { type: 'string' },
|
|
117
|
+
manifest: { type: 'object' },
|
|
118
|
+
files: fileSchema,
|
|
119
|
+
},
|
|
120
|
+
required: ['toolId'],
|
|
121
|
+
},
|
|
122
|
+
handler: async ({ toolId, manifest, files }) => {
|
|
123
|
+
const tool = bridge.updateTool(toolId, { manifest, files });
|
|
124
|
+
return { id: tool.manifest.id, files: tool.files.length };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'list_tools',
|
|
129
|
+
description: '列出 Mac 侧工具目录中的全部工具',
|
|
130
|
+
inputSchema: { type: 'object', properties: {} },
|
|
131
|
+
handler: async () => ({ tools: bridge.listTools() }),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'delete_tool',
|
|
135
|
+
description: '删除工具(Mac 侧目录 + 通知手机删除本地副本)',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: { toolId: { type: 'string' } },
|
|
139
|
+
required: ['toolId'],
|
|
140
|
+
},
|
|
141
|
+
handler: async ({ toolId }) => { bridge.deleteTool(toolId); return { deleted: toolId }; },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'reload',
|
|
145
|
+
description: '重推工具(reason=watch,手机端会激活)。不传 toolId 则全部重推',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: { toolId: { type: 'string' } },
|
|
149
|
+
},
|
|
150
|
+
handler: async ({ toolId }) => ({ pushed: bridge.reload(toolId) }),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'get_logs',
|
|
154
|
+
description: '读取手机回流的日志环形缓冲(工具 console.* 与系统事件)。since 用上一批最后一行的 t 做游标',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
since: { type: 'number', description: '只取 t 大于该值的行(毫秒时间戳)' },
|
|
159
|
+
toolId: { type: 'string' },
|
|
160
|
+
limit: { type: 'number' },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
handler: async (args) => ({
|
|
164
|
+
logs: bridge.getLogs({ since: args?.since ?? 0, toolId: args?.toolId ?? null, limit: args?.limit ?? 200 }),
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'rpc_call',
|
|
169
|
+
description: '调用手机上当前运行工具的 onMessage 处理器(与页面按钮事件同路);handler 的返回值会回传',
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
toolId: { type: 'string' },
|
|
174
|
+
name: { type: 'string', description: '消息名(对应 host.ui.onMessage 的 msg.name)' },
|
|
175
|
+
values: { type: 'object', description: '附带值(对应 msg.values)' },
|
|
176
|
+
timeoutMs: { type: 'number' },
|
|
177
|
+
},
|
|
178
|
+
required: ['toolId', 'name'],
|
|
179
|
+
},
|
|
180
|
+
handler: async ({ toolId, name, values, timeoutMs }) => bridge.rpcCall(
|
|
181
|
+
toolId, name,
|
|
182
|
+
// 协议 values 为字符串表(与页面表单事件 msg.values 语义一致)
|
|
183
|
+
Object.fromEntries(Object.entries(values ?? {}).map(([k, v]) => [k, String(v)])),
|
|
184
|
+
{ timeoutMs: timeoutMs ?? 5000 },
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const mcp = createMcpServer({
|
|
190
|
+
tools,
|
|
191
|
+
write: (line) => process.stdout.write(line + '\n'),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---------- stdio ----------
|
|
195
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
196
|
+
rl.on('line', (line) => { mcp.handleLine(line); });
|
|
197
|
+
rl.on('close', () => { bridge.stop().then(() => process.exit(0)); });
|
|
198
|
+
|
|
199
|
+
// ---------- 启动 ----------
|
|
200
|
+
bridge.start().then(() => {
|
|
201
|
+
const nets = Object.values(os.networkInterfaces()).flat()
|
|
202
|
+
.filter((n) => n && n.family === 'IPv4' && !n.internal)
|
|
203
|
+
.map((n) => n.address);
|
|
204
|
+
console.error(`[hostpad] 桥就绪 ws://0.0.0.0:${bridge.port}${INSECURE ? '(明文模式)' : '(加密+配对)'}`);
|
|
205
|
+
for (const ip of nets) console.error(`[hostpad] 手机连接:ws://${ip}:${bridge.port}`);
|
|
206
|
+
console.error(`[hostpad] 工具目录:${require('node:path').resolve(TOOLS)}`);
|
|
207
|
+
console.error('[hostpad] MCP stdio 就绪(agent 请经 stdin/stdout 对话)');
|
|
208
|
+
});
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// M2.3 Task 4:WebSocket 桥——MCP 服务器与 iPhone App 之间的同步通道(协议 v1.3)。
|
|
2
|
+
// 安全模式:hello → secure-start(X25519+HKDF+AESGCM)→ 密道内 auth/配对;
|
|
3
|
+
// 增量推送:连接内 per-tool 记「最后已发 hashes」,push 只带变更文件 + 全量 hashes;
|
|
4
|
+
// rpc:agent → 设备(rpc 帧)→ handler 返回值经 rpc-result 回传,5s 超时。
|
|
5
|
+
'use strict';
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const { WebSocketServer } = require('ws');
|
|
10
|
+
const C = require('./crypto.js');
|
|
11
|
+
const { createRateLimiter, createCodeStore } = require('./ratelimit.js');
|
|
12
|
+
const { createToolStore } = require('./tools.js');
|
|
13
|
+
|
|
14
|
+
function createBridge({
|
|
15
|
+
port = 8787,
|
|
16
|
+
host = '0.0.0.0',
|
|
17
|
+
toolsDir,
|
|
18
|
+
statePath,
|
|
19
|
+
secure = true,
|
|
20
|
+
pairCode = null,
|
|
21
|
+
maxPairFails = 5,
|
|
22
|
+
lockMs = 60_000,
|
|
23
|
+
logsCap = 1000,
|
|
24
|
+
watch = true,
|
|
25
|
+
log = console.log,
|
|
26
|
+
} = {}) {
|
|
27
|
+
const store = createToolStore(toolsDir);
|
|
28
|
+
const ratelimiter = createRateLimiter({ maxFails: maxPairFails, lockMs });
|
|
29
|
+
const codeStore = createCodeStore({ ttl: 60_000 });
|
|
30
|
+
|
|
31
|
+
// ---------- 设备 token 状态(兼容 M1 字符串数组) ----------
|
|
32
|
+
function loadState() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
35
|
+
return (raw.tokens || []).map((t) =>
|
|
36
|
+
typeof t === 'string' ? { token: t, deviceId: '?', name: '?', pairedAt: 0 } : t);
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
let tokens = loadState();
|
|
40
|
+
function saveState() {
|
|
41
|
+
fs.writeFileSync(statePath, JSON.stringify({ tokens }, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------- 连接与内存态 ----------
|
|
45
|
+
let wss = null;
|
|
46
|
+
const conns = new Set(); // 已建立 ws 的连接(含未认证)
|
|
47
|
+
const logs = []; // 日志环形缓冲 {t, toolId, level, text, ts}
|
|
48
|
+
const pendingRpc = new Map(); // rpcId → {resolve, reject, timer, conn}
|
|
49
|
+
const pendingReload = new Map(); // pushId → {t0, toolId, reason}
|
|
50
|
+
let soakWaiter = null; // {toolId, resolve}(server.js shim 压测用)
|
|
51
|
+
let heartbeat = null;
|
|
52
|
+
let watcher = null;
|
|
53
|
+
|
|
54
|
+
const authedConns = () => [...conns].filter((c) => c.authed);
|
|
55
|
+
|
|
56
|
+
function sendTo(conn, obj) {
|
|
57
|
+
if (conn.ws.readyState !== 1) return;
|
|
58
|
+
const json = JSON.stringify(obj);
|
|
59
|
+
if (conn.secure) conn.ws.send(C.encryptFrame(conn.secure.serverToApp, json));
|
|
60
|
+
else conn.ws.send(json);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- 推送 ----------
|
|
64
|
+
|
|
65
|
+
function pushTool(conn, toolId, reason, { force = false } = {}) {
|
|
66
|
+
const tool = store.read(toolId);
|
|
67
|
+
if (!tool) return null;
|
|
68
|
+
const hashes = store.hashes(toolId);
|
|
69
|
+
const manifestJSON = JSON.stringify(tool.manifest);
|
|
70
|
+
const prev = conn.sent.get(toolId);
|
|
71
|
+
let delta = false;
|
|
72
|
+
let files = tool.files;
|
|
73
|
+
if (reason !== 'initial' && prev) {
|
|
74
|
+
const changed = tool.files.filter((f) => prev.hashes[f.path] !== hashes[f.path]);
|
|
75
|
+
const removed = Object.keys(prev.hashes).filter((p) => !(p in hashes));
|
|
76
|
+
if (!force && changed.length === 0 && removed.length === 0 && prev.manifestJSON === manifestJSON) {
|
|
77
|
+
return null; // 无变化不推(fs.watch 与显式推送去重的关键)
|
|
78
|
+
}
|
|
79
|
+
delta = true;
|
|
80
|
+
files = changed; // 删除经 hashes 键集表达,无需带文件
|
|
81
|
+
}
|
|
82
|
+
const pushId = crypto.randomUUID();
|
|
83
|
+
sendTo(conn, {
|
|
84
|
+
type: 'push', pushId, reason, toolId,
|
|
85
|
+
manifest: tool.manifest, files, hashes, delta,
|
|
86
|
+
});
|
|
87
|
+
conn.sent.set(toolId, { pushId, hashes, manifestJSON });
|
|
88
|
+
pendingReload.set(pushId, { t0: Date.now(), toolId, reason });
|
|
89
|
+
log(`[push] ${toolId} ${reason} pushId=${pushId.slice(0, 8)} files=${files.length}${delta ? '(增量)' : ''}`);
|
|
90
|
+
return pushId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pushToAuthed(toolId, reason, opts) {
|
|
94
|
+
for (const conn of authedConns()) pushTool(conn, toolId, reason, opts);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pushAll(conn, reason) {
|
|
98
|
+
for (const id of store.list()) pushTool(conn, id, reason);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- 配对 ----------
|
|
102
|
+
|
|
103
|
+
function startPairing(conn) {
|
|
104
|
+
conn.pair = null;
|
|
105
|
+
const pairId = crypto.randomUUID();
|
|
106
|
+
const code = codeStore.issue(pairId, pairCode);
|
|
107
|
+
conn.pair = { pairId };
|
|
108
|
+
sendTo(conn, { type: 'pair-challenge', pairId, code });
|
|
109
|
+
log(`[pair] 设备 ${conn.device?.name ?? conn.ip} 显示配对码(60s 有效)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** MCP pair(code):对任一待配对连接校验配对码;错误计失败,达到阈值锁定 IP */
|
|
113
|
+
function pairConfirm(code) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
for (const conn of conns) {
|
|
116
|
+
if (!conn.pair || conn.authed) continue;
|
|
117
|
+
if (codeStore.verify(conn.pair.pairId, code)) {
|
|
118
|
+
ratelimiter.reset(conn.ip);
|
|
119
|
+
const token = crypto.randomUUID();
|
|
120
|
+
tokens.push({
|
|
121
|
+
token,
|
|
122
|
+
deviceId: conn.device?.id ?? '?',
|
|
123
|
+
name: conn.device?.name ?? '?',
|
|
124
|
+
pairedAt: Date.now(),
|
|
125
|
+
});
|
|
126
|
+
saveState();
|
|
127
|
+
conn.authed = true;
|
|
128
|
+
const pairId = conn.pair.pairId;
|
|
129
|
+
conn.pair = null;
|
|
130
|
+
sendTo(conn, { type: 'pair-granted', pairId, token });
|
|
131
|
+
log(`[pair] ✓ 配对成功 ${conn.device?.name ?? conn.ip}`);
|
|
132
|
+
pushAll(conn, 'initial');
|
|
133
|
+
resolve({ ok: true, device: conn.device });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// 该连接的码被消费但不匹配:清掉待配对态,手机重新 auth 可再触发
|
|
137
|
+
conn.pair = null;
|
|
138
|
+
ratelimiter.fail(conn.ip);
|
|
139
|
+
if (ratelimiter.isLocked(conn.ip)) {
|
|
140
|
+
conn.ws.close(4003, 'locked');
|
|
141
|
+
reject(new Error('IP 已锁定(配对失败次数过多),60s 后自动解除'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
reject(new Error('配对码不符或已过期'));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- rpc ----------
|
|
150
|
+
|
|
151
|
+
function rpcCall(toolId, name, values, { timeoutMs = 5000 } = {}) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const conn = authedConns()[0];
|
|
154
|
+
if (!conn) { reject(new Error('无已连接设备')); return; }
|
|
155
|
+
const rpcId = crypto.randomUUID();
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
pendingRpc.delete(rpcId);
|
|
158
|
+
reject(new Error('rpc 超时'));
|
|
159
|
+
}, timeoutMs);
|
|
160
|
+
pendingRpc.set(rpcId, {
|
|
161
|
+
resolve, reject, timer, conn,
|
|
162
|
+
settle: (result) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
pendingRpc.delete(rpcId);
|
|
165
|
+
if (result.ok) resolve({ ok: true, result: result.result });
|
|
166
|
+
else reject(new Error(result.error || 'rpc 失败'));
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
sendTo(conn, { type: 'rpc', rpcId, toolId, name, values: values ?? {} });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------- 连接处理 ----------
|
|
174
|
+
|
|
175
|
+
function handleFrame(conn, f) {
|
|
176
|
+
if (f.type === 'hello') {
|
|
177
|
+
if (!secure || conn.state !== 'new') return;
|
|
178
|
+
let eph, rand;
|
|
179
|
+
try {
|
|
180
|
+
eph = Buffer.from(f.eph, 'base64');
|
|
181
|
+
rand = Buffer.from(f.rand, 'base64');
|
|
182
|
+
if (eph.length !== 32 || rand.length !== 32) throw new Error('bad');
|
|
183
|
+
} catch { conn.ws.close(4000, 'bad hello'); return; }
|
|
184
|
+
const kp = C.makeKeyPair();
|
|
185
|
+
const srvRand = crypto.randomBytes(32);
|
|
186
|
+
const shared = C.sharedSecret(kp.privateKey, eph);
|
|
187
|
+
conn.device = f.device ?? null;
|
|
188
|
+
conn.state = 'secure';
|
|
189
|
+
// secure-start 是握手帧,必须明文发出(客户端拿到 eph/rand 才能派生密钥)
|
|
190
|
+
conn.ws.send(JSON.stringify({
|
|
191
|
+
type: 'secure-start',
|
|
192
|
+
eph: kp.publicKeyRaw.toString('base64'),
|
|
193
|
+
rand: srvRand.toString('base64'),
|
|
194
|
+
}));
|
|
195
|
+
conn.secure = C.deriveKeys(shared, rand, srvRand);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (f.type === 'auth') {
|
|
199
|
+
if (f.device) conn.device = f.device; // 明文模式下连接即 authed,设备信息仍要记录
|
|
200
|
+
if (conn.authed) return;
|
|
201
|
+
if (!secure) {
|
|
202
|
+
conn.authed = true;
|
|
203
|
+
if (!conn.initialPushed) { conn.initialPushed = true; pushAll(conn, 'initial'); }
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (conn.state !== 'secure') return;
|
|
207
|
+
const entry = tokens.find((t) => t.token === f.token);
|
|
208
|
+
if (entry) {
|
|
209
|
+
conn.authed = true;
|
|
210
|
+
log(`[auth] token ✓ ${conn.device?.name ?? ''}(配对于 ${new Date(entry.pairedAt).toLocaleString()})`);
|
|
211
|
+
pushAll(conn, 'initial');
|
|
212
|
+
} else {
|
|
213
|
+
log('[auth] 无有效 token → 发起配对');
|
|
214
|
+
startPairing(conn);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (f.type === 'ping') { if (conn.authed) sendTo(conn, { type: 'pong' }); return; }
|
|
219
|
+
if (f.type === 'log') {
|
|
220
|
+
if (!conn.authed) return;
|
|
221
|
+
logs.push({
|
|
222
|
+
t: Date.now(), toolId: f.toolId ?? '?', level: f.level ?? '?',
|
|
223
|
+
text: f.text ?? '', ts: f.ts, device: conn.device?.name,
|
|
224
|
+
});
|
|
225
|
+
if (logs.length > logsCap) logs.splice(0, logs.length - logsCap);
|
|
226
|
+
if (Number.isFinite(f.ts)) log(`[log] ${f.toolId ?? '?'} ${f.level ?? '?'} +${Date.now() - f.ts}ms ${f.text ?? ''}`);
|
|
227
|
+
else log(`[log] ${f.toolId ?? '?'} ${f.level ?? '?'} ${f.text ?? ''}`);
|
|
228
|
+
if (f.level === 'system' && f.text === 'loaded' && pendingReload.has(f.pushId)) {
|
|
229
|
+
const p = pendingReload.get(f.pushId);
|
|
230
|
+
pendingReload.delete(f.pushId);
|
|
231
|
+
log(`[reload] ${p.toolId} ${p.reason} → ${Date.now() - p.t0}ms`);
|
|
232
|
+
if (soakWaiter && soakWaiter.toolId === p.toolId) {
|
|
233
|
+
soakWaiter.resolve({ ok: true, ms: Date.now() - p.t0 });
|
|
234
|
+
soakWaiter = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (f.type === 'need-full') {
|
|
240
|
+
for (const [toolId, e] of conn.sent) {
|
|
241
|
+
if (e.pushId !== f.pushId) continue;
|
|
242
|
+
const tool = store.read(toolId);
|
|
243
|
+
if (!tool) return;
|
|
244
|
+
sendTo(conn, {
|
|
245
|
+
type: 'push', pushId: f.pushId, reason: 'watch', toolId,
|
|
246
|
+
manifest: tool.manifest, files: tool.files, hashes: store.hashes(toolId), delta: false,
|
|
247
|
+
});
|
|
248
|
+
log(`[push] ${toolId} need-full 重发(同 pushId)`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (f.type === 'rpc-result') {
|
|
254
|
+
const p = pendingRpc.get(f.rpcId);
|
|
255
|
+
if (p) p.settle({ ok: Boolean(f.ok), result: f.result, error: f.error });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onConnection(ws, req) {
|
|
260
|
+
const ip = (req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
|
|
261
|
+
if (ratelimiter.isLocked(ip)) {
|
|
262
|
+
log(`[conn] ${ip} 锁定期内连接,拒绝`);
|
|
263
|
+
ws.close(4003, 'locked');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const conn = {
|
|
267
|
+
ws, ip, state: 'new', secure: null, authed: !secure,
|
|
268
|
+
device: null, pair: null, initialPushed: false,
|
|
269
|
+
sent: new Map(), // toolId → {pushId, hashes, manifestJSON}
|
|
270
|
+
};
|
|
271
|
+
conns.add(conn);
|
|
272
|
+
log(`[conn] +1(共 ${conns.size})from ${ip}`);
|
|
273
|
+
ws.isAlive = true;
|
|
274
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
275
|
+
|
|
276
|
+
if (!secure) {
|
|
277
|
+
sendTo(conn, { type: 'plain' });
|
|
278
|
+
conn.initialPushed = true;
|
|
279
|
+
pushAll(conn, 'initial'); // M1 开发态语义:连接即全量
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
ws.on('message', (data, isBinary) => {
|
|
283
|
+
let obj = null;
|
|
284
|
+
if (isBinary) {
|
|
285
|
+
if (!conn.secure) return; // 通道未建立前的二进制:忽略
|
|
286
|
+
try { obj = JSON.parse(C.decryptFrame(conn.secure.appToServer, data)); } catch { return; }
|
|
287
|
+
} else {
|
|
288
|
+
if (conn.secure) return; // 通道建立后的明文:忽略(防御)
|
|
289
|
+
try { obj = JSON.parse(data.toString()); } catch { return; }
|
|
290
|
+
}
|
|
291
|
+
handleFrame(conn, obj);
|
|
292
|
+
});
|
|
293
|
+
ws.on('close', () => {
|
|
294
|
+
conns.delete(conn);
|
|
295
|
+
for (const [rpcId, p] of pendingRpc) {
|
|
296
|
+
if (p.conn === conn) { clearTimeout(p.timer); p.reject(new Error('设备断开')); pendingRpc.delete(rpcId); }
|
|
297
|
+
}
|
|
298
|
+
log(`[conn] -1(剩 ${conns.size})`);
|
|
299
|
+
});
|
|
300
|
+
ws.on('error', (e) => log(`[conn] error ${e.message}`));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------- 生命周期 ----------
|
|
304
|
+
|
|
305
|
+
async function start() {
|
|
306
|
+
wss = new WebSocketServer({ port, host });
|
|
307
|
+
await new Promise((resolve) => wss.on('listening', resolve));
|
|
308
|
+
wss.on('connection', onConnection);
|
|
309
|
+
|
|
310
|
+
heartbeat = setInterval(() => {
|
|
311
|
+
for (const ws of wss.clients) {
|
|
312
|
+
if (!ws.isAlive) { ws.terminate(); continue; }
|
|
313
|
+
ws.isAlive = false;
|
|
314
|
+
ws.ping();
|
|
315
|
+
}
|
|
316
|
+
}, 10_000);
|
|
317
|
+
|
|
318
|
+
if (watch) {
|
|
319
|
+
const debounce = new Map();
|
|
320
|
+
try {
|
|
321
|
+
watcher = fs.watch(toolsDir, { recursive: true }, (_event, filename) => {
|
|
322
|
+
if (!filename) return;
|
|
323
|
+
const toolId = filename.split(path.sep)[0];
|
|
324
|
+
if (toolId.startsWith('.')) return;
|
|
325
|
+
clearTimeout(debounce.get(toolId));
|
|
326
|
+
debounce.set(toolId, setTimeout(() => {
|
|
327
|
+
if (!fs.existsSync(path.join(toolsDir, toolId, 'manifest.json'))) return;
|
|
328
|
+
pushToAuthed(toolId, 'watch');
|
|
329
|
+
}, 200));
|
|
330
|
+
});
|
|
331
|
+
} catch (e) { log(`[warn] fs.watch 不可用: ${e.message}`); }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stop() {
|
|
336
|
+
clearInterval(heartbeat);
|
|
337
|
+
try { watcher?.close(); } catch {}
|
|
338
|
+
for (const [, p] of pendingRpc) { clearTimeout(p.timer); p.reject(new Error('bridge 停止')); }
|
|
339
|
+
pendingRpc.clear();
|
|
340
|
+
return new Promise((resolve) => {
|
|
341
|
+
for (const ws of wss?.clients ?? []) ws.terminate();
|
|
342
|
+
wss?.close(() => resolve());
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------- 对 MCP / shim 暴露的 API ----------
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
store,
|
|
350
|
+
get port() { return wss ? wss.address().port : port; },
|
|
351
|
+
start, stop,
|
|
352
|
+
listDevices() {
|
|
353
|
+
return [...conns].map((c) => ({
|
|
354
|
+
deviceId: c.device?.id ?? '(未认证)',
|
|
355
|
+
name: c.device?.name ?? '(未认证)',
|
|
356
|
+
proto: '1.3',
|
|
357
|
+
authed: c.authed,
|
|
358
|
+
pairing: Boolean(c.pair),
|
|
359
|
+
address: c.ip,
|
|
360
|
+
}));
|
|
361
|
+
},
|
|
362
|
+
pairConfirm,
|
|
363
|
+
createTool(manifest, files) {
|
|
364
|
+
const tool = store.create(manifest, files);
|
|
365
|
+
pushToAuthed(manifest.id, 'watch', { force: true });
|
|
366
|
+
return tool;
|
|
367
|
+
},
|
|
368
|
+
updateTool(id, patch) {
|
|
369
|
+
const tool = store.update(id, patch);
|
|
370
|
+
pushToAuthed(id, 'watch', { force: true });
|
|
371
|
+
return tool;
|
|
372
|
+
},
|
|
373
|
+
listTools() {
|
|
374
|
+
return store.list().map((id) => {
|
|
375
|
+
const t = store.read(id);
|
|
376
|
+
return { id, name: t.manifest.name, version: t.manifest.version, files: t.files.length };
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
deleteTool(id) {
|
|
380
|
+
store.remove(id);
|
|
381
|
+
for (const conn of authedConns()) {
|
|
382
|
+
conn.sent.delete(id);
|
|
383
|
+
sendTo(conn, { type: 'delete', toolId: id });
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
reload(toolId) {
|
|
387
|
+
const ids = toolId ? [toolId] : store.list();
|
|
388
|
+
for (const id of ids) pushToAuthed(id, 'watch', { force: true });
|
|
389
|
+
return ids;
|
|
390
|
+
},
|
|
391
|
+
getLogs({ since = 0, toolId = null, limit = 200 } = {}) {
|
|
392
|
+
return logs
|
|
393
|
+
.filter((l) => l.t > since && (!toolId || l.toolId === toolId))
|
|
394
|
+
.slice(-limit);
|
|
395
|
+
},
|
|
396
|
+
rpcCall,
|
|
397
|
+
waitForLoaded(toolId, timeoutMs) {
|
|
398
|
+
return new Promise((resolve) => {
|
|
399
|
+
soakWaiter = { toolId, resolve };
|
|
400
|
+
setTimeout(() => {
|
|
401
|
+
if (soakWaiter?.resolve === resolve) { soakWaiter = null; resolve({ ok: false, ms: -1 }); }
|
|
402
|
+
}, timeoutMs);
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
module.exports = { createBridge };
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// M2.3 Task 1:同步协议 v1.3 的密码学原语(Node 端,与 App 端 CryptoKit 实现互操作)
|
|
2
|
+
// 密钥派生:X25519 共享密钥 → HKDF-SHA256(ikm=共享密钥, salt=appRand‖srvRand, info, L=64)
|
|
3
|
+
// → appToServer(前32) ‖ serverToApp(后32)
|
|
4
|
+
// 密文帧: 0x01 ‖ nonce(12B 每帧随机) ‖ AES-256-GCM(ct ‖ tag16),无 AAD
|
|
5
|
+
'use strict';
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const HKDF_INFO = 'hostpad-sync-v1.3';
|
|
9
|
+
const FRAME_MAGIC = 0x01;
|
|
10
|
+
const NONCE_LEN = 12;
|
|
11
|
+
const TAG_LEN = 16;
|
|
12
|
+
|
|
13
|
+
// x25519 原始密钥的 DER 包装(node crypto 只接受 KeyObject)
|
|
14
|
+
const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
15
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
16
|
+
|
|
17
|
+
/** 生成一次性握手密钥对(X25519) */
|
|
18
|
+
function makeKeyPair() {
|
|
19
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519');
|
|
20
|
+
return { privateKey, publicKeyRaw: publicKeyRaw(publicKey) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** KeyObject 公钥 → 32B 原始表示 */
|
|
24
|
+
function publicKeyRaw(publicKey) {
|
|
25
|
+
const der = publicKey.export({ format: 'der', type: 'spki' });
|
|
26
|
+
return Buffer.from(der.subarray(der.length - 32));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 原始 32B 公钥 → KeyObject(对端 hello/secure-start 帧里是 base64 原始字节) */
|
|
30
|
+
function publicKeyFromRaw(raw) {
|
|
31
|
+
if (!Buffer.isBuffer(raw) || raw.length !== 32) {
|
|
32
|
+
throw new Error('bad peer public key length');
|
|
33
|
+
}
|
|
34
|
+
return crypto.createPublicKey({
|
|
35
|
+
key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** X25519(私钥, 对端公钥原始 32B) → 32B 共享密钥 */
|
|
40
|
+
function sharedSecret(privateKey, peerPublicKeyRaw) {
|
|
41
|
+
return crypto.diffieHellman({
|
|
42
|
+
privateKey, publicKey: publicKeyFromRaw(peerPublicKeyRaw),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* HKDF-SHA256 派生双向会话密钥。
|
|
48
|
+
* salt 固定为 appRand(32) ‖ srvRand(32)(App 的随机量在前,与角色无关)。
|
|
49
|
+
*/
|
|
50
|
+
function deriveKeys(shared, appRand, srvRand) {
|
|
51
|
+
const okm = Buffer.from(crypto.hkdfSync('sha256',
|
|
52
|
+
shared, Buffer.concat([appRand, srvRand]), HKDF_INFO, 64));
|
|
53
|
+
return { appToServer: okm.subarray(0, 32), serverToApp: okm.subarray(32, 64) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** JSON 明文 → 密文帧(0x01‖nonce‖ct‖tag) */
|
|
57
|
+
function encryptFrame(key, json) {
|
|
58
|
+
const nonce = crypto.randomBytes(NONCE_LEN);
|
|
59
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
60
|
+
const ct = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
|
61
|
+
return Buffer.concat([Buffer.from([FRAME_MAGIC]), nonce, ct, cipher.getAuthTag()]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 密文帧 → JSON 明文;magic/长度/认证任一失败即 throw */
|
|
65
|
+
function decryptFrame(key, frame) {
|
|
66
|
+
if (!Buffer.isBuffer(frame) || frame.length < 1 + NONCE_LEN + TAG_LEN || frame[0] !== FRAME_MAGIC) {
|
|
67
|
+
throw new Error('bad frame');
|
|
68
|
+
}
|
|
69
|
+
const nonce = frame.subarray(1, 1 + NONCE_LEN);
|
|
70
|
+
const body = frame.subarray(1 + NONCE_LEN);
|
|
71
|
+
const ct = body.subarray(0, body.length - TAG_LEN);
|
|
72
|
+
const tag = body.subarray(body.length - TAG_LEN);
|
|
73
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
74
|
+
decipher.setAuthTag(tag);
|
|
75
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
HKDF_INFO, makeKeyPair, publicKeyRaw, publicKeyFromRaw,
|
|
80
|
+
sharedSecret, deriveKeys, encryptFrame, decryptFrame,
|
|
81
|
+
};
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// M2.3 Task 5:MCP stdio 服务器——JSON-RPC 2.0(换行分隔 JSON)的最小实现。
|
|
2
|
+
// 只实现 MCP 的核心面:initialize / notifications/* / tools/list / tools/call;
|
|
3
|
+
// 不引 SDK(agent 侧零新增依赖),未知 method → -32601,坏行静默丢弃。
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_PROTOCOLS = ['2024-11-05', '2025-03-26', '2025-06-18'];
|
|
7
|
+
const DEFAULT_PROTOCOL = '2024-11-05';
|
|
8
|
+
|
|
9
|
+
function createMcpServer({ tools, write, serverInfo }) {
|
|
10
|
+
const info = serverInfo ?? { name: 'hostpad-agent', version: '0.2.0' };
|
|
11
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
12
|
+
|
|
13
|
+
function respond(msg) { write(JSON.stringify(msg)); }
|
|
14
|
+
|
|
15
|
+
async function handleLine(line) {
|
|
16
|
+
if (typeof line !== 'string' || !line.trim()) return;
|
|
17
|
+
let f;
|
|
18
|
+
try { f = JSON.parse(line); } catch { return; }
|
|
19
|
+
if (f.jsonrpc !== '2.0' || typeof f.method !== 'string') return;
|
|
20
|
+
const isNotification = f.id === undefined || f.id === null;
|
|
21
|
+
const id = f.id ?? null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
switch (f.method) {
|
|
25
|
+
case 'initialize': {
|
|
26
|
+
const requested = f.params?.protocolVersion;
|
|
27
|
+
respond({
|
|
28
|
+
jsonrpc: '2.0', id,
|
|
29
|
+
result: {
|
|
30
|
+
protocolVersion: SUPPORTED_PROTOCOLS.includes(requested) ? requested : DEFAULT_PROTOCOL,
|
|
31
|
+
capabilities: { tools: {} },
|
|
32
|
+
serverInfo: info,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
case 'tools/list':
|
|
38
|
+
respond({
|
|
39
|
+
jsonrpc: '2.0', id,
|
|
40
|
+
result: {
|
|
41
|
+
tools: tools.map((t) => ({
|
|
42
|
+
name: t.name, description: t.description, inputSchema: t.inputSchema,
|
|
43
|
+
})),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
case 'tools/call': {
|
|
48
|
+
const tool = byName.get(f.params?.name);
|
|
49
|
+
if (!tool) {
|
|
50
|
+
if (!isNotification) respond({
|
|
51
|
+
jsonrpc: '2.0', id,
|
|
52
|
+
error: { code: -32602, message: `未知工具: ${f.params?.name}` },
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = await tool.handler(f.params?.arguments ?? {});
|
|
59
|
+
respond({
|
|
60
|
+
jsonrpc: '2.0', id,
|
|
61
|
+
result: { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] },
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
respond({
|
|
65
|
+
jsonrpc: '2.0', id,
|
|
66
|
+
result: {
|
|
67
|
+
isError: true,
|
|
68
|
+
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
// notifications(无 id)不应答,避免对端等回包
|
|
76
|
+
if (!isNotification) respond({
|
|
77
|
+
jsonrpc: '2.0', id,
|
|
78
|
+
error: { code: -32601, message: `method not found: ${f.method}` },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (!isNotification) respond({
|
|
83
|
+
jsonrpc: '2.0', id,
|
|
84
|
+
error: { code: -32603, message: `internal error: ${e.message}` },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { handleLine };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { createMcpServer };
|
package/lib/ratelimit.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// M2.3 Task 2:配对防暴力原语(内存态,进程重启即清——v1 口径)
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IP 维度失败限速:达到 maxFails 次失败后锁定 lockMs;
|
|
6
|
+
* 锁定期内新的失败不追加计数(冻结),锁过期自动清零。
|
|
7
|
+
*/
|
|
8
|
+
function createRateLimiter({ maxFails = 5, lockMs = 60_000, now = Date.now } = {}) {
|
|
9
|
+
const state = new Map(); // ip -> { fails, lockedUntil }
|
|
10
|
+
return {
|
|
11
|
+
fail(ip) {
|
|
12
|
+
const s = state.get(ip) ?? { fails: 0, lockedUntil: 0 };
|
|
13
|
+
if (s.lockedUntil > now()) return; // 锁内冻结
|
|
14
|
+
s.fails += 1;
|
|
15
|
+
if (s.fails >= maxFails) {
|
|
16
|
+
s.lockedUntil = now() + lockMs;
|
|
17
|
+
s.fails = 0; // 锁过期后从零计
|
|
18
|
+
}
|
|
19
|
+
state.set(ip, s);
|
|
20
|
+
},
|
|
21
|
+
isLocked(ip) {
|
|
22
|
+
const s = state.get(ip);
|
|
23
|
+
return Boolean(s && s.lockedUntil > now());
|
|
24
|
+
},
|
|
25
|
+
failures(ip) {
|
|
26
|
+
const s = state.get(ip);
|
|
27
|
+
return s && s.lockedUntil > now() ? 0 : (s?.fails ?? 0);
|
|
28
|
+
},
|
|
29
|
+
reset(ip) { state.delete(ip); },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 配对码:6 位、ttl 内有效、verify 一次性(成功即消费)。
|
|
35
|
+
* 同一 pairId 重复 issue 覆盖旧码。
|
|
36
|
+
*/
|
|
37
|
+
function createCodeStore({ ttl = 60_000, now = Date.now } = {}) {
|
|
38
|
+
const codes = new Map(); // pairId -> { code, expiresAt }
|
|
39
|
+
return {
|
|
40
|
+
issue(pairId, fixedCode) {
|
|
41
|
+
const code = fixedCode || String(Math.floor(100000 + Math.random() * 900000));
|
|
42
|
+
codes.set(pairId, { code, expiresAt: now() + ttl });
|
|
43
|
+
return code;
|
|
44
|
+
},
|
|
45
|
+
verify(pairId, code) {
|
|
46
|
+
const entry = codes.get(pairId);
|
|
47
|
+
if (!entry) return false;
|
|
48
|
+
codes.delete(pairId); // 一次性:无论成败都消费
|
|
49
|
+
if (now() > entry.expiresAt) return false;
|
|
50
|
+
return entry.code === String(code).trim();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createRateLimiter, createCodeStore };
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// M2.3 Task 3:agent 侧工具目录管理。
|
|
2
|
+
// 目录布局 <toolsDir>/<id>/{manifest.json, <files>…};写走原子替换(tmp+rename)。
|
|
3
|
+
'use strict';
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
9
|
+
|
|
10
|
+
function assertManifest(m) {
|
|
11
|
+
if (!m || typeof m !== 'object') throw new Error('manifest 必须是对象');
|
|
12
|
+
for (const key of ['id', 'name', 'version', 'entry']) {
|
|
13
|
+
if (typeof m[key] !== 'string' || !m[key]) throw new Error(`manifest 缺字段: ${key}`);
|
|
14
|
+
}
|
|
15
|
+
if (!ID_RE.test(m.id)) throw new Error(`manifest.id 非法: ${m.id}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertFilePath(p) {
|
|
19
|
+
if (typeof p !== 'string' || !p) throw new Error(`非法文件路径: ${p}`);
|
|
20
|
+
if (path.isAbsolute(p) || p.split('/').includes('..')) throw new Error(`非法文件路径: ${p}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256Hex(content) {
|
|
24
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeFileAtomic(file, content) {
|
|
28
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
fs.writeFileSync(tmp, content);
|
|
31
|
+
fs.renameSync(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createToolStore(toolsDir) {
|
|
35
|
+
const toolDir = (id) => path.join(toolsDir, id);
|
|
36
|
+
|
|
37
|
+
function readRaw(id) {
|
|
38
|
+
const dir = toolDir(id);
|
|
39
|
+
if (!fs.existsSync(path.join(dir, 'manifest.json'))) return null;
|
|
40
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
|
41
|
+
const files = [];
|
|
42
|
+
const walk = (rel) => {
|
|
43
|
+
for (const name of fs.readdirSync(path.join(dir, rel))) {
|
|
44
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
45
|
+
const abs = path.join(dir, relPath);
|
|
46
|
+
if (fs.statSync(abs).isDirectory()) { walk(relPath); continue; }
|
|
47
|
+
if (relPath === 'manifest.json') continue;
|
|
48
|
+
files.push({ path: relPath, content: fs.readFileSync(abs, 'utf8') });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
walk('');
|
|
52
|
+
return { manifest, files };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
dir: toolsDir,
|
|
57
|
+
|
|
58
|
+
list() {
|
|
59
|
+
if (!fs.existsSync(toolsDir)) return [];
|
|
60
|
+
return fs.readdirSync(toolsDir, { withFileTypes: true })
|
|
61
|
+
.filter((d) => d.isDirectory() && ID_RE.test(d.name))
|
|
62
|
+
.map((d) => d.name)
|
|
63
|
+
.sort();
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
read(id) { return readRaw(id); },
|
|
67
|
+
|
|
68
|
+
hashes(id) {
|
|
69
|
+
const tool = readRaw(id);
|
|
70
|
+
if (!tool) return {};
|
|
71
|
+
const h = {};
|
|
72
|
+
for (const f of tool.files) h[f.path] = sha256Hex(f.content);
|
|
73
|
+
return h;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
create(manifest, files) {
|
|
77
|
+
assertManifest(manifest);
|
|
78
|
+
if (!Array.isArray(files) || files.length === 0) throw new Error('manifest.entry 需要文件');
|
|
79
|
+
for (const f of files) assertFilePath(f.path);
|
|
80
|
+
if (!files.some((f) => f.path === manifest.entry)) throw new Error('manifest.entry 需在 files 中');
|
|
81
|
+
const dir = toolDir(manifest.id);
|
|
82
|
+
if (fs.existsSync(path.join(dir, 'manifest.json'))) {
|
|
83
|
+
throw new Error(`工具已存在: ${manifest.id}`);
|
|
84
|
+
}
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
for (const f of files) writeFileAtomic(path.join(dir, f.path), f.content);
|
|
87
|
+
writeFileAtomic(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
88
|
+
return readRaw(manifest.id);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
update(id, { manifest, files } = {}) {
|
|
92
|
+
const existing = readRaw(id);
|
|
93
|
+
if (!existing) throw new Error(`not found: ${id}`);
|
|
94
|
+
const nextManifest = manifest ? { ...existing.manifest, ...manifest, id } : existing.manifest;
|
|
95
|
+
assertManifest(nextManifest);
|
|
96
|
+
if (files) {
|
|
97
|
+
if (!Array.isArray(files) || files.length === 0) throw new Error('files 不能为空');
|
|
98
|
+
for (const f of files) assertFilePath(f.path);
|
|
99
|
+
// 覆盖合并语义:只写传入文件,未提及的保留(agent 常只改一两个文件)
|
|
100
|
+
const merged = new Map(existing.files.map((f) => [f.path, f.content]));
|
|
101
|
+
for (const f of files) merged.set(f.path, f.content);
|
|
102
|
+
if (!merged.has(nextManifest.entry)) throw new Error('manifest.entry 需在 files 中');
|
|
103
|
+
const dir = toolDir(id);
|
|
104
|
+
for (const f of files) writeFileAtomic(path.join(dir, f.path), f.content);
|
|
105
|
+
}
|
|
106
|
+
writeFileAtomic(path.join(toolDir(id), 'manifest.json'), JSON.stringify(nextManifest, null, 2));
|
|
107
|
+
return readRaw(id);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
remove(id) {
|
|
111
|
+
fs.rmSync(toolDir(id), { recursive: true, force: true });
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { createToolStore };
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hostpad",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "HostPad 的 MCP 服务器 + iPhone 同步桥:AI agent 经 MCP 管理与调试 iOS 工具(写→推→读日志闭环)",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hostpad": "./bin/hostpad.js",
|
|
8
|
+
"hostpad-dev": "./server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"server.js"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.18.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// HostPad 开发态同步服务(M1 命令口径不变,内核已换成 lib/bridge.js)
|
|
3
|
+
//
|
|
4
|
+
// 用法:
|
|
5
|
+
// node server.js # 明文开发态:连接即全量同步(免认证)
|
|
6
|
+
// node server.js --pairing # 加密 + 配对模式(新设备需 6 位码,在本终端输入确认)
|
|
7
|
+
// node server.js --pairing --pair-code 123456 --auto-confirm 800 # 自动化配对
|
|
8
|
+
// node server.js --soak 100 # 压测:push→loaded 100 轮(预算 <1000ms)
|
|
9
|
+
//
|
|
10
|
+
// 正式的 agent 入口是 bin/hostpad.js(MCP 服务器,`npx hostpad`)。
|
|
11
|
+
'use strict';
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const readline = require('node:readline');
|
|
15
|
+
const { createBridge } = require('./lib/bridge.js');
|
|
16
|
+
|
|
17
|
+
const argv = process.argv.slice(2);
|
|
18
|
+
function arg(name, def) {
|
|
19
|
+
const i = argv.indexOf('--' + name);
|
|
20
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
|
|
21
|
+
}
|
|
22
|
+
const TOOLS_DIR = path.resolve(arg('dir', './tools'));
|
|
23
|
+
const PORT = parseInt(arg('port', '8787'), 10);
|
|
24
|
+
const SOAK_N = parseInt(arg('soak', '0'), 10);
|
|
25
|
+
const SOAK_INTERVAL = parseInt(arg('interval', '400'), 10);
|
|
26
|
+
const SOAK_TIMEOUT = parseInt(arg('soak-timeout', '4000'), 10);
|
|
27
|
+
const PAIRING = argv.includes('--pairing');
|
|
28
|
+
const FIXED_CODE = arg('pair-code', null);
|
|
29
|
+
const AUTO_CONFIRM_MS = parseInt(arg('auto-confirm', '0'), 10);
|
|
30
|
+
|
|
31
|
+
const bridge = createBridge({
|
|
32
|
+
port: PORT,
|
|
33
|
+
toolsDir: TOOLS_DIR,
|
|
34
|
+
statePath: path.join(__dirname, '.hostpad-state.json'),
|
|
35
|
+
secure: PAIRING,
|
|
36
|
+
pairCode: FIXED_CODE,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log(`[agent] tools=${TOOLS_DIR}`);
|
|
40
|
+
console.log(`[agent] 模式:${PAIRING ? '加密+配对(新设备需 6 位码)' : '明文开发态(免认证)'}`);
|
|
41
|
+
|
|
42
|
+
// 配对确认:优先自动(固定码),否则终端交互
|
|
43
|
+
let rl = null;
|
|
44
|
+
const stdinWaiters = [];
|
|
45
|
+
function askStdin(prompt) {
|
|
46
|
+
if (!rl) {
|
|
47
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
rl.on('line', (line) => { const w = stdinWaiters.shift(); if (w) w(line); });
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write(prompt);
|
|
51
|
+
return new Promise((resolve) => stdinWaiters.push(resolve));
|
|
52
|
+
}
|
|
53
|
+
if (PAIRING && AUTO_CONFIRM_MS > 0 && FIXED_CODE) {
|
|
54
|
+
setInterval(() => {
|
|
55
|
+
if (bridge.listDevices().some((d) => d.pairing)) bridge.pairConfirm(FIXED_CODE).catch(() => {});
|
|
56
|
+
}, AUTO_CONFIRM_MS);
|
|
57
|
+
} else if (PAIRING) {
|
|
58
|
+
setInterval(() => {
|
|
59
|
+
if (bridge.listDevices().some((d) => d.pairing)) {
|
|
60
|
+
askStdin('> ').then((line) => bridge.pairConfirm(line).catch((e) => console.log(`[pair] ✗ ${e.message}`)));
|
|
61
|
+
}
|
|
62
|
+
}, 500);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
bridge.start().then(() => {
|
|
66
|
+
console.log(`[agent] listening ws://0.0.0.0:${bridge.port}`);
|
|
67
|
+
console.log('[agent] watching for changes…(改 tools/ 下任意文件即热推送)');
|
|
68
|
+
if (SOAK_N > 0) runSoak().catch((e) => { console.log('[soak] error', e); process.exit(2); });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------- 压测(M1 验收口径:push→loaded 连续 N 次 0 失败) ----------
|
|
72
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
73
|
+
|
|
74
|
+
async function runSoak() {
|
|
75
|
+
const dir = bridge.store.list().map((id) => path.join(TOOLS_DIR, id))
|
|
76
|
+
.find((d) => {
|
|
77
|
+
try {
|
|
78
|
+
const m = JSON.parse(fs.readFileSync(path.join(d, 'manifest.json'), 'utf8'));
|
|
79
|
+
return fs.readFileSync(path.join(d, m.entry), 'utf8').includes('SOAK_MARKER');
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
});
|
|
82
|
+
if (!dir) { console.log('[soak] 没有含 SOAK_MARKER 的工具,无法压测'); process.exit(2); }
|
|
83
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
|
84
|
+
const entryPath = path.join(dir, manifest.entry);
|
|
85
|
+
const template = fs.readFileSync(entryPath, 'utf8');
|
|
86
|
+
console.log(`[soak] tool=${manifest.id} cycles=${SOAK_N} interval=${SOAK_INTERVAL}ms timeout=${SOAK_TIMEOUT}ms`);
|
|
87
|
+
console.log('[soak] 等待 App 连接…');
|
|
88
|
+
while (bridge.listDevices().filter((d) => d.authed).length === 0) await sleep(200);
|
|
89
|
+
await sleep(500);
|
|
90
|
+
|
|
91
|
+
const results = [];
|
|
92
|
+
for (let i = 1; i <= SOAK_N; i++) {
|
|
93
|
+
fs.writeFileSync(entryPath, template.replace(/SOAK_MARKER/g, `#${i}`)); // 触发 watch → push
|
|
94
|
+
const r = await bridge.waitForLoaded(manifest.id, SOAK_TIMEOUT);
|
|
95
|
+
results.push(r);
|
|
96
|
+
console.log(`[soak] ${i}/${SOAK_N} ${r.ok ? `${r.ms}ms` : `FAIL(>${SOAK_TIMEOUT}ms)`}`);
|
|
97
|
+
await sleep(SOAK_INTERVAL);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ok = results.filter((r) => r.ok).map((r) => r.ms).sort((a, b) => a - b);
|
|
101
|
+
const fails = results.length - ok.length;
|
|
102
|
+
const avg = ok.length ? Math.round(ok.reduce((a, b) => a + b, 0) / ok.length) : 0;
|
|
103
|
+
const p95 = ok.length ? ok[Math.min(ok.length - 1, Math.floor(ok.length * 0.95))] : 0;
|
|
104
|
+
const max = ok.length ? ok[ok.length - 1] : 0;
|
|
105
|
+
console.log('\n===== SOAK RESULT =====');
|
|
106
|
+
console.log(`cycles=${results.length} ok=${ok.length} fail=${fails}(验收线:0 失败)`);
|
|
107
|
+
console.log(`reload latency: avg=${avg}ms p95=${p95}ms max=${max}ms(预算 <1000ms)`);
|
|
108
|
+
console.log('=======================');
|
|
109
|
+
process.exit(fails > 0 ? 1 : 0);
|
|
110
|
+
}
|