@m2canvas/connect 0.7.1 → 0.7.3

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.
@@ -13,20 +13,41 @@
13
13
  * session/list 必须带 cwd(不带时 Claude / Codex 会列出全机所有项目的会话标题)。
14
14
  * - 白名单内的任何请求:params 里出现 `cwd` 必须等于会话目录;`mcpServers` 只能是空数组或不带;不允许 `_meta`、
15
15
  * 不允许非空 `additionalDirectories`。
16
+ * - 无人值守模式(change bridge-reject-unattended-modes):`session/set_mode` 的 `modeId`、`session/set_config_option` 的
17
+ * `value` 落在 `BRIDGE_UNATTENDED_MODE_VALUES` 的并集里就拒收(D1,按取值、不看 configId —— 这里分不出哪个 configId
18
+ * 是模式);页面对权限请求的回答选了会切到无人值守模式的选项(`UNATTENDED_PERMISSION_OPTION_IDS`)也拒收(D6)。
16
19
  * - 形状:不是 JSON 对象的行一律拒收(含 JSON-RPC 批量数组 —— ACP SDK 会把数组逐条入队);有 `method` 键但不是字符串的拒收
17
- * (不能当成响应放行);消息或 params 自带 `__proto__` / `constructor` / `prototype` 键的拒收。
20
+ * (不能当成响应放行);消息、params、回答的 result / outcome 自带 `__proto__` / `constructor` / `prototype` 键的拒收。
18
21
  *
19
22
  * 边界(不要把它当成安全的全部):这是纵深防御,不改变「配对 = 授权该页面以你的身份驱动本机 agent」——
20
- * 页面仍可以用提示词让 agent 执行命令、替用户回答审批、切换到不审批的工作模式。真正的门槛是 security.ts / pairing.ts 的准入。
23
+ * 页面仍可以用提示词让 agent 执行命令(包括改它自己的配置文件)、替用户回答审批(含「本会话 / 永久允许」,FU-2026-10-08-009);
24
+ * 这里只拒收**已知的**切到无人值守模式的请求与回答(两份清单随适配器 / CLI 版本重核)。Cursor、OpenCode 没有无人值守模式;
25
+ * OpenCode 默认的 build 模式在工作区内执行命令本来就不请示。真正的门槛是 security.ts / pairing.ts 的准入。
21
26
  * 前提:agent 解析 JSON 的结果与这里一致(例如重复键都取最后一个)。2026-10-03 核实:两个适配器经 ACP SDK 1.5.1 的
22
27
  * stream.js 用 JSON.parse;Cursor 是 node bundle;OpenCode、Qoder 是 Bun 单文件可执行(证据见 change 的 verification §13)。
23
- * 哪家换了解析器,要重新核这一条。
28
+ * 哪家换了解析器,要重新核这一条。两份清单依赖各家的模式名与 optionId 字面量:升级适配器或 CLI 后要重核,改名只会漏拦、不会误伤。
24
29
  */
25
- import { BRIDGE_PAGE_ACP_METHODS } from './protocol.js';
30
+ import { BRIDGE_PAGE_ACP_METHODS, BRIDGE_UNATTENDED_MODE_VALUES } from './protocol.js';
26
31
  /** 页面可以发给 agent 的方法:change 3 实际用到的方法 + JSON-RPC 层的取消请求(定义在 protocol.ts,前端也能 import) */
27
32
  export const PAGE_ACP_METHODS = BRIDGE_PAGE_ACP_METHODS;
28
33
  /** cwd 由 bridge 决定的会话类方法(必须带 cwd) */
29
34
  export const SESSION_SCOPE_METHODS = ['session/new', 'session/load', 'session/resume', 'session/list'];
35
+ /** 所有 agent 的无人值守模式取并集:守卫按行检查、不知道对面是哪家,且 Qoder 认 Claude 的取值 */
36
+ const UNATTENDED_MODES = Object.values(BRIDGE_UNATTENDED_MODE_VALUES).flat();
37
+ /**
38
+ * 权限回答里会把会话切到无人值守模式的 optionId(change bridge-reject-unattended-modes D6)= 全部无人值守模式取值 ∪
39
+ * Claude 适配器 0.84.0 退出计划的 `exit-plan-bypass` / `exit-plan-clear-bypass`(dist/permissions/options/shared.js,切到 bypassPermissions)。
40
+ * 必须包含全部模式取值:qodercli 1.1.65 处理退出计划回答时,把 optionId 交给与切模式同一个别名映射后直接切模式,
41
+ * 不核对它是不是给出的选项 —— 回答 `yolo` 或 Claude 的 `bypassPermissions` 都会切到 yolo(review r1 B1)。
42
+ * Claude 适配器会核对回答是否为给出的选项。这些都是 `allow_always` 或根本不在选项里,页面只发 `allow_once` / `reject_once`,
43
+ * 正常不会发它们。只有 bridge 用,页面按 kind 挑选、不需要这份。
44
+ */
45
+ const UNATTENDED_PERMISSION_OPTION_IDS = [...UNATTENDED_MODES, 'exit-plan-bypass', 'exit-plan-clear-bypass'];
46
+ /** 取值可能是模式的字段:`session/set_mode` 的 modeId、`session/set_config_option` 的 value */
47
+ const MODE_VALUE_FIELDS = {
48
+ 'session/set_mode': 'modeId',
49
+ 'session/set_config_option': 'value',
50
+ };
30
51
  /** JSON.parse 会把这些键建成普通自有属性;agent 侧的对象合并 / 校验可能对它们另有处理,一律不放行 */
31
52
  const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
