Pārlūkot izejas kodu

security: 凭据链第0级自举——sessionToken 动态换取 fmode API token

- vision-client.mjs: resolveApiToken() 新增第0级——FMODE_SESSION_TOKEN 或 ~/.fmode/config.json sessionToken → POST /api/fmode/voc-skill/install-prompt 动态换取 API token;token 仅内存持有,禁落盘禁日志;换取失败明确报错后回落原有四级
- smoke.js: resolveApiToken 现为 async,await 适配
- README/SKILL.md Token 加载链章节更新:画出第0级自举+回落链路
- version 0.2.0 → 0.2.1

Co-Authored-By: Claude Code <noreply@anthropic.com>
liuyuyang 1 nedēļu atpakaļ
vecāks
revīzija
fcf8669978

+ 2 - 1
README.md

@@ -33,8 +33,9 @@
 
 ## Token 加载链(仅 Fmode API 路径需要)
 
-按优先级自动解析(**仓库与代码中无任何密钥**):
+按优先级自动解析(第0级自举 → 回落,**仓库与代码中无任何密钥**):
 
+0. **第0级自举(推荐)**:`FMODE_SESSION_TOKEN` 环境变量或 `~/.fmode/config.json` 的 `sessionToken` → 调 fmode API 动态换取 API token。登录 FMODE Studio 即可,无需手工配置;token 仅内存持有,不落盘不进日志
 1. 环境变量 `FMODE_API_TOKEN`
 2. `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken`(FmodeStudio 保存配置后写入)
 3. `~/.claude/settings.json`(含 `settings.local.json` / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN` —— 即 Claude Code 的 `sk-` token(仅当 `sk-` 开头、非 `sk-ant-`、base 指向 fmode 时采纳,零配置自动命中)

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "fmode-vision",
-  "version": "0.2.0",
+  "version": "0.2.1",
   "description": "Claude Code skill: analyze images and videos with host-multimodal-first detection (Claude Code / Codex vision models) falling back to Fmode API glm-5.3-flash. Single-pass and multi-pass focused analysis with structured JSON output. Token chain: FMODE_API_TOKEN, ~/.fmode/config.json, ~/.claude/settings.json env.ANTHROPIC_AUTH_TOKEN, project .fmode/config.json.",
   "type": "commonjs",
   "bin": {

+ 1 - 1
scripts/smoke.js

@@ -63,7 +63,7 @@ for (const rel of required) {
   const savedFmodeToken = process.env.FMODE_API_TOKEN;
   delete process.env.FMODE_API_TOKEN;
   try {
-    mod.resolveApiToken(tmpHome);
+    await mod.resolveApiToken(tmpHome);
     fail('resolveApiToken should throw when no token source exists');
   } catch (e) {
     if (!/FMODE_API_TOKEN/.test(e.message)) fail('error message should mention FMODE_API_TOKEN');

+ 4 - 1
skills/fmode-vision/README.md

@@ -17,9 +17,12 @@
 
 ### 1. Token 解析链 (`resolveApiToken`)
 
-四级优先级,短路返回:
+第0级自举 + 四级回落,短路返回:
 
 ```
+【第0级 自举】FMODE_SESSION_TOKEN 环境变量 或 ~/.fmode/config.json 的 sessionToken
+  → 调 fmode API 动态换取 API token(登录 FMODE Studio 即可,无需手工配置;
+    token 仅内存持有,不落盘不进日志;换取失败明确报错后回落)
 FMODE_API_TOKEN 环境变量
   → ~/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
     → ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)

+ 3 - 2
skills/fmode-vision/SKILL.md

