@m2canvas/connect 0.5.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.
@@ -8,17 +8,22 @@
8
8
  * - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径)在完成握手之前,失败直接写 HTTP 状态并销毁 socket。
9
9
  * - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
10
10
  * 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
11
- * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
11
+ * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
12
+ * agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(分派在 auth-call.ts,命令清单写死在 authorize.ts)。
12
13
  * - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
13
14
  *
14
- * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责),不替用户登录。
15
+ * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责)。
16
+ * 登录 / m2 授权只代跑写死清单里的命令,授权页由用户在浏览器里亲自完成(authorize.ts);授权链接只发给发起的连接,不进终端日志。
15
17
  */
16
18
  import { createServer } from 'node:http';
17
19
  import { randomUUID } from 'node:crypto';
18
20
  import { WebSocketServer } from 'ws';
19
21
  import { TOKEN_IDLE_DAYS } from './pairing.js';
20
22
  import { ProcessError, ProcessManager } from './processes.js';
21
- import { BRIDGE_AGENT_IDS, BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
23
+ import { isBridgeAgentId } from './agents.js';
24
+ import { handleAuthCall } from './auth-call.js';
25
+ import { AuthorizationManager } from './authorize.js';
26
+ import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
22
27
  import { checkPageAcpLine } from './acp-guard.js';
23
28
  import { checkUpgrade } from './security.js';
24
29
  import { BridgeFailure } from './service.js';
@@ -32,9 +37,6 @@ const MAX_CALL_ID = 64;
32
37
  function isObject(value) {
33
38
  return typeof value === 'object' && value !== null && !Array.isArray(value);
34
39
  }
35
- function isAgentId(value) {
36
- return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
37
- }
38
40
  function isMethod(value) {
39
41
  return typeof value === 'string' && BRIDGE_METHODS.includes(value);
40
42
  }
@@ -77,6 +79,23 @@ export function startBridgeServer(options) {
77
79
  },
78
80
  onStderr: options.verbose ? (_id, agent, text) => process.stderr.write(`[${agent}] ${text}`) : undefined,
79
81
  });
82
+ const authorizations = new AuthorizationManager({
83
+ timeoutMs: options.timing?.authTimeoutMs,
84
+ graceMs: options.timing?.authGraceMs,
85
+ pollMs: options.timing?.authPollMs,
86
+ });
87
+ const authContext = {
88
+ authorizations,
89
+ service: options.service,
90
+ log,
91
+ isConnected: (ownerId) => connections.has(ownerId),
92
+ // 授权链接只发给发起的连接(D4)
93
+ sendLink: (ownerId, data) => {
94
+ const owner = connections.get(ownerId);
95
+ if (owner)
96
+ send(owner, { type: 'event', event: 'auth/link', data });
97
+ },
98
+ };
80
99
  const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
81
100
  const markPaired = (conn) => {
82
101
  conn.paired = true;
@@ -111,8 +130,10 @@ export function startBridgeServer(options) {
111
130
  }
112
131
  if (method === 'agents/list')
113
132
  return { agents: await options.service.list() };
133
+ if (method === 'agent/authorize' || method === 'agent/authorize-cancel')
134
+ return handleAuthCall(authContext, conn.id, method, params);
114
135
  const agent = params.agent;
115
- if (!isAgentId(agent))
136
+ if (!isBridgeAgentId(agent))
116
137
  throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
117
138
  switch (method) {
118
139
  case 'agent/prepare':
@@ -296,12 +317,12 @@ export function startBridgeServer(options) {
296
317
  clearTimeout(conn.timer);
297
318
  connections.delete(conn.id);
298
319
  processes.stopOwner(conn.id);
320
+ authorizations.cancelOwner(conn.id);
299
321
  });
300
322
  // ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
301
323
  ws.on('error', () => undefined);
302
324
  });
303
325
  });
304
- // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
305
326
  // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
306
327
  // 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
307
328
  const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
@@ -332,25 +353,33 @@ export function startBridgeServer(options) {
332
353
  },
333
354
  };
334
355
  })();
