@m2canvas/connect 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -27
- package/dist/bridge/args.js +30 -36
- package/dist/bridge/call-dispatch.js +36 -16
- package/dist/bridge/install-layout.js +2 -1
- package/dist/bridge/main.js +80 -92
- package/dist/bridge/processes.js +37 -6
- package/dist/bridge/protocol.js +11 -3
- package/dist/bridge/security.js +17 -27
- package/dist/bridge/server.js +49 -70
- package/dist/bridge/service.js +30 -8
- package/dist/bridge/session-token-io.js +41 -0
- package/dist/bridge/session-token.js +332 -0
- package/dist/bridge/ticket-socket.js +104 -0
- package/dist/bridge/ticket.js +70 -0
- package/dist/bridge/uninstall-call.js +2 -2
- package/dist/bridge/uninstall.js +1 -1
- package/dist/bridge/update.js +21 -1
- package/dist/cli.js +2 -1
- package/install/uninstall.sh +3 -2
- package/package.json +1 -1
- package/dist/bridge/handoff.js +0 -213
- package/dist/bridge/pair-call.js +0 -31
- package/dist/bridge/pairing.js +0 -349
package/README.md
CHANGED
|
@@ -26,40 +26,23 @@ Options: `--client <id>` sets up only that client; `--dry-run` shows the plan wi
|
|
|
26
26
|
|
|
27
27
|
After setup, restart each client so it loads the new server.
|
|
28
28
|
|
|
29
|
-
## Local agent bridge
|
|
29
|
+
## Local agent bridge (M2 for Mac)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
npx -y @m2canvas/connect@latest bridge
|
|
33
|
-
```
|
|
34
|
-
|
|
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.
|
|
31
|
+
The `bridge` subcommand is the local connection that lets M2 start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Local agents in M2 only work inside the M2 desktop app, which runs this bridge for you in the background. Running `npx @m2canvas/connect bridge` in a terminal only explains this and exits; to add M2 to an agent client instead, run `npx @m2canvas/connect` without `bridge`.
|
|
36
32
|
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
33
|
+
- **Desktop app only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://patch.design` that the M2 desktop app connects: for every connection the app first gets a one-time ticket from the bridge over `~/.m2canvas/bridge-<port>.sock` (only your account can open it) and adds it to the request. A browser tab can't add it, so browsers, other sites, other machines and other hosts are refused before a connection is made. Tickets work once, expire after 60 seconds and stop working when the bridge exits; pages hold no pairing token or other credential. (`--dev` also accepts a local M2 dev server on port 5173, still only through a desktop app started for development.)
|
|
34
|
+
- **Connecting gives the page your agents.** A connected page can make these agents read and change files and run commands as you, the same as typing into them yourself. The first time, the desktop app asks you before it starts the bridge.
|
|
35
|
+
- **Your own logins.** Agents use their own sign-in. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
|
|
36
|
+
- **M2 without setup for Claude Code and Codex (from 0.14.0).** When you are signed in to the M2 desktop app, the app keeps a 24-hour M2 session token in `~/.m2canvas/mcp-session/headers.json` (only your account can read it; the app removes it when you sign out or quit). For the Claude Code and Codex the bridge bundles, M2 can then ask the bridge to add one MCP server named `patch_session` to that agent session only: its address is fixed in this package, and the agent reads the token through a small script the bridge writes to `~/.m2canvas/bin/m2-mcp-session-headers`. The token never appears in process arguments or environment variables, the page never sees it, and your agent config files are not changed; for that session the bridge hides your own entries that point to M2. Other agents, and sessions where the token isn't available, use their own M2 MCP setup as before.
|
|
41
37
|
- **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
|
|
42
38
|
- **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (they bundle the agent: about 110 MB and 140 MB to download, 270 MB and 350 MB on disk); M2 shows the download progress. You don't need Claude Code or Codex installed yourself: checking sign-in, adding M2 and signing in all use the bundled `claude` and `codex`, which read the same settings as your own installs.
|
|
43
39
|
- **Checking an agent before use.** Before starting an agent, M2 asks the bridge whether it is signed in and has M2 set up (commands such as `claude auth status` and `claude mcp list`; `claude mcp list` starts the MCP servers you configured in order to check them). From 0.9.0 the bridge runs this check for Claude Code and Codex once when it starts, and remembers a result that passed, so opening another project doesn't wait for it again. It checks again when you click "Check again" in M2 and when M2 adds itself to an agent or signs in for you; when a page uses a remembered result that is more than 5 minutes old, it also checks again in the background and tells M2 if the result no longer holds. There is no periodic check. Results that need you to do something are never remembered, and nothing is written to disk.
|
|
44
40
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
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).
|
|
49
|
-
|
|
50
|
-
## One-time install (macOS)
|
|
42
|
+
## Install and updates (macOS)
|
|
51
43
|
|
|
52
|
-
M2
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
curl -fsSL https://patch.design/connect/install.sh | sh -s -- --pair <value from M2>
|
|
56
|
-
```
|
|
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`.
|
|
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.
|
|
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.
|
|
44
|
+
- **From the M2 desktop app.** The app puts a private Node.js and a pinned version of this package in `~/.m2canvas` (from its own bundle, or with `https://patch.design/connect/install.sh` as a fallback, checking every download against the hashes M2 publishes), writes the launcher `~/.m2canvas/bin/m2-bridge`, and starts the bridge in the background. Agent sessions start in `~/.m2canvas/workspace`.
|
|
45
|
+
- **Nothing starts at login.** The app runs the launcher when it opens. The launcher first updates the bridge to the version listed at `https://patch.design/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
|
|
61
46
|
- **Sandboxes.** Codex needs its Full access permission mode: its default sandbox can't reach the network or write to your home folder. Inside the Codex or Claude command sandbox, the scripts change nothing and say what to do instead.
|
|
62
47
|
- **What it leaves alone.** macOS 13.5 or later. It doesn't use sudo, register anything to start at login, change your shell config or PATH, or touch a Node.js you installed yourself. Logs are in `~/.m2canvas/logs/`.
|
|
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
|
|
64
|
-
|
|
65
|
-
The `npx` command above still works and is what M2 shows under "Advanced", for running the bridge in a folder you choose.
|
|
48
|
+
- **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. It stops the bridge and removes what the installer added (and the M2 session token, if any); `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a page the M2 desktop app connected can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
|
package/dist/bridge/args.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* (契约 14、`install/m2-bridge.sh` 头注释),已安装的 bridge 又会自动更新,所以每个版本都要保持这套接口兼容。
|
|
6
6
|
*/
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import { isPairCommitment } from './pairing.js';
|
|
9
8
|
import { BRIDGE_DEFAULT_PORT } from './protocol.js';
|
|
10
9
|
import { DEV_ORIGINS, PROD_ORIGINS } from './security.js';
|
|
11
10
|
/**
|
|
@@ -27,53 +26,50 @@ export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
|
|
|
27
26
|
export function backgroundLifetimeMs(args) {
|
|
28
27
|
return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
|
|
29
28
|
}
|
|
30
|
-
export const BRIDGE_HELP = `Usage:
|
|
31
|
-
npx @m2canvas/connect bridge --unpair
|
|
29
|
+
export const BRIDGE_HELP = `Usage: this command is run by the M2 desktop app; you don't need to run it yourself.
|
|
32
30
|
|
|
33
|
-
Runs the local agent bridge
|
|
31
|
+
Runs the local agent bridge that lets M2 for Mac start and talk to the agents
|
|
34
32
|
installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
|
|
35
33
|
|
|
36
|
-
It only listens on 127.0.0.1 and only accepts the M2
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Agents run in the directory you start the bridge from (or --cwd), with your own logins.
|
|
41
|
-
On macOS a paired page can also start an agent's own sign-in; you finish it in
|
|
42
|
-
your browser.
|
|
34
|
+
It only listens on 127.0.0.1 and only accepts pages that the M2 desktop app
|
|
35
|
+
opens from ${PROD_ORIGINS.join(', ')}: the app authorizes each connection, so a browser tab
|
|
36
|
+
can't connect. A connected page can make these agents read and change files and
|
|
37
|
+
run commands as you.
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
--verbose also print agent stderr and connection details
|
|
47
|
-
--dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
|
|
48
|
-
any app served on that port could then ask to pair
|
|
49
|
-
--pair <value> pair the M2 page that gave you this command (M2 adds it for you;
|
|
50
|
-
valid for 10 minutes, once)
|
|
51
|
-
--replace if an M2 bridge (0.6.0 or later) is already running on the port,
|
|
52
|
-
stop it first (its agent sessions end) and start this one instead;
|
|
53
|
-
not on Windows
|
|
54
|
-
--unpair forget all paired pages and exit
|
|
55
|
-
-h, --help show this help
|
|
39
|
+
To add M2 to an agent client instead (Claude Code, Codex, Cursor, OpenCode,
|
|
40
|
+
Qoder), run "npx @m2canvas/connect" without "bridge".
|
|
56
41
|
|
|
57
42
|
Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
|
|
58
|
-
--background run without a terminal
|
|
59
|
-
code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
--background run without a terminal; this is the only mode that accepts pages.
|
|
44
|
+
Exits with code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port,
|
|
45
|
+
${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it stops by itself after 2 hours
|
|
46
|
+
--port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
|
|
47
|
+
--cwd <dir> working directory for agent sessions
|
|
48
|
+
--dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')})
|
|
49
|
+
--replace if an M2 bridge is already running on the port, stop it first
|
|
50
|
+
(its agent sessions end) and start this one instead; not on Windows
|
|
51
|
+
--verbose also print agent stderr and connection details
|
|
62
52
|
--check-update update this installation to the version M2 publishes, then exit
|
|
63
53
|
--self-test check that this installation loads completely, print its version and exit
|
|
64
54
|
--stop stop the M2 bridge running on the port (only one it can recognize; it's
|
|
65
|
-
sent SIGTERM so it cleans up), then exit
|
|
55
|
+
sent SIGTERM so it cleans up), then exit
|
|
56
|
+
-h, --help show this help`;
|
|
57
|
+
/**
|
|
58
|
+
* 不带 `--background` 时的说明(change bridge-client-only D7,用户 2026-11-07「去掉NPX」):终端模式发不出票据,
|
|
59
|
+
* 任何页面都连不上,所以直接说明并退出。不带 `bridge` 的 `npx @m2canvas/connect` 是给外接 agent 写 M2 配置的命令,不受影响。
|
|
60
|
+
*/
|
|
61
|
+
export const TERMINAL_MODE_UNSUPPORTED = `Local agents in M2 now run only inside the M2 desktop app.
|
|
62
|
+
Open your project in M2 for Mac and connect from the Agent panel there; the app starts this bridge for you.
|
|
63
|
+
To add M2 to an agent client instead, run "npx @m2canvas/connect" (without "bridge").`;
|
|
66
64
|
/** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
|
|
67
65
|
export function parseBridgeArgs(argv) {
|
|
68
|
-
const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false,
|
|
66
|
+
const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, help: false, dev: false, legacyPair: false, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
|
|
69
67
|
for (let i = 0; i < argv.length; i++) {
|
|
70
68
|
const arg = argv[i];
|
|
71
69
|
if (arg === '-h' || arg === '--help')
|
|
72
70
|
args.help = true;
|
|
73
71
|
else if (arg === '--verbose')
|
|
74
72
|
args.verbose = true;
|
|
75
|
-
else if (arg === '--unpair')
|
|
76
|
-
args.unpair = true;
|
|
77
73
|
else if (arg === '--dev')
|
|
78
74
|
args.dev = true;
|
|
79
75
|
else if (arg === '--replace')
|
|
@@ -87,12 +83,10 @@ export function parseBridgeArgs(argv) {
|
|
|
87
83
|
else if (arg === '--stop')
|
|
88
84
|
args.stop = true;
|
|
89
85
|
else if (arg === '--pair') {
|
|
90
|
-
|
|
91
|
-
if (
|
|
86
|
+
// 值照收不验:旧启动器已按自己的规则校验过;这里拒绝任何值都会让启动器把新版本拉黑(见 BridgeArgs.legacyPair)
|
|
87
|
+
if (argv[++i] === undefined)
|
|
92
88
|
return '--pair needs a value';
|
|
93
|
-
|
|
94
|
-
return `invalid --pair value ${value}; copy the start command from M2 again`;
|
|
95
|
-
args.pair = value;
|
|
89
|
+
args.legacyPair = true;
|
|
96
90
|
}
|
|
97
91
|
else if (arg === '--port' || arg === '--cwd') {
|
|
98
92
|
const value = argv[++i];
|
|
@@ -1,26 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* bridge 方法的分派(从 server.ts
|
|
3
|
-
*
|
|
2
|
+
* bridge 方法的分派(从 server.ts 拆出):按方法交给 service / ProcessManager / auth-call.ts。
|
|
3
|
+
* 连接状态(怎么认证的、连接是否还在)只在 server.ts 里改,这里经 ctx 的回调读。
|
|
4
4
|
*
|
|
5
|
-
* 安全边界同 server.ts 文件头:页面可控的参数只有 agent id
|
|
5
|
+
* 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、processId、`process/start` 的会话令牌开关与用于比对的 userId
|
|
6
|
+
* (change acp-session-m2-token,契约 3 / 21:params 只认 `agent` 与 `m2Session: { userId }`,多一个键即 bad_message),进不了 spawn 的命令、参数、cwd、环境;
|
|
6
7
|
* agent/authorize 与 agent/authorize-cancel 的参数校验在 auth-call.ts。
|
|
7
8
|
* bridge/uninstall 不经过这里:server.ts 在调用 handleCall 之前就把它交给 uninstall-call.ts(它要等回复写出再关连接、退出)。
|
|
8
9
|
*/
|
|
9
10
|
import { isBridgeAgentId } from './agents.js';
|
|
10
11
|
import { handleAuthCall } from './auth-call.js';
|
|
11
|
-
import { handlePairCall } from './pair-call.js';
|
|
12
|
-
import { TOKEN_IDLE_DAYS } from './pairing.js';
|
|
13
12
|
import { ProcessError } from './processes.js';
|
|
13
|
+
import { isUserId } from './session-token.js';
|
|
14
14
|
import { BridgeFailure } from './service.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
/** params 必须恰好是这些键(自有属性),同 auth-call.ts */
|
|
16
|
+
function hasExactKeys(value, keys) {
|
|
17
|
+
const own = Object.keys(value);
|
|
18
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `process/start` 的参数(契约 3 / 21):只认 `agent` 与可选的 `m2Session: { userId }`。返回会话令牌开关;不合规抛 bad_message。
|
|
22
|
+
* 旧版 bridge 不校验多余字段、会把 `m2Session` 静默丢掉,所以页面只按预检的 `m2Session` 决定走哪条路径(design D11)。
|
|
23
|
+
*/
|
|
24
|
+
function sessionParam(params) {
|
|
25
|
+
if (hasExactKeys(params, ['agent']))
|
|
26
|
+
return undefined;
|
|
27
|
+
if (!hasExactKeys(params, ['agent', 'm2Session']))
|
|
28
|
+
throw new BridgeFailure('bad_message', 'process/start only accepts agent and m2Session');
|
|
29
|
+
const session = params.m2Session;
|
|
30
|
+
if (typeof session !== 'object' || session === null || Array.isArray(session) || !hasExactKeys(session, ['userId'])) {
|
|
31
|
+
throw new BridgeFailure('bad_message', 'm2Session only accepts userId');
|
|
21
32
|
}
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
const userId = session.userId;
|
|
34
|
+
if (!isUserId(userId))
|
|
35
|
+
throw new BridgeFailure('bad_message', 'm2Session.userId is not a valid user id');
|
|
36
|
+
return { userId };
|
|
37
|
+
}
|
|
38
|
+
export async function handleCall(ctx, conn, id, method, params) {
|
|
39
|
+
// 纵深判断:upgrade 只放行带有效票据的连接(server.ts 也在调用前判过一次)
|
|
40
|
+
if (conn.auth !== 'client-ticket')
|
|
41
|
+
throw new BridgeFailure('not_paired', 'this connection was not authorized by the M2 desktop app');
|
|
24
42
|
if (method === 'process/stop') {
|
|
25
43
|
const processId = params.processId;
|
|
26
44
|
if (typeof processId !== 'string' || !ctx.processes.stop(conn.id, processId))
|
|
@@ -44,14 +62,16 @@ export async function handleCall(ctx, conn, id, method, params) {
|
|
|
44
62
|
ctx.log(`Page asked to add M2 to ${agent}'s MCP config.`);
|
|
45
63
|
return { agent, ...(await ctx.service.configure(agent)) };
|
|
46
64
|
case 'process/start': {
|
|
47
|
-
|
|
65
|
+
// 先校验参数,再做任何检测(多余键不能借检测的副作用);连接是否经客户端认证已在函数开头判过(1a 的门,契约 21 依赖它)
|
|
66
|
+
const session = sessionParam(params);
|
|
67
|
+
const { m2SessionExpiresAt, ...launch } = await ctx.service.launch(agent, session);
|
|
48
68
|
try {
|
|
49
69
|
const processId = await ctx.processes.start(conn.id, agent, launch);
|
|
50
70
|
// 启动期间页面已断开:没有人会再停它,立刻清理
|
|
51
71
|
if (!ctx.isConnected(conn.id))
|
|
52
72
|
ctx.processes.stopOwner(conn.id);
|
|
53
|
-
ctx.log(`Started ${agent}.`);
|
|
54
|
-
return { processId, agent, cwd: ctx.cwd };
|
|
73
|
+
ctx.log(session ? `Started ${agent} with the M2 session token.` : `Started ${agent}.`);
|
|
74
|
+
return { processId, agent, cwd: ctx.cwd, ...(m2SessionExpiresAt === undefined ? {} : { m2SessionExpiresAt }) };
|
|
55
75
|
}
|
|
56
76
|
catch (error) {
|
|
57
77
|
if (error instanceof ProcessError)
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* (脚本不能 import 这里)。改这里要同批改那两处;脚本测试会按这里的路径核对它们的产物。
|
|
6
6
|
*
|
|
7
7
|
* 全部在用户主目录下,不写任何系统目录、不注册任何启动项、不改 PATH(`connect/local-bridge` 契约 14 delta)。
|
|
8
|
-
*
|
|
8
|
+
* 其余已有文件(状态文件 `bridge-<port>.json`、票据 socket `bridge-<port>.sock`、适配器 `acp-adapters/`)位置不变;旧版本的配对记录
|
|
9
|
+
* `bridge-pairings.json` 由新版本 bridge 启动时删除(change bridge-client-only D4)。
|
|
9
10
|
*/
|
|
10
11
|
import { join } from 'node:path';
|
|
11
12
|
export function installLayout(home) {
|
package/dist/bridge/main.js
CHANGED
|
@@ -3,38 +3,45 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 只由 cli.ts 在第一个参数是 `bridge` 时动态加载:默认的 MCP 配置流程(Copy to agent 起手式)不会加载这里的任何代码与依赖。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 本文件负责组装:启动横幅、信号处理、退出兜底清理。参数与帮助在 args.ts,协议在 server.ts,安全判定在 security.ts / ticket.ts。
|
|
7
|
+
* 输出用英文,与 connect 现有输出一致。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* 只剩后台模式(`--background`,由启动器 `~/.m2canvas/bin/m2-bridge` 使用;M2 客户端运行启动器)。change bridge-client-only 起:
|
|
10
|
+
* - 页面只能经 M2 客户端主进程替它领的一次性票据连上(D2 / D4)。票据从本进程监听的 `~/.m2canvas/bridge-<port>.sock` 领(ticket-socket.ts);
|
|
11
|
+
* socket 建不起来时退出,不留一个谁也连不上的进程(F7)。
|
|
12
|
+
* - 不带 `--background` 时直接说明「只能在 M2 客户端里用」并退出(D7,用户 2026-11-07「去掉NPX」)。
|
|
13
|
+
* - 没有配对:`--pair` 只为启动器兼容而接受(F1),拿到端口之后删掉遗留的 `bridge-pairings.json`。
|
|
14
|
+
* 输出写进日志文件,所以不能出现票据。端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
|
|
15
|
+
* 在 macOS 上还能卸载自己(`bridge/uninstall`,change bridge-uninstall-from-page D2 / D3;执行在 uninstall.ts)。
|
|
13
16
|
*/
|
|
14
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
15
18
|
import { join } from 'node:path';
|
|
16
19
|
import { fileURLToPath } from 'node:url';
|
|
17
20
|
import { home, isExecutable, which } from '../system.js';
|
|
18
21
|
import { AdapterManager, ADAPTERS, isAdapterAgent } from './adapters.js';
|
|
19
|
-
import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
|
|
22
|
+
import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, TERMINAL_MODE_UNSUPPORTED, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
|
|
20
23
|
import { detectAgent } from './agents.js';
|
|
21
24
|
import { EXPECT_PATH } from './authorize.js';
|
|
22
25
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
23
26
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
24
27
|
import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
|
|
25
|
-
import { handOffCommitment, listenForHandoff } from './handoff.js';
|
|
26
28
|
import { installLayout } from './install-layout.js';
|
|
27
29
|
import { installShutdownSignals } from './lifecycle.js';
|
|
28
|
-
import { PairingManager, createFilePairingStore } from './pairing.js';
|
|
29
30
|
import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
|
|
30
31
|
import { resolveNpmCommand } from './npm-command.js';
|
|
31
32
|
import { allowedOrigins } from './security.js';
|
|
32
33
|
import { prewarmPrechecks } from './precheck-cache.js';
|
|
33
34
|
import { selfTestProblems } from './self-test.js';
|
|
34
35
|
import { runCheckUpdate } from './update-io.js';
|
|
36
|
+
import { welcomeRejectedOption } from './update.js';
|
|
35
37
|
import { startBridgeServer } from './server.js';
|
|
36
38
|
import { createAgentService } from './service.js';
|
|
39
|
+
import { createSessionTokens } from './session-token.js';
|
|
40
|
+
import { ensureHelperScript, readTextFile } from './session-token-io.js';
|
|
41
|
+
import { M2_MCP_URL } from '../site-config.js';
|
|
37
42
|
import { KILL_GRACE_MS } from './processes.js';
|
|
43
|
+
import { TicketBook } from './ticket.js';
|
|
44
|
+
import { listenForTicketRequests, ticketSocketPathProblem } from './ticket-socket.js';
|
|
38
45
|
import { createSelfUninstaller, uninstallScriptPath } from './uninstall.js';
|
|
39
46
|
const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
|
|
40
47
|
/** 启动时预热预检的 agent(契约 18;用户 2026-11-04 拍板 B-P1:随包的两家,不管用没用过) */
|
|
@@ -53,31 +60,24 @@ function packageVersion() {
|
|
|
53
60
|
const version = typeof parsed === 'object' && parsed !== null ? parsed.version : undefined;
|
|
54
61
|
return typeof version === 'string' ? version : '0.0.0';
|
|
55
62
|
}
|
|
56
|
-
function printCode(info) {
|
|
57
|
-
const until = new Date(info.expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
58
|
-
const code = `Pairing code: ${info.display} (valid until ${until})`;
|
|
59
|
-
if (info.reason === 'startup')
|
|
60
|
-
console.log(`\n ${code}\n Enter it in M2 when it asks to connect to this bridge.\n`);
|
|
61
|
-
else if (info.reason === 'requested')
|
|
62
|
-
console.log(`\n→ ${info.origin ?? 'A page'} wants to pair with this bridge. ${code}\n Only enter it if you opened M2 yourself.\n`);
|
|
63
|
-
else if (info.reason === 'expired')
|
|
64
|
-
console.log(`\n→ The pairing code expired. New ${code}\n`);
|
|
65
|
-
else
|
|
66
|
-
console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
|
|
67
|
-
}
|
|
68
63
|
function clock(at) {
|
|
69
64
|
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
70
65
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
/**
|
|
67
|
+
* 拿到端口之后删掉旧版本留下的配对记录(change bridge-client-only D4):里面只有哈希,删掉是为了不留下「还能用」的误解。
|
|
68
|
+
* 必须在拿到端口之后:端口上可能还是旧版本的 bridge,它每次校验 token 都重读这个文件。删不掉只记日志。
|
|
69
|
+
*/
|
|
70
|
+
function removeLegacyPairings(m2Dir, log) {
|
|
71
|
+
const path = join(m2Dir, 'bridge-pairings.json');
|
|
72
|
+
if (!existsSync(path))
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
rmSync(path, { force: true });
|
|
76
|
+
log('Removed the pairing records left by an older version; pages now connect through the M2 desktop app.');
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
log(`Couldn't remove ${path} (${error instanceof Error ? error.message : String(error)}); it isn't used anymore.`);
|
|
80
|
+
}
|
|
81
81
|
}
|
|
82
82
|
function describeAdapter(agent, info) {
|
|
83
83
|
const name = `${LABELS[agent]} adapter (${ADAPTERS[agent].package}@${ADAPTERS[agent].version})`;
|
|
@@ -129,34 +129,31 @@ export async function runBridge(argv) {
|
|
|
129
129
|
return runCheckUpdate();
|
|
130
130
|
if (parsed.stop)
|
|
131
131
|
return stopRecognizedBridge(parsed.port);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
console.log('Forgot all paired pages. M2 will ask for a new pairing code next time.');
|
|
137
|
-
return 0;
|
|
132
|
+
// 终端模式发不出票据,任何页面都连不上(D7):说明后退出,不占端口、不写状态文件
|
|
133
|
+
if (!parsed.background) {
|
|
134
|
+
console.error(TERMINAL_MODE_UNSUPPORTED);
|
|
135
|
+
return 1;
|
|
138
136
|
}
|
|
137
|
+
const m2Dir = join(home, '.m2canvas');
|
|
139
138
|
if (!existsSync(parsed.cwd) || !statSync(parsed.cwd).isDirectory()) {
|
|
140
139
|
console.error(`--cwd ${parsed.cwd} is not a directory.`);
|
|
141
140
|
return 2;
|
|
142
141
|
}
|
|
143
142
|
const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
|
|
143
|
+
// 票据 socket 的路径放不进 sun_path 时,开始监听之前就退出:不去占端口,也不会 --replace 掉正在运行的那个(F7)
|
|
144
|
+
const socketProblem = ticketSocketPathProblem(m2Dir, parsed.port);
|
|
145
|
+
if (socketProblem) {
|
|
146
|
+
log(`The M2 desktop app can't reach this bridge: ${socketProblem}. Not starting.`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
if (parsed.legacyPair)
|
|
150
|
+
log('Ignoring --pair: pages no longer pair with this bridge; the M2 desktop app authorizes each connection.');
|
|
144
151
|
const probeEnv = buildProbeEnv();
|
|
145
|
-
// 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3
|
|
152
|
+
// 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3)
|
|
146
153
|
const npm = resolveNpmCommand({ background: parsed.background, execPath: process.execPath, platform: process.platform, which });
|
|
147
154
|
let server = null;
|
|
148
|
-
//
|
|
149
|
-
const
|
|
150
|
-
store,
|
|
151
|
-
// 后台模式没有终端、输出写进日志文件:不出码(契约 2「码只出现在终端」),配对只剩命令承诺与已有 token(D17 / D18)
|
|
152
|
-
issueCodes: !parsed.background,
|
|
153
|
-
onCodeIssued: printCode,
|
|
154
|
-
onLocked: printLocked,
|
|
155
|
-
onProblem: (message) => console.warn(message),
|
|
156
|
-
onCommitmentReused: printReused,
|
|
157
|
-
});
|
|
158
|
-
// 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
|
|
159
|
-
const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
|
|
155
|
+
// 票据只在本进程内存里(ticket.ts):进程退出全部失效
|
|
156
|
+
const tickets = new TicketBook();
|
|
160
157
|
// 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
|
|
161
158
|
const loggedState = new Map();
|
|
162
159
|
const adapters = new AdapterManager({
|
|
@@ -196,6 +193,17 @@ export async function runBridge(argv) {
|
|
|
196
193
|
expectAvailable: () => isExecutable(EXPECT_PATH),
|
|
197
194
|
// 出现过「通过」之后一次真跑没通过:告诉之前可能拿到通过的页面(尽力收回,契约 18)
|
|
198
195
|
onPrecheckRevoked: (result) => server?.broadcast('precheck/updated', result),
|
|
196
|
+
// M2 会话令牌(契约 21):令牌文件由 M2 客户端维护,这里只读;地址只取站点配置,--dev 也一样(design T3)
|
|
197
|
+
sessionTokens: createSessionTokens({
|
|
198
|
+
home,
|
|
199
|
+
cwd: parsed.cwd,
|
|
200
|
+
agentEnv: buildAgentEnv(),
|
|
201
|
+
mcpUrl: M2_MCP_URL,
|
|
202
|
+
readText: (path) => readTextFile(path),
|
|
203
|
+
ensureHelper: ensureHelperScript,
|
|
204
|
+
now: Date.now,
|
|
205
|
+
log,
|
|
206
|
+
}),
|
|
199
207
|
log,
|
|
200
208
|
});
|
|
201
209
|
const version = packageVersion();
|
|
@@ -203,10 +211,8 @@ export async function runBridge(argv) {
|
|
|
203
211
|
// 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
|
|
204
212
|
const instancePath = instanceFilePath(m2Dir, parsed.port);
|
|
205
213
|
let alreadyRunning = false;
|
|
206
|
-
/** 最近一次因 Origin 不在白名单被拒的页面(desktop-bridge-launcher D15):交接 socket 的状态查询带回给 M2 Mac 客户端 */
|
|
207
|
-
let lastRejected = null;
|
|
208
214
|
// 只有启动器从 ~/.m2canvas 拉起的后台 bridge、且在 macOS 上(后台安装只有 macOS,契约 15)才能卸载自己(D2)。
|
|
209
|
-
// 回调里用到的
|
|
215
|
+
// 回调里用到的 ticketSocket / stop 在下面才赋值:卸载只会在服务开始监听、它们都已就绪之后被调用
|
|
210
216
|
const uninstaller = parsed.background && process.platform === 'darwin'
|
|
211
217
|
? createSelfUninstaller({
|
|
212
218
|
scriptPath: uninstallScriptPath(PACKAGE_ROOT),
|
|
@@ -218,7 +224,7 @@ export async function runBridge(argv) {
|
|
|
218
224
|
stopInstalling: () => adapters.stopInstalling(),
|
|
219
225
|
killExecs: killActiveExecs,
|
|
220
226
|
forgetSelf: () => removeInstanceRecord(instancePath, process.pid),
|
|
221
|
-
|
|
227
|
+
closeTicketSocket: () => ticketSocket?.close(),
|
|
222
228
|
exit: () => void stop(),
|
|
223
229
|
})
|
|
224
230
|
: undefined;
|
|
@@ -233,14 +239,13 @@ export async function runBridge(argv) {
|
|
|
233
239
|
node: process.versions.node,
|
|
234
240
|
...(parsed.background ? { background: true } : {}),
|
|
235
241
|
},
|
|
236
|
-
|
|
242
|
+
tickets,
|
|
237
243
|
service,
|
|
238
244
|
agentEnv: buildAgentEnv(),
|
|
239
245
|
allowedOrigins: origins,
|
|
240
246
|
uninstall: uninstaller,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
},
|
|
247
|
+
// 启动器拉黑的版本只对后台模式有意义(终端模式不经启动器,change bridge-normal-update D6);每次 welcome 现读
|
|
248
|
+
...welcomeRejectedOption({ background: parsed.background, home }),
|
|
244
249
|
log,
|
|
245
250
|
verbose: parsed.verbose,
|
|
246
251
|
}), {
|
|
@@ -257,14 +262,9 @@ export async function runBridge(argv) {
|
|
|
257
262
|
alreadyRunning = true;
|
|
258
263
|
},
|
|
259
264
|
});
|
|
260
|
-
// listenWithReplace 返回 null
|
|
261
|
-
// 已在运行时先把承诺交给它,新页面不用重启 bridge 就能配对(D27);交不过去也照常退出码 3
|
|
262
|
-
if (!server && parsed.background && alreadyRunning && parsed.pair) {
|
|
263
|
-
const handed = await handOffCommitment({ dir: m2Dir, port: parsed.port, commitment: parsed.pair });
|
|
264
|
-
log(handed ? 'Handed the pairing to the M2 bridge already running on this port; the new page pairs with it.' : "Couldn't hand the pairing to the M2 bridge already running on this port; the new page can reconnect with --replace.");
|
|
265
|
-
}
|
|
265
|
+
// listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的):把「已在运行」与其余分开
|
|
266
266
|
if (!server)
|
|
267
|
-
return
|
|
267
|
+
return alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE;
|
|
268
268
|
const running = server;
|
|
269
269
|
try {
|
|
270
270
|
// startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
|
|
@@ -273,8 +273,10 @@ export async function runBridge(argv) {
|
|
|
273
273
|
catch (error) {
|
|
274
274
|
console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
|
|
275
275
|
}
|
|
276
|
-
//
|
|
277
|
-
|
|
276
|
+
// 端口已经是自己的:旧版本的 bridge 不会再读配对文件了
|
|
277
|
+
removeLegacyPairings(m2Dir, log);
|
|
278
|
+
// 票据 socket 在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
|
|
279
|
+
let ticketSocket = null;
|
|
278
280
|
// 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
|
|
279
281
|
// 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
|
|
280
282
|
process.on('exit', () => {
|
|
@@ -286,7 +288,7 @@ export async function runBridge(argv) {
|
|
|
286
288
|
// 见上
|
|
287
289
|
}
|
|
288
290
|
try {
|
|
289
|
-
|
|
291
|
+
ticketSocket?.close();
|
|
290
292
|
}
|
|
291
293
|
catch {
|
|
292
294
|
// 残留的 socket 文件由下一次启动删掉
|
|
@@ -304,7 +306,7 @@ export async function runBridge(argv) {
|
|
|
304
306
|
const exited = new Promise((resolve) => {
|
|
305
307
|
resolveExit = resolve;
|
|
306
308
|
});
|
|
307
|
-
const stop = async () => {
|
|
309
|
+
const stop = async (code = 0) => {
|
|
308
310
|
log('Stopping the bridge and its agents…');
|
|
309
311
|
// 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
|
|
310
312
|
// 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
|
|
@@ -315,7 +317,7 @@ export async function runBridge(argv) {
|
|
|
315
317
|
while (running.processes.size > 0 && Date.now() < deadline)
|
|
316
318
|
await new Promise((r) => setTimeout(r, 100));
|
|
317
319
|
running.processes.killAllNow();
|
|
318
|
-
resolveExit(
|
|
320
|
+
resolveExit(code);
|
|
319
321
|
};
|
|
320
322
|
installShutdownSignals(process, {
|
|
321
323
|
graceful: () => void stop(),
|
|
@@ -327,14 +329,12 @@ export async function runBridge(argv) {
|
|
|
327
329
|
process.exit(130);
|
|
328
330
|
},
|
|
329
331
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
log,
|
|
337
|
-
});
|
|
332
|
+
ticketSocket = await listenForTicketRequests({ dir: m2Dir, port: parsed.port, issue: () => tickets.issue(), log });
|
|
333
|
+
if (!ticketSocket) {
|
|
334
|
+
// 没有 socket 就领不到票据,任何页面都连不上:退出,让启动器与客户端看到「没在运行」而不是「已在运行」(F7)
|
|
335
|
+
log('No page can connect without the ticket socket; stopping.');
|
|
336
|
+
await stop(1);
|
|
337
|
+
return exited;
|
|
338
338
|
}
|
|
339
339
|
// 预热(契约 18,用户 2026-11-04 拍板 B-P1):信号处理装好之后再起,收尾时 killActiveExecs 能杀掉它的命令;不等它,页面来查会加入
|
|
340
340
|
void prewarmPrechecks({
|
|
@@ -353,22 +353,10 @@ export async function runBridge(argv) {
|
|
|
353
353
|
console.log(`M2 local agent bridge ${version}`);
|
|
354
354
|
console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
|
|
355
355
|
console.log(` Working directory for agents: ${parsed.cwd}`);
|
|
356
|
-
console.log(` Accepts pages from: ${origins.join(', ')}`);
|
|
357
|
-
if (parsed.dev)
|
|
358
|
-
console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
|
|
359
|
-
// 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
|
|
360
|
-
// 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
|
|
361
|
-
if (parsed.background)
|
|
362
|
-
console.log(' Started in the background by the M2 launcher: no pairing codes are shown; pair from M2 with a new start command.');
|
|
356
|
+
console.log(` Accepts only pages the M2 desktop app authorizes, from: ${origins.join(', ')}`);
|
|
363
357
|
if (lifetime !== null)
|
|
364
358
|
console.log(` Dev mode in the background: stops by itself at ${clock(Date.now() + lifetime)}.`);
|
|
365
|
-
if (pairUntil !== null)
|
|
366
|
-
console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
|
|
367
|
-
else if (!parsed.background)
|
|
368
|
-
pairing.issueCode('startup');
|
|
369
359
|
const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
|
|
370
360
|
console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
|
|
371
|
-
if (!parsed.background)
|
|
372
|
-
console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
|
|
373
361
|
return exited;
|
|
374
362
|
}
|