bootstrap.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /**
  2. * 一键凭证供给 —— ~/.fmode/ 自举
  3. * ---------------------------------------------------------------------------
  4. * 目标:新机器/新容器上,让技能在「零手工配置」前提下拿到可用的 Fmode 凭据。
  5. *
  6. * ⚠️ 诚实声明(2026-09-22 实测,务必先读)
  7. * ---------------------------------------------------------------------------
  8. * 任务书里描述的「手机号 + 验证码 → 创建 ~/.fmode/」路径依赖端点
  9. * POST /api/fmode/verifycode
  10. * 该端点**当前实测 404,服务端未上线**(状态 planned,见 lib/platform.mjs)。
  11. * 因此本模块**不会**伪造短信流程假装成功。
  12. *
  13. * 当前**真实可用**的自举路径(生产实测,与 skill-listen / skill-vision 同源):
  14. *
  15. * 用户登录 FMODE Studio 拿到 sessionToken
  16. * ↓ 写入 FMODE_SESSION_TOKEN 环境变量 或 ~/.fmode/config.json
  17. * POST https://server.fmode.cn/api/fmode/voc-skill/install-prompt
  18. * (header: x-parse-session-token)
  19. * ↓ 从 body.data.prompt 文本中提取 /sk-(?!ant-)[A-Za-z0-9_-]{8,}/
  20. * fmode API token(sk- 开头)—— 仅内存持有,不落盘、不进日志
  21. *
  22. * 一旦 /api/fmode/verifycode 上线,把 platform.mjs 里 verifyCode.status 改为
  23. * 'live',本模块的 requestVerifyCode() / verifyAndProvision() 即自动启用。
  24. */
  25. import fs from 'node:fs';
  26. import path from 'node:path';
  27. import os from 'node:os';
  28. import { ENDPOINTS, TOKEN_RULES, PLATFORM } from './platform.mjs';
  29. const BOM_RE = /^/;
  30. // ============================================================
  31. // 路径解析
  32. // ============================================================
  33. /**
  34. * 解析 ~/.fmode 目录。优先级:
  35. * 1. FMODE_HOME 环境变量
  36. * 2. <home>/.fmode
  37. * @returns {string}
  38. */
  39. export function resolveFmodeDir() {
  40. if (process.env.FMODE_HOME) return path.resolve(process.env.FMODE_HOME);
  41. return path.join(os.homedir(), '.fmode');
  42. }
  43. /** 用户级 config.json 路径 */
  44. export function resolveConfigPath() {
  45. return path.join(resolveFmodeDir(), 'config.json');
  46. }
  47. /** 安全读 JSON(剥 BOM,失败返回 null) */
  48. function readJson(file) {
  49. try {
  50. if (!fs.existsSync(file)) return null;
  51. return JSON.parse(fs.readFileSync(file, 'utf-8').replace(BOM_RE, ''));
  52. } catch {
  53. return null;
  54. }
  55. }
  56. // ============================================================
  57. // 凭据解析(标准 5 级链)
  58. // ============================================================
  59. /**
  60. * 第 0 级:解析 sessionToken。
  61. * 来源:FMODE_SESSION_TOKEN 环境变量 → ~/.fmode/config.json 的 sessionToken。
  62. * @returns {{token: string, source: string}|null}
  63. */
  64. export function resolveSessionToken() {
  65. const env = process.env.FMODE_SESSION_TOKEN;
  66. if (env && env.trim()) {
  67. return { token: env.trim(), source: 'env:FMODE_SESSION_TOKEN' };
  68. }
  69. const cfg = readJson(resolveConfigPath());
  70. if (cfg) {
  71. const t = cfg.sessionToken || (cfg.user && cfg.user.sessionToken) || null;
  72. if (t && String(t).trim()) {
  73. return { token: String(t).trim(), source: `${resolveConfigPath()}#sessionToken` };
  74. }
  75. }
  76. return null;
  77. }
  78. /**
  79. * 校验一个字符串是否是合法的 fmode API token。
  80. * 规则:sk- 开头、排除 sk-ant-、若设了 ANTHROPIC_BASE_URL 必须指向 fmode。
  81. * @param {string} token
  82. * @returns {{ok: boolean, reason?: string}}
  83. */
  84. export function validateToken(token) {
  85. if (!token || typeof token !== 'string') return { ok: false, reason: 'token 为空' };
  86. const t = token.trim();
  87. if (!t.startsWith(TOKEN_RULES.prefix)) {
  88. return { ok: false, reason: `token 必须以 "${TOKEN_RULES.prefix}" 开头` };
  89. }
  90. if (t.startsWith(TOKEN_RULES.exclude)) {
  91. return { ok: false, reason: `拒绝真正的 Anthropic 官方 key("${TOKEN_RULES.exclude}" 前缀)` };
  92. }
  93. const base = process.env.ANTHROPIC_BASE_URL;
  94. if (base && !base.includes(TOKEN_RULES.baseUrlMustInclude)) {
  95. return { ok: false, reason: `ANTHROPIC_BASE_URL=${base} 未指向 fmode,拒绝使用该 token` };
  96. }
  97. return { ok: true };
  98. }
  99. /**
  100. * 第 0 级自举:sessionToken → fmode API token。
  101. * token 仅内存持有,不落盘不进日志(与 listen/vision 生产实现一致)。
  102. *
  103. * @param {string} sessionToken
  104. * @param {{timeoutMs?: number, base?: string}} [opts]
  105. * @returns {Promise<{token: string, source: string}|null>} 失败返回 null(调用方回落下一级)
  106. */
  107. export async function fetchApiTokenFromSession(sessionToken, opts = {}) {
  108. if (!sessionToken) return null;
  109. const base = (opts.base || PLATFORM.gatewayBase).replace(/\/$/, '');
  110. try {
  111. const res = await fetch(`${base}/api/fmode/voc-skill/install-prompt`, {
  112. method: 'POST',
  113. headers: {
  114. 'Content-Type': 'application/json',
  115. 'x-parse-session-token': sessionToken,
  116. },
  117. body: JSON.stringify({
  118. channel: 'claude-code',
  119. scope: 'user',
  120. source: 'skill-core-guide-bootstrap',
  121. }),
  122. signal: AbortSignal.timeout(opts.timeoutMs || 15000),
  123. });
  124. if (!res.ok) return null;
  125. const body = await res.json().catch(() => null);
  126. const prompt =
  127. body && body.data && typeof body.data.prompt === 'string' ? body.data.prompt : '';
  128. const m = prompt.match(TOKEN_RULES.extractRe);
  129. if (!m) return null;
  130. return { token: m[0], source: 'sessionToken 自举(voc-skill/install-prompt)' };
  131. } catch {
  132. // 网络失败一律回落,不泄露错误细节
  133. return null;
  134. }
  135. }
  136. /**
  137. * 标准 5 级凭据解析链。命中即用,全失败返回 null。
  138. *
  139. * 0. sessionToken 自举(FMODE_SESSION_TOKEN / ~/.fmode/config.json)
  140. * 1. 环境变量 FMODE_API_TOKEN
  141. * 2. ~/.fmode/config.json → fmodeApiToken / newapiToken
  142. * 3. <cwd>/.fmode/config.json → fmodeApiToken / newapiToken
  143. * 4. ~/.claude/settings.json(含 .local / 项目级)的 env.ANTHROPIC_AUTH_TOKEN
  144. *
  145. * @param {{cwd?: string, timeoutMs?: number}} [opts]
  146. * @returns {Promise<{token: string, source: string, level: number}|null>}
  147. */
  148. export async function resolveApiToken(opts = {}) {
  149. const cwd = opts.cwd || process.cwd();
  150. // ---- 0. sessionToken 自举 ----
  151. const sess = resolveSessionToken();
  152. if (sess) {
  153. const boot = await fetchApiTokenFromSession(sess.token, { timeoutMs: opts.timeoutMs });
  154. if (boot) {
  155. const v = validateToken(boot.token);
  156. if (v.ok) return { token: boot.token, source: boot.source, level: 0 };
  157. }
  158. }
  159. // ---- 1. 环境变量 ----
  160. const env = process.env.FMODE_API_TOKEN;
  161. if (env && validateToken(env).ok) {
  162. return { token: env.trim(), source: 'env:FMODE_API_TOKEN', level: 1 };
  163. }
  164. // ---- 2. 用户级 config ----
  165. const userCfg = readJson(resolveConfigPath());
  166. if (userCfg) {
  167. const t = userCfg.fmodeApiToken || userCfg.newapiToken;
  168. if (t && validateToken(t).ok) {
  169. return { token: String(t).trim(), source: `${resolveConfigPath()}#fmodeApiToken`, level: 2 };
  170. }
  171. }
  172. // ---- 3. 项目级 config ----
  173. const projCfg = readJson(path.join(cwd, '.fmode', 'config.json'));
  174. if (projCfg) {
  175. const t = projCfg.fmodeApiToken || projCfg.newapiToken;
  176. if (t && validateToken(t).ok) {
  177. return { token: String(t).trim(), source: `${cwd}/.fmode/config.json#fmodeApiToken`, level: 3 };
  178. }
  179. }
  180. // ---- 4. Claude Code settings ----
  181. const settingsFiles = [
  182. path.join(os.homedir(), '.claude', 'settings.json'),
  183. path.join(os.homedir(), '.claude', 'settings.local.json'),
  184. path.join(cwd, '.claude', 'settings.json'),
  185. path.join(cwd, '.claude', 'settings.local.json'),
  186. ];
  187. for (const f of settingsFiles) {
  188. const j = readJson(f);
  189. const t = j && j.env && j.env.ANTHROPIC_AUTH_TOKEN;
  190. if (t && validateToken(t).ok) {
  191. return { token: String(t).trim(), source: `${f}#env.ANTHROPIC_AUTH_TOKEN`, level: 4 };
  192. }
  193. }
  194. return null;
  195. }
  196. // ============================================================
  197. // 目录 / 配置文件供给
  198. // ============================================================
  199. /**
  200. * 确保 ~/.fmode/ 目录结构存在(幂等)。
  201. * @param {{fmodeDir?: string}} [opts]
  202. * @returns {{fmodeDir: string, credentialsDir: string, created: string[]}}
  203. */
  204. export function ensureFmodeDir(opts = {}) {
  205. const fmodeDir = opts.fmodeDir || resolveFmodeDir();
  206. const credentialsDir = path.join(fmodeDir, 'credentials');
  207. const projectsDir = path.join(fmodeDir, 'projects');
  208. const created = [];
  209. for (const d of [fmodeDir, credentialsDir, projectsDir]) {
  210. if (!fs.existsSync(d)) {
  211. fs.mkdirSync(d, { recursive: true, mode: 0o700 });
  212. created.push(d);
  213. }
  214. }
  215. // 凭据目录强制 700
  216. try {
  217. fs.chmodSync(credentialsDir, 0o700);
  218. } catch {
  219. /* 非 POSIX 或权限不足时忽略 */
  220. }
  221. return { fmodeDir, credentialsDir, projectsDir, created };
  222. }
  223. /**
  224. * 写入 ~/.fmode/config.json(幂等合并,绝不覆盖已有字段)。
  225. * ⚠️ 只写非敏感字段。sessionToken 等敏感值由用户自行写入,本函数不代写。
  226. *
  227. * @param {object} patch 要合并进 config 的字段
  228. * @param {{fmodeDir?: string, mode?: number}} [opts]
  229. * @returns {{path: string, written: boolean, merged: object}}
  230. */
  231. export function writeConfig(patch = {}, opts = {}) {
  232. const fmodeDir = opts.fmodeDir || resolveFmodeDir();
  233. if (!fs.existsSync(fmodeDir)) fs.mkdirSync(fmodeDir, { recursive: true, mode: 0o700 });
  234. const file = path.join(fmodeDir, 'config.json');
  235. const existing = readJson(file) || {};
  236. // 敏感字段白名单外的一律不写
  237. const FORBIDDEN = ['apiKey', 'apiKeys', 'sessionToken', 'githubToken', 'password', 'secret'];
  238. const safe = {};
  239. for (const [k, v] of Object.entries(patch)) {
  240. if (FORBIDDEN.includes(k)) continue;
  241. safe[k] = v;
  242. }
  243. const merged = { ...existing, ...safe };
  244. const changed = JSON.stringify(existing) !== JSON.stringify(merged);
  245. if (changed) {
  246. fs.writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 });
  247. try {
  248. fs.chmodSync(file, 0o600);
  249. } catch {
  250. /* ignore */
  251. }
  252. }
  253. return { path: file, written: changed, merged };
  254. }
  255. /** 写一个凭据文件到 credentials/(600 权限) */
  256. export function writeCredential(name, content, opts = {}) {
  257. const { credentialsDir } = ensureFmodeDir(opts);
  258. const file = path.join(credentialsDir, name);
  259. fs.writeFileSync(file, content, { mode: 0o600 });
  260. try {
  261. fs.chmodSync(file, 0o600);
  262. } catch {
  263. /* ignore */
  264. }
  265. return file;
  266. }
  267. // ============================================================
  268. // 短信验证码路径(planned —— 端点未上线)
  269. // ============================================================
  270. /**
  271. * 请求手机号验证码。
  272. * ⚠️ 依赖 POST /api/fmode/verifycode,当前实测 404(未上线)。
  273. * 本函数会**先探测**端点,未上线时返回 { ok:false, planned:true },
  274. * 调用方应回落到 sessionToken 路径,**不要**把它当成发送成功。
  275. *
  276. * @param {string} phone
  277. * @param {{timeoutMs?: number, base?: string}} [opts]
  278. * @returns {Promise<{ok: boolean, planned?: boolean, status?: number, reason?: string}>}
  279. */
  280. export async function requestVerifyCode(phone, opts = {}) {
  281. if (!phone || !/^1[3-9]\d{9}$/.test(String(phone).trim())) {
  282. return { ok: false, reason: '手机号格式不合法(需中国大陆 11 位手机号)' };
  283. }
  284. const ep = ENDPOINTS.verifyCode;
  285. const base = (opts.base || PLATFORM.gatewayBase).replace(/\/$/, '');
  286. let status = 0;
  287. try {
  288. const res = await fetch(`${base}/api/fmode/verifycode`, {
  289. method: 'POST',
  290. headers: { 'Content-Type': 'application/json' },
  291. body: JSON.stringify({ phone: String(phone).trim(), action: 'send' }),
  292. signal: AbortSignal.timeout(opts.timeoutMs || 12000),
  293. });
  294. status = res.status;
  295. } catch (err) {
  296. return { ok: false, reason: `网络不可达:${err.message}` };
  297. }
  298. if (status === 404) {
  299. return {
  300. ok: false,
  301. planned: true,
  302. status,
  303. reason:
  304. `${ep.url} 返回 404 —— 该端点服务端未上线(真值表状态:${ep.status})。` +
  305. `请改用 sessionToken 路径(见 resolveSessionToken / fetchApiTokenFromSession)。`,
  306. };
  307. }
  308. if (status >= 200 && status < 300) {
  309. return { ok: true, status };
  310. }
  311. return { ok: false, status, reason: `端点返回 HTTP ${status}` };
  312. }
  313. /**
  314. * 验证码校验 + 开户(planned —— 端点未上线)。
  315. * 同 requestVerifyCode,端点未上线时显式返回 planned,不伪造成功。
  316. *
  317. * @param {string} phone
  318. * @param {string} code
  319. * @param {{timeoutMs?: number, base?: string}} [opts]
  320. * @returns {Promise<{ok: boolean, planned?: boolean, status?: number, reason?: string}>}
  321. */
  322. export async function verifyAndProvision(phone, code, opts = {}) {
  323. if (!code || !/^\d{4,8}$/.test(String(code).trim())) {
  324. return { ok: false, reason: '验证码格式不合法' };
  325. }
  326. const base = (opts.base || PLATFORM.gatewayBase).replace(/\/$/, '');
  327. let status = 0;
  328. let body = null;
  329. try {
  330. const res = await fetch(`${base}/api/fmode/verifycode`, {
  331. method: 'POST',
  332. headers: { 'Content-Type': 'application/json' },
  333. body: JSON.stringify({ phone: String(phone).trim(), code: String(code).trim(), action: 'verify' }),
  334. signal: AbortSignal.timeout(opts.timeoutMs || 12000),
  335. });
  336. status = res.status;
  337. body = await res.json().catch(() => null);
  338. } catch (err) {
  339. return { ok: false, reason: `网络不可达:${err.message}` };
  340. }
  341. if (status === 404) {
  342. return {
  343. ok: false,
  344. planned: true,
  345. status,
  346. reason:
  347. 'POST /api/fmode/verifycode 未上线(404)。' +
  348. '当前一键凭证供给请走 sessionToken 路径:登录 FMODE Studio → ' +
  349. 'export FMODE_SESSION_TOKEN=... 或写入 ~/.fmode/config.json 的 sessionToken。',
  350. };
  351. }
  352. if (status >= 200 && status < 300) {
  353. return { ok: true, status, body };
  354. }
  355. return { ok: false, status, reason: `端点返回 HTTP ${status}` };
  356. }
  357. // ============================================================
  358. // 编排:一键凭证供给
  359. // ============================================================
  360. /**
  361. * 一键凭证供给主入口。
  362. *
  363. * 流程:
  364. * 1. 确保 ~/.fmode/ 目录结构(幂等)
  365. * 2. 走标准 5 级凭据链解析 token
  366. * 3. 命中 → 返回可用凭据
  367. * 4. 未命中 → 尝试 planned 短信路径探测,明确报告未上线,并给出
  368. * **可执行的**下一步指引(不伪造成功)
  369. *
  370. * @param {{cwd?: string, phone?: string, code?: string, dryRun?: boolean, timeoutMs?: number}} [opts]
  371. * @returns {Promise<object>}
  372. */
  373. export async function bootstrap(opts = {}) {
  374. const report = {
  375. ok: false,
  376. fmodeDir: resolveFmodeDir(),
  377. steps: [],
  378. token: null,
  379. guidance: [],
  380. };
  381. const step = (name, status, detail) => report.steps.push({ name, status, detail });
  382. // 1. 目录
  383. const dirs = ensureFmodeDir(opts);
  384. step(
  385. 'ensure-fmode-dir',
  386. 'ok',
  387. dirs.created.length ? `已创建 ${dirs.created.length} 个目录` : '目录已存在(幂等)',
  388. );
  389. // 2. 凭据链
  390. const resolved = await resolveApiToken(opts);
  391. if (resolved) {
  392. report.ok = true;
  393. report.token = { source: resolved.source, level: resolved.level, masked: maskToken(resolved.token) };
  394. step('resolve-credential', 'ok', `第 ${resolved.level} 级命中:${resolved.source}`);
  395. step('verify-credential', 'ok', 'token 形态校验通过(sk- 前缀,非 sk-ant-)');
  396. return report;
  397. }
  398. step('resolve-credential', 'fail', '5 级凭据链全部未命中');
  399. // 3. 探测 planned 短信路径
  400. if (opts.phone) {
  401. const vc = await requestVerifyCode(opts.phone, opts);
  402. if (vc.planned) {
  403. step('sms-verifycode', 'planned', vc.reason);
  404. } else if (vc.ok) {
  405. step('sms-verifycode', 'ok', '验证码已发送');
  406. report.guidance.push('请向用户索取验证码,然后调用 verifyAndProvision(phone, code)');
  407. return report;
  408. } else {
  409. step('sms-verifycode', 'fail', vc.reason || '发送失败');
  410. }
  411. } else {
  412. step('sms-verifycode', 'skipped', '未提供 phone,跳过短信路径');
  413. }
  414. // 4. 明确指引
  415. report.guidance = [
  416. '当前可用路径(sessionToken 自举,生产已验证):',
  417. ' 1) 浏览器登录 FMODE Studio,取得 sessionToken(形如 r:xxxx)',
  418. ` 2) export FMODE_SESSION_TOKEN='r:xxxx' # 或写入 ${resolveConfigPath()} 的 "sessionToken" 字段`,
  419. ' 3) 重跑 skill-core bootstrap —— 将自动换取 fmode API token(仅内存持有)',
  420. '',
  421. '备选路径(手工配置,长期有效):',
  422. ` 在 ${resolveConfigPath()} 写入 { "fmodeApiToken": "sk-..." }`,
  423. ' 或在 Claude Code settings.json 的 env.ANTHROPIC_AUTH_TOKEN 中配置(平台 SK 即此值)',
  424. '',
  425. `短信验证码路径依赖 ${ENDPOINTS.verifyCode.url},该端点当前未上线(404),`,
  426. '上线后本模块自动启用,无需改代码。',
  427. ];
  428. return report;
  429. }
  430. /** token 脱敏展示(只留头尾,绝不打印本体) */
  431. export function maskToken(token) {
  432. if (!token || typeof token !== 'string') return '(none)';
  433. const t = token.trim();
  434. if (t.length <= 12) return `${t.slice(0, 3)}***`;
  435. return `${t.slice(0, 6)}...${t.slice(-4)}`;
  436. }
  437. /** 人类可读的自举状态摘要 */
  438. export function describeBootstrapStatus(report) {
  439. const lines = [];
  440. const icon = { ok: '✅', fail: '❌', planned: '🕓', skipped: '⏭️ ' };
  441. lines.push('\n Fmode 凭证自举状态');
  442. lines.push(' ' + '─'.repeat(60));
  443. lines.push(` ~/.fmode 目录:${report.fmodeDir}`);
  444. for (const s of report.steps) {
  445. lines.push(` ${icon[s.status] || '·'} ${s.name}:${s.detail}`);
  446. }
  447. lines.push(' ' + '─'.repeat(60));
  448. if (report.ok && report.token) {
  449. lines.push(` 结果:✅ 凭据可用(${report.token.source},${report.token.masked})`);
  450. } else {
  451. lines.push(' 结果:❌ 未取得可用凭据');
  452. }
  453. if (report.guidance.length) {
  454. lines.push('');
  455. for (const g of report.guidance) lines.push(` ${g}`);
  456. }
  457. lines.push('');
  458. return lines.join('\n');
  459. }
  460. export default { bootstrap, resolveApiToken, resolveSessionToken, ensureFmodeDir };