@m2canvas/connect 0.11.0 → 0.13.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 +13 -31
- package/dist/bridge/args.js +30 -36
- package/dist/bridge/call-dispatch.js +6 -13
- package/dist/bridge/install-layout.js +2 -1
- package/dist/bridge/main.js +63 -92
- package/dist/bridge/precheck.js +2 -1
- package/dist/bridge/protocol.js +11 -3
- package/dist/bridge/security.js +17 -27
- package/dist/bridge/server.js +46 -69
- package/dist/bridge/ticket-socket.js +104 -0
- package/dist/bridge/ticket.js +70 -0
- package/dist/bridge/uninstall-call.js +2 -2
- package/dist/bridge/uninstall.js +1 -1
- package/dist/cli.js +2 -1
- package/dist/clients.js +1 -1
- package/dist/config-files.js +14 -8
- package/package.json +1 -1
- package/dist/bridge/handoff.js +0 -213
- package/dist/bridge/pair-call.js +0 -31
- package/dist/bridge/pairing.js +0 -349
package/dist/bridge/security.js
CHANGED
|
@@ -1,22 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本机 ACP bridge 的连接准入判定(纯函数)。在 HTTP upgrade 阶段、完成 WebSocket 握手之前调用。
|
|
3
|
-
*
|
|
4
|
-
* 这里管的是「谁能连上来」:Origin 白名单与只接受本机连接(第三道「配对 token」在 pairing.ts)。
|
|
5
|
-
* 浏览器这层不可依赖:开了本机代理的用户,Chrome 不会弹本地网络访问框(ACP PoC 报告 §3),
|
|
6
|
-
* 所以任何网站都能尝试连 127.0.0.1,只能靠 bridge 自己挡。
|
|
7
|
-
* 连上并配对之后页面能做什么,见 server.ts / acp-guard.ts 的文件头:配对 = 授权该页面驱动本机 agent。
|
|
8
|
-
*
|
|
9
|
-
* 维护约束:
|
|
10
|
-
* - Origin 必须精确匹配(scheme + host + port 全等)。不要改成后缀匹配、正则或「包含主站主机名」——
|
|
11
|
-
* `https://<主站主机>.evil.com`、`https://evil.<主站主机>` 都必须被拒。名单作为参数传入:名单为空即全部拒绝。
|
|
12
|
-
* - 默认只放行主站。开发域名只在 `bridge --dev` 时放行:`localhost:5173` 是所有本机 Vite 项目共用的 Origin、
|
|
13
|
-
* 共用 localStorage,给开发版 M2 配对过后,任何跑在 5173 的项目(包括它的依赖)都能读走 token 免码连上
|
|
14
|
-
* (review r3 B2,用户 2026-10-02 拍板改为 --dev,推翻了最初的「开发域名常开」)。名单写死在包里(主站 origin 在
|
|
15
|
-
* `site-config.ts`),不提供任意扩展参数。
|
|
16
|
-
* - Host 校验防 DNS rebinding:攻击者域名解析到 127.0.0.1 时,Host 头是攻击者域名。
|
|
17
|
-
*/
|
|
18
1
|
import { PROD_ORIGINS } from '../site-config.js';
|
|
19
|
-
import { BRIDGE_WS_PATH } from './protocol.js';
|
|
2
|
+
import { BRIDGE_TICKET_HEADER, BRIDGE_WS_PATH } from './protocol.js';
|
|
20
3
|
export { PROD_ORIGINS };
|
|
21
4
|
/** 本地开发 M2 用(Vite 默认端口);只有 `bridge --dev` 才放行 */
|
|
22
5
|
export const DEV_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
@@ -34,13 +17,19 @@ export function isAllowedHost(host, port) {
|
|
|
34
17
|
export function isLoopbackAddress(address) {
|
|
35
18
|
return address === '127.0.0.1' || address === '::ffff:127.0.0.1' || address === '::1';
|
|
36
19
|
}
|
|
37
|
-
|
|
20
|
+
/** Node 把请求头名转成小写;同名头出现多次时自定义头会被拼成一个字符串(再由票据格式挡掉),数组一律当没有 */
|
|
21
|
+
export function readTicketHeader(headers) {
|
|
22
|
+
const value = headers[BRIDGE_TICKET_HEADER.toLowerCase()];
|
|
23
|
+
return typeof value === 'string' ? value : undefined;
|
|
24
|
+
}
|
|
38
25
|
/**
|
|
39
26
|
* 拒绝时返回 HTTP 状态,由调用方直接写回并销毁 socket,不完成握手。
|
|
40
|
-
* 403
|
|
27
|
+
* 403 / 401 而不是「握手后再关」:不对非白名单页面、没有票据的连接完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
|
|
41
28
|
* 任何网站用 no-cors 请求 http://127.0.0.1:<port>/ 都能分辨「有响应」与「连接被拒」,只暴露这一点信息。
|
|
29
|
+
*
|
|
30
|
+
* `redeem` 核对并用掉票据(TicketBook.redeem);只在前三条都通过后调用。
|
|
42
31
|
*/
|
|
43
|
-
export function checkUpgrade(info, port, origins) {
|
|
32
|
+
export function checkUpgrade(info, port, origins, redeem) {
|
|
44
33
|
const pathname = (info.path ?? '').split('?')[0];
|
|
45
34
|
if (pathname !== BRIDGE_WS_PATH)
|
|
46
35
|
return { ok: false, status: 404, reason: `unknown path ${pathname}` };
|
|
@@ -48,10 +37,11 @@ export function checkUpgrade(info, port, origins) {
|
|
|
48
37
|
return { ok: false, status: 403, reason: `remote ${info.remoteAddress ?? 'unknown'}` };
|
|
49
38
|
if (!isAllowedHost(info.host, port))
|
|
50
39
|
return { ok: false, status: 403, reason: `host ${info.host ?? 'missing'}` };
|
|
51
|
-
if (!isAllowedOrigin(info.origin, origins))
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { ok: false, status:
|
|
55
|
-
|
|
56
|
-
|
|
40
|
+
if (!isAllowedOrigin(info.origin, origins))
|
|
41
|
+
return { ok: false, status: 403, reason: info.origin === undefined ? 'origin missing' : `origin ${info.origin}` };
|
|
42
|
+
if (info.ticket === undefined)
|
|
43
|
+
return { ok: false, status: 401, reason: 'missing ticket' };
|
|
44
|
+
if (!redeem(info.ticket))
|
|
45
|
+
return { ok: false, status: 401, reason: 'invalid ticket' };
|
|
46
|
+
return { ok: true, auth: 'client-ticket' };
|
|
57
47
|
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 本机 ACP bridge 的 HTTP + WebSocket
|
|
2
|
+
* 本机 ACP bridge 的 HTTP + WebSocket 服务:连接准入、握手、方法分派、ACP 转发(页面 → agent 先经 acp-guard.ts 检查,agent → 页面原样)、心跳与清理。
|
|
3
3
|
* 协议规范见 protocol.ts 文件头指向的 design「页面协议」一节。
|
|
4
4
|
*
|
|
5
|
-
* 安全模型(改动前先读 security.ts /
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
5
|
+
* 安全模型(改动前先读 security.ts / ticket.ts / acp-guard.ts 的文件头):
|
|
6
|
+
* **连上 = 授权该页面以用户的身份驱动本机 agent**,包括让 agent 读写文件、执行命令(提示词、替用户回答审批、切换工作模式
|
|
7
|
+
* 都由页面发出)。所以安全的重心在「谁能连上」,连上之后能做的事只做纵深防御:
|
|
8
|
+
* - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径、M2 客户端主进程替页面加的一次性票据)在完成握手之前,
|
|
9
|
+
* 失败直接写 HTTP 状态并销毁 socket(change bridge-client-only D4)。没有任何不经票据的连接,也没有配对。
|
|
10
|
+
* - 握手后第一条消息必须是 hello,且只认 protocols 与 client:带 token / pairSecret(旧页面)或其他键一律 bad_message 并关闭。
|
|
11
|
+
* - 每条被接受的连接标记 `auth: 'client-ticket'`;方法调用、acp 帧、广播都再判一次(纵深判断,正常走不到另一支),
|
|
12
|
+
* 以后只开给客户端认证连接的能力(1b 的会话令牌模式)也按这个标记判。
|
|
13
|
+
* - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、processId —— 进不了 spawn 的命令、参数、cwd、环境。
|
|
12
14
|
* 方法分派在 call-dispatch.ts;agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(auth-call.ts,命令清单写死在 authorize.ts)。
|
|
13
15
|
* - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
|
|
14
|
-
* - bridge/uninstall(0.8.0,uninstall-call.ts
|
|
16
|
+
* - bridge/uninstall(0.8.0,uninstall-call.ts):开始后拒绝新连接与一切其他帧,端口占到退出,结果回出去后退出。
|
|
15
17
|
*
|
|
16
18
|
* 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责)。
|
|
17
19
|
* 登录 / m2 授权只代跑写死清单里的命令,授权页由用户在浏览器里亲自完成(authorize.ts);授权链接只发给发起的连接,不进终端日志。
|
|
@@ -24,31 +26,35 @@ import { handleCall } from './call-dispatch.js';
|
|
|
24
26
|
import { AuthorizationManager } from './authorize.js';
|
|
25
27
|
import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
|
|
26
28
|
import { checkPageAcpLine } from './acp-guard.js';
|
|
27
|
-
import { checkUpgrade } from './security.js';
|
|
29
|
+
import { checkUpgrade, readTicketHeader } from './security.js';
|
|
28
30
|
import { BridgeFailure } from './service.js';
|
|
29
31
|
import { startUninstall } from './uninstall-call.js';
|
|
30
32
|
export const HELLO_TIMEOUT_MS = 10_000;
|
|
31
|
-
export const UNPAIRED_TIMEOUT_MS = 10 * 60 * 1000;
|
|
32
|
-
export const MAX_UNPAIRED_CONNECTIONS = 4;
|
|
33
33
|
export const PING_INTERVAL_MS = 30_000;
|
|
34
34
|
export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
35
35
|
export const REJECT_LOG_WINDOW_MS = 60_000;
|
|
36
36
|
const MAX_CALL_ID = 64;
|
|
37
|
+
/** hello 里只认这几个键(change bridge-client-only D4):带 token / pairSecret 的旧页面与任何多出的键一律 bad_message */
|
|
38
|
+
const HELLO_KEYS = new Set(['type', 'protocols', 'client']);
|
|
39
|
+
/** 纵深判断:只有经 M2 客户端票据认证、且完成了 hello 的连接才能调用方法、收发 acp 与广播 */
|
|
40
|
+
function isReady(conn) {
|
|
41
|
+
return conn.hello && conn.auth === 'client-ticket';
|
|
42
|
+
}
|
|
37
43
|
function isObject(value) {
|
|
38
44
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
45
|
}
|
|
40
46
|
function isMethod(value) {
|
|
41
47
|
return typeof value === 'string' && BRIDGE_METHODS.includes(value);
|
|
42
48
|
}
|
|
49
|
+
const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' };
|
|
43
50
|
function rejectUpgrade(socket, status) {
|
|
44
|
-
const text = status
|
|
51
|
+
const text = STATUS_TEXT[status];
|
|
45
52
|
socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
46
53
|
socket.destroy();
|
|
47
54
|
}
|
|
48
55
|
export function startBridgeServer(options) {
|
|
49
56
|
const timing = {
|
|
50
57
|
helloTimeoutMs: options.timing?.helloTimeoutMs ?? HELLO_TIMEOUT_MS,
|
|
51
|
-
unpairedTimeoutMs: options.timing?.unpairedTimeoutMs ?? UNPAIRED_TIMEOUT_MS,
|
|
52
58
|
pingIntervalMs: options.timing?.pingIntervalMs ?? PING_INTERVAL_MS,
|
|
53
59
|
};
|
|
54
60
|
const connections = new Map();
|
|
@@ -110,26 +116,13 @@ export function startBridgeServer(options) {
|
|
|
110
116
|
},
|
|
111
117
|
log,
|
|
112
118
|
};
|
|
113
|
-
const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
|
|
114
|
-
const markPaired = (conn) => {
|
|
115
|
-
conn.paired = true;
|
|
116
|
-
if (conn.timer)
|
|
117
|
-
clearTimeout(conn.timer);
|
|
118
|
-
conn.timer = null;
|
|
119
|
-
};
|
|
120
119
|
const callContext = {
|
|
121
120
|
cwd: options.cwd,
|
|
122
|
-
pairing: options.pairing,
|
|
123
121
|
service: options.service,
|
|
124
122
|
processes,
|
|
125
123
|
auth: authContext,
|
|
126
124
|
log,
|
|
127
125
|
isConnected: (connectionId) => connections.has(connectionId),
|
|
128
|
-
markPaired: (connectionId) => {
|
|
129
|
-
const conn = connections.get(connectionId);
|
|
130
|
-
if (conn)
|
|
131
|
-
markPaired(conn);
|
|
132
|
-
},
|
|
133
126
|
};
|
|
134
127
|
function onHello(conn, frame) {
|
|
135
128
|
const protocols = Array.isArray(frame.protocols) ? frame.protocols.filter((value) => typeof value === 'number') : [];
|
|
@@ -143,48 +136,31 @@ export function startBridgeServer(options) {
|
|
|
143
136
|
conn.ws.close(BRIDGE_CLOSE_CODES.protocolUnsupported, 'protocol unsupported');
|
|
144
137
|
return;
|
|
145
138
|
}
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
const extra = Object.keys(frame).filter((key) => !HELLO_KEYS.has(key));
|
|
140
|
+
if (extra.length > 0) {
|
|
141
|
+
// 旧页面(带 token / pairSecret)刷新后就好;键名截短,不把页面给的任意内容原样写进日志
|
|
142
|
+
const names = extra.slice(0, 3).map((key) => key.slice(0, 32)).join(', ');
|
|
143
|
+
if (options.verbose)
|
|
144
|
+
log(`Refused a hello from ${conn.origin} with unexpected fields (${names}).`);
|
|
145
|
+
sendError(conn, null, { code: 'bad_message', message: `hello only accepts protocols and client (got ${names}); reload M2` });
|
|
146
|
+
conn.ws.close(BRIDGE_CLOSE_CODES.badHandshake, 'unexpected hello fields');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
148
149
|
if (conn.timer)
|
|
149
150
|
clearTimeout(conn.timer);
|
|
150
151
|
conn.timer = null;
|
|
151
|
-
const token = typeof frame.token === 'string' ? frame.token : undefined;
|
|
152
|
-
let paired = options.pairing.verifyToken(token, conn.origin);
|
|
153
|
-
// 命令承诺配对:在未配对名额检查之前 —— 合法页面不被别人占满的未配对名额挡住。对不上(含写盘失败)一律按未配对继续,不抛出
|
|
154
|
-
let issuedToken;
|
|
155
|
-
if (!paired && typeof frame.pairSecret === 'string') {
|
|
156
|
-
const result = options.pairing.pairWithSecret(frame.pairSecret, conn.origin);
|
|
157
|
-
if (result.ok) {
|
|
158
|
-
paired = true;
|
|
159
|
-
issuedToken = result.token;
|
|
160
|
-
log(`Paired with ${conn.origin}.`);
|
|
161
|
-
}
|
|
162
|
-
else if (options.verbose)
|
|
163
|
-
log(`${conn.origin} did not pair from the start command (${result.reason}).`);
|
|
164
|
-
}
|
|
165
|
-
if (paired) {
|
|
166
|
-
markPaired(conn);
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
if (unpairedCount() > MAX_UNPAIRED_CONNECTIONS) {
|
|
170
|
-
conn.ws.close(BRIDGE_CLOSE_CODES.tooManyUnpaired, 'too many unpaired pages');
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
options.pairing.ensureCode(conn.origin);
|
|
174
|
-
conn.timer = setTimeout(() => conn.ws.close(BRIDGE_CLOSE_CODES.timeout, 'pairing timed out'), timing.unpairedTimeoutMs);
|
|
175
|
-
}
|
|
176
152
|
const client = isObject(frame.client) && typeof frame.client.name === 'string' ? ` (${frame.client.name})` : '';
|
|
177
153
|
if (options.verbose)
|
|
178
|
-
log(`Page connected from ${conn.origin}${client}
|
|
154
|
+
log(`Page connected from ${conn.origin}${client}.`);
|
|
179
155
|
conn.hello = true;
|
|
180
156
|
send(conn, {
|
|
181
157
|
type: 'welcome',
|
|
182
158
|
protocol: Math.max(...common),
|
|
183
159
|
bridge: options.bridge,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
160
|
+
// 页面侧类型在 1a 阶段 B 才删:这三个字段的形状对现有页面不变(恒为已连上)
|
|
161
|
+
paired: true,
|
|
162
|
+
tokenRejected: false,
|
|
163
|
+
cwd: options.cwd,
|
|
188
164
|
});
|
|
189
165
|
}
|
|
190
166
|
function onFrame(conn, data, isBinary) {
|
|
@@ -200,7 +176,7 @@ export function startBridgeServer(options) {
|
|
|
200
176
|
frame = null;
|
|
201
177
|
}
|
|
202
178
|
if (uninstalling) {
|
|
203
|
-
//
|
|
179
|
+
// 卸载开始后:不再握手,也不再处理任何调用
|
|
204
180
|
if (!conn.hello)
|
|
205
181
|
return conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is uninstalling');
|
|
206
182
|
const id = isObject(frame) && typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= MAX_CALL_ID ? frame.id : null;
|
|
@@ -217,9 +193,11 @@ export function startBridgeServer(options) {
|
|
|
217
193
|
sendError(conn, null, { code: 'bad_message', message: 'frame must be a JSON object' });
|
|
218
194
|
return;
|
|
219
195
|
}
|
|
196
|
+
if (!isReady(conn)) {
|
|
197
|
+
// 纵深判断:upgrade 只放行带有效票据的连接,正常走不到这里
|
|
198
|
+
return sendError(conn, null, { code: 'not_paired', message: 'this connection was not authorized by the M2 desktop app' });
|
|
199
|
+
}
|
|
220
200
|
if (frame.type === 'acp') {
|
|
221
|
-
if (!conn.paired)
|
|
222
|
-
return sendError(conn, null, { code: 'not_paired', message: 'pair this page first' });
|
|
223
201
|
const { processId, line } = frame;
|
|
224
202
|
if (typeof processId !== 'string' || typeof line !== 'string' || /[\r\n]/.test(line)) {
|
|
225
203
|
return sendError(conn, null, { code: 'bad_message', message: 'acp frame needs processId and a single-line "line"' });
|
|
@@ -242,7 +220,7 @@ export function startBridgeServer(options) {
|
|
|
242
220
|
return sendError(conn, id, { code: 'unknown_method', message: `unknown method ${String(method)}` });
|
|
243
221
|
const params = isObject(frame.params) ? frame.params : {};
|
|
244
222
|
if (method === 'bridge/uninstall') {
|
|
245
|
-
const refusal = startUninstall(uninstallContext, {
|
|
223
|
+
const refusal = startUninstall(uninstallContext, { authenticated: isReady(conn), params: frame.params, reply: (result) => sendAndWait(conn, { type: 'result', id, result }) });
|
|
246
224
|
if (refusal)
|
|
247
225
|
sendError(conn, id, refusal);
|
|
248
226
|
return;
|
|
@@ -269,16 +247,15 @@ export function startBridgeServer(options) {
|
|
|
269
247
|
if (uninstalling)
|
|
270
248
|
return rejectUpgrade(socket, 503);
|
|
271
249
|
const port = http.address()?.port ?? options.port;
|
|
272
|
-
const decision = checkUpgrade({ origin: req.headers.origin, host: req.headers.host, remoteAddress: req.socket.remoteAddress, path: req.url }, port, options.allowedOrigins);
|
|
250
|
+
const decision = checkUpgrade({ origin: req.headers.origin, host: req.headers.host, remoteAddress: req.socket.remoteAddress, path: req.url, ticket: readTicketHeader(req.headers) }, port, options.allowedOrigins, (ticket) => options.tickets.redeem(ticket));
|
|
273
251
|
if (!decision.ok) {
|
|
274
252
|
rejectLog.note(decision.reason);
|
|
275
|
-
if (decision.rejectedOrigin !== undefined)
|
|
276
|
-
options.onOriginRejected?.(decision.rejectedOrigin);
|
|
277
253
|
rejectUpgrade(socket, decision.status);
|
|
278
254
|
return;
|
|
279
255
|
}
|
|
256
|
+
const auth = decision.auth;
|
|
280
257
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
281
|
-
const conn = { id: randomUUID(), ws, origin: req.headers.origin,
|
|
258
|
+
const conn = { id: randomUUID(), ws, origin: req.headers.origin, auth, hello: false, alive: true, timer: null };
|
|
282
259
|
connections.set(conn.id, conn);
|
|
283
260
|
conn.timer = setTimeout(() => ws.close(BRIDGE_CLOSE_CODES.timeout, 'hello timed out'), timing.helloTimeoutMs);
|
|
284
261
|
ws.on('pong', () => {
|
|
@@ -310,7 +287,7 @@ export function startBridgeServer(options) {
|
|
|
310
287
|
ws.on('error', () => undefined);
|
|
311
288
|
});
|
|
312
289
|
});
|
|
313
|
-
//
|
|
290
|
+
// 被拒连接的日志限频:任何网站都能反复连 127.0.0.1,逐条打印会刷满日志(review r3 S5)。
|
|
314
291
|
// 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
|
|
315
292
|
const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
|
|
316
293
|
const rejectLog = (() => {
|
|
@@ -369,7 +346,7 @@ export function startBridgeServer(options) {
|
|
|
369
346
|
authorizations,
|
|
370
347
|
broadcast(event, data) {
|
|
371
348
|
for (const conn of connections.values())
|
|
372
|
-
if (conn
|
|
349
|
+
if (isReady(conn))
|
|
373
350
|
send(conn, { type: 'event', event, data });
|
|
374
351
|
},
|
|
375
352
|
close() {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 票据 socket(change bridge-client-only D2 / D3 / D4;取代 acp-bridge-one-time-install 的交接 socket)。
|
|
3
|
+
*
|
|
4
|
+
* 后台模式的本机连接监听 `~/.m2canvas/bridge-<port>.sock`。M2 客户端主进程在页面每次连本机连接之前来这里领一张一次性票据
|
|
5
|
+
* (`ticket.ts`),加进那次 WebSocket 握手的 `X-M2-Bridge-Ticket` 请求头;领不到就取消握手。
|
|
6
|
+
*
|
|
7
|
+
* 跨版本接口(客户端与本机连接各自更新,版本可能不同;`connect/local-bridge` 契约 13 / 14 草案):
|
|
8
|
+
* - 请求一行(不含换行最多 256 字节):`{"type":"ticket"}\n`,多出的字段忽略;
|
|
9
|
+
* - 回复一行:`{"ok":true,"ticket":"<43 字符>"}` 或 `{"ok":false,"error":"bad_request"}`,回复后断开。
|
|
10
|
+
* - 认不出的请求(含旧客户端的 `status{commitment}`、旧启动器的 `arm{nonce}`)一律 `bad_request`。反过来,旧本机连接对
|
|
11
|
+
* `ticket` 也回 `bad_request`:M2 客户端据此认出「端口上是用户自己的旧本机连接」,取消握手并带 `--replace` 换掉它(design D11)。
|
|
12
|
+
* 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
|
|
13
|
+
*
|
|
14
|
+
* 维护约束:
|
|
15
|
+
* - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
|
|
16
|
+
* 同一账号的进程能领票据,与今天它能直接改配置、读钥匙串同级(design 威胁分析类别 B);跑在 agent 命令沙箱里、
|
|
17
|
+
* 能连 socket 却写不了 `~/.m2canvas` 的进程不另加门槛(design U9,用户 2026-11-08 定先不加;后续评估见 backlog 的「领票据门槛」)。
|
|
18
|
+
* - 票据不进日志、不进任何文件。
|
|
19
|
+
* - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住本机连接或吃内存。
|
|
20
|
+
* - socket 路径受 macOS 的 sun_path 限制(104 字节含结尾 0)。建不起来时返回 null,调用方必须退出:没有 socket 就没有任何
|
|
21
|
+
* 页面连得上,留着进程只会让启动器一直报「已在运行」(F7)。
|
|
22
|
+
*/
|
|
23
|
+
import { chmodSync, rmSync } from 'node:fs';
|
|
24
|
+
import { createServer } from 'node:net';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
/** sockaddr_un.sun_path 是 104 字节,含结尾的 0 */
|
|
27
|
+
export const TICKET_SOCKET_MAX_PATH_BYTES = 103;
|
|
28
|
+
export const TICKET_SOCKET_TIMEOUT_MS = 3_000;
|
|
29
|
+
/** `{"type":"ticket"}` 不到 20 字节,留足给以后加字段 */
|
|
30
|
+
const MAX_LINE_BYTES = 256;
|
|
31
|
+
export function ticketSocketPath(dir, port) {
|
|
32
|
+
return join(dir, `bridge-${port}.sock`);
|
|
33
|
+
}
|
|
34
|
+
/** 路径放不进 sun_path 时给出原因(启动时先查,查出来就不去占端口);没问题返回 null */
|
|
35
|
+
export function ticketSocketPathProblem(dir, port) {
|
|
36
|
+
const path = ticketSocketPath(dir, port);
|
|
37
|
+
return Buffer.byteLength(path) > TICKET_SOCKET_MAX_PATH_BYTES ? `${path} is too long for a local socket (max ${TICKET_SOCKET_MAX_PATH_BYTES} bytes)` : null;
|
|
38
|
+
}
|
|
39
|
+
function errorText(error) {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
function isTicketRequest(line) {
|
|
43
|
+
try {
|
|
44
|
+
const request = JSON.parse(line);
|
|
45
|
+
return typeof request === 'object' && request !== null && !Array.isArray(request) && request.type === 'ticket';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 后台模式的本机连接在拿到端口之后调用:同名的 socket 残留(上次没清干净)先删掉 —— 端口在自己手里,这个端口的 socket 不可能属于别人。
|
|
53
|
+
* 返回 null = 没监听(路径太长或建不起来),已记日志;调用方据此退出。
|
|
54
|
+
*/
|
|
55
|
+
export async function listenForTicketRequests(options) {
|
|
56
|
+
const { dir, port, issue, log } = options;
|
|
57
|
+
const path = ticketSocketPath(dir, port);
|
|
58
|
+
const problem = ticketSocketPathProblem(dir, port);
|
|
59
|
+
if (problem) {
|
|
60
|
+
log(`The M2 desktop app can't reach this bridge: ${problem}.`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const server = createServer((socket) => {
|
|
64
|
+
const deadline = setTimeout(() => socket.destroy(), TICKET_SOCKET_TIMEOUT_MS);
|
|
65
|
+
socket.on('close', () => clearTimeout(deadline));
|
|
66
|
+
socket.on('error', () => undefined);
|
|
67
|
+
let buffer = '';
|
|
68
|
+
let done = false;
|
|
69
|
+
socket.on('data', (chunk) => {
|
|
70
|
+
// 只处理第一行:之后再发什么都丢掉(不暂停读取,对方断开时这边也跟着关)
|
|
71
|
+
if (done)
|
|
72
|
+
return;
|
|
73
|
+
buffer += chunk.toString('utf8');
|
|
74
|
+
const end = buffer.indexOf('\n');
|
|
75
|
+
if (Buffer.byteLength(end < 0 ? buffer : buffer.slice(0, end)) > MAX_LINE_BYTES) {
|
|
76
|
+
socket.destroy();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (end < 0)
|
|
80
|
+
return;
|
|
81
|
+
done = true;
|
|
82
|
+
socket.end(`${JSON.stringify(isTicketRequest(buffer.slice(0, end)) ? { ok: true, ticket: issue() } : { ok: false, error: 'bad_request' })}\n`);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
rmSync(path, { force: true });
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.once('error', reject);
|
|
89
|
+
server.listen(path, () => resolve());
|
|
90
|
+
});
|
|
91
|
+
chmodSync(path, 0o600);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
server.close();
|
|
95
|
+
log(`The M2 desktop app can't reach this bridge: couldn't listen on ${path} (${errorText(error)}).`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
close() {
|
|
100
|
+
server.close();
|
|
101
|
+
rmSync(path, { force: true });
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机连接的一次性票据(change bridge-client-only D2 / D4;`connect/local-bridge` 契约 20 草案;19 已由换域名的 D-C1 使用)。
|
|
3
|
+
*
|
|
4
|
+
* 本机连接只接受 M2 客户端主进程替页面加了 `X-M2-Bridge-Ticket` 请求头的 WebSocket 握手。票据由后台模式的 socket
|
|
5
|
+
* (`ticket-socket.ts`,只有本账号能连)按需发出,页面自己既拿不到也看不到。浏览器里的页面加不了自定义请求头,所以天然被拒。
|
|
6
|
+
*
|
|
7
|
+
* 不变量(改动前先读 design D2 与威胁分析):
|
|
8
|
+
* - 32 字节随机数(base64url,43 字符),内存里只存 sha256;60 秒有效、用一次即作废;没用过的最多 64 张,超出丢最早的。
|
|
9
|
+
* - 只在发出它的这个进程的内存里,进程退出全部失效 —— 端口抢占者即使收到票据,也只能拿到「已经死掉的那个本机连接发的」票据
|
|
10
|
+
* (真本机连接占着端口时抢占者绑不上;它一退出,发过的票据就没用了)。不要落盘、不要跨进程共享、不要写日志。
|
|
11
|
+
* - 核对与用掉是同一步:不论之后握手成败,出示过的票据都不能再用。
|
|
12
|
+
* - 年龄为负(时钟往回拨过)当作过期,与预检缓存(契约 18)的口径一致。
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
15
|
+
export const TICKET_TTL_MS = 60_000;
|
|
16
|
+
export const MAX_UNUSED_TICKETS = 64;
|
|
17
|
+
const TICKET_BYTES = 32;
|
|
18
|
+
/** 32 字节的 base64url(无填充)恰好 43 个字符 */
|
|
19
|
+
const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
export function isTicketShape(value) {
|
|
21
|
+
return TICKET_RE.test(value);
|
|
22
|
+
}
|
|
23
|
+
function digest(ticket) {
|
|
24
|
+
return createHash('sha256').update(ticket).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
export class TicketBook {
|
|
27
|
+
now;
|
|
28
|
+
/** sha256 → 发出时刻。Map 按插入顺序迭代,最早发出的在前,超额时从头丢 */
|
|
29
|
+
unused = new Map();
|
|
30
|
+
constructor(now = Date.now) {
|
|
31
|
+
this.now = now;
|
|
32
|
+
}
|
|
33
|
+
issue() {
|
|
34
|
+
this.prune();
|
|
35
|
+
while (this.unused.size >= MAX_UNUSED_TICKETS) {
|
|
36
|
+
const oldest = this.unused.keys().next();
|
|
37
|
+
if (oldest.done)
|
|
38
|
+
break;
|
|
39
|
+
this.unused.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
42
|
+
this.unused.set(digest(ticket), this.now());
|
|
43
|
+
return ticket;
|
|
44
|
+
}
|
|
45
|
+
/** 核对并用掉。不是本进程发的、过期、用过、格式不对都返回 false */
|
|
46
|
+
redeem(ticket) {
|
|
47
|
+
if (typeof ticket !== 'string' || !isTicketShape(ticket))
|
|
48
|
+
return false;
|
|
49
|
+
const key = digest(ticket);
|
|
50
|
+
const issuedAt = this.unused.get(key);
|
|
51
|
+
if (issuedAt === undefined)
|
|
52
|
+
return false;
|
|
53
|
+
this.unused.delete(key);
|
|
54
|
+
return this.fresh(issuedAt);
|
|
55
|
+
}
|
|
56
|
+
/** 还能用的票据张数(测试与日志用) */
|
|
57
|
+
get size() {
|
|
58
|
+
this.prune();
|
|
59
|
+
return this.unused.size;
|
|
60
|
+
}
|
|
61
|
+
fresh(issuedAt) {
|
|
62
|
+
const age = this.now() - issuedAt;
|
|
63
|
+
return age >= 0 && age < TICKET_TTL_MS;
|
|
64
|
+
}
|
|
65
|
+
prune() {
|
|
66
|
+
for (const [key, issuedAt] of this.unused)
|
|
67
|
+
if (!this.fresh(issuedAt))
|
|
68
|
+
this.unused.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -30,8 +30,8 @@ async function perform(ctx, uninstaller, request) {
|
|
|
30
30
|
}
|
|
31
31
|
/** 开始卸载;不能开始时返回要回给页面的错误(什么都不做) */
|
|
32
32
|
export function startUninstall(ctx, request) {
|
|
33
|
-
if (!request.
|
|
34
|
-
return { code: 'not_paired', message: '
|
|
33
|
+
if (!request.authenticated)
|
|
34
|
+
return { code: 'not_paired', message: 'this connection was not authorized by the M2 desktop app' };
|
|
35
35
|
// 只能省略或是空对象:null、数组、字符串都不当成 {};自有属性一个都不能有(`__proto__` 之类的也算)—— 页面不能传任何影响卸载的东西
|
|
36
36
|
const { params } = request;
|
|
37
37
|
const empty = params === undefined || (typeof params === 'object' && params !== null && !Array.isArray(params) && Object.keys(params).length === 0);
|
package/dist/bridge/uninstall.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -26,7 +26,8 @@ sign-in steps.
|
|
|
26
26
|
--dry-run only show what would change
|
|
27
27
|
-h, --help show this help
|
|
28
28
|
|
|
29
|
-
bridge run the
|
|
29
|
+
bridge run by the M2 desktop app so M2 can use the agents on this machine
|
|
30
|
+
(local agents only work inside the M2 desktop app)`;
|
|
30
31
|
async function confirm(question) {
|
|
31
32
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
32
33
|
try {
|
package/dist/clients.js
CHANGED
|
@@ -6,7 +6,7 @@ function jsonExisting(path, find) {
|
|
|
6
6
|
const read = readJson(path);
|
|
7
7
|
return read.kind === 'ok' ? find(read.value) : null;
|
|
8
8
|
}
|
|
9
|
-
/** 用户级 server
|
|
9
|
+
/** 用户级 server 表里同名(`SERVER_NAME`)入口指向别处 → blocked 提示(D-C1);读不出文件按无冲突,由客户端自己的 CLI 报错 */
|
|
10
10
|
function jsonConflict(path, servers) {
|
|
11
11
|
const read = readJson(path);
|
|
12
12
|
return read.kind === 'ok' && hasNameConflictInServers(servers(read.value)) ? nameConflictNote(path) : null;
|
package/dist/config-files.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 各客户端配置文件的纯函数读写:查已有的本产品入口(按 MCP 主机认,不看名字)、按原格式合并。全部不碰磁盘,便于单测。
|
|
2
2
|
// MCP 地址来自 site-config.ts(connect 唯一的站点配置来源),这里转出供 cli.ts / clients.ts 沿用原导出名。
|
|
3
3
|
import { M2_MCP_URL, MCP_HOST } from './site-config.js';
|
|
4
4
|
export { M2_MCP_URL };
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* connect 写进各家 agent 配置的条目名(change mcp-entry-name-patch,2026-11-10 起由 `m2` 改为 `patch`)。
|
|
7
|
+
* 只用于新写入与同名冲突判断;识别已有条目一律按 MCP 主机(`isM2Url`),所以改名前写下、指向当前主机的 `m2`
|
|
8
|
+
* 照常认作已配置,不改名、不追加第二条(design D2)。网站接入片段与 `agent-setup.md` 用同一个名字,改动时同步。
|
|
9
|
+
*/
|
|
10
|
+
export const SERVER_NAME = 'patch';
|
|
6
11
|
function isObject(value) {
|
|
7
12
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
13
|
}
|
|
@@ -28,9 +33,10 @@ export function findM2Entry(servers) {
|
|
|
28
33
|
return null;
|
|
29
34
|
}
|
|
30
35
|
/**
|
|
31
|
-
* server 表里已有名为 `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* server 表里已有名为 `SERVER_NAME`、但不指向当前 MCP 主机的入口(change domain-cutover design D-C1,
|
|
37
|
+
* mcp-entry-name-patch D3 起判的是 `patch`)。我们只会以这个名字写入;同名入口指向别处时既不能追加
|
|
38
|
+
* (Codex 的 TOML 会出现重复表、整个文件解析失败),也不能覆盖(那可能是用户自己的 server,静默改掉用户看不出发生了什么)。
|
|
39
|
+
* 一律报冲突交给用户。
|
|
34
40
|
*/
|
|
35
41
|
export function hasNameConflictInServers(servers) {
|
|
36
42
|
if (!isObject(servers) || !Object.hasOwn(servers, SERVER_NAME))
|
|
@@ -40,7 +46,7 @@ export function hasNameConflictInServers(servers) {
|
|
|
40
46
|
}
|
|
41
47
|
/** 同名冲突的提示(给 ClientPlan.blocked 用):说明不改的原因与用户要做的事,不打印对方的地址 */
|
|
42
48
|
export function nameConflictNote(where) {
|
|
43
|
-
return `${where} already has an MCP server named "${SERVER_NAME}" that does not point to
|
|
49
|
+
return `${where} already has an MCP server named "${SERVER_NAME}" that does not point to Patch (${MCP_HOST}). Remove or rename it, then run this again.`;
|
|
44
50
|
}
|
|
45
51
|
/** Claude Code `~/.claude.json` 顶层 mcpServers = user scope。 */
|
|
46
52
|
export function findInClaudeConfig(config) {
|
|
@@ -73,7 +79,7 @@ export function findInCodexToml(toml) {
|
|
|
73
79
|
}
|
|
74
80
|
return null;
|
|
75
81
|
}
|
|
76
|
-
/** Codex `config.toml` 里已有 `[mcp_servers
|
|
82
|
+
/** Codex `config.toml` 里已有 `[mcp_servers.<SERVER_NAME>]` 段且段内 url 不指向当前 MCP 主机(判定见 `hasNameConflictInServers`) */
|
|
77
83
|
export function hasCodexNameConflict(toml) {
|
|
78
84
|
let inTarget = false;
|
|
79
85
|
let found = false;
|
|
@@ -132,7 +138,7 @@ export function mergeOpencodeConfig(config) {
|
|
|
132
138
|
return { existing: null, conflict: false, config: { ...base, mcp: nextMcp } };
|
|
133
139
|
}
|
|
134
140
|
/**
|
|
135
|
-
* Codex `config.toml` 追加
|
|
141
|
+
* Codex `config.toml` 追加 `[mcp_servers.<SERVER_NAME>]` 段。不走 `codex mcp add`:它在没有已存 token 时会顺手启动 OAuth
|
|
136
142
|
* 并阻塞等浏览器回调(实测),agent 代跑时输出被捕获、链接不可见,命令永远不返回。
|
|
137
143
|
* 只做追加、不解析重写,用户原有内容与注释逐字保留;调用方先用 findInCodexToml 判重。
|
|
138
144
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command, and run the local bridge that lets M2 drive them over ACP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|