@m2canvas/connect 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ curl -fsSL https://m2ui.ai/connect/install.sh | sh -s -- --pair <value from M2>
56
56
 
57
57
  - **From M2.** Click "Connect with Claude" (or Codex, or Cursor): the app opens with this command in a prompt, and you press Return. The installer puts a private Node.js and a pinned version of this package in `~/.m2canvas`, checking every download against the hashes M2 publishes; writes the launcher `~/.m2canvas/bin/m2-bridge`; and starts the bridge in the background. The page pairs by itself. Agent sessions start in `~/.m2canvas/workspace`.
58
58
  - **Nothing starts at login.** After a restart, M2 asks the agent app to run `~/.m2canvas/bin/m2-bridge start --pair <value>` (you can run it yourself too). The launcher first updates the bridge to the version listed at `https://m2ui.ai/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
59
- - **No pairing codes in the background.** A bridge started this way has no window to show a code in. If a page isn't paired (the pairing expired, or you use another browser), M2 offers "Reconnect", which runs the launcher with `--replace`: it restarts the bridge and pairs that page, and agent sessions on the old bridge end.
59
+ - **No pairing codes in the background.** A bridge started this way has no window to show a code in. When a page isn't paired yet (another browser, or the pairing expired), M2 gives it the same start command: the new bridge process sees the one already running and hands the page's pairing value to it, so the page pairs without restarting the bridge and other tabs keep their sessions. The handoff goes through `~/.m2canvas/bridge-<port>.sock` plus a one-time file in `~/.m2canvas`, so only a process that can write to that folder (your account; agent sandboxes normally can't write there) can hand a pairing value over; the file holds only the hash and is deleted right away. If that doesn't work, M2 offers to restart the bridge with `--replace`, which ends the sessions on the old one.
60
60
  - **Sandboxes.** Codex needs its Full access permission mode: its default sandbox can't reach the network or write to your home folder. Inside the Codex or Claude command sandbox, the scripts change nothing and say what to do instead.
61
61
  - **What it leaves alone.** macOS 13.5 or later. It doesn't use sudo, register anything to start at login, change your shell config or PATH, or touch a Node.js you installed yourself. Logs are in `~/.m2canvas/logs/`.
62
62
  - **Uninstall** with `curl -fsSL https://m2ui.ai/connect/uninstall.sh | sh`, or "Uninstall local connection" in the Local Agent tab's ⋯ menu. It stops the bridge and removes what the installer added; `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are.
