@m2canvas/connect 0.4.0 → 0.6.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 +7 -6
- package/dist/bridge/adapters.js +118 -3
- package/dist/bridge/agents.js +5 -0
- package/dist/bridge/auth-call.js +68 -0
- package/dist/bridge/authorize.js +341 -0
- package/dist/bridge/env.js +12 -0
- package/dist/bridge/errors.js +9 -0
- package/dist/bridge/exec.js +53 -12
- package/dist/bridge/instance.js +239 -0
- package/dist/bridge/main.js +107 -40
- package/dist/bridge/pairing.js +76 -1
- package/dist/bridge/precheck.js +22 -6
- package/dist/bridge/protocol.js +11 -1
- package/dist/bridge/server.js +66 -21
- package/dist/bridge/service.js +103 -20
- package/package.json +1 -1
package/dist/bridge/exec.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* 超时必须真的封顶:命令可能再拉起子进程(`claude mcp list` 会启动用户配置的 stdio MCP 做健康检查,npm ci 有自己的子进程),
|
|
10
10
|
* 孙进程继承了输出管道时,'close' 要等它们全部退出才触发。所以 POSIX 下每条命令是独立进程组,超时对整组 SIGKILL 并**立即**返回,
|
|
11
11
|
* 不再等 'close'。bridge 退出时 killActiveExecs 清掉仍在运行的命令(main.ts 的退出路径调用)。
|
|
12
|
+
*
|
|
13
|
+
* startCommand 是可从外部结束的版本(授权命令:页面取消、连接关闭、10 分钟超时,见 authorize.ts);execCommand 建在它上面,
|
|
14
|
+
* 两者共用同一个 active 集合,所以 bridge 退出时授权命令也一起被清理(design D7)。
|
|
12
15
|
*/
|
|
13
16
|
import { spawn } from 'node:child_process';
|
|
14
17
|
const DEFAULT_MAX_OUTPUT = 1024 * 1024;
|
|
@@ -34,22 +37,56 @@ export function killActiveExecs() {
|
|
|
34
37
|
killTree(child);
|
|
35
38
|
active.clear();
|
|
36
39
|
}
|
|
37
|
-
|
|
40
|
+
function signalTree(child, signal) {
|
|
41
|
+
if (child.pid === undefined)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
if (GROUPS)
|
|
45
|
+
process.kill(-child.pid, signal);
|
|
46
|
+
else
|
|
47
|
+
child.kill(signal);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ESRCH:已经退出,'close' 会照常结束这条命令
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function startCommand(command, args, options) {
|
|
38
54
|
const max = options.maxOutput ?? DEFAULT_MAX_OUTPUT;
|
|
39
55
|
let stdout = '';
|
|
40
56
|
let stderr = '';
|
|
41
57
|
let settled = false;
|
|
42
|
-
let
|
|
58
|
+
let graceTimer;
|
|
43
59
|
let child;
|
|
44
|
-
|
|
60
|
+
let resolve = () => undefined;
|
|
61
|
+
const result = new Promise((done) => {
|
|
62
|
+
resolve = done;
|
|
63
|
+
});
|
|
64
|
+
const finish = (outcome, timedOut = false) => {
|
|
45
65
|
if (settled)
|
|
46
66
|
return;
|
|
47
67
|
settled = true;
|
|
48
|
-
if (
|
|
49
|
-
clearTimeout(
|
|
68
|
+
if (graceTimer)
|
|
69
|
+
clearTimeout(graceTimer);
|
|
50
70
|
if (child)
|
|
51
71
|
active.delete(child);
|
|
52
|
-
resolve({ ...
|
|
72
|
+
resolve({ ...outcome, stdout, stderr, timedOut });
|
|
73
|
+
};
|
|
74
|
+
const kill = (error, timedOut = false) => {
|
|
75
|
+
if (settled)
|
|
76
|
+
return;
|
|
77
|
+
if (child)
|
|
78
|
+
killTree(child);
|
|
79
|
+
finish({ code: null, signal: null, error }, timedOut);
|
|
80
|
+
};
|
|
81
|
+
const handle = {
|
|
82
|
+
result,
|
|
83
|
+
kill,
|
|
84
|
+
terminate(graceMs, error, timedOut = false) {
|
|
85
|
+
if (settled || !child || graceTimer)
|
|
86
|
+
return;
|
|
87
|
+
signalTree(child, 'SIGTERM');
|
|
88
|
+
graceTimer = setTimeout(() => kill(error, timedOut), graceMs);
|
|
89
|
+
},
|
|
53
90
|
};
|
|
54
91
|
try {
|
|
55
92
|
child = spawn(command, args, {
|
|
@@ -64,25 +101,29 @@ export const execCommand = (command, args, options) => new Promise((resolve) =>
|
|
|
64
101
|
catch (error) {
|
|
65
102
|
// 同步抛出只发生在参数非法时;按启动失败处理
|
|
66
103
|
finish({ code: null, signal: null, error: error instanceof Error ? error.message : String(error) });
|
|
67
|
-
return;
|
|
104
|
+
return handle;
|
|
68
105
|
}
|
|
69
106
|
const running = child;
|
|
70
107
|
active.add(running);
|
|
71
|
-
timer = setTimeout(() => {
|
|
72
|
-
killTree(running);
|
|
73
|
-
finish({ code: null, signal: null, error: `timed out after ${options.timeoutMs}ms` }, true);
|
|
74
|
-
}, options.timeoutMs);
|
|
75
108
|
running.stdout?.setEncoding('utf8');
|
|
76
109
|
running.stderr?.setEncoding('utf8');
|
|
77
110
|
running.stdout?.on('data', (chunk) => {
|
|
78
111
|
stdout = (stdout + chunk).slice(-max);
|
|
112
|
+
options.onOutput?.(chunk);
|
|
79
113
|
});
|
|
80
114
|
running.stderr?.on('data', (chunk) => {
|
|
81
115
|
stderr = (stderr + chunk).slice(-max);
|
|
116
|
+
options.onOutput?.(chunk);
|
|
82
117
|
});
|
|
83
118
|
running.on('error', (error) => finish({ code: null, signal: null, error: error.message }));
|
|
84
119
|
running.on('close', (code, signal) => finish({ code, signal, error: null }));
|
|
85
|
-
|
|
120
|
+
return handle;
|
|
121
|
+
}
|
|
122
|
+
export const execCommand = (command, args, options) => {
|
|
123
|
+
const running = startCommand(command, args, options);
|
|
124
|
+
const timer = setTimeout(() => running.kill(`timed out after ${options.timeoutMs}ms`, true), options.timeoutMs);
|
|
125
|
+
return running.result.finally(() => clearTimeout(timer));
|
|
126
|
+
};
|
|
86
127
|
/** stdout 与 stderr 合并(有些 CLI 把状态写在 stderr,例如 `codex login status`) */
|
|
87
128
|
export function combinedOutput(result) {
|
|
88
129
|
const parts = [result.stdout, result.stderr, result.error ?? ''].filter((part) => part.length > 0);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 端口被占时识别并接管正在运行的 bridge(change acp-terminal-free-auth,design D20,`connect/local-bridge` 契约 13)。
|
|
3
|
+
*
|
|
4
|
+
* 场景:页面已经连上一个 bridge、只是没配对,用户却找不到那个 bridge 的终端(在别的窗口、被别的工具在后台拉起),
|
|
5
|
+
* 拿不到配对码;新运行的命令又撞端口。这里让新命令认出占端口的是不是 M2 bridge,给出 pid 与终端,或者在 `--replace` 时接管。
|
|
6
|
+
*
|
|
7
|
+
* 维护约束:
|
|
8
|
+
* - 状态文件只放 pid / port / startedAt,**不放配对码、token、承诺**:契约 2 要求码只出现在终端,
|
|
9
|
+
* 能读 `~/.m2canvas`、又能连本机端口的进程(例如沙箱里的 agent)拿到码就能自己配对。
|
|
10
|
+
* - 「认定」要同时满足:文件端口相符、pid 存活、`ps` 算出的启动时刻与 startedAt 相差 ≤ START_TOLERANCE_MS。
|
|
11
|
+
* 崩溃留下的旧文件、被复用的 pid 都过不了这一关;认不出来就什么都不停。
|
|
12
|
+
* - 停止只发 SIGTERM(第一次走旧 bridge 的正常收尾,第二次走它「连按两次」的立即清理),**不发 SIGKILL**:
|
|
13
|
+
* SIGKILL 跳过清理,agent 都在各自的进程组里,会变成孤儿(契约 5)。
|
|
14
|
+
* - Windows 没有 `ps`,一律不认定(D1 / CO-1)。
|
|
15
|
+
* - 认不出来时只提示:用 lsof 查到占端口的 pid 与命令行给用户看,让用户自己决定停不停;这里从不停认不出来的进程。
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
/** startedAt 是进程启动时刻(不是写文件时刻),与 ps 的 etime 只差秒级取整;留 3 秒余量 */
|
|
20
|
+
export const START_TOLERANCE_MS = 3_000;
|
|
21
|
+
/** 第一次 SIGTERM 后等多久:旧 bridge 正常收尾先关连接、停 agent,再等 agent 退出(宽限 3 秒),还要留出启动横幅里检测命令的余量 */
|
|
22
|
+
export const STOP_GRACEFUL_WAIT_MS = 8_000;
|
|
23
|
+
/** 第二次 SIGTERM(立即清理)后再等多久 */
|
|
24
|
+
export const STOP_IMMEDIATE_WAIT_MS = 3_000;
|
|
25
|
+
const STOP_POLL_MS = 100;
|
|
26
|
+
const PS_PATH = '/bin/ps';
|
|
27
|
+
const PS_TIMEOUT_MS = 5_000;
|
|
28
|
+
const LSOF_PATHS = ['/usr/sbin/lsof', '/usr/bin/lsof'];
|
|
29
|
+
const COMMAND_DISPLAY_CHARS = 160;
|
|
30
|
+
export function instanceFilePath(m2Dir, port) {
|
|
31
|
+
return join(m2Dir, `bridge-${port}.json`);
|
|
32
|
+
}
|
|
33
|
+
/** 本进程的启动时刻 */
|
|
34
|
+
export function processStartedAt() {
|
|
35
|
+
return Math.round(Date.now() - process.uptime() * 1000);
|
|
36
|
+
}
|
|
37
|
+
/** 只写这四个字段:调用方多带的任何字段都不落盘(契约 2:文件里不能有配对码、token、承诺) */
|
|
38
|
+
export function writeInstanceRecord(path, record) {
|
|
39
|
+
const { pid, port, startedAt } = record;
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
41
|
+
chmodSync(dirname(path), 0o700);
|
|
42
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, pid, port, startedAt })}\n`, { mode: 0o600 });
|
|
43
|
+
chmodSync(path, 0o600);
|
|
44
|
+
}
|
|
45
|
+
function isPositiveInteger(value) {
|
|
46
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
47
|
+
}
|
|
48
|
+
export function readInstanceRecord(path) {
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
57
|
+
return null;
|
|
58
|
+
const record = parsed;
|
|
59
|
+
if (record.version !== 1 || !isPositiveInteger(record.pid) || !isPositiveInteger(record.port) || !isPositiveInteger(record.startedAt))
|
|
60
|
+
return null;
|
|
61
|
+
return { version: 1, pid: record.pid, port: record.port, startedAt: record.startedAt };
|
|
62
|
+
}
|
|
63
|
+
/** 只删属于这个 pid 的文件:接管它的新 bridge 可能已经写了自己的 */
|
|
64
|
+
export function removeInstanceRecord(path, pid) {
|
|
65
|
+
if (readInstanceRecord(path)?.pid !== pid)
|
|
66
|
+
return;
|
|
67
|
+
rmSync(path, { force: true });
|
|
68
|
+
}
|
|
69
|
+
/** `ps -o etime=` 的格式 `[[dd-]hh:]mm:ss` → 秒 */
|
|
70
|
+
export function parseEtime(value) {
|
|
71
|
+
const match = /^(?:(?:(\d+)-)?(\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
72
|
+
if (!match)
|
|
73
|
+
return null;
|
|
74
|
+
const [, days, hours, minutes, seconds] = match;
|
|
75
|
+
const [d, h, m, s] = [days, hours, minutes, seconds].map((part) => (part === undefined ? 0 : Number(part)));
|
|
76
|
+
if (m === undefined || s === undefined || s > 59 || m > 59)
|
|
77
|
+
return null;
|
|
78
|
+
return (d ?? 0) * 86_400 + (h ?? 0) * 3_600 + m * 60 + s;
|
|
79
|
+
}
|
|
80
|
+
export function createPsProbe(exec, env, now = Date.now) {
|
|
81
|
+
return {
|
|
82
|
+
async inspect(pid) {
|
|
83
|
+
const result = await exec(PS_PATH, ['-o', 'etime=,tty=', '-p', String(pid)], {
|
|
84
|
+
env: { ...env, LC_ALL: 'C' },
|
|
85
|
+
timeoutMs: PS_TIMEOUT_MS,
|
|
86
|
+
maxOutput: 1024,
|
|
87
|
+
});
|
|
88
|
+
if (result.code !== 0)
|
|
89
|
+
return null;
|
|
90
|
+
const [etime, tty] = result.stdout.trim().split(/\s+/);
|
|
91
|
+
const seconds = etime === undefined ? null : parseEtime(etime);
|
|
92
|
+
if (seconds === null)
|
|
93
|
+
return null;
|
|
94
|
+
const terminal = tty && !/^[?-]+$/.test(tty) ? tty : null;
|
|
95
|
+
return { startedAt: now() - seconds * 1000, terminal };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** 用 lsof 查占端口的进程(只读)。没有 lsof(多数 Linux 默认没装)、没有监听者、输出认不出时 null */
|
|
100
|
+
export function createLsofProbe(exec, env, exists = existsSync) {
|
|
101
|
+
return {
|
|
102
|
+
async find(port) {
|
|
103
|
+
const lsof = LSOF_PATHS.find((path) => exists(path));
|
|
104
|
+
if (!lsof)
|
|
105
|
+
return null;
|
|
106
|
+
const options = { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 4096 };
|
|
107
|
+
const listed = await exec(lsof, ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'], options);
|
|
108
|
+
const pid = Number(/^p(\d+)$/m.exec(listed.stdout)?.[1]);
|
|
109
|
+
if (listed.code !== 0 || !Number.isInteger(pid) || pid <= 0)
|
|
110
|
+
return null;
|
|
111
|
+
const ps = await exec(PS_PATH, ['-o', 'command=', '-p', String(pid)], options);
|
|
112
|
+
const full = ps.code === 0 ? ps.stdout.trim() : '';
|
|
113
|
+
const command = full.length > COMMAND_DISPLAY_CHARS ? `${full.slice(0, COMMAND_DISPLAY_CHARS - 1)}…` : full;
|
|
114
|
+
return { pid, command: command || null };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export async function identifyRunningBridge(path, port, probe, platform) {
|
|
119
|
+
if (platform === 'win32')
|
|
120
|
+
return null;
|
|
121
|
+
const record = readInstanceRecord(path);
|
|
122
|
+
if (!record || record.port !== port)
|
|
123
|
+
return null;
|
|
124
|
+
const info = await probe.inspect(record.pid);
|
|
125
|
+
if (!info || Math.abs(info.startedAt - record.startedAt) > START_TOLERANCE_MS)
|
|
126
|
+
return null;
|
|
127
|
+
return { pid: record.pid, port: record.port, startedAt: record.startedAt, terminal: info.terminal };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 本机进程的实现。存活要两步判断:`kill(pid, 0)` 报 ESRCH 就是不在了;否则(成功或 EPERM)再看 `ps` 的状态,
|
|
131
|
+
* 僵尸(Z,已退出、父进程还没回收)按已退出处理 —— 端口已经释放,而 `kill(pid, 0)` 对僵尸照样成功。
|
|
132
|
+
* 被别的工具在后台拉起、父进程不及时回收的 bridge 会停在僵尸状态(实测:测试脚本拉起的 bridge 退出后一直是 Z)。
|
|
133
|
+
*/
|
|
134
|
+
export function createStopDeps(exec, env, kill = process.kill.bind(process)) {
|
|
135
|
+
return {
|
|
136
|
+
signal: (pid, signal) => {
|
|
137
|
+
kill(pid, signal);
|
|
138
|
+
},
|
|
139
|
+
async isAlive(pid) {
|
|
140
|
+
try {
|
|
141
|
+
kill(pid, 0);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code === 'ESRCH')
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const result = await exec(PS_PATH, ['-o', 'stat=', '-p', String(pid)], { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 256 });
|
|
148
|
+
if (result.code !== 0)
|
|
149
|
+
return false;
|
|
150
|
+
return !result.stdout.trim().startsWith('Z');
|
|
151
|
+
},
|
|
152
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
153
|
+
now: Date.now,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function waitForExit(pid, deps, timeoutMs) {
|
|
157
|
+
const deadline = deps.now() + timeoutMs;
|
|
158
|
+
while (await deps.isAlive(pid)) {
|
|
159
|
+
if (deps.now() >= deadline)
|
|
160
|
+
return false;
|
|
161
|
+
await deps.sleep(STOP_POLL_MS);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
function sendTerm(pid, deps) {
|
|
166
|
+
try {
|
|
167
|
+
deps.signal(pid, 'SIGTERM');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// 不在这里判断:已经退出(ESRCH)会被随后的存活检查认成「已停止」,发不了信号(EPERM)会被认成「没停掉」
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** 停掉认定的 bridge:SIGTERM → 等正常收尾 → 再一次 SIGTERM(立即清理)→ 再等。从不 SIGKILL。返回是否已退出 */
|
|
174
|
+
export async function stopRunningBridge(pid, deps) {
|
|
175
|
+
sendTerm(pid, deps);
|
|
176
|
+
if (await waitForExit(pid, deps, STOP_GRACEFUL_WAIT_MS))
|
|
177
|
+
return true;
|
|
178
|
+
sendTerm(pid, deps);
|
|
179
|
+
return waitForExit(pid, deps, STOP_IMMEDIATE_WAIT_MS);
|
|
180
|
+
}
|
|
181
|
+
function isAddressInUse(error) {
|
|
182
|
+
return error?.code === 'EADDRINUSE';
|
|
183
|
+
}
|
|
184
|
+
function describe(bridge, clock) {
|
|
185
|
+
const where = bridge.terminal ? `in terminal ${bridge.terminal}` : 'in the background';
|
|
186
|
+
return `pid ${bridge.pid}, started at ${clock(bridge.startedAt)} ${where}`;
|
|
187
|
+
}
|
|
188
|
+
/** 认不出来时的提示:只给可操作的查找 / 停止方法,不停任何进程 */
|
|
189
|
+
function unrecognizedMessage(port, listener, platform, replace) {
|
|
190
|
+
const note = replace ? ' --replace only stops a bridge it can recognize (0.6.0 or later, not on Windows), so nothing was stopped.' : '';
|
|
191
|
+
if (listener) {
|
|
192
|
+
return (`Port ${port} is already in use by pid ${listener.pid}${listener.command ? ` (${listener.command})` : ''}.\n` +
|
|
193
|
+
`If that's an older M2 bridge, stop it with "kill ${listener.pid}" (or press Ctrl+C in its terminal), then run this command again. ` +
|
|
194
|
+
`Otherwise start this one with --port <n>.${note}`);
|
|
195
|
+
}
|
|
196
|
+
const find = platform === 'win32'
|
|
197
|
+
? `find it with "netstat -ano | findstr :${port}" and stop it with "taskkill /PID <pid>"`
|
|
198
|
+
: `find it with "lsof -nP -iTCP:${port} -sTCP:LISTEN" and stop it with "kill <pid>"`;
|
|
199
|
+
return (`Port ${port} is already in use. If another bridge is running, ${find} (or press Ctrl+C in its terminal), then run this command again. ` +
|
|
200
|
+
`Otherwise start this one with --port <n>.${note}`);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 监听;撞端口时认定占用者并按 `--replace` 处理。返回 null 表示已打印原因、调用方以退出码 1 结束。
|
|
204
|
+
* 不是 EADDRINUSE 的错误照常抛出。
|
|
205
|
+
*/
|
|
206
|
+
export async function listenWithReplace(listen, ctx) {
|
|
207
|
+
try {
|
|
208
|
+
return await listen();
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (!isAddressInUse(error))
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const running = await ctx.identify();
|
|
215
|
+
if (!running) {
|
|
216
|
+
ctx.error(unrecognizedMessage(ctx.port, await ctx.findListener(), ctx.platform, ctx.replace));
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (!ctx.replace) {
|
|
220
|
+
const stopIt = running.terminal ? `press Ctrl+C in that terminal or run "kill ${running.pid}"` : `stop it with "kill ${running.pid}"`;
|
|
221
|
+
ctx.error(`Port ${ctx.port} is already in use by another M2 bridge (${describe(running, ctx.clock)}).\n` +
|
|
222
|
+
`To start this one instead, ${stopIt}, then run this command again. Or run it again with --replace to stop that bridge first.`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
ctx.log(`Stopping the M2 bridge already running on port ${ctx.port} (${describe(running, ctx.clock)})…`);
|
|
226
|
+
if (!(await ctx.stop(running.pid))) {
|
|
227
|
+
ctx.error(`The M2 bridge on port ${ctx.port} (pid ${running.pid}) didn't stop. Stop it with "kill ${running.pid}" (or press Ctrl+C in its terminal), then run this command again.`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
return await listen();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!isAddressInUse(error))
|
|
235
|
+
throw error;
|
|
236
|
+
ctx.error(`Port ${ctx.port} is still in use after stopping the old bridge. Wait a few seconds and run this command again.`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
package/dist/bridge/main.js
CHANGED
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { home, which } from '../system.js';
|
|
12
|
+
import { home, isExecutable, which } from '../system.js';
|
|
13
13
|
import { AdapterManager, ADAPTERS } from './adapters.js';
|
|
14
14
|
import { detectAgent } from './agents.js';
|
|
15
|
+
import { EXPECT_PATH } from './authorize.js';
|
|
15
16
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
16
17
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
18
|
+
import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
|
|
17
19
|
import { installShutdownSignals } from './lifecycle.js';
|
|
18
|
-
import { PairingManager, createFilePairingStore } from './pairing.js';
|
|
20
|
+
import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
|
|
19
21
|
import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
|
|
20
22
|
import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
|
|
21
23
|
import { startBridgeServer } from './server.js';
|
|
@@ -29,26 +31,35 @@ const LABELS = {
|
|
|
29
31
|
opencode: 'OpenCode',
|
|
30
32
|
qoder: 'Qoder',
|
|
31
33
|
};
|
|
32
|
-
const
|
|
34
|
+
export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
|
|
33
35
|
npx @m2canvas/connect bridge --unpair
|
|
34
36
|
|
|
35
37
|
Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
|
|
36
38
|
installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
|
|
37
39
|
|
|
38
40
|
It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
|
|
39
|
-
The
|
|
40
|
-
|
|
41
|
+
The command M2 gives you pairs that page automatically; other pages ask for the
|
|
42
|
+
pairing code shown here. A paired page can make these agents read and change
|
|
43
|
+
files and run commands as you, so only run commands you copied from M2.
|
|
41
44
|
Agents run in the directory you start the bridge from (or --cwd), with your own logins.
|
|
45
|
+
On macOS a paired page can also start an agent's own sign-in; you finish it in
|
|
46
|
+
your browser.
|
|
42
47
|
|
|
43
|
-
--port <n>
|
|
44
|
-
--cwd <dir>
|
|
45
|
-
--verbose
|
|
46
|
-
--dev
|
|
47
|
-
|
|
48
|
-
--
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
--port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
|
|
49
|
+
--cwd <dir> working directory for agent sessions (default: current directory)
|
|
50
|
+
--verbose also print agent stderr and connection details
|
|
51
|
+
--dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
|
|
52
|
+
any app served on that port could then ask to pair
|
|
53
|
+
--pair <value> pair the M2 page that gave you this command (M2 adds it for you;
|
|
54
|
+
valid for 10 minutes, once)
|
|
55
|
+
--replace if an M2 bridge (0.6.0 or later) is already running on the port,
|
|
56
|
+
stop it first (its agent sessions end) and start this one instead;
|
|
57
|
+
not on Windows
|
|
58
|
+
--unpair forget all paired pages and exit
|
|
59
|
+
-h, --help show this help`;
|
|
60
|
+
/** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
|
|
61
|
+
export function parseBridgeArgs(argv) {
|
|
62
|
+
const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false };
|
|
52
63
|
for (let i = 0; i < argv.length; i++) {
|
|
53
64
|
const arg = argv[i];
|
|
54
65
|
if (arg === '-h' || arg === '--help')
|
|
@@ -59,6 +70,16 @@ function parseArgs(argv) {
|
|
|
59
70
|
args.unpair = true;
|
|
60
71
|
else if (arg === '--dev')
|
|
61
72
|
args.dev = true;
|
|
73
|
+
else if (arg === '--replace')
|
|
74
|
+
args.replace = true;
|
|
75
|
+
else if (arg === '--pair') {
|
|
76
|
+
const value = argv[++i];
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return '--pair needs a value';
|
|
79
|
+
if (!isPairCommitment(value))
|
|
80
|
+
return `invalid --pair value ${value}; copy the start command from M2 again`;
|
|
81
|
+
args.pair = value;
|
|
82
|
+
}
|
|
62
83
|
else if (arg === '--port' || arg === '--cwd') {
|
|
63
84
|
const value = argv[++i];
|
|
64
85
|
if (value === undefined)
|
|
@@ -96,6 +117,15 @@ function printCode(info) {
|
|
|
96
117
|
else
|
|
97
118
|
console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
|
|
98
119
|
}
|
|
120
|
+
function clock(at) {
|
|
121
|
+
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
122
|
+
}
|
|
123
|
+
/** 启动命令里的配对已被用掉,同一个密钥又出现:同一个 M2 标签页被复制 / 连接途中刷新,或者别的程序抢先配对了(review r1 S2) */
|
|
124
|
+
function printReused(info) {
|
|
125
|
+
console.log(`\n→ Warning: the pairing in your start command was already used (by ${info.firstOrigin} at ${clock(info.usedAt)}), ` +
|
|
126
|
+
`and ${info.origin} just presented it again. If you didn't duplicate or reload that M2 tab while it was connecting, another program on this machine ` +
|
|
127
|
+
`may have paired with this bridge: press Ctrl+C, run "npx @m2canvas/connect bridge --unpair", then start the bridge again.\n`);
|
|
128
|
+
}
|
|
99
129
|
function printLocked(origin) {
|
|
100
130
|
const from = origin ? ` (the last ones came from ${origin})` : '';
|
|
101
131
|
console.log(`\n→ Too many wrong pairing codes${from}. New pages can't pair until you restart the bridge (Ctrl+C, then run it again); ` +
|
|
@@ -112,13 +142,13 @@ function describeAdapter(agent, info) {
|
|
|
112
142
|
return null;
|
|
113
143
|
}
|
|
114
144
|
export async function runBridge(argv) {
|
|
115
|
-
const parsed =
|
|
145
|
+
const parsed = parseBridgeArgs(argv);
|
|
116
146
|
if (typeof parsed === 'string') {
|
|
117
|
-
console.error(`${parsed}\n\n${
|
|
147
|
+
console.error(`${parsed}\n\n${BRIDGE_HELP}`);
|
|
118
148
|
return 2;
|
|
119
149
|
}
|
|
120
150
|
if (parsed.help) {
|
|
121
|
-
console.log(
|
|
151
|
+
console.log(BRIDGE_HELP);
|
|
122
152
|
return 0;
|
|
123
153
|
}
|
|
124
154
|
const m2Dir = join(home, '.m2canvas');
|
|
@@ -142,7 +172,12 @@ export async function runBridge(argv) {
|
|
|
142
172
|
onCodeIssued: printCode,
|
|
143
173
|
onLocked: printLocked,
|
|
144
174
|
onProblem: (message) => console.warn(message),
|
|
175
|
+
onCommitmentReused: printReused,
|
|
145
176
|
});
|
|
177
|
+
// 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
|
|
178
|
+
const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
|
|
179
|
+
// 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
|
|
180
|
+
const loggedState = new Map();
|
|
146
181
|
const adapters = new AdapterManager({
|
|
147
182
|
sourceRoot: join(PACKAGE_ROOT, 'adapters'),
|
|
148
183
|
installRoot: join(m2Dir, 'acp-adapters'),
|
|
@@ -154,9 +189,12 @@ export async function runBridge(argv) {
|
|
|
154
189
|
shell: process.platform === 'win32',
|
|
155
190
|
}),
|
|
156
191
|
onState: (agent, info) => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
192
|
+
if (loggedState.get(agent) !== info.state) {
|
|
193
|
+
loggedState.set(agent, info.state);
|
|
194
|
+
const line = describeAdapter(agent, info);
|
|
195
|
+
if (line)
|
|
196
|
+
log(line);
|
|
197
|
+
}
|
|
160
198
|
server?.broadcast('adapter/state', { agent, adapter: info });
|
|
161
199
|
},
|
|
162
200
|
});
|
|
@@ -171,34 +209,55 @@ export async function runBridge(argv) {
|
|
|
171
209
|
nodePath: process.execPath,
|
|
172
210
|
nodeVersion: process.versions.node,
|
|
173
211
|
cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
|
|
212
|
+
platform: process.platform,
|
|
213
|
+
expectAvailable: () => isExecutable(EXPECT_PATH),
|
|
174
214
|
});
|
|
175
215
|
const version = packageVersion();
|
|
176
216
|
const origins = allowedOrigins(parsed.dev);
|
|
217
|
+
// 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
|
|
218
|
+
const instancePath = instanceFilePath(m2Dir, parsed.port);
|
|
219
|
+
server = await listenWithReplace(() => startBridgeServer({
|
|
220
|
+
port: parsed.port,
|
|
221
|
+
cwd: parsed.cwd,
|
|
222
|
+
bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
|
|
223
|
+
pairing,
|
|
224
|
+
service,
|
|
225
|
+
agentEnv: buildAgentEnv(),
|
|
226
|
+
allowedOrigins: origins,
|
|
227
|
+
log,
|
|
228
|
+
verbose: parsed.verbose,
|
|
229
|
+
}), {
|
|
230
|
+
port: parsed.port,
|
|
231
|
+
replace: parsed.replace,
|
|
232
|
+
identify: () => identifyRunningBridge(instancePath, parsed.port, createPsProbe(execCommand, probeEnv), process.platform),
|
|
233
|
+
findListener: () => createLsofProbe(execCommand, probeEnv).find(parsed.port),
|
|
234
|
+
platform: process.platform,
|
|
235
|
+
stop: (pid) => stopRunningBridge(pid, createStopDeps(execCommand, probeEnv)),
|
|
236
|
+
log,
|
|
237
|
+
error: (message) => console.error(message),
|
|
238
|
+
clock,
|
|
239
|
+
});
|
|
240
|
+
if (!server)
|
|
241
|
+
return 1;
|
|
242
|
+
const running = server;
|
|
177
243
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
cwd: parsed.cwd,
|
|
181
|
-
bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
|
|
182
|
-
pairing,
|
|
183
|
-
service,
|
|
184
|
-
agentEnv: buildAgentEnv(),
|
|
185
|
-
allowedOrigins: origins,
|
|
186
|
-
log,
|
|
187
|
-
verbose: parsed.verbose,
|
|
188
|
-
});
|
|
244
|
+
// startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
|
|
245
|
+
writeInstanceRecord(instancePath, { version: 1, pid: process.pid, port: parsed.port, startedAt: processStartedAt() });
|
|
189
246
|
}
|
|
190
247
|
catch (error) {
|
|
191
|
-
|
|
192
|
-
if (code === 'EADDRINUSE') {
|
|
193
|
-
console.error(`Port ${parsed.port} is already in use. Another bridge may already be running; stop it, or start this one with --port <n>.`);
|
|
194
|
-
return 1;
|
|
195
|
-
}
|
|
196
|
-
throw error;
|
|
248
|
+
console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
|
|
197
249
|
}
|
|
198
|
-
const running = server;
|
|
199
250
|
// 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
|
|
200
251
|
// 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
|
|
201
252
|
process.on('exit', () => {
|
|
253
|
+
// 只删属于本进程的状态文件;删不掉只会让之后的认定落到「pid 不在」,不影响退出
|
|
254
|
+
try {
|
|
255
|
+
removeInstanceRecord(instancePath, process.pid);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// 见上
|
|
259
|
+
}
|
|
260
|
+
running.authorizations.killAllNow();
|
|
202
261
|
running.processes.killAllNow();
|
|
203
262
|
killActiveExecs();
|
|
204
263
|
});
|
|
@@ -213,7 +272,9 @@ export async function runBridge(argv) {
|
|
|
213
272
|
});
|
|
214
273
|
const stop = async () => {
|
|
215
274
|
log('Stopping the bridge and its agents…');
|
|
216
|
-
// 一次性命令(npm ci
|
|
275
|
+
// 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
|
|
276
|
+
// 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
|
|
277
|
+
running.authorizations.killAllNow();
|
|
217
278
|
killActiveExecs();
|
|
218
279
|
await running.close();
|
|
219
280
|
const deadline = Date.now() + KILL_GRACE_MS;
|
|
@@ -226,6 +287,7 @@ export async function runBridge(argv) {
|
|
|
226
287
|
graceful: () => void stop(),
|
|
227
288
|
// 收尾期间又来一个信号(连按 Ctrl+C):不再等宽限期,立刻清理退出(exit 钩子也会再清一遍)
|
|
228
289
|
immediate: () => {
|
|
290
|
+
running.authorizations.killAllNow();
|
|
229
291
|
running.processes.killAllNow();
|
|
230
292
|
killActiveExecs();
|
|
231
293
|
process.exit(130);
|
|
@@ -237,7 +299,12 @@ export async function runBridge(argv) {
|
|
|
237
299
|
console.log(` Accepts pages from: ${origins.join(', ')}`);
|
|
238
300
|
if (parsed.dev)
|
|
239
301
|
console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
|
|
240
|
-
|
|
302
|
+
// 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
|
|
303
|
+
// 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
|
|
304
|
+
if (pairUntil !== null)
|
|
305
|
+
console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
|
|
306
|
+
else
|
|
307
|
+
pairing.issueCode('startup');
|
|
241
308
|
const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
|
|
242
309
|
console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
|
|
243
310
|
console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
|