@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.
@@ -18,6 +18,9 @@
18
18
  * 不要为了「方便」放宽次数、延长有效期或加一个页面能触发的解锁:码是唯一挡住本机恶意页面的东西。
19
19
  * 也不要改成「在终端按回车解锁」:那需要读终端,而 bridge 放到后台(`… bridge &`)时读终端会被系统挂起(review r2 实测)。
20
20
  * - 比较用常量时间(timingSafeEqual),不要改回 ===。
21
+ * - 后台模式(`issueCodes: false`,`bridge --background`,change acp-bridge-one-time-install D17)不出码:没有终端,stdout 写的是日志文件,
22
+ * 出码就等于把码写盘。这时 `issueCode` / `ensureCode` 什么都不做,`pair` 一律 pairing_code_unavailable,配对只剩命令承诺与已有 token。
23
+ * 不要给后台模式另开出码通道(文件、系统对话框):「把码写进文件」已被否决过(`connect/local-bridge` 契约 2 / 13)。
21
24
  */
22
25
  import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
23
26
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -84,6 +87,7 @@ export class PairingManager {
84
87
  lastPrompt = new Map();
85
88
  store;
86
89
  now;
90
+ issueCodes;
87
91
  onCodeIssued;
88
92
  onLocked;
89
93
  onProblem;
@@ -91,13 +95,16 @@ export class PairingManager {
91
95
  constructor(options) {
92
96
  this.store = options.store;
93
97
  this.now = options.now ?? Date.now;
98
+ this.issueCodes = options.issueCodes ?? true;
94
99
  this.onCodeIssued = options.onCodeIssued;
95
100
  this.onLocked = options.onLocked ?? (() => undefined);
96
101
  this.onProblem = options.onProblem ?? (() => undefined);
97
102
  this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
98
103
  }
99
- /** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式 */
104
+ /** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式;后台模式不出码,返回 null */
100
105
  issueCode(reason, origin = null) {
106
+ if (!this.issueCodes)
107
+ return null;
101
108
  let code = '';
102
109
  for (let i = 0; i < CODE_LENGTH; i++)
103
110
  code += ALPHABET[randomInt(ALPHABET.length)];
@@ -118,10 +125,10 @@ export class PairingManager {
118
125
  * 未配对页面连上时调用。没有有效码就出新码;有有效码也把它连同发起方 Origin 再提示一次 ——
119
126
  * 最常见的首次流程是「启动 bridge 后马上打开页面」,终端里只有一个不带 Origin 的启动码,
120
127
  * 不再提示的话用户看不到是哪个页面在请求配对。同一 Origin、同一个码一分钟内只提示一次(页面重连不刷屏)。
121
- * 锁定期间什么都不做。
128
+ * 锁定期间、后台模式下什么都不做。
122
129
  */
123
130
  ensureCode(origin) {
124
- if (this.locked)
131
+ if (this.locked || !this.issueCodes)
125
132
  return;
126
133
  if (!this.hasValidCode() || !this.active) {
127
134
  this.issueCode('requested', origin);
@@ -141,6 +148,9 @@ export class PairingManager {
141
148
  * (review r1 实测 1.6 秒 20000 次),还会让陌生页面把用户的配对码一直刷掉。
142
149
  */
143
150
  pair(input, origin) {
151
+ // 后台模式没有码可比:在锁定检查之前返回,也不计入错码次数(页面并没有猜码,只是不知道这个 bridge 不出码)
152
+ if (!this.issueCodes)
153
+ return { ok: false, code: 'pairing_code_unavailable' };
144
154
  if (this.locked)
145
155
  return { ok: false, code: 'pairing_locked' };
146
156
  if (!this.active) {
@@ -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,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 更新清单 `https://m2ui.ai/connect/manifest.json` 的格式、解析与下载地址(change acp-bridge-one-time-install D13 / D14 / R7)。
3
+ *
4
+ * 清单由仓库的 `packages/connect/install/release.json` 生成,随 App 部署发布;部署 App 才是放量开关,发 npm 不等于推给所有用户。
5
+ * 清单**只提供版本号与哈希**:下载地址在这里由版本号拼出,固定指向官方源与 npmmirror。
6
+ * 不要在清单里加 URL 字段 —— 那会让清单(或篡改清单的人)把下载指到任意地方,信任根就不止 m2ui.ai 加哈希了。
7
+ *
8
+ * 跨版本兼容:已安装的旧版本 bridge 会读新部署的清单(`--check-update` 跑的是当前版本的代码)。
9
+ * - 同一 `schema` 内只加字段;本解析器忽略不认识的字段。
10
+ * - 要改语义就升 `schema`;旧版本认不出新 schema 时按「读清单失败」处理,用当前版本继续(不会误装)。
11
+ * - 版本号与哈希一律严格校验格式:它们会进入文件路径与下载地址。
12
+ */
13
+ export const MANIFEST_URL = 'https://m2ui.ai/connect/manifest.json';
14
+ export const MANIFEST_SCHEMA = 1;
15
+ /** 只认纯 x.y.z(不接受预发布、构建元数据):版本号会拼进目录名与下载地址 */
16
+ const VERSION_RE = /^\d{1,4}\.\d{1,4}\.\d{1,6}$/;
17
+ const SHA512_RE = /^[0-9a-f]{128}$/;
18
+ const SHA256_RE = /^[0-9a-f]{64}$/;
19
+ export function isReleaseVersion(value) {
20
+ return typeof value === 'string' && VERSION_RE.test(value);
21
+ }
22
+ function record(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
24
+ }
25
+ function pinned(value, hash) {
26
+ const entry = record(value);
27
+ if (!entry || !isReleaseVersion(entry.version) || typeof entry.sha512 !== 'string' || !hash.test(entry.sha512))
28
+ return null;
29
+ return { version: entry.version, sha512: entry.sha512 };
30
+ }
31
+ /** 解析清单正文;格式不对时返回原因(英文,写进 update.log) */
32
+ export function parseReleaseManifest(text) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ }
37
+ catch {
38
+ return 'manifest is not valid JSON';
39
+ }
40
+ const root = record(parsed);
41
+ if (!root)
42
+ return 'manifest is not a JSON object';
43
+ if (root.schema !== MANIFEST_SCHEMA)
44
+ return `unsupported manifest schema ${JSON.stringify(root.schema)}`;
45
+ const connect = pinned(root.connect, SHA512_RE);
46
+ if (!connect)
47
+ return 'manifest has no valid connect version / sha512';
48
+ const ws = pinned(root.ws, SHA512_RE);
49
+ if (!ws)
50
+ return 'manifest has no valid ws version / sha512';
51
+ const node = record(root.node);
52
+ const sha256 = record(node?.sha256);
53
+ const arm64 = sha256?.arm64;
54
+ const x64 = sha256?.x64;
55
+ if (!node || !isReleaseVersion(node.version) || typeof arm64 !== 'string' || !SHA256_RE.test(arm64) || typeof x64 !== 'string' || !SHA256_RE.test(x64)) {
56
+ return 'manifest has no valid node version / sha256';
57
+ }
58
+ return { schema: MANIFEST_SCHEMA, connect, ws, node: { version: node.version, sha256: { arm64, x64 } } };
59
+ }
60
+ /**
61
+ * 下载源:官方优先,失败(含校验不符)再退 npmmirror(D14)。顺序有意义,不要打乱。
62
+ * 安装脚本的生成块用的是同一组基址(src/install/assets.ts),两边只此一处。
63
+ */
64
+ export const NPM_REGISTRY_BASES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com'];
65
+ export const NODE_DIST_BASES = ['https://nodejs.org/dist', 'https://npmmirror.com/mirrors/node'];
66
+ export function connectTarballUrls(version) {
67
+ return NPM_REGISTRY_BASES.map((base) => `${base}/@m2canvas/connect/-/connect-${version}.tgz`);
68
+ }
69
+ export function wsTarballUrls(version) {
70
+ return NPM_REGISTRY_BASES.map((base) => `${base}/ws/-/ws-${version}.tgz`);
71
+ }
72
+ /** 私有 Node 的目录名,也是官方包解开后的顶层目录名 */
73
+ export function nodeDirName(version, arch) {
74
+ return `node-v${version}-darwin-${arch}`;
75
+ }
76
+ /** `.tar.xz`:只有 `.tar.gz` 的一半大小,macOS 自带的 bsdtar 能解(R9) */
77
+ export function nodeTarballUrls(version, arch) {
78
+ const file = `${nodeDirName(version, arch)}.tar.xz`;
79
+ return NODE_DIST_BASES.map((base) => `${base}/v${version}/${file}`);
80
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `bridge --self-test`:自动更新时对新版本的冒烟测试(change acp-bridge-one-time-install D20 ④、N4)。
3
+ *
4
+ * 当前版本的 `--check-update` 用新版本自己的 Node 运行新版本的 cli.js。能走到这里,说明 cli.js → bridge/main.js
5
+ * 及其全部静态依赖(含 ws)都加载成功;这里再核对几种「能加载、但装得不全」的情况,任何一项不满足就退出 1,
6
+ * 更新流程就不会切换到这个版本。
7
+ *
8
+ * 不监听端口、不写文件、不联网:它在旧版本 bridge 还在运行时执行。
9
+ * 检测不到「起来了但功能坏了」(例如协议处理有 bug):那种情况靠改清单回滚(`connect/local-bridge` 契约 16 delta)。
10
+ */
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { ADAPTERS } from './adapters.js';
14
+ import { bundledNpmCli } from './npm-command.js';
15
+ /** 返回发现的问题(英文,写进 update.log);空数组 = 通过 */
16
+ export function selfTestProblems(input) {
17
+ const exists = input.exists ?? existsSync;
18
+ const problems = [];
19
+ if (!/^\d+\.\d+\.\d+/.test(input.version) || input.version === '0.0.0')
20
+ problems.push(`unreadable package version (${input.version})`);
21
+ for (const spec of Object.values(ADAPTERS)) {
22
+ const lock = join(input.packageRoot, 'adapters', spec.dir, 'package-lock.json');
23
+ if (!exists(lock))
24
+ problems.push(`missing ${lock}`);
25
+ }
26
+ const npm = bundledNpmCli(input.execPath);
27
+ if (!exists(npm))
28
+ problems.push(`missing ${npm}`);
29
+ return problems;
30
+ }
@@ -8,17 +8,23 @@
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 { handlePairCall } from './pair-call.js';
26
+ import { AuthorizationManager } from './authorize.js';
27
+ import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
22
28
  import { checkPageAcpLine } from './acp-guard.js';
23
29
  import { checkUpgrade } from './security.js';
24
30
  import { BridgeFailure } from './service.js';
@@ -32,9 +38,6 @@ const MAX_CALL_ID = 64;
32
38
  function isObject(value) {
33
39
  return typeof value === 'object' && value !== null && !Array.isArray(value);
34
40
  }
35
- function isAgentId(value) {
36
- return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
37
- }
38
41
  function isMethod(value) {
39
42
  return typeof value === 'string' && BRIDGE_METHODS.includes(value);
40
43
  }
@@ -77,6 +80,23 @@ export function startBridgeServer(options) {
77
80
  },
78
81
  onStderr: options.verbose ? (_id, agent, text) => process.stderr.write(`[${agent}] ${text}`) : undefined,
79
82
  });
83
+ const authorizations = new AuthorizationManager({
84
+ timeoutMs: options.timing?.authTimeoutMs,
85
+ graceMs: options.timing?.authGraceMs,
86
+ pollMs: options.timing?.authPollMs,
87
+ });
88
+ const authContext = {
89
+ authorizations,
90
+ service: options.service,
91
+ log,
92
+ isConnected: (ownerId) => connections.has(ownerId),
93
+ // 授权链接只发给发起的连接(D4)
94
+ sendLink: (ownerId, data) => {
95
+ const owner = connections.get(ownerId);
96
+ if (owner)
97
+ send(owner, { type: 'event', event: 'auth/link', data });
98
+ },
99
+ };
80
100
  const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
81
101
  const markPaired = (conn) => {
82
102
  conn.paired = true;
@@ -86,20 +106,10 @@ export function startBridgeServer(options) {
86
106
  };
87
107
  async function handleCall(conn, id, method, params) {
88
108
  if (method === 'pair') {
89
- const code = params.code;
90
- if (typeof code !== 'string')
91
- throw new BridgeFailure('bad_message', 'pair needs a string "code"');
92
- const result = options.pairing.pair(code, conn.origin);
93
- if (!result.ok) {
94
- if (result.code === 'pairing_invalid')
95
- throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
96
- if (result.code === 'pairing_locked')
97
- throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
98
- throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
99
- }
109
+ const token = handlePairCall(options.pairing, conn.origin, params);
100
110
  markPaired(conn);
101
111
  log(`Paired with ${conn.origin}.`);
102
- return { token: result.token, cwd: options.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
112
+ return { token, cwd: options.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
103
113
  }
104
114
  if (!conn.paired)
105
115
  throw new BridgeFailure('not_paired', 'pair this page first');
@@ -111,8 +121,10 @@ export function startBridgeServer(options) {
111
121
  }
112
122
  if (method === 'agents/list')
113
123
  return { agents: await options.service.list() };
124
+ if (method === 'agent/authorize' || method === 'agent/authorize-cancel')
125
+ return handleAuthCall(authContext, conn.id, method, params);
114
126
  const agent = params.agent;
115
- if (!isAgentId(agent))
127
+ if (!isBridgeAgentId(agent))
116
128
  throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
117
129
  switch (method) {
118
130
  case 'agent/prepare':
@@ -296,12 +308,12 @@ export function startBridgeServer(options) {
296
308
  clearTimeout(conn.timer);
297
309
  connections.delete(conn.id);
298
310
  processes.stopOwner(conn.id);
311
+ authorizations.cancelOwner(conn.id);
299
312
  });
300
313
  // ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
301
314
  ws.on('error', () => undefined);
302
315
  });
303
316
  });
304
- // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
305
317
  // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
306
318
  // 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
307
319
  const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
@@ -332,25 +344,33 @@ export function startBridgeServer(options) {
332
344
  },
333
345
  };
334
346
  })();
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
347
  return new Promise((resolve, reject) => {
346
- http.once('error', reject);
348
+ // 监听失败(端口被占)时停掉已经开始的计时器:--replace 会在同一进程里再监听一次(review r4 N4)。心跳在监听成功后才开始
349
+ const fail = (error) => {
350
+ rejectLog.stop();
351
+ wss.close();
352
+ reject(error);
353
+ };
354
+ http.once('error', fail);
347
355
  http.listen(options.port, '127.0.0.1', () => {
348
- http.off('error', reject);
356
+ http.off('error', fail);
357
+ // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
358
+ const heartbeat = setInterval(() => {
359
+ for (const conn of connections.values()) {
360
+ if (!conn.alive) {
361
+ conn.ws.terminate();
362
+ continue;
363
+ }
364
+ conn.alive = false;
365
+ conn.ws.ping();
366
+ }
367
+ }, timing.pingIntervalMs);
349
368
  const address = http.address();
350
369
  resolve({
351
370
  port: address.port,
352
371
  address: address.address,
353
372
  processes,
373
+ authorizations,
354
374
  broadcast(event, data) {
355
375
  for (const conn of connections.values())
356
376
  if (conn.paired)
@@ -359,6 +379,8 @@ export function startBridgeServer(options) {
359
379
  close() {
360
380
  clearInterval(heartbeat);
361
381
  rejectLog.stop();
382
+ // 先于关闭连接:标记取消,被杀掉的授权命令不会被当成「失败」再去重跑预检
383
+ authorizations.killAllNow();
362
384
  for (const conn of connections.values())
363
385
  conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is shutting down');
364
386
  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
  }