@m2canvas/connect 0.7.0 → 0.7.1
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 +1 -1
- package/dist/bridge/handoff.js +197 -0
- package/dist/bridge/main.js +20 -3
- package/dist/bridge/pairing.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ curl -fsSL https://m2ui.ai/connect/install.sh | sh -s -- --pair <value from M2>
|
|
|
56
56
|
|
|
57
57
|
- **From M2.** Click "Connect with Claude" (or Codex, or Cursor): the app opens with this command in a prompt, and you press Return. The installer puts a private Node.js and a pinned version of this package in `~/.m2canvas`, checking every download against the hashes M2 publishes; writes the launcher `~/.m2canvas/bin/m2-bridge`; and starts the bridge in the background. The page pairs by itself. Agent sessions start in `~/.m2canvas/workspace`.
|
|
58
58
|
- **Nothing starts at login.** After a restart, M2 asks the agent app to run `~/.m2canvas/bin/m2-bridge start --pair <value>` (you can run it yourself too). The launcher first updates the bridge to the version listed at `https://m2ui.ai/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
|
|
59
|
-
- **No pairing codes in the background.** A bridge started this way has no window to show a code in.
|
|
59
|
+
- **No pairing codes in the background.** A bridge started this way has no window to show a code in. When a page isn't paired yet (another browser, or the pairing expired), M2 gives it the same start command: the new bridge process sees the one already running and hands the page's pairing value to it, so the page pairs without restarting the bridge and other tabs keep their sessions. The handoff goes through `~/.m2canvas/bridge-<port>.sock` plus a one-time file in `~/.m2canvas`, so only a process that can write to that folder (your account; agent sandboxes normally can't write there) can hand a pairing value over; the file holds only the hash and is deleted right away. If that doesn't work, M2 offers to restart the bridge with `--replace`, which ends the sessions on the old one.
|
|
60
60
|
- **Sandboxes.** Codex needs its Full access permission mode: its default sandbox can't reach the network or write to your home folder. Inside the Codex or Claude command sandbox, the scripts change nothing and say what to do instead.
|
|
61
61
|
- **What it leaves alone.** macOS 13.5 or later. It doesn't use sudo, register anything to start at login, change your shell config or PATH, or touch a Node.js you installed yourself. Logs are in `~/.m2canvas/logs/`.
|
|
62
62
|
- **Uninstall** with `curl -fsSL https://m2ui.ai/connect/uninstall.sh | sh`, or "Uninstall local connection" in the Local Agent tab's ⋯ menu. It stops the bridge and removes what the installer added; `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把命令承诺交给正在运行的后台 bridge(change acp-bridge-one-time-install design D27、R11、review r3 R3-S1;`connect/local-bridge` 契约 2 delta)。
|
|
3
|
+
*
|
|
4
|
+
* 后台模式的 bridge 不出配对码,新页面只能靠启动命令里的承诺配对;原来承诺只能在启动时经 `--pair` 带进去,
|
|
5
|
+
* 换一个浏览器就得 `--replace` 重启 bridge,打断其他页面上正在跑的 agent。现在:
|
|
6
|
+
* - 后台模式的 bridge 监听 `~/.m2canvas/bridge-<port>.sock`;
|
|
7
|
+
* - 启动器拉起的新 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时,把 c 写进 `~/.m2canvas/handoff-<编号>.pending`,
|
|
8
|
+
* 经 socket 只发这个编号;bridge 读出承诺、删掉文件、挂上,再回复。交出方随后以退出码 3 退出。
|
|
9
|
+
*
|
|
10
|
+
* 跨版本接口(启动器只随重装更新,bridge 会自动更新,交出方与接收方可能是不同版本;`connect/local-bridge` 契约 14 delta):
|
|
11
|
+
* - 请求一行(不含换行最多 256 字节):`{"type":"arm","nonce":"<32 位小写十六进制>"}\n`;承诺在 `<m2 目录>/handoff-<nonce>.pending`(普通文件、属于本账号);
|
|
12
|
+
* - 回复一行:`{"ok":true,"until":<毫秒时间戳>}` 或 `{"ok":false,"error":"…"}`,回复后断开。
|
|
13
|
+
* 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
|
|
14
|
+
*
|
|
15
|
+
* 维护约束:
|
|
16
|
+
* - 门槛是「能写 `~/.m2canvas`」,而不只是「能连 socket」(R3-S1):agent 沙箱里的命令可能被放开了本机 socket,
|
|
17
|
+
* 但默认只能写工作目录与临时目录;只靠 socket 的话,它就能配上、再经 bridge 驱动沙箱外的 agent。能写这个目录的进程
|
|
18
|
+
* 本来就能配对(bridge 每次校验 token 都重读 `bridge-pairings.json`,往里加一条记录即可;在沙箱里、做不到 `--replace` 也一样),
|
|
19
|
+
* 所以不算新的攻击者类别。这条论证依赖「每次重读配对文件」:改成只在启动时读或缓存时,要重新评估这里的门槛。
|
|
20
|
+
* 不要退回「socket 上直接带承诺」,也不要放宽文件名(只认 32 位十六进制)。
|
|
21
|
+
* - socket 上不收 S、不回 token:拿 token 仍要页面在 hello 里出示 S,Origin 白名单照旧。
|
|
22
|
+
* - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
|
|
23
|
+
* - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住 bridge 或吃内存。
|
|
24
|
+
* - socket 路径受 macOS 的 sun_path 限制(104 字节含结尾 0):超出就不监听,只记日志(交接不可用,页面用 `--replace` 兜底)。
|
|
25
|
+
*/
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { chmodSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { createConnection, createServer } from 'node:net';
|
|
29
|
+
import { basename, dirname, join } from 'node:path';
|
|
30
|
+
import { isPairCommitment } from './pairing.js';
|
|
31
|
+
/** sockaddr_un.sun_path 是 104 字节,含结尾的 0 */
|
|
32
|
+
export const HANDOFF_MAX_PATH_BYTES = 103;
|
|
33
|
+
export const HANDOFF_TIMEOUT_MS = 3_000;
|
|
34
|
+
/** `{"type":"arm","nonce":"<32 字符>"}` 不到 60 字节,留足余量 */
|
|
35
|
+
const MAX_LINE_BYTES = 256;
|
|
36
|
+
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
37
|
+
/** 承诺文件只放 22 个字符(可能带换行) */
|
|
38
|
+
const MAX_PENDING_BYTES = 64;
|
|
39
|
+
export function handoffSocketPath(dir, port) {
|
|
40
|
+
return join(dir, `bridge-${port}.sock`);
|
|
41
|
+
}
|
|
42
|
+
function pendingPath(dir, nonce) {
|
|
43
|
+
return join(dir, `handoff-${nonce}.pending`);
|
|
44
|
+
}
|
|
45
|
+
/** 删不掉(例如被换成了目录)只当没删:这里的异常没人接,抛出会让 bridge 退出(契约 5,r3 复核 R3C-N1) */
|
|
46
|
+
function removeQuietly(path) {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(path, { force: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// 留下的残留由卸载脚本或下一次同名交接处理
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function errorText(error) {
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
function requestedNonce(line) {
|
|
58
|
+
try {
|
|
59
|
+
const request = JSON.parse(line);
|
|
60
|
+
if (typeof request !== 'object' || request === null)
|
|
61
|
+
return null;
|
|
62
|
+
const { type, nonce } = request;
|
|
63
|
+
return type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce) ? nonce : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** 读出交出方写的承诺并删掉文件;不是本账号的普通文件、内容不对都当没有(不跟随符号链接) */
|
|
70
|
+
function takePendingCommitment(dir, nonce) {
|
|
71
|
+
const path = pendingPath(dir, nonce);
|
|
72
|
+
// 编号的正则已经挡住了路径分隔符;这里再按路径结构核一次,正则被改松时也不会读到目录外(r3 复核 R3C-S1)
|
|
73
|
+
if (dirname(path) !== join(dir) || basename(path) !== `handoff-${nonce}.pending`)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const info = lstatSync(path);
|
|
77
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : info.uid;
|
|
78
|
+
if (!info.isFile() || info.uid !== uid || info.size > MAX_PENDING_BYTES)
|
|
79
|
+
return null;
|
|
80
|
+
const commitment = readFileSync(path, 'utf8').trim();
|
|
81
|
+
return isPairCommitment(commitment) ? commitment : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
removeQuietly(path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 后台模式的 bridge 在拿到端口之后调用:同名的 socket 残留(上次没清干净)先删掉 —— 端口在自己手里,这个端口的 socket 不可能属于别人。
|
|
92
|
+
* 返回 null = 没监听(路径太长或建不起来),只记日志,不影响 bridge 本身。
|
|
93
|
+
*/
|
|
94
|
+
export async function listenForHandoff(options) {
|
|
95
|
+
const { dir, port, arm, log } = options;
|
|
96
|
+
const path = handoffSocketPath(dir, port);
|
|
97
|
+
if (Buffer.byteLength(path) > HANDOFF_MAX_PATH_BYTES) {
|
|
98
|
+
log(`Pairing handoff is unavailable: ${path} is too long for a local socket. A new browser has to use the reconnect command instead.`);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const server = createServer((socket) => {
|
|
102
|
+
const deadline = setTimeout(() => socket.destroy(), HANDOFF_TIMEOUT_MS);
|
|
103
|
+
socket.on('close', () => clearTimeout(deadline));
|
|
104
|
+
socket.on('error', () => undefined);
|
|
105
|
+
let buffer = '';
|
|
106
|
+
let done = false;
|
|
107
|
+
socket.on('data', (chunk) => {
|
|
108
|
+
// 只处理第一行:之后再发什么都丢掉(不暂停读取,对方断开时这边也跟着关)
|
|
109
|
+
if (done)
|
|
110
|
+
return;
|
|
111
|
+
buffer += chunk.toString('utf8');
|
|
112
|
+
const end = buffer.indexOf('\n');
|
|
113
|
+
if (Buffer.byteLength(end < 0 ? buffer : buffer.slice(0, end)) > MAX_LINE_BYTES) {
|
|
114
|
+
socket.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (end < 0)
|
|
118
|
+
return;
|
|
119
|
+
done = true;
|
|
120
|
+
const nonce = requestedNonce(buffer.slice(0, end));
|
|
121
|
+
const commitment = nonce ? takePendingCommitment(dir, nonce) : null;
|
|
122
|
+
if (!commitment) {
|
|
123
|
+
socket.end(`${JSON.stringify({ ok: false, error: nonce ? 'not_found' : 'bad_request' })}\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const until = arm(commitment);
|
|
127
|
+
log('Armed a pairing value received over the local handoff socket; the page that holds it can pair now.');
|
|
128
|
+
socket.end(`${JSON.stringify({ ok: true, until })}\n`);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
rmSync(path, { force: true });
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
server.once('error', reject);
|
|
135
|
+
server.listen(path, () => resolve());
|
|
136
|
+
});
|
|
137
|
+
chmodSync(path, 0o600);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
server.close();
|
|
141
|
+
log(`Pairing handoff is unavailable (${errorText(error)}). A new browser has to use the reconnect command instead.`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
close() {
|
|
146
|
+
server.close();
|
|
147
|
+
rmSync(path, { force: true });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 新的 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时调用:把承诺写进一次性文件,经 socket 发编号。
|
|
153
|
+
* true = 对方确认挂上了;没人在听、对方拒绝、超时都是 false(调用方照常以退出码 3 退出,只是新页面配不上)。文件无论如何都删掉。
|
|
154
|
+
*/
|
|
155
|
+
export function handOffCommitment(options) {
|
|
156
|
+
const { dir, port, commitment, timeoutMs = HANDOFF_TIMEOUT_MS } = options;
|
|
157
|
+
if (!isPairCommitment(commitment))
|
|
158
|
+
return Promise.resolve(false);
|
|
159
|
+
const nonce = randomBytes(16).toString('hex');
|
|
160
|
+
const file = pendingPath(dir, nonce);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(file, commitment, { mode: 0o600, flag: 'wx' });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return Promise.resolve(false);
|
|
166
|
+
}
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
let reply = '';
|
|
169
|
+
let settled = false;
|
|
170
|
+
const socket = createConnection(handoffSocketPath(dir, port));
|
|
171
|
+
const finish = (ok) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
socket.destroy();
|
|
177
|
+
resolve(ok);
|
|
178
|
+
};
|
|
179
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
180
|
+
socket.on('error', () => finish(false));
|
|
181
|
+
socket.on('data', (chunk) => {
|
|
182
|
+
reply += chunk.toString('utf8');
|
|
183
|
+
if (reply.length > MAX_LINE_BYTES)
|
|
184
|
+
finish(false);
|
|
185
|
+
});
|
|
186
|
+
socket.on('end', () => {
|
|
187
|
+
try {
|
|
188
|
+
const answer = JSON.parse(reply.trim());
|
|
189
|
+
finish(typeof answer === 'object' && answer !== null && answer.ok === true);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
finish(false);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'arm', nonce })}\n`));
|
|
196
|
+
}).finally(() => removeQuietly(file));
|
|
197
|
+
}
|
package/dist/bridge/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { EXPECT_PATH } from './authorize.js';
|
|
|
20
20
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
21
21
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
22
22
|
import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
|
|
23
|
+
import { handOffCommitment, listenForHandoff } from './handoff.js';
|
|
23
24
|
import { installShutdownSignals } from './lifecycle.js';
|
|
24
25
|
import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
|
|
25
26
|
import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
|
|
@@ -86,8 +87,9 @@ your browser.
|
|
|
86
87
|
|
|
87
88
|
Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
|
|
88
89
|
--background run without a terminal: no pairing codes are shown; exits with
|
|
89
|
-
code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port
|
|
90
|
-
something else holds the port; with --dev it
|
|
90
|
+
code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port (handing its --pair
|
|
91
|
+
to that bridge first), ${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it
|
|
92
|
+
stops by itself after 2 hours
|
|
91
93
|
--check-update update this installation to the version M2 publishes, then exit
|
|
92
94
|
--self-test check that this installation loads completely, print its version and exit
|
|
93
95
|
--stop stop the M2 bridge running on the port (only one it can recognize; it's
|
|
@@ -326,7 +328,12 @@ export async function runBridge(argv) {
|
|
|
326
328
|
alreadyRunning = true;
|
|
327
329
|
},
|
|
328
330
|
});
|
|
329
|
-
// listenWithReplace 返回 null
|
|
331
|
+
// listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开。
|
|
332
|
+
// 已在运行时先把承诺交给它,新页面不用重启 bridge 就能配对(D27);交不过去也照常退出码 3
|
|
333
|
+
if (!server && parsed.background && alreadyRunning && parsed.pair) {
|
|
334
|
+
const handed = await handOffCommitment({ dir: m2Dir, port: parsed.port, commitment: parsed.pair });
|
|
335
|
+
log(handed ? 'Handed the pairing to the M2 bridge already running on this port; the new page pairs with it.' : "Couldn't hand the pairing to the M2 bridge already running on this port; the new page can reconnect with --replace.");
|
|
336
|
+
}
|
|
330
337
|
if (!server)
|
|
331
338
|
return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
|
|
332
339
|
const running = server;
|
|
@@ -337,6 +344,8 @@ export async function runBridge(argv) {
|
|
|
337
344
|
catch (error) {
|
|
338
345
|
console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
|
|
339
346
|
}
|
|
347
|
+
// 交接 socket(D27)在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
|
|
348
|
+
let handoff = null;
|
|
340
349
|
// 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
|
|
341
350
|
// 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
|
|
342
351
|
process.on('exit', () => {
|
|
@@ -347,6 +356,12 @@ export async function runBridge(argv) {
|
|
|
347
356
|
catch {
|
|
348
357
|
// 见上
|
|
349
358
|
}
|
|
359
|
+
try {
|
|
360
|
+
handoff?.close();
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// 残留的 socket 文件由下一次启动删掉
|
|
364
|
+
}
|
|
350
365
|
running.authorizations.killAllNow();
|
|
351
366
|
running.processes.killAllNow();
|
|
352
367
|
killActiveExecs();
|
|
@@ -383,6 +398,8 @@ export async function runBridge(argv) {
|
|
|
383
398
|
process.exit(130);
|
|
384
399
|
},
|
|
385
400
|
});
|
|
401
|
+
if (parsed.background)
|
|
402
|
+
handoff = await listenForHandoff({ dir: m2Dir, port: parsed.port, arm: (c) => pairing.armCommitment(c), log });
|
|
386
403
|
const lifetime = backgroundLifetimeMs(parsed);
|
|
387
404
|
if (lifetime !== null) {
|
|
388
405
|
setTimeout(() => {
|
package/dist/bridge/pairing.js
CHANGED
|
@@ -248,7 +248,10 @@ export class PairingManager {
|
|
|
248
248
|
this.store.save(records.slice(0, MAX_PAIRINGS));
|
|
249
249
|
return token;
|
|
250
250
|
}
|
|
251
|
-
/**
|
|
251
|
+
/**
|
|
252
|
+
* 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定)。
|
|
253
|
+
* 每次都从存储读(不缓存):交接 socket 的门槛论证依赖这一点(`handoff.ts` 文件头),改成缓存时要一起重新评估。
|
|
254
|
+
*/
|
|
252
255
|
liveRecords() {
|
|
253
256
|
const all = this.store.load();
|
|
254
257
|
const live = all.filter((record) => this.now() - Date.parse(record.lastUsedAt) <= TOKEN_IDLE_MS);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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": {
|