32
53
  function isObject(value) {
@@ -60,6 +81,37 @@ function scopeViolation(params, cwd, sessionScoped) {
60
81
  return '_meta is not allowed';
61
82
  return null;
62
83
  }
84
+ /**
85
+ * 切模式的目标值是无人值守模式时返回原因。只看字符串:有无人值守模式的三家(Claude / Codex / Qoder)ACP 层都要求
86
+ * 字符串取值,非字符串切不过去;Claude 有合法的 boolean 配置项,拒收非字符串会误伤。
87
+ */
88
+ function modeViolation(method, params) {
89
+ const field = MODE_VALUE_FIELDS[method];
90
+ if (field === undefined)
91
+ return null;
92
+ const value = params[field];
93
+ return typeof value === 'string' && UNATTENDED_MODES.includes(value) ? `mode ${value} is not allowed from the page` : null;
94
+ }
95
+ /**
96
+ * 页面对 agent 请求的回答(没有 method 键):选了会切到无人值守模式的权限选项时返回原因。
97
+ * 不论 `outcome.outcome` 是什么都查 optionId(页面回 cancelled 时从不带 optionId,从严不会误伤)。
98
+ */
99
+ function answerViolation(message) {
100
+ const result = message.result;
101
+ if (!isObject(result))
102
+ return null;
103
+ if (hasForbiddenKey(result))
104
+ return 'result must not contain __proto__ / constructor / prototype';
105
+ const outcome = result.outcome;
106
+ if (!isObject(outcome))
107
+ return null;
108
+ if (hasForbiddenKey(outcome))
109
+ return 'outcome must not contain __proto__ / constructor / prototype';
110
+ const optionId = outcome.optionId;
111
+ return typeof optionId === 'string' && UNATTENDED_PERMISSION_OPTION_IDS.includes(optionId)
112
+ ? `permission option ${optionId} is not allowed from the page`
113
+ : null;
114
+ }
63
115
  export function checkPageAcpLine(line, cwd) {
64
116
  let message;
65
117
  try {
@@ -74,9 +126,12 @@ export function checkPageAcpLine(line, cwd) {
74
126
  const reject = (reason) => ({ ok: false, reason, method, rpcId: rpcIdOf(message) });
75
127
  if (hasForbiddenKey(message))
76
128
  return reject('message must not contain __proto__ / constructor / prototype');
77
- // 没有 method 键的是页面对 agent 请求的响应(例如审批结果),不涉及方法与会话范围
78
- if (!hasOwn(message, 'method'))
79
- return { ok: true };
129
+ // 没有 method 键的是页面对 agent 请求的响应(例如审批结果),不涉及方法与会话范围,只查 D6。
130
+ // 被拒时 method 为 null:页面不会为它合成错误响应(rpcId 是 agent 编的号,会误结束页面自己同号的请求)
131
+ if (!hasOwn(message, 'method')) {
132
+ const violation = answerViolation(message);
133
+ return violation ? reject(violation) : { ok: true };
134
+ }
80
135
  if (method === null)
81
136
  return reject('method must be a string');
82
137
  if (!PAGE_ACP_METHODS.includes(method))
@@ -87,6 +142,6 @@ export function checkPageAcpLine(line, cwd) {
87
142
  return { ok: true };
88
143
  if (!isObject(params))
89
144
  return reject('params must be an object');
90
- const violation = scopeViolation(params, cwd, sessionScoped);
145
+ const violation = scopeViolation(params, cwd, sessionScoped) ?? modeViolation(method, params);
91
146
  return violation ? reject(violation) : { ok: true };
92
147
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `bridge` 子命令的参数:解析、帮助文本、后台模式的退出码与寿命。
3
+ *
4
+ * 从 main.ts 拆出(行数门禁)。启动器 `~/.m2canvas/bin/m2-bridge` 按这里的参数与退出码调用任意版本的 bridge
5
+ * (契约 14、`install/m2-bridge.sh` 头注释),已安装的 bridge 又会自动更新,所以每个版本都要保持这套接口兼容。
6
+ */
7
+ import { resolve } from 'node:path';
8
+ import { isPairCommitment } from './pairing.js';
9
+ import { BRIDGE_DEFAULT_PORT } from './protocol.js';
10
+ import { DEV_ORIGINS, PROD_ORIGINS } from './security.js';
11
+ /**
12
+ * 后台模式下,端口上已有认定的 M2 bridge、又没带 `--replace` 时的退出码(change acp-bridge-one-time-install R1)。
13
+ * 启动器把它当「已在运行」(成功);认不出的占用者、停不掉等失败仍是 1,不能并进来。终端模式不用它(照旧 1)。
14
+ */
15
+ export const EXIT_ALREADY_RUNNING = 3;
16
+ /**
17
+ * 后台模式下,端口被占、又认不出占用者是 M2 bridge(或 `--replace` 停不掉旧的、停掉后端口仍被占)时的退出码(review r1 R1-S1)。
18
+ * 这些都是环境问题,不是这个版本坏了:启动器据此**不回退、不拉黑**刚更新的版本,只把原因写进结果行。终端模式仍是 1。
19
+ */
20
+ export const EXIT_PORT_UNAVAILABLE = 4;
21
+ /**
22
+ * 后台模式加 `--dev`(本机开发页联调用,design R6)时的寿命(review r1 R1-S6,用户 2026-10-05 拍板)。
23
+ * 开发域名的 Origin 与 localStorage 被本机所有 Vite 项目共用,契约 1 因此只在 `--dev` 时放行、且否决了「常开」;
24
+ * 后台 bridge 没有终端、一直运行到注销,不限时就等于常开。到时走正常收尾退出,需要时再用启动器拉起。
25
+ */
26
+ export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
27
+ export function backgroundLifetimeMs(args) {
28
+ return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
29
+ }
30
+ export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
31
+ npx @m2canvas/connect bridge --unpair
32
+
33
+ Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
34
+ installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
35
+
36
+ It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
37
+ The command M2 gives you pairs that page automatically; other pages ask for the
38
+ pairing code shown here. A paired page can make these agents read and change
39
+ files and run commands as you, so only run commands you copied from M2.
40
+ Agents run in the directory you start the bridge from (or --cwd), with your own logins.
41
+ On macOS a paired page can also start an agent's own sign-in; you finish it in
42
+ your browser.
43
+
44
+ --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
45
+ --cwd <dir> working directory for agent sessions (default: current directory)
46
+ --verbose also print agent stderr and connection details
47
+ --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
48
+ any app served on that port could then ask to pair
49
+ --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
50
+ valid for 10 minutes, once)
51
+ --replace if an M2 bridge (0.6.0 or later) is already running on the port,
52
+ stop it first (its agent sessions end) and start this one instead;
53
+ not on Windows
54
+ --unpair forget all paired pages and exit
55
+ -h, --help show this help
56
+
57
+ Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
58
+ --background run without a terminal: no pairing codes are shown; exits with
59
+ code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port (handing its --pair
60
+ to that bridge first), ${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it
61
+ stops by itself after 2 hours
62
+ --check-update update this installation to the version M2 publishes, then exit
63
+ --self-test check that this installation loads completely, print its version and exit
64
+ --stop stop the M2 bridge running on the port (only one it can recognize; it's
65
+ sent SIGTERM so it cleans up), then exit`;
66
+ /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
67
+ export function parseBridgeArgs(argv) {
68
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const arg = argv[i];
71
+ if (arg === '-h' || arg === '--help')
72
+ args.help = true;
73
+ else if (arg === '--verbose')
74
+ args.verbose = true;
75
+ else if (arg === '--unpair')
76
+ args.unpair = true;
77
+ else if (arg === '--dev')
78
+ args.dev = true;
79
+ else if (arg === '--replace')
80
+ args.replace = true;
81
+ else if (arg === '--background')
82
+ args.background = true;
83
+ else if (arg === '--self-test')
84
+ args.selfTest = true;
85
+ else if (arg === '--check-update')
86
+ args.checkUpdate = true;
87
+ else if (arg === '--stop')
88
+ args.stop = true;
89
+ else if (arg === '--pair') {
90
+ const value = argv[++i];
91
+ if (value === undefined)
92
+ return '--pair needs a value';
93
+ if (!isPairCommitment(value))
94
+ return `invalid --pair value ${value}; copy the start command from M2 again`;
95
+ args.pair = value;
96
+ }
97
+ else if (arg === '--port' || arg === '--cwd') {
98
+ const value = argv[++i];
99
+ if (value === undefined)
100
+ return `${arg} needs a value`;
101
+ if (arg === '--cwd')
102
+ args.cwd = resolve(value);
103
+ else {
104
+ const port = Number(value);
105
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
106
+ return `invalid --port ${value}`;
107
+ args.port = port;
108
+ }
109
+ }
110
+ else
111
+ return `unknown option ${arg}`;
112
+ }
113
+ return args;
114
+ }
@@ -10,6 +10,10 @@
10
10
  * 跨版本接口(启动器只随重装更新,bridge 会自动更新,交出方与接收方可能是不同版本;`connect/local-bridge` 契约 14 delta):
11
11
  * - 请求一行(不含换行最多 256 字节):`{"type":"arm","nonce":"<32 位小写十六进制>"}\n`;承诺在 `<m2 目录>/handoff-<nonce>.pending`(普通文件、属于本账号);
12
12
  * - 回复一行:`{"ok":true,"until":<毫秒时间戳>}` 或 `{"ok":false,"error":"…"}`,回复后断开。
13
+ * - 只读状态查询(change desktop-bridge-launcher D15 起;具体版本见 connect 的发布记录):请求 `{"type":"status","commitment":"<22 位 base64url>"}\n`,
14
+ * 回复 `{"ok":true,"state":"waiting","until":…}` / `{"ok":true,"state":"paired","origin":"…","at":…}` / `{"ok":true,"state":"unknown"}`,
15
+ * 可带 `"rejected":{"origin":"…","at":…}`(最近一次因 Origin 不在白名单被拒的连接)。旧版本对它回 `bad_request`,
16
+ * M2 Mac 客户端据此判定「不支持」。
13
17
  * 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
14
18
  *
15
19
  * 维护约束:
@@ -18,6 +22,9 @@
18
22
  * 本来就能配对(bridge 每次校验 token 都重读 `bridge-pairings.json`,往里加一条记录即可;在沙箱里、做不到 `--replace` 也一样),
19
23
  * 所以不算新的攻击者类别。这条论证依赖「每次重读配对文件」:改成只在启动时读或缓存时,要重新评估这里的门槛。
20
24
  * 不要退回「socket 上直接带承诺」,也不要放宽文件名(只认 32 位十六进制)。
25
+ * - `status` 只读、承诺随请求一起发,不要求能写 `~/.m2canvas`:它只透露「这个承诺配没配上、配给了哪个 origin、最近被拒的 origin」,
26
+ * 这些本来就写在 `~/.m2canvas/logs/bridge.log` 里,能连这个 socket 的本账号进程也能读到;不回 S、不回 token、不改任何状态,
27
+ * 也不能用来猜承诺(128 bit)。回复里要加别的东西时,先重做这条论证(design D15)。
21
28
  * - socket 上不收 S、不回 token:拿 token 仍要页面在 hello 里出示 S,Origin 白名单照旧。
22
29
  * - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
23
30
  * - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住 bridge 或吃内存。
@@ -54,13 +61,17 @@ function removeQuietly(path) {
54
61
  function errorText(error) {
55
62
  return error instanceof Error ? error.message : String(error);
56
63
  }
57
- function requestedNonce(line) {
64
+ function parseRequest(line) {
58
65
  try {
59
66
  const request = JSON.parse(line);
60
67
  if (typeof request !== 'object' || request === null)
61
68
  return null;
62
- const { type, nonce } = request;
63
- return type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce) ? nonce : null;
69
+ const { type, nonce, commitment } = request;
70
+ if (type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce))
71
+ return { type, nonce };
72
+ if (type === 'status' && typeof commitment === 'string' && isPairCommitment(commitment))
73
+ return { type, commitment };
74
+ return null;
64
75
  }
65
76
  catch {
66
77
  return null;
@@ -92,7 +103,7 @@ function takePendingCommitment(dir, nonce) {
92
103
  * 返回 null = 没监听(路径太长或建不起来),只记日志,不影响 bridge 本身。
93
104
  */
94
105
  export async function listenForHandoff(options) {
95
- const { dir, port, arm, log } = options;
106
+ const { dir, port, arm, status, log } = options;
96
107
  const path = handoffSocketPath(dir, port);
97
108
  if (Buffer.byteLength(path) > HANDOFF_MAX_PATH_BYTES) {
98
109
  log(`Pairing handoff is unavailable: ${path} is too long for a local socket. A new browser has to use the reconnect command instead.`);
@@ -117,7 +128,12 @@ export async function listenForHandoff(options) {
117
128
  if (end < 0)
118
129
  return;
119
130
  done = true;
120
- const nonce = requestedNonce(buffer.slice(0, end));
131
+ const request = parseRequest(buffer.slice(0, end));
132
+ if (request?.type === 'status' && status) {
133
+ socket.end(`${JSON.stringify({ ok: true, ...status(request.commitment) })}\n`);
134
+ return;
135
+ }
136
+ const nonce = request?.type === 'arm' ? request.nonce : null;
121
137
  const commitment = nonce ? takePendingCommitment(dir, nonce) : null;
122
138
  if (!commitment) {
123
139
  socket.end(`${JSON.stringify({ ok: false, error: nonce ? 'not_found' : 'bad_request' })}\n`);
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 只由 cli.ts 在第一个参数是 `bridge` 时动态加载:默认的 MCP 配置流程(Copy to agent 起手式)不会加载这里的任何代码与依赖。
5
5
  *
6
- * 本文件负责组装与终端交互:参数、启动横幅、配对码打印、信号处理、退出兜底清理。协议在 server.ts,安全判定在 security.ts / pairing.ts。
6
+ * 本文件负责组装与终端交互:启动横幅、配对码打印、信号处理、退出兜底清理。参数与帮助在 args.ts,协议在 server.ts,安全判定在 security.ts / pairing.ts。
7
7
  * 终端输出用英文,与 connect 现有输出一致。
8
8
  *
9
9
  * 后台模式(`--background`,只由启动器 `~/.m2canvas/bin/m2-bridge` 使用,change acp-bridge-one-time-install):输出写进日志文件,
@@ -11,10 +11,11 @@
11
11
  * 端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
12
12
  */
13
13
  import { existsSync, readFileSync, statSync } from 'node:fs';
14
- import { join, resolve } from 'node:path';
14
+ import { join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { home, isExecutable, which } from '../system.js';
17
17
  import { AdapterManager, ADAPTERS } from './adapters.js';
18
+ import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
18
19
  import { detectAgent } from './agents.js';
19
20
  import { EXPECT_PATH } from './authorize.js';
20
21
  import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
@@ -22,35 +23,16 @@ import { execCommand, killActiveExecs } from './exec.js';
22
23
  import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
23
24
  import { handOffCommitment, listenForHandoff } from './handoff.js';
24
25
  import { installShutdownSignals } from './lifecycle.js';
25
- import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
26
- import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
26
+ import { PairingManager, createFilePairingStore } from './pairing.js';
27
+ import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
27
28
  import { resolveNpmCommand } from './npm-command.js';
28
- import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
29
+ import { allowedOrigins } from './security.js';
29
30
  import { selfTestProblems } from './self-test.js';
30
31
  import { runCheckUpdate } from './update-io.js';
31
32
  import { startBridgeServer } from './server.js';
32
33
  import { createAgentService } from './service.js';
33
34
  import { KILL_GRACE_MS } from './processes.js';
34
35
  const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
35
- /**
36
- * 后台模式下,端口上已有认定的 M2 bridge、又没带 `--replace` 时的退出码(change acp-bridge-one-time-install R1)。
37
- * 启动器把它当「已在运行」(成功);认不出的占用者、停不掉等失败仍是 1,不能并进来。终端模式不用它(照旧 1)。
38
- */
39
- export const EXIT_ALREADY_RUNNING = 3;
40
- /**
41
- * 后台模式下,端口被占、又认不出占用者是 M2 bridge(或 `--replace` 停不掉旧的、停掉后端口仍被占)时的退出码(review r1 R1-S1)。
42
- * 这些都是环境问题,不是这个版本坏了:启动器据此**不回退、不拉黑**刚更新的版本,只把原因写进结果行。终端模式仍是 1。
43
- */
44
- export const EXIT_PORT_UNAVAILABLE = 4;
45
- /**
46
- * 后台模式加 `--dev`(本机开发页联调用,design R6)时的寿命(review r1 R1-S6,用户 2026-10-05 拍板)。
47
- * 开发域名的 Origin 与 localStorage 被本机所有 Vite 项目共用,契约 1 因此只在 `--dev` 时放行、且否决了「常开」;
48
- * 后台 bridge 没有终端、一直运行到注销,不限时就等于常开。到时走正常收尾退出,需要时再用启动器拉起。
49
- */
50
- export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
51
- export function backgroundLifetimeMs(args) {
52
- return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
53
- }
54
36
  const LABELS = {
55
37
  claude: 'Claude Code',
56
38
  codex: 'Codex',
@@ -58,91 +40,6 @@ const LABELS = {
58
40
  opencode: 'OpenCode',
59
41
  qoder: 'Qoder',
60
42
  };
61
- export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
62
- npx @m2canvas/connect bridge --unpair
63
-
64
- Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
65
- installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
66
-
67
- It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
68
- The command M2 gives you pairs that page automatically; other pages ask for the
69
- pairing code shown here. A paired page can make these agents read and change
70
- files and run commands as you, so only run commands you copied from M2.
71
- Agents run in the directory you start the bridge from (or --cwd), with your own logins.
72
- On macOS a paired page can also start an agent's own sign-in; you finish it in
73
- your browser.
74
-
75
- --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
76
- --cwd <dir> working directory for agent sessions (default: current directory)
77
- --verbose also print agent stderr and connection details
78
- --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
79
- any app served on that port could then ask to pair
80
- --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
81
- valid for 10 minutes, once)
82
- --replace if an M2 bridge (0.6.0 or later) is already running on the port,
83
- stop it first (its agent sessions end) and start this one instead;
84
- not on Windows
85
- --unpair forget all paired pages and exit
86
- -h, --help show this help
87
-
88
- Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
89
- --background run without a terminal: no pairing codes are shown; exits with
90
- code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port (handing its --pair
91
- to that bridge first), ${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it
92
- stops by itself after 2 hours
93
- --check-update update this installation to the version M2 publishes, then exit
94
- --self-test check that this installation loads completely, print its version and exit
95
- --stop stop the M2 bridge running on the port (only one it can recognize; it's
96
- sent SIGTERM so it cleans up), then exit`;
97
- /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
98
- export function parseBridgeArgs(argv) {
99
- const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
100
- for (let i = 0; i < argv.length; i++) {
101
- const arg = argv[i];
102
- if (arg === '-h' || arg === '--help')
103
- args.help = true;
104
- else if (arg === '--verbose')
105
- args.verbose = true;
106
- else if (arg === '--unpair')
107
- args.unpair = true;
108
- else if (arg === '--dev')
109
- args.dev = true;
110
- else if (arg === '--replace')
111
- args.replace = true;
112
- else if (arg === '--background')
113
- args.background = true;
114
- else if (arg === '--self-test')
115
- args.selfTest = true;
116
- else if (arg === '--check-update')
117
- args.checkUpdate = true;
118
- else if (arg === '--stop')
119
- args.stop = true;
120
- else if (arg === '--pair') {
121
- const value = argv[++i];
122
- if (value === undefined)
123
- return '--pair needs a value';
124
- if (!isPairCommitment(value))
125
- return `invalid --pair value ${value}; copy the start command from M2 again`;
126
- args.pair = value;
127
- }
128
- else if (arg === '--port' || arg === '--cwd') {
129
- const value = argv[++i];
130
- if (value === undefined)
131
- return `${arg} needs a value`;
132
- if (arg === '--cwd')
133
- args.cwd = resolve(value);
134
- else {
135
- const port = Number(value);
136
- if (!Number.isInteger(port) || port < 1 || port > 65535)
137
- return `invalid --port ${value}`;
138
- args.port = port;
139
- }
140
- }
141
- else
142
- return `unknown option ${arg}`;
143
- }
144
- return args;
145
- }
146
43
  /** 包根目录:dist/bridge/*.js 与 src/bridge/*.ts 到包根的相对深度相同 */
147
44
  const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
148
45
  function packageVersion() {
@@ -297,6 +194,8 @@ export async function runBridge(argv) {
297
194
  // 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
298
195
  const instancePath = instanceFilePath(m2Dir, parsed.port);
299
196
  let alreadyRunning = false;
197
+ /** 最近一次因 Origin 不在白名单被拒的页面(desktop-bridge-launcher D15):交接 socket 的状态查询带回给 M2 Mac 客户端 */
198
+ let lastRejected = null;
300
199
  server = await listenWithReplace(() => startBridgeServer({
301
200
  port: parsed.port,
302
201
  cwd: parsed.cwd,
@@ -312,6 +211,9 @@ export async function runBridge(argv) {
312
211
  service,
313
212
  agentEnv: buildAgentEnv(),
314
213
  allowedOrigins: origins,
214
+ onOriginRejected: (origin) => {
215
+ lastRejected = { origin, at: Date.now() };
216
+ },
315
217
  log,
316
218
  verbose: parsed.verbose,
317
219
  }), {
@@ -398,8 +300,15 @@ export async function runBridge(argv) {
398
300
  process.exit(130);
399
301
  },
400
302
  });
401
- if (parsed.background)
402
- handoff = await listenForHandoff({ dir: m2Dir, port: parsed.port, arm: (c) => pairing.armCommitment(c), log });
303
+ if (parsed.background) {
304
+ handoff = await listenForHandoff({
305
+ dir: m2Dir,
306
+ port: parsed.port,
307
+ arm: (c) => pairing.armCommitment(c),
308
+ status: (c) => ({ ...pairing.commitmentStatus(c), ...(lastRejected ? { rejected: lastRejected } : {}) }),
309
+ log,
310
+ });
311
+ }
403
312
  const lifetime = backgroundLifetimeMs(parsed);
404
313
  if (lifetime !== null) {
405
314
  setTimeout(() => {
@@ -192,6 +192,25 @@ export class PairingManager {
192
192
  this.commitment = { value: Buffer.from(commitment, 'base64url'), armedAt };
193
193
  return armedAt + CODE_TTL_MS;
194
194
  }
195
+ /**
196
+ * 承诺的状态(change desktop-bridge-launcher D15):M2 Mac 客户端运行启动器交上承诺之后,用它查页面有没有真的配上。
197
+ * 只读,不改变任何状态;比较用常量时间。只看两个槽:挂着的(10 分钟内)与最近一次用掉的 —— 被更新的承诺替换、过期、
198
+ * bridge 重启过的都是 unknown,调用方按「还没确认」处理。
199
+ */
200
+ commitmentStatus(commitment) {
201
+ if (!isPairCommitment(commitment))
202
+ return { state: 'unknown' };
203
+ const value = Buffer.from(commitment, 'base64url');
204
+ const armed = this.commitment;
205
+ if (armed && armed.value.length === value.length && timingSafeEqual(armed.value, value) && this.now() - armed.armedAt <= CODE_TTL_MS) {
206
+ return { state: 'waiting', until: armed.armedAt + CODE_TTL_MS };
207
+ }
208
+ const consumed = this.consumed;
209
+ if (consumed && consumed.value.length === value.length && timingSafeEqual(consumed.value, value)) {
210
+ return { state: 'paired', origin: consumed.origin, at: consumed.usedAt };
211
+ }
212
+ return { state: 'unknown' };
213
+ }
195
214
  /**
196
215
  * 页面在 hello 里出示 S。对不上不计入错码次数:S 有 32 字节、承诺有 128 bit,不存在可猜性,
197
216
  * 计入只会让多标签页、旧 S 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
@@ -9,7 +9,7 @@
9
9
  * 所以只能有类型和常量:不 import 任何模块、不用 enum、不用 Node 或 DOM 专有类型。
10
10
  * - 同一协议版本内只做加法(新方法、新事件、新可选字段、新枚举值)。删除字段或改变语义要升 BRIDGE_PROTOCOL_VERSION,
11
11
  * bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
12
- * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单与会话范围检查(acp-guard.ts),
12
+ * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单、会话范围与无人值守模式检查(acp-guard.ts),
13
13
  * 翻译全部在页面做。
14
14
  * - 配对 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令);页面代码与 token 的保管要按这个级别对待。
15
15
  */
@@ -46,6 +46,23 @@ export const BRIDGE_PAGE_ACP_METHODS = [
46
46
  'session/close',
47
47
  '$/cancel_request',
48
48
  ];
49
+ /**
50
+ * 不经批准执行一切的工作模式(change bridge-reject-unattended-modes D2,页面与 bridge 共用这一份):
51
+ * Claude `bypassPermissions`「Accepts all permissions」;Codex `agent-full-access`「Unrestricted access to the internet and any
52
+ * file on your computer」;Qoder `yolo`「Auto-approves all tools」。Qoder `dontAsk` 是「不询问、直接拒绝」,不在此列;
53
+ * Claude `auto` 由分类器代为决定,用户 2026-10-03 拍板时未列入。
54
+ * - 页面:模式下拉不提供、`setLocalMode` 拒绝(用户自己的配置已是这种模式时照实显示)。
55
+ * - bridge:`session/set_mode` / `session/set_config_option` 的取值落在**所有 agent 的并集**里就拒收(acp-guard.ts)——
56
+ * Qoder 会把 Claude 的 `bypassPermissions` 映射成自己的 `yolo`,只查本家挡不住。
57
+ * 某家新增同类模式、或升级适配器 / CLI 时,按本 change evidence/mode-paths.md 的复核方法重核并加在这里(bridge 要发版)。
58
+ */
59
+ export const BRIDGE_UNATTENDED_MODE_VALUES = {
60
+ claude: ['bypassPermissions'],
61
+ codex: ['agent-full-access'],
62
+ cursor: [],
63
+ opencode: [],
64
+ qoder: ['yolo'],
65
+ };
49
66
  export const BRIDGE_METHODS = [
50
67
  'pair',
51
68
  'agents/list',
@@ -33,6 +33,7 @@ export function isAllowedHost(host, port) {
33
33
  export function isLoopbackAddress(address) {
34
34
  return address === '127.0.0.1' || address === '::ffff:127.0.0.1' || address === '::1';
35
35
  }
36
+ const MAX_REPORTED_ORIGIN_CHARS = 200;
36
37
  /**
37
38
  * 拒绝时返回 HTTP 状态,由调用方直接写回并销毁 socket,不完成握手。
38
39
  * 403 而不是「握手后再关」:不对非白名单页面完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
@@ -46,7 +47,10 @@ export function checkUpgrade(info, port, origins) {
46
47
  return { ok: false, status: 403, reason: `remote ${info.remoteAddress ?? 'unknown'}` };
47
48
  if (!isAllowedHost(info.host, port))
48
49
  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'}` };
50
+ if (!isAllowedOrigin(info.origin, origins)) {
51
+ if (info.origin === undefined)
52
+ return { ok: false, status: 403, reason: 'origin missing' };
53
+ return { ok: false, status: 403, reason: `origin ${info.origin}`, rejectedOrigin: info.origin.slice(0, MAX_REPORTED_ORIGIN_CHARS) };
54
+ }
51
55
  return { ok: true };
52
56
  }
@@ -279,6 +279,8 @@ export function startBridgeServer(options) {
279
279
  const decision = checkUpgrade({ origin: req.headers.origin, host: req.headers.host, remoteAddress: req.socket.remoteAddress, path: req.url }, port, options.allowedOrigins);
280
280
  if (!decision.ok) {
281
281
  rejectLog.note(decision.reason);
282
+ if (decision.rejectedOrigin !== undefined)
283
+ options.onOriginRejected?.(decision.rejectedOrigin);
282
284
  rejectUpgrade(socket, decision.status);
283
285
  return;
284
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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": {