@m2canvas/connect 0.3.1 → 0.4.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * 配对:安全三条里的第三条。Origin 白名单挡住了其他网站,但挡不住两类来源 —— `--dev` 时本机其他跑在开发端口上的页面
3
+ * 和本机其他进程(可以伪造 Origin)。它们都拿不到只出现在终端里的配对码。
4
+ * 配对 = 授权该页面以用户的身份驱动本机 agent(包括执行命令),所以码和 token 都按这个级别保管。
5
+ *
6
+ * 流程:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
7
+ *
8
+ * 维护约束:
9
+ * - 本地只存 token 的 sha256,不存明文;文件 0600、目录 0700。
10
+ * - token 绑定配对时的 Origin:开发域名配出的 token 不能拿到正式域名用。
11
+ * - 配对码约 40 bit,每个码最多错 MAX_CODE_FAILURES 次、CODE_TTL_MS 后过期,二者都会轮换并在终端重新打印;
12
+ * 自上次成功配对以来累计错 MAX_TOTAL_FAILURES 次就锁定(不论中间换过几个码、码是不是过期换的),只有重启 bridge 才解除
13
+ * (锁只在内存里)。锁定不影响已配对的页面。
14
+ * 不要为了「方便」放宽次数、延长有效期或加一个页面能触发的解锁:码是唯一挡住本机恶意页面的东西。
15
+ * 也不要改成「在终端按回车解锁」:那需要读终端,而 bridge 放到后台(`… bridge &`)时读终端会被系统挂起(review r2 实测)。
16
+ * - 比较用常量时间(timingSafeEqual),不要改回 ===。
17
+ */
18
+ import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
19
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+ export const CODE_TTL_MS = 10 * 60 * 1000;
22
+ export const MAX_CODE_FAILURES = 5;
23
+ export const TOKEN_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
24
+ export const TOKEN_IDLE_DAYS = 30;
25
+ export const MAX_PAIRINGS = 20;
26
+ /**
27
+ * 自上次成功配对以来累计错这么多次就锁定,重启 bridge 才解除。按累计而不是「因错码轮换的次数」计:
28
+ * 码过期换新时不清零 —— 否则每个码错 4 次再等它过期就能无限猜下去(review r3 S4)。
29
+ */
30
+ export const MAX_TOTAL_FAILURES = 15;
31
+ /** 同一 Origin、同一个码在终端的重复提示间隔 */
32
+ const PROMPT_DEDUPE_MS = 60_000;
33
+ /** Crockford base32:去掉易混的 I、L、O、U */
34
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
35
+ const CODE_LENGTH = 8;
36
+ /** 用户输入归一:大小写、空格、连字符;按 Crockford 规则把 O 视同 0、I / L 视同 1 */
37
+ export function normalizePairingCode(input) {
38
+ return input
39
+ .toUpperCase()
40
+ .replace(/[\s-]/g, '')
41
+ .replace(/O/g, '0')
42
+ .replace(/[IL]/g, '1');
43
+ }
44
+ function formatCode(code) {
45
+ return `${code.slice(0, 4)}-${code.slice(4)}`;
46
+ }
47
+ function sha256(value) {
48
+ return createHash('sha256').update(value).digest();
49
+ }
50
+ function sameSecret(a, b) {
51
+ const left = sha256(a);
52
+ const right = sha256(b);
53
+ return timingSafeEqual(left, right);
54
+ }
55
+ function errorText(error) {
56
+ return error instanceof Error ? error.message : String(error);
57
+ }
58
+ export class PairingManager {
59
+ active = null;
60
+ /** 自上次成功配对以来累计的错码次数(跨码、跨过期);配对成功时清零 */
61
+ failuresSinceSuccess = 0;
62
+ locked = false;
63
+ /** Origin → 上次在终端提示它的时间与当时的码,用于去重 */
64
+ lastPrompt = new Map();
65
+ store;
66
+ now;
67
+ onCodeIssued;
68
+ onLocked;
69
+ onProblem;
70
+ constructor(options) {
71
+ this.store = options.store;
72
+ this.now = options.now ?? Date.now;
73
+ this.onCodeIssued = options.onCodeIssued;
74
+ this.onLocked = options.onLocked ?? (() => undefined);
75
+ this.onProblem = options.onProblem ?? (() => undefined);
76
+ }
77
+ /** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式 */
78
+ issueCode(reason, origin = null) {
79
+ let code = '';
80
+ for (let i = 0; i < CODE_LENGTH; i++)
81
+ code += ALPHABET[randomInt(ALPHABET.length)];
82
+ this.active = { code, issuedAt: this.now(), failures: 0 };
83
+ const display = formatCode(code);
84
+ if (origin)
85
+ this.lastPrompt.set(origin, { at: this.now(), code });
86
+ this.onCodeIssued({ display, reason, origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
87
+ return display;
88
+ }
89
+ hasValidCode() {
90
+ return this.active !== null && this.now() - this.active.issuedAt <= CODE_TTL_MS;
91
+ }
92
+ isLocked() {
93
+ return this.locked;
94
+ }
95
+ /**
96
+ * 未配对页面连上时调用。没有有效码就出新码;有有效码也把它连同发起方 Origin 再提示一次 ——
97
+ * 最常见的首次流程是「启动 bridge 后马上打开页面」,终端里只有一个不带 Origin 的启动码,
98
+ * 不再提示的话用户看不到是哪个页面在请求配对。同一 Origin、同一个码一分钟内只提示一次(页面重连不刷屏)。
99
+ * 锁定期间什么都不做。
100
+ */
101
+ ensureCode(origin) {
102
+ if (this.locked)
103
+ return;
104
+ if (!this.hasValidCode() || !this.active) {
105
+ this.issueCode('requested', origin);
106
+ return;
107
+ }
108
+ const last = this.lastPrompt.get(origin);
109
+ if (last && last.code === this.active.code && this.now() - last.at < PROMPT_DEDUPE_MS)
110
+ return;
111
+ this.lastPrompt.set(origin, { at: this.now(), code: this.active.code });
112
+ this.onCodeIssued({ display: formatCode(this.active.code), reason: 'requested', origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
113
+ }
114
+ /**
115
+ * 配对。保存 token 失败时抛出(调用方回给页面 internal),此时码不被消耗、可以重试。
116
+ *
117
+ * 暴力尝试的上限:每个码最多错 MAX_CODE_FAILURES 次(然后换码),自上次成功以来累计错 MAX_TOTAL_FAILURES 次锁定,
118
+ * 锁定期间不再出码、不再比对,只有重启 bridge 才解除。没有累计上限,换码本身就是无限次尝试
119
+ * (review r1 实测 1.6 秒 20000 次),还会让陌生页面把用户的配对码一直刷掉。
120
+ */
121
+ pair(input, origin) {
122
+ if (this.locked)
123
+ return { ok: false, code: 'pairing_locked' };
124
+ if (!this.active) {
125
+ // 码已被消费:请用户看终端里的新码
126
+ this.issueCode('requested', origin);
127
+ return { ok: false, code: 'pairing_code_rotated' };
128
+ }
129
+ if (!this.hasValidCode()) {
130
+ this.issueCode('expired', origin);
131
+ return { ok: false, code: 'pairing_code_rotated' };
132
+ }
133
+ const active = this.active;
134
+ if (sameSecret(normalizePairingCode(input), active.code)) {
135
+ const token = this.createToken(origin);
136
+ this.active = null;
137
+ this.failuresSinceSuccess = 0;
138
+ return { ok: true, token };
139
+ }
140
+ active.failures += 1;
141
+ this.failuresSinceSuccess += 1;
142
+ if (this.failuresSinceSuccess >= MAX_TOTAL_FAILURES) {
143
+ this.locked = true;
144
+ this.active = null;
145
+ this.onLocked(origin);
146
+ return { ok: false, code: 'pairing_locked' };
147
+ }
148
+ if (active.failures < MAX_CODE_FAILURES) {
149
+ const attemptsLeft = Math.min(MAX_CODE_FAILURES - active.failures, MAX_TOTAL_FAILURES - this.failuresSinceSuccess);
150
+ return { ok: false, code: 'pairing_invalid', attemptsLeft };
151
+ }
152
+ this.issueCode('too_many_failures', origin);
153
+ return { ok: false, code: 'pairing_code_rotated' };
154
+ }
155
+ createToken(origin) {
156
+ const token = randomBytes(32).toString('base64url');
157
+ const nowIso = new Date(this.now()).toISOString();
158
+ const records = this.liveRecords();
159
+ records.push({ id: randomUUID(), tokenHash: sha256(token).toString('hex'), origin, createdAt: nowIso, lastUsedAt: nowIso });
160
+ // 超出上限时淘汰最久未用的
161
+ records.sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
162
+ // 这里的写失败必须抛出:token 没存下来,页面拿到也用不了
163
+ this.store.save(records.slice(0, MAX_PAIRINGS));
164
+ return token;
165
+ }
166
+ /** 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定) */
167
+ liveRecords() {
168
+ const all = this.store.load();
169
+ const live = all.filter((record) => this.now() - Date.parse(record.lastUsedAt) <= TOKEN_IDLE_MS);
170
+ if (live.length !== all.length)
171
+ this.trySave(live);
172
+ return live;
173
+ }
174
+ trySave(records) {
175
+ try {
176
+ this.store.save(records);
177
+ }
178
+ catch (error) {
179
+ this.onProblem(`Could not update the pairing file (${errorText(error)}). Pairings still work for now but may not survive a restart.`);
180
+ }
181
+ }
182
+ /**
183
+ * token 有效且与当前 Origin 匹配时返回 true,并刷新 lastUsedAt(闲置期从此刻重新计算)。
184
+ * 刷新写盘失败只告警、仍判有效:判定本身只依赖读到的记录,不能因为磁盘满让已配对的页面全部掉线(更不能让 bridge 崩溃)。
185
+ */
186
+ verifyToken(token, origin) {
187
+ if (typeof token !== 'string' || token.length === 0)
188
+ return false;
189
+ const hash = sha256(token);
190
+ const records = this.liveRecords();
191
+ const match = records.find((record) => {
192
+ const stored = Buffer.from(record.tokenHash, 'hex');
193
+ return stored.length === hash.length && timingSafeEqual(stored, hash) && record.origin === origin;
194
+ });
195
+ if (!match)
196
+ return false;
197
+ match.lastUsedAt = new Date(this.now()).toISOString();
198
+ this.trySave(records);
199
+ return true;
200
+ }
201
+ unpairAll() {
202
+ this.store.clear();
203
+ }
204
+ }
205
+ function isRecord(value) {
206
+ if (typeof value !== 'object' || value === null)
207
+ return false;
208
+ const record = value;
209
+ return ['id', 'tokenHash', 'origin', 'createdAt', 'lastUsedAt'].every((key) => typeof record[key] === 'string');
210
+ }
211
+ /**
212
+ * `~/.m2canvas/bridge-pairings.json`。文件损坏按空处理(用户需要重新配对)并经 onProblem 报告,
213
+ * 不抛错:bridge 仍应能启动,只是之前的配对作废。
214
+ */
215
+ export function createFilePairingStore(path, onProblem = () => undefined) {
216
+ return {
217
+ load() {
218
+ if (!existsSync(path))
219
+ return [];
220
+ try {
221
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
222
+ const list = typeof parsed === 'object' && parsed !== null ? parsed.pairings : undefined;
223
+ if (!Array.isArray(list))
224
+ throw new Error('missing "pairings" array');
225
+ return list.filter(isRecord);
226
+ }
227
+ catch (error) {
228
+ onProblem(`Ignoring unreadable pairing file ${path} (${error instanceof Error ? error.message : String(error)}). Pages will need to pair again.`);
229
+ return [];
230
+ }
231
+ },
232
+ save(records) {
233
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
234
+ chmodSync(dirname(path), 0o700);
235
+ writeFileSync(path, `${JSON.stringify({ version: 1, pairings: records }, null, 2)}\n`, { mode: 0o600 });
236
+ chmodSync(path, 0o600);
237
+ },
238
+ clear() {
239
+ rmSync(path, { force: true });
240
+ },
241
+ };
242
+ }
@@ -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
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 会话前预检:登录状态 + m2 MCP 状态,并给出页面可展示的下一步(命令或手动步骤)。
3
+ *
4
+ * 为什么要预检:m2 没配置、没授权、还没连上时,ACP 里没有任何信号,agent 只会说「没有这个工具」(PoC 报告 §4.1)。
5
+ *
6
+ * 边界:
7
+ * - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查);bridge 不替用户登录、不替用户授权,只返回命令让用户在自己的终端执行。
8
+ * - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
9
+ * - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
10
+ * - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
11
+ * - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
12
+ */
13
+ import { join } from 'node:path';
14
+ import { findM2Names } from './m2-config.js';
15
+ import { CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
16
+ import { combinedOutput } from './exec.js';
17
+ import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
18
+ export const PRECHECK_TIMEOUT_MS = 45_000;
19
+ export function shellQuote(value) {
20
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
21
+ }
22
+ export function commandHint(command, args, cwd = null) {
23
+ const line = [command, ...args].map(shellQuote).join(' ');
24
+ return { command, args, cwd, display: cwd ? `cd ${shellQuote(cwd)} && ${line}` : line };
25
+ }
26
+ const run = (command, args) => ({ kind: 'command', command: commandHint(command, args) });
27
+ /** 执行失败(启动不了 / 超时)时的统一兜底:报 unknown 并带上原因,不猜 */
28
+ function failed(result) {
29
+ if (result.error && result.code === null)
30
+ return tail(combinedOutput(result));
31
+ return null;
32
+ }
33
+ function toLogin(parse, next) {
34
+ return { state: parse.state, next: parse.state === 'logged_out' || parse.state === 'unknown' ? next : null, raw: parse.raw };
35
+ }
36
+ function toM2(parse, nextFor) {
37
+ const next = parse.state === 'not_configured' ? { kind: 'configure' } : nextFor(parse.state, parse.entry ?? 'm2');
38
+ return { state: parse.state, entry: parse.entry, next, raw: parse.raw };
39
+ }
40
+ function plan(agent, deps) {
41
+ const cmd = agent.command ?? agent.path ?? agent.id;
42
+ const path = agent.path ?? cmd;
43
+ switch (agent.id) {
44
+ case 'claude':
45
+ return {
46
+ exec: { command: path, prefix: [] },
47
+ login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(cmd, ['auth', 'login']) },
48
+ m2: {
49
+ args: ['mcp', 'list'],
50
+ parse: (r) => parseClaudeMcpList(combinedOutput(r), r.code),
51
+ next: (state, entry) => state === 'needs_auth'
52
+ ? run(cmd, ['mcp', 'login', entry])
53
+ : state === 'needs_enable'
54
+ ? { kind: 'manual', text: `Run claude in ${deps.cwd} and approve the project MCP server "${entry}".` }
55
+ : null,
56
+ },
57
+ };
58
+ case 'codex': {
59
+ const dir = deps.adapters.installDir('codex');
60
+ const display = join(dir, CODEX_BIN_LINK);
61
+ return {
62
+ exec: { command: deps.nodePath, prefix: [join(dir, CODEX_SCRIPT)] },
63
+ login: { args: ['login', 'status'], parse: (r) => parseCodexLoginStatus(combinedOutput(r), r.code), next: run(display, ['login']) },
64
+ m2: {
65
+ args: ['mcp', 'list', '--json'],
66
+ parse: (r) => parseCodexMcpListJson(r.stdout, r.code),
67
+ next: (state, entry) => state === 'needs_auth' || state === 'unverified'
68
+ ? run(display, ['mcp', 'login', entry])
69
+ : state === 'needs_enable'
70
+ ? { kind: 'manual', text: `Set enabled = true under [mcp_servers.${entry}] in your Codex config.toml.` }
71
+ : null,
72
+ },
73
+ };
74
+ }
75
+ case 'cursor':
76
+ return {
77
+ exec: { command: path, prefix: [] },
78
+ login: { args: ['status'], parse: (r) => parseCursorStatus(combinedOutput(r)), next: run(cmd, ['login']) },
79
+ m2: {
80
+ args: ['mcp', 'list'],
81
+ parse: (r) => {
82
+ const found = findM2Names([join(deps.cwd, '.cursor', 'mcp.json'), join(deps.home, '.cursor', 'mcp.json')]);
83
+ // 配置文件坏了又找不到 m2:不能报 not_configured(会引导用户去重复添加),报 unknown 并说明是哪个文件
84
+ if (found.names.length === 0 && found.unreadable.length > 0) {
85
+ return { state: 'unknown', entry: null, raw: tail(`Could not read Cursor MCP config: ${found.unreadable.join('; ')}`) };
86
+ }
87
+ return parseCursorMcpList(combinedOutput(r), found.names, r.code);
88
+ },
89
+ next: (state, entry) => state === 'needs_auth'
90
+ ? { kind: 'command', command: commandHint(cmd, ['mcp', 'login', entry], deps.cwd) }
91
+ : state === 'needs_enable'
92
+ ? { kind: 'command', command: commandHint(cmd, ['mcp', 'enable', entry], deps.cwd) }
93
+ : null,
94
+ },
95
+ };
96
+ case 'opencode':
97
+ return {
98
+ exec: { command: path, prefix: [] },
99
+ // 不登录也能用自带的免费模型(PoC 报告 §2.4),不检查
100
+ login: { args: null, parse: () => ({ state: 'not_required', raw: null }), next: null },
101
+ m2: {
102
+ args: ['mcp', 'list'],
103
+ parse: (r) => parseOpencodeMcpList(combinedOutput(r), r.code),
104
+ next: (state, entry) => state === 'needs_auth'
105
+ ? run(cmd, ['mcp', 'auth', entry])
106
+ : state === 'needs_enable'
107
+ ? { kind: 'manual', text: `Set "enabled": true for "${entry}" in your OpenCode config.` }
108
+ : null,
109
+ },
110
+ };
111
+ case 'qoder':
112
+ return {
113
+ exec: { command: path, prefix: [] },
114
+ login: { args: ['status'], parse: (r) => parseQoderStatus(combinedOutput(r)), next: run(cmd, ['login']) },
115
+ m2: {
116
+ args: ['mcp', 'list'],
117
+ parse: (r) => parseQoderMcpList(combinedOutput(r), r.code),
118
+ // qodercli 没有 mcp login 子命令,只能在交互界面里授权
119
+ next: (state, entry) => (state === 'needs_auth' ? { kind: 'manual', text: `Run qodercli, type /mcp, choose "${entry}" and authorize it.` } : null),
120
+ },
121
+ };
122
+ }
123
+ }
124
+ async function runStep(deps, p, args) {
125
+ return deps.exec(p.exec.command, [...p.exec.prefix, ...args], { env: deps.env, cwd: deps.cwd, timeoutMs: PRECHECK_TIMEOUT_MS });
126
+ }
127
+ export async function precheckM2(agent, deps) {
128
+ const p = plan(agent, deps);
129
+ const result = await runStep(deps, p, p.m2.args);
130
+ const failure = failed(result);
131
+ return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
132
+ }
133
+ /** 调用方保证 agent 已安装;适配器类 agent 的适配器已就绪 */
134
+ export async function precheckAgent(agent, deps, now = () => new Date()) {
135
+ const p = plan(agent, deps);
136
+ const loginArgs = p.login.args;
137
+ const [loginResult, m2] = await Promise.all([
138
+ loginArgs ? runStep(deps, p, loginArgs) : Promise.resolve(null),
139
+ precheckM2(agent, deps),
140
+ ]);
141
+ let login;
142
+ if (!loginResult)
143
+ login = p.login.parse({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, error: null });
144
+ else {
145
+ const failure = failed(loginResult);
146
+ login = failure ? { state: 'unknown', raw: failure } : p.login.parse(loginResult);
147
+ }
148
+ return { agent: agent.id, login: toLogin(login, p.login.next), m2, checkedAt: now().toISOString() };
149
+ }