@@ -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,197 @@
1
+ /**
2
+ * 把命令承诺交给正在运行的后台 bridge(change acp-bridge-one-time-install design D27、R11、review r3 R3-S1;`connect/local-bridge` 契约 2 delta)。
3
+ *
4
+ * 后台模式的 bridge 不出配对码,新页面只能靠启动命令里的承诺配对;原来承诺只能在启动时经 `--pair` 带进去,
5
+ * 换一个浏览器就得 `--replace` 重启 bridge,打断其他页面上正在跑的 agent。现在:
6
+ * - 后台模式的 bridge 监听 `~/.m2canvas/bridge-<port>.sock`;
7
+ * - 启动器拉起的新 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时,把 c 写进 `~/.m2canvas/handoff-<编号>.pending`,
8
+ * 经 socket 只发这个编号;bridge 读出承诺、删掉文件、挂上,再回复。交出方随后以退出码 3 退出。
9
+ *
10
+ * 跨版本接口(启动器只随重装更新,bridge 会自动更新,交出方与接收方可能是不同版本;`connect/local-bridge` 契约 14 delta):
11
+ * - 请求一行(不含换行最多 256 字节):`{"type":"arm","nonce":"<32 位小写十六进制>"}\n`;承诺在 `<m2 目录>/handoff-<nonce>.pending`(普通文件、属于本账号);
12
+ * - 回复一行:`{"ok":true,"until":<毫秒时间戳>}` 或 `{"ok":false,"error":"…"}`,回复后断开。
13
+ * 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
14
+ *
15
+ * 维护约束:
16
+ * - 门槛是「能写 `~/.m2canvas`」,而不只是「能连 socket」(R3-S1):agent 沙箱里的命令可能被放开了本机 socket,
17
+ * 但默认只能写工作目录与临时目录;只靠 socket 的话,它就能配上、再经 bridge 驱动沙箱外的 agent。能写这个目录的进程
18
+ * 本来就能配对(bridge 每次校验 token 都重读 `bridge-pairings.json`,往里加一条记录即可;在沙箱里、做不到 `--replace` 也一样),
19
+ * 所以不算新的攻击者类别。这条论证依赖「每次重读配对文件」:改成只在启动时读或缓存时,要重新评估这里的门槛。
20
+ * 不要退回「socket 上直接带承诺」,也不要放宽文件名(只认 32 位十六进制)。
21
+ * - socket 上不收 S、不回 token:拿 token 仍要页面在 hello 里出示 S,Origin 白名单照旧。
22
+ * - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
23
+ * - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住 bridge 或吃内存。
24
+ * - socket 路径受 macOS 的 sun_path 限制(104 字节含结尾 0):超出就不监听,只记日志(交接不可用,页面用 `--replace` 兜底)。
25
+ */
26
+ import { randomBytes } from 'node:crypto';
27
+ import { chmodSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
28
+ import { createConnection, createServer } from 'node:net';
29
+ import { basename, dirname, join } from 'node:path';
30
+ import { isPairCommitment } from './pairing.js';
31
+ /** sockaddr_un.sun_path 是 104 字节,含结尾的 0 */
32
+ export const HANDOFF_MAX_PATH_BYTES = 103;
33
+ export const HANDOFF_TIMEOUT_MS = 3_000;
34
+ /** `{"type":"arm","nonce":"<32 字符>"}` 不到 60 字节,留足余量 */
35
+ const MAX_LINE_BYTES = 256;
36
+ const NONCE_RE = /^[0-9a-f]{32}$/;
37
+ /** 承诺文件只放 22 个字符(可能带换行) */
38
+ const MAX_PENDING_BYTES = 64;
39
+ export function handoffSocketPath(dir, port) {
40
+ return join(dir, `bridge-${port}.sock`);
41
+ }
42
+ function pendingPath(dir, nonce) {
43
+ return join(dir, `handoff-${nonce}.pending`);
44
+ }
45
+ /** 删不掉(例如被换成了目录)只当没删:这里的异常没人接,抛出会让 bridge 退出(契约 5,r3 复核 R3C-N1) */
46
+ function removeQuietly(path) {
47
+ try {
48
+ rmSync(path, { force: true });
49
+ }
50
+ catch {
51
+ // 留下的残留由卸载脚本或下一次同名交接处理
52
+ }
53
+ }
54
+ function errorText(error) {
55
+ return error instanceof Error ? error.message : String(error);
56
+ }
57
+ function requestedNonce(line) {
58
+ try {
59
+ const request = JSON.parse(line);
60
+ if (typeof request !== 'object' || request === null)
61
+ return null;
62
+ const { type, nonce } = request;
63
+ return type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce) ? nonce : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** 读出交出方写的承诺并删掉文件;不是本账号的普通文件、内容不对都当没有(不跟随符号链接) */
70
+ function takePendingCommitment(dir, nonce) {
71
+ const path = pendingPath(dir, nonce);
72
+ // 编号的正则已经挡住了路径分隔符;这里再按路径结构核一次,正则被改松时也不会读到目录外(r3 复核 R3C-S1)
73
+ if (dirname(path) !== join(dir) || basename(path) !== `handoff-${nonce}.pending`)
74
+ return null;
75
+ try {
76
+ const info = lstatSync(path);
77
+ const uid = typeof process.getuid === 'function' ? process.getuid() : info.uid;
78
+ if (!info.isFile() || info.uid !== uid || info.size > MAX_PENDING_BYTES)
79
+ return null;
80
+ const commitment = readFileSync(path, 'utf8').trim();
81
+ return isPairCommitment(commitment) ? commitment : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ finally {
87
+ removeQuietly(path);
88
+ }
89
+ }
90
+ /**
91
+ * 后台模式的 bridge 在拿到端口之后调用:同名的 socket 残留(上次没清干净)先删掉 —— 端口在自己手里,这个端口的 socket 不可能属于别人。
92
+ * 返回 null = 没监听(路径太长或建不起来),只记日志,不影响 bridge 本身。
93
+ */
94
+ export async function listenForHandoff(options) {
95
+ const { dir, port, arm, log } = options;
96
+ const path = handoffSocketPath(dir, port);
97
+ if (Buffer.byteLength(path) > HANDOFF_MAX_PATH_BYTES) {
98
+ log(`Pairing handoff is unavailable: ${path} is too long for a local socket. A new browser has to use the reconnect command instead.`);
99
+ return null;
100
+ }
101
+ const server = createServer((socket) => {
102
+ const deadline = setTimeout(() => socket.destroy(), HANDOFF_TIMEOUT_MS);
103
+ socket.on('close', () => clearTimeout(deadline));
104
+ socket.on('error', () => undefined);
105
+ let buffer = '';
106
+ let done = false;
107
+ socket.on('data', (chunk) => {
108
+ // 只处理第一行:之后再发什么都丢掉(不暂停读取,对方断开时这边也跟着关)
109
+ if (done)
110
+ return;
111
+ buffer += chunk.toString('utf8');
112
+ const end = buffer.indexOf('\n');
113
+ if (Buffer.byteLength(end < 0 ? buffer : buffer.slice(0, end)) > MAX_LINE_BYTES) {
114
+ socket.destroy();
115
+ return;
116
+ }
117
+ if (end < 0)
118
+ return;
119
+ done = true;
120
+ const nonce = requestedNonce(buffer.slice(0, end));
121
+ const commitment = nonce ? takePendingCommitment(dir, nonce) : null;
122
+ if (!commitment) {
123
+ socket.end(`${JSON.stringify({ ok: false, error: nonce ? 'not_found' : 'bad_request' })}\n`);
124
+ return;
125
+ }
126
+ const until = arm(commitment);
127
+ log('Armed a pairing value received over the local handoff socket; the page that holds it can pair now.');
128
+ socket.end(`${JSON.stringify({ ok: true, until })}\n`);
129
+ });
130
+ });
131
+ try {
132
+ rmSync(path, { force: true });
133
+ await new Promise((resolve, reject) => {
134
+ server.once('error', reject);
135
+ server.listen(path, () => resolve());
136
+ });
137
+ chmodSync(path, 0o600);
138
+ }
139
+ catch (error) {
140
+ server.close();
141
+ log(`Pairing handoff is unavailable (${errorText(error)}). A new browser has to use the reconnect command instead.`);
142
+ return null;
143
+ }
144
+ return {
145
+ close() {
146
+ server.close();
147
+ rmSync(path, { force: true });
148
+ },
149
+ };
150
+ }
151
+ /**
152
+ * 新的 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时调用:把承诺写进一次性文件,经 socket 发编号。
153
+ * true = 对方确认挂上了;没人在听、对方拒绝、超时都是 false(调用方照常以退出码 3 退出,只是新页面配不上)。文件无论如何都删掉。
154
+ */
155
+ export function handOffCommitment(options) {
156
+ const { dir, port, commitment, timeoutMs = HANDOFF_TIMEOUT_MS } = options;
157
+ if (!isPairCommitment(commitment))
158
+ return Promise.resolve(false);
159
+ const nonce = randomBytes(16).toString('hex');
160
+ const file = pendingPath(dir, nonce);
161
+ try {
162
+ writeFileSync(file, commitment, { mode: 0o600, flag: 'wx' });
163
+ }
164
+ catch {
165
+ return Promise.resolve(false);
166
+ }
167
+ return new Promise((resolve) => {
168
+ let reply = '';
169
+ let settled = false;
170
+ const socket = createConnection(handoffSocketPath(dir, port));
171
+ const finish = (ok) => {
172
+ if (settled)
173
+ return;
174
+ settled = true;
175
+ clearTimeout(timer);
176
+ socket.destroy();
177
+ resolve(ok);
178
+ };
179
+ const timer = setTimeout(() => finish(false), timeoutMs);
180
+ socket.on('error', () => finish(false));
181
+ socket.on('data', (chunk) => {
182
+ reply += chunk.toString('utf8');
183
+ if (reply.length > MAX_LINE_BYTES)
184
+ finish(false);
185
+ });
186
+ socket.on('end', () => {
187
+ try {
188
+ const answer = JSON.parse(reply.trim());
189
+ finish(typeof answer === 'object' && answer !== null && answer.ok === true);
190
+ }
191
+ catch {
192
+ finish(false);
193
+ }
194
+ });
195
+ socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'arm', nonce })}\n`));
196
+ }).finally(() => removeQuietly(file));
197
+ }
@@ -20,6 +20,7 @@ import { EXPECT_PATH } from './authorize.js';
20
20
  import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
21
21
  import { execCommand, killActiveExecs } from './exec.js';
22
22
  import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
23
+ import { handOffCommitment, listenForHandoff } from './handoff.js';
23
24
  import { installShutdownSignals } from './lifecycle.js';
24
25
  import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
25
26
  import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
@@ -86,8 +87,9 @@ your browser.
86
87
 
87
88
  Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
88
89
  --background run without a terminal: no pairing codes are shown; exits with
89
- code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port, ${EXIT_PORT_UNAVAILABLE} if
90
- something else holds the port; with --dev it stops by itself after 2 hours
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
91
93
  --check-update update this installation to the version M2 publishes, then exit
92
94
  --self-test check that this installation loads completely, print its version and exit
93
95
  --stop stop the M2 bridge running on the port (only one it can recognize; it's
@@ -326,7 +328,12 @@ export async function runBridge(argv) {
326
328
  alreadyRunning = true;
327
329
  },
328
330
  });
329
- // listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开
331
+ // listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开。
332
+ // 已在运行时先把承诺交给它,新页面不用重启 bridge 就能配对(D27);交不过去也照常退出码 3
333
+ if (!server && parsed.background && alreadyRunning && parsed.pair) {
334
+ const handed = await handOffCommitment({ dir: m2Dir, port: parsed.port, commitment: parsed.pair });
335
+ log(handed ? 'Handed the pairing to the M2 bridge already running on this port; the new page pairs with it.' : "Couldn't hand the pairing to the M2 bridge already running on this port; the new page can reconnect with --replace.");
336
+ }
330
337
  if (!server)
331
338
  return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
332
339
  const running = server;
@@ -337,6 +344,8 @@ export async function runBridge(argv) {
337
344
  catch (error) {
338
345
  console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
339
346
  }
347
+ // 交接 socket(D27)在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
348
+ let handoff = null;
340
349
  // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
341
350
  // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
342
351
  process.on('exit', () => {
@@ -347,6 +356,12 @@ export async function runBridge(argv) {
347
356
  catch {
348
357
  // 见上
349
358
  }
359
+ try {
360
+ handoff?.close();
361
+ }
362
+ catch {
363
+ // 残留的 socket 文件由下一次启动删掉
364
+ }
350
365
  running.authorizations.killAllNow();
351
366
  running.processes.killAllNow();
352
367
  killActiveExecs();
@@ -383,6 +398,8 @@ export async function runBridge(argv) {
383
398
  process.exit(130);
384
399
  },
385
400
  });
401
+ if (parsed.background)
402
+ handoff = await listenForHandoff({ dir: m2Dir, port: parsed.port, arm: (c) => pairing.armCommitment(c), log });
386
403
  const lifetime = backgroundLifetimeMs(parsed);
387
404
  if (lifetime !== null) {
388
405
  setTimeout(() => {
@@ -248,7 +248,10 @@ export class PairingManager {
248
248
  this.store.save(records.slice(0, MAX_PAIRINGS));
249
249
  return token;
250
250
  }
251
- /** 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定) */
251
+ /**
252
+ * 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定)。
253
+ * 每次都从存储读(不缓存):交接 socket 的门槛论证依赖这一点(`handoff.ts` 文件头),改成缓存时要一起重新评估。
254
+ */
252
255
  liveRecords() {
253
256
  const all = this.store.load();
254
257
  const live = all.filter((record) => this.now() - Date.parse(record.lastUsedAt) <= TOKEN_IDLE_MS);
@@ -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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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": {