@m2canvas/connect 0.5.0 → 0.6.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 +5 -4
- package/dist/bridge/adapters.js +118 -3
- package/dist/bridge/agents.js +5 -0
- package/dist/bridge/auth-call.js +68 -0
- package/dist/bridge/authorize.js +341 -0
- package/dist/bridge/env.js +12 -0
- package/dist/bridge/errors.js +9 -0
- package/dist/bridge/exec.js +53 -12
- package/dist/bridge/instance.js +239 -0
- package/dist/bridge/main.js +63 -25
- package/dist/bridge/precheck.js +22 -6
- package/dist/bridge/protocol.js +11 -1
- package/dist/bridge/server.js +51 -20
- package/dist/bridge/service.js +103 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,11 +36,12 @@ Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on
|
|
|
36
36
|
|
|
37
37
|
- **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://m2ui.ai`. 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
|
-
- **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.
|
|
40
|
-
- **Your own logins.** Agents use their own sign-in and their own M2 MCP setup
|
|
41
|
-
- **
|
|
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
|
+
- **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
|
+
- **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
|
|
42
|
+
- **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (about 270 MB and 350 MB, because they bundle the agent); 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.
|
|
42
43
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
|
43
44
|
|
|
44
45
|
Agent sessions start in that directory.
|
|
45
46
|
|
|
46
|
-
Options: `--port <n>`, `--cwd <dir>` (default: current directory), `--verbose` (also print agent stderr), `--dev` (also accept the local M2 dev server).
|
|
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).
|
package/dist/bridge/adapters.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* - `--ignore-scripts`:两条依赖链上没有安装脚本(实测),加上它能挡掉安装期执行代码。
|
|
14
14
|
* - 安装目录名带 lockfile 哈希:换了 lockfile 就是新目录,旧目录不会被半新半旧地复用。旧目录目前不清理(FU-2026-10-02-120)。
|
|
15
15
|
* - 先装到临时目录、写好就绪标记再原子改名;残留的临时目录或没有标记的目录一律视为未安装。
|
|
16
|
+
* - 升级 Claude 适配器时复核 bundledClaudePath 与 SDK 自身的解析规则是否仍一致(acp-terminal-free-auth evidence/sdk-binary-resolution.md),
|
|
17
|
+
* 并按新版本实测 installedBytes。
|
|
16
18
|
*/
|
|
17
19
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
19
22
|
import { join } from 'node:path';
|
|
20
23
|
import { combinedOutput } from './exec.js';
|
|
21
24
|
import { tail } from './precheck-parse.js';
|
|
@@ -25,18 +28,87 @@ export const ADAPTERS = {
|
|
|
25
28
|
package: '@agentclientprotocol/claude-agent-acp',
|
|
26
29
|
version: '0.84.0',
|
|
27
30
|
entry: 'node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js',
|
|
31
|
+
installedBytes: 268_451_230,
|
|
28
32
|
},
|
|
29
33
|
codex: {
|
|
30
34
|
dir: 'codex-acp',
|
|
31
35
|
package: '@agentclientprotocol/codex-acp',
|
|
32
36
|
version: '2.1.0',
|
|
33
37
|
entry: 'node_modules/@agentclientprotocol/codex-acp/dist/index.js',
|
|
38
|
+
installedBytes: 346_221_434,
|
|
34
39
|
},
|
|
35
40
|
};
|
|
36
41
|
/** 适配器自带的 codex:预检与授权命令都要用它(本机旧版 codex 存的凭证,新版本刷新不了,PoC 报告 §2.2) */
|
|
37
42
|
export const CODEX_SCRIPT = 'node_modules/@openai/codex/bin/codex.js';
|
|
38
43
|
export const CODEX_BIN_LINK = 'node_modules/.bin/codex';
|
|
39
44
|
const READY_MARKER = '.m2canvas-ready';
|
|
45
|
+
const PROGRESS_INTERVAL_MS = 1000;
|
|
46
|
+
/**
|
|
47
|
+
* Linux 上没有 glibc(Alpine 等)时优先 musl 版本:与 claude-agent-sdk 自身的判断相同(process.report 的 glibcVersionRuntime)。
|
|
48
|
+
*/
|
|
49
|
+
function preferMusl() {
|
|
50
|
+
if (process.platform !== 'linux')
|
|
51
|
+
return false;
|
|
52
|
+
const report = typeof process.report?.getReport === 'function' ? process.report.getReport() : null;
|
|
53
|
+
return report !== null && report.header?.glibcVersionRuntime === undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 适配器自带的 claude(D9):会话真正用的是它,预检、写配置、授权也都用它,看到的才是同一份程序。
|
|
57
|
+
* 规则照搬 claude-agent-sdk 0.3.284 的解析(候选 `@anthropic-ai/claude-agent-sdk-<platform>-<arch>[-musl]/claude[.exe]`,
|
|
58
|
+
* 取第一个存在的)。找不到返回 null,调用方报 unknown —— **不得退回本机的 claude**,否则预检和会话用的是两个程序。
|
|
59
|
+
*/
|
|
60
|
+
export function bundledClaudePath(installDir, options = {}) {
|
|
61
|
+
const platform = options.platform ?? process.platform;
|
|
62
|
+
const arch = options.arch ?? process.arch;
|
|
63
|
+
const exists = options.exists ?? existsSync;
|
|
64
|
+
const musl = options.preferMusl ?? preferMusl();
|
|
65
|
+
const pkg = '@anthropic-ai/claude-agent-sdk';
|
|
66
|
+
const names = platform === 'android'
|
|
67
|
+
? [`${pkg}-linux-${arch}-android`]
|
|
68
|
+
: platform === 'linux'
|
|
69
|
+
? musl
|
|
70
|
+
? [`${pkg}-linux-${arch}-musl`, `${pkg}-linux-${arch}`]
|
|
71
|
+
: [`${pkg}-linux-${arch}`, `${pkg}-linux-${arch}-musl`]
|
|
72
|
+
: [`${pkg}-${platform}-${arch}`];
|
|
73
|
+
const binary = platform === 'win32' ? 'claude.exe' : 'claude';
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
const candidate = join(installDir, 'node_modules', ...name.split('/'), binary);
|
|
76
|
+
if (exists(candidate))
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/** 已写入字节 / 预估体积 → 0–0.99(两位小数);算不出时 undefined(页面退回转圈) */
|
|
82
|
+
export function estimateProgress(bytes, expected) {
|
|
83
|
+
if (!Number.isFinite(bytes) || !Number.isFinite(expected) || expected <= 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
return Math.min(0.99, Math.max(0, Math.floor((bytes / expected) * 100) / 100));
|
|
86
|
+
}
|
|
87
|
+
/** 目录里所有文件的字节数(不跟随符号链接)。npm 正在挪动文件,读不到的项直接跳过 */
|
|
88
|
+
async function directoryBytes(dir) {
|
|
89
|
+
let total = 0;
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const path = join(dir, entry.name);
|
|
99
|
+
if (entry.isDirectory())
|
|
100
|
+
total += await directoryBytes(path);
|
|
101
|
+
else {
|
|
102
|
+
try {
|
|
103
|
+
total += (await lstat(path)).size;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// 刚被 npm 移走
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
40
112
|
function isProcessAlive(pid) {
|
|
41
113
|
try {
|
|
42
114
|
process.kill(pid, 0);
|
|
@@ -54,6 +126,7 @@ export class AdapterManager {
|
|
|
54
126
|
options;
|
|
55
127
|
installing = new Map();
|
|
56
128
|
lastError = new Map();
|
|
129
|
+
progress = new Map();
|
|
57
130
|
constructor(options) {
|
|
58
131
|
this.options = options;
|
|
59
132
|
}
|
|
@@ -71,13 +144,19 @@ export class AdapterManager {
|
|
|
71
144
|
const dir = this.installDir(agent);
|
|
72
145
|
return existsSync(join(dir, READY_MARKER)) && existsSync(join(dir, ADAPTERS[agent].entry));
|
|
73
146
|
}
|
|
147
|
+
/** 适配器自带的 claude(D9);适配器没就绪或找不到时为 null */
|
|
148
|
+
claudeBinary() {
|
|
149
|
+
return this.isReady('claude') ? bundledClaudePath(this.installDir('claude')) : null;
|
|
150
|
+
}
|
|
74
151
|
info(agent) {
|
|
75
152
|
const spec = ADAPTERS[agent];
|
|
76
153
|
const base = { package: spec.package, version: spec.version };
|
|
77
|
-
if (this.installing.has(agent))
|
|
78
|
-
|
|
154
|
+
if (this.installing.has(agent)) {
|
|
155
|
+
const progress = this.progress.get(agent);
|
|
156
|
+
return { ...base, state: 'installing', error: null, ...(progress === undefined ? {} : { progress }) };
|
|
157
|
+
}
|
|
79
158
|
if (this.isReady(agent))
|
|
80
|
-
return { ...base, state: 'ready', error: null };
|
|
159
|
+
return { ...base, state: 'ready', error: null, progress: 1 };
|
|
81
160
|
const error = this.lastError.get(agent);
|
|
82
161
|
return error ? { ...base, state: 'failed', error } : { ...base, state: 'not_installed', error: null };
|
|
83
162
|
}
|
|
@@ -91,6 +170,7 @@ export class AdapterManager {
|
|
|
91
170
|
this.lastError.delete(agent);
|
|
92
171
|
const job = this.install(agent).finally(() => {
|
|
93
172
|
this.installing.delete(agent);
|
|
173
|
+
this.progress.delete(agent);
|
|
94
174
|
this.options.onState?.(agent, this.info(agent));
|
|
95
175
|
});
|
|
96
176
|
this.installing.set(agent, job.then(() => this.info(agent)));
|
|
@@ -117,11 +197,13 @@ export class AdapterManager {
|
|
|
117
197
|
const finalDir = this.installDir(agent);
|
|
118
198
|
this.removeStaleTemps(spec);
|
|
119
199
|
const tmpDir = join(this.options.installRoot, `.tmp-${spec.dir}-${process.pid}-${randomBytes(4).toString('hex')}`);
|
|
200
|
+
let stopProgress = () => undefined;
|
|
120
201
|
try {
|
|
121
202
|
mkdirSync(tmpDir, { recursive: true });
|
|
122
203
|
for (const file of ['package.json', 'package-lock.json']) {
|
|
123
204
|
copyFileSync(join(this.options.sourceRoot, spec.dir, file), join(tmpDir, file));
|
|
124
205
|
}
|
|
206
|
+
stopProgress = this.trackProgress(agent, tmpDir);
|
|
125
207
|
const result = await this.options.runNpmCi(tmpDir);
|
|
126
208
|
if (result.code !== 0)
|
|
127
209
|
throw new Error(`npm ci failed (${result.timedOut ? 'timed out' : `exit ${String(result.code)}`}): ${tail(combinedOutput(result))}`);
|
|
@@ -140,7 +222,40 @@ export class AdapterManager {
|
|
|
140
222
|
this.lastError.set(agent, error instanceof Error ? error.message : String(error));
|
|
141
223
|
}
|
|
142
224
|
finally {
|
|
225
|
+
stopProgress();
|
|
143
226
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
144
227
|
}
|
|
145
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* npm ci 不报进度(D12):每隔一段时间统计临时安装目录已写入的字节,除以预估体积得到近似进度,变化了才广播。
|
|
231
|
+
* 只读不写,统计失败当 0 处理;返回停止函数。
|
|
232
|
+
*/
|
|
233
|
+
trackProgress(agent, dir) {
|
|
234
|
+
const expected = this.options.expectedBytes?.[agent] ?? ADAPTERS[agent].installedBytes;
|
|
235
|
+
let stopped = false;
|
|
236
|
+
let measuring = false;
|
|
237
|
+
const timer = setInterval(() => {
|
|
238
|
+
if (measuring)
|
|
239
|
+
return;
|
|
240
|
+
measuring = true;
|
|
241
|
+
void directoryBytes(dir)
|
|
242
|
+
.then((bytes) => {
|
|
243
|
+
const next = estimateProgress(bytes, expected);
|
|
244
|
+
if (stopped || next === undefined || next === this.progress.get(agent))
|
|
245
|
+
return;
|
|
246
|
+
this.progress.set(agent, next);
|
|
247
|
+
this.options.onState?.(agent, this.info(agent));
|
|
248
|
+
})
|
|
249
|
+
// 广播失败(日志 / 连接写出出错)只影响这一次进度:不能变成未处理的 rejection 让 bridge 退出、杀掉所有 agent(契约 5)
|
|
250
|
+
.catch((error) => console.warn(`Could not report adapter install progress: ${error instanceof Error ? error.message : String(error)}`))
|
|
251
|
+
.finally(() => {
|
|
252
|
+
measuring = false;
|
|
253
|
+
});
|
|
254
|
+
}, this.options.progressIntervalMs ?? PROGRESS_INTERVAL_MS);
|
|
255
|
+
timer.unref();
|
|
256
|
+
return () => {
|
|
257
|
+
stopped = true;
|
|
258
|
+
clearInterval(timer);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
146
261
|
}
|
package/dist/bridge/agents.js
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
import { realpathSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { isExecutable, which } from '../system.js';
|
|
15
|
+
import { BRIDGE_AGENT_IDS } from './protocol.js';
|
|
15
16
|
export const VERSION_TIMEOUT_MS = 15_000;
|
|
17
|
+
/** 页面传来的 agent id 只能是五家之一(server.ts 与 auth-call.ts 共用) */
|
|
18
|
+
export function isBridgeAgentId(value) {
|
|
19
|
+
return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
|
|
20
|
+
}
|
|
16
21
|
/** claude / codex 经适配器拉起(不用检测到的本机二进制);其余三家用自带的 ACP 模式 */
|
|
17
22
|
export const LAUNCH_KIND = {
|
|
18
23
|
claude: 'adapter',
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent/authorize` / `agent/authorize-cancel` 的分派(change acp-terminal-free-auth,design D2 / D7 / D16),从 server.ts 拆出。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(契约 3):页面只能选 agent × 动作,params 只能恰好是规定的键,多一个(command、args、cwd、env、entry……)就 bad_message、
|
|
5
|
+
* 不静默忽略;命令计划由 service 按写死清单生成(authorize.ts)。授权链接只发给发起的连接;发起的连接关闭即取消(server.ts 的 close 监听器)。
|
|
6
|
+
* 终端日志说明页面让 bridge 做了什么与结果,**不打印链接**(带 state 等一次性参数)。
|
|
7
|
+
*/
|
|
8
|
+
import { isBridgeAgentId } from './agents.js';
|
|
9
|
+
import { BridgeFailure } from './errors.js';
|
|
10
|
+
import { BRIDGE_AUTH_ACTIONS } from './protocol.js';
|
|
11
|
+
function isAuthAction(value) {
|
|
12
|
+
return typeof value === 'string' && BRIDGE_AUTH_ACTIONS.includes(value);
|
|
13
|
+
}
|
|
14
|
+
/** params 必须恰好是这些键(自有属性,`__proto__` 之类的也算多出来的键) */
|
|
15
|
+
function hasExactKeys(params, keys) {
|
|
16
|
+
const own = Object.keys(params);
|
|
17
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(params, key));
|
|
18
|
+
}
|
|
19
|
+
function describeOutcome(action, result) {
|
|
20
|
+
switch (result.outcome) {
|
|
21
|
+
case 'succeeded':
|
|
22
|
+
return action === 'login' ? 'signed in' : 'authorized';
|
|
23
|
+
case 'failed':
|
|
24
|
+
return result.exitCode === null ? 'failed' : `failed (exit ${result.exitCode})`;
|
|
25
|
+
case 'canceled':
|
|
26
|
+
return 'canceled';
|
|
27
|
+
case 'timed_out':
|
|
28
|
+
return 'timed out';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function handleAuthCall(ctx, ownerId, method, params) {
|
|
32
|
+
const keys = method === 'agent/authorize' ? ['agent', 'action'] : ['agent'];
|
|
33
|
+
if (!hasExactKeys(params, keys))
|
|
34
|
+
throw new BridgeFailure('bad_message', `${method} takes exactly: ${keys.join(', ')}`);
|
|
35
|
+
const agent = params.agent;
|
|
36
|
+
if (!isBridgeAgentId(agent))
|
|
37
|
+
throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
|
|
38
|
+
if (method === 'agent/authorize-cancel') {
|
|
39
|
+
if (!ctx.authorizations.cancel(ownerId, agent))
|
|
40
|
+
throw new BridgeFailure('auth_not_found', `no sign-in for ${agent} in progress on this connection`);
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
const action = params.action;
|
|
44
|
+
if (!isAuthAction(action))
|
|
45
|
+
throw new BridgeFailure('bad_message', `action must be one of ${BRIDGE_AUTH_ACTIONS.join(', ')}`);
|
|
46
|
+
const what = action === 'login' ? 'sign-in' : 'M2 authorization';
|
|
47
|
+
const pending = ctx.authorizations.run({
|
|
48
|
+
ownerId,
|
|
49
|
+
agent,
|
|
50
|
+
action,
|
|
51
|
+
plan: () => ctx.service.authorizePlan(agent, action),
|
|
52
|
+
precheck: () => ctx.service.precheck(agent),
|
|
53
|
+
onLink: (link) => ctx.sendLink(ownerId, { agent, action, url: link.url, open: link.open }),
|
|
54
|
+
});
|
|
55
|
+
// 占用在 run 的同步部分已登记;此时页面若已断开(极少见),没有人会再取消它
|
|
56
|
+
if (!ctx.isConnected(ownerId))
|
|
57
|
+
ctx.authorizations.cancelOwner(ownerId);
|
|
58
|
+
ctx.log(action === 'login' ? `Page asked to sign in to ${agent}.` : `Page asked to authorize M2 for ${agent}.`);
|
|
59
|
+
try {
|
|
60
|
+
const result = await pending;
|
|
61
|
+
ctx.log(`${agent} ${what}: ${describeOutcome(action, result)}.`);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
ctx.log(`${agent} ${what}: did not start (${error instanceof BridgeFailure ? error.code : 'internal'}).`);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge 代跑授权命令(change acp-terminal-free-auth):账号登录与 m2 MCP 授权不再要求用户打开终端。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(`connect/local-bridge` 契约 3 / 8,design D2 / 安全分析):
|
|
5
|
+
* - 页面只能选「哪个 agent × 哪个动作」。可执行文件、参数、环境、cwd 全部来自下面写死的 AUTH_COMMANDS 与 bridge 自己的检测;
|
|
6
|
+
* m2 授权用的配置项名只取 bridge 自己的 m2 预检(调用方 service.ts 负责:优先用最近一次预检的结果,没有记录时拉起前重跑,D19),不接受页面传入。
|
|
7
|
+
* - 授权必须由用户在浏览器里亲自完成:bridge 只截下 CLI 要打开的授权页链接交给页面,从不替用户点任何东西。
|
|
8
|
+
*
|
|
9
|
+
* 链接怎么截(D4,各家行为实测见 evidence/auth-commands-macos.md):
|
|
10
|
+
* - 每次授权新建 0700 的临时目录,放 `open` / `xdg-open` 替身并排在 PATH 最前;替身只把参数逐行追加到 `M2_AUTH_LINK_FILE`,
|
|
11
|
+
* **从不执行参数、从不打开浏览器**。Claude / Cursor / OpenCode / Qoder 都经 PATH 上的 `open` 打开授权页。
|
|
12
|
+
* - Codex 的原生程序经 NSWorkspace 直接打开默认浏览器,截不到,但会把链接打印出来:从输出里取(`opens: 'agent'`,页面只作备用)。
|
|
13
|
+
* - 按每条命令的链接来源只取一种:Claude 打印在输出里的是「粘码」备用链接,回调不在本机,不能转交。
|
|
14
|
+
* - 只转交 https、≤ 8KB 的链接,同一条只转交一次;事件只发给发起授权的连接(server.ts)。
|
|
15
|
+
*
|
|
16
|
+
* 生命周期(D7):同一个 agent 同一时间一次(跨连接);拥有者是发起的连接,连接关闭即取消;10 分钟超时;
|
|
17
|
+
* 取消 / 超时 = 对进程组 SIGTERM,2 秒后仍在则 SIGKILL(OpenCode 收到 SIGTERM 约 20 秒不退出)。
|
|
18
|
+
* 命令经 exec.ts 的 startCommand 拉起,与一次性命令共用 active 集合,bridge 退出时一起清理;临时目录由 killAllNow 同步删除。
|
|
19
|
+
*
|
|
20
|
+
* Claude 的 m2 授权(D6):`claude mcp login` 遇到非终端直接拒绝,用 macOS 自带的 /usr/bin/expect 提供伪终端。
|
|
21
|
+
* 脚本文本固定,参数经 Tcl 的 $argv 列表传给 `spawn {*}$argv`,不拼进脚本。expect 退出后伪终端关闭,claude 随之收到 SIGHUP 退出,
|
|
22
|
+
* 所以对 expect 的进程组发信号足以清理(evidence/pty-cleanup.md)。
|
|
23
|
+
*
|
|
24
|
+
* 结果以预检为准(D8):命令结束后重跑预检随结果返回,outcome 只反映命令本身(退出码不可靠)。
|
|
25
|
+
*/
|
|
26
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { CODEX_SCRIPT } from './adapters.js';
|
|
30
|
+
import { prependPath } from './env.js';
|
|
31
|
+
import { BridgeFailure } from './errors.js';
|
|
32
|
+
import { combinedOutput, startCommand } from './exec.js';
|
|
33
|
+
import { stripAnsi } from './precheck-parse.js';
|
|
34
|
+
import { BRIDGE_MAX_AUTH_LINK_CHARS } from './protocol.js';
|
|
35
|
+
export const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
|
+
export const AUTH_KILL_GRACE_MS = 2_000;
|
|
37
|
+
export const AUTH_LINK_POLL_MS = 250;
|
|
38
|
+
export const MAX_AUTH_LINK_CHARS = BRIDGE_MAX_AUTH_LINK_CHARS;
|
|
39
|
+
export const AUTH_OUTPUT_LIMIT = 4096;
|
|
40
|
+
/** 每条输出流保留的尾部(只为截链接与最终的 4KB 摘要,不需要更多) */
|
|
41
|
+
const AUTH_STREAM_KEEP = 64 * 1024;
|
|
42
|
+
export const EXPECT_PATH = '/usr/bin/expect';
|
|
43
|
+
/**
|
|
44
|
+
* 写死的命令清单(design「命令清单」表)。不在这里的组合一律 auth_unavailable:
|
|
45
|
+
* OpenCode 的登录是交互菜单、预检也不要求登录;Qoder 的 m2 授权没有命令行与 ACP 入口。
|
|
46
|
+
*/
|
|
47
|
+
export const AUTH_COMMANDS = {
|
|
48
|
+
claude: { login: { opens: 'page', linkSource: 'open', pty: false }, m2: { opens: 'page', linkSource: 'open', pty: true } },
|
|
49
|
+
codex: { login: { opens: 'agent', linkSource: 'output', pty: false }, m2: { opens: 'agent', linkSource: 'output', pty: false } },
|
|
50
|
+
cursor: { login: { opens: 'page', linkSource: 'open', pty: false }, m2: { opens: 'page', linkSource: 'open', pty: false } },
|
|
51
|
+
opencode: { m2: { opens: 'page', linkSource: 'open', pty: false } },
|
|
52
|
+
qoder: { login: { opens: 'page', linkSource: 'open', pty: false } },
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 预检结果里的 `authorize` 字段(D1、D5):只在 macOS(实测平台)出现;Claude 要找得到自带的 claude,Claude 的 m2 还要有 expect。
|
|
56
|
+
* 页面只在这个字段存在时调用 agent/authorize,所以旧 bridge 与其他平台自动退回命令引导。
|
|
57
|
+
*/
|
|
58
|
+
export function authCapability(agent, action, env) {
|
|
59
|
+
const spec = AUTH_COMMANDS[agent]?.[action];
|
|
60
|
+
if (env.platform !== 'darwin' || !spec)
|
|
61
|
+
return null;
|
|
62
|
+
if (agent === 'claude' && !env.claudeBinary)
|
|
63
|
+
return null;
|
|
64
|
+
if (spec.pty && !env.expectAvailable)
|
|
65
|
+
return null;
|
|
66
|
+
return { opens: spec.opens };
|
|
67
|
+
}
|
|
68
|
+
const unavailable = (message) => new BridgeFailure('auth_unavailable', message);
|
|
69
|
+
/**
|
|
70
|
+
* m2 配置项名会作为参数传给 CLI。它来自 bridge 自己的预检,但预检读的配置包括会话目录里的项目文件(Cursor 的
|
|
71
|
+
* `<cwd>/.cursor/mcp.json`、Claude 的 `.mcp.json`)—— 仓库可以把它命名成 `--xxx`,让页面一键触发时注入 CLI 选项(review r1 S-3)。
|
|
72
|
+
* 参数走 argv、不经过 shell,所以只挡选项与控制字符:不以 `-` 开头、不含控制字符、1–128 字符;空格、中文、引号照常传
|
|
73
|
+
* (误拒会让按钮无端消失,review r2 R2-S1)。预检的 `authorize` 能力用同一个函数判断,按钮出现就一定能执行。
|
|
74
|
+
*/
|
|
75
|
+
// eslint-disable-next-line no-control-regex
|
|
76
|
+
const SAFE_ENTRY = /^(?!-)[^\u0000-\u001f\u007f]{1,128}$/u;
|
|
77
|
+
export function isSafeAuthEntry(entry) {
|
|
78
|
+
return SAFE_ENTRY.test(entry);
|
|
79
|
+
}
|
|
80
|
+
/** 把 agent × 动作翻译成具体命令。调用方已确认平台、安装、适配器就绪与 expect;这里只按清单组装。 */
|
|
81
|
+
export function buildAuthPlan(input) {
|
|
82
|
+
const { agent, action, entry } = input;
|
|
83
|
+
const spec = AUTH_COMMANDS[agent]?.[action];
|
|
84
|
+
if (!spec)
|
|
85
|
+
throw unavailable(`the bridge cannot run ${action === 'login' ? 'sign-in' : 'M2 authorization'} for ${agent}`);
|
|
86
|
+
if (action === 'm2' && !entry)
|
|
87
|
+
throw unavailable(`add M2 to ${agent}'s MCP config first (m2/configure)`);
|
|
88
|
+
if (action === 'm2' && entry && !isSafeAuthEntry(entry))
|
|
89
|
+
throw unavailable(`the M2 entry name ${JSON.stringify(entry)} can't be passed to ${agent} safely; use the terminal command instead`);
|
|
90
|
+
const m2Entry = entry ?? '';
|
|
91
|
+
const base = { agent, action, cwd: input.cwd, env: input.env, pty: spec.pty, linkSource: spec.linkSource, opens: spec.opens };
|
|
92
|
+
switch (agent) {
|
|
93
|
+
case 'claude': {
|
|
94
|
+
const claude = input.claudeBinary;
|
|
95
|
+
if (!claude)
|
|
96
|
+
throw unavailable('the Claude Code program bundled with the adapter was not found');
|
|
97
|
+
return { ...base, steps: [{ command: claude, args: action === 'login' ? ['auth', 'login'] : ['mcp', 'login', m2Entry] }], pathPrefix: [dirname(claude)] };
|
|
98
|
+
}
|
|
99
|
+
case 'codex': {
|
|
100
|
+
const script = join(input.codexDir, CODEX_SCRIPT);
|
|
101
|
+
const args = action === 'login' ? [script, 'login'] : [script, 'mcp', 'login', m2Entry];
|
|
102
|
+
return { ...base, steps: [{ command: input.nodePath, args }], pathPrefix: [join(input.codexDir, 'node_modules', '.bin'), dirname(input.nodePath)] };
|
|
103
|
+
}
|
|
104
|
+
default: {
|
|
105
|
+
const path = input.detected.path;
|
|
106
|
+
if (!path)
|
|
107
|
+
throw unavailable(`${agent} is not installed on this machine`);
|
|
108
|
+
// OpenCode 已授权时重跑 `mcp auth` 会停在交互确认(Re-authenticate?),无终端会卡住;先 logout(无交互)再 auth(D15)
|
|
109
|
+
const steps = agent === 'opencode'
|
|
110
|
+
? [
|
|
111
|
+
{ command: path, args: ['mcp', 'logout', m2Entry], ignoreFailure: true },
|
|
112
|
+
{ command: path, args: ['mcp', 'auth', m2Entry] },
|
|
113
|
+
]
|
|
114
|
+
: [{ command: path, args: action === 'login' ? ['login'] : ['mcp', 'login', m2Entry] }];
|
|
115
|
+
return { ...base, steps, pathPrefix: [] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ───────────────────────────── 链接 ─────────────────────────────
|
|
120
|
+
/** 只收能解析的 https 链接、≤ 8KB;其余(http 本机回调、file:、命令行选项)返回 null */
|
|
121
|
+
export function acceptAuthLink(candidate) {
|
|
122
|
+
const text = candidate.trim();
|
|
123
|
+
if (text.length === 0 || text.length > MAX_AUTH_LINK_CHARS)
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
return new URL(text).protocol === 'https:' ? text : null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// eslint-disable-next-line no-control-regex
|
|
133
|
+
const OUTPUT_LINK_RE = /https:\/\/[^\s"'<>\u001b]+(?=[\s"'<>])/g;
|
|
134
|
+
/**
|
|
135
|
+
* 从命令输出里取完整的 https 链接:必须后面跟着空白或引号,避免把还没输出完的半截链接转交出去。
|
|
136
|
+
* 调用方传入累计输出(去 ANSI 在这里做,转义序列可能跨两段输出)。
|
|
137
|
+
*/
|
|
138
|
+
export function linksInOutput(text) {
|
|
139
|
+
const found = [];
|
|
140
|
+
for (const match of stripAnsi(text).matchAll(OUTPUT_LINK_RE)) {
|
|
141
|
+
const link = acceptAuthLink(match[0]);
|
|
142
|
+
if (link && !found.includes(link))
|
|
143
|
+
found.push(link);
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
// ───────────────────────────── 运行 ─────────────────────────────
|
|
148
|
+
/** 替身:只把参数逐行追加到 bridge 指定的文件。不执行参数、不打开浏览器(D4) */
|
|
149
|
+
const OPEN_SHIM = `#!/bin/sh
|
|
150
|
+
# M2 local agent bridge: records the sign-in link for the M2 page to open in your current browser. It never runs its arguments.
|
|
151
|
+
for arg in "$@"; do
|
|
152
|
+
printf '%s\\n' "$arg" >> "$M2_AUTH_LINK_FILE"
|
|
153
|
+
done
|
|
154
|
+
exit 0
|
|
155
|
+
`;
|
|
156
|
+
/** 伪终端包装(D6):文本固定,被包命令只经 $argv 传入 */
|
|
157
|
+
const PTY_SCRIPT = `set timeout -1
|
|
158
|
+
log_user 1
|
|
159
|
+
spawn -noecho {*}$argv
|
|
160
|
+
expect eof
|
|
161
|
+
catch wait result
|
|
162
|
+
exit [lindex $result 3]
|
|
163
|
+
`;
|
|
164
|
+
function outputTail(text) {
|
|
165
|
+
const clean = stripAnsi(text).replace(/\r/g, '');
|
|
166
|
+
return clean.length > AUTH_OUTPUT_LIMIT ? clean.slice(-AUTH_OUTPUT_LIMIT) : clean;
|
|
167
|
+
}
|
|
168
|
+
export function startAuthRun(plan, options) {
|
|
169
|
+
const dir = mkdtempSync(join(tmpdir(), 'm2-auth-'));
|
|
170
|
+
chmodSync(dir, 0o700);
|
|
171
|
+
let removed = false;
|
|
172
|
+
const removeDir = () => {
|
|
173
|
+
if (removed)
|
|
174
|
+
return;
|
|
175
|
+
removed = true;
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
};
|
|
178
|
+
let current = null;
|
|
179
|
+
let canceled = false;
|
|
180
|
+
let timedOut = false;
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const offer = (candidate) => {
|
|
183
|
+
const link = acceptAuthLink(candidate);
|
|
184
|
+
if (!link || seen.has(link))
|
|
185
|
+
return;
|
|
186
|
+
seen.add(link);
|
|
187
|
+
options.onLink(link);
|
|
188
|
+
};
|
|
189
|
+
let linkFile = '';
|
|
190
|
+
let script = '';
|
|
191
|
+
let env = plan.env;
|
|
192
|
+
try {
|
|
193
|
+
const bin = join(dir, 'bin');
|
|
194
|
+
mkdirSync(bin, { mode: 0o700 });
|
|
195
|
+
for (const name of ['open', 'xdg-open'])
|
|
196
|
+
writeFileSync(join(bin, name), OPEN_SHIM, { mode: 0o700 });
|
|
197
|
+
linkFile = join(dir, 'links');
|
|
198
|
+
writeFileSync(linkFile, '', { mode: 0o600 });
|
|
199
|
+
script = join(dir, 'pty.exp');
|
|
200
|
+
if (plan.pty)
|
|
201
|
+
writeFileSync(script, PTY_SCRIPT, { mode: 0o600 });
|
|
202
|
+
env = { ...prependPath(plan.env, [bin, ...plan.pathPrefix]), M2_AUTH_LINK_FILE: linkFile };
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
removeDir();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
let linkLines = 0;
|
|
209
|
+
const readLinkFile = () => {
|
|
210
|
+
let text;
|
|
211
|
+
try {
|
|
212
|
+
text = readFileSync(linkFile, 'utf8');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return; // 临时目录已删(结束或 killNow)
|
|
216
|
+
}
|
|
217
|
+
// 最后一段可能是替身还没写完的半行
|
|
218
|
+
const lines = text.split('\n').slice(0, -1);
|
|
219
|
+
for (const line of lines.slice(linkLines))
|
|
220
|
+
offer(line);
|
|
221
|
+
linkLines = lines.length;
|
|
222
|
+
};
|
|
223
|
+
const poll = plan.linkSource === 'open' ? setInterval(readLinkFile, options.pollMs) : null;
|
|
224
|
+
poll?.unref();
|
|
225
|
+
let scanned = '';
|
|
226
|
+
const onOutput = plan.linkSource === 'output'
|
|
227
|
+
? (chunk) => {
|
|
228
|
+
scanned = (scanned + chunk).slice(-2 * MAX_AUTH_LINK_CHARS);
|
|
229
|
+
for (const link of linksInOutput(scanned))
|
|
230
|
+
offer(link);
|
|
231
|
+
}
|
|
232
|
+
: undefined;
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
timedOut = true;
|
|
235
|
+
current?.terminate(options.graceMs, 'timed out', true);
|
|
236
|
+
}, options.timeoutMs);
|
|
237
|
+
timer.unref();
|
|
238
|
+
const execute = async () => {
|
|
239
|
+
const outputs = [];
|
|
240
|
+
let last = null;
|
|
241
|
+
try {
|
|
242
|
+
for (const step of plan.steps) {
|
|
243
|
+
if (canceled || timedOut)
|
|
244
|
+
break;
|
|
245
|
+
const command = plan.pty ? EXPECT_PATH : step.command;
|
|
246
|
+
const args = plan.pty ? ['-f', script, '--', step.command, ...step.args] : step.args;
|
|
247
|
+
current = startCommand(command, args, { env, cwd: plan.cwd, maxOutput: AUTH_STREAM_KEEP, onOutput });
|
|
248
|
+
last = await current.result;
|
|
249
|
+
current = null;
|
|
250
|
+
outputs.push(combinedOutput(last));
|
|
251
|
+
if (last.code !== 0 && !step.ignoreFailure)
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
if (poll)
|
|
258
|
+
clearInterval(poll);
|
|
259
|
+
removeDir();
|
|
260
|
+
}
|
|
261
|
+
const outcome = canceled ? 'canceled' : timedOut ? 'timed_out' : last?.code === 0 ? 'succeeded' : 'failed';
|
|
262
|
+
return { outcome, exitCode: last?.code ?? null, output: outputTail(outputs.filter((part) => part.length > 0).join('\n')) };
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
result: execute(),
|
|
266
|
+
cancel() {
|
|
267
|
+
if (canceled)
|
|
268
|
+
return;
|
|
269
|
+
canceled = true;
|
|
270
|
+
current?.terminate(options.graceMs, 'canceled');
|
|
271
|
+
},
|
|
272
|
+
killNow() {
|
|
273
|
+
canceled = true;
|
|
274
|
+
current?.kill('bridge is shutting down');
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
if (poll)
|
|
277
|
+
clearInterval(poll);
|
|
278
|
+
removeDir();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export class AuthorizationManager {
|
|
283
|
+
options;
|
|
284
|
+
/** 按 agent 记:同一 agent 的两次授权会争同一个本机回调端口(Cursor 固定 8787)、写同一份凭证 */
|
|
285
|
+
entries = new Map();
|
|
286
|
+
constructor(options = {}) {
|
|
287
|
+
this.options = options;
|
|
288
|
+
}
|
|
289
|
+
async run(request) {
|
|
290
|
+
const { agent, action } = request;
|
|
291
|
+
if (this.entries.has(agent))
|
|
292
|
+
throw new BridgeFailure('auth_busy', `another sign-in for ${agent} is in progress`, { agent });
|
|
293
|
+
const entry = { ownerId: request.ownerId, canceled: false, run: null };
|
|
294
|
+
this.entries.set(agent, entry);
|
|
295
|
+
const canceledResult = { agent, action, outcome: 'canceled', exitCode: null, output: '', precheck: null };
|
|
296
|
+
let ran;
|
|
297
|
+
try {
|
|
298
|
+
const plan = await request.plan();
|
|
299
|
+
if (entry.canceled)
|
|
300
|
+
return canceledResult;
|
|
301
|
+
const start = this.options.start ?? startAuthRun;
|
|
302
|
+
entry.run = start(plan, {
|
|
303
|
+
timeoutMs: this.options.timeoutMs ?? AUTH_TIMEOUT_MS,
|
|
304
|
+
graceMs: this.options.graceMs ?? AUTH_KILL_GRACE_MS,
|
|
305
|
+
pollMs: this.options.pollMs ?? AUTH_LINK_POLL_MS,
|
|
306
|
+
onLink: (url) => request.onLink({ url, open: plan.opens }),
|
|
307
|
+
});
|
|
308
|
+
ran = await entry.run.result;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
// 命令结束即释放:端口与凭证的争用已经结束,之后的预检不需要占着
|
|
312
|
+
if (this.entries.get(agent) === entry)
|
|
313
|
+
this.entries.delete(agent);
|
|
314
|
+
}
|
|
315
|
+
if (ran.outcome === 'canceled' || entry.canceled)
|
|
316
|
+
return { ...canceledResult, exitCode: ran.exitCode, output: ran.output };
|
|
317
|
+
return { agent, action, ...ran, precheck: await request.precheck() };
|
|
318
|
+
}
|
|
319
|
+
/** 页面取消:只有发起的连接能取消 */
|
|
320
|
+
cancel(ownerId, agent) {
|
|
321
|
+
const entry = this.entries.get(agent);
|
|
322
|
+
if (!entry || entry.ownerId !== ownerId)
|
|
323
|
+
return false;
|
|
324
|
+
entry.canceled = true;
|
|
325
|
+
entry.run?.cancel();
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
/** 连接关闭 */
|
|
329
|
+
cancelOwner(ownerId) {
|
|
330
|
+
for (const [agent, entry] of this.entries)
|
|
331
|
+
if (entry.ownerId === ownerId)
|
|
332
|
+
this.cancel(ownerId, agent);
|
|
333
|
+
}
|
|
334
|
+
/** bridge 退出:同步杀掉全部并删除临时目录 */
|
|
335
|
+
killAllNow() {
|
|
336
|
+
for (const entry of this.entries.values()) {
|
|
337
|
+
entry.canceled = true;
|
|
338
|
+
entry.run?.killNow();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
package/dist/bridge/env.js
CHANGED
|
@@ -105,3 +105,15 @@ export function buildProbeEnv(source = process.env, platform = process.platform)
|
|
|
105
105
|
export function buildNpmEnv(source = process.env, platform = process.platform) {
|
|
106
106
|
return { ...buildAgentEnv(source, platform), ...pick(source, (name) => NPM_CONFIG_NAMES.includes(name)) };
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* 把目录排到 PATH 最前(D10:让 connect 的 cli.js 与授权命令找得到适配器自带的 claude / codex 和 bridge 自己的 node)。
|
|
110
|
+
* Windows 的环境变量名不区分大小写,白名单保留的可能是 `Path`:改写原来那个键,不另加一个 `PATH`。
|
|
111
|
+
*/
|
|
112
|
+
export function prependPath(env, dirs, platform = process.platform) {
|
|
113
|
+
if (dirs.length === 0)
|
|
114
|
+
return env;
|
|
115
|
+
const key = platform === 'win32' ? (Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'PATH') : 'PATH';
|
|
116
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
117
|
+
const current = env[key];
|
|
118
|
+
return { ...env, [key]: [...dirs, ...(current ? [current] : [])].join(separator) };
|
|
119
|
+
}
|