@m2canvas/connect 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -32,7 +32,8 @@ The `bridge` subcommand is the local connection that lets M2 start and talk to t
32
32
 
33
33
  - **Desktop app only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://patch.design` that the M2 desktop app connects: for every connection the app first gets a one-time ticket from the bridge over `~/.m2canvas/bridge-<port>.sock` (only your account can open it) and adds it to the request. A browser tab can't add it, so browsers, other sites, other machines and other hosts are refused before a connection is made. Tickets work once, expire after 60 seconds and stop working when the bridge exits; pages hold no pairing token or other credential. (`--dev` also accepts a local M2 dev server on port 5173, still only through a desktop app started for development.)
34
34
  - **Connecting gives the page your agents.** A connected page can make these agents read and change files and run commands as you, the same as typing into them yourself. The first time, the desktop app asks you before it starts the bridge.
35
- - **Your own logins.** Agents use their own sign-in and their own M2 MCP setup. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
35
+ - **Your own logins.** Agents use their own sign-in. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
36
+ - **M2 without setup for Claude Code and Codex (from 0.14.0).** When you are signed in to the M2 desktop app, the app keeps a 24-hour M2 session token in `~/.m2canvas/mcp-session/headers.json` (only your account can read it; the app removes it when you sign out or quit). For the Claude Code and Codex the bridge bundles, M2 can then ask the bridge to add one MCP server named `patch_session` to that agent session only: its address is fixed in this package, and the agent reads the token through a small script the bridge writes to `~/.m2canvas/bin/m2-mcp-session-headers`. The token never appears in process arguments or environment variables, the page never sees it, and your agent config files are not changed; for that session the bridge hides your own entries that point to M2. Other agents, and sessions where the token isn't available, use their own M2 MCP setup as before.
36
37
  - **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
37
38
  - **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (they bundle the agent: about 110 MB and 140 MB to download, 270 MB and 350 MB on disk); M2 shows the download progress. You don't need Claude Code or Codex installed yourself: checking sign-in, adding M2 and signing in all use the bundled `claude` and `codex`, which read the same settings as your own installs.
38
39
  - **Checking an agent before use.** Before starting an agent, M2 asks the bridge whether it is signed in and has M2 set up (commands such as `claude auth status` and `claude mcp list`; `claude mcp list` starts the MCP servers you configured in order to check them). From 0.9.0 the bridge runs this check for Claude Code and Codex once when it starts, and remembers a result that passed, so opening another project doesn't wait for it again. It checks again when you click "Check again" in M2 and when M2 adds itself to an agent or signs in for you; when a page uses a remembered result that is more than 5 minutes old, it also checks again in the background and tells M2 if the result no longer holds. There is no periodic check. Results that need you to do something are never remembered, and nothing is written to disk.
@@ -44,4 +45,4 @@ The `bridge` subcommand is the local connection that lets M2 start and talk to t
44
45
  - **Nothing starts at login.** The app runs the launcher when it opens. The launcher first updates the bridge to the version listed at `https://patch.design/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.
45
46
  - **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.
46
47
  - **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/`.
47
- - **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. 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. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a page the M2 desktop app connected can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
48
+ - **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. It stops the bridge and removes what the installer added (and the M2 session token, if any); `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a page the M2 desktop app connected can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
@@ -2,14 +2,39 @@
2
2
  * bridge 方法的分派(从 server.ts 拆出):按方法交给 service / ProcessManager / auth-call.ts。
3
3
  * 连接状态(怎么认证的、连接是否还在)只在 server.ts 里改,这里经 ctx 的回调读。
4
4
  *
5
- * 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、processId,进不了 spawn 的命令、参数、cwd、环境;
5
+ * 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、processId、`process/start` 的会话令牌开关与用于比对的 userId
6
+ * (change acp-session-m2-token,契约 3 / 21:params 只认 `agent` 与 `m2Session: { userId }`,多一个键即 bad_message),进不了 spawn 的命令、参数、cwd、环境;
6
7
  * agent/authorize 与 agent/authorize-cancel 的参数校验在 auth-call.ts。
7
8
  * bridge/uninstall 不经过这里:server.ts 在调用 handleCall 之前就把它交给 uninstall-call.ts(它要等回复写出再关连接、退出)。
8
9
  */
9
10
  import { isBridgeAgentId } from './agents.js';
10
11
  import { handleAuthCall } from './auth-call.js';
11
12
  import { ProcessError } from './processes.js';
13
+ import { isUserId } from './session-token.js';
12
14
  import { BridgeFailure } from './service.js';
15
+ /** params 必须恰好是这些键(自有属性),同 auth-call.ts */
16
+ function hasExactKeys(value, keys) {
17
+ const own = Object.keys(value);
18
+ return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
19
+ }
20
+ /**
21
+ * `process/start` 的参数(契约 3 / 21):只认 `agent` 与可选的 `m2Session: { userId }`。返回会话令牌开关;不合规抛 bad_message。
22
+ * 旧版 bridge 不校验多余字段、会把 `m2Session` 静默丢掉,所以页面只按预检的 `m2Session` 决定走哪条路径(design D11)。
23
+ */
24
+ function sessionParam(params) {
25
+ if (hasExactKeys(params, ['agent']))
26
+ return undefined;
27
+ if (!hasExactKeys(params, ['agent', 'm2Session']))
28
+ throw new BridgeFailure('bad_message', 'process/start only accepts agent and m2Session');
29
+ const session = params.m2Session;
30
+ if (typeof session !== 'object' || session === null || Array.isArray(session) || !hasExactKeys(session, ['userId'])) {
31
+ throw new BridgeFailure('bad_message', 'm2Session only accepts userId');
32
+ }
33
+ const userId = session.userId;
34
+ if (!isUserId(userId))
35
+ throw new BridgeFailure('bad_message', 'm2Session.userId is not a valid user id');
36
+ return { userId };
37
+ }
13
38
  export async function handleCall(ctx, conn, id, method, params) {
14
39
  // 纵深判断:upgrade 只放行带有效票据的连接(server.ts 也在调用前判过一次)
15
40
  if (conn.auth !== 'client-ticket')
@@ -37,14 +62,16 @@ export async function handleCall(ctx, conn, id, method, params) {
37
62
  ctx.log(`Page asked to add M2 to ${agent}'s MCP config.`);
