@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.
@@ -3,7 +3,11 @@
3
3
  * 和本机其他进程(可以伪造 Origin)。它们都拿不到只出现在终端里的配对码。
4
4
  * 配对 = 授权该页面以用户的身份驱动本机 agent(包括执行命令),所以码和 token 都按这个级别保管。
5
5
  *
6
- * 流程:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
6
+ * 两条途径,都要证明「终端用户同意了这个页面」(change acp-bridge-command-pairing):
7
+ * - 输码:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
8
+ * - 命令承诺:页面生成密钥 S,启动命令只带承诺 c = base64url(sha256(S) 的前 16 字节)(`bridge --pair c`);
9
+ * 页面在 hello 里出示 S,一致就换取同样的 token。「在自己终端里运行了这个页面给的命令」与「抄了终端里的码」证明的是同一件事。
10
+ * 命令会进 `ps` 与 shell 历史,所以命令里只能有承诺、不能有 S;承诺只在内存里、自挂上起 CODE_TTL_MS 有效、成功一次即作废。
7
11
  *
8
12
  * 维护约束:
9
13
  * - 本地只存 token 的 sha256,不存明文;文件 0600、目录 0700。
@@ -33,6 +37,18 @@ const PROMPT_DEDUPE_MS = 60_000;
33
37
  /** Crockford base32:去掉易混的 I、L、O、U */
34
38
  const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
35
39
  const CODE_LENGTH = 8;
40
+ /** 页面密钥 S:32 字节随机数的 base64url(无填充) */
41
+ const PAIR_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
42
+ /** 承诺:sha256(S) 前 16 字节的 base64url(无填充) */
43
+ const PAIR_COMMITMENT_RE = /^[A-Za-z0-9_-]{22}$/;
44
+ const COMMITMENT_BYTES = 16;
45
+ export function isPairCommitment(value) {
46
+ return PAIR_COMMITMENT_RE.test(value);
47
+ }
48
+ /** 承诺 = base64url(sha256(S 的 UTF-8) 的前 16 字节)。前端 `bridge-pair-secret.ts` 用 WebCrypto 算同一个值,改这里要同批改那边。 */
49
+ export function pairCommitmentOf(secret) {
50
+ return sha256(secret).subarray(0, COMMITMENT_BYTES).toString('base64url');
51
+ }
36
52
  /** 用户输入归一:大小写、空格、连字符;按 Crockford 规则把 O 视同 0、I / L 视同 1 */
