@m2canvas/connect 0.6.0 → 0.7.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 +17 -0
- package/dist/bridge/adapters.js +12 -6
- package/dist/bridge/install-layout.js +37 -0
- package/dist/bridge/instance.js +7 -1
- package/dist/bridge/main.js +116 -14
- package/dist/bridge/npm-command.js +25 -0
- package/dist/bridge/pair-call.js +31 -0
- package/dist/bridge/pairing.js +13 -3
- 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. If a page isn't paired (the pairing expired, or you use another browser), M2 offers "Reconnect", which runs the launcher with `--replace`: it restarts the bridge and pairs that page, and agent sessions on the old bridge end.
|
|
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,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';
|
|
@@ -19,11 +23,33 @@ import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge,
|
|
|
19
23
|
import { installShutdownSignals } from './lifecycle.js';
|
|
20
24
|
import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
|
|
21
25
|
import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
|
|
26
|
+
import { resolveNpmCommand } from './npm-command.js';
|
|
22
27
|
import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
|
|
28
|
+
import { selfTestProblems } from './self-test.js';
|
|
29
|
+
import { runCheckUpdate } from './update-io.js';
|
|
23
30
|
import { startBridgeServer } from './server.js';
|
|
24
31
|
import { createAgentService } from './service.js';
|
|
25
32
|
import { KILL_GRACE_MS } from './processes.js';
|
|
26
33
|
const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
|
|
34
|
+
/**
|
|
35
|
+
* 后台模式下,端口上已有认定的 M2 bridge、又没带 `--replace` 时的退出码(change acp-bridge-one-time-install R1)。
|
|
36
|
+
* 启动器把它当「已在运行」(成功);认不出的占用者、停不掉等失败仍是 1,不能并进来。终端模式不用它(照旧 1)。
|
|
37
|
+
*/
|
|
38
|
+
export const EXIT_ALREADY_RUNNING = 3;
|
|
39
|
+
/**
|
|
40
|
+
* 后台模式下,端口被占、又认不出占用者是 M2 bridge(或 `--replace` 停不掉旧的、停掉后端口仍被占)时的退出码(review r1 R1-S1)。
|
|
41
|
+
* 这些都是环境问题,不是这个版本坏了:启动器据此**不回退、不拉黑**刚更新的版本,只把原因写进结果行。终端模式仍是 1。
|
|
42
|
+
*/
|
|
43
|
+
export const EXIT_PORT_UNAVAILABLE = 4;
|
|
44
|
+
/**
|
|
45
|
+
* 后台模式加 `--dev`(本机开发页联调用,design R6)时的寿命(review r1 R1-S6,用户 2026-10-05 拍板)。
|
|
46
|
+
* 开发域名的 Origin 与 localStorage 被本机所有 Vite 项目共用,契约 1 因此只在 `--dev` 时放行、且否决了「常开」;
|
|
47
|
+
* 后台 bridge 没有终端、一直运行到注销,不限时就等于常开。到时走正常收尾退出,需要时再用启动器拉起。
|
|
48
|
+
*/
|
|
49
|
+
export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
|
|
50
|
+
export function backgroundLifetimeMs(args) {
|
|
51
|
+
return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
|
|
52
|
+
}
|
|
27
53
|
const LABELS = {
|
|
28
54
|
claude: 'Claude Code',
|
|
29
55
|
codex: 'Codex',
|
|
@@ -56,10 +82,19 @@ your browser.
|
|
|
56
82
|
stop it first (its agent sessions end) and start this one instead;
|
|
57
83
|
not on Windows
|
|
58
84
|
--unpair forget all paired pages and exit
|
|
59
|
-
-h, --help show this help
|
|
85
|
+
-h, --help show this help
|
|
86
|
+
|
|
87
|
+
Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
|
|
88
|
+
--background run without a terminal: no pairing codes are shown; exits with
|
|
89
|
+
code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port, ${EXIT_PORT_UNAVAILABLE} if
|
|
90
|
+
something else holds the port; with --dev it stops by itself after 2 hours
|
|
91
|
+
--check-update update this installation to the version M2 publishes, then exit
|
|
92
|
+
--self-test check that this installation loads completely, print its version and exit
|
|
93
|
+
--stop stop the M2 bridge running on the port (only one it can recognize; it's
|
|
94
|
+
sent SIGTERM so it cleans up), then exit`;
|
|
60
95
|
/** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
|
|
61
96
|
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 };
|
|
97
|
+
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
98
|
for (let i = 0; i < argv.length; i++) {
|
|
64
99
|
const arg = argv[i];
|
|
65
100
|
if (arg === '-h' || arg === '--help')
|
|
@@ -72,6 +107,14 @@ export function parseBridgeArgs(argv) {
|
|
|
72
107
|
args.dev = true;
|
|
73
108
|
else if (arg === '--replace')
|
|
74
109
|
args.replace = true;
|
|
110
|
+
else if (arg === '--background')
|
|
111
|
+
args.background = true;
|
|
112
|
+
else if (arg === '--self-test')
|
|
113
|
+
args.selfTest = true;
|
|
114
|
+
else if (arg === '--check-update')
|
|
115
|
+
args.checkUpdate = true;
|
|
116
|
+
else if (arg === '--stop')
|
|
117
|
+
args.stop = true;
|
|
75
118
|
else if (arg === '--pair') {
|
|
76
119
|
const value = argv[++i];
|
|
77
120
|
if (value === undefined)
|
|
@@ -141,6 +184,22 @@ function describeAdapter(agent, info) {
|
|
|
141
184
|
return `Installing the ${name} failed: ${info.error ?? 'unknown error'}`;
|
|
142
185
|
return null;
|
|
143
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* `--stop`:卸载脚本先停掉正在运行的 bridge(change acp-bridge-one-time-install D23 ①)。
|
|
189
|
+
* 认定与停止都复用契约 13 的实现(instance.ts):只停认定的 M2 bridge,只发 SIGTERM(走它自己的清理),认不出就什么都不停。
|
|
190
|
+
* 不在卸载脚本里用 sh 再写一遍认定(要用 `ps` 推算启动时刻,两份实现会漂;理由同 design R1)。
|
|
191
|
+
*/
|
|
192
|
+
async function stopRecognizedBridge(port) {
|
|
193
|
+
const probeEnv = buildProbeEnv();
|
|
194
|
+
const running = await identifyRunningBridge(instanceFilePath(join(home, '.m2canvas'), port), port, createPsProbe(execCommand, probeEnv), process.platform);
|
|
195
|
+
if (!running) {
|
|
196
|
+
console.log(`No M2 bridge that this version can recognize is running on port ${port}; nothing was stopped.`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
const stopped = await stopRunningBridge(running.pid, createStopDeps(execCommand, probeEnv));
|
|
200
|
+
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.`);
|
|
201
|
+
return stopped ? 0 : 1;
|
|
202
|
+
}
|
|
144
203
|
export async function runBridge(argv) {
|
|
145
204
|
const parsed = parseBridgeArgs(argv);
|
|
146
205
|
if (typeof parsed === 'string') {
|
|
@@ -151,6 +210,20 @@ export async function runBridge(argv) {
|
|
|
151
210
|
console.log(BRIDGE_HELP);
|
|
152
211
|
return 0;
|
|
153
212
|
}
|
|
213
|
+
if (parsed.selfTest) {
|
|
214
|
+
const version = packageVersion();
|
|
215
|
+
const problems = selfTestProblems({ packageRoot: PACKAGE_ROOT, execPath: process.execPath, version });
|
|
216
|
+
if (problems.length > 0) {
|
|
217
|
+
console.error(`M2 bridge self-test failed: ${problems.join('; ')}`);
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
console.log(`M2 bridge self-test ok ${version}`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (parsed.checkUpdate)
|
|
224
|
+
return runCheckUpdate();
|
|
225
|
+
if (parsed.stop)
|
|
226
|
+
return stopRecognizedBridge(parsed.port);
|
|
154
227
|
const m2Dir = join(home, '.m2canvas');
|
|
155
228
|
const store = createFilePairingStore(join(m2Dir, 'bridge-pairings.json'), (message) => console.warn(message));
|
|
156
229
|
if (parsed.unpair) {
|
|
@@ -164,11 +237,14 @@ export async function runBridge(argv) {
|
|
|
164
237
|
}
|
|
165
238
|
const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
|
|
166
239
|
const probeEnv = buildProbeEnv();
|
|
167
|
-
|
|
240
|
+
// 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3);终端模式照旧用 PATH 上的
|
|
241
|
+
const npm = resolveNpmCommand({ background: parsed.background, execPath: process.execPath, platform: process.platform, which });
|
|
168
242
|
let server = null;
|
|
169
243
|
// 配对锁定只能靠重启 bridge 解除:bridge 不读终端(放到后台时读终端会被系统挂起),页面也没有任何途径解锁
|
|
170
244
|
const pairing = new PairingManager({
|
|
171
245
|
store,
|
|
246
|
+
// 后台模式没有终端、输出写进日志文件:不出码(契约 2「码只出现在终端」),配对只剩命令承诺与已有 token(D17 / D18)
|
|
247
|
+
issueCodes: !parsed.background,
|
|
172
248
|
onCodeIssued: printCode,
|
|
173
249
|
onLocked: printLocked,
|
|
174
250
|
onProblem: (message) => console.warn(message),
|
|
@@ -181,13 +257,15 @@ export async function runBridge(argv) {
|
|
|
181
257
|
const adapters = new AdapterManager({
|
|
182
258
|
sourceRoot: join(PACKAGE_ROOT, 'adapters'),
|
|
183
259
|
installRoot: join(m2Dir, 'acp-adapters'),
|
|
184
|
-
runNpmCi: (dir) =>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
260
|
+
runNpmCi: (dir) => 'error' in npm
|
|
261
|
+
? Promise.reject(new Error(npm.error))
|
|
262
|
+
: execCommand(npm.command, [...npm.prefix, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], {
|
|
263
|
+
cwd: dir,
|
|
264
|
+
env: buildNpmEnv(),
|
|
265
|
+
timeoutMs: NPM_CI_TIMEOUT_MS,
|
|
266
|
+
maxOutput: 64 * 1024,
|
|
267
|
+
shell: npm.shell,
|
|
268
|
+
}),
|
|
191
269
|
onState: (agent, info) => {
|
|
192
270
|
if (loggedState.get(agent) !== info.state) {
|
|
193
271
|
loggedState.set(agent, info.state);
|
|
@@ -216,10 +294,18 @@ export async function runBridge(argv) {
|
|
|
216
294
|
const origins = allowedOrigins(parsed.dev);
|
|
217
295
|
// 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
|
|
218
296
|
const instancePath = instanceFilePath(m2Dir, parsed.port);
|
|
297
|
+
let alreadyRunning = false;
|
|
219
298
|
server = await listenWithReplace(() => startBridgeServer({
|
|
220
299
|
port: parsed.port,
|
|
221
300
|
cwd: parsed.cwd,
|
|
222
|
-
bridge: {
|
|
301
|
+
bridge: {
|
|
302
|
+
name: '@m2canvas/connect',
|
|
303
|
+
version,
|
|
304
|
+
platform: process.platform,
|
|
305
|
+
arch: process.arch,
|
|
306
|
+
node: process.versions.node,
|
|
307
|
+
...(parsed.background ? { background: true } : {}),
|
|
308
|
+
},
|
|
223
309
|
pairing,
|
|
224
310
|
service,
|
|
225
311
|
agentEnv: buildAgentEnv(),
|
|
@@ -236,9 +322,13 @@ export async function runBridge(argv) {
|
|
|
236
322
|
log,
|
|
237
323
|
error: (message) => console.error(message),
|
|
238
324
|
clock,
|
|
325
|
+
alreadyRunning: () => {
|
|
326
|
+
alreadyRunning = true;
|
|
327
|
+
},
|
|
239
328
|
});
|
|
329
|
+
// listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开
|
|
240
330
|
if (!server)
|
|
241
|
-
return 1;
|
|
331
|
+
return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
|
|
242
332
|
const running = server;
|
|
243
333
|
try {
|
|
244
334
|
// startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
|
|
@@ -293,6 +383,13 @@ export async function runBridge(argv) {
|
|
|
293
383
|
process.exit(130);
|
|
294
384
|
},
|
|
295
385
|
});
|
|
386
|
+
const lifetime = backgroundLifetimeMs(parsed);
|
|
387
|
+
if (lifetime !== null) {
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
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.');
|
|
390
|
+
void stop();
|
|
391
|
+
}, lifetime);
|
|
392
|
+
}
|
|
296
393
|
console.log(`M2 local agent bridge ${version}`);
|
|
297
394
|
console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
|
|
298
395
|
console.log(` Working directory for agents: ${parsed.cwd}`);
|
|
@@ -301,12 +398,17 @@ export async function runBridge(argv) {
|
|
|
301
398
|
console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
|
|
302
399
|
// 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
|
|
303
400
|
// 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
|
|
401
|
+
if (parsed.background)
|
|
402
|
+
console.log(' Started in the background by the M2 launcher: no pairing codes are shown; pair from M2 with a new start command.');
|
|
403
|
+
if (lifetime !== null)
|
|
404
|
+
console.log(` Dev mode in the background: stops by itself at ${clock(Date.now() + lifetime)}.`);
|
|
304
405
|
if (pairUntil !== null)
|
|
305
406
|
console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
|
|
306
|
-
else
|
|
407
|
+
else if (!parsed.background)
|
|
307
408
|
pairing.issueCode('startup');
|
|
308
409
|
const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
|
|
309
410
|
console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
|
|
310
|
-
|
|
411
|
+
if (!parsed.background)
|
|
412
|
+
console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
|
|
311
413
|
return exited;
|
|
312
414
|
}
|
|
@@ -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
|
+
}
|
package/dist/bridge/pairing.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* 不要为了「方便」放宽次数、延长有效期或加一个页面能触发的解锁:码是唯一挡住本机恶意页面的东西。
|
|
19
19
|
* 也不要改成「在终端按回车解锁」:那需要读终端,而 bridge 放到后台(`… bridge &`)时读终端会被系统挂起(review r2 实测)。
|
|
20
20
|
* - 比较用常量时间(timingSafeEqual),不要改回 ===。
|
|
21
|
+
* - 后台模式(`issueCodes: false`,`bridge --background`,change acp-bridge-one-time-install D17)不出码:没有终端,stdout 写的是日志文件,
|
|
22
|
+
* 出码就等于把码写盘。这时 `issueCode` / `ensureCode` 什么都不做,`pair` 一律 pairing_code_unavailable,配对只剩命令承诺与已有 token。
|
|
23
|
+
* 不要给后台模式另开出码通道(文件、系统对话框):「把码写进文件」已被否决过(`connect/local-bridge` 契约 2 / 13)。
|
|
21
24
|
*/
|
|
22
25
|
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
23
26
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -84,6 +87,7 @@ export class PairingManager {
|
|
|
84
87
|
lastPrompt = new Map();
|
|
85
88
|
store;
|
|
86
89
|
now;
|
|
90
|
+
issueCodes;
|
|
87
91
|
onCodeIssued;
|
|
88
92
|
onLocked;
|
|
89
93
|
onProblem;
|
|
@@ -91,13 +95,16 @@ export class PairingManager {
|
|
|
91
95
|
constructor(options) {
|
|
92
96
|
this.store = options.store;
|
|
93
97
|
this.now = options.now ?? Date.now;
|
|
98
|
+
this.issueCodes = options.issueCodes ?? true;
|
|
94
99
|
this.onCodeIssued = options.onCodeIssued;
|
|
95
100
|
this.onLocked = options.onLocked ?? (() => undefined);
|
|
96
101
|
this.onProblem = options.onProblem ?? (() => undefined);
|
|
97
102
|
this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
|
|
98
103
|
}
|
|
99
|
-
/**
|
|
104
|
+
/** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式;后台模式不出码,返回 null */
|
|
100
105
|
issueCode(reason, origin = null) {
|
|
106
|
+
if (!this.issueCodes)
|
|
107
|
+
return null;
|
|
101
108
|
let code = '';
|
|
102
109
|
for (let i = 0; i < CODE_LENGTH; i++)
|
|
103
110
|
code += ALPHABET[randomInt(ALPHABET.length)];
|
|
@@ -118,10 +125,10 @@ export class PairingManager {
|
|
|
118
125
|
* 未配对页面连上时调用。没有有效码就出新码;有有效码也把它连同发起方 Origin 再提示一次 ——
|
|
119
126
|
* 最常见的首次流程是「启动 bridge 后马上打开页面」,终端里只有一个不带 Origin 的启动码,
|
|
120
127
|
* 不再提示的话用户看不到是哪个页面在请求配对。同一 Origin、同一个码一分钟内只提示一次(页面重连不刷屏)。
|
|
121
|
-
*
|
|
128
|
+
* 锁定期间、后台模式下什么都不做。
|
|
122
129
|
*/
|
|
123
130
|
ensureCode(origin) {
|
|
124
|
-
if (this.locked)
|
|
131
|
+
if (this.locked || !this.issueCodes)
|
|
125
132
|
return;
|
|
126
133
|
if (!this.hasValidCode() || !this.active) {
|
|
127
134
|
this.issueCode('requested', origin);
|
|
@@ -141,6 +148,9 @@ export class PairingManager {
|
|
|
141
148
|
* (review r1 实测 1.6 秒 20000 次),还会让陌生页面把用户的配对码一直刷掉。
|
|
142
149
|
*/
|
|
143
150
|
pair(input, origin) {
|
|
151
|
+
// 后台模式没有码可比:在锁定检查之前返回,也不计入错码次数(页面并没有猜码,只是不知道这个 bridge 不出码)
|
|
152
|
+
if (!this.issueCodes)
|
|
153
|
+
return { ok: false, code: 'pairing_code_unavailable' };
|
|
144
154
|
if (this.locked)
|
|
145
155
|
return { ok: false, code: 'pairing_locked' };
|
|
146
156
|
if (!this.active) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 更新清单 `https://m2ui.ai/connect/manifest.json` 的格式、解析与下载地址(change acp-bridge-one-time-install D13 / D14 / R7)。
|
|
3
|
+
*
|
|
4
|
+
* 清单由仓库的 `packages/connect/install/release.json` 生成,随 App 部署发布;部署 App 才是放量开关,发 npm 不等于推给所有用户。
|
|
5
|
+
* 清单**只提供版本号与哈希**:下载地址在这里由版本号拼出,固定指向官方源与 npmmirror。
|
|
6
|
+
* 不要在清单里加 URL 字段 —— 那会让清单(或篡改清单的人)把下载指到任意地方,信任根就不止 m2ui.ai 加哈希了。
|
|
7
|
+
*
|
|
8
|
+
* 跨版本兼容:已安装的旧版本 bridge 会读新部署的清单(`--check-update` 跑的是当前版本的代码)。
|
|
9
|
+
* - 同一 `schema` 内只加字段;本解析器忽略不认识的字段。
|
|
10
|
+
* - 要改语义就升 `schema`;旧版本认不出新 schema 时按「读清单失败」处理,用当前版本继续(不会误装)。
|
|
11
|
+
* - 版本号与哈希一律严格校验格式:它们会进入文件路径与下载地址。
|
|
12
|
+
*/
|
|
13
|
+
export const MANIFEST_URL = 'https://m2ui.ai/connect/manifest.json';
|
|
14
|
+
export const MANIFEST_SCHEMA = 1;
|
|
15
|
+
/** 只认纯 x.y.z(不接受预发布、构建元数据):版本号会拼进目录名与下载地址 */
|
|
16
|
+
const VERSION_RE = /^\d{1,4}\.\d{1,4}\.\d{1,6}$/;
|
|
17
|
+
const SHA512_RE = /^[0-9a-f]{128}$/;
|
|
18
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
19
|
+
export function isReleaseVersion(value) {
|
|
20
|
+
return typeof value === 'string' && VERSION_RE.test(value);
|
|
21
|
+
}
|
|
22
|
+
function record(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
24
|
+
}
|
|
25
|
+
function pinned(value, hash) {
|
|
26
|
+
const entry = record(value);
|
|
27
|
+
if (!entry || !isReleaseVersion(entry.version) || typeof entry.sha512 !== 'string' || !hash.test(entry.sha512))
|
|
28
|
+
return null;
|
|
29
|
+
return { version: entry.version, sha512: entry.sha512 };
|
|
30
|
+
}
|
|
31
|
+
/** 解析清单正文;格式不对时返回原因(英文,写进 update.log) */
|
|
32
|
+
export function parseReleaseManifest(text) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 'manifest is not valid JSON';
|
|
39
|
+
}
|
|
40
|
+
const root = record(parsed);
|
|
41
|
+
if (!root)
|
|
42
|
+
return 'manifest is not a JSON object';
|
|
43
|
+
if (root.schema !== MANIFEST_SCHEMA)
|
|
44
|
+
return `unsupported manifest schema ${JSON.stringify(root.schema)}`;
|
|
45
|
+
const connect = pinned(root.connect, SHA512_RE);
|
|
46
|
+
if (!connect)
|
|
47
|
+
return 'manifest has no valid connect version / sha512';
|
|
48
|
+
const ws = pinned(root.ws, SHA512_RE);
|
|
49
|
+
if (!ws)
|
|
50
|
+
return 'manifest has no valid ws version / sha512';
|
|
51
|
+
const node = record(root.node);
|
|
52
|
+
const sha256 = record(node?.sha256);
|
|
53
|
+
const arm64 = sha256?.arm64;
|
|
54
|
+
const x64 = sha256?.x64;
|
|
55
|
+
if (!node || !isReleaseVersion(node.version) || typeof arm64 !== 'string' || !SHA256_RE.test(arm64) || typeof x64 !== 'string' || !SHA256_RE.test(x64)) {
|
|
56
|
+
return 'manifest has no valid node version / sha256';
|
|
57
|
+
}
|
|
58
|
+
return { schema: MANIFEST_SCHEMA, connect, ws, node: { version: node.version, sha256: { arm64, x64 } } };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 下载源:官方优先,失败(含校验不符)再退 npmmirror(D14)。顺序有意义,不要打乱。
|
|
62
|
+
* 安装脚本的生成块用的是同一组基址(src/install/assets.ts),两边只此一处。
|
|
63
|
+
*/
|
|
64
|
+
export const NPM_REGISTRY_BASES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com'];
|
|
65
|
+
export const NODE_DIST_BASES = ['https://nodejs.org/dist', 'https://npmmirror.com/mirrors/node'];
|
|
66
|
+
export function connectTarballUrls(version) {
|
|
67
|
+
return NPM_REGISTRY_BASES.map((base) => `${base}/@m2canvas/connect/-/connect-${version}.tgz`);
|
|
68
|
+
}
|
|
69
|
+
export function wsTarballUrls(version) {
|
|
70
|
+
return NPM_REGISTRY_BASES.map((base) => `${base}/ws/-/ws-${version}.tgz`);
|
|
71
|
+
}
|
|
72
|
+
/** 私有 Node 的目录名,也是官方包解开后的顶层目录名 */
|
|
73
|
+
export function nodeDirName(version, arch) {
|
|
74
|
+
return `node-v${version}-darwin-${arch}`;
|
|
75
|
+
}
|
|
76
|
+
/** `.tar.xz`:只有 `.tar.gz` 的一半大小,macOS 自带的 bsdtar 能解(R9) */
|
|
77
|
+
export function nodeTarballUrls(version, arch) {
|
|
78
|
+
const file = `${nodeDirName(version, arch)}.tar.xz`;
|
|
79
|
+
return NODE_DIST_BASES.map((base) => `${base}/v${version}/${file}`);
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bridge --self-test`:自动更新时对新版本的冒烟测试(change acp-bridge-one-time-install D20 ④、N4)。
|
|
3
|
+
*
|
|
4
|
+
* 当前版本的 `--check-update` 用新版本自己的 Node 运行新版本的 cli.js。能走到这里,说明 cli.js → bridge/main.js
|
|
5
|
+
* 及其全部静态依赖(含 ws)都加载成功;这里再核对几种「能加载、但装得不全」的情况,任何一项不满足就退出 1,
|
|
6
|
+
* 更新流程就不会切换到这个版本。
|
|
7
|
+
*
|
|
8
|
+
* 不监听端口、不写文件、不联网:它在旧版本 bridge 还在运行时执行。
|
|
9
|
+
* 检测不到「起来了但功能坏了」(例如协议处理有 bug):那种情况靠改清单回滚(`connect/local-bridge` 契约 16 delta)。
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { ADAPTERS } from './adapters.js';
|
|
14
|
+
import { bundledNpmCli } from './npm-command.js';
|
|
15
|
+
/** 返回发现的问题(英文,写进 update.log);空数组 = 通过 */
|
|
16
|
+
export function selfTestProblems(input) {
|
|
17
|
+
const exists = input.exists ?? existsSync;
|
|
18
|
+
const problems = [];
|
|
19
|
+
if (!/^\d+\.\d+\.\d+/.test(input.version) || input.version === '0.0.0')
|
|
20
|
+
problems.push(`unreadable package version (${input.version})`);
|
|
21
|
+
for (const spec of Object.values(ADAPTERS)) {
|
|
22
|
+
const lock = join(input.packageRoot, 'adapters', spec.dir, 'package-lock.json');
|
|
23
|
+
if (!exists(lock))
|
|
24
|
+
problems.push(`missing ${lock}`);
|
|
25
|
+
}
|
|
26
|
+
const npm = bundledNpmCli(input.execPath);
|
|
27
|
+
if (!exists(npm))
|
|
28
|
+
problems.push(`missing ${npm}`);
|
|
29
|
+
return problems;
|
|
30
|
+
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { TOKEN_IDLE_DAYS } from './pairing.js';
|
|
|
22
22
|
import { ProcessError, ProcessManager } from './processes.js';
|
|
23
23
|
import { isBridgeAgentId } from './agents.js';
|
|
24
24
|
import { handleAuthCall } from './auth-call.js';
|
|
25
|
+
import { handlePairCall } from './pair-call.js';
|
|
25
26
|
import { AuthorizationManager } from './authorize.js';
|
|
26
27
|
import { BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
|
|
27
28
|
import { checkPageAcpLine } from './acp-guard.js';
|
|
@@ -105,20 +106,10 @@ export function startBridgeServer(options) {
|
|
|
105
106
|
};
|
|
106
107
|
async function handleCall(conn, id, method, params) {
|
|
107
108
|
if (method === 'pair') {
|
|
108
|
-
const
|
|
109
|
-
if (typeof code !== 'string')
|
|
110
|
-
throw new BridgeFailure('bad_message', 'pair needs a string "code"');
|
|
111
|
-
const result = options.pairing.pair(code, conn.origin);
|
|
112
|
-
if (!result.ok) {
|
|
113
|
-
if (result.code === 'pairing_invalid')
|
|
114
|
-
throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
|
|
115
|
-
if (result.code === 'pairing_locked')
|
|
116
|
-
throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
|
|
117
|
-
throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
|
|
118
|
-
}
|
|
109
|
+
const token = handlePairCall(options.pairing, conn.origin, params);
|
|
119
110
|
markPaired(conn);
|
|
120
111
|
log(`Paired with ${conn.origin}.`);
|
|
121
|
-
return { token
|
|
112
|
+
return { token, cwd: options.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
|
|
122
113
|
}
|
|
123
114
|
if (!conn.paired)
|
|
124
115
|
throw new BridgeFailure('not_paired', 'pair this page first');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bridge --check-update` 的真实 IO 与命令入口(change acp-bridge-one-time-install D20、R7)。更新的判断都在 update.ts,这里只做外部调用:
|
|
3
|
+
* - 读清单、下载:一律 `/usr/bin/curl`。安装脚本也用 curl,两边代理行为相同 —— curl 读 HTTPS_PROXY / ALL_PROXY / NO_PROXY 与
|
|
4
|
+
* `~/.curlrc`,Node 的 fetch 默认不读代理变量。`-f`:HTTP 错误码当失败,不把错误页写成「下载好的文件」。
|
|
5
|
+
* - 解压:`/usr/bin/tar`(macOS 的 bsdtar 自带 xz;内容已按清单哈希校验过)。
|
|
6
|
+
* - 冒烟:用新版本自己的 Node 跑新版本的 `cli.js bridge --self-test`。
|
|
7
|
+
* 子进程环境由调用方给:生产用 env.ts 的白名单(代理与证书变量都在里面),不把宿主注入的变量带给 curl。
|
|
8
|
+
*
|
|
9
|
+
* 不提供清单地址、下载地址的覆盖参数或环境变量(design R8):测试经 UpdateDeps 注入,或直接对本模块的原语传本机地址。
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
12
|
+
import { home } from '../system.js';
|
|
13
|
+
import { buildAgentEnv } from './env.js';
|
|
14
|
+
import { combinedOutput, execCommand } from './exec.js';
|
|
15
|
+
import { installLayout } from './install-layout.js';
|
|
16
|
+
import { tail } from './precheck-parse.js';
|
|
17
|
+
import { checkForUpdate } from './update.js';
|
|
18
|
+
const CURL = '/usr/bin/curl';
|
|
19
|
+
const TAR = '/usr/bin/tar';
|
|
20
|
+
/** 单个文件的下载上限:Node 包约 27MB,慢网(约 100KB/s)也要下得完 */
|
|
21
|
+
const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
const EXTRACT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
23
|
+
/** 冒烟时限(D20 ④) */
|
|
24
|
+
const SELF_TEST_TIMEOUT_MS = 20_000;
|
|
25
|
+
/** update.log 超过这个大小先轮转成 `.1`(每次启动只写几行,很少触发) */
|
|
26
|
+
const UPDATE_LOG_MAX_BYTES = 1024 * 1024;
|
|
27
|
+
function failure(what, result) {
|
|
28
|
+
const reason = result.timedOut ? 'timed out' : result.code === null ? (result.error ?? 'did not start') : `exited ${result.code}`;
|
|
29
|
+
const output = tail(combinedOutput(result).trim());
|
|
30
|
+
return new Error(`${what} ${reason}${output ? `: ${output}` : ''}`);
|
|
31
|
+
}
|
|
32
|
+
export function createUpdateIO(options) {
|
|
33
|
+
const { exec, env } = options;
|
|
34
|
+
return {
|
|
35
|
+
async fetchText(url, timeoutMs) {
|
|
36
|
+
const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
|
|
37
|
+
const result = await exec(CURL, ['-fsSL', '--connect-timeout', '5', '--max-time', seconds, url], { env, timeoutMs: timeoutMs + 2_000, maxOutput: 256 * 1024 });
|
|
38
|
+
if (result.code !== 0)
|
|
39
|
+
throw failure('curl', result);
|
|
40
|
+
return result.stdout;
|
|
41
|
+
},
|
|
42
|
+
async download(url, dest) {
|
|
43
|
+
const seconds = String(DOWNLOAD_TIMEOUT_MS / 1000);
|
|
44
|
+
// 低于 10KB/s 持续 30 秒就放弃、换下一个源:官方源只有涓流速度时不能拖到总时限(review r1 R1-S4,与安装脚本相同)
|
|
45
|
+
const result = await exec(CURL, ['-fsSL', '--connect-timeout', '10', '--speed-limit', '10240', '--speed-time', '30', '--max-time', seconds, '-o', dest, url], {
|
|
46
|
+
env,
|
|
47
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS + 5_000,
|
|
48
|
+
maxOutput: 16 * 1024,
|
|
49
|
+
});
|
|
50
|
+
if (result.code !== 0) {
|
|
51
|
+
// curl 失败时可能留下半截或空文件:删掉,免得被当成下载好的内容
|
|
52
|
+
rmSync(dest, { force: true });
|
|
53
|
+
throw failure('curl', result);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async extract(archive, dir) {
|
|
57
|
+
const result = await exec(TAR, ['-xf', archive, '-C', dir], { env, timeoutMs: EXTRACT_TIMEOUT_MS, maxOutput: 16 * 1024 });
|
|
58
|
+
if (result.code !== 0)
|
|
59
|
+
throw failure('tar', result);
|
|
60
|
+
},
|
|
61
|
+
async selfTest(nodePath, cliPath) {
|
|
62
|
+
const result = await exec(nodePath, [cliPath, 'bridge', '--self-test'], { env, timeoutMs: SELF_TEST_TIMEOUT_MS, maxOutput: 16 * 1024 });
|
|
63
|
+
return { ok: result.code === 0, output: result.timedOut ? `self-test timed out after ${SELF_TEST_TIMEOUT_MS / 1000}s` : combinedOutput(result) };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** 追加一行到 `logs/update.log`(目录 0700、文件 0600,与 bridge.log 相同);超过上限先轮转 */
|
|
68
|
+
export function appendUpdateLog(layout, line, maxBytes = UPDATE_LOG_MAX_BYTES) {
|
|
69
|
+
mkdirSync(layout.logs, { recursive: true, mode: 0o700 });
|
|
70
|
+
chmodSync(layout.logs, 0o700);
|
|
71
|
+
if (existsSync(layout.updateLog) && statSync(layout.updateLog).size > maxBytes)
|
|
72
|
+
renameSync(layout.updateLog, `${layout.updateLog}.1`);
|
|
73
|
+
appendFileSync(layout.updateLog, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
74
|
+
chmodSync(layout.updateLog, 0o600);
|
|
75
|
+
}
|
|
76
|
+
function describeOutcome(outcome) {
|
|
77
|
+
switch (outcome.kind) {
|
|
78
|
+
case 'unsupported':
|
|
79
|
+
return 'skipped (only macOS is supported)';
|
|
80
|
+
case 'not_installed':
|
|
81
|
+
return 'skipped (not installed by the M2 installer)';
|
|
82
|
+
case 'manifest_failed':
|
|
83
|
+
return `kept the current version (couldn't read the manifest: ${outcome.reason})`;
|
|
84
|
+
case 'up_to_date':
|
|
85
|
+
return `up to date (${outcome.version})`;
|
|
86
|
+
case 'rejected':
|
|
87
|
+
return `kept the current version (${outcome.version} failed to start before)`;
|
|
88
|
+
case 'failed':
|
|
89
|
+
return `kept the current version (update to ${outcome.version} failed: ${outcome.reason})`;
|
|
90
|
+
case 'updated':
|
|
91
|
+
return `updated ${outcome.from} -> ${outcome.to}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 命令入口(由启动器调用)。日志写进 update.log;标准输出只打一行摘要(启动器把它追加进 bridge.log)。
|
|
96
|
+
* 退出码只供排障:启动器不论成败都用 current 继续,是否刚切换过版本由它自己比较 current 的前后指向得出。
|
|
97
|
+
*/
|
|
98
|
+
export async function runCheckUpdate() {
|
|
99
|
+
const layout = installLayout(home);
|
|
100
|
+
const io = createUpdateIO({ exec: execCommand, env: buildAgentEnv() });
|
|
101
|
+
const outcome = await checkForUpdate({
|
|
102
|
+
layout,
|
|
103
|
+
platform: process.platform,
|
|
104
|
+
arch: process.arch,
|
|
105
|
+
...io,
|
|
106
|
+
log: (line) => {
|
|
107
|
+
try {
|
|
108
|
+
appendUpdateLog(layout, line);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// 日志写不进(磁盘满、权限)不能拦住更新判断;摘要仍会打到标准输出
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
now: Date.now,
|
|
115
|
+
});
|
|
116
|
+
console.log(`M2 bridge update: ${describeOutcome(outcome)}`);
|
|
117
|
+
return outcome.kind === 'failed' || outcome.kind === 'manifest_failed' ? 1 : 0;
|
|
118
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bridge --check-update`:启动器每次拉起 bridge 之前调用(change acp-bridge-one-time-install D9 / D13 / D14 / D20)。
|
|
3
|
+
* 运行它的是**当前版本**的代码;它把新版本下载、校验、冒烟后切成 current,下一步由启动器用新版本拉起。
|
|
4
|
+
*
|
|
5
|
+
* 不变量(`connect/local-bridge` 契约 16 delta):
|
|
6
|
+
* - 只更新到 m2ui.ai 清单指定的版本(清单版本比当前低也照做,这就是回滚),不跟随 npm latest;
|
|
7
|
+
* - 清单只给版本与哈希,下载地址由 release-manifest.ts 固定拼出;任何下载内容与清单哈希不符都换下一个源,全部不符就放弃;
|
|
8
|
+
* - 读清单失败、超时、格式认不出:什么都不改,用当前版本继续;
|
|
9
|
+
* - 新版本先冒烟(`--self-test`),通过才切换;切换用「先建临时软链再改名」,任何时刻 current 都指向一个完整的版本;
|
|
10
|
+
* - 「新版本拉起后没起来」的回退不在这里:新版本可能连模块都加载不了,回退判定在很少改动的启动器里(契约 16 delta);
|
|
11
|
+
* - 不加清单地址、下载地址的覆盖开关:那会让「只读 m2ui.ai 清单」多出一条旁路(design R8)。测试经 UpdateDeps 注入下载。
|
|
12
|
+
*
|
|
13
|
+
* 并发(design N1):不加锁。所有写入先写临时目录 / 临时软链再改名;两个更新同时跑时各自下到自己的临时目录,
|
|
14
|
+
* 后完成的发现版本目录已完整就丢掉自己的,发现 current 已经切好就不再切。
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { basename, dirname, join } from 'node:path';
|
|
19
|
+
import { ADAPTERS, ADAPTER_READY_MARKER, adapterInstallDirName } from './adapters.js';
|
|
20
|
+
import { NODE_PATH_FILE, connectCliPath, connectPackageDir } from './install-layout.js';
|
|
21
|
+
import { readInstanceRecord } from './instance.js';
|
|
22
|
+
import { tail } from './precheck-parse.js';
|
|
23
|
+
import { MANIFEST_URL, connectTarballUrls, isReleaseVersion, nodeDirName, nodeTarballUrls, parseReleaseManifest, wsTarballUrls, } from './release-manifest.js';
|
|
24
|
+
/** 读清单的总时限(不含下载):读不到就用当前版本继续,不能拖住启动(D20 ①) */
|
|
25
|
+
export const MANIFEST_TIMEOUT_MS = 10_000;
|
|
26
|
+
/** 临时目录超过这个时长就当作被杀掉的更新留下的残留(正在进行的更新不会这么久) */
|
|
27
|
+
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
28
|
+
const NODE_DIR_RE = /^node-v\d+\.\d+\.\d+-darwin-(arm64|x64)$/;
|
|
29
|
+
class UpdateError extends Error {
|
|
30
|
+
}
|
|
31
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
32
|
+
const tempName = (label) => `.tmp-${label}-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
33
|
+
/** current / previous 软链指向的版本号;软链不在、内容不是版本号时为 null */
|
|
34
|
+
function readVersionLink(path) {
|
|
35
|
+
try {
|
|
36
|
+
const target = readlinkSync(path);
|
|
37
|
+
return isReleaseVersion(target) ? target : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 启动器写入的拉黑名单:每行一个版本号,其他内容忽略 */
|
|
44
|
+
export function readRejected(path) {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(path, 'utf8')
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.trim())
|
|
49
|
+
.filter(isReleaseVersion);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function readNodePath(versionDir) {
|
|
56
|
+
try {
|
|
57
|
+
const value = readFileSync(join(versionDir, NODE_PATH_FILE), 'utf8').trim();
|
|
58
|
+
return value || null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** 版本目录是否完整:node-path、connect 的 cli.js、ws 都在 */
|
|
65
|
+
function isCompleteVersion(dir) {
|
|
66
|
+
return readNodePath(dir) !== null && existsSync(connectCliPath(dir)) && existsSync(join(dir, 'node_modules', 'ws', 'package.json'));
|
|
67
|
+
}
|
|
68
|
+
/** 原子替换软链:先建临时软链再 rename 覆盖 */
|
|
69
|
+
function swapLink(link, target) {
|
|
70
|
+
const tmp = `${link}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
71
|
+
symlinkSync(target, tmp);
|
|
72
|
+
renameSync(tmp, link);
|
|
73
|
+
}
|
|
74
|
+
/** 原子写小文件:先写临时文件再 rename */
|
|
75
|
+
function writeFileAtomic(path, content) {
|
|
76
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
77
|
+
writeFileSync(tmp, content);
|
|
78
|
+
renameSync(tmp, path);
|
|
79
|
+
}
|
|
80
|
+
function fileHash(algorithm, path) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const hash = createHash(algorithm);
|
|
83
|
+
createReadStream(path)
|
|
84
|
+
.on('error', reject)
|
|
85
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
86
|
+
.on('end', () => resolve(hash.digest('hex')));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** 依次试各个源(官方在前),下载失败或哈希不符都换下一个;全部不行就删掉 dest 并抛错 */
|
|
90
|
+
async function fetchVerified(deps, urls, algorithm, expected, dest) {
|
|
91
|
+
const problems = [];
|
|
92
|
+
for (const url of urls) {
|
|
93
|
+
try {
|
|
94
|
+
await deps.download(url, dest);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
problems.push(`download ${url} failed: ${message(error)}`);
|
|
98
|
+
deps.log(problems[problems.length - 1] ?? '');
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if ((await fileHash(algorithm, dest)) === expected) {
|
|
102
|
+
deps.log(`downloaded ${url} (${algorithm} ok)`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
problems.push(`${algorithm} mismatch for ${url}`);
|
|
106
|
+
deps.log(problems[problems.length - 1] ?? '');
|
|
107
|
+
}
|
|
108
|
+
rmSync(dest, { force: true });
|
|
109
|
+
throw new UpdateError(problems.join('; '));
|
|
110
|
+
}
|
|
111
|
+
/** 清单要求的 Node 已在就直接用;否则下载、校验、解到临时目录再改名(D8 / R9) */
|
|
112
|
+
async function ensureNode(deps, manifest, arch) {
|
|
113
|
+
const name = nodeDirName(manifest.node.version, arch);
|
|
114
|
+
const nodeDir = join(deps.layout.runtime, name);
|
|
115
|
+
const nodeBin = join(nodeDir, 'bin', 'node');
|
|
116
|
+
if (existsSync(nodeBin))
|
|
117
|
+
return nodeBin;
|
|
118
|
+
mkdirSync(deps.layout.runtime, { recursive: true });
|
|
119
|
+
if (existsSync(nodeDir)) {
|
|
120
|
+
// 残缺的旧目录(上次解到一半被打断):挪开再装,不然改名会一直失败(review r1 R1-N3);挪开的由之后的清理删除
|
|
121
|
+
renameSync(nodeDir, join(deps.layout.runtime, tempName('old-node')));
|
|
122
|
+
deps.log(`moved an incomplete ${name} aside`);
|
|
123
|
+
}
|
|
124
|
+
const tmp = join(deps.layout.runtime, tempName('node'));
|
|
125
|
+
mkdirSync(tmp);
|
|
126
|
+
try {
|
|
127
|
+
const archive = join(tmp, `${name}.tar.xz`);
|
|
128
|
+
await fetchVerified(deps, nodeTarballUrls(manifest.node.version, arch), 'sha256', manifest.node.sha256[arch], archive);
|
|
129
|
+
await deps.extract(archive, tmp);
|
|
130
|
+
if (!existsSync(join(tmp, name, 'bin', 'node')))
|
|
131
|
+
throw new UpdateError(`${name}.tar.xz has no bin/node`);
|
|
132
|
+
// 别的更新抢先装好了同一份 Node:用它的
|
|
133
|
+
if (!existsSync(nodeBin))
|
|
134
|
+
renameSync(join(tmp, name), nodeDir);
|
|
135
|
+
deps.log(`installed Node.js ${manifest.node.version} (${arch})`);
|
|
136
|
+
return nodeBin;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 解开 npm tarball(顶层目录 `package/`)并改名到 dest */
|
|
143
|
+
async function unpackNpm(deps, archive, workDir, dest) {
|
|
144
|
+
const unpacked = join(workDir, `${basename(archive)}.d`);
|
|
145
|
+
mkdirSync(unpacked);
|
|
146
|
+
await deps.extract(archive, unpacked);
|
|
147
|
+
if (!existsSync(join(unpacked, 'package', 'package.json')))
|
|
148
|
+
throw new UpdateError(`${basename(archive)} has no package/package.json`);
|
|
149
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
150
|
+
renameSync(join(unpacked, 'package'), dest);
|
|
151
|
+
rmSync(unpacked, { recursive: true, force: true });
|
|
152
|
+
rmSync(archive, { force: true });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 准备目标版本目录:已完整存在(例如回滚到 previous)就复用,只把 node-path 对齐到清单的 Node;
|
|
156
|
+
* 否则在 `bridge/.tmp-*` 里组装(connect 与 ws 两个 tarball 就是完整的 bridge,D13),返回临时目录,由调用方冒烟后改名。
|
|
157
|
+
*/
|
|
158
|
+
async function prepareVersion(deps, manifest, nodeBin) {
|
|
159
|
+
const versionDir = join(deps.layout.bridge, manifest.connect.version);
|
|
160
|
+
if (isCompleteVersion(versionDir)) {
|
|
161
|
+
if (readNodePath(versionDir) !== nodeBin)
|
|
162
|
+
writeFileAtomic(join(versionDir, NODE_PATH_FILE), `${nodeBin}\n`);
|
|
163
|
+
deps.log(`reusing ${versionDir}`);
|
|
164
|
+
return { dir: versionDir, fresh: false };
|
|
165
|
+
}
|
|
166
|
+
const tmp = join(deps.layout.bridge, tempName(manifest.connect.version));
|
|
167
|
+
mkdirSync(tmp, { recursive: true });
|
|
168
|
+
try {
|
|
169
|
+
const connectArchive = join(tmp, 'connect.tgz');
|
|
170
|
+
await fetchVerified(deps, connectTarballUrls(manifest.connect.version), 'sha512', manifest.connect.sha512, connectArchive);
|
|
171
|
+
await unpackNpm(deps, connectArchive, tmp, connectPackageDir(tmp));
|
|
172
|
+
const wsArchive = join(tmp, 'ws.tgz');
|
|
173
|
+
await fetchVerified(deps, wsTarballUrls(manifest.ws.version), 'sha512', manifest.ws.sha512, wsArchive);
|
|
174
|
+
await unpackNpm(deps, wsArchive, tmp, join(tmp, 'node_modules', 'ws'));
|
|
175
|
+
writeFileSync(join(tmp, NODE_PATH_FILE), `${nodeBin}\n`);
|
|
176
|
+
return { dir: tmp, fresh: true };
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** 冒烟通过的临时目录改名为版本目录;版本目录在这期间被别的更新装好了就丢掉自己的 */
|
|
184
|
+
function commitVersion(deps, tmp, version) {
|
|
185
|
+
const versionDir = join(deps.layout.bridge, version);
|
|
186
|
+
if (isCompleteVersion(versionDir)) {
|
|
187
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (existsSync(versionDir)) {
|
|
191
|
+
// 不完整的残留(被杀掉的旧更新):先挪开再删,保证改名那一刻目标不存在
|
|
192
|
+
const aside = join(deps.layout.bridge, tempName('old'));
|
|
193
|
+
renameSync(versionDir, aside);
|
|
194
|
+
rmSync(aside, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
renameSync(tmp, versionDir);
|
|
197
|
+
}
|
|
198
|
+
function isStaleTemp(path, now) {
|
|
199
|
+
try {
|
|
200
|
+
return now - statSync(path).mtimeMs > STALE_TEMP_MS;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function defaultIsAlive(pid) {
|
|
207
|
+
try {
|
|
208
|
+
process.kill(pid, 0);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
// EPERM:进程在,只是不属于当前用户 —— 也算在运行
|
|
213
|
+
return error.code === 'EPERM';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** 状态文件里还活着的 bridge 的 pid(任意端口、任意版本,含终端里运行的 npx bridge) */
|
|
217
|
+
function runningBridgePid(deps) {
|
|
218
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
219
|
+
const names = existsSync(deps.layout.root) ? readdirSync(deps.layout.root) : [];
|
|
220
|
+
for (const name of names) {
|
|
221
|
+
if (!/^bridge-\d+\.json$/.test(name))
|
|
222
|
+
continue;
|
|
223
|
+
const record = readInstanceRecord(join(deps.layout.root, name));
|
|
224
|
+
if (record && isAlive(record.pid))
|
|
225
|
+
return record.pid;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const listDir = (dir) => (existsSync(dir) ? readdirSync(dir) : []);
|
|
230
|
+
function removePath(deps, path, what) {
|
|
231
|
+
try {
|
|
232
|
+
rmSync(path, { recursive: true, force: true });
|
|
233
|
+
deps.log(`removed ${what}`);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
deps.log(`couldn't remove ${what}: ${message(error)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 过期的 `bridge/.tmp-*`、`runtime/.tmp-*`:被打断的安装 / 更新留下的半截下载,以及挪开的残缺目录(review r2 R2-N4)。
|
|
241
|
+
* 每次检查都清(启动器每次启动都会调用),不等版本切换:清单版本不变时它们会一直留着,慢网下每被 agent 打断一次就多一份。
|
|
242
|
+
* 不受「有 bridge 在运行」的约束:它们不属于任何在用的版本;正在写入的临时目录是新近的(下载一次最多 10 分钟),过不了 1 小时的门槛。
|
|
243
|
+
*/
|
|
244
|
+
function removeStaleTemps(deps) {
|
|
245
|
+
const now = deps.now();
|
|
246
|
+
for (const dir of [deps.layout.bridge, deps.layout.runtime]) {
|
|
247
|
+
for (const name of listDir(dir)) {
|
|
248
|
+
const path = join(dir, name);
|
|
249
|
+
if (name.startsWith('.tmp-') && isStaleTemp(path, now))
|
|
250
|
+
removePath(deps, path, `stale ${name}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* 只保留两个版本与它们用到的 Node 和适配器目录(D20「清理」、N2)。清理失败只记日志,不影响这次更新的结果。
|
|
256
|
+
* 有 bridge 在运行时整段跳过(review r1 R1-S3):它可能是更早的版本(bridge 运行期间启动器被调用、中间跨了两次清单更新),
|
|
257
|
+
* 它用的 Node、npm 与组件目录都不能删;之后某次启动时没有 bridge 在运行,再清。过期临时目录另由 `removeStaleTemps` 每次清。
|
|
258
|
+
*/
|
|
259
|
+
function cleanup(deps, keep) {
|
|
260
|
+
const { layout } = deps;
|
|
261
|
+
const running = runningBridgePid(deps);
|
|
262
|
+
if (running !== null) {
|
|
263
|
+
deps.log(`skipped cleanup: an M2 bridge is running (pid ${running})`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const remove = (path, what) => removePath(deps, path, what);
|
|
267
|
+
for (const name of listDir(layout.bridge)) {
|
|
268
|
+
if (isReleaseVersion(name) && !keep.includes(name))
|
|
269
|
+
remove(join(layout.bridge, name), `bridge ${name}`);
|
|
270
|
+
}
|
|
271
|
+
const usedNode = new Set(keep.map((version) => readNodePath(join(layout.bridge, version))).flatMap((node) => (node ? [basename(dirname(dirname(node)))] : [])));
|
|
272
|
+
for (const name of listDir(layout.runtime)) {
|
|
273
|
+
if (NODE_DIR_RE.test(name) && !usedNode.has(name))
|
|
274
|
+
remove(join(layout.runtime, name), `runtime ${name}`);
|
|
275
|
+
}
|
|
276
|
+
// 适配器目录名由各版本自带的 lockfile 决定;任何一个保留版本的 lockfile 读不出,就整段跳过(宁可多留几百 MB,不能删掉在用的)
|
|
277
|
+
const keepAdapters = new Set();
|
|
278
|
+
try {
|
|
279
|
+
for (const version of keep) {
|
|
280
|
+
const sourceRoot = join(connectPackageDir(join(layout.bridge, version)), 'adapters');
|
|
281
|
+
for (const spec of Object.values(ADAPTERS))
|
|
282
|
+
keepAdapters.add(adapterInstallDirName(sourceRoot, spec.dir));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
deps.log(`skipped adapter cleanup: ${message(error)}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const patterns = Object.values(ADAPTERS).map((spec) => new RegExp(`^${spec.dir}-[0-9a-f]{16}$`));
|
|
290
|
+
for (const name of listDir(layout.adapters)) {
|
|
291
|
+
if (!patterns.some((pattern) => pattern.test(name)) || keepAdapters.has(name))
|
|
292
|
+
continue;
|
|
293
|
+
if (existsSync(join(layout.adapters, name, ADAPTER_READY_MARKER)))
|
|
294
|
+
remove(join(layout.adapters, name), `adapter ${name}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export async function checkForUpdate(deps) {
|
|
298
|
+
const arch = deps.arch === 'arm64' || deps.arch === 'x64' ? deps.arch : null;
|
|
299
|
+
if (deps.platform !== 'darwin' || !arch)
|
|
300
|
+
return { kind: 'unsupported' };
|
|
301
|
+
const installed = readVersionLink(deps.layout.current);
|
|
302
|
+
if (!installed)
|
|
303
|
+
return { kind: 'not_installed' };
|
|
304
|
+
try {
|
|
305
|
+
removeStaleTemps(deps);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
deps.log(`stale temp cleanup stopped early: ${message(error)}`);
|
|
309
|
+
}
|
|
310
|
+
let manifest;
|
|
311
|
+
try {
|
|
312
|
+
const parsed = parseReleaseManifest(await deps.fetchText(MANIFEST_URL, MANIFEST_TIMEOUT_MS));
|
|
313
|
+
if (typeof parsed === 'string')
|
|
314
|
+
throw new UpdateError(parsed);
|
|
315
|
+
manifest = parsed;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
const reason = message(error);
|
|
319
|
+
deps.log(`manifest unavailable (${reason}); staying on ${installed}`);
|
|
320
|
+
return { kind: 'manifest_failed', reason };
|
|
321
|
+
}
|
|
322
|
+
const target = manifest.connect.version;
|
|
323
|
+
deps.log(`manifest says ${target}; installed ${installed}`);
|
|
324
|
+
if (target === installed)
|
|
325
|
+
return { kind: 'up_to_date', version: target };
|
|
326
|
+
if (readRejected(deps.layout.rejected).includes(target)) {
|
|
327
|
+
deps.log(`skipping ${target}: it failed to start before (listed in rejected)`);
|
|
328
|
+
return { kind: 'rejected', version: target };
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const nodeBin = await ensureNode(deps, manifest, arch);
|
|
332
|
+
const candidate = await prepareVersion(deps, manifest, nodeBin);
|
|
333
|
+
const test = await deps.selfTest(nodeBin, connectCliPath(candidate.dir));
|
|
334
|
+
if (!test.ok) {
|
|
335
|
+
if (candidate.fresh)
|
|
336
|
+
rmSync(candidate.dir, { recursive: true, force: true });
|
|
337
|
+
throw new UpdateError(`self-test failed: ${tail(test.output.trim())}`);
|
|
338
|
+
}
|
|
339
|
+
deps.log(`self-test ok for ${target}`);
|
|
340
|
+
if (candidate.fresh)
|
|
341
|
+
commitVersion(deps, candidate.dir, target);
|
|
342
|
+
const before = readVersionLink(deps.layout.current);
|
|
343
|
+
if (before === target) {
|
|
344
|
+
deps.log(`${target} is already current (another update switched it)`);
|
|
345
|
+
return { kind: 'up_to_date', version: target };
|
|
346
|
+
}
|
|
347
|
+
if (before)
|
|
348
|
+
swapLink(deps.layout.previous, before);
|
|
349
|
+
swapLink(deps.layout.current, target);
|
|
350
|
+
deps.log(`switched current ${before ?? '(none)'} -> ${target}`);
|
|
351
|
+
try {
|
|
352
|
+
cleanup(deps, before ? [target, before] : [target]);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
// 已经切换完成:清理出错只是少删了东西,不能把这次更新报成失败(review r1 R1-N4)
|
|
356
|
+
deps.log(`cleanup stopped early: ${message(error)}`);
|
|
357
|
+
}
|
|
358
|
+
return { kind: 'updated', from: before ?? installed, to: target };
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
const reason = message(error);
|
|
362
|
+
deps.log(`update to ${target} failed (${reason}); staying on ${installed}`);
|
|
363
|
+
return { kind: 'failed', version: target, reason };
|
|
364
|
+
}
|
|
365
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|