@m2canvas/connect 0.6.0 → 0.7.1
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 +17 -0
- package/dist/bridge/adapters.js +12 -6
- package/dist/bridge/handoff.js +197 -0
- package/dist/bridge/install-layout.js +37 -0
- package/dist/bridge/instance.js +7 -1
- package/dist/bridge/main.js +133 -14
- package/dist/bridge/npm-command.js +25 -0
- package/dist/bridge/pair-call.js +31 -0
- package/dist/bridge/pairing.js +17 -4
- package/dist/bridge/release-manifest.js +80 -0
- package/dist/bridge/self-test.js +30 -0
- package/dist/bridge/server.js +3 -12
- package/dist/bridge/update-io.js +118 -0
- package/dist/bridge/update.js +365 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,3 +45,20 @@ Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on
|
|
|
45
45
|
Agent sessions start in that directory.
|
|
46
46
|
|
|
47
47
|
Options: `--port <n>`, `--cwd <dir>` (default: current directory), `--verbose` (also print agent stderr), `--dev` (also accept the local M2 dev server), `--replace` (stop the M2 bridge already running on the port first).
|
|
48
|
+
|
|
49
|
+
## One-time install (macOS)
|
|
50
|
+
|
|
51
|
+
M2's Local Agent tab can have an agent app set the bridge up for you, so you need neither a terminal nor Node.js:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -fsSL https://m2ui.ai/connect/install.sh | sh -s -- --pair <value from M2>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **From M2.** Click "Connect with Claude" (or Codex, or Cursor): the app opens with this command in a prompt, and you press Return. The installer puts a private Node.js and a pinned version of this package in `~/.m2canvas`, checking every download against the hashes M2 publishes; writes the launcher `~/.m2canvas/bin/m2-bridge`; and starts the bridge in the background. The page pairs by itself. Agent sessions start in `~/.m2canvas/workspace`.
|
|
58
|
+
- **Nothing starts at login.** After a restart, M2 asks the agent app to run `~/.m2canvas/bin/m2-bridge start --pair <value>` (you can run it yourself too). The launcher first updates the bridge to the version listed at `https://m2ui.ai/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
|
|
59
|
+
- **No pairing codes in the background.** A bridge started this way has no window to show a code in. 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
|
+
- **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
|
+
- **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 `curl -fsSL https://m2ui.ai/connect/uninstall.sh | sh`, or "Uninstall local connection" in the Local Agent tab's ⋯ menu. It stops the bridge and removes what the installer added; `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are.
|
|
63
|
+
|
|
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
|
@@ -41,7 +41,9 @@ export const ADAPTERS = {
|
|
|
41
41
|
/** 适配器自带的 codex:预检与授权命令都要用它(本机旧版 codex 存的凭证,新版本刷新不了,PoC 报告 §2.2) */
|
|
42
42
|
export const CODEX_SCRIPT = 'node_modules/@openai/codex/bin/codex.js';
|
|
43
43
|
export const CODEX_BIN_LINK = 'node_modules/.bin/codex';
|
|
44
|
-
|
|
44
|
+
/** 安装完成的标记文件:没有它的目录一律视为未安装(自动更新清理旧目录时也只删带它的,change acp-bridge-one-time-install N2) */
|
|
45
|
+
export const ADAPTER_READY_MARKER = '.m2canvas-ready';
|
|
46
|
+
const READY_MARKER = ADAPTER_READY_MARKER;
|
|
45
47
|
const PROGRESS_INTERVAL_MS = 1000;
|
|
46
48
|
/**
|
|
47
49
|
* Linux 上没有 glibc(Alpine 等)时优先 musl 版本:与 claude-agent-sdk 自身的判断相同(process.report 的 glibcVersionRuntime)。
|
|
@@ -122,6 +124,14 @@ function isProcessAlive(pid) {
|
|
|
122
124
|
export function isAdapterAgent(agent) {
|
|
123
125
|
return agent === 'claude' || agent === 'codex';
|
|
124
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* 安装目录名 = `<适配器目录名>-<lockfile 的 sha256 前 16 位>`:换了 lockfile 就是新目录。
|
|
129
|
+
* 自动更新按 current / previous 两个版本各自的 lockfile 算出要保留的目录(change acp-bridge-one-time-install N2),所以规则只能在这一处。
|
|
130
|
+
*/
|
|
131
|
+
export function adapterInstallDirName(sourceRoot, dir) {
|
|
132
|
+
const lock = readFileSync(join(sourceRoot, dir, 'package-lock.json'));
|
|
133
|
+
return `${dir}-${createHash('sha256').update(lock).digest('hex').slice(0, 16)}`;
|
|
134
|
+
}
|
|
125
135
|
export class AdapterManager {
|
|
126
136
|
options;
|
|
127
137
|
installing = new Map();
|
|
@@ -130,12 +140,8 @@ export class AdapterManager {
|
|
|
130
140
|
constructor(options) {
|
|
131
141
|
this.options = options;
|
|
132
142
|
}
|
|
133
|
-
lockKey(agent) {
|
|
134
|
-
const lock = readFileSync(join(this.options.sourceRoot, ADAPTERS[agent].dir, 'package-lock.json'));
|
|
135
|
-
return createHash('sha256').update(lock).digest('hex').slice(0, 16);
|
|
136
|
-
}
|
|
137
143
|
installDir(agent) {
|
|
138
|
-
return join(this.options.installRoot,
|
|
144
|
+
return join(this.options.installRoot, adapterInstallDirName(this.options.sourceRoot, ADAPTERS[agent].dir));
|
|
139
145
|
}
|
|
140
146
|
entryPath(agent) {
|
|
141
147
|
return join(this.installDir(agent), ADAPTERS[agent].entry);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把命令承诺交给正在运行的后台 bridge(change acp-bridge-one-time-install design D27、R11、review r3 R3-S1;`connect/local-bridge` 契约 2 delta)。
|
|
3
|
+
*
|
|
4
|
+
* 后台模式的 bridge 不出配对码,新页面只能靠启动命令里的承诺配对;原来承诺只能在启动时经 `--pair` 带进去,
|
|
5
|
+
* 换一个浏览器就得 `--replace` 重启 bridge,打断其他页面上正在跑的 agent。现在:
|
|
6
|
+
* - 后台模式的 bridge 监听 `~/.m2canvas/bridge-<port>.sock`;
|
|
7
|
+
* - 启动器拉起的新 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时,把 c 写进 `~/.m2canvas/handoff-<编号>.pending`,
|
|
8
|
+
* 经 socket 只发这个编号;bridge 读出承诺、删掉文件、挂上,再回复。交出方随后以退出码 3 退出。
|
|
9
|
+
*
|
|
10
|
+
* 跨版本接口(启动器只随重装更新,bridge 会自动更新,交出方与接收方可能是不同版本;`connect/local-bridge` 契约 14 delta):
|
|
11
|
+
* - 请求一行(不含换行最多 256 字节):`{"type":"arm","nonce":"<32 位小写十六进制>"}\n`;承诺在 `<m2 目录>/handoff-<nonce>.pending`(普通文件、属于本账号);
|
|
12
|
+
* - 回复一行:`{"ok":true,"until":<毫秒时间戳>}` 或 `{"ok":false,"error":"…"}`,回复后断开。
|
|
13
|
+
* 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
|
|
14
|
+
*
|
|
15
|
+
* 维护约束:
|
|
16
|
+
* - 门槛是「能写 `~/.m2canvas`」,而不只是「能连 socket」(R3-S1):agent 沙箱里的命令可能被放开了本机 socket,
|
|
17
|
+
* 但默认只能写工作目录与临时目录;只靠 socket 的话,它就能配上、再经 bridge 驱动沙箱外的 agent。能写这个目录的进程
|
|
18
|
+
* 本来就能配对(bridge 每次校验 token 都重读 `bridge-pairings.json`,往里加一条记录即可;在沙箱里、做不到 `--replace` 也一样),
|
|
19
|
+
* 所以不算新的攻击者类别。这条论证依赖「每次重读配对文件」:改成只在启动时读或缓存时,要重新评估这里的门槛。
|
|
20
|
+
* 不要退回「socket 上直接带承诺」,也不要放宽文件名(只认 32 位十六进制)。
|
|
21
|
+
* - socket 上不收 S、不回 token:拿 token 仍要页面在 hello 里出示 S,Origin 白名单照旧。
|
|
22
|
+
* - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
|
|
23
|
+
* - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住 bridge 或吃内存。
|
|
24
|
+
* - socket 路径受 macOS 的 sun_path 限制(104 字节含结尾 0):超出就不监听,只记日志(交接不可用,页面用 `--replace` 兜底)。
|
|
25
|
+
*/
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { chmodSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { createConnection, createServer } from 'node:net';
|
|
29
|
+
import { basename, dirname, join } from 'node:path';
|
|
30
|
+
import { isPairCommitment } from './pairing.js';
|
|
31
|
+
/** sockaddr_un.sun_path 是 104 字节,含结尾的 0 */
|
|
32
|
+
export const HANDOFF_MAX_PATH_BYTES = 103;
|
|
33
|
+
export const HANDOFF_TIMEOUT_MS = 3_000;
|
|
34
|
+
/** `{"type":"arm","nonce":"<32 字符>"}` 不到 60 字节,留足余量 */
|
|
35
|
+
const MAX_LINE_BYTES = 256;
|
|
36
|
+
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
37
|
+
/** 承诺文件只放 22 个字符(可能带换行) */
|
|
38
|
+
const MAX_PENDING_BYTES = 64;
|
|
39
|
+
export function handoffSocketPath(dir, port) {
|
|
40
|
+
return join(dir, `bridge-${port}.sock`);
|
|
41
|
+
}
|
|
42
|
+
function pendingPath(dir, nonce) {
|
|
43
|
+
return join(dir, `handoff-${nonce}.pending`);
|
|
44
|
+
}
|
|
45
|
+
/** 删不掉(例如被换成了目录)只当没删:这里的异常没人接,抛出会让 bridge 退出(契约 5,r3 复核 R3C-N1) */
|
|
46
|
+
function removeQuietly(path) {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(path, { force: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// 留下的残留由卸载脚本或下一次同名交接处理
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function errorText(error) {
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
function requestedNonce(line) {
|
|
58
|
+
try {
|
|
59
|
+
const request = JSON.parse(line);
|
|
60
|
+
if (typeof request !== 'object' || request === null)
|
|
61
|
+
return null;
|
|
62
|
+
const { type, nonce } = request;
|
|
63
|
+
return type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce) ? nonce : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** 读出交出方写的承诺并删掉文件;不是本账号的普通文件、内容不对都当没有(不跟随符号链接) */
|
|
70
|
+
function takePendingCommitment(dir, nonce) {
|
|
71
|
+
const path = pendingPath(dir, nonce);
|
|
72
|
+
// 编号的正则已经挡住了路径分隔符;这里再按路径结构核一次,正则被改松时也不会读到目录外(r3 复核 R3C-S1)
|
|
73
|
+
if (dirname(path) !== join(dir) || basename(path) !== `handoff-${nonce}.pending`)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const info = lstatSync(path);
|
|
77
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : info.uid;
|
|
78
|
+
if (!info.isFile() || info.uid !== uid || info.size > MAX_PENDING_BYTES)
|
|
79
|
+
return null;
|
|
80
|
+
const commitment = readFileSync(path, 'utf8').trim();
|
|
81
|
+
return isPairCommitment(commitment) ? commitment : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
removeQuietly(path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 后台模式的 bridge 在拿到端口之后调用:同名的 socket 残留(上次没清干净)先删掉 —— 端口在自己手里,这个端口的 socket 不可能属于别人。
|
|
92
|
+
* 返回 null = 没监听(路径太长或建不起来),只记日志,不影响 bridge 本身。
|
|
93
|
+
*/
|
|
94
|
+
export async function listenForHandoff(options) {
|
|
95
|
+
const { dir, port, arm, log } = options;
|
|
96
|
+
const path = handoffSocketPath(dir, port);
|
|
97
|
+
if (Buffer.byteLength(path) > HANDOFF_MAX_PATH_BYTES) {
|
|
98
|
+
log(`Pairing handoff is unavailable: ${path} is too long for a local socket. A new browser has to use the reconnect command instead.`);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const server = createServer((socket) => {
|
|
102
|
+
const deadline = setTimeout(() => socket.destroy(), HANDOFF_TIMEOUT_MS);
|
|
103
|
+
socket.on('close', () => clearTimeout(deadline));
|
|
104
|
+
socket.on('error', () => undefined);
|
|
105
|
+
let buffer = '';
|
|
106
|
+
let done = false;
|
|
107
|
+
socket.on('data', (chunk) => {
|
|
108
|
+
// 只处理第一行:之后再发什么都丢掉(不暂停读取,对方断开时这边也跟着关)
|
|
109
|
+
if (done)
|
|
110
|
+
return;
|
|
111
|
+
buffer += chunk.toString('utf8');
|
|
112
|
+
const end = buffer.indexOf('\n');
|
|
113
|
+
if (Buffer.byteLength(end < 0 ? buffer : buffer.slice(0, end)) > MAX_LINE_BYTES) {
|
|
114
|
+
socket.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (end < 0)
|
|
118
|
+
return;
|
|
119
|
+
done = true;
|
|
120
|
+
const nonce = requestedNonce(buffer.slice(0, end));
|
|
121
|
+
const commitment = nonce ? takePendingCommitment(dir, nonce) : null;
|
|
122
|
+
if (!commitment) {
|
|
123
|
+
socket.end(`${JSON.stringify({ ok: false, error: nonce ? 'not_found' : 'bad_request' })}\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const until = arm(commitment);
|
|
127
|
+
log('Armed a pairing value received over the local handoff socket; the page that holds it can pair now.');
|
|
128
|
+
socket.end(`${JSON.stringify({ ok: true, until })}\n`);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
rmSync(path, { force: true });
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
server.once('error', reject);
|
|
135
|
+
server.listen(path, () => resolve());
|
|
136
|
+
});
|
|
137
|
+
chmodSync(path, 0o600);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
server.close();
|
|
141
|
+
log(`Pairing handoff is unavailable (${errorText(error)}). A new browser has to use the reconnect command instead.`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
close() {
|
|
146
|
+
server.close();
|
|
147
|
+
rmSync(path, { force: true });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 新的 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时调用:把承诺写进一次性文件,经 socket 发编号。
|
|
153
|
+
* true = 对方确认挂上了;没人在听、对方拒绝、超时都是 false(调用方照常以退出码 3 退出,只是新页面配不上)。文件无论如何都删掉。
|
|
154
|
+
*/
|
|
155
|
+
export function handOffCommitment(options) {
|
|
156
|
+
const { dir, port, commitment, timeoutMs = HANDOFF_TIMEOUT_MS } = options;
|
|
157
|
+
if (!isPairCommitment(commitment))
|
|
158
|
+
return Promise.resolve(false);
|
|
159
|
+
const nonce = randomBytes(16).toString('hex');
|
|
160
|
+
const file = pendingPath(dir, nonce);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(file, commitment, { mode: 0o600, flag: 'wx' });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return Promise.resolve(false);
|
|
166
|
+
}
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
let reply = '';
|
|
169
|
+
let settled = false;
|
|
170
|
+
const socket = createConnection(handoffSocketPath(dir, port));
|
|
171
|
+
const finish = (ok) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
socket.destroy();
|
|
177
|
+
resolve(ok);
|
|
178
|
+
};
|
|
179
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
180
|
+
socket.on('error', () => finish(false));
|
|
181
|
+
socket.on('data', (chunk) => {
|
|
182
|
+
reply += chunk.toString('utf8');
|
|
183
|
+
if (reply.length > MAX_LINE_BYTES)
|
|
184
|
+
finish(false);
|
|
185
|
+
});
|
|
186
|
+
socket.on('end', () => {
|
|
187
|
+
try {
|
|
188
|
+
const answer = JSON.parse(reply.trim());
|
|
189
|
+
finish(typeof answer === 'object' && answer !== null && answer.ok === true);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
finish(false);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'arm', nonce })}\n`));
|
|
196
|
+
}).finally(() => removeQuietly(file));
|
|
197
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性安装在 `~/.m2canvas` 下的目录布局(change acp-bridge-one-time-install D15)。
|
|
3
|
+
*
|
|
4
|
+
* 同一份布局还写死在两个 sh 脚本里:安装脚本 `public/connect/install.sh` 与启动器源 `packages/connect/install/m2-bridge.sh`
|
|
5
|
+
* (脚本不能 import 这里)。改这里要同批改那两处;脚本测试会按这里的路径核对它们的产物。
|
|
6
|
+
*
|
|
7
|
+
* 全部在用户主目录下,不写任何系统目录、不注册任何启动项、不改 PATH(`connect/local-bridge` 契约 14 delta)。
|
|
8
|
+
* 其余已有文件(配对记录 `bridge-pairings.json`、状态文件 `bridge-<port>.json`、适配器 `acp-adapters/`)位置不变。
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
export function installLayout(home) {
|
|
12
|
+
const root = join(home, '.m2canvas');
|
|
13
|
+
const bridge = join(root, 'bridge');
|
|
14
|
+
const logs = join(root, 'logs');
|
|
15
|
+
return {
|
|
16
|
+
root,
|
|
17
|
+
runtime: join(root, 'runtime'),
|
|
18
|
+
bridge,
|
|
19
|
+
current: join(bridge, 'current'),
|
|
20
|
+
previous: join(bridge, 'previous'),
|
|
21
|
+
rejected: join(bridge, 'rejected'),
|
|
22
|
+
launcher: join(root, 'bin', 'm2-bridge'),
|
|
23
|
+
logs,
|
|
24
|
+
updateLog: join(logs, 'update.log'),
|
|
25
|
+
workspace: join(root, 'workspace'),
|
|
26
|
+
adapters: join(root, 'acp-adapters'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** 版本目录里的文件:这个版本用哪份 Node(绝对路径一行)。回退版本时 Node 跟着一起回退 */
|
|
30
|
+
export const NODE_PATH_FILE = 'node-path';
|
|
31
|
+
/** 版本目录内 connect 包的位置(npm tarball 解开后的 `package/` 改名而来) */
|
|
32
|
+
export function connectPackageDir(versionDir) {
|
|
33
|
+
return join(versionDir, 'node_modules', '@m2canvas', 'connect');
|
|
34
|
+
}
|
|
35
|
+
export function connectCliPath(versionDir) {
|
|
36
|
+
return join(connectPackageDir(versionDir), 'dist', 'cli.js');
|
|
37
|
+
}
|
package/dist/bridge/instance.js
CHANGED
|
@@ -34,7 +34,12 @@ export function instanceFilePath(m2Dir, port) {
|
|
|
34
34
|
export function processStartedAt() {
|
|
35
35
|
return Math.round(Date.now() - process.uptime() * 1000);
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* 只写这四个字段:调用方多带的任何字段都不落盘(契约 2:文件里不能有配对码、token、承诺)。
|
|
39
|
+
* 这也是跨版本接口:用户机器上的启动器 `~/.m2canvas/bin/m2-bridge` 只随重装更新,它按「JSON 对象里的 "pid"」核对
|
|
40
|
+
* 拉起的是不是这个进程(change acp-bridge-one-time-install review r1 R1-S2)。改格式前先确认已装出去的启动器仍认得,
|
|
41
|
+
* `install-launcher-real.test.ts` 用真实 bridge 锁住了这一点。
|
|
42
|
+
*/
|
|
38
43
|
export function writeInstanceRecord(path, record) {
|
|
39
44
|
const { pid, port, startedAt } = record;
|
|
40
45
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -217,6 +222,7 @@ export async function listenWithReplace(listen, ctx) {
|
|
|
217
222
|
return null;
|
|
218
223
|
}
|
|
219
224
|
if (!ctx.replace) {
|
|
225
|
+
ctx.alreadyRunning?.(running);
|
|
220
226
|
const stopIt = running.terminal ? `press Ctrl+C in that terminal or run "kill ${running.pid}"` : `stop it with "kill ${running.pid}"`;
|
|
221
227
|
ctx.error(`Port ${ctx.port} is already in use by another M2 bridge (${describe(running, ctx.clock)}).\n` +
|
|
222
228
|
`To start this one instead, ${stopIt}, then run this command again. Or run it again with --replace to stop that bridge first.`);
|
package/dist/bridge/main.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 本文件负责组装与终端交互:参数、启动横幅、配对码打印、信号处理、退出兜底清理。协议在 server.ts,安全判定在 security.ts / pairing.ts。
|
|
7
7
|
* 终端输出用英文,与 connect 现有输出一致。
|
|
8
|
+
*
|
|
9
|
+
* 后台模式(`--background`,只由启动器 `~/.m2canvas/bin/m2-bridge` 使用,change acp-bridge-one-time-install):输出写进日志文件,
|
|
10
|
+
* 所以不出配对码(契约 2)、不提示「保持窗口打开」;页面经 `welcome.bridge.background` 得知,改走「重新连接」。
|
|
11
|
+
* 端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
|
|
8
12
|
*/
|
|
9
13
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
14
|
import { join, resolve } from 'node:path';
|
|
@@ -16,14 +20,37 @@ import { EXPECT_PATH } from './authorize.js';
|
|
|
16
20
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
17
21
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
18
22
|
import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
|
|
23
|
+
import { handOffCommitment, listenForHandoff } from './handoff.js';
|
|
19
24
|
import { installShutdownSignals } from './lifecycle.js';
|
|
20
25
|
import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
|
|
21
26
|
import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
|
|
27
|
+
import { resolveNpmCommand } from './npm-command.js';
|
|
22
28
|
import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
|
|
29
|
+
import { selfTestProblems } from './self-test.js';
|
|
30
|
+
import { runCheckUpdate } from './update-io.js';
|
|
23
31
|
import { startBridgeServer } from './server.js';
|
|
24
32
|
import { createAgentService } from './service.js';
|
|
25
33
|
import { KILL_GRACE_MS } from './processes.js';
|
|
26
34
|
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
|
+
}
|
|
27
54
|
const LABELS = {
|
|
28
55
|
claude: 'Claude Code',
|
|
29
56
|
codex: 'Codex',
|
|
@@ -56,10 +83,20 @@ your browser.
|
|
|
56
83
|
stop it first (its agent sessions end) and start this one instead;
|
|
57
84
|
not on Windows
|
|
58
85
|
--unpair forget all paired pages and exit
|
|
59
|
-
-h, --help show this help
|
|
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`;
|
|
60
97
|
/** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
|
|
61
98
|
export function parseBridgeArgs(argv) {
|
|
62
|
-
const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false };
|
|
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 };
|
|
63
100
|
for (let i = 0; i < argv.length; i++) {
|
|
64
101
|
const arg = argv[i];
|
|
65
102
|
if (arg === '-h' || arg === '--help')
|
|
@@ -72,6 +109,14 @@ export function parseBridgeArgs(argv) {
|
|
|
72
109
|
args.dev = true;
|
|
73
110
|
else if (arg === '--replace')
|
|
74
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;
|
|
75
120
|
else if (arg === '--pair') {
|
|
76
121
|
const value = argv[++i];
|
|
77
122
|
if (value === undefined)
|
|
@@ -141,6 +186,22 @@ function describeAdapter(agent, info) {
|
|
|
141
186
|
return `Installing the ${name} failed: ${info.error ?? 'unknown error'}`;
|
|
142
187
|
return null;
|
|
143
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* `--stop`:卸载脚本先停掉正在运行的 bridge(change acp-bridge-one-time-install D23 ①)。
|
|
191
|
+
* 认定与停止都复用契约 13 的实现(instance.ts):只停认定的 M2 bridge,只发 SIGTERM(走它自己的清理),认不出就什么都不停。
|
|
192
|
+
* 不在卸载脚本里用 sh 再写一遍认定(要用 `ps` 推算启动时刻,两份实现会漂;理由同 design R1)。
|
|
193
|
+
*/
|
|
194
|
+
async function stopRecognizedBridge(port) {
|
|
195
|
+
const probeEnv = buildProbeEnv();
|
|
196
|
+
const running = await identifyRunningBridge(instanceFilePath(join(home, '.m2canvas'), port), port, createPsProbe(execCommand, probeEnv), process.platform);
|
|
197
|
+
if (!running) {
|
|
198
|
+
console.log(`No M2 bridge that this version can recognize is running on port ${port}; nothing was stopped.`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const stopped = await stopRunningBridge(running.pid, createStopDeps(execCommand, probeEnv));
|
|
202
|
+
console.log(stopped ? `Stopped the M2 bridge on port ${port} (pid ${running.pid}).` : `The M2 bridge on port ${port} (pid ${running.pid}) didn't stop.`);
|
|
203
|
+
return stopped ? 0 : 1;
|
|
204
|
+
}
|
|
144
205
|
export async function runBridge(argv) {
|
|
145
206
|
const parsed = parseBridgeArgs(argv);
|
|
146
207
|
if (typeof parsed === 'string') {
|
|
@@ -151,6 +212,20 @@ export async function runBridge(argv) {
|
|
|
151
212
|
console.log(BRIDGE_HELP);
|
|
152
213
|
return 0;
|
|
153
214
|
}
|
|
215
|
+
if (parsed.selfTest) {
|
|
216
|
+
const version = packageVersion();
|
|
217
|
+
const problems = selfTestProblems({ packageRoot: PACKAGE_ROOT, execPath: process.execPath, version });
|
|
218
|
+
if (problems.length > 0) {
|
|
219
|
+
console.error(`M2 bridge self-test failed: ${problems.join('; ')}`);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
console.log(`M2 bridge self-test ok ${version}`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
if (parsed.checkUpdate)
|
|
226
|
+
return runCheckUpdate();
|
|
227
|
+
if (parsed.stop)
|
|
228
|
+
return stopRecognizedBridge(parsed.port);
|
|
154
229
|
const m2Dir = join(home, '.m2canvas');
|
|
155
230
|
const store = createFilePairingStore(join(m2Dir, 'bridge-pairings.json'), (message) => console.warn(message));
|
|
156
231
|
if (parsed.unpair) {
|
|
@@ -164,11 +239,14 @@ export async function runBridge(argv) {
|
|
|
164
239
|
}
|
|
165
240
|
const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
|
|
166
241
|
const probeEnv = buildProbeEnv();
|
|
167
|
-
|
|
242
|
+
// 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3);终端模式照旧用 PATH 上的
|
|
243
|
+
const npm = resolveNpmCommand({ background: parsed.background, execPath: process.execPath, platform: process.platform, which });
|
|
168
244
|
let server = null;
|
|
169
245
|
// 配对锁定只能靠重启 bridge 解除:bridge 不读终端(放到后台时读终端会被系统挂起),页面也没有任何途径解锁
|
|
170
246
|
const pairing = new PairingManager({
|
|
171
247
|
store,
|
|
248
|
+
// 后台模式没有终端、输出写进日志文件:不出码(契约 2「码只出现在终端」),配对只剩命令承诺与已有 token(D17 / D18)
|
|
249
|
+
issueCodes: !parsed.background,
|
|
172
250
|
onCodeIssued: printCode,
|
|
173
251
|
onLocked: printLocked,
|
|
174
252
|
onProblem: (message) => console.warn(message),
|
|
@@ -181,13 +259,15 @@ export async function runBridge(argv) {
|
|
|
181
259
|
const adapters = new AdapterManager({
|
|
182
260
|
sourceRoot: join(PACKAGE_ROOT, 'adapters'),
|
|
183
261
|
installRoot: join(m2Dir, 'acp-adapters'),
|
|
184
|
-
runNpmCi: (dir) =>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
262
|
+
runNpmCi: (dir) => 'error' in npm
|
|
263
|
+
? Promise.reject(new Error(npm.error))
|
|
264
|
+
: execCommand(npm.command, [...npm.prefix, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], {
|
|
265
|
+
cwd: dir,
|
|
266
|
+
env: buildNpmEnv(),
|
|
267
|
+
timeoutMs: NPM_CI_TIMEOUT_MS,
|
|
268
|
+
maxOutput: 64 * 1024,
|
|
269
|
+
shell: npm.shell,
|
|
270
|
+
}),
|
|
191
271
|
onState: (agent, info) => {
|
|
192
272
|
if (loggedState.get(agent) !== info.state) {
|
|
193
273
|
loggedState.set(agent, info.state);
|
|
@@ -216,10 +296,18 @@ export async function runBridge(argv) {
|
|
|
216
296
|
const origins = allowedOrigins(parsed.dev);
|
|
217
297
|
// 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
|
|
218
298
|
const instancePath = instanceFilePath(m2Dir, parsed.port);
|
|
299
|
+
let alreadyRunning = false;
|
|
219
300
|
server = await listenWithReplace(() => startBridgeServer({
|
|
220
301
|
port: parsed.port,
|
|
221
302
|
cwd: parsed.cwd,
|
|
222
|
-
bridge: {
|
|
303
|
+
bridge: {
|
|
304
|
+
name: '@m2canvas/connect',
|
|
305
|
+
version,
|
|
306
|
+
platform: process.platform,
|
|
307
|
+
arch: process.arch,
|
|
308
|
+
node: process.versions.node,
|
|
309
|
+
...(parsed.background ? { background: true } : {}),
|
|
310
|
+
},
|
|
223
311
|
pairing,
|
|
224
312
|
service,
|
|
225
313
|
agentEnv: buildAgentEnv(),
|
|
@@ -236,9 +324,18 @@ export async function runBridge(argv) {
|
|
|
236
324
|
log,
|
|
237
325
|
error: (message) => console.error(message),
|
|
238
326
|
clock,
|
|
327
|
+
alreadyRunning: () => {
|
|
328
|
+
alreadyRunning = true;
|
|
329
|
+
},
|
|
239
330
|
});
|
|
331
|
+
// listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开。
|
|
332
|
+
// 已在运行时先把承诺交给它,新页面不用重启 bridge 就能配对(D27);交不过去也照常退出码 3
|
|
333
|
+
if (!server && parsed.background && alreadyRunning && parsed.pair) {
|
|
334
|
+
const handed = await handOffCommitment({ dir: m2Dir, port: parsed.port, commitment: parsed.pair });
|
|
335
|
+
log(handed ? 'Handed the pairing to the M2 bridge already running on this port; the new page pairs with it.' : "Couldn't hand the pairing to the M2 bridge already running on this port; the new page can reconnect with --replace.");
|
|
336
|
+
}
|
|
240
337
|
if (!server)
|
|
241
|
-
return 1;
|
|
338
|
+
return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
|
|
242
339
|
const running = server;
|
|
243
340
|
try {
|
|
244
341
|
// startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
|
|
@@ -247,6 +344,8 @@ export async function runBridge(argv) {
|
|
|
247
344
|
catch (error) {
|
|
248
345
|
console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
|
|
249
346
|
}
|
|
347
|
+
// 交接 socket(D27)在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
|
|
348
|
+
let handoff = null;
|
|
250
349
|
// 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
|
|
251
350
|
// 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
|
|
252
351
|
process.on('exit', () => {
|
|
@@ -257,6 +356,12 @@ export async function runBridge(argv) {
|
|
|
257
356
|
catch {
|
|
258
357
|
// 见上
|
|
259
358
|
}
|
|
359
|
+
try {
|
|
360
|
+
handoff?.close();
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// 残留的 socket 文件由下一次启动删掉
|
|
364
|
+
}
|
|
260
365
|
running.authorizations.killAllNow();
|
|
261
366
|
running.processes.killAllNow();
|
|
262
367
|
killActiveExecs();
|
|
@@ -293,6 +398,15 @@ export async function runBridge(argv) {
|
|
|
293
398
|
process.exit(130);
|
|
294
399
|
},
|
|
295
400
|
});
|
|
401
|
+
if (parsed.background)
|
|
402
|
+
handoff = await listenForHandoff({ dir: m2Dir, port: parsed.port, arm: (c) => pairing.armCommitment(c), log });
|
|
403
|
+
const lifetime = backgroundLifetimeMs(parsed);
|
|
404
|
+
if (lifetime !== null) {
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
log('Dev mode in the background reached its 2-hour limit; stopping. Start it again with ~/.m2canvas/bin/m2-bridge if you still need it.');
|
|
407
|
+
void stop();
|
|
408
|
+
}, lifetime);
|
|
409
|
+
}
|
|
296
410
|
console.log(`M2 local agent bridge ${version}`);
|
|
297
411
|
console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
|
|
298
412
|
console.log(` Working directory for agents: ${parsed.cwd}`);
|
|
@@ -301,12 +415,17 @@ export async function runBridge(argv) {
|
|
|
301
415
|
console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
|
|
302
416
|
// 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
|
|
303
417
|
// 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
|
|
418
|
+
if (parsed.background)
|
|
419
|
+
console.log(' Started in the background by the M2 launcher: no pairing codes are shown; pair from M2 with a new start command.');
|
|
420
|
+
if (lifetime !== null)
|
|
421
|
+
console.log(` Dev mode in the background: stops by itself at ${clock(Date.now() + lifetime)}.`);
|
|
304
422
|
if (pairUntil !== null)
|
|
305
423
|
console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
|
|
306
|
-
else
|
|
424
|
+
else if (!parsed.background)
|
|
307
425
|
pairing.issueCode('startup');
|
|
308
426
|
const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
|
|
309
427
|
console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
|
|
310
|
-
|
|
428
|
+
if (!parsed.background)
|
|
429
|
+
console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
|
|
311
430
|
return exited;
|
|
312
431
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 适配器安装(`npm ci`)用哪个 npm(change acp-bridge-one-time-install R3)。
|
|
3
|
+
*
|
|
4
|
+
* - 终端模式(`npx @m2canvas/connect bridge`):沿用 PATH 上的 npm —— 用户能运行 npx,就一定有 npm;不改既有行为。
|
|
5
|
+
* - 后台模式(`bridge --background`,由启动器用 `~/.m2canvas/runtime` 里的私有 Node 拉起):目标用户往往根本没装 Node,
|
|
6
|
+
* PATH 上没有 npm。必须用运行 bridge 的那份 Node(`process.execPath`)去跑同一 Node 包里的 `lib/node_modules/npm/bin/npm-cli.js`。
|
|
7
|
+
* 不能直接运行 `bin/npm`:它的 shebang 是 `#!/usr/bin/env node`,又会去 PATH 找 node。
|
|
8
|
+
* 找不到 npm-cli.js 就报错,**不退回 PATH 上的 npm**:那会让组件装进一份与 bridge 不同的 Node 环境里,失败时也更难查。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
/** 官方 Node 二进制包的布局:`<prefix>/bin/node` 与 `<prefix>/lib/node_modules/npm/bin/npm-cli.js` */
|
|
13
|
+
export function bundledNpmCli(execPath) {
|
|
14
|
+
return join(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
15
|
+
}
|
|
16
|
+
export function resolveNpmCommand(input) {
|
|
17
|
+
if (input.background) {
|
|
18
|
+
const cli = bundledNpmCli(input.execPath);
|
|
19
|
+
if (!(input.exists ?? existsSync)(cli))
|
|
20
|
+
return { error: `npm was not found next to this bridge's Node.js (${cli}); reinstall the M2 local connection` };
|
|
21
|
+
return { command: input.execPath, prefix: [cli], shell: false };
|
|
22
|
+
}
|
|
23
|
+
const windows = input.platform === 'win32';
|
|
24
|
+
return { command: input.which('npm') ?? (windows ? 'npm.cmd' : 'npm'), prefix: [], shell: windows };
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge 方法 `pair`(页面输码换 token):校验参数、调用 PairingManager.pair、把失败映射成页面看得懂的错误码。
|
|
3
|
+
* 只负责「这次输码对不对」;配对成功后把连接标为已配对、记日志、回 cwd 由 server.ts 负责(连接状态只在那里改)。
|
|
4
|
+
*
|
|
5
|
+
* 错误码的语义是页面协议的一部分(`protocol.ts` 的 BridgeErrorCode,`connect/local-bridge` 契约 9):
|
|
6
|
+
* - pairing_invalid:错码,带剩余次数;
|
|
7
|
+
* - pairing_code_rotated:码过期或错太多次被换掉,新码已打印在终端;
|
|
8
|
+
* - pairing_locked:累计错码太多,只有重启 bridge 才解除;
|
|
9
|
+
* - pairing_code_unavailable:后台模式(`bridge --background`)根本不出码,页面要改走「重新连接」(change acp-bridge-one-time-install D17)。
|
|
10
|
+
* 不要把后两种并进 pairing_code_rotated:页面据此决定是让用户看终端,还是让 agent 重新拉起 bridge。
|
|
11
|
+
*/
|
|
12
|
+
import { BridgeFailure } from './errors.js';
|
|
13
|
+
/** 成功时返回新签发的 token;失败抛 BridgeFailure */
|
|
14
|
+
export function handlePairCall(pairing, origin, params) {
|
|
15
|
+
const code = params.code;
|
|
16
|
+
if (typeof code !== 'string')
|
|
17
|
+
throw new BridgeFailure('bad_message', 'pair needs a string "code"');
|
|
18
|
+
const result = pairing.pair(code, origin);
|
|
19
|
+
if (result.ok)
|
|
20
|
+
return result.token;
|
|
21
|
+
switch (result.code) {
|
|
22
|
+
case 'pairing_invalid':
|
|
23
|
+
throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
|
|
24
|
+
case 'pairing_locked':
|
|
25
|
+
throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
|
|
26
|
+
case 'pairing_code_unavailable':
|
|
27
|
+
throw new BridgeFailure('pairing_code_unavailable', 'this bridge was started in the background and shows no pairing code; reconnect from M2 to pair');
|
|
28
|
+
case 'pairing_code_rotated':
|
|
29
|
+
throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
|
|
30
|
+
}
|
|
31
|
+
}
|