@m2canvas/connect 0.7.2 → 0.8.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 +2 -2
- package/dist/bridge/adapters.js +13 -1
- package/dist/bridge/args.js +114 -0
- package/dist/bridge/call-dispatch.js +64 -0
- package/dist/bridge/handoff.js +21 -5
- package/dist/bridge/main.js +41 -111
- package/dist/bridge/pairing.js +19 -0
- package/dist/bridge/processes.js +11 -0
- package/dist/bridge/protocol.js +1 -0
- package/dist/bridge/security.js +6 -2
- package/dist/bridge/self-test.js +5 -0
- package/dist/bridge/server.js +53 -58
- package/dist/bridge/uninstall-call.js +47 -0
- package/dist/bridge/uninstall.js +88 -0
- package/install/uninstall.sh +125 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on
|
|
|
39
39
|
- **Pairing.** When you start the bridge with the command M2 gives you, the page that gave it pairs automatically within 10 minutes. The command only carries a hash (`--pair`); the secret it matches stays in that browser tab, so the command is safe to appear in your shell history, and it works once. Any other page asks for the pairing code printed in this window; the window also shows which site is asking. After that the page remembers the pairing for 30 days of inactivity. After too many wrong codes, new pages can't pair until you restart the bridge (pages already paired keep working); close the page the window names first. `bridge --unpair` forgets all paired pages. If you can't find this window, run the command M2 shows under the code box: it adds `--replace`, which stops the M2 bridge already running on the port (its agent sessions end) and starts a new one that pairs by itself. It only stops a bridge (0.6.0 or later) it can recognize from a small state file in `~/.m2canvas` (process id and start time; no codes or tokens are written there), never anything else on the port, and not on Windows. When it can't recognize what holds the port, it shows you that process (or how to find it) and leaves it alone. With an older bridge, M2 shows the Ctrl+C instructions instead.
|
|
40
40
|
- **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.
|
|
41
41
|
- **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.
|
|
42
|
-
- **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 (about
|
|
42
|
+
- **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.
|
|
43
43
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
|
44
44
|
|
|
45
45
|
Agent sessions start in that directory.
|
|
@@ -59,6 +59,6 @@ curl -fsSL https://m2ui.ai/connect/install.sh | sh -s -- --pair <value from M2>
|
|
|
59
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
|
-
- **Uninstall** with
|
|
62
|
+
- **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://m2ui.ai/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 paired page 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.
|
|
63
63
|
|
|
64
64
|
The `npx` command above still works and is what M2 shows under "Advanced", for running the bridge in a folder you choose.
|
package/dist/bridge/adapters.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - 安装目录名带 lockfile 哈希:换了 lockfile 就是新目录,旧目录不会被半新半旧地复用。旧目录目前不清理(FU-2026-10-02-120)。
|
|
15
15
|
* - 先装到临时目录、写好就绪标记再原子改名;残留的临时目录或没有标记的目录一律视为未安装。
|
|
16
16
|
* - 升级 Claude 适配器时复核 bundledClaudePath 与 SDK 自身的解析规则是否仍一致(acp-terminal-free-auth evidence/sdk-binary-resolution.md),
|
|
17
|
-
* 并按新版本实测 installedBytes。
|
|
17
|
+
* 并按新版本实测 installedBytes 与 downloadBytes。
|
|
18
18
|
*/
|
|
19
19
|
import { createHash, randomBytes } from 'node:crypto';
|
|
20
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -29,6 +29,7 @@ export const ADAPTERS = {
|
|
|
29
29
|
version: '0.84.0',
|
|
30
30
|
entry: 'node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js',
|
|
31
31
|
installedBytes: 268_451_230,
|
|
32
|
+
downloadBytes: 106_768_111,
|
|
32
33
|
},
|
|
33
34
|
codex: {
|
|
34
35
|
dir: 'codex-acp',
|
|
@@ -36,6 +37,7 @@ export const ADAPTERS = {
|
|
|
36
37
|
version: '2.1.0',
|
|
37
38
|
entry: 'node_modules/@agentclientprotocol/codex-acp/dist/index.js',
|
|
38
39
|
installedBytes: 346_221_434,
|
|
40
|
+
downloadBytes: 135_994_489,
|
|
39
41
|
},
|
|
40
42
|
};
|
|
41
43
|
/** 适配器自带的 codex:预检与授权命令都要用它(本机旧版 codex 存的凭证,新版本刷新不了,PoC 报告 §2.2) */
|
|
@@ -136,6 +138,7 @@ export class AdapterManager {
|
|
|
136
138
|
options;
|
|
137
139
|
installing = new Map();
|
|
138
140
|
lastError = new Map();
|
|
141
|
+
stopped = false;
|
|
139
142
|
progress = new Map();
|
|
140
143
|
constructor(options) {
|
|
141
144
|
this.options = options;
|
|
@@ -166,10 +169,19 @@ export class AdapterManager {
|
|
|
166
169
|
const error = this.lastError.get(agent);
|
|
167
170
|
return error ? { ...base, state: 'failed', error } : { ...base, state: 'not_installed', error: null };
|
|
168
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* bridge 在卸载自己(change bridge-uninstall-from-page 实现中发现 I1):之后不再开始新的安装。
|
|
174
|
+
* 安装第一步就 mkdir 临时目录,会把刚被卸载脚本删掉的 `~/.m2canvas` 重建出来;进行中的 npm ci 由调用方 killActiveExecs 结束。
|
|
175
|
+
*/
|
|
176
|
+
stopInstalling() {
|
|
177
|
+
this.stopped = true;
|
|
178
|
+
}
|
|
169
179
|
/** 已就绪直接返回;正在装就等同一次安装;否则开始安装。失败不抛错,返回 state = failed */
|
|
170
180
|
prepare(agent) {
|
|
171
181
|
if (this.isReady(agent))
|
|
172
182
|
return Promise.resolve(this.info(agent));
|
|
183
|
+
if (this.stopped)
|
|
184
|
+
return Promise.resolve({ ...this.info(agent), state: 'failed', error: 'the bridge is uninstalling itself' });
|
|
173
185
|
const running = this.installing.get(agent);
|
|
174
186
|
if (running)
|
|
175
187
|
return running;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge 方法的分派(从 server.ts 拆出):未配对只放行 pair,其余按方法交给 service / ProcessManager / auth-call.ts。
|
|
3
|
+
* 连接状态(是否已配对、连接是否还在)只在 server.ts 里改,这里经 ctx 的回调读写。
|
|
4
|
+
*
|
|
5
|
+
* 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、配对码、processId,进不了 spawn 的命令、参数、cwd、环境;
|
|
6
|
+
* agent/authorize 与 agent/authorize-cancel 的参数校验在 auth-call.ts。
|
|
7
|
+
* bridge/uninstall 不经过这里:server.ts 在调用 handleCall 之前就把它交给 uninstall-call.ts(它要等回复写出再关连接、退出)。
|
|
8
|
+
*/
|
|
9
|
+
import { isBridgeAgentId } from './agents.js';
|
|
10
|
+
import { handleAuthCall } from './auth-call.js';
|
|
11
|
+
import { handlePairCall } from './pair-call.js';
|
|
12
|
+
import { TOKEN_IDLE_DAYS } from './pairing.js';
|
|
13
|
+
import { ProcessError } from './processes.js';
|
|
14
|
+
import { BridgeFailure } from './service.js';
|
|
15
|
+
export async function handleCall(ctx, conn, id, method, params) {
|
|
16
|
+
if (method === 'pair') {
|
|
17
|
+
const token = handlePairCall(ctx.pairing, conn.origin, params);
|
|
18
|
+
ctx.markPaired(conn.id);
|
|
19
|
+
ctx.log(`Paired with ${conn.origin}.`);
|
|
20
|
+
return { token, cwd: ctx.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
|
|
21
|
+
}
|
|
22
|
+
if (!conn.paired)
|
|
23
|
+
throw new BridgeFailure('not_paired', 'pair this page first');
|
|
24
|
+
if (method === 'process/stop') {
|
|
25
|
+
const processId = params.processId;
|
|
26
|
+
if (typeof processId !== 'string' || !ctx.processes.stop(conn.id, processId))
|
|
27
|
+
throw new BridgeFailure('process_not_found', 'no such process on this connection');
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
if (method === 'agents/list')
|
|
31
|
+
return { agents: await ctx.service.list() };
|
|
32
|
+
if (method === 'agent/authorize' || method === 'agent/authorize-cancel')
|
|
33
|
+
return handleAuthCall(ctx.auth, conn.id, method, params);
|
|
34
|
+
const agent = params.agent;
|
|
35
|
+
if (!isBridgeAgentId(agent))
|
|
36
|
+
throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
|
|
37
|
+
switch (method) {
|
|
38
|
+
case 'agent/prepare':
|
|
39
|
+
return { agent, adapter: await ctx.service.prepare(agent) };
|
|
40
|
+
case 'agent/precheck':
|
|
41
|
+
return ctx.service.precheck(agent);
|
|
42
|
+
case 'm2/configure':
|
|
43
|
+
ctx.log(`Page asked to add M2 to ${agent}'s MCP config.`);
|
|
44
|
+
return { agent, ...(await ctx.service.configure(agent)) };
|
|
45
|
+
case 'process/start': {
|
|
46
|
+
const launch = await ctx.service.launch(agent);
|
|
47
|
+
try {
|
|
48
|
+
const processId = await ctx.processes.start(conn.id, agent, launch);
|
|
49
|
+
// 启动期间页面已断开:没有人会再停它,立刻清理
|
|
50
|
+
if (!ctx.isConnected(conn.id))
|
|
51
|
+
ctx.processes.stopOwner(conn.id);
|
|
52
|
+
ctx.log(`Started ${agent}.`);
|
|
53
|
+
return { processId, agent, cwd: ctx.cwd };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof ProcessError)
|
|
57
|
+
throw new BridgeFailure(error.code, error.message);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 未知方法在调用前已被 server.ts 的 isMethod 拦下;走到这里说明 BRIDGE_METHODS 与上面的分支不一致
|
|
63
|
+
throw new BridgeFailure('internal', `unhandled method ${method} (call ${id})`);
|
|
64
|
+
}
|
package/dist/bridge/handoff.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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`);
|
package/dist/bridge/main.js
CHANGED
|
@@ -3,54 +3,39 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 只由 cli.ts 在第一个参数是 `bridge` 时动态加载:默认的 MCP 配置流程(Copy to agent 起手式)不会加载这里的任何代码与依赖。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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):输出写进日志文件,
|
|
10
10
|
* 所以不出配对码(契约 2)、不提示「保持窗口打开」;页面经 `welcome.bridge.background` 得知,改走「重新连接」。
|
|
11
11
|
* 端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
|
|
12
|
+
* 后台模式且在 macOS 上时还能卸载自己(`bridge/uninstall`,change bridge-uninstall-from-page D2 / D3;执行在 uninstall.ts)。
|
|
12
13
|
*/
|
|
13
14
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
-
import { join
|
|
15
|
+
import { join } from 'node:path';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
16
17
|
import { home, isExecutable, which } from '../system.js';
|
|
17
18
|
import { AdapterManager, ADAPTERS } from './adapters.js';
|
|
19
|
+
import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
|
|
18
20
|
import { detectAgent } from './agents.js';
|
|
19
21
|
import { EXPECT_PATH } from './authorize.js';
|
|
20
22
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
21
23
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
22
24
|
import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
|
|
23
25
|
import { handOffCommitment, listenForHandoff } from './handoff.js';
|
|
26
|
+
import { installLayout } from './install-layout.js';
|
|
24
27
|
import { installShutdownSignals } from './lifecycle.js';
|
|
25
|
-
import { PairingManager, createFilePairingStore
|
|
26
|
-
import { BRIDGE_AGENT_IDS,
|
|
28
|
+
import { PairingManager, createFilePairingStore } from './pairing.js';
|
|
29
|
+
import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
|
|
27
30
|
import { resolveNpmCommand } from './npm-command.js';
|
|
28
|
-
import {
|
|
31
|
+
import { allowedOrigins } from './security.js';
|
|
29
32
|
import { selfTestProblems } from './self-test.js';
|
|
30
33
|
import { runCheckUpdate } from './update-io.js';
|
|
31
34
|
import { startBridgeServer } from './server.js';
|
|
32
35
|
import { createAgentService } from './service.js';
|
|
33
36
|
import { KILL_GRACE_MS } from './processes.js';
|
|
37
|
+
import { createSelfUninstaller, uninstallScriptPath } from './uninstall.js';
|
|
34
38
|
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
39
|
const LABELS = {
|
|
55
40
|
claude: 'Claude Code',
|
|
56
41
|
codex: 'Codex',
|
|
@@ -58,91 +43,6 @@ const LABELS = {
|
|
|
58
43
|
opencode: 'OpenCode',
|
|
59
44
|
qoder: 'Qoder',
|
|
60
45
|
};
|
|
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
46
|
/** 包根目录:dist/bridge/*.js 与 src/bridge/*.ts 到包根的相对深度相同 */
|
|
147
47
|
const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
148
48
|
function packageVersion() {
|
|
@@ -297,6 +197,25 @@ export async function runBridge(argv) {
|
|
|
297
197
|
// 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
|
|
298
198
|
const instancePath = instanceFilePath(m2Dir, parsed.port);
|
|
299
199
|
let alreadyRunning = false;
|
|
200
|
+
/** 最近一次因 Origin 不在白名单被拒的页面(desktop-bridge-launcher D15):交接 socket 的状态查询带回给 M2 Mac 客户端 */
|
|
201
|
+
let lastRejected = null;
|
|
202
|
+
// 只有启动器从 ~/.m2canvas 拉起的后台 bridge、且在 macOS 上(后台安装只有 macOS,契约 15)才能卸载自己(D2)。
|
|
203
|
+
// 回调里用到的 handoff / stop 在下面才赋值:卸载只会在服务开始监听、它们都已就绪之后被调用
|
|
204
|
+
const uninstaller = parsed.background && process.platform === 'darwin'
|
|
205
|
+
? createSelfUninstaller({
|
|
206
|
+
scriptPath: uninstallScriptPath(PACKAGE_ROOT),
|
|
207
|
+
home,
|
|
208
|
+
workspace: installLayout(home).workspace,
|
|
209
|
+
env: buildAgentEnv(),
|
|
210
|
+
exec: execCommand,
|
|
211
|
+
exists: existsSync,
|
|
212
|
+
stopInstalling: () => adapters.stopInstalling(),
|
|
213
|
+
killExecs: killActiveExecs,
|
|
214
|
+
forgetSelf: () => removeInstanceRecord(instancePath, process.pid),
|
|
215
|
+
closeHandoff: () => handoff?.close(),
|
|
216
|
+
exit: () => void stop(),
|
|
217
|
+
})
|
|
218
|
+
: undefined;
|
|
300
219
|
server = await listenWithReplace(() => startBridgeServer({
|
|
301
220
|
port: parsed.port,
|
|
302
221
|
cwd: parsed.cwd,
|
|
@@ -312,6 +231,10 @@ export async function runBridge(argv) {
|
|
|
312
231
|
service,
|
|
313
232
|
agentEnv: buildAgentEnv(),
|
|
314
233
|
allowedOrigins: origins,
|
|
234
|
+
uninstall: uninstaller,
|
|
235
|
+
onOriginRejected: (origin) => {
|
|
236
|
+
lastRejected = { origin, at: Date.now() };
|
|
237
|
+
},
|
|
315
238
|
log,
|
|
316
239
|
verbose: parsed.verbose,
|
|
317
240
|
}), {
|
|
@@ -398,8 +321,15 @@ export async function runBridge(argv) {
|
|
|
398
321
|
process.exit(130);
|
|
399
322
|
},
|
|
400
323
|
});
|
|
401
|
-
if (parsed.background)
|
|
402
|
-
handoff = await listenForHandoff({
|
|
324
|
+
if (parsed.background) {
|
|
325
|
+
handoff = await listenForHandoff({
|
|
326
|
+
dir: m2Dir,
|
|
327
|
+
port: parsed.port,
|
|
328
|
+
arm: (c) => pairing.armCommitment(c),
|
|
329
|
+
status: (c) => ({ ...pairing.commitmentStatus(c), ...(lastRejected ? { rejected: lastRejected } : {}) }),
|
|
330
|
+
log,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
403
333
|
const lifetime = backgroundLifetimeMs(parsed);
|
|
404
334
|
if (lifetime !== null) {
|
|
405
335
|
setTimeout(() => {
|
package/dist/bridge/pairing.js
CHANGED
|
@@ -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 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
|
package/dist/bridge/processes.js
CHANGED
|
@@ -48,6 +48,8 @@ export class ProcessManager {
|
|
|
48
48
|
processes = new Map();
|
|
49
49
|
/** 已调用 spawn、还没等到 'spawn' 事件的进程(按 owner 计):也要计入上限,否则同一批并发的 start 能突破上限 */
|
|
50
50
|
starting = new Map();
|
|
51
|
+
/** bridge 在卸载自己:之后不再拉起任何 agent(`close`) */
|
|
52
|
+
closed = false;
|
|
51
53
|
constructor(options) {
|
|
52
54
|
this.options = options;
|
|
53
55
|
}
|
|
@@ -71,8 +73,17 @@ export class ProcessManager {
|
|
|
71
73
|
else
|
|
72
74
|
this.starting.delete(ownerId);
|
|
73
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* 不再拉起新的 agent(bridge 卸载自己时调用,change bridge-uninstall-from-page review r1 N1):卸载开始前已在途、
|
|
78
|
+
* 还卡在检测阶段的 process/start 之后走到这里也会被拒,不会在卸载脚本运行时从正在被删的适配器目录拉起 agent。已在跑的由调用方停。
|
|
79
|
+
*/
|
|
80
|
+
close() {
|
|
81
|
+
this.closed = true;
|
|
82
|
+
}
|
|
74
83
|
/** 等到子进程真正启动('spawn' 事件)才返回,启动失败(如 ENOENT)以 ProcessError 拒绝 */
|
|
75
84
|
start(ownerId, agent, launch) {
|
|
85
|
+
if (this.closed)
|
|
86
|
+
return Promise.reject(new ProcessError('spawn_failed', 'the bridge is shutting down and starts no new agents'));
|
|
76
87
|
if (this.countFor(ownerId) >= (this.options.maxPerOwner ?? MAX_PROCESSES_PER_OWNER)) {
|
|
77
88
|
return Promise.reject(new ProcessError('too_many_processes', 'too many agent processes for this page'));
|
|
78
89
|
}
|
package/dist/bridge/protocol.js
CHANGED
package/dist/bridge/security.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/bridge/self-test.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync } from 'node:fs';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { ADAPTERS } from './adapters.js';
|
|
14
14
|
import { bundledNpmCli } from './npm-command.js';
|
|
15
|
+
import { uninstallScriptPath } from './uninstall.js';
|
|
15
16
|
/** 返回发现的问题(英文,写进 update.log);空数组 = 通过 */
|
|
16
17
|
export function selfTestProblems(input) {
|
|
17
18
|
const exists = input.exists ?? existsSync;
|
|
@@ -26,5 +27,9 @@ export function selfTestProblems(input) {
|
|
|
26
27
|
const npm = bundledNpmCli(input.execPath);
|
|
27
28
|
if (!exists(npm))
|
|
28
29
|
problems.push(`missing ${npm}`);
|
|
30
|
+
// bridge/uninstall 运行的就是它(change bridge-uninstall-from-page D3):缺了就不切换到这个版本
|
|
31
|
+
const uninstaller = uninstallScriptPath(input.packageRoot);
|
|
32
|
+
if (!exists(uninstaller))
|
|
33
|
+
problems.push(`missing ${uninstaller}`);
|
|
29
34
|
return problems;
|
|
30
35
|
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
|
|
10
10
|
* 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
|
|
11
11
|
* - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、授权动作(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
|
|
12
|
-
* agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message
|
|
12
|
+
* 方法分派在 call-dispatch.ts;agent/authorize 与 agent/authorize-cancel 的 params 只能有规定的键,多一个就 bad_message(auth-call.ts,命令清单写死在 authorize.ts)。
|
|
13
13
|
* - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
|
|
14
|
+
* - bridge/uninstall(0.8.0,uninstall-call.ts):开始后拒绝新连接与一切其他帧(握手与配对会写盘、重建 ~/.m2canvas),端口占到退出,结果回出去后退出。
|
|
14
15
|
*
|
|
15
16
|
* 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责)。
|
|
16
17
|
* 登录 / m2 授权只代跑写死清单里的命令,授权页由用户在浏览器里亲自完成(authorize.ts);授权链接只发给发起的连接,不进终端日志。
|
|
@@ -18,16 +19,14 @@
|
|
|
18
19
|
import { createServer } from 'node:http';
|
|
19
20
|
import { randomUUID } from 'node:crypto';
|
|
20
21
|
import { WebSocketServer } from 'ws';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { isBridgeAgentId } from './agents.js';
|
|
24
|
-
import { handleAuthCall } from './auth-call.js';
|
|
25
|
-
import { handlePairCall } from './pair-call.js';
|
|
22
|
+
import { KILL_GRACE_MS, ProcessManager } from './processes.js';
|
|
23
|
+
import { handleCall } from './call-dispatch.js';
|
|
26
24
|
import { AuthorizationManager } from './authorize.js';
|
|
27
25
|
import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
|
|
28
26
|
import { checkPageAcpLine } from './acp-guard.js';
|
|
29
27
|
import { checkUpgrade } from './security.js';
|
|
30
28
|
import { BridgeFailure } from './service.js';
|
|
29
|
+
import { startUninstall } from './uninstall-call.js';
|
|
31
30
|
export const HELLO_TIMEOUT_MS = 10_000;
|
|
32
31
|
export const UNPAIRED_TIMEOUT_MS = 10 * 60 * 1000;
|
|
33
32
|
export const MAX_UNPAIRED_CONNECTIONS = 4;
|
|
@@ -42,7 +41,7 @@ function isMethod(value) {
|
|
|
42
41
|
return typeof value === 'string' && BRIDGE_METHODS.includes(value);
|
|
43
42
|
}
|
|
44
43
|
function rejectUpgrade(socket, status) {
|
|
45
|
-
const text = status === 403 ? 'Forbidden' : 'Not Found';
|
|
44
|
+
const text = status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : 'Service Unavailable';
|
|
46
45
|
socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
47
46
|
socket.destroy();
|
|
48
47
|
}
|
|
@@ -59,6 +58,10 @@ export function startBridgeServer(options) {
|
|
|
59
58
|
conn.ws.send(JSON.stringify(frame));
|
|
60
59
|
};
|
|
61
60
|
const sendError = (conn, id, error) => send(conn, { type: 'error', id, error });
|
|
61
|
+
/** 等数据写进 socket 再完成:bridge/uninstall 回复之后紧接着关连接、退出 */
|
|
62
|
+
const sendAndWait = (conn, frame) => new Promise((done) => (conn.ws.readyState === conn.ws.OPEN ? conn.ws.send(JSON.stringify(frame), () => done()) : done()));
|
|
63
|
+
/** bridge/uninstall 已开始(D3 ⓪):不再接受新连接,已有连接上的其他帧一律拒绝 */
|
|
64
|
+
let uninstalling = false;
|
|
62
65
|
const processes = new ProcessManager({
|
|
63
66
|
env: options.agentEnv,
|
|
64
67
|
cwd: options.cwd,
|
|
@@ -97,6 +100,16 @@ export function startBridgeServer(options) {
|
|
|
97
100
|
send(owner, { type: 'event', event: 'auth/link', data });
|
|
98
101
|
},
|
|
99
102
|
};
|
|
103
|
+
const uninstallContext = {
|
|
104
|
+
uninstaller: options.uninstall,
|
|
105
|
+
processes,
|
|
106
|
+
authorizations,
|
|
107
|
+
killGraceMs: options.timing?.killGraceMs ?? KILL_GRACE_MS,
|
|
108
|
+
stopAccepting: () => {
|
|
109
|
+
uninstalling = true;
|
|
110
|
+
},
|
|
111
|
+
log,
|
|
112
|
+
};
|
|
100
113
|
const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
|
|
101
114
|
const markPaired = (conn) => {
|
|
102
115
|
conn.paired = true;
|
|
@@ -104,56 +117,20 @@ export function startBridgeServer(options) {
|
|
|
104
117
|
clearTimeout(conn.timer);
|
|
105
118
|
conn.timer = null;
|
|
106
119
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
if (method === 'agents/list')
|
|
123
|
-
return { agents: await options.service.list() };
|
|
124
|
-
if (method === 'agent/authorize' || method === 'agent/authorize-cancel')
|
|
125
|
-
return handleAuthCall(authContext, conn.id, method, params);
|
|
126
|
-
const agent = params.agent;
|
|
127
|
-
if (!isBridgeAgentId(agent))
|
|
128
|
-
throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
|
|
129
|
-
switch (method) {
|
|
130
|
-
case 'agent/prepare':
|
|
131
|
-
return { agent, adapter: await options.service.prepare(agent) };
|
|
132
|
-
case 'agent/precheck':
|
|
133
|
-
return options.service.precheck(agent);
|
|
134
|
-
case 'm2/configure':
|
|
135
|
-
log(`Page asked to add M2 to ${agent}'s MCP config.`);
|
|
136
|
-
return { agent, ...(await options.service.configure(agent)) };
|
|
137
|
-
case 'process/start': {
|
|
138
|
-
const launch = await options.service.launch(agent);
|
|
139
|
-
try {
|
|
140
|
-
const processId = await processes.start(conn.id, agent, launch);
|
|
141
|
-
// 启动期间页面已断开:没有人会再停它,立刻清理
|
|
142
|
-
if (!connections.has(conn.id))
|
|
143
|
-
processes.stopOwner(conn.id);
|
|
144
|
-
log(`Started ${agent}.`);
|
|
145
|
-
return { processId, agent, cwd: options.cwd };
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
if (error instanceof ProcessError)
|
|
149
|
-
throw new BridgeFailure(error.code, error.message);
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// 未知方法在调用前已被 isMethod 拦下;走到这里说明 BRIDGE_METHODS 与上面的分支不一致
|
|
155
|
-
throw new BridgeFailure('internal', `unhandled method ${method} (call ${id})`);
|
|
156
|
-
}
|
|
120
|
+
const callContext = {
|
|
121
|
+
cwd: options.cwd,
|
|
122
|
+
pairing: options.pairing,
|
|
123
|
+
service: options.service,
|
|
124
|
+
processes,
|
|
125
|
+
auth: authContext,
|
|
126
|
+
log,
|
|
127
|
+
isConnected: (connectionId) => connections.has(connectionId),
|
|
128
|
+
markPaired: (connectionId) => {
|
|
129
|
+
const conn = connections.get(connectionId);
|
|
130
|
+
if (conn)
|
|
131
|
+
markPaired(conn);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
157
134
|
function onHello(conn, frame) {
|
|
158
135
|
const protocols = Array.isArray(frame.protocols) ? frame.protocols.filter((value) => typeof value === 'number') : [];
|
|
159
136
|
if (frame.type !== 'hello' || protocols.length === 0) {
|
|
@@ -222,6 +199,13 @@ export function startBridgeServer(options) {
|
|
|
222
199
|
catch {
|
|
223
200
|
frame = null;
|
|
224
201
|
}
|
|
202
|
+
if (uninstalling) {
|
|
203
|
+
// 卸载开始后:不再握手(带 token 的 hello 会刷新使用时间、写配对文件),也不再处理任何调用
|
|
204
|
+
if (!conn.hello)
|
|
205
|
+
return conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is uninstalling');
|
|
206
|
+
const id = isObject(frame) && typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= MAX_CALL_ID ? frame.id : null;
|
|
207
|
+
return sendError(conn, id, { code: 'uninstalling', message: 'this bridge is uninstalling itself' });
|
|
208
|
+
}
|
|
225
209
|
if (!conn.hello) {
|
|
226
210
|
if (isObject(frame))
|
|
227
211
|
onHello(conn, frame);
|
|
@@ -257,7 +241,13 @@ export function startBridgeServer(options) {
|
|
|
257
241
|
if (!isMethod(method))
|
|
258
242
|
return sendError(conn, id, { code: 'unknown_method', message: `unknown method ${String(method)}` });
|
|
259
243
|
const params = isObject(frame.params) ? frame.params : {};
|
|
260
|
-
|
|
244
|
+
if (method === 'bridge/uninstall') {
|
|
245
|
+
const refusal = startUninstall(uninstallContext, { paired: conn.paired, params: frame.params, reply: (result) => sendAndWait(conn, { type: 'result', id, result }) });
|
|
246
|
+
if (refusal)
|
|
247
|
+
sendError(conn, id, refusal);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
handleCall(callContext, conn, id, method, params).then((result) => send(conn, { type: 'result', id, result }), (error) => {
|
|
261
251
|
if (error instanceof BridgeFailure)
|
|
262
252
|
sendError(conn, id, { code: error.code, message: error.message, ...(error.data === undefined ? {} : { data: error.data }) });
|
|
263
253
|
else {
|
|
@@ -275,10 +265,15 @@ export function startBridgeServer(options) {
|
|
|
275
265
|
});
|
|
276
266
|
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
|
|
277
267
|
http.on('upgrade', (req, socket, head) => {
|
|
268
|
+
// 卸载开始后不再接受新连接;监听本身留到退出才关 —— 端口一直占着,卸载期间被拉起的新 bridge 撞端口、认不出而退出(review r1 S1)
|
|
269
|
+
if (uninstalling)
|
|
270
|
+
return rejectUpgrade(socket, 503);
|
|
278
271
|
const port = http.address()?.port ?? options.port;
|
|
279
272
|
const decision = checkUpgrade({ origin: req.headers.origin, host: req.headers.host, remoteAddress: req.socket.remoteAddress, path: req.url }, port, options.allowedOrigins);
|
|
280
273
|
if (!decision.ok) {
|
|
281
274
|
rejectLog.note(decision.reason);
|
|
275
|
+
if (decision.rejectedOrigin !== undefined)
|
|
276
|
+
options.onOriginRejected?.(decision.rejectedOrigin);
|
|
282
277
|
rejectUpgrade(socket, decision.status);
|
|
283
278
|
return;
|
|
284
279
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
async function stopAgents(ctx) {
|
|
2
|
+
ctx.processes.close();
|
|
3
|
+
ctx.authorizations.killAllNow();
|
|
4
|
+
ctx.processes.stopAll();
|
|
5
|
+
const deadline = Date.now() + ctx.killGraceMs;
|
|
6
|
+
while (ctx.processes.size > 0 && Date.now() < deadline)
|
|
7
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
8
|
+
ctx.processes.killAllNow();
|
|
9
|
+
}
|
|
10
|
+
async function perform(ctx, uninstaller, request) {
|
|
11
|
+
ctx.stopAccepting();
|
|
12
|
+
ctx.log('Page asked to uninstall the M2 bridge. No longer accepting pages; stopping agents…');
|
|
13
|
+
try {
|
|
14
|
+
uninstaller.begin();
|
|
15
|
+
await stopAgents(ctx);
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
result = await uninstaller.run();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
ctx.log(`Uninstall failed with an internal error: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
result = { outcome: 'failed', kept: null, message: 'M2 bridge: the uninstall stopped because of an internal error. Run the uninstall command in Terminal.' };
|
|
23
|
+
}
|
|
24
|
+
ctx.log(`Uninstall ${result.outcome}: ${result.message}`);
|
|
25
|
+
await request.reply(result);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
uninstaller.exit();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** 开始卸载;不能开始时返回要回给页面的错误(什么都不做) */
|
|
32
|
+
export function startUninstall(ctx, request) {
|
|
33
|
+
if (!request.paired)
|
|
34
|
+
return { code: 'not_paired', message: 'pair this page first' };
|
|
35
|
+
// 只能省略或是空对象:null、数组、字符串都不当成 {};自有属性一个都不能有(`__proto__` 之类的也算)—— 页面不能传任何影响卸载的东西
|
|
36
|
+
const { params } = request;
|
|
37
|
+
const empty = params === undefined || (typeof params === 'object' && params !== null && !Array.isArray(params) && Object.keys(params).length === 0);
|
|
38
|
+
if (!empty)
|
|
39
|
+
return { code: 'bad_message', message: 'bridge/uninstall takes no params' };
|
|
40
|
+
const uninstaller = ctx.uninstaller;
|
|
41
|
+
if (!uninstaller)
|
|
42
|
+
return { code: 'uninstall_unavailable', message: 'only a bridge started in the background by the M2 launcher on macOS can uninstall itself' };
|
|
43
|
+
// stopAccepting 在 perform 的同步部分执行:本函数返回之前,后续到达的帧已经会被拒。
|
|
44
|
+
// 兜住意外的 rejection:未处理的 rejection 会让 bridge 直接退出,跳过收尾
|
|
45
|
+
perform(ctx, uninstaller, request).catch((error) => ctx.log(`Uninstall failed with an internal error: ${error instanceof Error ? error.message : String(error)}`));
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bridge/uninstall` 在 main 一侧的执行(change bridge-uninstall-from-page D3 ②③④):bridge 运行随包发布的卸载脚本卸载自己。
|
|
3
|
+
* 门槛、停止接受新连接、停 agent、回复与退出在 server.ts / uninstall-call.ts;本文件只管「怎么跑脚本、怎么读结果」。
|
|
4
|
+
*
|
|
5
|
+
* 不在 TypeScript 里再写一份删除逻辑:删除范围只有 `install/uninstall.sh` 一份实现(`connect/local-bridge` 契约 15「卸载脚本」),
|
|
6
|
+
* 终端、agent 应用、M2 Mac 客户端运行的是 m2ui.ai 上它的逐字副本(`connect:install-assets -- --check` 锁住)。
|
|
7
|
+
*
|
|
8
|
+
* 维护约束:
|
|
9
|
+
* - **先删自己的状态文件,再运行脚本。** 脚本只停它能从状态文件找到的 M2 bridge(`--stop` 发 SIGTERM);自己的还在,
|
|
10
|
+
* 脚本就会停掉自己,收尾会先关掉全部连接 —— 页面收不到结果。删不掉就不运行脚本,如实回 failed。
|
|
11
|
+
* - **脚本会删掉 bridge 自己所在的安装目录**(含正在运行的 node 与已加载的模块,verification §3 实测仍能回复):
|
|
12
|
+
* 本文件与卸载之后会走到的代码都只能用静态 import,不得动态加载模块(删除后会 ERR_MODULE_NOT_FOUND)。
|
|
13
|
+
* - 脚本用白名单环境运行(调用方传 `buildAgentEnv()`):里面没有沙箱标记,脚本不会误判自己在 agent 沙箱里(grep-summary §3)。
|
|
14
|
+
*/
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** 脚本对每个还在运行的 bridge 最多等 8 + 3 秒(`--stop`);通常只有自己,而自己已从状态文件里拿掉 */
|
|
17
|
+
export const UNINSTALL_SCRIPT_TIMEOUT_MS = 60_000;
|
|
18
|
+
/** 页面只显示一行,太长的截掉 */
|
|
19
|
+
const MAX_MESSAGE_CHARS = 1000;
|
|
20
|
+
const RESULT_PREFIX = 'M2 bridge:';
|
|
21
|
+
/** 包内卸载脚本的位置(`package.json` 的 files 带上了它) */
|
|
22
|
+
export function uninstallScriptPath(packageRoot) {
|
|
23
|
+
return join(packageRoot, 'install', 'uninstall.sh');
|
|
24
|
+
}
|
|
25
|
+
function clip(message) {
|
|
26
|
+
return message.length > MAX_MESSAGE_CHARS ? `${message.slice(0, MAX_MESSAGE_CHARS - 3)}...` : message;
|
|
27
|
+
}
|
|
28
|
+
/** 输出里最后一行以 `M2 bridge:` 开头的(契约 15:脚本的结果行) */
|
|
29
|
+
function resultLine(output) {
|
|
30
|
+
const lines = output
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter((line) => line.startsWith(RESULT_PREFIX));
|
|
34
|
+
return lines[lines.length - 1] ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** 按退出码与结果行组结果:退出 0 才算卸载了;超时、没能运行、没有结果行都写 bridge 自己的说明,不留空 */
|
|
37
|
+
export function uninstallResult(exec, kept, timeoutMs = UNINSTALL_SCRIPT_TIMEOUT_MS) {
|
|
38
|
+
const line = resultLine(exec.stdout) ?? resultLine(exec.stderr);
|
|
39
|
+
if (exec.code === 0)
|
|
40
|
+
return { outcome: 'uninstalled', kept, message: clip(line ?? `${RESULT_PREFIX} uninstalled.`) };
|
|
41
|
+
if (exec.timedOut) {
|
|
42
|
+
return { outcome: 'failed', kept, message: `${RESULT_PREFIX} the uninstaller didn't finish within ${Math.round(timeoutMs / 1000)} seconds. Run the uninstall command in Terminal.` };
|
|
43
|
+
}
|
|
44
|
+
if (exec.code === null && exec.signal) {
|
|
45
|
+
return { outcome: 'failed', kept, message: `${RESULT_PREFIX} the uninstaller was stopped (${exec.signal}). Run the uninstall command in Terminal.` };
|
|
46
|
+
}
|
|
47
|
+
if (exec.code === null)
|
|
48
|
+
return { outcome: 'failed', kept, message: `${RESULT_PREFIX} couldn't run the uninstaller. Run the uninstall command in Terminal.` };
|
|
49
|
+
return { outcome: 'failed', kept, message: clip(line ?? `${RESULT_PREFIX} the uninstaller exited with code ${exec.code}. Run the uninstall command in Terminal.`) };
|
|
50
|
+
}
|
|
51
|
+
/** 不抛出:任何一步出错都变成 failed 结果,server 照常回复并退出 */
|
|
52
|
+
export async function runSelfUninstall(deps) {
|
|
53
|
+
deps.stopCommands();
|
|
54
|
+
try {
|
|
55
|
+
deps.forgetSelf();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {
|
|
59
|
+
outcome: 'failed',
|
|
60
|
+
kept: null,
|
|
61
|
+
message: `${RESULT_PREFIX} couldn't remove its own state file, so it didn't run the uninstaller. Run the uninstall command in Terminal.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
deps.closeHandoff();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// socket 文件删不掉不影响卸载:脚本会把停掉的 bridge 的 socket 一并删掉
|
|
69
|
+
}
|
|
70
|
+
const timeoutMs = deps.timeoutMs ?? UNINSTALL_SCRIPT_TIMEOUT_MS;
|
|
71
|
+
const result = await deps.exec('/bin/sh', [deps.scriptPath], { env: deps.env, cwd: deps.home, timeoutMs, maxOutput: 64 * 1024 });
|
|
72
|
+
return uninstallResult(result, deps.exists(deps.workspace) ? deps.workspace : null, timeoutMs);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* main 提供给 server 的卸载能力(review r1 S2 / S3:接线抽成可单测的工厂)。
|
|
76
|
+
* begin 在 ⓪ 就停新安装、杀一次性命令(与停止接受新连接同时);run 在 ② 再来一遍,收掉 ① 期间才走到这里的在途调用。
|
|
77
|
+
*/
|
|
78
|
+
export function createSelfUninstaller(deps) {
|
|
79
|
+
const stopCommands = () => {
|
|
80
|
+
deps.stopInstalling();
|
|
81
|
+
deps.killExecs();
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
begin: stopCommands,
|
|
85
|
+
run: () => runSelfUninstall({ ...deps, stopCommands }),
|
|
86
|
+
exit: deps.exit,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# M2 local connection uninstaller: https://m2ui.ai/connect/uninstall.sh
|
|
3
|
+
# curl -fsSL https://m2ui.ai/connect/uninstall.sh | sh
|
|
4
|
+
#
|
|
5
|
+
# What it does:
|
|
6
|
+
# 1. Stops the M2 bridge if one is running (only a bridge it can recognize, and only with
|
|
7
|
+
# SIGTERM, so it shuts down its agents cleanly).
|
|
8
|
+
# 2. Removes what the installer put in ~/.m2canvas: runtime, bridge, bin, logs, acp-adapters,
|
|
9
|
+
# the pairing records, the bridge state files, pairing-handoff sockets and leftover
|
|
10
|
+
# handoff files.
|
|
11
|
+
# 3. Removes ~/.m2canvas/workspace only if it is empty; otherwise keeps it and prints its path.
|
|
12
|
+
# Removes ~/.m2canvas itself only if nothing else is left in it.
|
|
13
|
+
# It does not touch your agents' M2 settings (set up by npx @m2canvas/connect, they work
|
|
14
|
+
# without the bridge) or any Node.js you installed yourself.
|
|
15
|
+
#
|
|
16
|
+
# Maintenance: keep this file ASCII-only, POSIX sh, and write every variable as ${VAR}.
|
|
17
|
+
# The layout must match packages/connect/src/bridge/install-layout.ts.
|
|
18
|
+
# The source is packages/connect/install/uninstall.sh; it ships in the npm package (a running bridge uses it
|
|
19
|
+
# to uninstall itself) and public/connect/uninstall.sh is a generated copy (npm run connect:install-assets).
|
|
20
|
+
|
|
21
|
+
say() {
|
|
22
|
+
printf 'M2 bridge: %s\n' "$*"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
main() {
|
|
26
|
+
if [ -n "${CODEX_SANDBOX:-}" ]; then
|
|
27
|
+
say "Codex is running this command in its sandbox, where files in your home folder can't be removed. In Codex, switch the permission mode to Full access and run it again, or run it in Terminal."
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
if [ "${SANDBOX_RUNTIME:-}" = "1" ]; then
|
|
31
|
+
say "Claude is running this command in its sandbox, where files in your home folder can't be removed. Run this exact command again outside the sandbox."
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
case "${HOME:-}" in
|
|
35
|
+
/?*) ;;
|
|
36
|
+
*)
|
|
37
|
+
say "HOME is not set."
|
|
38
|
+
exit 1
|
|
39
|
+
;;
|
|
40
|
+
esac
|
|
41
|
+
|
|
42
|
+
M2_HOME="${HOME}/.m2canvas"
|
|
43
|
+
if [ ! -d "${M2_HOME}" ]; then
|
|
44
|
+
say "nothing to uninstall."
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# 1. Stop running bridges with the installed bridge itself (it knows how to recognize one).
|
|
49
|
+
CURRENT="$(readlink "${M2_HOME}/bridge/current" 2>/dev/null)"
|
|
50
|
+
NODE="$(cat "${M2_HOME}/bridge/${CURRENT}/node-path" 2>/dev/null)"
|
|
51
|
+
CLI="${M2_HOME}/bridge/${CURRENT}/node_modules/@m2canvas/connect/dist/cli.js"
|
|
52
|
+
# A bridge that didn't stop keeps its state file, so a later --replace can still recognize it.
|
|
53
|
+
STILL_RUNNING=""
|
|
54
|
+
if [ -n "${CURRENT}" ] && [ -x "${NODE}" ] && [ -f "${CLI}" ]; then
|
|
55
|
+
for STATE in "${M2_HOME}"/bridge-*.json; do
|
|
56
|
+
[ -f "${STATE}" ] || continue
|
|
57
|
+
PORT="${STATE##*/bridge-}"
|
|
58
|
+
PORT="${PORT%.json}"
|
|
59
|
+
case "${PORT}" in
|
|
60
|
+
"" | *[!0-9]*) continue ;;
|
|
61
|
+
esac
|
|
62
|
+
if ! "${NODE}" "${CLI}" bridge --stop --port "${PORT}" </dev/null; then
|
|
63
|
+
STILL_RUNNING="${STILL_RUNNING} ${PORT}"
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
else
|
|
67
|
+
# Without a working bridge it can't recognize what is running, so it stops nothing. A state file whose
|
|
68
|
+
# process is still alive is kept and reported (that process may be the bridge); the others are stale.
|
|
69
|
+
echo "The installed bridge is incomplete, so a running bridge (if any) was not stopped."
|
|
70
|
+
for STATE in "${M2_HOME}"/bridge-*.json; do
|
|
71
|
+
[ -f "${STATE}" ] || continue
|
|
72
|
+
PORT="${STATE##*/bridge-}"
|
|
73
|
+
PORT="${PORT%.json}"
|
|
74
|
+
PID="$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "${STATE}" | head -n 1)"
|
|
75
|
+
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
|
76
|
+
STILL_RUNNING="${STILL_RUNNING} ${PORT}"
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# 2. Remove what the installer created, and check that it is really gone.
|
|
82
|
+
FAILED=""
|
|
83
|
+
for NAME in runtime bridge bin logs acp-adapters bridge-pairings.json; do
|
|
84
|
+
rm -rf "${M2_HOME:?}/${NAME}"
|
|
85
|
+
if [ -e "${M2_HOME}/${NAME}" ]; then
|
|
86
|
+
FAILED="${FAILED} ${M2_HOME}/${NAME}"
|
|
87
|
+
fi
|
|
88
|
+
done
|
|
89
|
+
# State files and pairing-handoff sockets of the bridges that stopped (a running bridge removes its own socket when it exits).
|
|
90
|
+
for STATE in "${M2_HOME}"/bridge-*.json "${M2_HOME}"/bridge-*.sock; do
|
|
91
|
+
[ -e "${STATE}" ] || continue
|
|
92
|
+
PORT="${STATE##*/bridge-}"
|
|
93
|
+
PORT="${PORT%.*}"
|
|
94
|
+
case " ${STILL_RUNNING} " in
|
|
95
|
+
*" ${PORT} "*) continue ;;
|
|
96
|
+
esac
|
|
97
|
+
rm -f "${STATE}"
|
|
98
|
+
done
|
|
99
|
+
# One-time pairing-handoff files: the process that wrote one removes it within seconds, so any left over is stale.
|
|
100
|
+
rm -f "${M2_HOME}"/handoff-*.pending
|
|
101
|
+
|
|
102
|
+
# 3. Keep the workspace if anything is in it (the agents may have created files there).
|
|
103
|
+
KEPT=""
|
|
104
|
+
if [ -d "${M2_HOME}/workspace" ] && ! rmdir "${M2_HOME}/workspace" 2>/dev/null; then
|
|
105
|
+
KEPT="${M2_HOME}/workspace"
|
|
106
|
+
fi
|
|
107
|
+
rmdir "${M2_HOME}" 2>/dev/null
|
|
108
|
+
|
|
109
|
+
# The last line is what the agent repeats to the user: it must not claim success that didn't happen.
|
|
110
|
+
if [ -n "${FAILED}" ]; then
|
|
111
|
+
say "couldn't remove${FAILED}. Remove them yourself, or run this again."
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
if [ -n "${STILL_RUNNING}" ]; then
|
|
115
|
+
say "uninstalled, but the M2 bridge on port${STILL_RUNNING} is still running. Restart your Mac to stop it."
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
if [ -n "${KEPT}" ]; then
|
|
119
|
+
say "uninstalled. Kept ${KEPT} because it has files in it; delete it yourself if you don't need them."
|
|
120
|
+
else
|
|
121
|
+
say "uninstalled."
|
|
122
|
+
fi
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
-
"adapters"
|
|
11
|
+
"adapters",
|
|
12
|
+
"install/uninstall.sh"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=18"
|