@@ -47,9 +47,10 @@ if (result.provider === 'host') {
 
 ## Token 获取(加载链)
 
-仅 Fmode API 路径需要 token(宿主多模态路径零 token)。按以下优先级查找:
+仅 Fmode API 路径需要 token(宿主多模态路径零 token)。按以下优先级查找(第0级自举 → 回落)
 
-1. **环境变量** `FMODE_API_TOKEN`(最高优先级)
+0. **第0级自举**:`FMODE_SESSION_TOKEN` 或 `~/.fmode/config.json` 的 `sessionToken` → 调 fmode API 动态换取 API token(登录 FMODE Studio 即可,无需手工配置;token 仅内存持有,不落盘不进日志)
+1. **环境变量** `FMODE_API_TOKEN`
    ```bash
    echo $FMODE_API_TOKEN
    ```

+ 61 - 6
skills/fmode-vision/scripts/vision-client.mjs

@@ -19,7 +19,11 @@ import os from 'os';
 // Token 加载链(与 voc / fmode-listen 共享层一致)
 // ============================================================
 //
-// 优先级(任务书规定):
+// 优先级(第0级为 sessionToken 自举,其余为回落链):
+//   0. sessionToken 自举:FMODE_SESSION_TOKEN 环境变量或 ~/.fmode/config.json 的
+//      sessionToken → POST https://server.fmode.cn/api/fmode/voc-skill/install-prompt
+//      → 从返回 prompt 中提取 fmode API token(sk- 开头,排除 sk-ant-)。
+//      ⚠️ token 仅内存持有,不落盘不进日志。
 //   1. 环境变量 FMODE_API_TOKEN
 //   2. ~/.fmode/config.json → fmodeApiToken / newapiToken
 //   3. ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
@@ -35,6 +39,44 @@ import os from 'os';
 // UTF-8 BOM(EF BB BF):用户手工保存的 config.json 可能带 BOM,解析前剥掉。
 const BOM_RE = /^/;
 
+const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
+
+/** 第0级:解析 sessionToken(env FMODE_SESSION_TOKEN → ~/.fmode/config.json)。找不到返回 null。 */
+function resolveSessionToken() {
+  if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
+  const p = path.join(os.homedir(), '.fmode', 'config.json');
+  try {
+    if (!fs.existsSync(p)) return null;
+    const cfg = JSON.parse(fs.readFileSync(p, 'utf-8').replace(BOM_RE, ''));
+    const t = cfg.sessionToken || (cfg.user && cfg.user.sessionToken) || null;
+    return t && String(t).trim() ? String(t).trim() : null;
+  } catch { return null; }
+}
+
+/**
+ * 第0级:sessionToken → fmode API token(自举)。
+ * 服务端唯一以 session 鉴权并返回 token 本体的端点是 voc-skill 安装指令生成器;
+ * token 内嵌在返回 prompt 文本中,这里提取后仅内存持有(不落盘不进日志)。
+ * @returns {Promise<string|null>} 提取失败返回 null(调用方回落下一级)。
+ */
+async function fetchApiTokenFromSession(sessionToken) {
+  if (!sessionToken) return null;
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/fmode/voc-skill/install-prompt`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'x-parse-session-token': sessionToken },
+      body: JSON.stringify({ channel: 'claude-code', scope: 'user', source: 'skill-token-bootstrap' }),
+      signal: AbortSignal.timeout(15000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const prompt = body && body.data && typeof body.data.prompt === 'string' ? body.data.prompt : '';
+    const m = prompt.match(/sk-(?!ant-)[A-Za-z0-9_-]{8,}/);
+    return m ? m[0] : null;
+  } catch { /* 网络失败一律回落,不泄露错误细节 */ }
+  return null;
+}
+
 function readJsonMaybe(filePath) {
   try {
     if (!filePath || !fs.existsSync(filePath)) return {};
@@ -89,16 +131,28 @@ function pickFmodeAnthropicToken(env) {
 }
 
 /**
- * 获取 fmode API token。加载链优先级:
+ * 获取 fmode API token。加载链优先级(第0级自举 → 回落):
+ *   0. sessionToken 自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json)→ fmode API 换取
  *   1. 环境变量 FMODE_API_TOKEN
  *   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
  *   3. ~/.claude/settings.json 等 → env.ANTHROPIC_AUTH_TOKEN(sk- 开头非 sk-ant-)
  *   4. <project>/.fmode/config.json → fmodeApiToken / newapiToken
  *
  * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
- * @returns {{ token: string, source: string }}
+ * @returns {Promise<{ token: string, source: string }>}
  */
-export function resolveApiToken(projectRoot) {
+export async function resolveApiToken(projectRoot) {
+  // 第0级自举:sessionToken → fmode API 换取
+  const sessionToken = resolveSessionToken();
+  if (sessionToken) {
+    const bootstrapped = await fetchApiTokenFromSession(sessionToken);
+    if (bootstrapped) {
+      return { token: bootstrapped, source: 'level0:sessionToken->fmode-api' };
+    }
+    // 自举失败:明确报错指向重新登录,随后回落
+    console.error('sessionToken 存在但换取 fmode API token 失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
+  }
+
   // 1. 环境变量
   if (process.env.FMODE_API_TOKEN) {
     return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
@@ -132,7 +186,8 @@ export function resolveApiToken(projectRoot) {
   }
 
   throw new Error(
-    '未找到 Fmode API token。请通过以下任一方式提供(优先级从高到低):\n' +
+    '未找到 Fmode API token。请通过以下任一方式提供(第0级自举 → 回落):\n' +
+    '  0. 登录 FMODE Studio 后自动自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken)\n' +
     '  1. 环境变量 FMODE_API_TOKEN\n' +
     '  2. ~/.fmode/config.json 中 fmodeApiToken / newapiToken 字段(FmodeStudio 保存配置后写入)\n' +
     '  3. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(Claude Code 的 sk- token,会自动读取)\n' +
@@ -391,7 +446,7 @@ export async function callVisionAPI(opts) {
     model, temperature, maxTokens, apiToken,
   } = opts;
 
-  const token = apiToken || resolveApiToken().token;
+  const token = apiToken || (await resolveApiToken()).token;
   const messages = [{ role: 'system', content: systemPrompt }];
 
   const userContent = [{ type: 'text', text: userPrompt }];