@m2canvas/connect 0.12.0 → 0.14.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.
@@ -18,6 +18,25 @@ export const KILL_GRACE_MS = 3_000;
18
18
  export const MAX_LINE_CHARS = 64 * 1024 * 1024;
19
19
  const STDERR_KEEP = 8 * 1024;
20
20
  const STDERR_REPORT = 4 * 1024;
21
+ /**
22
+ * M2 会话令牌(`m2st_…`,change acp-session-m2-token)的打码:stderr 尾巴会经 `process/exited` 发给页面(页面里跑着不可信的 DS 代码),
23
+ * agent 在 helper 出错或令牌被拒时会不会把请求头打到 stderr 不受我们控制(review r1 残余风险),所以转发前一律替换掉。
24
+ * 正在写的半截令牌(只有 `m2st_` 开头)也替换。
25
+ */
26
+ export function redactSessionTokens(text) {
27
+ return text.replace(/m2st_[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*)?/g, 'm2st_[redacted]');
28
+ }
29
+ /**
30
+ * 只留最后 `max` 个字符;被截断时从第一个空白之后开始 —— 令牌不含空白,截在令牌中间时丢掉那半截
31
+ * (否则 `m2st_` 前缀已被截掉、只剩签名段,打码认不出它)。
32
+ */
33
+ export function keepTail(text, max) {
34
+ if (text.length <= max)
35
+ return text;
36
+ const cut = text.slice(-max);
37
+ const space = cut.search(/\s/);
38
+ return space === -1 ? '' : cut.slice(space);
39
+ }
21
40
  export class ProcessError extends Error {
22
41
  code;
23
42
  constructor(code, message) {
@@ -96,7 +115,7 @@ export class ProcessManager {
96
115
  try {
97
116
  child = spawn(launch.command, launch.args, {
98
117
  cwd: this.options.cwd,
99
- env: this.options.env,
118
+ env: launch.env ? { ...this.options.env, ...launch.env } : this.options.env,
100
119
  stdio: ['pipe', 'pipe', 'pipe'],
101
120
  detached: process.platform !== 'win32',
102
121
  windowsHide: true,
@@ -107,7 +126,7 @@ export class ProcessManager {
107
126
  this.adjustStarting(ownerId, -1);
108
127
  return Promise.reject(new ProcessError('spawn_failed', `${launch.command}: ${error instanceof Error ? error.message : String(error)}`));
109
128
  }
110
- const managed = { id, ownerId, agent, child, stopping: false, tooLarge: false, stderr: '', killTimer: null };
129
+ const managed = { id, ownerId, agent, child, rewritePageLine: launch.rewritePageLine ?? null, stopping: false, tooLarge: false, stderr: '', stderrDropping: false, killTimer: null };
111
130
  return new Promise((resolve, reject) => {
112
131
  let started = false;
113
132
  child.once('spawn', () => {
@@ -150,8 +169,19 @@ export class ProcessManager {
150
169
  });
151
170
  child.stderr?.setEncoding('utf8');
152
171
  child.stderr?.on('data', (chunk) => {
153
- managed.stderr = (managed.stderr + chunk).slice(-STDERR_KEEP);
154
- this.options.onStderr?.(managed.id, managed.agent, chunk);
172
+ let piece = chunk;
173
+ if (managed.stderrDropping) {
174
+ const space = piece.search(/\s/);
175
+ piece = space === -1 ? '' : piece.slice(space);
176
+ managed.stderrDropping = space === -1;
177
+ }
178
+ const joined = managed.stderr + piece;
179
+ managed.stderr = keepTail(joined, STDERR_KEEP);
180
+ // 截成空:结尾可能正是一个被拆开的令牌的前半截,它的后半截会在下一块开头
181
+ if (joined.length > 0 && managed.stderr === '')
182
+ managed.stderrDropping = true;
183
+ // 只在 --verbose 时打到 bridge 自己的终端:逐块打码,尽力而为(跨块的令牌只会出现在同账号的终端里)
184
+ this.options.onStderr?.(managed.id, managed.agent, redactSessionTokens(chunk));
155
185
  });
156
186
  // 'close' 在 stdio 全部关闭后触发,保证最后几行先于退出事件送出
157
187
  child.on('close', (code, signal) => {
@@ -171,7 +201,7 @@ export class ProcessManager {
171
201
  code,
172
202
  signal,
173
203
  reason: managed.tooLarge ? 'output_too_large' : managed.stopping ? 'stopped' : 'exited',
174
- stderrTail: managed.stderr.slice(-STDERR_REPORT),
204
+ stderrTail: redactSessionTokens(keepTail(managed.stderr, STDERR_REPORT)),
175
205
  });
176
206
  });
177
207
  }
@@ -185,7 +215,8 @@ export class ProcessManager {
185
215
  const managed = this.processes.get(processId);
186
216
  if (!managed || managed.ownerId !== ownerId || managed.stopping)
187
217
  return false;
188
- managed.child.stdin?.write(`${line}\n`);
218
+ const out = managed.rewritePageLine ? managed.rewritePageLine(line) : line;
219
+ managed.child.stdin?.write(`${out}\n`);
189
220
  return true;
190
221
  }
191
222
  stopManaged(managed) {
@@ -11,7 +11,11 @@
11
11
  * bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
12
12
  * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单、会话范围与无人值守模式检查(acp-guard.ts),
13
13
  * 翻译全部在页面做。
14
- * - 配对 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令);页面代码与 token 的保管要按这个级别对待。
14
+ * - 连上本机连接 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令)。change bridge-client-only 起只有 M2 客户端主进程
15
+ * 替页面加了一次性票据(`BRIDGE_TICKET_HEADER`)的握手才被接受,页面不持有任何本机连接凭证;配对整体删掉(hello 的
16
+ * `token` / `pairSecret`、`pair` 方法、`pairing_*` 错误码、`tooManyUnpaired`、welcome 的 `token`),类型随页面一起删了(1a 阶段 B)。
17
+ * 不升协议版本、也不同时支持前一版本:这是契约 9「同版本只加法」经用户拍板的例外(U5 / F6,不兼容旧版本)。
18
+ * welcome 的 `paired` / `tokenRejected` 本机连接仍按旧形状发出(恒为 true / false),页面不再读。
15
19
  */
16
20
  /** 本 bridge 支持的协议版本(页面在 hello 里给出自己支持的列表,bridge 选双方共有的最大值) */
17
21
  export const BRIDGE_PROTOCOL_VERSION = 1;
@@ -19,6 +23,12 @@ export const BRIDGE_SUPPORTED_PROTOCOLS = [1];
19
23
  /** 默认端口与路径。页面必须连 127.0.0.1(不是 localhost:localhost 可能先解析到 ::1,bridge 不监听 IPv6)。 */
20
24
  export const BRIDGE_DEFAULT_PORT = 47821;
21
25
  export const BRIDGE_WS_PATH = '/bridge';
26
+ /**
27
+ * M2 客户端主进程替页面加进 WebSocket 握手的一次性票据请求头(change bridge-client-only D3 / D4 起)。
28
+ * 票据从后台模式的 `~/.m2canvas/bridge-<port>.sock` 领(请求 `{"type":"ticket"}`)。页面代码不会、也不能设置它:
29
+ * 浏览器的 WebSocket API 加不了自定义请求头。客户端那边各存一份常量,由防漂移单测对照这里(mac-client 契约 11)。
30
+ */
31
+ export const BRIDGE_TICKET_HEADER = 'X-M2-Bridge-Ticket';
22
32
  /** 与前端 src/lib/agent-types 的 AgentType 取值一致(是它的子集),顺序即 agents/list 的返回顺序 */
23
33
  export const BRIDGE_AGENT_IDS = ['claude', 'codex', 'cursor', 'opencode', 'qoder'];
24
34
  /**
@@ -64,7 +74,6 @@ export const BRIDGE_UNATTENDED_MODE_VALUES = {
64
74
  qoder: ['yolo'],
65
75
  };
66
76
  export const BRIDGE_METHODS = [
67
- 'pair',
68
77
  'agents/list',
69
78
  'agent/prepare',
70
79
  'agent/precheck',
@@ -86,5 +95,4 @@ export const BRIDGE_CLOSE_CODES = {
86
95
  badHandshake: 4400,
87
96
  timeout: 4408,
88
97
  protocolUnsupported: 4426,
89
- tooManyUnpaired: 4429,
90
98
  };
@@ -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,21 @@
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、`process/start` 的会话令牌开关与用于比对的
14
+ * userId(change acp-session-m2-token,契约 21)—— 进不了 spawn 的命令、参数、cwd、环境。会话令牌模式下 bridge 自己给进程加的 MCP 条目
15
+ * (Claude 的 `_meta`、Codex 的 `CODEX_CONFIG`)在页面的行过了 acp-guard 之后才加,内容全部由 bridge 决定(session-token.ts)。
12
16
  * 方法分派在 call-dispatch.ts;agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(auth-call.ts,命令清单写死在 authorize.ts)。
13
17
  * - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
14
- * - bridge/uninstall(0.8.0,uninstall-call.ts):开始后拒绝新连接与一切其他帧(握手与配对会写盘、重建 ~/.m2canvas),端口占到退出,结果回出去后退出。
18
+ * - bridge/uninstall(0.8.0,uninstall-call.ts):开始后拒绝新连接与一切其他帧,端口占到退出,结果回出去后退出。
15
19
  *
16
20
  * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责)。
17
21
  * 登录 / m2 授权只代跑写死清单里的命令,授权页由用户在浏览器里亲自完成(authorize.ts);授权链接只发给发起的连接,不进终端日志。
@@ -24,31 +28,35 @@ import { handleCall } from './call-dispatch.js';
24
28
  import { AuthorizationManager } from './authorize.js';
25
29
  import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
26
30
  import { checkPageAcpLine } from './acp-guard.js';
27
- import { checkUpgrade } from './security.js';
31
+ import { checkUpgrade, readTicketHeader } from './security.js';
28
32
  import { BridgeFailure } from './service.js';
29
33
  import { startUninstall } from './uninstall-call.js';
30
34
  export const HELLO_TIMEOUT_MS = 10_000;
31
- export const UNPAIRED_TIMEOUT_MS = 10 * 60 * 1000;
32
- export const MAX_UNPAIRED_CONNECTIONS = 4;
33
35
  export const PING_INTERVAL_MS = 30_000;
34
36
  export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
35
37
  export const REJECT_LOG_WINDOW_MS = 60_000;
36
38
  const MAX_CALL_ID = 64;
39
+ /** hello 里只认这几个键(change bridge-client-only D4):带 token / pairSecret 的旧页面与任何多出的键一律 bad_message */
40
+ const HELLO_KEYS = new Set(['type', 'protocols', 'client']);
41
+ /** 纵深判断:只有经 M2 客户端票据认证、且完成了 hello 的连接才能调用方法、收发 acp 与广播 */
42
+ function isReady(conn) {
43
+ return conn.hello && conn.auth === 'client-ticket';
44
+ }
37
45
  function isObject(value) {
38
46
  return typeof value === 'object' && value !== null && !Array.isArray(value);
39
47
  }
40
48
  function isMethod(value) {
41
49
  return typeof value === 'string' && BRIDGE_METHODS.includes(value);
42
50
  }
51
+ const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' };
43
52
  function rejectUpgrade(socket, status) {
44
- const text = status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : 'Service Unavailable';
53
+ const text = STATUS_TEXT[status];
45
54
  socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
46
55
  socket.destroy();
47
56
  }
48
57
  export function startBridgeServer(options) {
49
58
  const timing = {
50
59
  helloTimeoutMs: options.timing?.helloTimeoutMs ?? HELLO_TIMEOUT_MS,
51
- unpairedTimeoutMs: options.timing?.unpairedTimeoutMs ?? UNPAIRED_TIMEOUT_MS,
52
60
  pingIntervalMs: options.timing?.pingIntervalMs ?? PING_INTERVAL_MS,
53
61
  };
54
62
  const connections = new Map();
@@ -110,26 +118,13 @@ export function startBridgeServer(options) {
110
118
  },
111
119
  log,
112
120
  };
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
121
  const callContext = {
121
122
  cwd: options.cwd,
122
- pairing: options.pairing,
123
123
  service: options.service,
124
124
  processes,
125
125
  auth: authContext,
126
126
  log,
127
127
  isConnected: (connectionId) => connections.has(connectionId),
128
- markPaired: (connectionId) => {
129
- const conn = connections.get(connectionId);
130
- if (conn)
131
- markPaired(conn);
132
- },
133
128
  };
134
129
  function onHello(conn, frame) {
135
130
  const protocols = Array.isArray(frame.protocols) ? frame.protocols.filter((value) => typeof value === 'number') : [];
@@ -143,48 +138,31 @@ export function startBridgeServer(options) {
143
138
  conn.ws.close(BRIDGE_CLOSE_CODES.protocolUnsupported, 'protocol unsupported');
144
139
  return;
145
140
  }
146
- // conn.hello 等 welcome 发出前一刻才置位:中间任何一步抛异常时,消息监听器的 catch 看到「还在握手」,
147
- // 以 1011 关闭连接,而不是留下一个收不到 welcome、计时器又已清掉的挂起连接
141
+ const extra = Object.keys(frame).filter((key) => !HELLO_KEYS.has(key));
142
+ if (extra.length > 0) {
143
+ // 旧页面(带 token / pairSecret)刷新后就好;键名截短,不把页面给的任意内容原样写进日志
144
+ const names = extra.slice(0, 3).map((key) => key.slice(0, 32)).join(', ');
145
+ if (options.verbose)
146
+ log(`Refused a hello from ${conn.origin} with unexpected fields (${names}).`);
147
+ sendError(conn, null, { code: 'bad_message', message: `hello only accepts protocols and client (got ${names}); reload M2` });
148
+ conn.ws.close(BRIDGE_CLOSE_CODES.badHandshake, 'unexpected hello fields');
149
+ return;
150
+ }
148
151
  if (conn.timer)
149
152
  clearTimeout(conn.timer);
150
153
  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
154
  const client = isObject(frame.client) && typeof frame.client.name === 'string' ? ` (${frame.client.name})` : '';
177
155
  if (options.verbose)
178
- log(`Page connected from ${conn.origin}${client}, ${paired ? 'paired' : 'not paired'}.`);
156
+ log(`Page connected from ${conn.origin}${client}.`);
179
157
  conn.hello = true;
180
158
  send(conn, {
181
159
  type: 'welcome',
182
160
  protocol: Math.max(...common),
183
- bridge: options.bridge,
184
- paired,
185
- tokenRejected: token !== undefined && !paired,
186
- cwd: paired ? options.cwd : null,
187
- ...(issuedToken ? { token: issuedToken } : {}),
161
+ bridge: options.rejectedVersions ? { ...options.bridge, rejected: options.rejectedVersions() } : options.bridge,
162
+ // 页面侧类型在 1a 阶段 B 才删:这三个字段的形状对现有页面不变(恒为已连上)
163
+ paired: true,
164
+ tokenRejected: false,
165
+ cwd: options.cwd,
188
166
  });
189
167
  }
190
168
  function onFrame(conn, data, isBinary) {
@@ -200,7 +178,7 @@ export function startBridgeServer(options) {
200
178
  frame = null;
201
179
  }
202
180
  if (uninstalling) {
203
- // 卸载开始后:不再握手(带 token 的 hello 会刷新使用时间、写配对文件),也不再处理任何调用
181
+ // 卸载开始后:不再握手,也不再处理任何调用
204
182
  if (!conn.hello)
205
183
  return conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is uninstalling');
206
184
  const id = isObject(frame) && typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= MAX_CALL_ID ? frame.id : null;
@@ -217,9 +195,11 @@ export function startBridgeServer(options) {
217
195
  sendError(conn, null, { code: 'bad_message', message: 'frame must be a JSON object' });
218
196
  return;
219
197
  }
198
+ if (!isReady(conn)) {
199
+ // 纵深判断:upgrade 只放行带有效票据的连接,正常走不到这里
200
+ return sendError(conn, null, { code: 'not_paired', message: 'this connection was not authorized by the M2 desktop app' });
201
+ }
220
202
  if (frame.type === 'acp') {
221
- if (!conn.paired)
222
- return sendError(conn, null, { code: 'not_paired', message: 'pair this page first' });
223
203
  const { processId, line } = frame;
224
204
  if (typeof processId !== 'string' || typeof line !== 'string' || /[\r\n]/.test(line)) {
225
205
  return sendError(conn, null, { code: 'bad_message', message: 'acp frame needs processId and a single-line "line"' });
@@ -242,7 +222,7 @@ export function startBridgeServer(options) {
242
222
  return sendError(conn, id, { code: 'unknown_method', message: `unknown method ${String(method)}` });
243
223
  const params = isObject(frame.params) ? frame.params : {};
244
224
  if (method === 'bridge/uninstall') {
245
- const refusal = startUninstall(uninstallContext, { paired: conn.paired, params: frame.params, reply: (result) => sendAndWait(conn, { type: 'result', id, result }) });
225
+ const refusal = startUninstall(uninstallContext, { authenticated: isReady(conn), params: frame.params, reply: (result) => sendAndWait(conn, { type: 'result', id, result }) });
246
226
  if (refusal)
247
227
  sendError(conn, id, refusal);
248
228
  return;
@@ -269,16 +249,15 @@ export function startBridgeServer(options) {
269
249
  if (uninstalling)
270
250
  return rejectUpgrade(socket, 503);
271
251
  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);
252
+ 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
253
  if (!decision.ok) {
274
254
  rejectLog.note(decision.reason);
275
- if (decision.rejectedOrigin !== undefined)
276
- options.onOriginRejected?.(decision.rejectedOrigin);
277
255
  rejectUpgrade(socket, decision.status);
278
256
  return;
279
257
  }
258
+ const auth = decision.auth;
280
259
  wss.handleUpgrade(req, socket, head, (ws) => {
281
- const conn = { id: randomUUID(), ws, origin: req.headers.origin, hello: false, paired: false, alive: true, timer: null };
260
+ const conn = { id: randomUUID(), ws, origin: req.headers.origin, auth, hello: false, alive: true, timer: null };
282
261
  connections.set(conn.id, conn);
283
262
  conn.timer = setTimeout(() => ws.close(BRIDGE_CLOSE_CODES.timeout, 'hello timed out'), timing.helloTimeoutMs);
284
263
  ws.on('pong', () => {
@@ -310,7 +289,7 @@ export function startBridgeServer(options) {
310
289
  ws.on('error', () => undefined);
311
290
  });
312
291
  });
313
- // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
292
+ // 被拒连接的日志限频:任何网站都能反复连 127.0.0.1,逐条打印会刷满日志(review r3 S5)。
314
293
  // 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
315
294
  const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
316
295
  const rejectLog = (() => {
@@ -369,7 +348,7 @@ export function startBridgeServer(options) {
369
348
  authorizations,
370
349
  broadcast(event, data) {
371
350
  for (const conn of connections.values())
372
- if (conn.paired)
351
+ if (isReady(conn))
373
352
  send(conn, { type: 'event', event, data });
374
353
  },
375
354
  close() {
@@ -15,6 +15,9 @@
15
15
  * 优先用最近一次预检 / 写配置时得到的名字(D19,页面先预检才会显示按钮,省掉一次重跑 —— Claude 的 `mcp list` 实测约 5 秒),没有记录时重跑;
16
16
  * 预检 / 写配置的结果按同一判据带上 `authorize` 能力,页面只在能力存在时才调用 agent/authorize。
17
17
  *
18
+ * 会话令牌(change acp-session-m2-token,契约 21,规则在 session-token.ts):claude / codex 的预检结果带 `m2Session`,每次给出时现读
19
+ * 令牌文件、不进预检缓存;`launch` 带 `session` 时由 session-token.ts 核对文件与账号并给出这个进程要加的环境 / 行改写。
20
+ *
18
21
  * 预检缓存(change bridge-precheck-speedup,契约 18,规则在 precheck-cache.ts):带连接 id 的预检是页面的 `agent/precheck`,
19
22
  * 每条连接第一次查某家可以用缓存;不带的(授权结束后的重查)一律真跑并写回。写配置、授权在**开始运行之前**作废这家的缓存 ——
20
23
  * 前置条件不满足而什么都没运行时不作废。缓存存不带能力的原始结果,给出时再按当前判据加 `authorize`。
@@ -92,11 +95,21 @@ export function createAgentService(deps) {
92
95
  return check;
93
96
  return { ...check, authorize };
94
97
  }
95
- const withCapabilities = (result) => ({
96
- ...result,
97
- login: withCapability(result.login, result.agent, 'login'),
98
- m2: withCapability(result.m2, result.agent, 'm2'),
99
- });
98
+ /**
99
+ * 给出预检结果时加上能力与 `m2Session`。缓存只存原始结果;`m2Session` 每次现读令牌文件(客户端随时会换掉它,契约 21),
100
+ * 所以缓存命中、真跑、`precheck/updated` 推送三条路径给出的都是此刻的状态。只有 claude / codex 带。
101
+ */
102
+ const withCapabilities = (result) => {
103
+ const decorated = {
104
+ ...result,
105
+ login: withCapability(result.login, result.agent, 'login'),
106
+ m2: withCapability(result.m2, result.agent, 'm2'),
107
+ };
108
+ delete decorated.m2Session;
109
+ if (deps.sessionTokens && isAdapterAgent(result.agent))
110
+ decorated.m2Session = deps.sessionTokens.state();
111
+ return decorated;
112
+ };
100
113
  const precheckCache = new PrecheckCache({
101
114
  run: async (agent) => {
102
115
  const result = await precheckAgent(await ready(agent), precheckDeps);
@@ -174,10 +187,19 @@ export function createAgentService(deps) {
174
187
  m2: withCapability(remember(agent, await precheckM2(detected, precheckDeps)), agent, 'm2'),
175
188
  };
176
189
  },
177
- async launch(agent) {
190
+ async launch(agent, session) {
191
+ if (session && !deps.sessionTokens)
192
+ throw new BridgeFailure('m2_session_unavailable', 'this bridge does not support the M2 session token');
178
193
  const detected = await ready(agent);
179
- if (isAdapterAgent(agent))
180
- return { command: deps.nodePath, args: [deps.adapters.entryPath(agent)] };
194
+ if (isAdapterAgent(agent)) {
195
+ const spec = { command: deps.nodePath, args: [deps.adapters.entryPath(agent)] };
196
+ if (!session || !deps.sessionTokens)
197
+ return spec;
198
+ const { env, rewritePageLine, expiresAt } = deps.sessionTokens.prepare(agent, session.userId);
199
+ return { ...spec, ...(env ? { env } : {}), ...(rewritePageLine ? { rewritePageLine } : {}), m2SessionExpiresAt: expiresAt };
200
+ }
201
+ if (session)
202
+ throw new BridgeFailure('m2_session_unavailable', `the M2 session token is only for Claude and Codex, not ${agent}`);
181
203
  // 已安装的原生 agent 必有 path(detectAgent 的约定),这里只为收窄类型
182
204
  if (!detected.path)
183
205
  throw new BridgeFailure('internal', `no executable path for ${agent}`);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 会话令牌模式的本机文件读写(change acp-session-m2-token T1):读令牌文件与 agent 配置、写 helper 脚本。
3
+ * 规则在 session-token.ts;这里只做文件操作,单测用临时家目录。
4
+ *
5
+ * bridge 只写 helper(`~/.m2canvas/bin/m2-mcp-session-headers`),从不写、不删令牌文件(客户端负责,design D4)。
6
+ * helper 先写同目录临时文件(0700)再改名,读到的总是完整的旧脚本或新脚本;内容与权限都对就不写。
7
+ */
8
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ /** 读文本文件;不存在、读不了、超过 `maxBytes` 都返回 null(调用方按「没有」处理,不区分原因、不打印内容) */
11
+ export function readTextFile(path, maxBytes = 1024 * 1024) {
12
+ try {
13
+ if (statSync(path).size > maxBytes)
14
+ return null;
15
+ return readFileSync(path, 'utf8');
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function ensureHelperScript(path, content) {
22
+ try {
23
+ const stat = statSync(path);
24
+ if ((stat.mode & 0o777) === 0o700 && stat.isFile() && readFileSync(path, 'utf8') === content)
25
+ return;
26
+ }
27
+ catch {
28
+ // 不存在:照常写
29
+ }
30
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
31
+ const tmp = `${path}.${process.pid}.tmp`;
32
+ try {
33
+ writeFileSync(tmp, content, { mode: 0o700 });
34
+ chmodSync(tmp, 0o700);
35
+ renameSync(tmp, path);
36
+ }
37
+ catch (error) {
38
+ rmSync(tmp, { force: true });
39
+ throw error;
40
+ }
41
+ }