@m2canvas/connect 0.5.0 → 0.7.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 +22 -4
- package/dist/bridge/adapters.js +130 -9
- 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/install-layout.js +37 -0
- package/dist/bridge/instance.js +245 -0
- package/dist/bridge/main.js +176 -36
- package/dist/bridge/npm-command.js +25 -0
- package/dist/bridge/pair-call.js +31 -0
- package/dist/bridge/pairing.js +13 -3
- package/dist/bridge/precheck.js +22 -6
- package/dist/bridge/protocol.js +11 -1
- package/dist/bridge/release-manifest.js +80 -0
- package/dist/bridge/self-test.js +30 -0
- package/dist/bridge/server.js +54 -32
- package/dist/bridge/service.js +103 -20
- package/dist/bridge/update-io.js +118 -0
- package/dist/bridge/update.js +365 -0
- package/package.json +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge 代跑授权命令(change acp-terminal-free-auth):账号登录与 m2 MCP 授权不再要求用户打开终端。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(`connect/local-bridge` 契约 3 / 8,design D2 / 安全分析):
|
|
5
|
+
* - 页面只能选「哪个 agent × 哪个动作」。可执行文件、参数、环境、cwd 全部来自下面写死的 AUTH_COMMANDS 与 bridge 自己的检测;
|
|
6
|
+
* m2 授权用的配置项名只取 bridge 自己的 m2 预检(调用方 service.ts 负责:优先用最近一次预检的结果,没有记录时拉起前重跑,D19),不接受页面传入。
|
|
7
|
+
* - 授权必须由用户在浏览器里亲自完成:bridge 只截下 CLI 要打开的授权页链接交给页面,从不替用户点任何东西。
|
|
8
|
+
*
|
|
9
|
+
* 链接怎么截(D4,各家行为实测见 evidence/auth-commands-macos.md):
|
|
10
|
+
* - 每次授权新建 0700 的临时目录,放 `open` / `xdg-open` 替身并排在 PATH 最前;替身只把参数逐行追加到 `M2_AUTH_LINK_FILE`,
|
|
11
|
+
* **从不执行参数、从不打开浏览器**。Claude / Cursor / OpenCode / Qoder 都经 PATH 上的 `open` 打开授权页。
|
|
12
|
+
* - Codex 的原生程序经 NSWorkspace 直接打开默认浏览器,截不到,但会把链接打印出来:从输出里取(`opens: 'agent'`,页面只作备用)。
|
|
13
|
+
* - 按每条命令的链接来源只取一种:Claude 打印在输出里的是「粘码」备用链接,回调不在本机,不能转交。
|
|
14
|
+
* - 只转交 https、≤ 8KB 的链接,同一条只转交一次;事件只发给发起授权的连接(server.ts)。
|
|
15
|
+
*
|
|
16
|
+
* 生命周期(D7):同一个 agent 同一时间一次(跨连接);拥有者是发起的连接,连接关闭即取消;10 分钟超时;
|
|
17
|
+
* 取消 / 超时 = 对进程组 SIGTERM,2 秒后仍在则 SIGKILL(OpenCode 收到 SIGTERM 约 20 秒不退出)。
|
|
18
|
+
* 命令经 exec.ts 的 startCommand 拉起,与一次性命令共用 active 集合,bridge 退出时一起清理;临时目录由 killAllNow 同步删除。
|
|
19
|
+
*
|
|
20
|
+
* Claude 的 m2 授权(D6):`claude mcp login` 遇到非终端直接拒绝,用 macOS 自带的 /usr/bin/expect 提供伪终端。
|
|
21
|
+
* 脚本文本固定,参数经 Tcl 的 $argv 列表传给 `spawn {*}$argv`,不拼进脚本。expect 退出后伪终端关闭,claude 随之收到 SIGHUP 退出,
|
|
22
|
+
* 所以对 expect 的进程组发信号足以清理(evidence/pty-cleanup.md)。
|
|
23
|
+
*
|
|
24
|
+
* 结果以预检为准(D8):命令结束后重跑预检随结果返回,outcome 只反映命令本身(退出码不可靠)。
|
|
25
|
+
*/
|
|
26
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { CODEX_SCRIPT } from './adapters.js';
|
|
30
|
+
import { prependPath } from './env.js';
|
|
31
|
+
import { BridgeFailure } from './errors.js';
|
|
32
|
+
import { combinedOutput, startCommand } from './exec.js';
|
|
33
|
+
import { stripAnsi } from './precheck-parse.js';
|
|
34
|
+
import { BRIDGE_MAX_AUTH_LINK_CHARS } from './protocol.js';
|
|
35
|
+
export const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
|
+
export const AUTH_KILL_GRACE_MS = 2_000;
|
|
37
|
+
export const AUTH_LINK_POLL_MS = 250;
|
|
38
|
+
export const MAX_AUTH_LINK_CHARS = BRIDGE_MAX_AUTH_LINK_CHARS;
|
|
39
|
+
export const AUTH_OUTPUT_LIMIT = 4096;
|
|
40
|
+
/** 每条输出流保留的尾部(只为截链接与最终的 4KB 摘要,不需要更多) */
|
|
41
|
+
const AUTH_STREAM_KEEP = 64 * 1024;
|
|
42
|
+
export const EXPECT_PATH = '/usr/bin/expect';
|
|
43
|
+
/**
|
|
44
|
+
* 写死的命令清单(design「命令清单」表)。不在这里的组合一律 auth_unavailable:
|
|
45
|
+
* OpenCode 的登录是交互菜单、预检也不要求登录;Qoder 的 m2 授权没有命令行与 ACP 入口。
|
|
46
|
+
*/
|
|
47
|
+
export const AUTH_COMMANDS = {
|
|
48
|
+
claude: { login: { opens: 'page', linkSource: 'open', pty: false }, m2: { opens: 'page', linkSource: 'open', pty: true } },
|
|
49
|
+
codex: { login: { opens: 'agent', linkSource: 'output', pty: false }, m2: { opens: 'agent', linkSource: 'output', pty: false } },
|
|
50
|
+
cursor: { login: { opens: 'page', linkSource: 'open', pty: false }, m2: { opens: 'page', linkSource: 'open', pty: false } },
|
|
51
|
+
opencode: { m2: { opens: 'page', linkSource: 'open', pty: false } },
|
|
52
|
+
qoder: { login: { opens: 'page', linkSource: 'open', pty: false } },
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 预检结果里的 `authorize` 字段(D1、D5):只在 macOS(实测平台)出现;Claude 要找得到自带的 claude,Claude 的 m2 还要有 expect。
|
|
56
|
+
* 页面只在这个字段存在时调用 agent/authorize,所以旧 bridge 与其他平台自动退回命令引导。
|
|
57
|
+
*/
|
|
58
|
+
export function authCapability(agent, action, env) {
|
|
59
|
+
const spec = AUTH_COMMANDS[agent]?.[action];
|
|
60
|
+
if (env.platform !== 'darwin' || !spec)
|
|
61
|
+
return null;
|
|
62
|
+
if (agent === 'claude' && !env.claudeBinary)
|
|
63
|
+
return null;
|
|
64
|
+
if (spec.pty && !env.expectAvailable)
|
|
65
|
+
return null;
|
|
66
|
+
return { opens: spec.opens };
|
|
67
|
+
}
|
|
68
|
+
const unavailable = (message) => new BridgeFailure('auth_unavailable', message);
|
|
69
|
+
/**
|
|
70
|
+
* m2 配置项名会作为参数传给 CLI。它来自 bridge 自己的预检,但预检读的配置包括会话目录里的项目文件(Cursor 的
|
|
71
|
+
* `<cwd>/.cursor/mcp.json`、Claude 的 `.mcp.json`)—— 仓库可以把它命名成 `--xxx`,让页面一键触发时注入 CLI 选项(review r1 S-3)。
|
|
72
|
+
* 参数走 argv、不经过 shell,所以只挡选项与控制字符:不以 `-` 开头、不含控制字符、1–128 字符;空格、中文、引号照常传
|
|
73
|
+
* (误拒会让按钮无端消失,review r2 R2-S1)。预检的 `authorize` 能力用同一个函数判断,按钮出现就一定能执行。
|
|
74
|
+
*/
|
|
75
|
+
// eslint-disable-next-line no-control-regex
|
|
76
|
+
const SAFE_ENTRY = /^(?!-)[^\u0000-\u001f\u007f]{1,128}$/u;
|
|
77
|
+
export function isSafeAuthEntry(entry) {
|
|
78
|
+
return SAFE_ENTRY.test(entry);
|
|
79
|
+
}
|
|
80
|
+
/** 把 agent × 动作翻译成具体命令。调用方已确认平台、安装、适配器就绪与 expect;这里只按清单组装。 */
|
|
81
|
+
export function buildAuthPlan(input) {
|
|
82
|
+
const { agent, action, entry } = input;
|
|
83
|
+
const spec = AUTH_COMMANDS[agent]?.[action];
|
|
84
|
+
if (!spec)
|
|
85
|
+
throw unavailable(`the bridge cannot run ${action === 'login' ? 'sign-in' : 'M2 authorization'} for ${agent}`);
|
|
86
|
+
if (action === 'm2' && !entry)
|
|
87
|
+
throw unavailable(`add M2 to ${agent}'s MCP config first (m2/configure)`);
|
|
88
|
+
if (action === 'm2' && entry && !isSafeAuthEntry(entry))
|
|
89
|
+
throw unavailable(`the M2 entry name ${JSON.stringify(entry)} can't be passed to ${agent} safely; use the terminal command instead`);
|
|
90
|
+
const m2Entry = entry ?? '';
|
|
91
|
+
const base = { agent, action, cwd: input.cwd, env: input.env, pty: spec.pty, linkSource: spec.linkSource, opens: spec.opens };
|
|
92
|
+
switch (agent) {
|
|
93
|
+
case 'claude': {
|
|
94
|
+
const claude = input.claudeBinary;
|
|
95
|
+
if (!claude)
|
|
96
|
+
throw unavailable('the Claude Code program bundled with the adapter was not found');
|
|
97
|
+
return { ...base, steps: [{ command: claude, args: action === 'login' ? ['auth', 'login'] : ['mcp', 'login', m2Entry] }], pathPrefix: [dirname(claude)] };
|
|
98
|
+
}
|
|
99
|
+
case 'codex': {
|
|
100
|
+
const script = join(input.codexDir, CODEX_SCRIPT);
|
|
101
|
+
const args = action === 'login' ? [script, 'login'] : [script, 'mcp', 'login', m2Entry];
|
|
102
|
+
return { ...base, steps: [{ command: input.nodePath, args }], pathPrefix: [join(input.codexDir, 'node_modules', '.bin'), dirname(input.nodePath)] };
|
|
103
|
+
}
|
|
104
|
+
default: {
|
|
105
|
+
const path = input.detected.path;
|
|
106
|
+
if (!path)
|
|
107
|
+
throw unavailable(`${agent} is not installed on this machine`);
|
|
108
|
+
// OpenCode 已授权时重跑 `mcp auth` 会停在交互确认(Re-authenticate?),无终端会卡住;先 logout(无交互)再 auth(D15)
|
|
109
|
+
const steps = agent === 'opencode'
|
|
110
|
+
? [
|
|
111
|
+
{ command: path, args: ['mcp', 'logout', m2Entry], ignoreFailure: true },
|
|
112
|
+
{ command: path, args: ['mcp', 'auth', m2Entry] },
|
|
113
|
+
]
|
|
114
|
+
: [{ command: path, args: action === 'login' ? ['login'] : ['mcp', 'login', m2Entry] }];
|
|
115
|
+
return { ...base, steps, pathPrefix: [] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ───────────────────────────── 链接 ─────────────────────────────
|
|
120
|
+
/** 只收能解析的 https 链接、≤ 8KB;其余(http 本机回调、file:、命令行选项)返回 null */
|
|
121
|
+
export function acceptAuthLink(candidate) {
|
|
122
|
+
const text = candidate.trim();
|
|
123
|
+
if (text.length === 0 || text.length > MAX_AUTH_LINK_CHARS)
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
return new URL(text).protocol === 'https:' ? text : null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// eslint-disable-next-line no-control-regex
|
|
133
|
+
const OUTPUT_LINK_RE = /https:\/\/[^\s"'<>\u001b]+(?=[\s"'<>])/g;
|
|
134
|
+
/**
|
|
135
|
+
* 从命令输出里取完整的 https 链接:必须后面跟着空白或引号,避免把还没输出完的半截链接转交出去。
|
|
136
|
+
* 调用方传入累计输出(去 ANSI 在这里做,转义序列可能跨两段输出)。
|
|
137
|
+
*/
|
|
138
|
+
export function linksInOutput(text) {
|
|
139
|
+
const found = [];
|
|
140
|
+
for (const match of stripAnsi(text).matchAll(OUTPUT_LINK_RE)) {
|
|
141
|
+
const link = acceptAuthLink(match[0]);
|
|
142
|
+
if (link && !found.includes(link))
|
|
143
|
+
found.push(link);
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
// ───────────────────────────── 运行 ─────────────────────────────
|
|
148
|
+
/** 替身:只把参数逐行追加到 bridge 指定的文件。不执行参数、不打开浏览器(D4) */
|
|
149
|
+
const OPEN_SHIM = `#!/bin/sh
|
|
150
|
+
# M2 local agent bridge: records the sign-in link for the M2 page to open in your current browser. It never runs its arguments.
|
|
151
|
+
for arg in "$@"; do
|
|
152
|
+
printf '%s\\n' "$arg" >> "$M2_AUTH_LINK_FILE"
|
|
153
|
+
done
|
|
154
|
+
exit 0
|
|
155
|
+
`;
|
|
156
|
+
/** 伪终端包装(D6):文本固定,被包命令只经 $argv 传入 */
|
|
157
|
+
const PTY_SCRIPT = `set timeout -1
|
|
158
|
+
log_user 1
|
|
159
|
+
spawn -noecho {*}$argv
|
|
160
|
+
expect eof
|
|
161
|
+
catch wait result
|
|
162
|
+
exit [lindex $result 3]
|
|
163
|
+
`;
|
|
164
|
+
function outputTail(text) {
|
|
165
|
+
const clean = stripAnsi(text).replace(/\r/g, '');
|
|
166
|
+
return clean.length > AUTH_OUTPUT_LIMIT ? clean.slice(-AUTH_OUTPUT_LIMIT) : clean;
|
|
167
|
+
}
|
|
168
|
+
export function startAuthRun(plan, options) {
|
|
169
|
+
const dir = mkdtempSync(join(tmpdir(), 'm2-auth-'));
|
|
170
|
+
chmodSync(dir, 0o700);
|
|
171
|
+
let removed = false;
|
|
172
|
+
const removeDir = () => {
|
|
173
|
+
if (removed)
|
|
174
|
+
return;
|
|
175
|
+
removed = true;
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
};
|
|
178
|
+
let current = null;
|
|
179
|
+
let canceled = false;
|
|
180
|
+
let timedOut = false;
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const offer = (candidate) => {
|
|
183
|
+
const link = acceptAuthLink(candidate);
|
|
184
|
+
if (!link || seen.has(link))
|
|
185
|
+
return;
|
|
186
|
+
seen.add(link);
|
|
187
|
+
options.onLink(link);
|
|
188
|
+
};
|
|
189
|
+
let linkFile = '';
|
|
190
|
+
let script = '';
|
|
191
|
+
let env = plan.env;
|
|
192
|
+
try {
|
|
193
|
+
const bin = join(dir, 'bin');
|
|
194
|
+
mkdirSync(bin, { mode: 0o700 });
|
|
195
|
+
for (const name of ['open', 'xdg-open'])
|
|
196
|
+
writeFileSync(join(bin, name), OPEN_SHIM, { mode: 0o700 });
|
|
197
|
+
linkFile = join(dir, 'links');
|
|
198
|
+
writeFileSync(linkFile, '', { mode: 0o600 });
|
|
199
|
+
script = join(dir, 'pty.exp');
|
|
200
|
+
if (plan.pty)
|
|
201
|
+
writeFileSync(script, PTY_SCRIPT, { mode: 0o600 });
|
|
202
|
+
env = { ...prependPath(plan.env, [bin, ...plan.pathPrefix]), M2_AUTH_LINK_FILE: linkFile };
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
removeDir();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
let linkLines = 0;
|
|
209
|
+
const readLinkFile = () => {
|
|
210
|
+
let text;
|
|
211
|
+
try {
|
|
212
|
+
text = readFileSync(linkFile, 'utf8');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return; // 临时目录已删(结束或 killNow)
|
|
216
|
+
}
|
|
217
|
+
// 最后一段可能是替身还没写完的半行
|
|
218
|
+
const lines = text.split('\n').slice(0, -1);
|
|
219
|
+
for (const line of lines.slice(linkLines))
|
|
220
|
+
offer(line);
|
|
221
|
+
linkLines = lines.length;
|
|
222
|
+
};
|
|
223
|
+
const poll = plan.linkSource === 'open' ? setInterval(readLinkFile, options.pollMs) : null;
|
|
224
|
+
poll?.unref();
|
|
225
|
+
let scanned = '';
|
|
226
|
+
const onOutput = plan.linkSource === 'output'
|
|
227
|
+
? (chunk) => {
|
|
228
|
+
scanned = (scanned + chunk).slice(-2 * MAX_AUTH_LINK_CHARS);
|
|
229
|
+
for (const link of linksInOutput(scanned))
|
|
230
|
+
offer(link);
|
|
231
|
+
}
|
|
232
|
+
: undefined;
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
timedOut = true;
|
|
235
|
+
current?.terminate(options.graceMs, 'timed out', true);
|
|
236
|
+
}, options.timeoutMs);
|
|
237
|
+
timer.unref();
|
|
238
|
+
const execute = async () => {
|
|
239
|
+
const outputs = [];
|
|
240
|
+
let last = null;
|
|
241
|
+
try {
|
|
242
|
+
for (const step of plan.steps) {
|
|
243
|
+
if (canceled || timedOut)
|
|
244
|
+
break;
|
|
245
|
+
const command = plan.pty ? EXPECT_PATH : step.command;
|
|
246
|
+
const args = plan.pty ? ['-f', script, '--', step.command, ...step.args] : step.args;
|
|
247
|
+
current = startCommand(command, args, { env, cwd: plan.cwd, maxOutput: AUTH_STREAM_KEEP, onOutput });
|
|
248
|
+
last = await current.result;
|
|
249
|
+
current = null;
|
|
250
|
+
outputs.push(combinedOutput(last));
|
|
251
|
+
if (last.code !== 0 && !step.ignoreFailure)
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
if (poll)
|
|
258
|
+
clearInterval(poll);
|
|
259
|
+
removeDir();
|
|
260
|
+
}
|
|
261
|
+
const outcome = canceled ? 'canceled' : timedOut ? 'timed_out' : last?.code === 0 ? 'succeeded' : 'failed';
|
|
262
|
+
return { outcome, exitCode: last?.code ?? null, output: outputTail(outputs.filter((part) => part.length > 0).join('\n')) };
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
result: execute(),
|
|
266
|
+
cancel() {
|
|
267
|
+
if (canceled)
|
|
268
|
+
return;
|
|
269
|
+
canceled = true;
|
|
270
|
+
current?.terminate(options.graceMs, 'canceled');
|
|
271
|
+
},
|
|
272
|
+
killNow() {
|
|
273
|
+
canceled = true;
|
|
274
|
+
current?.kill('bridge is shutting down');
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
if (poll)
|
|
277
|
+
clearInterval(poll);
|
|
278
|
+
removeDir();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export class AuthorizationManager {
|
|
283
|
+
options;
|
|
284
|
+
/** 按 agent 记:同一 agent 的两次授权会争同一个本机回调端口(Cursor 固定 8787)、写同一份凭证 */
|
|
285
|
+
entries = new Map();
|
|
286
|
+
constructor(options = {}) {
|
|
287
|
+
this.options = options;
|
|
288
|
+
}
|
|
289
|
+
async run(request) {
|
|
290
|
+
const { agent, action } = request;
|
|
291
|
+
if (this.entries.has(agent))
|
|
292
|
+
throw new BridgeFailure('auth_busy', `another sign-in for ${agent} is in progress`, { agent });
|
|
293
|
+
const entry = { ownerId: request.ownerId, canceled: false, run: null };
|
|
294
|
+
this.entries.set(agent, entry);
|
|
295
|
+
const canceledResult = { agent, action, outcome: 'canceled', exitCode: null, output: '', precheck: null };
|
|
296
|
+
let ran;
|
|
297
|
+
try {
|
|
298
|
+
const plan = await request.plan();
|
|
299
|
+
if (entry.canceled)
|
|
300
|
+
return canceledResult;
|
|
301
|
+
const start = this.options.start ?? startAuthRun;
|
|
302
|
+
entry.run = start(plan, {
|
|
303
|
+
timeoutMs: this.options.timeoutMs ?? AUTH_TIMEOUT_MS,
|
|
304
|
+
graceMs: this.options.graceMs ?? AUTH_KILL_GRACE_MS,
|
|
305
|
+
pollMs: this.options.pollMs ?? AUTH_LINK_POLL_MS,
|
|
306
|
+
onLink: (url) => request.onLink({ url, open: plan.opens }),
|
|
307
|
+
});
|
|
308
|
+
ran = await entry.run.result;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
// 命令结束即释放:端口与凭证的争用已经结束,之后的预检不需要占着
|
|
312
|
+
if (this.entries.get(agent) === entry)
|
|
313
|
+
this.entries.delete(agent);
|
|
314
|
+
}
|
|
315
|
+
if (ran.outcome === 'canceled' || entry.canceled)
|
|
316
|
+
return { ...canceledResult, exitCode: ran.exitCode, output: ran.output };
|
|
317
|
+
return { agent, action, ...ran, precheck: await request.precheck() };
|
|
318
|
+
}
|
|
319
|
+
/** 页面取消:只有发起的连接能取消 */
|
|
320
|
+
cancel(ownerId, agent) {
|
|
321
|
+
const entry = this.entries.get(agent);
|
|
322
|
+
if (!entry || entry.ownerId !== ownerId)
|
|
323
|
+
return false;
|
|
324
|
+
entry.canceled = true;
|
|
325
|
+
entry.run?.cancel();
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
/** 连接关闭 */
|
|
329
|
+
cancelOwner(ownerId) {
|
|
330
|
+
for (const [agent, entry] of this.entries)
|
|
331
|
+
if (entry.ownerId === ownerId)
|
|
332
|
+
this.cancel(ownerId, agent);
|
|
333
|
+
}
|
|
334
|
+
/** bridge 退出:同步杀掉全部并删除临时目录 */
|
|
335
|
+
killAllNow() {
|
|
336
|
+
for (const entry of this.entries.values()) {
|
|
337
|
+
entry.canceled = true;
|
|
338
|
+
entry.run?.killNow();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
package/dist/bridge/env.js
CHANGED
|
@@ -105,3 +105,15 @@ export function buildProbeEnv(source = process.env, platform = process.platform)
|
|
|
105
105
|
export function buildNpmEnv(source = process.env, platform = process.platform) {
|
|
106
106
|
return { ...buildAgentEnv(source, platform), ...pick(source, (name) => NPM_CONFIG_NAMES.includes(name)) };
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* 把目录排到 PATH 最前(D10:让 connect 的 cli.js 与授权命令找得到适配器自带的 claude / codex 和 bridge 自己的 node)。
|
|
110
|
+
* Windows 的环境变量名不区分大小写,白名单保留的可能是 `Path`:改写原来那个键,不另加一个 `PATH`。
|
|
111
|
+
*/
|
|
112
|
+
export function prependPath(env, dirs, platform = process.platform) {
|
|
113
|
+
if (dirs.length === 0)
|
|
114
|
+
return env;
|
|
115
|
+
const key = platform === 'win32' ? (Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'PATH') : 'PATH';
|
|
116
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
117
|
+
const current = env[key];
|
|
118
|
+
return { ...env, [key]: [...dirs, ...(current ? [current] : [])].join(separator) };
|
|
119
|
+
}
|
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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性安装在 `~/.m2canvas` 下的目录布局(change acp-bridge-one-time-install D15)。
|
|
3
|
+
*
|
|
4
|
+
* 同一份布局还写死在两个 sh 脚本里:安装脚本 `public/connect/install.sh` 与启动器源 `packages/connect/install/m2-bridge.sh`
|
|
5
|
+
* (脚本不能 import 这里)。改这里要同批改那两处;脚本测试会按这里的路径核对它们的产物。
|
|
6
|
+
*
|
|
7
|
+
* 全部在用户主目录下,不写任何系统目录、不注册任何启动项、不改 PATH(`connect/local-bridge` 契约 14 delta)。
|
|
8
|
+
* 其余已有文件(配对记录 `bridge-pairings.json`、状态文件 `bridge-<port>.json`、适配器 `acp-adapters/`)位置不变。
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
export function installLayout(home) {
|
|
12
|
+
const root = join(home, '.m2canvas');
|
|
13
|
+
const bridge = join(root, 'bridge');
|
|
14
|
+
const logs = join(root, 'logs');
|
|
15
|
+
return {
|
|
16
|
+
root,
|
|
17
|
+
runtime: join(root, 'runtime'),
|
|
18
|
+
bridge,
|
|
19
|
+
current: join(bridge, 'current'),
|
|
20
|
+
previous: join(bridge, 'previous'),
|
|
21
|
+
rejected: join(bridge, 'rejected'),
|
|
22
|
+
launcher: join(root, 'bin', 'm2-bridge'),
|
|
23
|
+
logs,
|
|
24
|
+
updateLog: join(logs, 'update.log'),
|
|
25
|
+
workspace: join(root, 'workspace'),
|
|
26
|
+
adapters: join(root, 'acp-adapters'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** 版本目录里的文件:这个版本用哪份 Node(绝对路径一行)。回退版本时 Node 跟着一起回退 */
|
|
30
|
+
export const NODE_PATH_FILE = 'node-path';
|
|
31
|
+
/** 版本目录内 connect 包的位置(npm tarball 解开后的 `package/` 改名而来) */
|
|
32
|
+
export function connectPackageDir(versionDir) {
|
|
33
|
+
return join(versionDir, 'node_modules', '@m2canvas', 'connect');
|
|
34
|
+
}
|
|
35
|
+
export function connectCliPath(versionDir) {
|
|
36
|
+
return join(connectPackageDir(versionDir), 'dist', 'cli.js');
|
|
37
|
+
}
|