@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.
@@ -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
- const MAX_REPORTED_ORIGIN_CHARS = 200;
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 而不是「握手后再关」:不对非白名单页面完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
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
- if (info.origin === undefined)
53
- return { ok: false, status: 403, reason: 'origin missing' };
54
- return { ok: false, status: 403, reason: `origin ${info.origin}`, rejectedOrigin: info.origin.slice(0, MAX_REPORTED_ORIGIN_CHARS) };
55
- }
56
- return { ok: true };
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
  }
@@ -1,17 +1,19 @@
1
1
  /**
2
- * 本机 ACP bridge 的 HTTP + WebSocket 服务:连接准入、握手、配对、方法分派、ACP 转发(页面 → agent 先经 acp-guard.ts 检查,agent → 页面原样)、心跳与清理。
2
+ * 本机 ACP bridge 的 HTTP + WebSocket 服务:连接准入、握手、方法分派、ACP 转发(页面 → agent 先经 acp-guard.ts 检查,agent → 页面原样)、心跳与清理。
3
3
  * 协议规范见 protocol.ts 文件头指向的 design「页面协议」一节。
4
4
  *
5
- * 安全模型(改动前先读 security.ts / pairing.ts / acp-guard.ts 的文件头):
6
- * **配对 = 授权该页面以用户的身份驱动本机 agent**,包括让 agent 读写文件、执行命令(提示词、替用户回答审批、切换工作模式
7
- * 都由页面发出)。所以安全的重心在「谁能配对」,配对之后能做的事只做纵深防御:
8
- * - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径)在完成握手之前,失败直接写 HTTP 状态并销毁 socket。
9
- * - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
10
- * 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
11
- * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
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):开始后拒绝新连接与一切其他帧(握手与配对会写盘、重建 ~/.m2canvas),端口占到退出,结果回出去后退出。
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 === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : 'Service Unavailable';
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
- // conn.hello 等 welcome 发出前一刻才置位:中间任何一步抛异常时,消息监听器的 catch 看到「还在握手」,
147
- // 以 1011 关闭连接,而不是留下一个收不到 welcome、计时器又已清掉的挂起连接
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}, ${paired ? 'paired' : 'not paired'}.`);
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
- paired,
185
- tokenRejected: token !== undefined && !paired,
186
- cwd: paired ? options.cwd : null,
187
- ...(issuedToken ? { token: issuedToken } : {}),
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
- // 卸载开始后:不再握手(带 token 的 hello 会刷新使用时间、写配对文件),也不再处理任何调用
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, { paired: conn.paired, params: frame.params, reply: (result) => sendAndWait(conn, { type: 'result', id, result }) });
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, hello: false, paired: false, alive: true, timer: null };
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
- // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
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.paired)
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.paired)
34
- return { code: 'not_paired', message: 'pair this page first' };
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);
@@ -62,7 +62,7 @@ export async function runSelfUninstall(deps) {
62
62
  };
63
63
  }
64
64
  try {
65
- deps.closeHandoff();
65
+ deps.closeTicketSocket();
66
66
  }
67
67
  catch {
68
68
  // socket 文件删不掉不影响卸载:脚本会把停掉的 bridge 的 socket 一并删掉
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 local agent bridge so M2 can use the agents on this machine`;
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 表里同名 `m2` 指向别处 → blocked 提示(D-C1);读不出文件按无冲突,由客户端自己的 CLI 报错 */
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;
@@ -1,8 +1,13 @@
1
- // 各客户端配置文件的纯函数读写:查已有 m2 入口、按原格式合并。全部不碰磁盘,便于单测。
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
- export const SERVER_NAME = 'm2';
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 表里已有名为 `m2`、但不指向 M2 的入口(change domain-cutover design D-C1)。
32
- * 我们只会以 `m2` 这个名字写入;同名入口指向别处时既不能追加(Codex 的 TOML 会出现重复表、整个文件解析失败),
33
- * 也不能覆盖(那可能是用户自己的 server,或换域名前留下的旧入口,静默改掉用户看不出发生了什么)。一律报冲突交给用户。
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 M2 (for example one left from an old M2 address). Remove or rename it, then run this again.`;
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.m2]` 段且段内 url 不指向 M2(判定见 `hasNameConflictInServers`) */
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` 追加 m2 段。不走 `codex mcp add`:它在没有已存 token 时会顺手启动 OAuth
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.11.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": {