@m2canvas/connect 0.3.1 → 0.5.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 +19 -0
- package/adapters/claude-agent-acp/package-lock.json +1535 -0
- package/adapters/claude-agent-acp/package.json +8 -0
- package/adapters/codex-acp/package-lock.json +364 -0
- package/adapters/codex-acp/package.json +9 -0
- package/dist/bridge/acp-guard.js +92 -0
- package/dist/bridge/adapters.js +146 -0
- package/dist/bridge/agents.js +73 -0
- package/dist/bridge/env.js +107 -0
- package/dist/bridge/exec.js +90 -0
- package/dist/bridge/lifecycle.js +24 -0
- package/dist/bridge/m2-config.js +38 -0
- package/dist/bridge/main.js +274 -0
- package/dist/bridge/pairing.js +317 -0
- package/dist/bridge/precheck-parse.js +246 -0
- package/dist/bridge/precheck.js +149 -0
- package/dist/bridge/processes.js +212 -0
- package/dist/bridge/protocol.js +62 -0
- package/dist/bridge/security.js +52 -0
- package/dist/bridge/server.js +379 -0
- package/dist/bridge/service.js +109 -0
- package/dist/cli.js +8 -2
- package/package.json +7 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话前预检:登录状态 + m2 MCP 状态,并给出页面可展示的下一步(命令或手动步骤)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要预检:m2 没配置、没授权、还没连上时,ACP 里没有任何信号,agent 只会说「没有这个工具」(PoC 报告 §4.1)。
|
|
5
|
+
*
|
|
6
|
+
* 边界:
|
|
7
|
+
* - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查);bridge 不替用户登录、不替用户授权,只返回命令让用户在自己的终端执行。
|
|
8
|
+
* - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
|
|
9
|
+
* - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
|
|
10
|
+
* - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
|
|
11
|
+
* - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
|
|
12
|
+
*/
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { findM2Names } from './m2-config.js';
|
|
15
|
+
import { CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
|
|
16
|
+
import { combinedOutput } from './exec.js';
|
|
17
|
+
import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
|
|
18
|
+
export const PRECHECK_TIMEOUT_MS = 45_000;
|
|
19
|
+
export function shellQuote(value) {
|
|
20
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
21
|
+
}
|
|
22
|
+
export function commandHint(command, args, cwd = null) {
|
|
23
|
+
const line = [command, ...args].map(shellQuote).join(' ');
|
|
24
|
+
return { command, args, cwd, display: cwd ? `cd ${shellQuote(cwd)} && ${line}` : line };
|
|
25
|
+
}
|
|
26
|
+
const run = (command, args) => ({ kind: 'command', command: commandHint(command, args) });
|
|
27
|
+
/** 执行失败(启动不了 / 超时)时的统一兜底:报 unknown 并带上原因,不猜 */
|
|
28
|
+
function failed(result) {
|
|
29
|
+
if (result.error && result.code === null)
|
|
30
|
+
return tail(combinedOutput(result));
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function toLogin(parse, next) {
|
|
34
|
+
return { state: parse.state, next: parse.state === 'logged_out' || parse.state === 'unknown' ? next : null, raw: parse.raw };
|
|
35
|
+
}
|
|
36
|
+
function toM2(parse, nextFor) {
|
|
37
|
+
const next = parse.state === 'not_configured' ? { kind: 'configure' } : nextFor(parse.state, parse.entry ?? 'm2');
|
|
38
|
+
return { state: parse.state, entry: parse.entry, next, raw: parse.raw };
|
|
39
|
+
}
|
|
40
|
+
function plan(agent, deps) {
|
|
41
|
+
const cmd = agent.command ?? agent.path ?? agent.id;
|
|
42
|
+
const path = agent.path ?? cmd;
|
|
43
|
+
switch (agent.id) {
|
|
44
|
+
case 'claude':
|
|
45
|
+
return {
|
|
46
|
+
exec: { command: path, prefix: [] },
|
|
47
|
+
login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(cmd, ['auth', 'login']) },
|
|
48
|
+
m2: {
|
|
49
|
+
args: ['mcp', 'list'],
|
|
50
|
+
parse: (r) => parseClaudeMcpList(combinedOutput(r), r.code),
|
|
51
|
+
next: (state, entry) => state === 'needs_auth'
|
|
52
|
+
? run(cmd, ['mcp', 'login', entry])
|
|
53
|
+
: state === 'needs_enable'
|
|
54
|
+
? { kind: 'manual', text: `Run claude in ${deps.cwd} and approve the project MCP server "${entry}".` }
|
|
55
|
+
: null,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
case 'codex': {
|
|
59
|
+
const dir = deps.adapters.installDir('codex');
|
|
60
|
+
const display = join(dir, CODEX_BIN_LINK);
|
|
61
|
+
return {
|
|
62
|
+
exec: { command: deps.nodePath, prefix: [join(dir, CODEX_SCRIPT)] },
|
|
63
|
+
login: { args: ['login', 'status'], parse: (r) => parseCodexLoginStatus(combinedOutput(r), r.code), next: run(display, ['login']) },
|
|
64
|
+
m2: {
|
|
65
|
+
args: ['mcp', 'list', '--json'],
|
|
66
|
+
parse: (r) => parseCodexMcpListJson(r.stdout, r.code),
|
|
67
|
+
next: (state, entry) => state === 'needs_auth' || state === 'unverified'
|
|
68
|
+
? run(display, ['mcp', 'login', entry])
|
|
69
|
+
: state === 'needs_enable'
|
|
70
|
+
? { kind: 'manual', text: `Set enabled = true under [mcp_servers.${entry}] in your Codex config.toml.` }
|
|
71
|
+
: null,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
case 'cursor':
|
|
76
|
+
return {
|
|
77
|
+
exec: { command: path, prefix: [] },
|
|
78
|
+
login: { args: ['status'], parse: (r) => parseCursorStatus(combinedOutput(r)), next: run(cmd, ['login']) },
|
|
79
|
+
m2: {
|
|
80
|
+
args: ['mcp', 'list'],
|
|
81
|
+
parse: (r) => {
|
|
82
|
+
const found = findM2Names([join(deps.cwd, '.cursor', 'mcp.json'), join(deps.home, '.cursor', 'mcp.json')]);
|
|
83
|
+
// 配置文件坏了又找不到 m2:不能报 not_configured(会引导用户去重复添加),报 unknown 并说明是哪个文件
|
|
84
|
+
if (found.names.length === 0 && found.unreadable.length > 0) {
|
|
85
|
+
return { state: 'unknown', entry: null, raw: tail(`Could not read Cursor MCP config: ${found.unreadable.join('; ')}`) };
|
|
86
|
+
}
|
|
87
|
+
return parseCursorMcpList(combinedOutput(r), found.names, r.code);
|
|
88
|
+
},
|
|
89
|
+
next: (state, entry) => state === 'needs_auth'
|
|
90
|
+
? { kind: 'command', command: commandHint(cmd, ['mcp', 'login', entry], deps.cwd) }
|
|
91
|
+
: state === 'needs_enable'
|
|
92
|
+
? { kind: 'command', command: commandHint(cmd, ['mcp', 'enable', entry], deps.cwd) }
|
|
93
|
+
: null,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
case 'opencode':
|
|
97
|
+
return {
|
|
98
|
+
exec: { command: path, prefix: [] },
|
|
99
|
+
// 不登录也能用自带的免费模型(PoC 报告 §2.4),不检查
|
|
100
|
+
login: { args: null, parse: () => ({ state: 'not_required', raw: null }), next: null },
|
|
101
|
+
m2: {
|
|
102
|
+
args: ['mcp', 'list'],
|
|
103
|
+
parse: (r) => parseOpencodeMcpList(combinedOutput(r), r.code),
|
|
104
|
+
next: (state, entry) => state === 'needs_auth'
|
|
105
|
+
? run(cmd, ['mcp', 'auth', entry])
|
|
106
|
+
: state === 'needs_enable'
|
|
107
|
+
? { kind: 'manual', text: `Set "enabled": true for "${entry}" in your OpenCode config.` }
|
|
108
|
+
: null,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
case 'qoder':
|
|
112
|
+
return {
|
|
113
|
+
exec: { command: path, prefix: [] },
|
|
114
|
+
login: { args: ['status'], parse: (r) => parseQoderStatus(combinedOutput(r)), next: run(cmd, ['login']) },
|
|
115
|
+
m2: {
|
|
116
|
+
args: ['mcp', 'list'],
|
|
117
|
+
parse: (r) => parseQoderMcpList(combinedOutput(r), r.code),
|
|
118
|
+
// qodercli 没有 mcp login 子命令,只能在交互界面里授权
|
|
119
|
+
next: (state, entry) => (state === 'needs_auth' ? { kind: 'manual', text: `Run qodercli, type /mcp, choose "${entry}" and authorize it.` } : null),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function runStep(deps, p, args) {
|
|
125
|
+
return deps.exec(p.exec.command, [...p.exec.prefix, ...args], { env: deps.env, cwd: deps.cwd, timeoutMs: PRECHECK_TIMEOUT_MS });
|
|
126
|
+
}
|
|
127
|
+
export async function precheckM2(agent, deps) {
|
|
128
|
+
const p = plan(agent, deps);
|
|
129
|
+
const result = await runStep(deps, p, p.m2.args);
|
|
130
|
+
const failure = failed(result);
|
|
131
|
+
return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
|
|
132
|
+
}
|
|
133
|
+
/** 调用方保证 agent 已安装;适配器类 agent 的适配器已就绪 */
|
|
134
|
+
export async function precheckAgent(agent, deps, now = () => new Date()) {
|
|
135
|
+
const p = plan(agent, deps);
|
|
136
|
+
const loginArgs = p.login.args;
|
|
137
|
+
const [loginResult, m2] = await Promise.all([
|
|
138
|
+
loginArgs ? runStep(deps, p, loginArgs) : Promise.resolve(null),
|
|
139
|
+
precheckM2(agent, deps),
|
|
140
|
+
]);
|
|
141
|
+
let login;
|
|
142
|
+
if (!loginResult)
|
|
143
|
+
login = p.login.parse({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, error: null });
|
|
144
|
+
else {
|
|
145
|
+
const failure = failed(loginResult);
|
|
146
|
+
login = failure ? { state: 'unknown', raw: failure } : p.login.parse(loginResult);
|
|
147
|
+
}
|
|
148
|
+
return { agent: agent.id, login: toLogin(login, p.login.next), m2, checkedAt: now().toISOString() };
|
|
149
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent 子进程:拉起、stdio ↔ 行 的转发、按进程组清理。
|
|
3
|
+
*
|
|
4
|
+
* 边界:
|
|
5
|
+
* - 不解析 ACP。stdout 按 \n 切行(去掉行尾 \r、跳过空行),每行原样交给 onLine;写入时补一个 \n。
|
|
6
|
+
* - 每个 agent 是独立进程组(POSIX detached):适配器会再拉起 agent 本体(claude-agent-acp → claude、codex-acp → codex),
|
|
7
|
+
* 只杀直接子进程会留下孙进程。停止 = 对整组 SIGTERM,宽限期后仍在就 SIGKILL。
|
|
8
|
+
* - 进程属于拉起它的连接(ownerId):连接关闭时调用方对该 owner 调 stopOwner。bridge 退出时 killAllNow 同步清理,
|
|
9
|
+
* 这条也挂在 process 'exit' 钩子上(覆盖 process.exit 与未捕获异常);SIGINT / SIGTERM / SIGHUP 不触发 'exit',由 main.ts 显式处理。
|
|
10
|
+
* - Windows 的进程树清理用 taskkill /T /F,未实测(一期只在 macOS 实测)。
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
export const MAX_PROCESSES_PER_OWNER = 5;
|
|
15
|
+
export const MAX_PROCESSES_TOTAL = 10;
|
|
16
|
+
export const KILL_GRACE_MS = 3_000;
|
|
17
|
+
/** 单行累计超过这个长度仍无换行:认为 agent 输出异常,停止进程(防止内存无限增长) */
|
|
18
|
+
export const MAX_LINE_CHARS = 64 * 1024 * 1024;
|
|
19
|
+
const STDERR_KEEP = 8 * 1024;
|
|
20
|
+
const STDERR_REPORT = 4 * 1024;
|
|
21
|
+
export class ProcessError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function signalGroup(child, signal) {
|
|
29
|
+
if (child.pid === undefined)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.kill(-child.pid, signal);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
// ESRCH = 进程组已经不在了,预期内。其他错误(如 EPERM)不应出现:不抛(这里在退出回调里,抛出会让 bridge 崩溃),但要看得见
|
|
41
|
+
if (error.code !== 'ESRCH') {
|
|
42
|
+
console.warn(`Could not signal agent process group ${child.pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class ProcessManager {
|
|
47
|
+
options;
|
|
48
|
+
processes = new Map();
|
|
49
|
+
/** 已调用 spawn、还没等到 'spawn' 事件的进程(按 owner 计):也要计入上限,否则同一批并发的 start 能突破上限 */
|
|
50
|
+
starting = new Map();
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
}
|
|
54
|
+
countFor(ownerId) {
|
|
55
|
+
let count = this.starting.get(ownerId) ?? 0;
|
|
56
|
+
for (const managed of this.processes.values())
|
|
57
|
+
if (managed.ownerId === ownerId)
|
|
58
|
+
count++;
|
|
59
|
+
return count;
|
|
60
|
+
}
|
|
61
|
+
totalCount() {
|
|
62
|
+
let starting = 0;
|
|
63
|
+
for (const count of this.starting.values())
|
|
64
|
+
starting += count;
|
|
65
|
+
return this.processes.size + starting;
|
|
66
|
+
}
|
|
67
|
+
adjustStarting(ownerId, delta) {
|
|
68
|
+
const next = (this.starting.get(ownerId) ?? 0) + delta;
|
|
69
|
+
if (next > 0)
|
|
70
|
+
this.starting.set(ownerId, next);
|
|
71
|
+
else
|
|
72
|
+
this.starting.delete(ownerId);
|
|
73
|
+
}
|
|
74
|
+
/** 等到子进程真正启动('spawn' 事件)才返回,启动失败(如 ENOENT)以 ProcessError 拒绝 */
|
|
75
|
+
start(ownerId, agent, launch) {
|
|
76
|
+
if (this.countFor(ownerId) >= (this.options.maxPerOwner ?? MAX_PROCESSES_PER_OWNER)) {
|
|
77
|
+
return Promise.reject(new ProcessError('too_many_processes', 'too many agent processes for this page'));
|
|
78
|
+
}
|
|
79
|
+
if (this.totalCount() >= (this.options.maxTotal ?? MAX_PROCESSES_TOTAL)) {
|
|
80
|
+
return Promise.reject(new ProcessError('too_many_processes', 'too many agent processes on this bridge'));
|
|
81
|
+
}
|
|
82
|
+
const id = randomUUID();
|
|
83
|
+
this.adjustStarting(ownerId, 1);
|
|
84
|
+
let child;
|
|
85
|
+
try {
|
|
86
|
+
child = spawn(launch.command, launch.args, {
|
|
87
|
+
cwd: this.options.cwd,
|
|
88
|
+
env: this.options.env,
|
|
89
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
detached: process.platform !== 'win32',
|
|
91
|
+
windowsHide: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// 参数非法等同步异常(例如 cwd 不存在在部分平台上):同样按启动失败处理,并把计数还回去
|
|
96
|
+
this.adjustStarting(ownerId, -1);
|
|
97
|
+
return Promise.reject(new ProcessError('spawn_failed', `${launch.command}: ${error instanceof Error ? error.message : String(error)}`));
|
|
98
|
+
}
|
|
99
|
+
const managed = { id, ownerId, agent, child, stopping: false, tooLarge: false, stderr: '', killTimer: null };
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
let started = false;
|
|
102
|
+
child.once('spawn', () => {
|
|
103
|
+
started = true;
|
|
104
|
+
this.adjustStarting(ownerId, -1);
|
|
105
|
+
this.processes.set(id, managed);
|
|
106
|
+
this.wire(managed);
|
|
107
|
+
resolve(id);
|
|
108
|
+
});
|
|
109
|
+
// 用 on 而不是 once:启动后的 error 事件(极少见)没有监听器会让整个 bridge 崩溃;启动后的错误由 'close' 上报
|
|
110
|
+
child.on('error', (error) => {
|
|
111
|
+
if (started)
|
|
112
|
+
return;
|
|
113
|
+
this.adjustStarting(ownerId, -1);
|
|
114
|
+
reject(new ProcessError('spawn_failed', `${launch.command}: ${error.message}`));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
wire(managed) {
|
|
119
|
+
const { child } = managed;
|
|
120
|
+
let buffer = '';
|
|
121
|
+
child.stdin?.on('error', () => undefined); // agent 先退出时写入会 EPIPE,退出事件另行上报
|
|
122
|
+
child.stdout?.setEncoding('utf8');
|
|
123
|
+
const maxLine = this.options.maxLineChars ?? MAX_LINE_CHARS;
|
|
124
|
+
child.stdout?.on('data', (chunk) => {
|
|
125
|
+
// 已判定输出异常、正在停止:剩下的数据一律丢弃,不再切行、不再累积(否则超长行的尾巴会被当成一行发出去)
|
|
126
|
+
if (managed.tooLarge)
|
|
127
|
+
return;
|
|
128
|
+
buffer += chunk;
|
|
129
|
+
let index;
|
|
130
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
131
|
+
this.emitLine(managed, buffer.slice(0, index));
|
|
132
|
+
buffer = buffer.slice(index + 1);
|
|
133
|
+
}
|
|
134
|
+
if (buffer.length > maxLine) {
|
|
135
|
+
managed.tooLarge = true;
|
|
136
|
+
buffer = '';
|
|
137
|
+
this.stopManaged(managed);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
child.stderr?.setEncoding('utf8');
|
|
141
|
+
child.stderr?.on('data', (chunk) => {
|
|
142
|
+
managed.stderr = (managed.stderr + chunk).slice(-STDERR_KEEP);
|
|
143
|
+
this.options.onStderr?.(managed.id, managed.agent, chunk);
|
|
144
|
+
});
|
|
145
|
+
// 'close' 在 stdio 全部关闭后触发,保证最后几行先于退出事件送出
|
|
146
|
+
child.on('close', (code, signal) => {
|
|
147
|
+
if (buffer.length > 0 && !managed.tooLarge)
|
|
148
|
+
this.emitLine(managed, buffer);
|
|
149
|
+
buffer = '';
|
|
150
|
+
if (managed.killTimer)
|
|
151
|
+
clearTimeout(managed.killTimer);
|
|
152
|
+
this.processes.delete(managed.id);
|
|
153
|
+
// 进程组里可能还有孙进程(直接子进程退出不代表整组退出)。POSIX 下组里还有成员时 pgid 不会被复用,组空了是 ESRCH;
|
|
154
|
+
// Windows 没有进程组,taskkill 只能按 PID 找树,而进程已退出后 PID 可能很快被复用 —— 那里不做这一步,以免误杀无关进程
|
|
155
|
+
if (process.platform !== 'win32')
|
|
156
|
+
signalGroup(child, 'SIGKILL');
|
|
157
|
+
this.options.onExit(managed.ownerId, {
|
|
158
|
+
processId: managed.id,
|
|
159
|
+
agent: managed.agent,
|
|
160
|
+
code,
|
|
161
|
+
signal,
|
|
162
|
+
reason: managed.tooLarge ? 'output_too_large' : managed.stopping ? 'stopped' : 'exited',
|
|
163
|
+
stderrTail: managed.stderr.slice(-STDERR_REPORT),
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
emitLine(managed, raw) {
|
|
168
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
169
|
+
if (line.trim().length > 0)
|
|
170
|
+
this.options.onLine(managed.ownerId, managed.id, line);
|
|
171
|
+
}
|
|
172
|
+
/** 写入一行(调用方已保证不含换行)。进程不存在或不属于该 owner 时返回 false */
|
|
173
|
+
write(ownerId, processId, line) {
|
|
174
|
+
const managed = this.processes.get(processId);
|
|
175
|
+
if (!managed || managed.ownerId !== ownerId || managed.stopping)
|
|
176
|
+
return false;
|
|
177
|
+
managed.child.stdin?.write(`${line}\n`);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
stopManaged(managed) {
|
|
181
|
+
if (managed.stopping)
|
|
182
|
+
return;
|
|
183
|
+
managed.stopping = true;
|
|
184
|
+
managed.child.stdin?.end();
|
|
185
|
+
signalGroup(managed.child, 'SIGTERM');
|
|
186
|
+
managed.killTimer = setTimeout(() => signalGroup(managed.child, 'SIGKILL'), this.options.killGraceMs ?? KILL_GRACE_MS);
|
|
187
|
+
}
|
|
188
|
+
stop(ownerId, processId) {
|
|
189
|
+
const managed = this.processes.get(processId);
|
|
190
|
+
if (!managed || managed.ownerId !== ownerId)
|
|
191
|
+
return false;
|
|
192
|
+
this.stopManaged(managed);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
stopOwner(ownerId) {
|
|
196
|
+
for (const managed of this.processes.values())
|
|
197
|
+
if (managed.ownerId === ownerId)
|
|
198
|
+
this.stopManaged(managed);
|
|
199
|
+
}
|
|
200
|
+
stopAll() {
|
|
201
|
+
for (const managed of this.processes.values())
|
|
202
|
+
this.stopManaged(managed);
|
|
203
|
+
}
|
|
204
|
+
/** 同步、不等待:bridge 退出时用(包括 process 'exit' 钩子里) */
|
|
205
|
+
killAllNow() {
|
|
206
|
+
for (const managed of this.processes.values())
|
|
207
|
+
signalGroup(managed.child, 'SIGKILL');
|
|
208
|
+
}
|
|
209
|
+
get size() {
|
|
210
|
+
return this.processes.size;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机 ACP bridge ↔ M2 页面的 WebSocket 协议(change acp-local-bridge,给 change 3 acp-panel-local-agent 用)。
|
|
3
|
+
*
|
|
4
|
+
* 规范正文在 openspec 里该 change 的 design「页面协议」一节;本文件是它的类型落地,两者不一致时以 design 为准并修正这里。
|
|
5
|
+
*
|
|
6
|
+
* 维护约束:
|
|
7
|
+
* - 本文件同时被 bridge(Node,ES2022 / NodeNext)和前端(浏览器,ES2020 / bundler / noUncheckedIndexedAccess)编译,
|
|
8
|
+
* 所以只能有类型和常量:不 import 任何模块、不用 enum、不用 Node 或 DOM 专有类型。
|
|
9
|
+
* - 同一协议版本内只做加法(新方法、新事件、新可选字段、新枚举值)。删除字段或改变语义要升 BRIDGE_PROTOCOL_VERSION,
|
|
10
|
+
* bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
|
|
11
|
+
* - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单与会话范围检查(acp-guard.ts),
|
|
12
|
+
* 翻译全部在页面做。
|
|
13
|
+
* - 配对 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令);页面代码与 token 的保管要按这个级别对待。
|
|
14
|
+
*/
|
|
15
|
+
/** 本 bridge 支持的协议版本(页面在 hello 里给出自己支持的列表,bridge 选双方共有的最大值) */
|
|
16
|
+
export const BRIDGE_PROTOCOL_VERSION = 1;
|
|
17
|
+
export const BRIDGE_SUPPORTED_PROTOCOLS = [1];
|
|
18
|
+
/** 默认端口与路径。页面必须连 127.0.0.1(不是 localhost:localhost 可能先解析到 ::1,bridge 不监听 IPv6)。 */
|
|
19
|
+
export const BRIDGE_DEFAULT_PORT = 47821;
|
|
20
|
+
export const BRIDGE_WS_PATH = '/bridge';
|
|
21
|
+
/** 与前端 src/lib/agent-types 的 AgentType 取值一致(是它的子集),顺序即 agents/list 的返回顺序 */
|
|
22
|
+
export const BRIDGE_AGENT_IDS = ['claude', 'codex', 'cursor', 'opencode', 'qoder'];
|
|
23
|
+
/**
|
|
24
|
+
* 页面可以发给 agent 的 ACP 方法(bridge 的 acp-guard.ts 按它放行,名单外回 `acp_rejected`、不转发)。
|
|
25
|
+
* 页面对 agent 请求的响应(没有 method 键,例如审批结果)不受它限制。页面要用新方法:先在这里加上并发新版 bridge,
|
|
26
|
+
* 页面按 welcome 的 `bridge.version` 判断对方是否支持。
|
|
27
|
+
*/
|
|
28
|
+
export const BRIDGE_PAGE_ACP_METHODS = [
|
|
29
|
+
'initialize',
|
|
30
|
+
'session/new',
|
|
31
|
+
'session/load',
|
|
32
|
+
'session/resume',
|
|
33
|
+
'session/list',
|
|
34
|
+
'session/prompt',
|
|
35
|
+
'session/cancel',
|
|
36
|
+
'session/set_mode',
|
|
37
|
+
'session/set_config_option',
|
|
38
|
+
'session/close',
|
|
39
|
+
'$/cancel_request',
|
|
40
|
+
];
|
|
41
|
+
export const BRIDGE_METHODS = [
|
|
42
|
+
'pair',
|
|
43
|
+
'agents/list',
|
|
44
|
+
'agent/prepare',
|
|
45
|
+
'agent/precheck',
|
|
46
|
+
'm2/configure',
|
|
47
|
+
'process/start',
|
|
48
|
+
'process/stop',
|
|
49
|
+
];
|
|
50
|
+
export const BRIDGE_CLOSE_CODES = {
|
|
51
|
+
normal: 1000,
|
|
52
|
+
goingAway: 1001,
|
|
53
|
+
unsupportedData: 1003,
|
|
54
|
+
/** 单帧超过 64MB(ws 库自动发出) */
|
|
55
|
+
messageTooBig: 1009,
|
|
56
|
+
/** 握手阶段 bridge 内部出错 */
|
|
57
|
+
internalError: 1011,
|
|
58
|
+
badHandshake: 4400,
|
|
59
|
+
timeout: 4408,
|
|
60
|
+
protocolUnsupported: 4426,
|
|
61
|
+
tooManyUnpaired: 4429,
|
|
62
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机 ACP bridge 的连接准入判定(纯函数)。在 HTTP upgrade 阶段、完成 WebSocket 握手之前调用。
|
|
3
|
+
*
|
|
4
|
+
* 这里管的是「谁能连上来」:Origin 白名单与只接受本机连接(第三道「配对 token」在 pairing.ts)。
|
|
5
|
+
* 浏览器这层不可依赖:开了本机代理的用户,Chrome 不会弹本地网络访问框(ACP PoC 报告 §3),
|
|
6
|
+
* 所以任何网站都能尝试连 127.0.0.1,只能靠 bridge 自己挡。
|
|
7
|
+
* 连上并配对之后页面能做什么,见 server.ts / acp-guard.ts 的文件头:配对 = 授权该页面驱动本机 agent。
|
|
8
|
+
*
|
|
9
|
+
* 维护约束:
|
|
10
|
+
* - Origin 必须精确匹配(scheme + host + port 全等)。不要改成后缀匹配、正则或「包含 m2ui.ai」——
|
|
11
|
+
* `https://m2ui.ai.evil.com`、`https://evil.m2ui.ai` 都必须被拒。
|
|
12
|
+
* - 默认只放行正式域名。开发域名只在 `bridge --dev` 时放行:`localhost:5173` 是所有本机 Vite 项目共用的 Origin、
|
|
13
|
+
* 共用 localStorage,给开发版 M2 配对过后,任何跑在 5173 的项目(包括它的依赖)都能读走 token 免码连上
|
|
14
|
+
* (review r3 B2,用户 2026-10-02 拍板改为 --dev,推翻了最初的「开发域名常开」)。名单写死在包里,不提供任意扩展参数。
|
|
15
|
+
* - Host 校验防 DNS rebinding:攻击者域名解析到 127.0.0.1 时,Host 头是攻击者域名。
|
|
16
|
+
*/
|
|
17
|
+
import { BRIDGE_WS_PATH } from './protocol.js';
|
|
18
|
+
/** www.m2ui.ai 由 m2-router 301 到根域,页面 Origin 不会是它,所以不列 */
|
|
19
|
+
export const PROD_ORIGINS = ['https://m2ui.ai'];
|
|
20
|
+
/** 本地开发 M2 用(Vite 默认端口);只有 `bridge --dev` 才放行 */
|
|
21
|
+
export const DEV_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
22
|
+
export function allowedOrigins(dev) {
|
|
23
|
+
return dev ? [...PROD_ORIGINS, ...DEV_ORIGINS] : PROD_ORIGINS;
|
|
24
|
+
}
|
|
25
|
+
export function isAllowedOrigin(origin, origins) {
|
|
26
|
+
return typeof origin === 'string' && origins.includes(origin);
|
|
27
|
+
}
|
|
28
|
+
/** 只接受 `127.0.0.1:<port>` 与 `localhost:<port>`;port 是实际监听端口(测试里可能是随机端口) */
|
|
29
|
+
export function isAllowedHost(host, port) {
|
|
30
|
+
return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
|
|
31
|
+
}
|
|
32
|
+
/** 服务只监听 127.0.0.1,这里是第二道确认(例如以后有人把监听地址改宽了) */
|
|
33
|
+
export function isLoopbackAddress(address) {
|
|
34
|
+
return address === '127.0.0.1' || address === '::ffff:127.0.0.1' || address === '::1';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 拒绝时返回 HTTP 状态,由调用方直接写回并销毁 socket,不完成握手。
|
|
38
|
+
* 403 而不是「握手后再关」:不对非白名单页面完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
|
|
39
|
+
* 任何网站用 no-cors 请求 http://127.0.0.1:<port>/ 都能分辨「有响应」与「连接被拒」,只暴露这一点信息。
|
|
40
|
+
*/
|
|
41
|
+
export function checkUpgrade(info, port, origins) {
|
|
42
|
+
const pathname = (info.path ?? '').split('?')[0];
|
|
43
|
+
if (pathname !== BRIDGE_WS_PATH)
|
|
44
|
+
return { ok: false, status: 404, reason: `unknown path ${pathname}` };
|
|
45
|
+
if (!isLoopbackAddress(info.remoteAddress))
|
|
46
|
+
return { ok: false, status: 403, reason: `remote ${info.remoteAddress ?? 'unknown'}` };
|
|
47
|
+
if (!isAllowedHost(info.host, port))
|
|
48
|
+
return { ok: false, status: 403, reason: `host ${info.host ?? 'missing'}` };
|
|
49
|
+
if (!isAllowedOrigin(info.origin, origins))
|
|
50
|
+
return { ok: false, status: 403, reason: `origin ${info.origin ?? 'missing'}` };
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|