@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.
- package/README.md +10 -27
- package/dist/bridge/args.js +30 -36
- package/dist/bridge/call-dispatch.js +36 -16
- package/dist/bridge/install-layout.js +2 -1
- package/dist/bridge/main.js +80 -92
- package/dist/bridge/processes.js +37 -6
- package/dist/bridge/protocol.js +11 -3
- package/dist/bridge/security.js +17 -27
- package/dist/bridge/server.js +49 -70
- package/dist/bridge/service.js +30 -8
- package/dist/bridge/session-token-io.js +41 -0
- package/dist/bridge/session-token.js +332 -0
- 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/bridge/update.js +21 -1
- package/dist/cli.js +2 -1
- package/install/uninstall.sh +3 -2
- 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/processes.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
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
|
|
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.
|
|
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) {
|
package/dist/bridge/protocol.js
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
|
|
12
12
|
* - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单、会话范围与无人值守模式检查(acp-guard.ts),
|
|
13
13
|
* 翻译全部在页面做。
|
|
14
|
-
* -
|
|
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
|
};
|
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,21 @@
|
|
|
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、`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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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}
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
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, {
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
351
|
+
if (isReady(conn))
|
|
373
352
|
send(conn, { type: 'event', event, data });
|
|
374
353
|
},
|
|
375
354
|
close() {
|
package/dist/bridge/service.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
+
}
|