335
- const heartbeat = setInterval(() => {
336
- for (const conn of connections.values()) {
337
- if (!conn.alive) {
338
- conn.ws.terminate();
339
- continue;
340
- }
341
- conn.alive = false;
342
- conn.ws.ping();
343
- }
344
- }, timing.pingIntervalMs);
345
356
  return new Promise((resolve, reject) => {
346
- http.once('error', reject);
357
+ // 监听失败(端口被占)时停掉已经开始的计时器:--replace 会在同一进程里再监听一次(review r4 N4)。心跳在监听成功后才开始
358
+ const fail = (error) => {
359
+ rejectLog.stop();
360
+ wss.close();
361
+ reject(error);
362
+ };
363
+ http.once('error', fail);
347
364
  http.listen(options.port, '127.0.0.1', () => {
348
- http.off('error', reject);
365
+ http.off('error', fail);
366
+ // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
367
+ const heartbeat = setInterval(() => {
368
+ for (const conn of connections.values()) {
369
+ if (!conn.alive) {
370
+ conn.ws.terminate();
371
+ continue;
372
+ }
373
+ conn.alive = false;
374
+ conn.ws.ping();
375
+ }
376
+ }, timing.pingIntervalMs);
349
377
  const address = http.address();
350
378
  resolve({
351
379
  port: address.port,
352
380
  address: address.address,
353
381
  processes,
382
+ authorizations,
354
383
  broadcast(event, data) {
355
384
  for (const conn of connections.values())
356
385
  if (conn.paired)
@@ -359,6 +388,8 @@ export function startBridgeServer(options) {
359
388
  close() {
360
389
  clearInterval(heartbeat);
361
390
  rejectLog.stop();
391
+ // 先于关闭连接:标记取消,被杀掉的授权命令不会被当成「失败」再去重跑预检
392
+ authorizations.killAllNow();
362
393
  for (const conn of connections.values())
363
394
  conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is shutting down');
364
395
  processes.stopAll();
@@ -1,19 +1,29 @@
1
1
  /**
2
- * 页面方法背后的 agent 操作:列表、准备适配器、预检、写 m2 配置、生成启动规格。server.ts 只做协议,这里只做业务。
2
+ * 页面方法背后的 agent 操作:列表、准备适配器、预检、写 m2 配置、生成启动规格与授权命令计划。server.ts 只做协议,这里只做业务。
3
3
  *
4
4
  * 前置条件统一在这里检查,错误以 BridgeFailure(带协议错误码)抛出:
5
- * - 未安装 → agent_not_installed(安装命令与文档链接由前端提供,bridge 不代装);
5
+ * - 拉不起来 → agent_not_installed(安装命令与文档链接由前端提供,bridge 不代装)。claude / codex 用适配器自带的程序,
6
+ * 本机没装命令行也能拉起(D11:`available` 恒为 true;`installed` 仍只表示本机检测到了命令行);
6
7
  * - 适配器类 agent 未就绪 → adapter_not_ready(页面要先调 agent/prepare)。
7
8
  *
8
9
  * m2/configure 以子进程运行本包自己的 cli.js `--client <id> --yes`:stdin 不是 TTY,cli.js 走现有的非交互分支
9
10
  * (只写配置、不跑登录、退出码 0 = 全部写入或已存在)。这样一字不改 clients.ts,也不让它的 spawnSync 卡住转发。
10
- * 改 cli.js 非交互分支的行为时要同步检查这里。
11
+ * 改 cli.js 非交互分支的行为时要同步检查这里。clients.ts 只认 PATH 上跑得起来的 claude / codex,所以对这两家把自带程序所在目录
12
+ * 和 bridge 自己的 node 排到 PATH 最前(D10),本机没装命令行也能写配置。
13
+ *
14
+ * 授权(change acp-terminal-free-auth):authorizePlan 按写死清单生成命令(authorize.ts),m2 的配置项名只取 bridge 自己的 m2 预检:
15
+ * 优先用最近一次预检 / 写配置时得到的名字(D19,页面先预检才会显示按钮,省掉一次重跑 —— Claude 的 `mcp list` 实测约 5 秒),没有记录时重跑;
16
+ * 预检 / 写配置的结果按同一判据带上 `authorize` 能力,页面只在能力存在时才调用 agent/authorize。
11
17
  */
18
+ import { dirname, join } from 'node:path';
12
19
  import { ADAPTERS, isAdapterAgent } from './adapters.js';
20
+ import { BridgeFailure } from './errors.js';
13
21
  import { LAUNCH_KIND, NATIVE_ACP_ARGS } from './agents.js';
22
+ import { authCapability, buildAuthPlan, isSafeAuthEntry } from './authorize.js';
23
+ import { prependPath } from './env.js';
14
24
  import { combinedOutput } from './exec.js';
15
25
  import { precheckAgent, precheckM2 } from './precheck.js';
16
- import { BRIDGE_AGENT_IDS } from './protocol.js';
26
+ import { BRIDGE_AGENT_IDS, } from './protocol.js';
17
27
  export const CONFIGURE_TIMEOUT_MS = 120_000;
18
28
  /**
19
29
  * Claude 适配器(claude-agent-acp@0.84.0)声明 `node >=22`,实测在 Node 20 上可用(ACP PoC 与本 change 的端到端都是 Node 20);
@@ -21,15 +31,7 @@ export const CONFIGURE_TIMEOUT_MS = 120_000;
21
31
  */
22
32
  export const MIN_NODE_MAJOR_FOR_CLAUDE = 20;
23
33
  const CONFIGURE_OUTPUT_LIMIT = 4096;
24
- export class BridgeFailure extends Error {
25
- code;
26
- data;
27
- constructor(code, message, data) {
28
- super(message);
29
- this.code = code;
30
- this.data = data;
31
- }
32
- }
34
+ export { BridgeFailure };
33
35
  /** bridge 的 agent id → connect `--client` 的取值 */
34
36
  const CLIENT_ID = {
35
37
  claude: 'claude-code',
@@ -40,25 +42,74 @@ const CLIENT_ID = {
40
42
  };
41
43
  export function createAgentService(deps) {
42
44
  const precheckDeps = { ...deps, env: deps.probeEnv };
43
- async function installed(agent) {
45
+ /**
46
+ * 最近一次 m2 预检得到的配置项名(D19)。只由 bridge 自己的预检写入,页面仍不能指定(契约 3);
47
+ * 最近一次没有名字(未配置、读不出)就清掉,授权时重跑。名字过期(预检到点击之间用户改了配置)时,授权命令失败并带输出(再点一次即可:命令结束后的预检已经刷新了记录),
48
+ * 或者针对同名的新配置项(用户在授权页上看得到授权对象)。Claude / Codex / Cursor 实测名字不存在时退出码 1;OpenCode 不是,所以它不用记录(见下)。
49
+ */
50
+ const m2Entries = new Map();
51
+ /**
52
+ * 授权前总是重新检查、不用记录的 agent(D21)。OpenCode 的 m2 授权先 `mcp logout <entry>`:名字过期时可能删掉同名的别的服务器的凭证;
53
+ * 而且名字不存在时 `mcp logout` / `mcp auth` 都以退出码 0 结束(实测 1.18.5),bridge 会判成成功。多 2–4 秒,换来名字一定是当下的。
54
+ */
55
+ const alwaysRecheck = new Set(['opencode']);
56
+ function remember(agent, m2) {
57
+ if (m2.state !== 'not_configured' && m2.entry)
58
+ m2Entries.set(agent, m2.entry);
59
+ else
60
+ m2Entries.delete(agent);
61
+ return m2;
62
+ }
63
+ /** 能拉起(D11):claude / codex 用适配器自带的程序,不要求本机命令行;其余三家要本机装了 */
64
+ async function available(agent) {
44
65
  const detected = await deps.detect(agent);
45
- if (!detected.installed)
66
+ if (!isAdapterAgent(agent) && !detected.installed)
46
67
  throw new BridgeFailure('agent_not_installed', `${agent} is not installed on this machine`);
47
68
  return detected;
48
69
  }
49
70
  async function ready(agent) {
50
- const detected = await installed(agent);
71
+ const detected = await available(agent);
51
72
  if (isAdapterAgent(agent) && !deps.adapters.isReady(agent)) {
52
73
  throw new BridgeFailure('adapter_not_ready', `call agent/prepare for ${agent} first`, { adapter: deps.adapters.info(agent) });
53
74
  }
54
75
  return detected;
55
76
  }
77
+ const capability = (agent, action) => authCapability(agent, action, {
78
+ platform: deps.platform,
79
+ expectAvailable: deps.expectAvailable(),
80
+ claudeBinary: agent === 'claude' ? deps.adapters.claudeBinary() : null,
81
+ });
82
+ /** 预检 / 写配置结果带上「能不能代跑」:与 authorizePlan 用同一判据(含 m2 配置项名的校验),页面看到按钮就一定能调用 */
83
+ function withCapability(check, agent, action) {
84
+ const authorize = capability(agent, action);
85
+ const entry = 'entry' in check ? check.entry : null;
86
+ if (!authorize || (entry !== null && !isSafeAuthEntry(entry)))
87
+ return check;
88
+ return { ...check, authorize };
89
+ }
90
+ const withCapabilities = (result) => ({
91
+ ...result,
92
+ login: withCapability(result.login, result.agent, 'login'),
93
+ m2: withCapability(result.m2, result.agent, 'm2'),
94
+ });
95
+ /** D10:clients.ts 只认 PATH 上能跑起来的 claude / codex */
96
+ function configureEnv(agent) {
97
+ const node = dirname(deps.nodePath);
98
+ if (agent === 'claude') {
99
+ const claude = deps.adapters.claudeBinary();
100
+ return prependPath(deps.probeEnv, claude ? [dirname(claude), node] : [node], deps.platform);
101
+ }
102
+ if (agent === 'codex')
103
+ return prependPath(deps.probeEnv, [join(deps.adapters.installDir('codex'), 'node_modules', '.bin'), node], deps.platform);
104
+ return deps.probeEnv;
105
+ }
56
106
  return {
57
107
  async list() {
58
108
  const detected = await Promise.all(BRIDGE_AGENT_IDS.map((id) => deps.detect(id)));
59
109
  return detected.map((agent) => ({
60
110
  id: agent.id,
61
111
  installed: agent.installed,
112
+ available: isAdapterAgent(agent.id) || agent.installed,
62
113
  path: agent.path,
63
114
  version: agent.version,
64
115
  launch: LAUNCH_KIND[agent.id],
@@ -66,7 +117,7 @@ export function createAgentService(deps) {
66
117
  }));
67
118
  },
68
119
  async prepare(agent) {
69
- await installed(agent);
120
+ await available(agent);
70
121
  if (!isAdapterAgent(agent))
71
122
  return null;
72
123
  const major = Number(deps.nodeVersion.split('.')[0]);
@@ -80,12 +131,14 @@ export function createAgentService(deps) {
80
131
  return info;
81
132
  },
82
133
  async precheck(agent) {
83
- return precheckAgent(await ready(agent), precheckDeps);
134
+ const result = await precheckAgent(await ready(agent), precheckDeps);
135
+ remember(agent, result.m2);
136
+ return withCapabilities(result);
84
137
  },
85
138
  async configure(agent) {
86
139
  const detected = await ready(agent);
87
140
  const result = await deps.exec(deps.nodePath, [deps.cliPath, '--client', CLIENT_ID[agent], '--yes'], {
88
- env: deps.probeEnv,
141
+ env: configureEnv(agent),
89
142
  cwd: deps.cwd,
90
143
  timeoutMs: CONFIGURE_TIMEOUT_MS,
91
144
  });
@@ -93,7 +146,7 @@ export function createAgentService(deps) {
93
146
  return {
94
147
  ok: result.code === 0,
95
148
  output: output.length > CONFIGURE_OUTPUT_LIMIT ? output.slice(-CONFIGURE_OUTPUT_LIMIT) : output,
96
- m2: await precheckM2(detected, precheckDeps),
149
+ m2: withCapability(remember(agent, await precheckM2(detected, precheckDeps)), agent, 'm2'),
97
150
  };
98
151
  },
99
152
  async launch(agent) {
@@ -105,5 +158,35 @@ export function createAgentService(deps) {
105
158
  throw new BridgeFailure('internal', `no executable path for ${agent}`);
106
159
  return { command: detected.path, args: NATIVE_ACP_ARGS[agent] };
107
160
  },
161
+ async authorizePlan(agent, action) {
162
+ const verb = action === 'login' ? 'sign-in' : 'M2 authorization';
163
+ if (deps.platform !== 'darwin')
164
+ throw new BridgeFailure('auth_unavailable', `the bridge only runs ${verb} on macOS; use the terminal command instead`);
165
+ const detected = await ready(agent);
166
+ if (!capability(agent, action))
167
+ throw new BridgeFailure('auth_unavailable', `the bridge cannot run ${verb} for ${agent}; use the terminal command instead`);
168
+ let entry = null;
169
+ if (action === 'm2') {
170
+ // 配置项名不一定叫 m2,也不能由页面指定:用 bridge 最近一次预检的结果,没有就重跑(D19);OpenCode 总是重跑(D21)
171
+ const rechecked = alwaysRecheck.has(agent) || !m2Entries.has(agent) ? remember(agent, await precheckM2(detected, precheckDeps)) : null;
172
+ entry = m2Entries.get(agent) ?? null;
173
+ if (!entry) {
174
+ // 读不出(unknown)不等于没配置:不能叫已经配置了 M2 的用户「先添加」(review r5 R5-N5)
175
+ const why = rechecked?.state === 'unknown' ? `couldn't read ${agent}'s MCP config; check again, or use the terminal command` : `add M2 to ${agent}'s MCP config first (m2/configure)`;
176
+ throw new BridgeFailure('auth_unavailable', why);
177
+ }
178
+ }
179
+ return buildAuthPlan({
180
+ agent,
181
+ action,
182
+ detected,
183
+ entry,
184
+ cwd: deps.cwd,
185
+ env: deps.probeEnv,
186
+ nodePath: deps.nodePath,
187
+ claudeBinary: agent === 'claude' ? deps.adapters.claudeBinary() : null,
188
+ codexDir: deps.adapters.installDir('codex'),
189
+ });
190
+ },
108
191
  };
109
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command, and run the local bridge that lets M2 drive them over ACP.",
5
5
  "type": "module",
6
6
  "bin": {