@m2canvas/connect 0.3.1 → 0.5.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 +19 -0
- package/adapters/claude-agent-acp/package-lock.json +1535 -0
- package/adapters/claude-agent-acp/package.json +8 -0
- package/adapters/codex-acp/package-lock.json +364 -0
- package/adapters/codex-acp/package.json +9 -0
- package/dist/bridge/acp-guard.js +92 -0
- package/dist/bridge/adapters.js +146 -0
- package/dist/bridge/agents.js +73 -0
- package/dist/bridge/env.js +107 -0
- package/dist/bridge/exec.js +90 -0
- package/dist/bridge/lifecycle.js +24 -0
- package/dist/bridge/m2-config.js +38 -0
- package/dist/bridge/main.js +274 -0
- package/dist/bridge/pairing.js +317 -0
- package/dist/bridge/precheck-parse.js +246 -0
- package/dist/bridge/precheck.js +149 -0
- package/dist/bridge/processes.js +212 -0
- package/dist/bridge/protocol.js +62 -0
- package/dist/bridge/security.js +52 -0
- package/dist/bridge/server.js +379 -0
- package/dist/bridge/service.js +109 -0
- package/dist/cli.js +8 -2
- package/package.json +7 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配对:安全三条里的第三条。Origin 白名单挡住了其他网站,但挡不住两类来源 —— `--dev` 时本机其他跑在开发端口上的页面
|
|
3
|
+
* 和本机其他进程(可以伪造 Origin)。它们都拿不到只出现在终端里的配对码。
|
|
4
|
+
* 配对 = 授权该页面以用户的身份驱动本机 agent(包括执行命令),所以码和 token 都按这个级别保管。
|
|
5
|
+
*
|
|
6
|
+
* 两条途径,都要证明「终端用户同意了这个页面」(change acp-bridge-command-pairing):
|
|
7
|
+
* - 输码:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
|
|
8
|
+
* - 命令承诺:页面生成密钥 S,启动命令只带承诺 c = base64url(sha256(S) 的前 16 字节)(`bridge --pair c`);
|
|
9
|
+
* 页面在 hello 里出示 S,一致就换取同样的 token。「在自己终端里运行了这个页面给的命令」与「抄了终端里的码」证明的是同一件事。
|
|
10
|
+
* 命令会进 `ps` 与 shell 历史,所以命令里只能有承诺、不能有 S;承诺只在内存里、自挂上起 CODE_TTL_MS 有效、成功一次即作废。
|
|
11
|
+
*
|
|
12
|
+
* 维护约束:
|
|
13
|
+
* - 本地只存 token 的 sha256,不存明文;文件 0600、目录 0700。
|
|
14
|
+
* - token 绑定配对时的 Origin:开发域名配出的 token 不能拿到正式域名用。
|
|
15
|
+
* - 配对码约 40 bit,每个码最多错 MAX_CODE_FAILURES 次、CODE_TTL_MS 后过期,二者都会轮换并在终端重新打印;
|
|
16
|
+
* 自上次成功配对以来累计错 MAX_TOTAL_FAILURES 次就锁定(不论中间换过几个码、码是不是过期换的),只有重启 bridge 才解除
|
|
17
|
+
* (锁只在内存里)。锁定不影响已配对的页面。
|
|
18
|
+
* 不要为了「方便」放宽次数、延长有效期或加一个页面能触发的解锁:码是唯一挡住本机恶意页面的东西。
|
|
19
|
+
* 也不要改成「在终端按回车解锁」:那需要读终端,而 bridge 放到后台(`… bridge &`)时读终端会被系统挂起(review r2 实测)。
|
|
20
|
+
* - 比较用常量时间(timingSafeEqual),不要改回 ===。
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname } from 'node:path';
|
|
25
|
+
export const CODE_TTL_MS = 10 * 60 * 1000;
|
|
26
|
+
export const MAX_CODE_FAILURES = 5;
|
|
27
|
+
export const TOKEN_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
|
+
export const TOKEN_IDLE_DAYS = 30;
|
|
29
|
+
export const MAX_PAIRINGS = 20;
|
|
30
|
+
/**
|
|
31
|
+
* 自上次成功配对以来累计错这么多次就锁定,重启 bridge 才解除。按累计而不是「因错码轮换的次数」计:
|
|
32
|
+
* 码过期换新时不清零 —— 否则每个码错 4 次再等它过期就能无限猜下去(review r3 S4)。
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_TOTAL_FAILURES = 15;
|
|
35
|
+
/** 同一 Origin、同一个码在终端的重复提示间隔 */
|
|
36
|
+
const PROMPT_DEDUPE_MS = 60_000;
|
|
37
|
+
/** Crockford base32:去掉易混的 I、L、O、U */
|
|
38
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
39
|
+
const CODE_LENGTH = 8;
|
|
40
|
+
/** 页面密钥 S:32 字节随机数的 base64url(无填充) */
|
|
41
|
+
const PAIR_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
42
|
+
/** 承诺:sha256(S) 前 16 字节的 base64url(无填充) */
|
|
43
|
+
const PAIR_COMMITMENT_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
44
|
+
const COMMITMENT_BYTES = 16;
|
|
45
|
+
export function isPairCommitment(value) {
|
|
46
|
+
return PAIR_COMMITMENT_RE.test(value);
|
|
47
|
+
}
|
|
48
|
+
/** 承诺 = base64url(sha256(S 的 UTF-8) 的前 16 字节)。前端 `bridge-pair-secret.ts` 用 WebCrypto 算同一个值,改这里要同批改那边。 */
|
|
49
|
+
export function pairCommitmentOf(secret) {
|
|
50
|
+
return sha256(secret).subarray(0, COMMITMENT_BYTES).toString('base64url');
|
|
51
|
+
}
|
|
52
|
+
/** 用户输入归一:大小写、空格、连字符;按 Crockford 规则把 O 视同 0、I / L 视同 1 */
|
|
53
|
+
export function normalizePairingCode(input) {
|
|
54
|
+
return input
|
|
55
|
+
.toUpperCase()
|
|
56
|
+
.replace(/[\s-]/g, '')
|
|
57
|
+
.replace(/O/g, '0')
|
|
58
|
+
.replace(/[IL]/g, '1');
|
|
59
|
+
}
|
|
60
|
+
function formatCode(code) {
|
|
61
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
62
|
+
}
|
|
63
|
+
function sha256(value) {
|
|
64
|
+
return createHash('sha256').update(value).digest();
|
|
65
|
+
}
|
|
66
|
+
function sameSecret(a, b) {
|
|
67
|
+
const left = sha256(a);
|
|
68
|
+
const right = sha256(b);
|
|
69
|
+
return timingSafeEqual(left, right);
|
|
70
|
+
}
|
|
71
|
+
function errorText(error) {
|
|
72
|
+
return error instanceof Error ? error.message : String(error);
|
|
73
|
+
}
|
|
74
|
+
export class PairingManager {
|
|
75
|
+
active = null;
|
|
76
|
+
/** `--pair` 挂上的承诺(只在内存里:bridge 重启即失效,重新运行同一条命令会重新挂上) */
|
|
77
|
+
commitment = null;
|
|
78
|
+
/** 已用掉的承诺:同一个密钥再出现时告警(只在内存里,只记最近一个) */
|
|
79
|
+
consumed = null;
|
|
80
|
+
/** 自上次成功配对以来累计的错码次数(跨码、跨过期);配对成功时清零 */
|
|
81
|
+
failuresSinceSuccess = 0;
|
|
82
|
+
locked = false;
|
|
83
|
+
/** Origin → 上次在终端提示它的时间与当时的码,用于去重 */
|
|
84
|
+
lastPrompt = new Map();
|
|
85
|
+
store;
|
|
86
|
+
now;
|
|
87
|
+
onCodeIssued;
|
|
88
|
+
onLocked;
|
|
89
|
+
onProblem;
|
|
90
|
+
onCommitmentReused;
|
|
91
|
+
constructor(options) {
|
|
92
|
+
this.store = options.store;
|
|
93
|
+
this.now = options.now ?? Date.now;
|
|
94
|
+
this.onCodeIssued = options.onCodeIssued;
|
|
95
|
+
this.onLocked = options.onLocked ?? (() => undefined);
|
|
96
|
+
this.onProblem = options.onProblem ?? (() => undefined);
|
|
97
|
+
this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
|
|
98
|
+
}
|
|
99
|
+
/** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式 */
|
|
100
|
+
issueCode(reason, origin = null) {
|
|
101
|
+
let code = '';
|
|
102
|
+
for (let i = 0; i < CODE_LENGTH; i++)
|
|
103
|
+
code += ALPHABET[randomInt(ALPHABET.length)];
|
|
104
|
+
this.active = { code, issuedAt: this.now(), failures: 0 };
|
|
105
|
+
const display = formatCode(code);
|
|
106
|
+
if (origin)
|
|
107
|
+
this.lastPrompt.set(origin, { at: this.now(), code });
|
|
108
|
+
this.onCodeIssued({ display, reason, origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
|
|
109
|
+
return display;
|
|
110
|
+
}
|
|
111
|
+
hasValidCode() {
|
|
112
|
+
return this.active !== null && this.now() - this.active.issuedAt <= CODE_TTL_MS;
|
|
113
|
+
}
|
|
114
|
+
isLocked() {
|
|
115
|
+
return this.locked;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 未配对页面连上时调用。没有有效码就出新码;有有效码也把它连同发起方 Origin 再提示一次 ——
|
|
119
|
+
* 最常见的首次流程是「启动 bridge 后马上打开页面」,终端里只有一个不带 Origin 的启动码,
|
|
120
|
+
* 不再提示的话用户看不到是哪个页面在请求配对。同一 Origin、同一个码一分钟内只提示一次(页面重连不刷屏)。
|
|
121
|
+
* 锁定期间什么都不做。
|
|
122
|
+
*/
|
|
123
|
+
ensureCode(origin) {
|
|
124
|
+
if (this.locked)
|
|
125
|
+
return;
|
|
126
|
+
if (!this.hasValidCode() || !this.active) {
|
|
127
|
+
this.issueCode('requested', origin);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const last = this.lastPrompt.get(origin);
|
|
131
|
+
if (last && last.code === this.active.code && this.now() - last.at < PROMPT_DEDUPE_MS)
|
|
132
|
+
return;
|
|
133
|
+
this.lastPrompt.set(origin, { at: this.now(), code: this.active.code });
|
|
134
|
+
this.onCodeIssued({ display: formatCode(this.active.code), reason: 'requested', origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 配对。保存 token 失败时抛出(调用方回给页面 internal),此时码不被消耗、可以重试。
|
|
138
|
+
*
|
|
139
|
+
* 暴力尝试的上限:每个码最多错 MAX_CODE_FAILURES 次(然后换码),自上次成功以来累计错 MAX_TOTAL_FAILURES 次锁定,
|
|
140
|
+
* 锁定期间不再出码、不再比对,只有重启 bridge 才解除。没有累计上限,换码本身就是无限次尝试
|
|
141
|
+
* (review r1 实测 1.6 秒 20000 次),还会让陌生页面把用户的配对码一直刷掉。
|
|
142
|
+
*/
|
|
143
|
+
pair(input, origin) {
|
|
144
|
+
if (this.locked)
|
|
145
|
+
return { ok: false, code: 'pairing_locked' };
|
|
146
|
+
if (!this.active) {
|
|
147
|
+
// 码已被消费:请用户看终端里的新码
|
|
148
|
+
this.issueCode('requested', origin);
|
|
149
|
+
return { ok: false, code: 'pairing_code_rotated' };
|
|
150
|
+
}
|
|
151
|
+
if (!this.hasValidCode()) {
|
|
152
|
+
this.issueCode('expired', origin);
|
|
153
|
+
return { ok: false, code: 'pairing_code_rotated' };
|
|
154
|
+
}
|
|
155
|
+
const active = this.active;
|
|
156
|
+
if (sameSecret(normalizePairingCode(input), active.code)) {
|
|
157
|
+
const token = this.createToken(origin);
|
|
158
|
+
this.active = null;
|
|
159
|
+
this.failuresSinceSuccess = 0;
|
|
160
|
+
return { ok: true, token };
|
|
161
|
+
}
|
|
162
|
+
active.failures += 1;
|
|
163
|
+
this.failuresSinceSuccess += 1;
|
|
164
|
+
if (this.failuresSinceSuccess >= MAX_TOTAL_FAILURES) {
|
|
165
|
+
this.locked = true;
|
|
166
|
+
this.active = null;
|
|
167
|
+
this.onLocked(origin);
|
|
168
|
+
return { ok: false, code: 'pairing_locked' };
|
|
169
|
+
}
|
|
170
|
+
if (active.failures < MAX_CODE_FAILURES) {
|
|
171
|
+
const attemptsLeft = Math.min(MAX_CODE_FAILURES - active.failures, MAX_TOTAL_FAILURES - this.failuresSinceSuccess);
|
|
172
|
+
return { ok: false, code: 'pairing_invalid', attemptsLeft };
|
|
173
|
+
}
|
|
174
|
+
this.issueCode('too_many_failures', origin);
|
|
175
|
+
return { ok: false, code: 'pairing_code_rotated' };
|
|
176
|
+
}
|
|
177
|
+
/** 挂上启动命令里的承诺,返回它的过期时刻。格式不对时抛出:由 `--pair` 的参数解析先挡掉。 */
|
|
178
|
+
armCommitment(commitment) {
|
|
179
|
+
if (!isPairCommitment(commitment))
|
|
180
|
+
throw new Error('invalid pairing commitment');
|
|
181
|
+
const armedAt = this.now();
|
|
182
|
+
this.commitment = { value: Buffer.from(commitment, 'base64url'), armedAt };
|
|
183
|
+
return armedAt + CODE_TTL_MS;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 页面在 hello 里出示 S。对不上不计入错码次数:S 有 32 字节、承诺有 128 bit,不存在可猜性,
|
|
187
|
+
* 计入只会让多标签页、旧 S 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
|
|
188
|
+
* 签发 token 写盘失败时不抛出(hello 里抛出会以 1011 关闭,等待中的页面会反复撞上)、不消费承诺,交给调用方按未配对处理。
|
|
189
|
+
*/
|
|
190
|
+
pairWithSecret(secret, origin) {
|
|
191
|
+
const commitment = this.commitment;
|
|
192
|
+
// 重现检查在锁定检查之前:否则抢先配对的一方再用错码把 bridge 锁住,就能压掉这次告警(review r2 S1)
|
|
193
|
+
if (!commitment)
|
|
194
|
+
return this.checkReused(secret, origin);
|
|
195
|
+
if (this.locked)
|
|
196
|
+
return { ok: false, reason: 'locked' };
|
|
197
|
+
if (this.now() - commitment.armedAt > CODE_TTL_MS) {
|
|
198
|
+
this.commitment = null;
|
|
199
|
+
return { ok: false, reason: 'expired' };
|
|
200
|
+
}
|
|
201
|
+
if (!PAIR_SECRET_RE.test(secret))
|
|
202
|
+
return { ok: false, reason: 'malformed' };
|
|
203
|
+
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), commitment.value))
|
|
204
|
+
return { ok: false, reason: 'mismatch' };
|
|
205
|
+
let token;
|
|
206
|
+
try {
|
|
207
|
+
token = this.createToken(origin);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
this.onProblem(`Could not save the pairing (${errorText(error)}). This page will be asked for a pairing code instead.`);
|
|
211
|
+
return { ok: false, reason: 'store_failed' };
|
|
212
|
+
}
|
|
213
|
+
this.commitment = null;
|
|
214
|
+
this.consumed = { value: commitment.value, origin, usedAt: this.now(), warned: false };
|
|
215
|
+
this.failuresSinceSuccess = 0;
|
|
216
|
+
return { ok: true, token };
|
|
217
|
+
}
|
|
218
|
+
checkReused(secret, origin) {
|
|
219
|
+
const consumed = this.consumed;
|
|
220
|
+
if (!consumed || !PAIR_SECRET_RE.test(secret))
|
|
221
|
+
return { ok: false, reason: 'no_commitment' };
|
|
222
|
+
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), consumed.value))
|
|
223
|
+
return { ok: false, reason: 'no_commitment' };
|
|
224
|
+
if (!consumed.warned) {
|
|
225
|
+
consumed.warned = true;
|
|
226
|
+
this.onCommitmentReused({ origin, firstOrigin: consumed.origin, usedAt: consumed.usedAt });
|
|
227
|
+
}
|
|
228
|
+
return { ok: false, reason: 'reused' };
|
|
229
|
+
}
|
|
230
|
+
createToken(origin) {
|
|
231
|
+
const token = randomBytes(32).toString('base64url');
|
|
232
|
+
const nowIso = new Date(this.now()).toISOString();
|
|
233
|
+
const records = this.liveRecords();
|
|
234
|
+
records.push({ id: randomUUID(), tokenHash: sha256(token).toString('hex'), origin, createdAt: nowIso, lastUsedAt: nowIso });
|
|
235
|
+
// 超出上限时淘汰最久未用的
|
|
236
|
+
records.sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
|
|
237
|
+
// 这里的写失败必须抛出:token 没存下来,页面拿到也用不了
|
|
238
|
+
this.store.save(records.slice(0, MAX_PAIRINGS));
|
|
239
|
+
return token;
|
|
240
|
+
}
|
|
241
|
+
/** 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定) */
|
|
242
|
+
liveRecords() {
|
|
243
|
+
const all = this.store.load();
|
|
244
|
+
const live = all.filter((record) => this.now() - Date.parse(record.lastUsedAt) <= TOKEN_IDLE_MS);
|
|
245
|
+
if (live.length !== all.length)
|
|
246
|
+
this.trySave(live);
|
|
247
|
+
return live;
|
|
248
|
+
}
|
|
249
|
+
trySave(records) {
|
|
250
|
+
try {
|
|
251
|
+
this.store.save(records);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.onProblem(`Could not update the pairing file (${errorText(error)}). Pairings still work for now but may not survive a restart.`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* token 有效且与当前 Origin 匹配时返回 true,并刷新 lastUsedAt(闲置期从此刻重新计算)。
|
|
259
|
+
* 刷新写盘失败只告警、仍判有效:判定本身只依赖读到的记录,不能因为磁盘满让已配对的页面全部掉线(更不能让 bridge 崩溃)。
|
|
260
|
+
*/
|
|
261
|
+
verifyToken(token, origin) {
|
|
262
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
263
|
+
return false;
|
|
264
|
+
const hash = sha256(token);
|
|
265
|
+
const records = this.liveRecords();
|
|
266
|
+
const match = records.find((record) => {
|
|
267
|
+
const stored = Buffer.from(record.tokenHash, 'hex');
|
|
268
|
+
return stored.length === hash.length && timingSafeEqual(stored, hash) && record.origin === origin;
|
|
269
|
+
});
|
|
270
|
+
if (!match)
|
|
271
|
+
return false;
|
|
272
|
+
match.lastUsedAt = new Date(this.now()).toISOString();
|
|
273
|
+
this.trySave(records);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
unpairAll() {
|
|
277
|
+
this.store.clear();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function isRecord(value) {
|
|
281
|
+
if (typeof value !== 'object' || value === null)
|
|
282
|
+
return false;
|
|
283
|
+
const record = value;
|
|
284
|
+
return ['id', 'tokenHash', 'origin', 'createdAt', 'lastUsedAt'].every((key) => typeof record[key] === 'string');
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `~/.m2canvas/bridge-pairings.json`。文件损坏按空处理(用户需要重新配对)并经 onProblem 报告,
|
|
288
|
+
* 不抛错:bridge 仍应能启动,只是之前的配对作废。
|
|
289
|
+
*/
|
|
290
|
+
export function createFilePairingStore(path, onProblem = () => undefined) {
|
|
291
|
+
return {
|
|
292
|
+
load() {
|
|
293
|
+
if (!existsSync(path))
|
|
294
|
+
return [];
|
|
295
|
+
try {
|
|
296
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
297
|
+
const list = typeof parsed === 'object' && parsed !== null ? parsed.pairings : undefined;
|
|
298
|
+
if (!Array.isArray(list))
|
|
299
|
+
throw new Error('missing "pairings" array');
|
|
300
|
+
return list.filter(isRecord);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
onProblem(`Ignoring unreadable pairing file ${path} (${error instanceof Error ? error.message : String(error)}). Pages will need to pair again.`);
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
save(records) {
|
|
308
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
309
|
+
chmodSync(dirname(path), 0o700);
|
|
310
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, pairings: records }, null, 2)}\n`, { mode: 0o600 });
|
|
311
|
+
chmodSync(path, 0o600);
|
|
312
|
+
},
|
|
313
|
+
clear() {
|
|
314
|
+
rmSync(path, { force: true });
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 各家 CLI 的登录状态与 `mcp list` 输出解析(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 这些输出都是给人看的文本,没有格式承诺,各家升级就可能变。所以规则是:只认实测过的关键词(样本见单测,
|
|
5
|
+
* 原文记录在 acp-local-bridge 的 evidence/cli-probes.md),其余一律 unknown 并附原文。
|
|
6
|
+
* **不要加「认不出就当 ready / logged_in」的分支** —— ACP 对 m2 不可用完全不报(PoC 报告 §4.1),
|
|
7
|
+
* 误报 ready 会让用户在一个没有画布工具的会话里白等。
|
|
8
|
+
*
|
|
9
|
+
* m2 配置项按 URL 的 host 识别(与 connect 的 isM2Url 同一判据),名字可以是任意的。
|
|
10
|
+
*/
|
|
11
|
+
import { isM2Url } from '../config-files.js';
|
|
12
|
+
/** raw 字段最多保留的尾部字符数 */
|
|
13
|
+
export const RAW_LIMIT = 2048;
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
16
|
+
export function stripAnsi(text) {
|
|
17
|
+
return text.replace(ANSI_RE, '');
|
|
18
|
+
}
|
|
19
|
+
export function tail(text) {
|
|
20
|
+
return text.length > RAW_LIMIT ? text.slice(-RAW_LIMIT) : text;
|
|
21
|
+
}
|
|
22
|
+
function login(state, output) {
|
|
23
|
+
return { state, raw: state === 'unknown' ? tail(stripAnsi(output)) : null };
|
|
24
|
+
}
|
|
25
|
+
function m2(state, entry, output) {
|
|
26
|
+
return { state, entry, raw: state === 'error' || state === 'unknown' ? tail(stripAnsi(output)) : null };
|
|
27
|
+
}
|
|
28
|
+
/** 多个指向 m2 的配置项时取最好的那个 */
|
|
29
|
+
const RANK = {
|
|
30
|
+
ready: 0,
|
|
31
|
+
unverified: 1,
|
|
32
|
+
needs_enable: 2,
|
|
33
|
+
needs_auth: 3,
|
|
34
|
+
error: 4,
|
|
35
|
+
unknown: 5,
|
|
36
|
+
not_configured: 6,
|
|
37
|
+
};
|
|
38
|
+
function best(candidates, output) {
|
|
39
|
+
const sorted = [...candidates].sort((a, b) => RANK[a.state] - RANK[b.state]);
|
|
40
|
+
const top = sorted[0];
|
|
41
|
+
return top ? m2(top.state, top.entry, output) : m2('unknown', null, output);
|
|
42
|
+
}
|
|
43
|
+
// ───────────────────────────── 登录 ─────────────────────────────
|
|
44
|
+
/** `claude auth status --json`:未登录时退出码 1,stdout 仍是完整 JSON */
|
|
45
|
+
export function parseClaudeAuthStatus(stdout, _exitCode) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(stdout);
|
|
48
|
+
const loggedIn = typeof parsed === 'object' && parsed !== null ? parsed.loggedIn : undefined;
|
|
49
|
+
if (loggedIn === true)
|
|
50
|
+
return login('logged_in', stdout);
|
|
51
|
+
if (loggedIn === false)
|
|
52
|
+
return login('logged_out', stdout);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// 落到 unknown
|
|
56
|
+
}
|
|
57
|
+
return login('unknown', stdout);
|
|
58
|
+
}
|
|
59
|
+
/** `codex login status`:输出在 stderr;已登录退出码 0,未登录退出码 1 */
|
|
60
|
+
export function parseCodexLoginStatus(output, exitCode) {
|
|
61
|
+
if (/Not logged in/i.test(output))
|
|
62
|
+
return login('logged_out', output);
|
|
63
|
+
if (exitCode === 0 && /Logged in/i.test(output))
|
|
64
|
+
return login('logged_in', output);
|
|
65
|
+
return login('unknown', output);
|
|
66
|
+
}
|
|
67
|
+
/** `agent status`(Cursor):两种情况退出码都是 0 */
|
|
68
|
+
export function parseCursorStatus(output) {
|
|
69
|
+
if (/Not logged in/i.test(output))
|
|
70
|
+
return login('logged_out', output);
|
|
71
|
+
if (/Logged in/i.test(output))
|
|
72
|
+
return login('logged_in', output);
|
|
73
|
+
return login('unknown', output);
|
|
74
|
+
}
|
|
75
|
+
/** `qodercli status`:未登录是 `Account: Not logged in`,已登录列出 Username / Email */
|
|
76
|
+
export function parseQoderStatus(output) {
|
|
77
|
+
if (/Not logged in/i.test(output))
|
|
78
|
+
return login('logged_out', output);
|
|
79
|
+
if (/^(Email|Username):/m.test(output))
|
|
80
|
+
return login('logged_in', output);
|
|
81
|
+
return login('unknown', output);
|
|
82
|
+
}
|
|
83
|
+
// ───────────────────────────── m2 ─────────────────────────────
|
|
84
|
+
/** `<名字>: <url> …`;Qoder 行首还有一个状态图标(✓ / 🔑),图标由非字母数字字符组成,名字可含空格 */
|
|
85
|
+
const URL_LINE_RE = /^(?:[^\p{L}\p{N}\s]+\s+)?(.+?): (https?:\/\/\S+)(.*)$/u;
|
|
86
|
+
/**
|
|
87
|
+
* 任何服务器行(含 stdio 服务器:`<名字>: <命令> - <状态>`,没有 URL)。用来区分「列出了服务器但没有 m2」
|
|
88
|
+
* (→ not_configured,页面给出 configure)和「输出认不出」(→ unknown)。
|
|
89
|
+
*
|
|
90
|
+
* 有意的取舍:这个模式比较宽。如果哪天某个 CLI 不再给 HTTP 服务器打印 URL,已配置的 m2 会被判成 not_configured
|
|
91
|
+
* (页面引导「添加」,connect 回「已存在」,再预检仍是 not_configured)——是一次可见的引导错误,但不会误报 ready。
|
|
92
|
+
* 反过来收窄成「只认带 URL 的行」,只配了 stdio 服务器的用户就永远拿不到 configure(review r1 S6)。
|
|
93
|
+
*/
|
|
94
|
+
const SERVER_LINE_RE = /^(?:[^\p{L}\p{N}\s]+\s+)?.+?: \S.* - \S/u;
|
|
95
|
+
function hasServerLines(output) {
|
|
96
|
+
return stripAnsi(output)
|
|
97
|
+
.split(/\r?\n/)
|
|
98
|
+
.some((line) => SERVER_LINE_RE.test(line.trim()));
|
|
99
|
+
}
|
|
100
|
+
function urlLines(output) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (const raw of stripAnsi(output).split(/\r?\n/)) {
|
|
103
|
+
const match = URL_LINE_RE.exec(raw.trim());
|
|
104
|
+
if (match?.[1] && match[2])
|
|
105
|
+
lines.push({ name: match[1], url: match[2], rest: match[3] ?? '' });
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
function claudeStatus(rest) {
|
|
110
|
+
if (/Failed to connect/i.test(rest))
|
|
111
|
+
return 'error';
|
|
112
|
+
if (/Needs authentication/i.test(rest))
|
|
113
|
+
return 'needs_auth';
|
|
114
|
+
if (/Pending approval/i.test(rest))
|
|
115
|
+
return 'needs_enable';
|
|
116
|
+
if (/\bConnected\b/.test(rest))
|
|
117
|
+
return 'ready';
|
|
118
|
+
return 'unknown';
|
|
119
|
+
}
|
|
120
|
+
/** `claude mcp list`:每行 `<名字>: <url> (HTTP) - <图标> <状态>`;stdio 服务器的行没有 URL,与 m2 无关 */
|
|
121
|
+
export function parseClaudeMcpList(output, exitCode) {
|
|
122
|
+
if (exitCode !== 0)
|
|
123
|
+
return m2('unknown', null, output);
|
|
124
|
+
const lines = urlLines(output);
|
|
125
|
+
const candidates = lines.filter((line) => isM2Url(line.url)).map((line) => ({ state: claudeStatus(line.rest), entry: line.name }));
|
|
126
|
+
if (candidates.length > 0)
|
|
127
|
+
return best(candidates, output);
|
|
128
|
+
if (lines.length > 0 || hasServerLines(output) || /No MCP servers configured/i.test(output))
|
|
129
|
+
return m2('not_configured', null, output);
|
|
130
|
+
return m2('unknown', null, output);
|
|
131
|
+
}
|
|
132
|
+
/** `qodercli mcp list`:`<图标> <名字>: <url> (http) - <状态>`;它也读项目级 .mcp.json */
|
|
133
|
+
export function parseQoderMcpList(output, exitCode) {
|
|
134
|
+
if (exitCode !== 0)
|
|
135
|
+
return m2('unknown', null, output);
|
|
136
|
+
const lines = urlLines(output);
|
|
137
|
+
const candidates = lines
|
|
138
|
+
.filter((line) => isM2Url(line.url))
|
|
139
|
+
.map((line) => ({
|
|
140
|
+
state: /Needs authentication/i.test(line.rest) ? 'needs_auth' : /\bConnected\b/.test(line.rest) ? 'ready' : 'unknown',
|
|
141
|
+
entry: line.name,
|
|
142
|
+
}));
|
|
143
|
+
if (candidates.length > 0)
|
|
144
|
+
return best(candidates, output);
|
|
145
|
+
if (lines.length > 0 || hasServerLines(output) || /No MCP servers configured/i.test(output))
|
|
146
|
+
return m2('not_configured', null, output);
|
|
147
|
+
return m2('unknown', null, output);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 适配器自带 codex 的 `mcp list --json`。`auth_status: "o_auth"` 只说明存过 OAuth 凭证,不说明还有效
|
|
151
|
+
* (旧版本 codex 存的凭证新版本刷新不了,PoC 报告 §2.2),所以只能报 unverified。
|
|
152
|
+
*/
|
|
153
|
+
export function parseCodexMcpListJson(stdout, exitCode) {
|
|
154
|
+
if (exitCode !== 0)
|
|
155
|
+
return m2('unknown', null, stdout);
|
|
156
|
+
let servers;
|
|
157
|
+
try {
|
|
158
|
+
servers = JSON.parse(stdout);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return m2('unknown', null, stdout);
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(servers))
|
|
164
|
+
return m2('unknown', null, stdout);
|
|
165
|
+
const candidates = [];
|
|
166
|
+
for (const server of servers) {
|
|
167
|
+
if (typeof server !== 'object' || server === null || !isM2Url(server.transport?.url))
|
|
168
|
+
continue;
|
|
169
|
+
const entry = typeof server.name === 'string' ? server.name : '';
|
|
170
|
+
if (server.enabled === false)
|
|
171
|
+
candidates.push({ state: 'needs_enable', entry });
|
|
172
|
+
else if (server.auth_status === 'not_logged_in')
|
|
173
|
+
candidates.push({ state: 'needs_auth', entry });
|
|
174
|
+
else if (server.auth_status === 'o_auth')
|
|
175
|
+
candidates.push({ state: 'unverified', entry });
|
|
176
|
+
else
|
|
177
|
+
candidates.push({ state: 'unknown', entry });
|
|
178
|
+
}
|
|
179
|
+
return candidates.length > 0 ? best(candidates, stdout) : m2('not_configured', null, stdout);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* `agent mcp list`(Cursor):输出只有 `<名字>: <状态>`,没有 URL,所以由调用方先从
|
|
183
|
+
* `<cwd>/.cursor/mcp.json` 与 `~/.cursor/mcp.json` 找出指向 m2 的名字(entryNames)再对行。
|
|
184
|
+
* 授权按目录保存,调用方必须在会话 cwd 下执行命令。
|
|
185
|
+
*/
|
|
186
|
+
export function parseCursorMcpList(output, entryNames, exitCode) {
|
|
187
|
+
if (entryNames.length === 0)
|
|
188
|
+
return m2('not_configured', null, output);
|
|
189
|
+
if (exitCode !== 0)
|
|
190
|
+
return m2('unknown', null, output);
|
|
191
|
+
const status = new Map();
|
|
192
|
+
for (const raw of stripAnsi(output).split(/\r?\n/)) {
|
|
193
|
+
const index = raw.lastIndexOf(': ');
|
|
194
|
+
if (index > 0)
|
|
195
|
+
status.set(raw.slice(0, index).trim(), raw.slice(index + 2).trim());
|
|
196
|
+
}
|
|
197
|
+
const candidates = [];
|
|
198
|
+
for (const name of entryNames) {
|
|
199
|
+
const value = status.get(name);
|
|
200
|
+
if (value === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
const state = value === 'ready' ? 'ready' : value === 'requires_authentication' ? 'needs_auth' : /needs approval/i.test(value) ? 'needs_enable' : 'unknown';
|
|
203
|
+
candidates.push({ state, entry: name });
|
|
204
|
+
}
|
|
205
|
+
return best(candidates, output);
|
|
206
|
+
}
|
|
207
|
+
/** OpenCode 状态行的结尾词(实测过的三种) */
|
|
208
|
+
const OPENCODE_STATUS = [
|
|
209
|
+
['needs authentication', 'needs_auth'],
|
|
210
|
+
['connected', 'ready'],
|
|
211
|
+
['disabled', 'needs_enable'],
|
|
212
|
+
];
|
|
213
|
+
/**
|
|
214
|
+
* `opencode mcp list`:带框线与 ANSI(NO_COLOR 无效)。每个服务器两行:
|
|
215
|
+
* `● <图标> <名字> <状态>`,下一行 `│ <url>`。
|
|
216
|
+
*/
|
|
217
|
+
export function parseOpencodeMcpList(output, exitCode) {
|
|
218
|
+
if (exitCode !== 0)
|
|
219
|
+
return m2('unknown', null, output);
|
|
220
|
+
const text = stripAnsi(output);
|
|
221
|
+
if (/No MCP servers configured/i.test(text))
|
|
222
|
+
return m2('not_configured', null, output);
|
|
223
|
+
const candidates = [];
|
|
224
|
+
let pending = null;
|
|
225
|
+
let sawServer = false;
|
|
226
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
227
|
+
const line = raw.trim();
|
|
228
|
+
const head = /^●\s+\S+\s+(.+)$/u.exec(line);
|
|
229
|
+
if (head?.[1]) {
|
|
230
|
+
sawServer = true;
|
|
231
|
+
const body = head[1].trim();
|
|
232
|
+
const known = OPENCODE_STATUS.find(([word]) => body.endsWith(` ${word}`));
|
|
233
|
+
pending = known ? { entry: body.slice(0, -known[0].length - 1).trim(), state: known[1] } : { entry: body, state: 'unknown' };
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const url = /^│\s+(https?:\/\/\S+)/u.exec(line);
|
|
237
|
+
if (url?.[1] && pending) {
|
|
238
|
+
if (isM2Url(url[1]))
|
|
239
|
+
candidates.push(pending);
|
|
240
|
+
pending = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (candidates.length > 0)
|
|
244
|
+
return best(candidates, output);
|
|
245
|
+
return sawServer ? m2('not_configured', null, output) : m2('unknown', null, output);
|
|
246
|
+
}
|