37
53
  export function normalizePairingCode(input) {
38
54
  return input
@@ -57,6 +73,10 @@ function errorText(error) {
57
73
  }
58
74
  export class PairingManager {
59
75
  active = null;
76
+ /** `--pair` 挂上的承诺(只在内存里:bridge 重启即失效,重新运行同一条命令会重新挂上) */
77
+ commitment = null;
78
+ /** 已用掉的承诺:同一个密钥再出现时告警(只在内存里,只记最近一个) */
79
+ consumed = null;
60
80
  /** 自上次成功配对以来累计的错码次数(跨码、跨过期);配对成功时清零 */
61
81
  failuresSinceSuccess = 0;
62
82
  locked = false;
@@ -67,12 +87,14 @@ export class PairingManager {
67
87
  onCodeIssued;
68
88
  onLocked;
69
89
  onProblem;
90
+ onCommitmentReused;
70
91
  constructor(options) {
71
92
  this.store = options.store;
72
93
  this.now = options.now ?? Date.now;
73
94
  this.onCodeIssued = options.onCodeIssued;
74
95
  this.onLocked = options.onLocked ?? (() => undefined);
75
96
  this.onProblem = options.onProblem ?? (() => undefined);
97
+ this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
76
98
  }
77
99
  /** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式 */
78
100
  issueCode(reason, origin = null) {
@@ -152,6 +174,59 @@ export class PairingManager {
152
174
  this.issueCode('too_many_failures', origin);
153
175
  return { ok: false, code: 'pairing_code_rotated' };
154
176
  }
177
+ /** 挂上启动命令里的承诺,返回它的过期时刻。格式不对时抛出:由 `--pair` 的参数解析先挡掉。 */
178
+ armCommitment(commitment) {
179
+ if (!isPairCommitment(commitment))
180
+ throw new Error('invalid pairing commitment');
181
+ const armedAt = this.now();
182
+ this.commitment = { value: Buffer.from(commitment, 'base64url'), armedAt };
183
+ return armedAt + CODE_TTL_MS;
184
+ }
185
+ /**
186
+ * 页面在 hello 里出示 S。对不上不计入错码次数:S 有 32 字节、承诺有 128 bit,不存在可猜性,
187
+ * 计入只会让多标签页、旧 S 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
188
+ * 签发 token 写盘失败时不抛出(hello 里抛出会以 1011 关闭,等待中的页面会反复撞上)、不消费承诺,交给调用方按未配对处理。
189
+ */
190
+ pairWithSecret(secret, origin) {
191
+ const commitment = this.commitment;
192
+ // 重现检查在锁定检查之前:否则抢先配对的一方再用错码把 bridge 锁住,就能压掉这次告警(review r2 S1)
193
+ if (!commitment)
194
+ return this.checkReused(secret, origin);
195
+ if (this.locked)
196
+ return { ok: false, reason: 'locked' };
197
+ if (this.now() - commitment.armedAt > CODE_TTL_MS) {
198
+ this.commitment = null;
199
+ return { ok: false, reason: 'expired' };
200
+ }
201
+ if (!PAIR_SECRET_RE.test(secret))
202
+ return { ok: false, reason: 'malformed' };
203
+ if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), commitment.value))
204
+ return { ok: false, reason: 'mismatch' };
205
+ let token;
206
+ try {
207
+ token = this.createToken(origin);
208
+ }
209
+ catch (error) {
210
+ this.onProblem(`Could not save the pairing (${errorText(error)}). This page will be asked for a pairing code instead.`);
211
+ return { ok: false, reason: 'store_failed' };
212
+ }
213
+ this.commitment = null;
214
+ this.consumed = { value: commitment.value, origin, usedAt: this.now(), warned: false };
215
+ this.failuresSinceSuccess = 0;
216
+ return { ok: true, token };
217
+ }
218
+ checkReused(secret, origin) {
219
+ const consumed = this.consumed;
220
+ if (!consumed || !PAIR_SECRET_RE.test(secret))
221
+ return { ok: false, reason: 'no_commitment' };
222
+ if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), consumed.value))
223
+ return { ok: false, reason: 'no_commitment' };
224
+ if (!consumed.warned) {
225
+ consumed.warned = true;
226
+ this.onCommitmentReused({ origin, firstOrigin: consumed.origin, usedAt: consumed.usedAt });
227
+ }
228
+ return { ok: false, reason: 'reused' };
229
+ }
155
230
  createToken(origin) {
156
231
  const token = randomBytes(32).toString('base64url');
157
232
  const nowIso = new Date(this.now()).toISOString();
@@ -4,15 +4,19 @@
4
4
  * 为什么要预检:m2 没配置、没授权、还没连上时,ACP 里没有任何信号,agent 只会说「没有这个工具」(PoC 报告 §4.1)。
5
5
  *
6
6
  * 边界:
7
- * - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查);bridge 不替用户登录、不替用户授权,只返回命令让用户在自己的终端执行。
7
+ * - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查)。
8
+ * 预检本身不登录、不授权,只返回命令(终端兜底);代跑写死清单里的授权命令是 agent/authorize 的事(authorize.ts),
9
+ * 预检结果里的 `authorize` 能力由 service.ts 加上。
8
10
  * - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
9
11
  * - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
10
12
  * - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
11
13
  * - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
14
+ * - Claude 同样用适配器自带的 claude(D9):会话用的就是它,本机那份只决定兜底命令怎么显示。找不到自带程序时报 unknown,
15
+ * 不退回本机的 claude(否则预检和会话看的是两个程序)。
12
16
  */