38
63
  return { agent, ...(await ctx.service.configure(agent)) };
39
64
  case 'process/start': {
40
- const launch = await ctx.service.launch(agent);
65
+ // 先校验参数,再做任何检测(多余键不能借检测的副作用);连接是否经客户端认证已在函数开头判过(1a 的门,契约 21 依赖它)
66
+ const session = sessionParam(params);
67
+ const { m2SessionExpiresAt, ...launch } = await ctx.service.launch(agent, session);
41
68
  try {
42
69
  const processId = await ctx.processes.start(conn.id, agent, launch);
43
70
  // 启动期间页面已断开:没有人会再停它,立刻清理
44
71
  if (!ctx.isConnected(conn.id))
45
72
  ctx.processes.stopOwner(conn.id);
46
- ctx.log(`Started ${agent}.`);
47
- return { processId, agent, cwd: ctx.cwd };
73
+ ctx.log(session ? `Started ${agent} with the M2 session token.` : `Started ${agent}.`);
74
+ return { processId, agent, cwd: ctx.cwd, ...(m2SessionExpiresAt === undefined ? {} : { m2SessionExpiresAt }) };
48
75
  }
49
76
  catch (error) {
50
77
  if (error instanceof ProcessError)
@@ -33,8 +33,12 @@ import { allowedOrigins } from './security.js';
33
33
  import { prewarmPrechecks } from './precheck-cache.js';
34
34
  import { selfTestProblems } from './self-test.js';
35
35
  import { runCheckUpdate } from './update-io.js';
36
+ import { welcomeRejectedOption } from './update.js';
36
37
  import { startBridgeServer } from './server.js';
37
38
  import { createAgentService } from './service.js';
39
+ import { createSessionTokens } from './session-token.js';
40
+ import { ensureHelperScript, readTextFile } from './session-token-io.js';
41
+ import { M2_MCP_URL } from '../site-config.js';
38
42
  import { KILL_GRACE_MS } from './processes.js';
39
43
  import { TicketBook } from './ticket.js';
40
44
  import { listenForTicketRequests, ticketSocketPathProblem } from './ticket-socket.js';
@@ -189,6 +193,17 @@ export async function runBridge(argv) {
189
193
  expectAvailable: () => isExecutable(EXPECT_PATH),
190
194
  // 出现过「通过」之后一次真跑没通过:告诉之前可能拿到通过的页面(尽力收回,契约 18)
191
195
  onPrecheckRevoked: (result) => server?.broadcast('precheck/updated', result),
196
+ // M2 会话令牌(契约 21):令牌文件由 M2 客户端维护,这里只读;地址只取站点配置,--dev 也一样(design T3)
197
+ sessionTokens: createSessionTokens({
198
+ home,
199
+ cwd: parsed.cwd,
200
+ agentEnv: buildAgentEnv(),
201
+ mcpUrl: M2_MCP_URL,
202
+ readText: (path) => readTextFile(path),
203
+ ensureHelper: ensureHelperScript,
204
+ now: Date.now,
205
+ log,
206
+ }),
192
207
  log,
193
208
  });
194
209
  const version = packageVersion();
@@ -229,6 +244,8 @@ export async function runBridge(argv) {
229
244
  agentEnv: buildAgentEnv(),
230
245
  allowedOrigins: origins,
231
246
  uninstall: uninstaller,
247
+ // 启动器拉黑的版本只对后台模式有意义(终端模式不经启动器,change bridge-normal-update D6);每次 welcome 现读
248
+ ...welcomeRejectedOption({ background: parsed.background, home }),
232
249
  log,
233
250
  verbose: parsed.verbose,
234
251
  }), {
@@ -18,6 +18,25 @@ export const KILL_GRACE_MS = 3_000;
18
18
  export const MAX_LINE_CHARS = 64 * 1024 * 1024;
19
19
  const STDERR_KEEP = 8 * 1024;
20
20
  const STDERR_REPORT = 4 * 1024;
21
+ /**
22
+ * M2 会话令牌(`m2st_…`,change acp-session-m2-token)的打码:stderr 尾巴会经 `process/exited` 发给页面(页面里跑着不可信的 DS 代码),
23
+ * agent 在 helper 出错或令牌被拒时会不会把请求头打到 stderr 不受我们控制(review r1 残余风险),所以转发前一律替换掉。
24
+ * 正在写的半截令牌(只有 `m2st_` 开头)也替换。
25
+ */
26
+ export function redactSessionTokens(text) {
27
+ return text.replace(/m2st_[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*)?/g, 'm2st_[redacted]');
28
+ }
29
+ /**
30
+ * 只留最后 `max` 个字符;被截断时从第一个空白之后开始 —— 令牌不含空白,截在令牌中间时丢掉那半截
31
+ * (否则 `m2st_` 前缀已被截掉、只剩签名段,打码认不出它)。
32
+ */
33
+ export function keepTail(text, max) {
34
+ if (text.length <= max)
35
+ return text;
36
+ const cut = text.slice(-max);
37
+ const space = cut.search(/\s/);
38
+ return space === -1 ? '' : cut.slice(space);
39
+ }
21
40
  export class ProcessError extends Error {
22
41
  code;
23
42
  constructor(code, message) {
@@ -96,7 +115,7 @@ export class ProcessManager {
96
115
  try {
97
116
  child = spawn(launch.command, launch.args, {
98
117
  cwd: this.options.cwd,
99
- env: this.options.env,
118
+ env: launch.env ? { ...this.options.env, ...launch.env } : this.options.env,
100
119
  stdio: ['pipe', 'pipe', 'pipe'],
101
120
  detached: process.platform !== 'win32',
102
121
  windowsHide: true,
@@ -107,7 +126,7 @@ export class ProcessManager {
107
126
  this.adjustStarting(ownerId, -1);
108
127
  return Promise.reject(new ProcessError('spawn_failed', `${launch.command}: ${error instanceof Error ? error.message : String(error)}`));
109
128
  }
110
- const managed = { id, ownerId, agent, child, stopping: false, tooLarge: false, stderr: '', killTimer: null };
129
+ const managed = { id, ownerId, agent, child, rewritePageLine: launch.rewritePageLine ?? null, stopping: false, tooLarge: false, stderr: '', stderrDropping: false, killTimer: null };
111
130
  return new Promise((resolve, reject) => {
112
131
  let started = false;
113
132
  child.once('spawn', () => {
@@ -150,8 +169,19 @@ export class ProcessManager {
150
169
  });
151
170
  child.stderr?.setEncoding('utf8');
152
171
  child.stderr?.on('data', (chunk) => {
153
- managed.stderr = (managed.stderr + chunk).slice(-STDERR_KEEP);
154
- this.options.onStderr?.(managed.id, managed.agent, chunk);
172
+ let piece = chunk;
173
+ if (managed.stderrDropping) {
174
+ const space = piece.search(/\s/);
175
+ piece = space === -1 ? '' : piece.slice(space);
176
+ managed.stderrDropping = space === -1;
177
+ }
178
+ const joined = managed.stderr + piece;
179
+ managed.stderr = keepTail(joined, STDERR_KEEP);
180
+ // 截成空:结尾可能正是一个被拆开的令牌的前半截,它的后半截会在下一块开头
181
+ if (joined.length > 0 && managed.stderr === '')
182
+ managed.stderrDropping = true;
183
+ // 只在 --verbose 时打到 bridge 自己的终端:逐块打码,尽力而为(跨块的令牌只会出现在同账号的终端里)
184
+ this.options.onStderr?.(managed.id, managed.agent, redactSessionTokens(chunk));
155
185
  });
156
186
  // 'close' 在 stdio 全部关闭后触发,保证最后几行先于退出事件送出
157
187
  child.on('close', (code, signal) => {
@@ -171,7 +201,7 @@ export class ProcessManager {
171
201
  code,
172
202
  signal,
173
203
  reason: managed.tooLarge ? 'output_too_large' : managed.stopping ? 'stopped' : 'exited',
174
- stderrTail: managed.stderr.slice(-STDERR_REPORT),
204
+ stderrTail: redactSessionTokens(keepTail(managed.stderr, STDERR_REPORT)),
175
205
  });
176
206
  });
177
207
  }
@@ -185,7 +215,8 @@ export class ProcessManager {
185
215
  const managed = this.processes.get(processId);
186
216
  if (!managed || managed.ownerId !== ownerId || managed.stopping)
187
217
  return false;
188
- managed.child.stdin?.write(`${line}\n`);
218
+ const out = managed.rewritePageLine ? managed.rewritePageLine(line) : line;
219
+ managed.child.stdin?.write(`${out}\n`);
189
220
  return true;
190
221
  }
191
222
  stopManaged(managed) {
@@ -10,7 +10,9 @@
10
10
  * - 握手后第一条消息必须是 hello,且只认 protocols 与 client:带 token / pairSecret(旧页面)或其他键一律 bad_message 并关闭。
11
11
  * - 每条被接受的连接标记 `auth: 'client-ticket'`;方法调用、acp 帧、广播都再判一次(纵深判断,正常走不到另一支),
12
12
  * 以后只开给客户端认证连接的能力(1b 的会话令牌模式)也按这个标记判。
13
- * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、processId —— 进不了 spawn 的命令、参数、cwd、环境。
13
+ * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、processId、`process/start` 的会话令牌开关与用于比对的
14
+ * userId(change acp-session-m2-token,契约 21)—— 进不了 spawn 的命令、参数、cwd、环境。会话令牌模式下 bridge 自己给进程加的 MCP 条目
15
+ * (Claude 的 `_meta`、Codex 的 `CODEX_CONFIG`)在页面的行过了 acp-guard 之后才加,内容全部由 bridge 决定(session-token.ts)。
14
16
  * 方法分派在 call-dispatch.ts;agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(auth-call.ts,命令清单写死在 authorize.ts)。
15
17
  * - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
16
18
  * - bridge/uninstall(0.8.0,uninstall-call.ts):开始后拒绝新连接与一切其他帧,端口占到退出,结果回出去后退出。
@@ -156,7 +158,7 @@ export function startBridgeServer(options) {
156
158
  send(conn, {
157
159
  type: 'welcome',
158
160
  protocol: Math.max(...common),
159
- bridge: options.bridge,
161
+ bridge: options.rejectedVersions ? { ...options.bridge, rejected: options.rejectedVersions() } : options.bridge,
160
162
  // 页面侧类型在 1a 阶段 B 才删:这三个字段的形状对现有页面不变(恒为已连上)
161
163
  paired: true,
162
164
  tokenRejected: false,
@@ -15,6 +15,9 @@
15
15
  * 优先用最近一次预检 / 写配置时得到的名字(D19,页面先预检才会显示按钮,省掉一次重跑 —— Claude 的 `mcp list` 实测约 5 秒),没有记录时重跑;
16
16
  * 预检 / 写配置的结果按同一判据带上 `authorize` 能力,页面只在能力存在时才调用 agent/authorize。
17
17
  *
18
+ * 会话令牌(change acp-session-m2-token,契约 21,规则在 session-token.ts):claude / codex 的预检结果带 `m2Session`,每次给出时现读
19
+ * 令牌文件、不进预检缓存;`launch` 带 `session` 时由 session-token.ts 核对文件与账号并给出这个进程要加的环境 / 行改写。
20
+ *
18
21
  * 预检缓存(change bridge-precheck-speedup,契约 18,规则在 precheck-cache.ts):带连接 id 的预检是页面的 `agent/precheck`,
19
22
  * 每条连接第一次查某家可以用缓存;不带的(授权结束后的重查)一律真跑并写回。写配置、授权在**开始运行之前**作废这家的缓存 ——
20
23
  * 前置条件不满足而什么都没运行时不作废。缓存存不带能力的原始结果,给出时再按当前判据加 `authorize`。
@@ -92,11 +95,21 @@ export function createAgentService(deps) {
92
95
  return check;
93
96
  return { ...check, authorize };
94
97
  }
95
- const withCapabilities = (result) => ({
96
- ...result,
97
- login: withCapability(result.login, result.agent, 'login'),
98
- m2: withCapability(result.m2, result.agent, 'm2'),
99
- });
98
+ /**
99
+ * 给出预检结果时加上能力与 `m2Session`。缓存只存原始结果;`m2Session` 每次现读令牌文件(客户端随时会换掉它,契约 21),
100
+ * 所以缓存命中、真跑、`precheck/updated` 推送三条路径给出的都是此刻的状态。只有 claude / codex 带。
101
+ */
102
+ const withCapabilities = (result) => {
103
+ const decorated = {
104
+ ...result,
105
+ login: withCapability(result.login, result.agent, 'login'),
106
+ m2: withCapability(result.m2, result.agent, 'm2'),
107
+ };
108
+ delete decorated.m2Session;
109
+ if (deps.sessionTokens && isAdapterAgent(result.agent))
110
+ decorated.m2Session = deps.sessionTokens.state();
111
+ return decorated;
112
+ };
100
113
  const precheckCache = new PrecheckCache({
101
114
  run: async (agent) => {
102
115
  const result = await precheckAgent(await ready(agent), precheckDeps);
@@ -174,10 +187,19 @@ export function createAgentService(deps) {
174
187
  m2: withCapability(remember(agent, await precheckM2(detected, precheckDeps)), agent, 'm2'),
175
188
  };
176
189
  },
177
- async launch(agent) {
190
+ async launch(agent, session) {
191
+ if (session && !deps.sessionTokens)
192
+ throw new BridgeFailure('m2_session_unavailable', 'this bridge does not support the M2 session token');
178
193
  const detected = await ready(agent);
179
- if (isAdapterAgent(agent))
180
- return { command: deps.nodePath, args: [deps.adapters.entryPath(agent)] };
194
+ if (isAdapterAgent(agent)) {
195
+ const spec = { command: deps.nodePath, args: [deps.adapters.entryPath(agent)] };
196
+ if (!session || !deps.sessionTokens)
197
+ return spec;
198
+ const { env, rewritePageLine, expiresAt } = deps.sessionTokens.prepare(agent, session.userId);
199
+ return { ...spec, ...(env ? { env } : {}), ...(rewritePageLine ? { rewritePageLine } : {}), m2SessionExpiresAt: expiresAt };
200
+ }
201
+ if (session)
202
+ throw new BridgeFailure('m2_session_unavailable', `the M2 session token is only for Claude and Codex, not ${agent}`);
181
203
  // 已安装的原生 agent 必有 path(detectAgent 的约定),这里只为收窄类型
182
204
  if (!detected.path)
183
205
  throw new BridgeFailure('internal', `no executable path for ${agent}`);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 会话令牌模式的本机文件读写(change acp-session-m2-token T1):读令牌文件与 agent 配置、写 helper 脚本。
3
+ * 规则在 session-token.ts;这里只做文件操作,单测用临时家目录。
4
+ *
5
+ * bridge 只写 helper(`~/.m2canvas/bin/m2-mcp-session-headers`),从不写、不删令牌文件(客户端负责,design D4)。
6
+ * helper 先写同目录临时文件(0700)再改名,读到的总是完整的旧脚本或新脚本;内容与权限都对就不写。
7
+ */
8
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ /** 读文本文件;不存在、读不了、超过 `maxBytes` 都返回 null(调用方按「没有」处理,不区分原因、不打印内容) */
11
+ export function readTextFile(path, maxBytes = 1024 * 1024) {
12
+ try {
13
+ if (statSync(path).size > maxBytes)
14
+ return null;
15
+ return readFileSync(path, 'utf8');
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function ensureHelperScript(path, content) {
22
+ try {
23
+ const stat = statSync(path);
24
+ if ((stat.mode & 0o777) === 0o700 && stat.isFile() && readFileSync(path, 'utf8') === content)
25
+ return;
26
+ }
27
+ catch {
28
+ // 不存在:照常写
29
+ }
30
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
31
+ const tmp = `${path}.${process.pid}.tmp`;
32
+ try {
33
+ writeFileSync(tmp, content, { mode: 0o700 });
34
+ chmodSync(tmp, 0o700);
35
+ renameSync(tmp, path);
36
+ }
37
+ catch (error) {
38
+ rmSync(tmp, { force: true });
39
+ throw error;
40
+ }
41
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * 会话令牌模式(change acp-session-m2-token D6 / D7 / T1 / T2;`connect/local-bridge` 契约 3 / 21)。
3
+ *
4
+ * M2 客户端主进程替已登录用户领一张 24 小时的 M2 会话令牌,写进 `~/.m2canvas/mcp-session/headers.json`(内容就是
5
+ * `{"Authorization":"Bearer m2st_…"}`,0600)。bridge 只读、不写不删这个文件;经 1a 客户端认证的连接在 `process/start`
6
+ * 带上 `m2Session: { userId }` 时,bridge 给这个 agent 进程加一条名为 `patch_session`、地址由 bridge 决定的 MCP 条目:
7
+ * - Claude:转发页面的 `session/new` / `session/load` / `session/resume` 时补 `_meta.claudeCode.options`
8
+ * (`mcpServers` + `settings.deniedMcpServers`)。**三个入口一律补**:漏一个,该进程就没有注入的 M2,而且用户自己的
9
+ * M2 条目不再被屏蔽(evidence T16)。页面自己发的 `_meta` 仍由 acp-guard.ts 先拒(这里只处理已经过检查的行)。
10
+ * - Codex:拉起适配器时设环境变量 `CODEX_CONFIG`(嵌套写法,evidence C4 / C9)。
11
+ * 令牌不进任何进程的参数与环境变量(macOS 的 /bin/ps 是 setuid root,本机任何账号都能看到全部参数,evidence T1):
12
+ * 两家都经 helper 读文件。helper 是 bridge 写好的固定脚本(T1:Codex 只接受一个字符串、npm 包里的脚本装到本机没有执行位),
13
+ * 内容只有一行 `exec /bin/cat '<令牌文件>'`,不带参数。参数、环境、`_meta`、`CODEX_CONFIG` 里只有地址、脚本路径与屏蔽名单。
14
+ *
15
+ * 屏蔽名单:只对本进程生效、不写任何配置文件。按 MCP 主机认条目(`isM2Url`,与 connect 写配置同一判据),不看名字。
16
+ * - Claude 按名字屏蔽(按地址会连注入那条一起挡住,evidence E-e);屏蔽不存在的名字没有副作用,所以多读几处:
17
+ * `~/.claude.json`(设了 `CLAUDE_CONFIG_DIR` 时另读那里的)的用户级与本目录级、会话目录的 `.mcp.json`。
18
+ * - Codex 用 `{ enabled: false }`,只列它真会读到的条目:给不存在的条目只写 `enabled` 会让整个配置加载失败(T2 实测)。
19
+ * 只读 `$CODEX_HOME/config.toml`,用户已有任何名为 `patch_session` 的条目时不进会话令牌模式(深合并会混成一条)。
20
+ * 漏掉的条目(例如 claude.ai 网页里加的连接器)会与注入那条并存:页面的身份核对(面板契约 8–10)判 mismatch 并锁住进程。
21
+ *
22
+ * 维护约束:
23
+ * - 日志与错误里不得出现令牌或文件内容;只说状态、原因与到期时间。
24
+ * - 升级 Claude 适配器时(契约 7)复核:`_meta.claudeCode.options` 的 `mcpServers` / `settings` 仍并入 SDK 选项、
25
+ * `headersHelper` 原样进 `--mcp-config`、`--settings` 只出现一次(evidence T15)。升级 Codex 时复核 `CODEX_CONFIG`
26
+ * 在页面不传 `mcpServers` 时仍原样生效、嵌套 `enabled = false` 只关该条、`http_headers_helper` 仍是一个字符串。
27
+ */
28
+ import { join } from 'node:path';
29
+ import { isM2Url } from '../config-files.js';
30
+ import { isSafeAuthEntry } from './authorize.js';
31
+ import { BridgeFailure } from './errors.js';
32
+ /** 注入条目的名字(D7)。不与 connect 写入名 `patch` 相同:Codex 同名是按键深合并,Claude 同名时 helper 与已存 OAuth 谁优先未测 */
33
+ export const SESSION_ENTRY = 'patch_session';
34
+ /** 剩余有效期不足它就报 `expiring`、不进会话令牌模式(D6):客户端每 4 小时刷新文件,正常不会低于 20 小时 */
35
+ export const SESSION_READY_MIN_REMAINING_MS = 6 * 3600 * 1000;
36
+ /** 令牌文件的大小上限:正常约 350 字节 */
37
+ const MAX_HEADERS_FILE_BYTES = 4096;
38
+ const TOKEN_TYP = 'm2-mcp-session';
39
+ /** helper 路径能原样交给 shell 执行或直接执行的字符(T1):家目录名带空格等时不进会话令牌模式 */
40
+ const SAFE_HELPER_PATH = /^\/[A-Za-z0-9._/-]+$/;
41
+ /** 页面传来的 userId:Supabase 用户 id 是 uuid;放宽到常见 id 字符,只用来比对 */
42
+ const USER_ID = /^[A-Za-z0-9-]{1,128}$/;
43
+ /** Claude 适配器会读 `_meta.claudeCode.options` 的会话方法(D6;页面能发的会话类方法里只有这三个会建会话) */
44
+ export const SESSION_INJECT_METHODS = ['session/new', 'session/load', 'session/resume'];
45
+ export function sessionPaths(home) {
46
+ return {
47
+ headersFile: join(home, '.m2canvas', 'mcp-session', 'headers.json'),
48
+ helper: join(home, '.m2canvas', 'bin', 'm2-mcp-session-headers'),
49
+ };
50
+ }
51
+ export function isUserId(value) {
52
+ return typeof value === 'string' && USER_ID.test(value);
53
+ }
54
+ function isObject(value) {
55
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
56
+ }
57
+ /**
58
+ * 读令牌文件的状态(不验签:bridge 没有密钥,只看账号与剩余有效期;签名由服务端验)。
59
+ * 任何读不了、格式不对、已过期都按 `missing`:页面退回 OAuth 路径并说明原因(D8)。
60
+ */
61
+ export function readSessionFile(read, headersFile, nowMs) {
62
+ const text = read(headersFile);
63
+ if (text === null || text.length > MAX_HEADERS_FILE_BYTES)
64
+ return { state: 'missing' };
65
+ let headers;
66
+ try {
67
+ headers = JSON.parse(text);
68
+ }
69
+ catch {
70
+ return { state: 'missing' };
71
+ }
72
+ if (!isObject(headers) || Object.keys(headers).length !== 1 || typeof headers.Authorization !== 'string')
73
+ return { state: 'missing' };
74
+ const match = /^Bearer m2st_([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]{43}$/.exec(headers.Authorization);
75
+ if (!match)
76
+ return { state: 'missing' };
77
+ let payload;
78
+ try {
79
+ payload = JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8'));
80
+ }
81
+ catch {
82
+ return { state: 'missing' };
83
+ }
84
+ if (!isObject(payload) || payload.typ !== TOKEN_TYP || !isUserId(payload.sub) || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
85
+ return { state: 'missing' };
86
+ }
87
+ const expiresAt = payload.exp * 1000;
88
+ const remaining = expiresAt - nowMs;
89
+ if (remaining <= 0)
90
+ return { state: 'missing' };
91
+ return { state: remaining >= SESSION_READY_MIN_REMAINING_MS ? 'ready' : 'expiring', sub: payload.sub, expiresAt };
92
+ }
93
+ export function isSafeHelperPath(path) {
94
+ return SAFE_HELPER_PATH.test(path);
95
+ }
96
+ /** helper 脚本的全文:不带参数、不读环境变量,只把令牌文件原样输出(文件就是 helper 要输出的 JSON 请求头) */
97
+ export function helperScript(headersFile) {
98
+ return `#!/bin/sh\n# M2 local bridge (change acp-session-m2-token): prints the headers of the injected ${SESSION_ENTRY} MCP server.\nexec /bin/cat '${headersFile}'\n`;
99
+ }
100
+ // ───────────────────────────── 屏蔽名单 ─────────────────────────────
101
+ /** 字符串里出现的 http(s) 地址有没有指向 M2(stdio 包装写法把地址放在 args / command 里) */
102
+ function mentionsM2(value) {
103
+ if (typeof value !== 'string')
104
+ return false;
105
+ for (const found of value.match(/https?:\/\/[^\s"'`,\]]+/g) ?? [])
106
+ if (isM2Url(found))
107
+ return true;
108
+ return false;
109
+ }
110
+ /** Claude / `.mcp.json` 形状的一条 server 是否指向 M2:url / serverUrl,或 stdio 包装的 command / args 里带 M2 地址 */
111
+ export function claudeEntryPointsToM2(entry) {
112
+ if (!isObject(entry))
113
+ return false;
114
+ if (isM2Url(entry.url) || isM2Url(entry.serverUrl))
115
+ return true;
116
+ if (mentionsM2(entry.command))
117
+ return true;
118
+ return Array.isArray(entry.args) && entry.args.some(mentionsM2);
119
+ }
120
+ function namesIn(servers) {
121
+ if (!isObject(servers))
122
+ return [];
123
+ return Object.entries(servers).filter(([, entry]) => claudeEntryPointsToM2(entry)).map(([name]) => name);
124
+ }
125
+ /**
126
+ * Claude 的 server 名只能是字母、数字、`-`、`_`(随包 claude 2.1.284 的校验文案「Server name can only contain letters, numbers, hyphens,
127
+ * and underscores」):屏蔽名单里混进不合规的名字,整份 `--settings` 可能被拒、一条都屏蔽不了,所以 Claude 只收这种名字。
128
+ */
129
+ const CLAUDE_SERVER_NAME = /^[A-Za-z0-9_-]{1,128}$/;
130
+ function finish(found, valid = isSafeAuthEntry) {
131
+ const names = [];
132
+ const skipped = [];
133
+ for (const name of found) {
134
+ if (name === SESSION_ENTRY || names.includes(name) || skipped.includes(name))
135
+ continue;
136
+ if (valid(name))
137
+ names.push(name);
138
+ else
139
+ skipped.push(name);
140
+ }
141
+ return { names, skipped };
142
+ }
143
+ /**
144
+ * Claude 会读到的、指向 M2 的条目名(D7)。读不了的文件跳过:屏蔽名单是尽力而为,漏掉的由身份核对兜底;
145
+ * 注入的 `patch_session` 不进名单(Claude 按名字屏蔽,会连注入那条一起挡住,D7 第 3 条);用户自己恰好有同名条目时以注入那条为准
146
+ * (evidence 验证点 3「E-同名」:`--mcp-config` 的同名条目整条盖过用户配置,`_meta` 走的也是 `--mcp-config`,T15)。
147
+ */
148
+ export function claudeM2EntryNames(readJsonFile, options) {
149
+ const found = [];
150
+ const configs = [join(options.home, '.claude.json')];
151
+ if (options.claudeConfigDir)
152
+ configs.push(join(options.claudeConfigDir, '.claude.json'));
153
+ for (const path of configs) {
154
+ const config = readJsonFile(path);
155
+ if (!isObject(config))
156
+ continue;
157
+ found.push(...namesIn(config.mcpServers));
158
+ const project = isObject(config.projects) ? config.projects[options.cwd] : undefined;
159
+ if (isObject(project))
160
+ found.push(...namesIn(project.mcpServers));
161
+ }
162
+ const local = readJsonFile(join(options.cwd, '.mcp.json'));
163
+ if (isObject(local))
164
+ found.push(...namesIn(local.mcpServers));
165
+ return finish(found, (name) => CLAUDE_SERVER_NAME.test(name));
166
+ }
167
+ const BARE_OR_QUOTED = String.raw `(?:"([^"\\]+)"|'([^']+)'|([A-Za-z0-9_-]+))`;
168
+ const MULTILINE_QUOTES = ['"""', "'''"];
169
+ const nameOf = (match) => (match[1] ?? match[2] ?? match[3]);
170
+ const SECTION = new RegExp(String.raw `^\[\s*mcp_servers\.${BARE_OR_QUOTED}\s*\]\s*(?:#.*)?$`);
171
+ const SERVERS_TABLE = /^\[\s*mcp_servers\s*\]\s*(?:#.*)?$/;
172
+ /** server 的子表(`[mcp_servers.<名>.env]` 等):说明这个名字的条目存在(同名冲突要认它),子表里的行不算 server 本身的字段 */
173
+ const SUB_SECTION = new RegExp(String.raw `^\[\s*mcp_servers\.${BARE_OR_QUOTED}\s*\.`);
174
+ const INLINE_ENTRY = new RegExp(String.raw `^${BARE_OR_QUOTED}\s*=\s*\{(.*)\}\s*(?:#.*)?$`);
175
+ /** `[mcp_servers]` 段里的点路径(`m2.url = "…"`):认它的名字(冲突判断与指向 M2 都按它) */
176
+ const SERVERS_DOTTED = new RegExp(String.raw `^${BARE_OR_QUOTED}\.[A-Za-z0-9_]+\s*=(.*)$`);
177
+ const ROOT_DOTTED = new RegExp(String.raw `^mcp_servers\.${BARE_OR_QUOTED}\s*(?:\.|=)(.*)$`);
178
+ /**
179
+ * Codex `config.toml` 里指向 M2 的条目名(T2)。不引 TOML 解析器,只认三种写法:`[mcp_servers.<名>]` 段(段内任何一行带 M2 地址
180
+ * 都算,覆盖 url 与 stdio 包装的 command / args,含跨行数组)、`[mcp_servers]` 段里的行内表、文件开头的点路径。
181
+ * 认不出的写法不列(不列只是两条并存,列错了会让 Codex 整个配置加载失败),名字一律过 `isSafeAuthEntry`。
182
+ * 多行字符串(`"""` / `'''`)里的行整段跳过:里面像段头的行不能被当成条目(列出不存在的条目会让 Codex 整个配置加载失败,review r1 S1)。
183
+ * 已知不认的写法(很少见,tasks CO-10):文件开头的 `mcp_servers = { … }` 整表行内写法、跨行的行内表 —— 只会漏屏蔽 / 漏判同名
184
+ * (漏屏蔽由身份核对兜底),不会列出不存在的条目。
185
+ */
186
+ export function codexM2Entries(toml) {
187
+ const found = [];
188
+ const all = new Set();
189
+ /** null = 文件开头(还没有任何表头);'servers' = `[mcp_servers]`;其余 = 某个 server 段的名字;'' = 别的表 */
190
+ let table = null;
191
+ /** 正在哪种多行字符串里(null = 不在):里面的行是字符串内容,整行跳过 */
192
+ let inMultiline = null;
193
+ for (const raw of (toml ?? '').split(/\r?\n/)) {
194
+ if (inMultiline !== null) {
195
+ if (raw.split(inMultiline).length % 2 === 0)
196
+ inMultiline = null;
197
+ continue;
198
+ }
199
+ const line = raw.trim();
200
+ if (line === '' || line.startsWith('#'))
201
+ continue;
202
+ // 这一行打开了多行字符串、却没在本行关上:本行照常看(键在这一行),之后的行跳过
203
+ for (const quote of MULTILINE_QUOTES)
204
+ if (line.split(quote).length % 2 === 0)
205
+ inMultiline = quote;
206
+ if (line.startsWith('[')) {
207
+ const section = SECTION.exec(line);
208
+ const sub = section ? null : SUB_SECTION.exec(line);
209
+ if (section) {
210
+ table = nameOf(section);
211
+ all.add(table);
212
+ }
213
+ else {
214
+ if (sub)
215
+ all.add(nameOf(sub));
216
+ table = SERVERS_TABLE.test(line) ? 'servers' : '';
217
+ }
218
+ continue;
219
+ }
220
+ if (table === null) {
221
+ const dotted = ROOT_DOTTED.exec(line);
222
+ if (dotted) {
223
+ const name = nameOf(dotted);
224
+ all.add(name);
225
+ if (mentionsM2(dotted[4]))
226
+ found.push(name);
227
+ }
228
+ continue;
229
+ }
230
+ if (table === 'servers') {
231
+ const entry = INLINE_ENTRY.exec(line) ?? SERVERS_DOTTED.exec(line);
232
+ if (entry) {
233
+ const name = nameOf(entry);
234
+ all.add(name);
235
+ if (mentionsM2(entry[4]))
236
+ found.push(name);
237
+ }
238
+ continue;
239
+ }
240
+ if (table !== '' && mentionsM2(line))
241
+ found.push(table);
242
+ }
243
+ return { ...finish(found), conflict: all.has(SESSION_ENTRY) };
244
+ }
245
+ // ───────────────────────────── 注入 ─────────────────────────────
246
+ /** Claude:补进 `session/new` / `load` / `resume` 的 `_meta`(D6,evidence T15 的形状) */
247
+ export function claudeSessionMeta(mcpUrl, helper, denied) {
248
+ const options = { mcpServers: { [SESSION_ENTRY]: { type: 'http', url: mcpUrl, headersHelper: helper } } };
249
+ if (denied.length > 0)
250
+ options.settings = { deniedMcpServers: denied.map((serverName) => ({ serverName })) };
251
+ return { claudeCode: { options } };
252
+ }
253
+ /**
254
+ * 给页面发给 Claude 进程、已经过 acp-guard 检查的一行补 `_meta`:只改 `SESSION_INJECT_METHODS` 三个请求,其余原样。
255
+ * acp-guard 已保证这三个请求的 params 是对象且不带 `_meta`;万一不是对象就原样转发(agent 会自己报错),不在这里编造。
256
+ */
257
+ export function withClaudeSessionMeta(line, meta) {
258
+ let message;
259
+ try {
260
+ message = JSON.parse(line);
261
+ }
262
+ catch {
263
+ return line;
264
+ }
265
+ if (!isObject(message) || typeof message.method !== 'string' || !SESSION_INJECT_METHODS.includes(message.method) || !isObject(message.params))
266
+ return line;
267
+ return JSON.stringify({ ...message, params: { ...message.params, _meta: meta } });
268
+ }
269
+ /** Codex:拉起适配器时的 `CODEX_CONFIG`(嵌套写法;点路径写法无效,evidence C2 / C4) */
270
+ export function codexSessionConfig(mcpUrl, helper, disabled) {
271
+ const servers = { [SESSION_ENTRY]: { url: mcpUrl, http_headers_helper: helper } };
272
+ for (const name of disabled)
273
+ servers[name] = { enabled: false };
274
+ return JSON.stringify({ mcp_servers: servers });
275
+ }
276
+ function unavailable(message) {
277
+ return new BridgeFailure('m2_session_unavailable', message);
278
+ }
279
+ export function createSessionTokens(deps) {
280
+ const paths = sessionPaths(deps.home);
281
+ const readJsonFile = (path) => {
282
+ const text = deps.readText(path);
283
+ if (text === null)
284
+ return null;
285
+ try {
286
+ return JSON.parse(text);
287
+ }
288
+ catch {
289
+ deps.log(`Could not read ${path} while listing the M2 entries to hide for this session; skipping it.`);
290
+ return null;
291
+ }
292
+ };
293
+ const logSkipped = (agent, skipped) => {
294
+ if (skipped.length > 0)
295
+ deps.log(`${skipped.length} M2 entr${skipped.length === 1 ? 'y' : 'ies'} in ${agent}'s config can't be hidden safely (unusual name); the identity check covers it.`);
296
+ };
297
+ return {
298
+ state() {
299
+ return readSessionFile(deps.readText, paths.headersFile, deps.now()).state;
300
+ },
301
+ prepare(agent, userId) {
302
+ if (agent !== 'claude' && agent !== 'codex')
303
+ throw unavailable(`the M2 session token is only for Claude and Codex, not ${agent}`);
304
+ const file = readSessionFile(deps.readText, paths.headersFile, deps.now());
305
+ if (file.state !== 'ready' || file.sub === undefined || file.expiresAt === undefined) {
306
+ throw unavailable(file.state === 'expiring' ? 'the M2 session token expires soon; the M2 desktop app has not refreshed it yet' : 'no usable M2 session token on this machine');
307
+ }
308
+ if (file.sub !== userId)
309
+ throw new BridgeFailure('m2_session_mismatch', 'the M2 session token belongs to a different account than this page');
310
+ if (!isSafeHelperPath(paths.helper))
311
+ throw unavailable('the home directory path has characters the agents cannot run a helper from');
312
+ try {
313
+ deps.ensureHelper(paths.helper, helperScript(paths.headersFile));
314
+ }
315
+ catch (error) {
316
+ throw unavailable(`could not write the M2 session helper (${error instanceof Error ? error.message : String(error)})`);
317
+ }
318
+ if (agent === 'claude') {
319
+ const { names, skipped } = claudeM2EntryNames(readJsonFile, { home: deps.home, claudeConfigDir: deps.agentEnv.CLAUDE_CONFIG_DIR, cwd: deps.cwd });
320
+ logSkipped(agent, skipped);
321
+ const meta = claudeSessionMeta(deps.mcpUrl, paths.helper, names);
322
+ return { rewritePageLine: (line) => withClaudeSessionMeta(line, meta), expiresAt: file.expiresAt };
323
+ }
324
+ const codexHome = deps.agentEnv.CODEX_HOME || join(deps.home, '.codex');
325
+ const { names, skipped, conflict } = codexM2Entries(deps.readText(join(codexHome, 'config.toml')));
326
+ if (conflict)
327
+ throw unavailable(`Codex's config already has an MCP server named "${SESSION_ENTRY}"`);
328
+ logSkipped(agent, skipped);
329
+ return { env: { CODEX_CONFIG: codexSessionConfig(deps.mcpUrl, paths.helper, names) }, expiresAt: file.expiresAt };
330
+ },
331
+ };
332
+ }
@@ -17,7 +17,7 @@ import { createHash, randomBytes } from 'node:crypto';
17
17
  import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
18
18
  import { basename, dirname, join } from 'node:path';
19
19
  import { ADAPTERS, ADAPTER_READY_MARKER, adapterInstallDirName } from './adapters.js';
20
- import { NODE_PATH_FILE, connectCliPath, connectPackageDir } from './install-layout.js';
20
+ import { NODE_PATH_FILE, connectCliPath, connectPackageDir, installLayout } from './install-layout.js';
21
21
  import { readInstanceRecord } from './instance.js';
22
22
  import { tail } from './precheck-parse.js';
23
23
  import { MANIFEST_URL, connectTarballUrls, isReleaseVersion, nodeDirName, nodeTarballUrls, parseReleaseManifest, wsTarballUrls, } from './release-manifest.js';
@@ -52,6 +52,26 @@ export function readRejected(path) {
52
52
  return [];
53
53
  }
54
54
  }
55
+ /** welcome 里最多带几个拉黑的版本(change bridge-normal-update D6):文件只增不减,只报最近的 */
56
+ export const MAX_REPORTED_REJECTED = 20;
57
+ /** 报给页面的拉黑名单:合法版本号、去重、保留最近的 {@link MAX_REPORTED_REJECTED} 个(按写入先后) */
58
+ export function recentRejected(path) {
59
+ const unique = [];
60
+ for (const version of readRejected(path).reverse()) {
61
+ if (!unique.includes(version))
62
+ unique.push(version);
63
+ if (unique.length === MAX_REPORTED_REJECTED)
64
+ break;
65
+ }
66
+ return unique.reverse();
67
+ }
68
+ /**
69
+ * 交给 `startBridgeServer` 的拉黑名单来源(D6):只有后台模式(由启动器拉起,才有拉黑这回事)注入;每次 welcome 现读。
70
+ * 终端模式返回空对象,welcome 不带 `rejected`。
71
+ */
72
+ export function welcomeRejectedOption({ background, home }) {
73
+ return background ? { rejectedVersions: () => recentRejected(installLayout(home).rejected) } : {};
74
+ }
55
75
  function readNodePath(versionDir) {
56
76
  try {
57
77
  const value = readFileSync(join(versionDir, NODE_PATH_FILE), 'utf8').trim();
@@ -7,7 +7,8 @@
7
7
  # SIGTERM, so it shuts down its agents cleanly).
8
8
  # 2. Removes what the installer put in ~/.m2canvas: runtime, bridge, bin, logs, acp-adapters,
9
9
  # the pairing records, the bridge state files, pairing-handoff sockets and leftover
10
- # handoff files.
10
+ # handoff files, and the M2 session token the M2 desktop app keeps for the bridge
11
+ # (mcp-session).
11
12
  # 3. Removes ~/.m2canvas/workspace only if it is empty; otherwise keeps it and prints its path.
12
13
  # Removes ~/.m2canvas itself only if nothing else is left in it.
13
14
  # It does not touch your agents' M2 settings (set up by npx @m2canvas/connect, they work
@@ -80,7 +81,7 @@ main() {
80
81
 
81
82
  # 2. Remove what the installer created, and check that it is really gone.
82
83
  FAILED=""
83
- for NAME in runtime bridge bin logs acp-adapters bridge-pairings.json; do
84
+ for NAME in runtime bridge bin logs acp-adapters bridge-pairings.json mcp-session; do
84
85
  rm -rf "${M2_HOME:?}/${NAME}"
85
86
  if [ -e "${M2_HOME}/${NAME}" ]; then
86
87
  FAILED="${FAILED} ${M2_HOME}/${NAME}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {