@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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话令牌模式(change acp-session-m2-token D6 / D7 / T1 / T2;`connect/local-bridge` 契约 3 / 21)。
|
|
3
|
+
*
|
|
4
|
+
* M2 客户端主进程替已登录用户领一张 24 小时的 M2 会话令牌,写进 `~/.m2canvas/mcp-session/headers.json`(内容就是
|
|
5
|
+
* `{"Authorization":"Bearer m2st_…"}`,0600)。bridge 只读、不写不删这个文件;经 1a 客户端认证的连接在 `process/start`
|
|
6
|
+
* 带上 `m2Session: { userId }` 时,bridge 给这个 agent 进程加一条名为 `patch_session`、地址由 bridge 决定的 MCP 条目:
|
|
7
|
+
* - Claude:转发页面的 `session/new` / `session/load` / `session/resume` 时补 `_meta.claudeCode.options`
|
|
8
|
+
* (`mcpServers` + `settings.deniedMcpServers`)。**三个入口一律补**:漏一个,该进程就没有注入的 M2,而且用户自己的
|
|
9
|
+
* M2 条目不再被屏蔽(evidence T16)。页面自己发的 `_meta` 仍由 acp-guard.ts 先拒(这里只处理已经过检查的行)。
|
|
10
|
+
* - Codex:拉起适配器时设环境变量 `CODEX_CONFIG`(嵌套写法,evidence C4 / C9)。
|
|
11
|
+
* 令牌不进任何进程的参数与环境变量(macOS 的 /bin/ps 是 setuid root,本机任何账号都能看到全部参数,evidence T1):
|
|
12
|
+
* 两家都经 helper 读文件。helper 是 bridge 写好的固定脚本(T1:Codex 只接受一个字符串、npm 包里的脚本装到本机没有执行位),
|
|
13
|
+
* 内容只有一行 `exec /bin/cat '<令牌文件>'`,不带参数。参数、环境、`_meta`、`CODEX_CONFIG` 里只有地址、脚本路径与屏蔽名单。
|
|
14
|
+
*
|
|
15
|
+
* 屏蔽名单:只对本进程生效、不写任何配置文件。按 MCP 主机认条目(`isM2Url`,与 connect 写配置同一判据),不看名字。
|
|
16
|
+
* - Claude 按名字屏蔽(按地址会连注入那条一起挡住,evidence E-e);屏蔽不存在的名字没有副作用,所以多读几处:
|
|
17
|
+
* `~/.claude.json`(设了 `CLAUDE_CONFIG_DIR` 时另读那里的)的用户级与本目录级、会话目录的 `.mcp.json`。
|
|
18
|
+
* - Codex 用 `{ enabled: false }`,只列它真会读到的条目:给不存在的条目只写 `enabled` 会让整个配置加载失败(T2 实测)。
|
|
19
|
+
* 只读 `$CODEX_HOME/config.toml`,用户已有任何名为 `patch_session` 的条目时不进会话令牌模式(深合并会混成一条)。
|
|
20
|
+
* 漏掉的条目(例如 claude.ai 网页里加的连接器)会与注入那条并存:页面的身份核对(面板契约 8–10)判 mismatch 并锁住进程。
|
|
21
|
+
*
|
|
22
|
+
* 维护约束:
|
|
23
|
+
* - 日志与错误里不得出现令牌或文件内容;只说状态、原因与到期时间。
|
|
24
|
+
* - 升级 Claude 适配器时(契约 7)复核:`_meta.claudeCode.options` 的 `mcpServers` / `settings` 仍并入 SDK 选项、
|
|
25
|
+
* `headersHelper` 原样进 `--mcp-config`、`--settings` 只出现一次(evidence T15)。升级 Codex 时复核 `CODEX_CONFIG`
|
|
26
|
+
* 在页面不传 `mcpServers` 时仍原样生效、嵌套 `enabled = false` 只关该条、`http_headers_helper` 仍是一个字符串。
|
|
27
|
+
*/
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { isM2Url } from '../config-files.js';
|
|
30
|
+
import { isSafeAuthEntry } from './authorize.js';
|
|
31
|
+
import { BridgeFailure } from './errors.js';
|
|
32
|
+
/** 注入条目的名字(D7)。不与 connect 写入名 `patch` 相同:Codex 同名是按键深合并,Claude 同名时 helper 与已存 OAuth 谁优先未测 */
|
|
33
|
+
export const SESSION_ENTRY = 'patch_session';
|
|
34
|
+
/** 剩余有效期不足它就报 `expiring`、不进会话令牌模式(D6):客户端每 4 小时刷新文件,正常不会低于 20 小时 */
|
|
35
|
+
export const SESSION_READY_MIN_REMAINING_MS = 6 * 3600 * 1000;
|
|
36
|
+
/** 令牌文件的大小上限:正常约 350 字节 */
|
|
37
|
+
const MAX_HEADERS_FILE_BYTES = 4096;
|
|
38
|
+
const TOKEN_TYP = 'm2-mcp-session';
|
|
39
|
+
/** helper 路径能原样交给 shell 执行或直接执行的字符(T1):家目录名带空格等时不进会话令牌模式 */
|
|
40
|
+
const SAFE_HELPER_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
41
|
+
/** 页面传来的 userId:Supabase 用户 id 是 uuid;放宽到常见 id 字符,只用来比对 */
|
|
42
|
+
const USER_ID = /^[A-Za-z0-9-]{1,128}$/;
|
|
43
|
+
/** Claude 适配器会读 `_meta.claudeCode.options` 的会话方法(D6;页面能发的会话类方法里只有这三个会建会话) */
|
|
44
|
+
export const SESSION_INJECT_METHODS = ['session/new', 'session/load', 'session/resume'];
|
|
45
|
+
export function sessionPaths(home) {
|
|
46
|
+
return {
|
|
47
|
+
headersFile: join(home, '.m2canvas', 'mcp-session', 'headers.json'),
|
|
48
|
+
helper: join(home, '.m2canvas', 'bin', 'm2-mcp-session-headers'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function isUserId(value) {
|
|
52
|
+
return typeof value === 'string' && USER_ID.test(value);
|
|
53
|
+
}
|
|
54
|
+
function isObject(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 读令牌文件的状态(不验签:bridge 没有密钥,只看账号与剩余有效期;签名由服务端验)。
|
|
59
|
+
* 任何读不了、格式不对、已过期都按 `missing`:页面退回 OAuth 路径并说明原因(D8)。
|
|
60
|
+
*/
|
|
61
|
+
export function readSessionFile(read, headersFile, nowMs) {
|
|
62
|
+
const text = read(headersFile);
|
|
63
|
+
if (text === null || text.length > MAX_HEADERS_FILE_BYTES)
|
|
64
|
+
return { state: 'missing' };
|
|
65
|
+
let headers;
|
|
66
|
+
try {
|
|
67
|
+
headers = JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { state: 'missing' };
|
|
71
|
+
}
|
|
72
|
+
if (!isObject(headers) || Object.keys(headers).length !== 1 || typeof headers.Authorization !== 'string')
|
|
73
|
+
return { state: 'missing' };
|
|
74
|
+
const match = /^Bearer m2st_([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]{43}$/.exec(headers.Authorization);
|
|
75
|
+
if (!match)
|
|
76
|
+
return { state: 'missing' };
|
|
77
|
+
let payload;
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8'));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { state: 'missing' };
|
|
83
|
+
}
|
|
84
|
+
if (!isObject(payload) || payload.typ !== TOKEN_TYP || !isUserId(payload.sub) || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
|
|
85
|
+
return { state: 'missing' };
|
|
86
|
+
}
|
|
87
|
+
const expiresAt = payload.exp * 1000;
|
|
88
|
+
const remaining = expiresAt - nowMs;
|
|
89
|
+
if (remaining <= 0)
|
|
90
|
+
return { state: 'missing' };
|
|
91
|
+
return { state: remaining >= SESSION_READY_MIN_REMAINING_MS ? 'ready' : 'expiring', sub: payload.sub, expiresAt };
|
|
92
|
+
}
|
|
93
|
+
export function isSafeHelperPath(path) {
|
|
94
|
+
return SAFE_HELPER_PATH.test(path);
|
|
95
|
+
}
|
|
96
|
+
/** helper 脚本的全文:不带参数、不读环境变量,只把令牌文件原样输出(文件就是 helper 要输出的 JSON 请求头) */
|
|
97
|
+
export function helperScript(headersFile) {
|
|
98
|
+
return `#!/bin/sh\n# M2 local bridge (change acp-session-m2-token): prints the headers of the injected ${SESSION_ENTRY} MCP server.\nexec /bin/cat '${headersFile}'\n`;
|
|
99
|
+
}
|
|
100
|
+
// ───────────────────────────── 屏蔽名单 ─────────────────────────────
|
|
101
|
+
/** 字符串里出现的 http(s) 地址有没有指向 M2(stdio 包装写法把地址放在 args / command 里) */
|
|
102
|
+
function mentionsM2(value) {
|
|
103
|
+
if (typeof value !== 'string')
|
|
104
|
+
return false;
|
|
105
|
+
for (const found of value.match(/https?:\/\/[^\s"'`,\]]+/g) ?? [])
|
|
106
|
+
if (isM2Url(found))
|
|
107
|
+
return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
/** Claude / `.mcp.json` 形状的一条 server 是否指向 M2:url / serverUrl,或 stdio 包装的 command / args 里带 M2 地址 */
|
|
111
|
+
export function claudeEntryPointsToM2(entry) {
|
|
112
|
+
if (!isObject(entry))
|
|
113
|
+
return false;
|
|
114
|
+
if (isM2Url(entry.url) || isM2Url(entry.serverUrl))
|
|
115
|
+
return true;
|
|
116
|
+
if (mentionsM2(entry.command))
|
|
117
|
+
return true;
|
|
118
|
+
return Array.isArray(entry.args) && entry.args.some(mentionsM2);
|
|
119
|
+
}
|
|
120
|
+
function namesIn(servers) {
|
|
121
|
+
if (!isObject(servers))
|
|
122
|
+
return [];
|
|
123
|
+
return Object.entries(servers).filter(([, entry]) => claudeEntryPointsToM2(entry)).map(([name]) => name);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Claude 的 server 名只能是字母、数字、`-`、`_`(随包 claude 2.1.284 的校验文案「Server name can only contain letters, numbers, hyphens,
|
|
127
|
+
* and underscores」):屏蔽名单里混进不合规的名字,整份 `--settings` 可能被拒、一条都屏蔽不了,所以 Claude 只收这种名字。
|
|
128
|
+
*/
|
|
129
|
+
const CLAUDE_SERVER_NAME = /^[A-Za-z0-9_-]{1,128}$/;
|
|
130
|
+
function finish(found, valid = isSafeAuthEntry) {
|
|
131
|
+
const names = [];
|
|
132
|
+
const skipped = [];
|
|
133
|
+
for (const name of found) {
|
|
134
|
+
if (name === SESSION_ENTRY || names.includes(name) || skipped.includes(name))
|
|
135
|
+
continue;
|
|
136
|
+
if (valid(name))
|
|
137
|
+
names.push(name);
|
|
138
|
+
else
|
|
139
|
+
skipped.push(name);
|
|
140
|
+
}
|
|
141
|
+
return { names, skipped };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Claude 会读到的、指向 M2 的条目名(D7)。读不了的文件跳过:屏蔽名单是尽力而为,漏掉的由身份核对兜底;
|
|
145
|
+
* 注入的 `patch_session` 不进名单(Claude 按名字屏蔽,会连注入那条一起挡住,D7 第 3 条);用户自己恰好有同名条目时以注入那条为准
|
|
146
|
+
* (evidence 验证点 3「E-同名」:`--mcp-config` 的同名条目整条盖过用户配置,`_meta` 走的也是 `--mcp-config`,T15)。
|
|
147
|
+
*/
|
|
148
|
+
export function claudeM2EntryNames(readJsonFile, options) {
|
|
149
|
+
const found = [];
|
|
150
|
+
const configs = [join(options.home, '.claude.json')];
|
|
151
|
+
if (options.claudeConfigDir)
|
|
152
|
+
configs.push(join(options.claudeConfigDir, '.claude.json'));
|
|
153
|
+
for (const path of configs) {
|
|
154
|
+
const config = readJsonFile(path);
|
|
155
|
+
if (!isObject(config))
|
|
156
|
+
continue;
|
|
157
|
+
found.push(...namesIn(config.mcpServers));
|
|
158
|
+
const project = isObject(config.projects) ? config.projects[options.cwd] : undefined;
|
|
159
|
+
if (isObject(project))
|
|
160
|
+
found.push(...namesIn(project.mcpServers));
|
|
161
|
+
}
|
|
162
|
+
const local = readJsonFile(join(options.cwd, '.mcp.json'));
|
|
163
|
+
if (isObject(local))
|
|
164
|
+
found.push(...namesIn(local.mcpServers));
|
|
165
|
+
return finish(found, (name) => CLAUDE_SERVER_NAME.test(name));
|
|
166
|
+
}
|
|
167
|
+
const BARE_OR_QUOTED = String.raw `(?:"([^"\\]+)"|'([^']+)'|([A-Za-z0-9_-]+))`;
|
|
168
|
+
const MULTILINE_QUOTES = ['"""', "'''"];
|
|
169
|
+
const nameOf = (match) => (match[1] ?? match[2] ?? match[3]);
|
|
170
|
+
const SECTION = new RegExp(String.raw `^\[\s*mcp_servers\.${BARE_OR_QUOTED}\s*\]\s*(?:#.*)?$`);
|
|
171
|
+
const SERVERS_TABLE = /^\[\s*mcp_servers\s*\]\s*(?:#.*)?$/;
|
|
172
|
+
/** server 的子表(`[mcp_servers.<名>.env]` 等):说明这个名字的条目存在(同名冲突要认它),子表里的行不算 server 本身的字段 */
|
|
173
|
+
const SUB_SECTION = new RegExp(String.raw `^\[\s*mcp_servers\.${BARE_OR_QUOTED}\s*\.`);
|
|
174
|
+
const INLINE_ENTRY = new RegExp(String.raw `^${BARE_OR_QUOTED}\s*=\s*\{(.*)\}\s*(?:#.*)?$`);
|
|
175
|
+
/** `[mcp_servers]` 段里的点路径(`m2.url = "…"`):认它的名字(冲突判断与指向 M2 都按它) */
|
|
176
|
+
const SERVERS_DOTTED = new RegExp(String.raw `^${BARE_OR_QUOTED}\.[A-Za-z0-9_]+\s*=(.*)$`);
|
|
177
|
+
const ROOT_DOTTED = new RegExp(String.raw `^mcp_servers\.${BARE_OR_QUOTED}\s*(?:\.|=)(.*)$`);
|
|
178
|
+
/**
|
|
179
|
+
* Codex `config.toml` 里指向 M2 的条目名(T2)。不引 TOML 解析器,只认三种写法:`[mcp_servers.<名>]` 段(段内任何一行带 M2 地址
|
|
180
|
+
* 都算,覆盖 url 与 stdio 包装的 command / args,含跨行数组)、`[mcp_servers]` 段里的行内表、文件开头的点路径。
|
|
181
|
+
* 认不出的写法不列(不列只是两条并存,列错了会让 Codex 整个配置加载失败),名字一律过 `isSafeAuthEntry`。
|
|
182
|
+
* 多行字符串(`"""` / `'''`)里的行整段跳过:里面像段头的行不能被当成条目(列出不存在的条目会让 Codex 整个配置加载失败,review r1 S1)。
|
|
183
|
+
* 已知不认的写法(很少见,tasks CO-10):文件开头的 `mcp_servers = { … }` 整表行内写法、跨行的行内表 —— 只会漏屏蔽 / 漏判同名
|
|
184
|
+
* (漏屏蔽由身份核对兜底),不会列出不存在的条目。
|
|
185
|
+
*/
|
|
186
|
+
export function codexM2Entries(toml) {
|
|
187
|
+
const found = [];
|
|
188
|
+
const all = new Set();
|
|
189
|
+
/** null = 文件开头(还没有任何表头);'servers' = `[mcp_servers]`;其余 = 某个 server 段的名字;'' = 别的表 */
|
|
190
|
+
let table = null;
|
|
191
|
+
/** 正在哪种多行字符串里(null = 不在):里面的行是字符串内容,整行跳过 */
|
|
192
|
+
let inMultiline = null;
|
|
193
|
+
for (const raw of (toml ?? '').split(/\r?\n/)) {
|
|
194
|
+
if (inMultiline !== null) {
|
|
195
|
+
if (raw.split(inMultiline).length % 2 === 0)
|
|
196
|
+
inMultiline = null;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const line = raw.trim();
|
|
200
|
+
if (line === '' || line.startsWith('#'))
|
|
201
|
+
continue;
|
|
202
|
+
// 这一行打开了多行字符串、却没在本行关上:本行照常看(键在这一行),之后的行跳过
|
|
203
|
+
for (const quote of MULTILINE_QUOTES)
|
|
204
|
+
if (line.split(quote).length % 2 === 0)
|
|
205
|
+
inMultiline = quote;
|
|
206
|
+
if (line.startsWith('[')) {
|
|
207
|
+
const section = SECTION.exec(line);
|
|
208
|
+
const sub = section ? null : SUB_SECTION.exec(line);
|
|
209
|
+
if (section) {
|
|
210
|
+
table = nameOf(section);
|
|
211
|
+
all.add(table);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
if (sub)
|
|
215
|
+
all.add(nameOf(sub));
|
|
216
|
+
table = SERVERS_TABLE.test(line) ? 'servers' : '';
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (table === null) {
|
|
221
|
+
const dotted = ROOT_DOTTED.exec(line);
|
|
222
|
+
if (dotted) {
|
|
223
|
+
const name = nameOf(dotted);
|
|
224
|
+
all.add(name);
|
|
225
|
+
if (mentionsM2(dotted[4]))
|
|
226
|
+
found.push(name);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (table === 'servers') {
|
|
231
|
+
const entry = INLINE_ENTRY.exec(line) ?? SERVERS_DOTTED.exec(line);
|
|
232
|
+
if (entry) {
|
|
233
|
+
const name = nameOf(entry);
|
|
234
|
+
all.add(name);
|
|
235
|
+
if (mentionsM2(entry[4]))
|
|
236
|
+
found.push(name);
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (table !== '' && mentionsM2(line))
|
|
241
|
+
found.push(table);
|
|
242
|
+
}
|
|
243
|
+
return { ...finish(found), conflict: all.has(SESSION_ENTRY) };
|
|
244
|
+
}
|
|
245
|
+
// ───────────────────────────── 注入 ─────────────────────────────
|
|
246
|
+
/** Claude:补进 `session/new` / `load` / `resume` 的 `_meta`(D6,evidence T15 的形状) */
|
|
247
|
+
export function claudeSessionMeta(mcpUrl, helper, denied) {
|
|
248
|
+
const options = { mcpServers: { [SESSION_ENTRY]: { type: 'http', url: mcpUrl, headersHelper: helper } } };
|
|
249
|
+
if (denied.length > 0)
|
|
250
|
+
options.settings = { deniedMcpServers: denied.map((serverName) => ({ serverName })) };
|
|
251
|
+
return { claudeCode: { options } };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 给页面发给 Claude 进程、已经过 acp-guard 检查的一行补 `_meta`:只改 `SESSION_INJECT_METHODS` 三个请求,其余原样。
|
|
255
|
+
* acp-guard 已保证这三个请求的 params 是对象且不带 `_meta`;万一不是对象就原样转发(agent 会自己报错),不在这里编造。
|
|
256
|
+
*/
|
|
257
|
+
export function withClaudeSessionMeta(line, meta) {
|
|
258
|
+
let message;
|
|
259
|
+
try {
|
|
260
|
+
message = JSON.parse(line);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return line;
|
|
264
|
+
}
|
|
265
|
+
if (!isObject(message) || typeof message.method !== 'string' || !SESSION_INJECT_METHODS.includes(message.method) || !isObject(message.params))
|
|
266
|
+
return line;
|
|
267
|
+
return JSON.stringify({ ...message, params: { ...message.params, _meta: meta } });
|
|
268
|
+
}
|
|
269
|
+
/** Codex:拉起适配器时的 `CODEX_CONFIG`(嵌套写法;点路径写法无效,evidence C2 / C4) */
|
|
270
|
+
export function codexSessionConfig(mcpUrl, helper, disabled) {
|
|
271
|
+
const servers = { [SESSION_ENTRY]: { url: mcpUrl, http_headers_helper: helper } };
|
|
272
|
+
for (const name of disabled)
|
|
273
|
+
servers[name] = { enabled: false };
|
|
274
|
+
return JSON.stringify({ mcp_servers: servers });
|
|
275
|
+
}
|
|
276
|
+
function unavailable(message) {
|
|
277
|
+
return new BridgeFailure('m2_session_unavailable', message);
|
|
278
|
+
}
|
|
279
|
+
export function createSessionTokens(deps) {
|
|
280
|
+
const paths = sessionPaths(deps.home);
|
|
281
|
+
const readJsonFile = (path) => {
|
|
282
|
+
const text = deps.readText(path);
|
|
283
|
+
if (text === null)
|
|
284
|
+
return null;
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(text);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
deps.log(`Could not read ${path} while listing the M2 entries to hide for this session; skipping it.`);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const logSkipped = (agent, skipped) => {
|
|
294
|
+
if (skipped.length > 0)
|
|
295
|
+
deps.log(`${skipped.length} M2 entr${skipped.length === 1 ? 'y' : 'ies'} in ${agent}'s config can't be hidden safely (unusual name); the identity check covers it.`);
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
state() {
|
|
299
|
+
return readSessionFile(deps.readText, paths.headersFile, deps.now()).state;
|
|
300
|
+
},
|
|
301
|
+
prepare(agent, userId) {
|
|
302
|
+
if (agent !== 'claude' && agent !== 'codex')
|
|
303
|
+
throw unavailable(`the M2 session token is only for Claude and Codex, not ${agent}`);
|
|
304
|
+
const file = readSessionFile(deps.readText, paths.headersFile, deps.now());
|
|
305
|
+
if (file.state !== 'ready' || file.sub === undefined || file.expiresAt === undefined) {
|
|
306
|
+
throw unavailable(file.state === 'expiring' ? 'the M2 session token expires soon; the M2 desktop app has not refreshed it yet' : 'no usable M2 session token on this machine');
|
|
307
|
+
}
|
|
308
|
+
if (file.sub !== userId)
|
|
309
|
+
throw new BridgeFailure('m2_session_mismatch', 'the M2 session token belongs to a different account than this page');
|
|
310
|
+
if (!isSafeHelperPath(paths.helper))
|
|
311
|
+
throw unavailable('the home directory path has characters the agents cannot run a helper from');
|
|
312
|
+
try {
|
|
313
|
+
deps.ensureHelper(paths.helper, helperScript(paths.headersFile));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
throw unavailable(`could not write the M2 session helper (${error instanceof Error ? error.message : String(error)})`);
|
|
317
|
+
}
|
|
318
|
+
if (agent === 'claude') {
|
|
319
|
+
const { names, skipped } = claudeM2EntryNames(readJsonFile, { home: deps.home, claudeConfigDir: deps.agentEnv.CLAUDE_CONFIG_DIR, cwd: deps.cwd });
|
|
320
|
+
logSkipped(agent, skipped);
|
|
321
|
+
const meta = claudeSessionMeta(deps.mcpUrl, paths.helper, names);
|
|
322
|
+
return { rewritePageLine: (line) => withClaudeSessionMeta(line, meta), expiresAt: file.expiresAt };
|
|
323
|
+
}
|
|
324
|
+
const codexHome = deps.agentEnv.CODEX_HOME || join(deps.home, '.codex');
|
|
325
|
+
const { names, skipped, conflict } = codexM2Entries(deps.readText(join(codexHome, 'config.toml')));
|
|
326
|
+
if (conflict)
|
|
327
|
+
throw unavailable(`Codex's config already has an MCP server named "${SESSION_ENTRY}"`);
|
|
328
|
+
logSkipped(agent, skipped);
|
|
329
|
+
return { env: { CODEX_CONFIG: codexSessionConfig(deps.mcpUrl, paths.helper, names) }, expiresAt: file.expiresAt };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -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/bridge/update.js
CHANGED
|
@@ -17,7 +17,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
17
17
|
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { basename, dirname, join } from 'node:path';
|
|
19
19
|
import { ADAPTERS, ADAPTER_READY_MARKER, adapterInstallDirName } from './adapters.js';
|
|
20
|
-
import { NODE_PATH_FILE, connectCliPath, connectPackageDir } from './install-layout.js';
|
|
20
|
+
import { NODE_PATH_FILE, connectCliPath, connectPackageDir, installLayout } from './install-layout.js';
|
|
21
21
|
import { readInstanceRecord } from './instance.js';
|
|
22
22
|
import { tail } from './precheck-parse.js';
|
|
23
23
|
import { MANIFEST_URL, connectTarballUrls, isReleaseVersion, nodeDirName, nodeTarballUrls, parseReleaseManifest, wsTarballUrls, } from './release-manifest.js';
|
|
@@ -52,6 +52,26 @@ export function readRejected(path) {
|
|
|
52
52
|
return [];
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/** welcome 里最多带几个拉黑的版本(change bridge-normal-update D6):文件只增不减,只报最近的 */
|
|
56
|
+
export const MAX_REPORTED_REJECTED = 20;
|
|
57
|
+
/** 报给页面的拉黑名单:合法版本号、去重、保留最近的 {@link MAX_REPORTED_REJECTED} 个(按写入先后) */
|
|
58
|
+
export function recentRejected(path) {
|
|
59
|
+
const unique = [];
|
|
60
|
+
for (const version of readRejected(path).reverse()) {
|
|
61
|
+
if (!unique.includes(version))
|
|
62
|
+
unique.push(version);
|
|
63
|
+
if (unique.length === MAX_REPORTED_REJECTED)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return unique.reverse();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 交给 `startBridgeServer` 的拉黑名单来源(D6):只有后台模式(由启动器拉起,才有拉黑这回事)注入;每次 welcome 现读。
|
|
70
|
+
* 终端模式返回空对象,welcome 不带 `rejected`。
|
|
71
|
+
*/
|
|
72
|
+
export function welcomeRejectedOption({ background, home }) {
|
|
73
|
+
return background ? { rejectedVersions: () => recentRejected(installLayout(home).rejected) } : {};
|
|
74
|
+
}
|
|
55
75
|
function readNodePath(versionDir) {
|
|
56
76
|
try {
|
|
57
77
|
const value = readFileSync(join(versionDir, NODE_PATH_FILE), 'utf8').trim();
|
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/install/uninstall.sh
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
# SIGTERM, so it shuts down its agents cleanly).
|
|
8
8
|
# 2. Removes what the installer put in ~/.m2canvas: runtime, bridge, bin, logs, acp-adapters,
|
|
9
9
|
# the pairing records, the bridge state files, pairing-handoff sockets and leftover
|
|
10
|
-
# handoff files
|
|
10
|
+
# handoff files, and the M2 session token the M2 desktop app keeps for the bridge
|
|
11
|
+
# (mcp-session).
|
|
11
12
|
# 3. Removes ~/.m2canvas/workspace only if it is empty; otherwise keeps it and prints its path.
|
|
12
13
|
# Removes ~/.m2canvas itself only if nothing else is left in it.
|
|
13
14
|
# It does not touch your agents' M2 settings (set up by npx @m2canvas/connect, they work
|
|
@@ -80,7 +81,7 @@ main() {
|
|
|
80
81
|
|
|
81
82
|
# 2. Remove what the installer created, and check that it is really gone.
|
|
82
83
|
FAILED=""
|
|
83
|
-
for NAME in runtime bridge bin logs acp-adapters bridge-pairings.json; do
|
|
84
|
+
for NAME in runtime bridge bin logs acp-adapters bridge-pairings.json mcp-session; do
|
|
84
85
|
rm -rf "${M2_HOME:?}/${NAME}"
|
|
85
86
|
if [ -e "${M2_HOME}/${NAME}" ]; then
|
|
86
87
|
FAILED="${FAILED} ${M2_HOME}/${NAME}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|