@m2canvas/connect 0.8.0 → 0.10.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 +6 -5
- package/dist/bridge/call-dispatch.js +2 -1
- package/dist/bridge/main.js +14 -1
- package/dist/bridge/precheck-cache.js +144 -0
- package/dist/bridge/precheck.js +33 -3
- package/dist/bridge/release-manifest.js +4 -3
- package/dist/bridge/security.js +7 -6
- package/dist/bridge/server.js +1 -0
- package/dist/bridge/service.js +31 -3
- package/dist/bridge/uninstall.js +1 -1
- package/dist/bridge/update.js +2 -2
- package/dist/clients.js +16 -4
- package/dist/config-files.js +48 -5
- package/dist/site-config.js +28 -0
- package/install/uninstall.sh +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @m2canvas/connect
|
|
2
2
|
|
|
3
|
-
Connect the agent clients on your machine to [M2 Canvas](https://
|
|
3
|
+
Connect the agent clients on your machine to [M2 Canvas](https://patch.design) in one command.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @m2canvas/connect
|
|
@@ -34,12 +34,13 @@ npx -y @m2canvas/connect@latest bridge
|
|
|
34
34
|
|
|
35
35
|
Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Run it in the project directory you want the agents to work in, and keep the window open. Copy the command from M2's Local Agent tab: it adds `--pair <value>` so that page pairs by itself.
|
|
36
36
|
|
|
37
|
-
- **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://
|
|
37
|
+
- **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://patch.design`. Other sites, other machines and other hosts are refused before a connection is made. (`--dev` also accepts a local M2 dev server on port 5173; any app you serve on that port could then ask to pair, so only use it while developing M2.)
|
|
38
38
|
- **Pairing gives the page your agents.** A paired page can make these agents read and change files and run commands as you, the same as typing into them yourself. Only run commands you copied from M2, and only enter the code into M2.
|
|
39
39
|
- **Pairing.** When you start the bridge with the command M2 gives you, the page that gave it pairs automatically within 10 minutes. The command only carries a hash (`--pair`); the secret it matches stays in that browser tab, so the command is safe to appear in your shell history, and it works once. Any other page asks for the pairing code printed in this window; the window also shows which site is asking. After that the page remembers the pairing for 30 days of inactivity. After too many wrong codes, new pages can't pair until you restart the bridge (pages already paired keep working); close the page the window names first. `bridge --unpair` forgets all paired pages. If you can't find this window, run the command M2 shows under the code box: it adds `--replace`, which stops the M2 bridge already running on the port (its agent sessions end) and starts a new one that pairs by itself. It only stops a bridge (0.6.0 or later) it can recognize from a small state file in `~/.m2canvas` (process id and start time; no codes or tokens are written there), never anything else on the port, and not on Windows. When it can't recognize what holds the port, it shows you that process (or how to find it) and leaves it alone. With an older bridge, M2 shows the Ctrl+C instructions instead.
|
|
40
40
|
- **Your own logins.** Agents use their own sign-in and their own M2 MCP setup. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
|
|
41
41
|
- **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
|
|
42
42
|
- **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (they bundle the agent: about 110 MB and 140 MB to download, 270 MB and 350 MB on disk); M2 shows the download progress. You don't need Claude Code or Codex installed yourself: checking sign-in, adding M2 and signing in all use the bundled `claude` and `codex`, which read the same settings as your own installs.
|
|
43
|
+
- **Checking an agent before use.** Before starting an agent, M2 asks the bridge whether it is signed in and has M2 set up (commands such as `claude auth status` and `claude mcp list`; `claude mcp list` starts the MCP servers you configured in order to check them). From 0.9.0 the bridge runs this check for Claude Code and Codex once when it starts, and remembers a result that passed, so opening another project doesn't wait for it again. It checks again when you click "Check again" in M2 and when M2 adds itself to an agent or signs in for you; when a page uses a remembered result that is more than 5 minutes old, it also checks again in the background and tells M2 if the result no longer holds. There is no periodic check. Results that need you to do something are never remembered, and nothing is written to disk.
|
|
43
44
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
|
44
45
|
|
|
45
46
|
Agent sessions start in that directory.
|
|
@@ -51,14 +52,14 @@ Options: `--port <n>`, `--cwd <dir>` (default: current directory), `--verbose` (
|
|
|
51
52
|
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
|
|
|
53
54
|
```bash
|
|
54
|
-
curl -fsSL https://
|
|
55
|
+
curl -fsSL https://patch.design/connect/install.sh | sh -s -- --pair <value from M2>
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
- **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://
|
|
59
|
+
- **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://patch.design/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
|
|
59
60
|
- **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
61
|
- **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
62
|
- **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 "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://
|
|
63
|
+
- **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. It stops the bridge and removes what the installer added; `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a paired page can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
|
|
63
64
|
|
|
64
65
|
The `npx` command above still works and is what M2 shows under "Advanced", for running the bridge in a folder you choose.
|
|
@@ -38,7 +38,8 @@ export async function handleCall(ctx, conn, id, method, params) {
|
|
|
38
38
|
case 'agent/prepare':
|
|
39
39
|
return { agent, adapter: await ctx.service.prepare(agent) };
|
|
40
40
|
case 'agent/precheck':
|
|
41
|
-
|
|
41
|
+
// 带连接 id:每条连接第一次查这家可用缓存,之后一律真跑(契约 18)
|
|
42
|
+
return ctx.service.precheck(agent, conn.id);
|
|
42
43
|
case 'm2/configure':
|
|
43
44
|
ctx.log(`Page asked to add M2 to ${agent}'s MCP config.`);
|
|
44
45
|
return { agent, ...(await ctx.service.configure(agent)) };
|
package/dist/bridge/main.js
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { home, isExecutable, which } from '../system.js';
|
|
18
|
-
import { AdapterManager, ADAPTERS } from './adapters.js';
|
|
18
|
+
import { AdapterManager, ADAPTERS, isAdapterAgent } from './adapters.js';
|
|
19
19
|
import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
|
|
20
20
|
import { detectAgent } from './agents.js';
|
|
21
21
|
import { EXPECT_PATH } from './authorize.js';
|
|
@@ -29,6 +29,7 @@ import { PairingManager, createFilePairingStore } from './pairing.js';
|
|
|
29
29
|
import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
|
|
30
30
|
import { resolveNpmCommand } from './npm-command.js';
|
|
31
31
|
import { allowedOrigins } from './security.js';
|
|
32
|
+
import { prewarmPrechecks } from './precheck-cache.js';
|
|
32
33
|
import { selfTestProblems } from './self-test.js';
|
|
33
34
|
import { runCheckUpdate } from './update-io.js';
|
|
34
35
|
import { startBridgeServer } from './server.js';
|
|
@@ -36,6 +37,8 @@ import { createAgentService } from './service.js';
|
|
|
36
37
|
import { KILL_GRACE_MS } from './processes.js';
|
|
37
38
|
import { createSelfUninstaller, uninstallScriptPath } from './uninstall.js';
|
|
38
39
|
const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
|
|
40
|
+
/** 启动时预热预检的 agent(契约 18;用户 2026-11-04 拍板 B-P1:随包的两家,不管用没用过) */
|
|
41
|
+
const PREWARM_AGENTS = ['claude', 'codex'];
|
|
39
42
|
const LABELS = {
|
|
40
43
|
claude: 'Claude Code',
|
|
41
44
|
codex: 'Codex',
|
|
@@ -191,6 +194,9 @@ export async function runBridge(argv) {
|
|
|
191
194
|
cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
|
|
192
195
|
platform: process.platform,
|
|
193
196
|
expectAvailable: () => isExecutable(EXPECT_PATH),
|
|
197
|
+
// 出现过「通过」之后一次真跑没通过:告诉之前可能拿到通过的页面(尽力收回,契约 18)
|
|
198
|
+
onPrecheckRevoked: (result) => server?.broadcast('precheck/updated', result),
|
|
199
|
+
log,
|
|
194
200
|
});
|
|
195
201
|
const version = packageVersion();
|
|
196
202
|
const origins = allowedOrigins(parsed.dev);
|
|
@@ -330,6 +336,13 @@ export async function runBridge(argv) {
|
|
|
330
336
|
log,
|
|
331
337
|
});
|
|
332
338
|
}
|
|
339
|
+
// 预热(契约 18,用户 2026-11-04 拍板 B-P1):信号处理装好之后再起,收尾时 killActiveExecs 能杀掉它的命令;不等它,页面来查会加入
|
|
340
|
+
void prewarmPrechecks({
|
|
341
|
+
agents: PREWARM_AGENTS,
|
|
342
|
+
ready: (agent) => isAdapterAgent(agent) && adapters.isReady(agent),
|
|
343
|
+
warm: (agent) => service.warmPrecheck(agent),
|
|
344
|
+
log,
|
|
345
|
+
});
|
|
333
346
|
const lifetime = backgroundLifetimeMs(parsed);
|
|
334
347
|
if (lifetime !== null) {
|
|
335
348
|
setTimeout(() => {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** 缓存最长可用这么久;之后的第一次查询真跑 */
|
|
2
|
+
export const PRECHECK_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
/** 用到的缓存比这更旧时,同时在后台复查一次 */
|
|
4
|
+
export const PRECHECK_REFRESH_AFTER_MS = 5 * 60 * 1000;
|
|
5
|
+
/** 能缓存的「通过」:比页面的 precheckPasses 严 —— 登录 unknown、已跳过的 m2 unknown 页面放行,这里都不记 */
|
|
6
|
+
export function isCacheablePrecheck(result) {
|
|
7
|
+
const login = result.login.state;
|
|
8
|
+
const m2 = result.m2.state;
|
|
9
|
+
return (login === 'logged_in' || login === 'not_required') && (m2 === 'ready' || m2 === 'unverified');
|
|
10
|
+
}
|
|
11
|
+
export class PrecheckCache {
|
|
12
|
+
deps;
|
|
13
|
+
slots = new Map();
|
|
14
|
+
/** 每条连接查过哪几家;连接关闭时 forgetConnection */
|
|
15
|
+
asked = new Map();
|
|
16
|
+
sequence = 0;
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
this.deps = deps;
|
|
19
|
+
}
|
|
20
|
+
/** 页面连接上的一次 `agent/precheck` */
|
|
21
|
+
forConnection(connectionId, agent) {
|
|
22
|
+
const asked = this.asked.get(connectionId) ?? new Set();
|
|
23
|
+
this.asked.set(connectionId, asked);
|
|
24
|
+
if (asked.has(agent))
|
|
25
|
+
return this.fresh(agent, 'same page checked again');
|
|
26
|
+
asked.add(agent);
|
|
27
|
+
const slot = this.slot(agent);
|
|
28
|
+
const cached = slot.cached;
|
|
29
|
+
if (cached && this.deps.usable(agent)) {
|
|
30
|
+
const age = this.deps.now() - cached.startedAt;
|
|
31
|
+
// 年龄为负(系统时钟往回拨过)当作过期:不能让它一直「不到 5 分钟」而永不复查(review r1 N2)
|
|
32
|
+
if (age >= 0 && age <= PRECHECK_CACHE_MAX_AGE_MS) {
|
|
33
|
+
const refresh = age > PRECHECK_REFRESH_AFTER_MS;
|
|
34
|
+
this.deps.log(`Precheck of ${agent} for a page: used the result from ${Math.round(age / 1000)}s ago${refresh ? ' (checking again in the background)' : ''}.`);
|
|
35
|
+
if (refresh)
|
|
36
|
+
this.warm(agent, 'background re-check').catch(() => undefined); // 结果与出错由 start 记日志
|
|
37
|
+
slot.passHandedOut = true;
|
|
38
|
+
return Promise.resolve(cached.result);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (slot.shared) {
|
|
42
|
+
this.deps.log(`Precheck of ${agent} for a page: joined a check already running.`);
|
|
43
|
+
return slot.shared;
|
|
44
|
+
}
|
|
45
|
+
return this.warm(agent, 'first check for a page');
|
|
46
|
+
}
|
|
47
|
+
/** 独立的一次真跑(同一连接的再查、授权结束后的重查):不加入任何在跑的,结果照常写回 */
|
|
48
|
+
fresh(agent, reason) {
|
|
49
|
+
return this.start(agent, reason);
|
|
50
|
+
}
|
|
51
|
+
/** 可共享的真跑:这家已有一次在跑就加入它,否则起一次 */
|
|
52
|
+
warm(agent, reason) {
|
|
53
|
+
const slot = this.slot(agent);
|
|
54
|
+
if (slot.shared)
|
|
55
|
+
return slot.shared;
|
|
56
|
+
const running = this.start(agent, reason);
|
|
57
|
+
slot.shared = running;
|
|
58
|
+
const clear = () => {
|
|
59
|
+
if (slot.shared === running)
|
|
60
|
+
slot.shared = null;
|
|
61
|
+
};
|
|
62
|
+
running.then(clear, clear);
|
|
63
|
+
return running;
|
|
64
|
+
}
|
|
65
|
+
/** 写配置 / 授权开始时:这家的状态马上要变 */
|
|
66
|
+
invalidate(agent) {
|
|
67
|
+
const slot = this.slot(agent);
|
|
68
|
+
slot.generation += 1;
|
|
69
|
+
slot.cached = null;
|
|
70
|
+
slot.latest = null;
|
|
71
|
+
slot.shared = null;
|
|
72
|
+
}
|
|
73
|
+
forgetConnection(connectionId) {
|
|
74
|
+
this.asked.delete(connectionId);
|
|
75
|
+
}
|
|
76
|
+
slot(agent) {
|
|
77
|
+
let slot = this.slots.get(agent);
|
|
78
|
+
if (!slot) {
|
|
79
|
+
slot = { generation: 0, cached: null, latest: null, lastWrite: 0, passHandedOut: false, shared: null };
|
|
80
|
+
this.slots.set(agent, slot);
|
|
81
|
+
}
|
|
82
|
+
return slot;
|
|
83
|
+
}
|
|
84
|
+
start(agent, reason) {
|
|
85
|
+
const slot = this.slot(agent);
|
|
86
|
+
const run = { sequence: ++this.sequence, generation: slot.generation, startedAt: this.deps.now() };
|
|
87
|
+
return this.deps.run(agent).then((result) => {
|
|
88
|
+
const seconds = ((this.deps.now() - run.startedAt) / 1000).toFixed(1);
|
|
89
|
+
this.deps.log(`Precheck of ${agent} (${reason}): ${verdict(result)} in ${seconds}s.`);
|
|
90
|
+
this.write(agent, run, result);
|
|
91
|
+
// 被更晚开始、已写入的真跑推翻:交出那个更新的结论。不看代数:latest 作废时清空、之后只由作废后开始的真跑写入,必然更新
|
|
92
|
+
const handedOut = run.sequence < slot.lastWrite && slot.latest ? slot.latest : result;
|
|
93
|
+
if (isCacheablePrecheck(handedOut))
|
|
94
|
+
slot.passHandedOut = true;
|
|
95
|
+
return handedOut;
|
|
96
|
+
}, (error) => {
|
|
97
|
+
this.deps.log(`Precheck of ${agent} (${reason}) did not run: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
|
+
// 前置条件不满足:原来的通过不再可信,删掉;不推送 —— 调用方自己会拿到这个错误
|
|
99
|
+
if (this.canWrite(slot, run)) {
|
|
100
|
+
slot.lastWrite = run.sequence;
|
|
101
|
+
slot.cached = null;
|
|
102
|
+
slot.latest = null;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
canWrite(slot, run) {
|
|
108
|
+
return run.generation === slot.generation && run.sequence > slot.lastWrite;
|
|
109
|
+
}
|
|
110
|
+
write(agent, run, result) {
|
|
111
|
+
const slot = this.slot(agent);
|
|
112
|
+
if (!this.canWrite(slot, run))
|
|
113
|
+
return;
|
|
114
|
+
slot.lastWrite = run.sequence;
|
|
115
|
+
slot.latest = result;
|
|
116
|
+
if (isCacheablePrecheck(result)) {
|
|
117
|
+
slot.cached = { result, startedAt: run.startedAt };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
slot.cached = null;
|
|
121
|
+
if (slot.passHandedOut) {
|
|
122
|
+
slot.passHandedOut = false;
|
|
123
|
+
this.deps.onRevoked(agent, result);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function verdict(result) {
|
|
128
|
+
return isCacheablePrecheck(result) ? 'passed' : `not passed (sign-in ${result.login.state}, M2 ${result.m2.state})`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* bridge 开始监听之后预热(design D6,用户 2026-11-04 拍板 B-P1:Claude 与 Codex,不管用没用过)。
|
|
132
|
+
* 副作用(契约 8):`claude mcp list` 会启动用户配置的 stdio MCP 并联网,客户端用户同意连接后每次打开客户端都会发生一次。
|
|
133
|
+
* 只在启动时一次,不定时重复;各家并行;失败不抛出。结果与耗时由检查本身记一行(`Precheck of … (startup): …`),
|
|
134
|
+
* 真机的 bridge.log 因此自带每次启动的预检耗时。
|
|
135
|
+
*/
|
|
136
|
+
export async function prewarmPrechecks(deps) {
|
|
137
|
+
await Promise.all(deps.agents.map(async (agent) => {
|
|
138
|
+
if (!deps.ready(agent)) {
|
|
139
|
+
deps.log(`Skipped the startup precheck of ${agent}: its adapter isn't installed.`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await deps.warm(agent).catch(() => undefined);
|
|
143
|
+
}));
|
|
144
|
+
}
|
package/dist/bridge/precheck.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
|
|
11
11
|
* - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
|
|
12
12
|
* - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
|
|
13
|
-
* - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
|
|
13
|
+
* - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。Codex 的 m2 检查先断网跑一次,只有本地就能定的
|
|
14
|
+
* 三种结果直接用,其余联网再跑(`CODEX_OFFLINE_PROXY` 处的说明)。
|
|
14
15
|
* - Claude 同样用适配器自带的 claude(D9):会话用的就是它,本机那份只决定兜底命令怎么显示。找不到自带程序时报 unknown,
|
|
15
16
|
* 不退回本机的 claude(否则预检和会话看的是两个程序)。
|
|
16
17
|
*/
|
|
@@ -20,6 +21,24 @@ import { ADAPTERS, CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
|
|
|
20
21
|
import { combinedOutput } from './exec.js';
|
|
21
22
|
import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
|
|
22
23
|
export const PRECHECK_TIMEOUT_MS = 45_000;
|
|
24
|
+
/**
|
|
25
|
+
* Codex 的 m2 检查先断网跑一次(change bridge-precheck-speedup D12)。`codex mcp list --json` 联网时会探测每个还没存凭证的
|
|
26
|
+
* HTTP 服务器支不支持 OAuth(用户真实环境 3–6.6 秒);而 M2 的 `o_auth`(有本地凭证)、没配置、停用三种状态只由本地配置与凭证决定,
|
|
27
|
+
* 断网结果与联网一致(2026-11-04 隔离实测,`evidence/measurement.md` §1c)。「配了没授权」断网会给 unknown(联网是 not_logged_in),
|
|
28
|
+
* 所以只有这三种直接采用,其余一律联网再跑、以联网为准 —— 结果与只联网逐状态一致,变化只在耗时。
|
|
29
|
+
* 依赖 Codex 的实现细节:升级 Codex 适配器时按该 evidence 的做法重测三种状态(契约 7)。
|
|
30
|
+
*/
|
|
31
|
+
export const CODEX_OFFLINE_PROXY = 'http://127.0.0.1:9';
|
|
32
|
+
/**
|
|
33
|
+
* 断网那次实测 0.06 秒;超过这个时限就当没结果、联网再跑。不用更紧的值:新铺的程序第一次运行有约 4 秒的系统扫描
|
|
34
|
+
* (`desktop-bundled-local-runtime` 实测),太紧会让首次 Codex 预检白等一次再联网(review r1 N3)。
|
|
35
|
+
*/
|
|
36
|
+
export const CODEX_OFFLINE_TIMEOUT_MS = 10_000;
|
|
37
|
+
const OFFLINE_TRUSTED = new Set(['unverified', 'not_configured', 'needs_enable']);
|
|
38
|
+
function offlineEnv(env) {
|
|
39
|
+
const proxy = CODEX_OFFLINE_PROXY;
|
|
40
|
+
return { ...env, HTTPS_PROXY: proxy, HTTP_PROXY: proxy, ALL_PROXY: proxy, https_proxy: proxy, http_proxy: proxy, all_proxy: proxy, NO_PROXY: '', no_proxy: '' };
|
|
41
|
+
}
|
|
23
42
|
export function shellQuote(value) {
|
|
24
43
|
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
25
44
|
}
|
|
@@ -74,6 +93,7 @@ function plan(agent, deps) {
|
|
|
74
93
|
login: { args: ['login', 'status'], parse: (r) => parseCodexLoginStatus(combinedOutput(r), r.code), next: run(display, ['login']) },
|
|
75
94
|
m2: {
|
|
76
95
|
args: ['mcp', 'list', '--json'],
|
|
96
|
+
offlineFirst: true,
|
|
77
97
|
parse: (r) => parseCodexMcpListJson(r.stdout, r.code),
|
|
78
98
|
next: (state, entry) => state === 'needs_auth' || state === 'unverified'
|
|
79
99
|
? run(display, ['mcp', 'login', entry])
|
|
@@ -132,13 +152,23 @@ function plan(agent, deps) {
|
|
|
132
152
|
};
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
|
-
async function runStep(deps, p, args) {
|
|
136
|
-
return deps.exec(p.exec.command, [...p.exec.prefix, ...args], {
|
|
155
|
+
async function runStep(deps, p, args, options = {}) {
|
|
156
|
+
return deps.exec(p.exec.command, [...p.exec.prefix, ...args], {
|
|
157
|
+
env: options.offline ? offlineEnv(deps.env) : deps.env,
|
|
158
|
+
cwd: deps.cwd,
|
|
159
|
+
timeoutMs: options.offline ? CODEX_OFFLINE_TIMEOUT_MS : PRECHECK_TIMEOUT_MS,
|
|
160
|
+
});
|
|
137
161
|
}
|
|
138
162
|
export async function precheckM2(agent, deps) {
|
|
139
163
|
const p = plan(agent, deps);
|
|
140
164
|
if (p.missing)
|
|
141
165
|
return toM2({ state: 'unknown', entry: null, raw: tail(p.missing) }, p.m2.next);
|
|
166
|
+
if (p.m2.offlineFirst) {
|
|
167
|
+
// 断网那次出任何意外(起不来、超时、退出码非 0、认不出),Codex 的解析都给 unknown(退出码不是 0 就不信输出),于是联网再跑
|
|
168
|
+
const offline = p.m2.parse(await runStep(deps, p, p.m2.args, { offline: true }));
|
|
169
|
+
if (OFFLINE_TRUSTED.has(offline.state))
|
|
170
|
+
return toM2(offline, p.m2.next);
|
|
171
|
+
}
|
|
142
172
|
const result = await runStep(deps, p, p.m2.args);
|
|
143
173
|
const failure = failed(result);
|
|
144
174
|
return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 主站更新清单(`MANIFEST_URL`,主站的 `/connect/manifest.json`,地址在 `site-config.ts`)的格式、解析与下载地址
|
|
3
|
+
* (change acp-bridge-one-time-install D13 / D14 / R7)。
|
|
3
4
|
*
|
|
4
5
|
* 清单由仓库的 `packages/connect/install/release.json` 生成,随 App 部署发布;部署 App 才是放量开关,发 npm 不等于推给所有用户。
|
|
5
6
|
* 清单**只提供版本号与哈希**:下载地址在这里由版本号拼出,固定指向官方源与 npmmirror。
|
|
6
|
-
* 不要在清单里加 URL 字段 ——
|
|
7
|
+
* 不要在清单里加 URL 字段 —— 那会让清单(或篡改清单的人)把下载指到任意地方,信任根就不止主站加哈希了。
|
|
7
8
|
*
|
|
8
9
|
* 跨版本兼容:已安装的旧版本 bridge 会读新部署的清单(`--check-update` 跑的是当前版本的代码)。
|
|
9
10
|
* - 同一 `schema` 内只加字段;本解析器忽略不认识的字段。
|
|
10
11
|
* - 要改语义就升 `schema`;旧版本认不出新 schema 时按「读清单失败」处理,用当前版本继续(不会误装)。
|
|
11
12
|
* - 版本号与哈希一律严格校验格式:它们会进入文件路径与下载地址。
|
|
12
13
|
*/
|
|
13
|
-
export
|
|
14
|
+
export { MANIFEST_URL } from '../site-config.js';
|
|
14
15
|
export const MANIFEST_SCHEMA = 1;
|
|
15
16
|
/** 只认纯 x.y.z(不接受预发布、构建元数据):版本号会拼进目录名与下载地址 */
|
|
16
17
|
const VERSION_RE = /^\d{1,4}\.\d{1,4}\.\d{1,6}$/;
|
package/dist/bridge/security.js
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
* 连上并配对之后页面能做什么,见 server.ts / acp-guard.ts 的文件头:配对 = 授权该页面驱动本机 agent。
|
|
8
8
|
*
|
|
9
9
|
* 维护约束:
|
|
10
|
-
* - Origin 必须精确匹配(scheme + host + port
|
|
11
|
-
* `https
|
|
12
|
-
* -
|
|
10
|
+
* - Origin 必须精确匹配(scheme + host + port 全等)。不要改成后缀匹配、正则或「包含主站主机名」——
|
|
11
|
+
* `https://<主站主机>.evil.com`、`https://evil.<主站主机>` 都必须被拒。名单作为参数传入:名单为空即全部拒绝。
|
|
12
|
+
* - 默认只放行主站。开发域名只在 `bridge --dev` 时放行:`localhost:5173` 是所有本机 Vite 项目共用的 Origin、
|
|
13
13
|
* 共用 localStorage,给开发版 M2 配对过后,任何跑在 5173 的项目(包括它的依赖)都能读走 token 免码连上
|
|
14
|
-
* (review r3 B2,用户 2026-10-02 拍板改为 --dev
|
|
14
|
+
* (review r3 B2,用户 2026-10-02 拍板改为 --dev,推翻了最初的「开发域名常开」)。名单写死在包里(主站 origin 在
|
|
15
|
+
* `site-config.ts`),不提供任意扩展参数。
|
|
15
16
|
* - Host 校验防 DNS rebinding:攻击者域名解析到 127.0.0.1 时,Host 头是攻击者域名。
|
|
16
17
|
*/
|
|
18
|
+
import { PROD_ORIGINS } from '../site-config.js';
|
|
17
19
|
import { BRIDGE_WS_PATH } from './protocol.js';
|
|
18
|
-
|
|
19
|
-
export const PROD_ORIGINS = ['https://m2ui.ai'];
|
|
20
|
+
export { PROD_ORIGINS };
|
|
20
21
|
/** 本地开发 M2 用(Vite 默认端口);只有 `bridge --dev` 才放行 */
|
|
21
22
|
export const DEV_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
22
23
|
export function allowedOrigins(dev) {
|
package/dist/bridge/server.js
CHANGED
|
@@ -304,6 +304,7 @@ export function startBridgeServer(options) {
|
|
|
304
304
|
connections.delete(conn.id);
|
|
305
305
|
processes.stopOwner(conn.id);
|
|
306
306
|
authorizations.cancelOwner(conn.id);
|
|
307
|
+
options.service.forgetConnection(conn.id);
|
|
307
308
|
});
|
|
308
309
|
// ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
|
|
309
310
|
ws.on('error', () => undefined);
|
package/dist/bridge/service.js
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* 授权(change acp-terminal-free-auth):authorizePlan 按写死清单生成命令(authorize.ts),m2 的配置项名只取 bridge 自己的 m2 预检:
|
|
15
15
|
* 优先用最近一次预检 / 写配置时得到的名字(D19,页面先预检才会显示按钮,省掉一次重跑 —— Claude 的 `mcp list` 实测约 5 秒),没有记录时重跑;
|
|
16
16
|
* 预检 / 写配置的结果按同一判据带上 `authorize` 能力,页面只在能力存在时才调用 agent/authorize。
|
|
17
|
+
*
|
|
18
|
+
* 预检缓存(change bridge-precheck-speedup,契约 18,规则在 precheck-cache.ts):带连接 id 的预检是页面的 `agent/precheck`,
|
|
19
|
+
* 每条连接第一次查某家可以用缓存;不带的(授权结束后的重查)一律真跑并写回。写配置、授权在**开始运行之前**作废这家的缓存 ——
|
|
20
|
+
* 前置条件不满足而什么都没运行时不作废。缓存存不带能力的原始结果,给出时再按当前判据加 `authorize`。
|
|
17
21
|
*/
|
|
18
22
|
import { dirname, join } from 'node:path';
|
|
19
23
|
import { ADAPTERS, isAdapterAgent } from './adapters.js';
|
|
@@ -23,6 +27,7 @@ import { authCapability, buildAuthPlan, isSafeAuthEntry } from './authorize.js';
|
|
|
23
27
|
import { prependPath } from './env.js';
|
|
24
28
|
import { combinedOutput } from './exec.js';
|
|
25
29
|
import { precheckAgent, precheckM2 } from './precheck.js';
|
|
30
|
+
import { PrecheckCache } from './precheck-cache.js';
|
|
26
31
|
import { BRIDGE_AGENT_IDS, } from './protocol.js';
|
|
27
32
|
export const CONFIGURE_TIMEOUT_MS = 120_000;
|
|
28
33
|
/**
|
|
@@ -92,6 +97,18 @@ export function createAgentService(deps) {
|
|
|
92
97
|
login: withCapability(result.login, result.agent, 'login'),
|
|
93
98
|
m2: withCapability(result.m2, result.agent, 'm2'),
|
|
94
99
|
});
|
|
100
|
+
const precheckCache = new PrecheckCache({
|
|
101
|
+
run: async (agent) => {
|
|
102
|
+
const result = await precheckAgent(await ready(agent), precheckDeps);
|
|
103
|
+
remember(agent, result.m2);
|
|
104
|
+
return result;
|
|
105
|
+
},
|
|
106
|
+
// 只看文件状态、不起子进程;原生 agent 的检测要跑命令行,不在给缓存前做(拉起时 launch 会报 agent_not_installed)
|
|
107
|
+
usable: (agent) => !isAdapterAgent(agent) || deps.adapters.isReady(agent),
|
|
108
|
+
now: deps.now ?? Date.now,
|
|
109
|
+
onRevoked: (_agent, result) => deps.onPrecheckRevoked?.(withCapabilities(result)),
|
|
110
|
+
log: deps.log ?? (() => undefined),
|
|
111
|
+
});
|
|
95
112
|
/** D10:clients.ts 只认 PATH 上能跑起来的 claude / codex */
|
|
96
113
|
function configureEnv(agent) {
|
|
97
114
|
const node = dirname(deps.nodePath);
|
|
@@ -130,13 +147,21 @@ export function createAgentService(deps) {
|
|
|
130
147
|
}
|
|
131
148
|
return info;
|
|
132
149
|
},
|
|
133
|
-
async precheck(agent) {
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
async precheck(agent, connectionId) {
|
|
151
|
+
// 不带连接的只有授权结束后的重查(auth-call.ts):一律新起一次真跑,不加入授权期间别的连接起的共享真跑(契约 12 delta)
|
|
152
|
+
const result = connectionId === undefined ? await precheckCache.fresh(agent, 'after sign-in or M2 authorization') : await precheckCache.forConnection(connectionId, agent);
|
|
136
153
|
return withCapabilities(result);
|
|
137
154
|
},
|
|
155
|
+
async warmPrecheck(agent) {
|
|
156
|
+
return precheckCache.warm(agent, 'startup');
|
|
157
|
+
},
|
|
158
|
+
forgetConnection(connectionId) {
|
|
159
|
+
precheckCache.forgetConnection(connectionId);
|
|
160
|
+
},
|
|
138
161
|
async configure(agent) {
|
|
139
162
|
const detected = await ready(agent);
|
|
163
|
+
// 马上要写配置:这家的预检结果不再可信(契约 18)
|
|
164
|
+
precheckCache.invalidate(agent);
|
|
140
165
|
const result = await deps.exec(deps.nodePath, [deps.cliPath, '--client', CLIENT_ID[agent], '--yes'], {
|
|
141
166
|
env: configureEnv(agent),
|
|
142
167
|
cwd: deps.cwd,
|
|
@@ -165,6 +190,9 @@ export function createAgentService(deps) {
|
|
|
165
190
|
const detected = await ready(agent);
|
|
166
191
|
if (!capability(agent, action))
|
|
167
192
|
throw new BridgeFailure('auth_unavailable', `the bridge cannot run ${verb} for ${agent}; use the terminal command instead`);
|
|
193
|
+
// 取计划之后就运行授权命令(AuthorizationManager.run):登录 / 授权状态马上要变,先作废缓存(契约 18)。
|
|
194
|
+
// 下面因配置项名缺失而 auth_unavailable 时也已作废 —— 没运行任何命令,多一次真跑无害
|
|
195
|
+
precheckCache.invalidate(agent);
|
|
168
196
|
let entry = null;
|
|
169
197
|
if (action === 'm2') {
|
|
170
198
|
// 配置项名不一定叫 m2,也不能由页面指定:用 bridge 最近一次预检的结果,没有就重跑(D19);OpenCode 总是重跑(D21)
|
package/dist/bridge/uninstall.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 门槛、停止接受新连接、停 agent、回复与退出在 server.ts / uninstall-call.ts;本文件只管「怎么跑脚本、怎么读结果」。
|
|
4
4
|
*
|
|
5
5
|
* 不在 TypeScript 里再写一份删除逻辑:删除范围只有 `install/uninstall.sh` 一份实现(`connect/local-bridge` 契约 15「卸载脚本」),
|
|
6
|
-
* 终端、agent 应用、M2 Mac
|
|
6
|
+
* 终端、agent 应用、M2 Mac 客户端运行的是主站上它的逐字副本(`connect:install-assets -- --check` 锁住)。
|
|
7
7
|
*
|
|
8
8
|
* 维护约束:
|
|
9
9
|
* - **先删自己的状态文件,再运行脚本。** 脚本只停它能从状态文件找到的 M2 bridge(`--stop` 发 SIGTERM);自己的还在,
|
package/dist/bridge/update.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* 运行它的是**当前版本**的代码;它把新版本下载、校验、冒烟后切成 current,下一步由启动器用新版本拉起。
|
|
4
4
|
*
|
|
5
5
|
* 不变量(`connect/local-bridge` 契约 16 delta):
|
|
6
|
-
* -
|
|
6
|
+
* - 只更新到主站清单指定的版本(清单版本比当前低也照做,这就是回滚),不跟随 npm latest;
|
|
7
7
|
* - 清单只给版本与哈希,下载地址由 release-manifest.ts 固定拼出;任何下载内容与清单哈希不符都换下一个源,全部不符就放弃;
|
|
8
8
|
* - 读清单失败、超时、格式认不出:什么都不改,用当前版本继续;
|
|
9
9
|
* - 新版本先冒烟(`--self-test`),通过才切换;切换用「先建临时软链再改名」,任何时刻 current 都指向一个完整的版本;
|
|
10
10
|
* - 「新版本拉起后没起来」的回退不在这里:新版本可能连模块都加载不了,回退判定在很少改动的启动器里(契约 16 delta);
|
|
11
|
-
* -
|
|
11
|
+
* - 不加清单地址、下载地址的覆盖开关:那会让「只读主站清单」多出一条旁路(design R8)。测试经 UpdateDeps 注入下载。
|
|
12
12
|
*
|
|
13
13
|
* 并发(design N1):不加锁。所有写入先写临时目录 / 临时软链再改名;两个更新同时跑时各自下到自己的临时目录,
|
|
14
14
|
* 后完成的发现版本目录已完整就丢掉自己的,发现 current 已经切好就不再切。
|
package/dist/clients.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
// 各客户端的探测 / 写入 / 登录。每家的命令与坑位都经本机逐一实测,见 README。
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { M2_MCP_URL, SERVER_NAME, findInClaudeConfig, findInCodexToml, findInQoderSettings, appendCodexServer, mergeCursorConfig, mergeOpencodeConfig, } from './config-files.js';
|
|
3
|
+
import { M2_MCP_URL, SERVER_NAME, findInClaudeConfig, findInCodexToml, findInQoderSettings, hasCodexNameConflict, hasNameConflictInServers, nameConflictNote, appendCodexServer, mergeCursorConfig, mergeOpencodeConfig, } from './config-files.js';
|
|
4
4
|
import { existsSync, firstExecutable, firstRunnable, home, readJson, readText, run, which, writeJsonWithBackup, writeTextWithBackup, } from './system.js';
|
|
5
5
|
function jsonExisting(path, find) {
|
|
6
6
|
const read = readJson(path);
|
|
7
7
|
return read.kind === 'ok' ? find(read.value) : null;
|
|
8
8
|
}
|
|
9
|
+
/** 用户级 server 表里同名 `m2` 指向别处 → blocked 提示(D-C1);读不出文件按无冲突,由客户端自己的 CLI 报错 */
|
|
10
|
+
function jsonConflict(path, servers) {
|
|
11
|
+
const read = readJson(path);
|
|
12
|
+
return read.kind === 'ok' && hasNameConflictInServers(servers(read.value)) ? nameConflictNote(path) : null;
|
|
13
|
+
}
|
|
14
|
+
function mcpServersOf(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null ? value.mcpServers : undefined;
|
|
16
|
+
}
|
|
9
17
|
function addWith(command, args) {
|
|
10
18
|
const result = run(command, args);
|
|
11
19
|
return result.status === 0 ? { ok: true } : { ok: false, note: result.output };
|
|
@@ -17,7 +25,7 @@ function claudeCode() {
|
|
|
17
25
|
return {
|
|
18
26
|
label: 'Claude Code',
|
|
19
27
|
existing: jsonExisting(join(home, '.claude.json'), findInClaudeConfig),
|
|
20
|
-
blocked:
|
|
28
|
+
blocked: jsonConflict(join(home, '.claude.json'), mcpServersOf),
|
|
21
29
|
actions: [`claude mcp add --transport http --scope user ${SERVER_NAME} ${M2_MCP_URL}`],
|
|
22
30
|
restart: 'Restart Claude Code (claude --continue keeps your conversation).',
|
|
23
31
|
// `claude mcp login` 在非 TTY 下直接失败(实测 "stdin isn't a terminal"),agent 的 shell 就是这种。
|
|
@@ -38,7 +46,7 @@ function codex() {
|
|
|
38
46
|
return {
|
|
39
47
|
label: 'Codex',
|
|
40
48
|
existing: toml ? findInCodexToml(toml) : null,
|
|
41
|
-
blocked: null,
|
|
49
|
+
blocked: toml && !findInCodexToml(toml) && hasCodexNameConflict(toml) ? nameConflictNote(path) : null,
|
|
42
50
|
actions: [`add [mcp_servers.${SERVER_NAME}] to ${path}`],
|
|
43
51
|
// 顺序敏感:login 的本地回调端口挂在 login 进程上,必须授权完成后再重启 Codex。
|
|
44
52
|
restart: 'Fully quit and reopen Codex (after signing in).',
|
|
@@ -58,7 +66,7 @@ function qoder() {
|
|
|
58
66
|
return {
|
|
59
67
|
label: 'Qoder',
|
|
60
68
|
existing: jsonExisting(join(home, '.qoder', 'settings.json'), findInQoderSettings),
|
|
61
|
-
blocked:
|
|
69
|
+
blocked: jsonConflict(join(home, '.qoder', 'settings.json'), mcpServersOf),
|
|
62
70
|
actions: [`qodercli mcp add -s user -t http ${SERVER_NAME} ${M2_MCP_URL}`],
|
|
63
71
|
restart: 'Reopen Qoder if it is running.',
|
|
64
72
|
// qodercli 没有 mcp login 子命令,授权只能在 UI 里点。
|
|
@@ -93,6 +101,8 @@ function opencode() {
|
|
|
93
101
|
if (read.kind === 'invalid')
|
|
94
102
|
return { ...base, blocked: `${path} is not valid JSON (${read.error}).` };
|
|
95
103
|
const merged = mergeOpencodeConfig(read.kind === 'ok' ? read.value : {});
|
|
104
|
+
if (merged.conflict)
|
|
105
|
+
return { ...base, blocked: nameConflictNote(path) };
|
|
96
106
|
return {
|
|
97
107
|
...base,
|
|
98
108
|
existing: merged.existing,
|
|
@@ -123,6 +133,8 @@ function cursor() {
|
|
|
123
133
|
if (read.kind === 'invalid')
|
|
124
134
|
return { ...base, blocked: `${path} is not valid JSON (${read.error}).` };
|
|
125
135
|
const merged = mergeCursorConfig(read.kind === 'ok' ? read.value : {});
|
|
136
|
+
if (merged.conflict)
|
|
137
|
+
return { ...base, blocked: nameConflictNote(path) };
|
|
126
138
|
return {
|
|
127
139
|
...base,
|
|
128
140
|
existing: merged.existing,
|
package/dist/config-files.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// 各客户端配置文件的纯函数读写:查已有 m2 入口、按原格式合并。全部不碰磁盘,便于单测。
|
|
2
|
-
|
|
2
|
+
// MCP 地址来自 site-config.ts(connect 唯一的站点配置来源),这里转出供 cli.ts / clients.ts 沿用原导出名。
|
|
3
|
+
import { M2_MCP_URL, MCP_HOST } from './site-config.js';
|
|
4
|
+
export { M2_MCP_URL };
|
|
3
5
|
export const SERVER_NAME = 'm2';
|
|
4
6
|
function isObject(value) {
|
|
5
7
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -9,7 +11,7 @@ export function isM2Url(value) {
|
|
|
9
11
|
if (typeof value !== 'string')
|
|
10
12
|
return false;
|
|
11
13
|
try {
|
|
12
|
-
return new URL(value).host ===
|
|
14
|
+
return new URL(value).host === MCP_HOST;
|
|
13
15
|
}
|
|
14
16
|
catch {
|
|
15
17
|
return false;
|
|
@@ -25,6 +27,21 @@ export function findM2Entry(servers) {
|
|
|
25
27
|
}
|
|
26
28
|
return null;
|
|
27
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* server 表里已有名为 `m2`、但不指向 M2 的入口(change domain-cutover design D-C1)。
|
|
32
|
+
* 我们只会以 `m2` 这个名字写入;同名入口指向别处时既不能追加(Codex 的 TOML 会出现重复表、整个文件解析失败),
|
|
33
|
+
* 也不能覆盖(那可能是用户自己的 server,或换域名前留下的旧入口,静默改掉用户看不出发生了什么)。一律报冲突交给用户。
|
|
34
|
+
*/
|
|
35
|
+
export function hasNameConflictInServers(servers) {
|
|
36
|
+
if (!isObject(servers) || !Object.hasOwn(servers, SERVER_NAME))
|
|
37
|
+
return false;
|
|
38
|
+
const entry = servers[SERVER_NAME];
|
|
39
|
+
return !(isObject(entry) && (isM2Url(entry.url) || isM2Url(entry.serverUrl)));
|
|
40
|
+
}
|
|
41
|
+
/** 同名冲突的提示(给 ClientPlan.blocked 用):说明不改的原因与用户要做的事,不打印对方的地址 */
|
|
42
|
+
export function nameConflictNote(where) {
|
|
43
|
+
return `${where} already has an MCP server named "${SERVER_NAME}" that does not point to M2 (for example one left from an old M2 address). Remove or rename it, then run this again.`;
|
|
44
|
+
}
|
|
28
45
|
/** Claude Code `~/.claude.json` 顶层 mcpServers = user scope。 */
|
|
29
46
|
export function findInClaudeConfig(config) {
|
|
30
47
|
return isObject(config) ? findM2Entry(config.mcpServers) : null;
|
|
@@ -56,15 +73,38 @@ export function findInCodexToml(toml) {
|
|
|
56
73
|
}
|
|
57
74
|
return null;
|
|
58
75
|
}
|
|
76
|
+
/** Codex `config.toml` 里已有 `[mcp_servers.m2]` 段且段内 url 不指向 M2(判定见 `hasNameConflictInServers`) */
|
|
77
|
+
export function hasCodexNameConflict(toml) {
|
|
78
|
+
let inTarget = false;
|
|
79
|
+
let found = false;
|
|
80
|
+
let pointsToM2 = false;
|
|
81
|
+
for (const raw of toml.split(/\r?\n/)) {
|
|
82
|
+
const line = raw.trim();
|
|
83
|
+
const header = /^\[mcp_servers\.("?)([^\]"]+)\1\]$/.exec(line);
|
|
84
|
+
if (header || line.startsWith('[')) {
|
|
85
|
+
inTarget = header?.[2] === SERVER_NAME;
|
|
86
|
+
if (inTarget)
|
|
87
|
+
found = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const url = /^url\s*=\s*["']([^"']+)["']/.exec(line);
|
|
91
|
+
if (inTarget && url && isM2Url(url[1]))
|
|
92
|
+
pointsToM2 = true;
|
|
93
|
+
}
|
|
94
|
+
return found && !pointsToM2;
|
|
95
|
+
}
|
|
59
96
|
/** Cursor `~/.cursor/mcp.json`:合并 `{ url }`,保留其余 server。 */
|
|
60
97
|
export function mergeCursorConfig(config) {
|
|
61
98
|
const base = isObject(config) ? config : {};
|
|
62
99
|
const existing = findM2Entry(base.mcpServers);
|
|
63
100
|
if (existing)
|
|
64
|
-
return { existing, config: base };
|
|
101
|
+
return { existing, conflict: false, config: base };
|
|
102
|
+
if (hasNameConflictInServers(base.mcpServers))
|
|
103
|
+
return { existing: null, conflict: true, config: base };
|
|
65
104
|
const servers = isObject(base.mcpServers) ? base.mcpServers : {};
|
|
66
105
|
return {
|
|
67
106
|
existing: null,
|
|
107
|
+
conflict: false,
|
|
68
108
|
config: { ...base, mcpServers: { ...servers, [SERVER_NAME]: { url: M2_MCP_URL } } },
|
|
69
109
|
};
|
|
70
110
|
}
|
|
@@ -81,12 +121,15 @@ export function mergeOpencodeConfig(config) {
|
|
|
81
121
|
const flatEntries = Object.fromEntries(Object.entries(mcp).filter(([key]) => key !== 'servers'));
|
|
82
122
|
const existing = findM2Entry(flatEntries) ?? findM2Entry(nested);
|
|
83
123
|
if (existing)
|
|
84
|
-
return { existing, config: base };
|
|
124
|
+
return { existing, conflict: false, config: base };
|
|
125
|
+
if (hasNameConflictInServers(flatEntries) || hasNameConflictInServers(nested)) {
|
|
126
|
+
return { existing: null, conflict: true, config: base };
|
|
127
|
+
}
|
|
85
128
|
const useNested = nested !== null && Object.keys(flatEntries).length === 0;
|
|
86
129
|
const nextMcp = useNested
|
|
87
130
|
? { ...mcp, servers: { ...nested, [SERVER_NAME]: { type: 'remote', url: M2_MCP_URL } } }
|
|
88
131
|
: { ...mcp, [SERVER_NAME]: { type: 'remote', url: M2_MCP_URL, enabled: true } };
|
|
89
|
-
return { existing: null, config: { ...base, mcp: nextMcp } };
|
|
132
|
+
return { existing: null, conflict: false, config: { ...base, mcp: nextMcp } };
|
|
90
133
|
}
|
|
91
134
|
/**
|
|
92
135
|
* Codex `config.toml` 追加 m2 段。不走 `codex mcp add`:它在没有已存 token 时会顺手启动 OAuth
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect 唯一的站点配置来源(change domain-config-externalize D1)。
|
|
3
|
+
*
|
|
4
|
+
* connect 随 npm 发布、在用户机器上运行,没有部署期配置可读,所以站点地址以代码常量的形式集中在这里;
|
|
5
|
+
* 包内其余代码只从这里取值。换域名时只改本文件(`package.json` 的 homepage 同批改),随 connect 发版生效。
|
|
6
|
+
*
|
|
7
|
+
* 维护约束:
|
|
8
|
+
* - 这些值是安全边界的一部分:主站 origin 是 bridge 唯一放行的生产 Origin(`connect/local-bridge` 契约 1:名单写死在包里、
|
|
9
|
+
* 精确匹配),主站清单是更新的唯一信任根(契约 16)。不得改成读环境变量、命令行参数或配置文件 —— 那等于给 Origin
|
|
10
|
+
* 白名单与清单地址加了覆盖开关。
|
|
11
|
+
* - 只写下面两个 origin 字面量,派生值一律由它们拼出,不写第二份。两个 origin 保持单行 `export const NAME = '…';`:
|
|
12
|
+
* 不发版的包之间靠读源码比对防漂移,换写法会让这类比对失效。
|
|
13
|
+
* - origin 必须是规范形式(https、不带路径与尾斜杠、不写默认端口):bridge 拿它与浏览器的 Origin 头逐字比较。
|
|
14
|
+
*/
|
|
15
|
+
/** 主站(用户打开画布的网站)的 origin;页面连 bridge 时 Origin 头就是它 */
|
|
16
|
+
export const APP_PUBLIC_ORIGIN = 'https://patch.design';
|
|
17
|
+
/** MCP 服务的 origin;connect 写进各家 agent 客户端配置的 MCP 地址在它下面 */
|
|
18
|
+
export const MCP_ORIGIN = 'https://mcp.m2ui.ai';
|
|
19
|
+
/** 主站主机名(含端口时带端口) */
|
|
20
|
+
export const APP_PUBLIC_HOST = new URL(APP_PUBLIC_ORIGIN).host;
|
|
21
|
+
/** MCP 服务主机名:按它识别已有的 M2 配置项,老入口的 path / query / 尾斜杠不论 */
|
|
22
|
+
export const MCP_HOST = new URL(MCP_ORIGIN).host;
|
|
23
|
+
/** bridge 默认放行的 Origin:只有主站。`www.<主站主机>` 由 m2-router 301 到根域,页面 Origin 不会是它,所以不列 */
|
|
24
|
+
export const PROD_ORIGINS = [APP_PUBLIC_ORIGIN];
|
|
25
|
+
/** 主站清单:bridge 更新时唯一读取的版本与哈希来源 */
|
|
26
|
+
export const MANIFEST_URL = `${APP_PUBLIC_ORIGIN}/connect/manifest.json`;
|
|
27
|
+
/** 写进各家 agent 客户端配置的 MCP 地址(streamable HTTP) */
|
|
28
|
+
export const M2_MCP_URL = `${MCP_ORIGIN}/mcp`;
|
package/install/uninstall.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# M2 local connection uninstaller
|
|
3
|
-
# curl -fsSL https
|
|
2
|
+
# M2 local connection uninstaller, served by the M2 site at /connect/uninstall.sh
|
|
3
|
+
# curl -fsSL https://<M2 site>/connect/uninstall.sh | sh
|
|
4
4
|
#
|
|
5
5
|
# What it does:
|
|
6
6
|
# 1. Stops the M2 bridge if one is running (only a bridge it can recognize, and only with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"build": "tsc -p tsconfig.json",
|
|
22
22
|
"prepublishOnly": "npm run build"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://
|
|
24
|
+
"homepage": "https://patch.design/mcp",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public",
|