13
17
  import { join } from 'node:path';
14
18
  import { findM2Names } from './m2-config.js';
15
- import { CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
19
+ import { ADAPTERS, CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
16
20
  import { combinedOutput } from './exec.js';
17
21
  import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
18
22
  export const PRECHECK_TIMEOUT_MS = 45_000;
@@ -41,20 +45,27 @@ function plan(agent, deps) {
41
45
  const cmd = agent.command ?? agent.path ?? agent.id;
42
46
  const path = agent.path ?? cmd;
43
47
  switch (agent.id) {
44
- case 'claude':
48
+ case 'claude': {
49
+ const binary = deps.adapters.claudeBinary();
50
+ // 兜底命令:本机装了 claude 就显示 `claude …`(用户熟悉),否则显示自带程序的绝对路径(D9)
51
+ const shown = agent.installed ? cmd : (binary ?? cmd);
45
52
  return {
46
- exec: { command: path, prefix: [] },
47
- login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(cmd, ['auth', 'login']) },
53
+ exec: { command: binary ?? '', prefix: [] },
54
+ missing: binary
55
+ ? undefined
56
+ : `Could not find the Claude Code program bundled with ${ADAPTERS.claude.package}@${ADAPTERS.claude.version}; try reinstalling the adapter.`,
57
+ login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(shown, ['auth', 'login']) },
48
58
  m2: {
49
59
  args: ['mcp', 'list'],
50
60
  parse: (r) => parseClaudeMcpList(combinedOutput(r), r.code),
51
61
  next: (state, entry) => state === 'needs_auth'
52
- ? run(cmd, ['mcp', 'login', entry])
62
+ ? run(shown, ['mcp', 'login', entry])
53
63
  : state === 'needs_enable'
54
64
  ? { kind: 'manual', text: `Run claude in ${deps.cwd} and approve the project MCP server "${entry}".` }
55
65
  : null,
56
66
  },
57
67
  };
68
+ }
58
69
  case 'codex': {
59
70
  const dir = deps.adapters.installDir('codex');
60
71
  const display = join(dir, CODEX_BIN_LINK);
@@ -126,6 +137,8 @@ async function runStep(deps, p, args) {
126
137
  }
127
138
  export async function precheckM2(agent, deps) {
128
139
  const p = plan(agent, deps);
140
+ if (p.missing)
141
+ return toM2({ state: 'unknown', entry: null, raw: tail(p.missing) }, p.m2.next);
129
142
  const result = await runStep(deps, p, p.m2.args);
130
143
  const failure = failed(result);
131
144
  return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
@@ -133,6 +146,9 @@ export async function precheckM2(agent, deps) {
133
146
  /** 调用方保证 agent 已安装;适配器类 agent 的适配器已就绪 */
134
147
  export async function precheckAgent(agent, deps, now = () => new Date()) {
135
148
  const p = plan(agent, deps);
149
+ if (p.missing) {
150
+ return { agent: agent.id, login: toLogin({ state: 'unknown', raw: tail(p.missing) }, p.login.next), m2: await precheckM2(agent, deps), checkedAt: now().toISOString() };
151
+ }
136
152
  const loginArgs = p.login.args;
137
153
  const [loginResult, m2] = await Promise.all([
138
154
  loginArgs ? runStep(deps, p, loginArgs) : Promise.resolve(null),
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 本机 ACP bridge ↔ M2 页面的 WebSocket 协议(change acp-local-bridge,给 change 3 acp-panel-local-agent 用)。
3
3
  *
4
- * 规范正文在 openspec 里该 change 的 design「页面协议」一节;本文件是它的类型落地,两者不一致时以 design 为准并修正这里。
4
+ * 规范正文在 openspec/archive/acp-local-bridge/design.md 的「页面协议」一节(之后改协议的 change 在自己的 design 里写出变化);
5
+ * 本文件是它的类型落地,两者不一致时以 design 为准并修正这里。长期契约见 openspec/specs/connect/local-bridge.md。
5
6
  *
6
7
  * 维护约束:
7
8
  * - 本文件同时被 bridge(Node,ES2022 / NodeNext)和前端(浏览器,ES2020 / bundler / noUncheckedIndexedAccess)编译,
@@ -20,6 +21,13 @@ export const BRIDGE_DEFAULT_PORT = 47821;
20
21
  export const BRIDGE_WS_PATH = '/bridge';
21
22
  /** 与前端 src/lib/agent-types 的 AgentType 取值一致(是它的子集),顺序即 agents/list 的返回顺序 */
22
23
  export const BRIDGE_AGENT_IDS = ['claude', 'codex', 'cursor', 'opencode', 'qoder'];
24
+ /**
25
+ * `agent/authorize` 的动作(change acp-terminal-free-auth,0.6.0 起):login = agent 账号登录,m2 = m2 MCP 授权。
26
+ * 页面只能选「哪个 agent × 哪个动作」,可执行文件、参数、环境、工作目录全部由 bridge 的写死清单决定(契约 3)。
27
+ */
28
+ export const BRIDGE_AUTH_ACTIONS = ['login', 'm2'];
29
+ /** `auth/link` 的链接上限(bridge 只转交不超过它的 https 链接,页面收到时再按同一上限把一次关) */
30
+ export const BRIDGE_MAX_AUTH_LINK_CHARS = 8 * 1024;
23
31
  /**
24
32
  * 页面可以发给 agent 的 ACP 方法(bridge 的 acp-guard.ts 按它放行,名单外回 `acp_rejected`、不转发)。
25
33
  * 页面对 agent 请求的响应(没有 method 键,例如审批结果)不受它限制。页面要用新方法:先在这里加上并发新版 bridge,
@@ -46,6 +54,8 @@ export const BRIDGE_METHODS = [
46
54
  'm2/configure',
47
55
  'process/start',
48
56
  'process/stop',
57
+ 'agent/authorize',
58
+ 'agent/authorize-cancel',
49
59
  ];
50
60
  export const BRIDGE_CLOSE_CODES = {
51
61
  normal: 1000,
@@ -7,17 +7,23 @@
7
7
  * 都由页面发出)。所以安全的重心在「谁能配对」,配对之后能做的事只做纵深防御:
8
8
  * - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径)在完成握手之前,失败直接写 HTTP 状态并销毁 socket。
9
9
  * - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
10
- * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
10
+ * 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
11
+ * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
12
+ * agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(分派在 auth-call.ts,命令清单写死在 authorize.ts)。
11
13
  * - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
12
14
  *
13
- * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责),不替用户登录。
15
+ * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责)。
16
+ * 登录 / m2 授权只代跑写死清单里的命令,授权页由用户在浏览器里亲自完成(authorize.ts);授权链接只发给发起的连接,不进终端日志。
14
17
  */
15
18
  import { createServer } from 'node:http';
16
19
  import { randomUUID } from 'node:crypto';
17
20
  import { WebSocketServer } from 'ws';
18
21
  import { TOKEN_IDLE_DAYS } from './pairing.js';
19
22
  import { ProcessError, ProcessManager } from './processes.js';
20
- 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';
21
27
  import { checkPageAcpLine } from './acp-guard.js';
22
28
  import { checkUpgrade } from './security.js';
23
29
  import { BridgeFailure } from './service.js';
@@ -31,9 +37,6 @@ const MAX_CALL_ID = 64;
31
37
  function isObject(value) {
32
38
  return typeof value === 'object' && value !== null && !Array.isArray(value);
33
39
  }
34
- function isAgentId(value) {
35
- return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
36
- }
37
40
  function isMethod(value) {
38
41
  return typeof value === 'string' && BRIDGE_METHODS.includes(value);
39
42
  }
@@ -76,6 +79,23 @@ export function startBridgeServer(options) {
76
79
  },
77
80
  onStderr: options.verbose ? (_id, agent, text) => process.stderr.write(`[${agent}] ${text}`) : undefined,
78
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
+ };
79
99
  const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
80
100
  const markPaired = (conn) => {
81
101
  conn.paired = true;
@@ -110,8 +130,10 @@ export function startBridgeServer(options) {
110
130
  }
111
131
  if (method === 'agents/list')
112
132
  return { agents: await options.service.list() };
133
+ if (method === 'agent/authorize' || method === 'agent/authorize-cancel')
134
+ return handleAuthCall(authContext, conn.id, method, params);
113
135
  const agent = params.agent;
114
- if (!isAgentId(agent))
136
+ if (!isBridgeAgentId(agent))
115
137
  throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
116
138
  switch (method) {
117
139
  case 'agent/prepare':
@@ -159,7 +181,19 @@ export function startBridgeServer(options) {
159
181
  clearTimeout(conn.timer);
160
182
  conn.timer = null;
161
183
  const token = typeof frame.token === 'string' ? frame.token : undefined;
162
- const paired = options.pairing.verifyToken(token, conn.origin);
184
+ let paired = options.pairing.verifyToken(token, conn.origin);
185
+ // 命令承诺配对:在未配对名额检查之前 —— 合法页面不被别人占满的未配对名额挡住。对不上(含写盘失败)一律按未配对继续,不抛出
186
+ let issuedToken;
187
+ if (!paired && typeof frame.pairSecret === 'string') {
188
+ const result = options.pairing.pairWithSecret(frame.pairSecret, conn.origin);
189
+ if (result.ok) {
190
+ paired = true;
191
+ issuedToken = result.token;
192
+ log(`Paired with ${conn.origin}.`);
193
+ }
194
+ else if (options.verbose)
195
+ log(`${conn.origin} did not pair from the start command (${result.reason}).`);
196
+ }
163
197
  if (paired) {
164
198
  markPaired(conn);
165
199
  }
@@ -182,6 +216,7 @@ export function startBridgeServer(options) {
182
216
  paired,
183
217
  tokenRejected: token !== undefined && !paired,
184
218
  cwd: paired ? options.cwd : null,
219
+ ...(issuedToken ? { token: issuedToken } : {}),
185
220
  });
186
221
  }
187
222
  function onFrame(conn, data, isBinary) {
@@ -282,12 +317,12 @@ export function startBridgeServer(options) {
282
317
  clearTimeout(conn.timer);
283
318
  connections.delete(conn.id);
284
319
  processes.stopOwner(conn.id);
320
+ authorizations.cancelOwner(conn.id);
285
321
  });
286
322
  // ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
287
323
  ws.on('error', () => undefined);
288
324
  });
289
325
  });
290
- // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
291
326
  // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
292
327
  // 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
293
328
  const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
@@ -318,25 +353,33 @@ export function startBridgeServer(options) {
318
353
  },
319
354
  };
320
355
  })();
321
- const heartbeat = setInterval(() => {
322
- for (const conn of connections.values()) {
323
- if (!conn.alive) {
324
- conn.ws.terminate();
325
- continue;
326
- }
327
- conn.alive = false;
328
- conn.ws.ping();
329
- }
330
- }, timing.pingIntervalMs);
331
356
  return new Promise((resolve, reject) => {
332
- 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);
333
364
  http.listen(options.port, '127.0.0.1', () => {
334
- 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);
335
377
  const address = http.address();
336
378
  resolve({
337
379
  port: address.port,
338
380
  address: address.address,
339
381
  processes,
382
+ authorizations,
340
383
  broadcast(event, data) {
341
384
  for (const conn of connections.values())
342
385
  if (conn.paired)
@@ -345,6 +388,8 @@ export function startBridgeServer(options) {
345
388
  close() {
346
389
  clearInterval(heartbeat);
347
390
  rejectLog.stop();
391
+ // 先于关闭连接:标记取消,被杀掉的授权命令不会被当成「失败」再去重跑预检
392
+ authorizations.killAllNow();
348
393
  for (const conn of connections.values())
349
394
  conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is shutting down');
350
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.4.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": {