Просмотр исходного кода

security: 凭据链第0级自举——sessionToken→STS 临时凭证直传 OBS,替代预配置 AK/SK

- uploader.mjs: resolveStorageConfig() 新增第0级:FMODE_SESSION_TOKEN 或 ~/.fmode/config.json sessionToken → POST /api/storage/credentials 换 STS
- STS 仅内存持有:一次性临时配置目录(0700/0600)承载 obsutil 调用,命令结束即删;禁落盘禁日志;config 诊断输出不含任何密钥本体
- 原有 4 级回落链保留:自建 OBS/已有 obsutil config 用户不受影响
- 换取失败明确报错指向重新登录 FMODE Studio
- README/SKILL.md 凭据章节更新:画出第0级自举+回落链路图
- version 0.1.0 → 0.2.1

Co-Authored-By: Claude Code <noreply@anthropic.com>
liuyuyang 1 неделя назад
Родитель
Сommit
2a1e1f9e90
4 измененных файлов с 209 добавлено и 36 удалено
  1. 20 8
      README.md
  2. 1 1
      package.json
  3. 26 11
      skills/fmode-storage/SKILL.md
  4. 162 16
      skills/fmode-storage/scripts/uploader.mjs

+ 20 - 8
README.md

@@ -29,17 +29,29 @@ git clone https://git.fmode.cn/fmode/skill-storage.git
 cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storage
 cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storage
 ```
 ```
 
 
-## 凭据(零密钥入库)
+## 凭据(第0级 sessionToken 自举 + 4 级回落,零密钥入库)
 
 
-运行器按优先级自动解析(检测不到就换下一级,绝不空转):
+凭据解析链(命中即用,检测不到就换下一级,绝不空转):
 
 
-1. 环境变量 `FMODE_STORAGE_TOKEN` / `FMODE_OBS_CONF`(OBS util 配置文件路径)
-2. `~/.fmode/config.json` → `obsUtilPath` / `obsBucket` / `obsEndpoint` / `cdnDomain`
-3. 项目 `./.fmode/config.json` → 同上
-4. 华为云 SDK 默认链(若装了 obsutil/huaweicloud-sdk 且已 `obsutil config`)
+```
+第0级(自举): sessionToken(FMODE_SESSION_TOKEN 环境变量
+                              或 ~/.fmode/config.json 的 sessionToken
+                              或 ~/.fmode/config/user.json)
+             → POST https://server.fmode.cn/api/storage/credentials
+             → STS 临时凭证 {AK, SK, SecurityToken}(作用域限定用户 prefix,短时)
+             → 一次性 obsutil 临时配置直传 OBS(STS 不落盘、不进日志,用完即删)
+             ✅ 登录 FMODE Studio 即可上传,无需预配任何 OBS 密钥
+第1级: 环境变量 FMODE_OBS_CONF(OBS util 配置文件路径)
+第2级: ~/.fmode/config.json → obsUtilPath / obsBucket / obsEndpoint / cdnDomain
+第3级: 项目 ./.fmode/config.json → 同上
+第4级: 华为云 SDK 默认链(若装了 obsutil/huaweicloud-sdk 且已 obsutil config)
+```
+
+- **STS 临时凭证只在内存持有**:不写文件(除一次性临时配置目录,命令结束即删)、不打日志、不进错误信息与诊断输出。
+- 第0级换取失败时明确报错:`sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN`,随后自动回落第1-4级——已配置的自建 OBS 用户完全不受影响。
 
 
-> obsutil 配置方法(一次性):`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
-> **不要把 AK/SK 写进本仓库或任何对话。**
+> 自建 OBS 配置方法(一次性,第1-4级回落):`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> **不要把 AK/SK、sessionToken、STS 写进本仓库或任何对话。**
 
 
 ## 用法示例
 ## 用法示例
 
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "fmode-storage",
   "name": "fmode-storage",
-  "version": "0.1.0",
+  "version": "0.2.1",
   "description": "Claude Code skill: 录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。提供 `npx fmode-listen transcribe` 直连命令 + Claude Code 技能安装器。",
   "description": "Claude Code skill: 录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。提供 `npx fmode-listen transcribe` 直连命令 + Claude Code 技能安装器。",
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {

+ 26 - 11
skills/fmode-storage/SKILL.md

@@ -1,33 +1,48 @@
 ---
 ---
 name: fmode-storage
 name: fmode-storage
-description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储(OBS/S3),本地零长期占用,生成公开分享链接。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+ACL 设置 (4) 需要公开 URL 供转发或嵌入。AK/SK 用 obsutil config 配置,凭据不入库。"
-description_en: "Upload binary files (images/audio/video/HTML reports) to object storage (OBS/S3), keep local disk clean, and get public share URLs instantly. Use for publishing reports/courseware, hosting media assets, batch upload with ACL, or any scenario needing public URLs."
+description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储(OBS/S3),本地零长期占用,生成公开分享链接。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+ACL 设置 (4) 需要公开 URL 供转发或嵌入。第0级 sessionToken 自举换 STS 直传,无需预配 OBS 密钥。"
+description_en: "Upload binary files (images/audio/video/HTML reports) to object storage (OBS/S3), keep local disk clean, and get public share URLs instantly. Use for publishing reports/courseware, hosting media assets, batch upload with ACL, or any scenario needing public URLs. Level-0 sessionToken bootstraps STS — no pre-configured OBS keys needed."
 ---
 ---
 
 
 # Fmode Storage — 对象存储与公开分享技能
 # Fmode Storage — 对象存储与公开分享技能
 
 
 ## Overview
 ## Overview
 
 
-本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——AK/SK 只在 obsutil/环境配置里。
+本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——STS 临时凭证仅内存持有,AK/SK 长期密钥只在 obsutil/环境配置里。
 
 
 ## 快速用
 ## 快速用
 
 
 ```bash
 ```bash
 node <skill_dir>/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
 node <skill_dir>/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
-# → { "url": "https://fmode.cn/reports/20260911/report.html", ... }
+# → { "url": "https://fmode.cn/reports/20260911/report.html", "via": "level0:sessionToken->STS", ... }
 
 
 node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
 node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
 ```
 ```
 
 
-## 凭据(4 级自动解析
+## 凭据(第0级自举 + 4 级回落
 
 
-1. 环境变量 `FMODE_OBS_CONF`(JSON 文件路径)
-2. `~/.fmode/config.json` → `obsBucket` / `obsEndpoint` / `cdnDomain` / `obsUtilPath`
-3. 项目 `./.fmode/config.json`
-4. obsutil 默认链(已 `obsutil config` 则自动探测 bucket)
+```
+┌─ 第0级(自举,推荐)──────────────────────────────────────────┐
+│ FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken    │
+│   → POST https://server.fmode.cn/api/storage/credentials      │
+│   → STS 临时凭证 {AK, SK, SecurityToken}(限用户 prefix,短时)│
+│   → 一次性 obsutil 临时配置直传 OBS(命令结束即删,不落盘)    │
+└───────────────────────────────────────────────────────────────┘
+┌─ 回落(自建 OBS / 已有 obsutil config 的用户)────────────────┐
+│ 1. 环境变量 FMODE_OBS_CONF(JSON 配置文件路径)                │
+│ 2. ~/.fmode/config.json → obsBucket/obsEndpoint/cdnDomain     │
+│ 3. 项目 ./.fmode/config.json → 同上                           │
+│ 4. obsutil 默认链(已 obsutil config 则自动探测 bucket)       │
+└───────────────────────────────────────────────────────────────┘
+```
+
+- 第0级命中时**无需任何 OBS 密钥预配置**——登录 FMODE Studio 即可上传。
+- STS 临时凭证:**内存持有,禁落盘、禁日志**;`runWithSts()` 用临时目录(0700)承载配置,命令结束立即删除。
+- 换取失败报错:`sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN`,随后自动回落第1-4级。
+- 自建 OBS 用户保持原链路不变(第1-4级不受影响)。
 
 
-> AK/SK 配置一次:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
-> **禁止把 AK/SK 或任何密钥写进仓库、日志或对话。**
+> 自建 OBS 一次性配置:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> **禁止把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志或对话。**
 
 
 ## 何时用
 ## 何时用
 
 

+ 162 - 16
skills/fmode-storage/scripts/uploader.mjs

@@ -1,11 +1,16 @@
 #!/usr/bin/env node
 #!/usr/bin/env node
 /**
 /**
  * fmode-storage uploader — 对象存储上传/公开链接/ACL
  * fmode-storage uploader — 对象存储上传/公开链接/ACL
- * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
+ * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
+ *
+ * 第0级(自举):sessionToken → POST /api/storage/credentials 换 STS 临时凭证,
+ *   内存持有直传 OBS(不落盘、不进日志)。后续级为回落链(自建 OBS / 已有
+ *   obsutil config 的用户不受影响)。
  *
  *
  * 用法:
  * 用法:
  *   node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
  *   node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
  *   node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
  *   node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
+ *   node uploader.mjs config
  */
  */
 import fs from 'fs';
 import fs from 'fs';
 import os from 'os';
 import os from 'os';
@@ -14,29 +19,155 @@ import { execSync, spawnSync } from 'child_process';
 
 
 const HOME = os.homedir();
 const HOME = os.homedir();
 
 
-/** 4 级凭据/配置解析: env → ~/.fmode/config.json → ./.fmode/config.json → obsutil 默认链 */
-export function resolveStorageConfig() {
-  const cfg = { obsUtilPath: 'obsutil', endpoint: 'obs.cn-south-1.myhuaweicloud.com', bucket: null, cdnDomain: null };
-  // 1) 环境变量
+// fmode 网关基址(签发 STS / 下载 URL)
+const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
+const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
+
+/**
+ * 第0级:解析 sessionToken。
+ * 来源(优先级):FMODE_SESSION_TOKEN 环境变量
+ *   → ~/.fmode/config.json 的 sessionToken / user.sessionToken
+ *   → ~/.fmode/config/user.json 的 sessionToken
+ * 找到即返回字符串,找不到返回 null(不报错——交给上层决定是否回落)。
+ */
+export function resolveSessionToken() {
+  if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
+  const candidates = [
+    path.join(HOME, '.fmode', 'config.json'),
+    path.join(process.cwd(), '.fmode', 'config.json'),
+    path.join(HOME, '.fmode', 'config', 'user.json'),
+  ];
+  for (const p of candidates) {
+    try {
+      if (!fs.existsSync(p)) continue;
+      const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
+      const t = j.sessionToken || (j.user && j.user.sessionToken) || null;
+      if (t && String(t).trim()) return String(t).trim();
+    } catch { /* try next source */ }
+  }
+  return null;
+}
+
+/**
+ * 第0级:sessionToken → STS 临时凭证(AK/SK/SecurityToken,作用域限定用户 prefix)。
+ *
+ * 权威链路(fmode-studio/docs/obs-cdn/02-架构与路径划分.md §5.2):
+ *   sessionToken → POST /api/storage/credentials
+ *                → { accessKeyId, secretAccessKey, sessionToken(即 SecurityToken),
+ *                    endpoint, bucket, prefix, expiration }
+ *                → 客户端直传 OBS
+ *
+ * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
+ *
+ * @returns {Promise<{ ak: string, sk: string, securityToken: string,
+ *           endpoint: string, bucket: string, prefix: string } | null>}
+ *          签发失败返回 null(调用方回落第1-4级)。
+ */
+export async function fetchStsCredentials(sessionToken) {
+  if (!sessionToken) return null;
+  try {
+    const res = await fetch(STORAGE_CREDENTIALS_URL, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${sessionToken}`,
+      },
+      body: JSON.stringify({ op: ['put', 'delete'] }),
+      signal: AbortSignal.timeout(15000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const cred = body && (body.credentials || body.data);
+    if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
+    return {
+      ak: cred.accessKeyId,
+      sk: cred.secretAccessKey,
+      securityToken: cred.sessionToken || cred.securityToken || '',
+      endpoint: cred.endpoint || 'obs.cn-south-1.myhuaweicloud.com',
+      bucket: cred.bucket || null,
+      prefix: cred.prefix || '',
+    };
+  } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
+  return null;
+}
+
+/**
+ * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
+ * 配置只写进临时目录(0700),命令结束立即删除——STS 不落盘留存、不进日志。
+ *
+ * @param {object} sts  fetchStsCredentials() 的返回值
+ * @param {string[]} args obsutil 参数(不含 -e/-i/-k/-t)
+ */
+export function runWithSts(sts, args, timeout = 300000) {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
+  fs.chmodSync(tmpDir, 0o700);
+  const cfgFile = path.join(tmpDir, '.obsutilconfig');
+  // STS 写入临时配置仅供 obsutil 本次调用读取,结束后立刻删除
+  fs.writeFileSync(cfgFile, [
+    `endpoint=${sts.endpoint}`,
+    `ak=${sts.ak}`,
+    `sk=${sts.sk}`,
+    `token=${sts.securityToken}`,
+    '',
+  ].join('\n'), { mode: 0o600 });
+  try {
+    const r = spawnSync('obsutil', args, {
+      encoding: 'utf8',
+      timeout,
+      env: { ...process.env, OBSUTIL_CONFIG_FILE: cfgFile },
+    });
+    const out = (r.stdout || '') + (r.stderr || '');
+    return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
+  } finally {
+    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
+  }
+}
+
+/** 5 级凭据/配置解析:第0级 sessionToken 自举 → env → ~/.fmode → ./.fmode → obsutil 默认链 */
+export async function resolveStorageConfig() {
+  const cfg = {
+    obsUtilPath: 'obsutil',
+    endpoint: 'obs.cn-south-1.myhuaweicloud.com',
+    bucket: null,
+    cdnDomain: null,
+    sts: null,       // 第0级自举得到的 STS(内存持有)
+    via: null,       // 命中的解析级别(诊断用,不含任何密钥)
+  };
+  // ---- 第0级自举:sessionToken → STS ----
+  const sessionToken = resolveSessionToken();
+  if (sessionToken) {
+    const sts = await fetchStsCredentials(sessionToken);
+    if (sts) {
+      cfg.sts = sts;
+      cfg.bucket = sts.bucket || cfg.bucket;
+      cfg.endpoint = sts.endpoint || cfg.endpoint;
+      cfg.via = 'level0:sessionToken->STS';
+      return cfg; // 自举成功,无需回落
+    }
+    // sessionToken 存在但换取失败:给出明确报错指向重新登录
+    console.error('sessionToken 存在但 STS 换取失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
+    console.error('(将继续尝试第1-4级回落配置)');
+  }
+  // ---- 第1级:环境变量 ----
   if (process.env.FMODE_OBS_CONF) {
   if (process.env.FMODE_OBS_CONF) {
-    try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); } catch {}
+    try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); cfg.via = 'level1:FMODE_OBS_CONF'; } catch {}
   }
   }
-  // 2) ~/.fmode/config.json
+  // ---- 第2/3级:~/.fmode/config.json → ./.fmode/config.json ----
   for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
   for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
     try {
     try {
       const j = JSON.parse(fs.readFileSync(p, 'utf8'));
       const j = JSON.parse(fs.readFileSync(p, 'utf8'));
       if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
       if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
-      if (j.obsBucket) cfg.bucket = j.obsBucket;
+      if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = cfg.via || `level:${p}`; }
       if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
       if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
       if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
       if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
     } catch {}
     } catch {}
   }
   }
+  // ---- 第4级:obsutil 默认链(已配置则探测 bucket)----
   if (!cfg.bucket) {
   if (!cfg.bucket) {
-    // 3) obsutil 默认链: 若 obsutil 已配置, 从其 log/配置探测 bucket
     try {
     try {
       const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
       const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
       const m = out.match(/obs:\/\/([^/]+)/);
       const m = out.match(/obs:\/\/([^/]+)/);
-      if (m) cfg.bucket = m[1];
+      if (m) { cfg.bucket = m[1]; cfg.via = 'level4:obsutil-default'; }
     } catch {}
     } catch {}
   }
   }
   return cfg;
   return cfg;
@@ -49,17 +180,20 @@ export function publicUrl(cfg, key) {
 }
 }
 
 
 function obs(args, cfg, timeout = 120000) {
 function obs(args, cfg, timeout = 120000) {
+  // 第0级命中 → STS 一次性临时配置执行(不落盘留存)
+  if (cfg.sts) return runWithSts(cfg.sts, args, timeout);
+  // 回落:用户自己的 obsutil 配置
   const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
   const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
   const out = (r.stdout || '') + (r.stderr || '');
   const out = (r.stdout || '') + (r.stderr || '');
   return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
   return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
 }
 }
 
 
-function main() {
+async function main() {
   const [cmd, ...rest] = process.argv.slice(2);
   const [cmd, ...rest] = process.argv.slice(2);
-  const cfg = resolveStorageConfig();
+  const cfg = await resolveStorageConfig();
   if (!cfg.bucket && cmd !== 'help') {
   if (!cfg.bucket && cmd !== 'help') {
-    console.error('未解析到 bucket:请配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
-    console.error('(技能绝不内置密钥;AK/SK 用 obsutil config 自行配置)');
+    console.error('未解析到 bucket:请登录 FMODE Studio(sessionToken 自举)或配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
+    console.error('(技能绝不内置密钥;自建 OBS 用户用 obsutil config 自行配置)');
     process.exit(2);
     process.exit(2);
   }
   }
   const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
   const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
@@ -72,7 +206,7 @@ function main() {
     const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
     const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
     const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
     const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
     if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
     if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
-    console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket }, null, 2));
+    console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
     return;
     return;
   }
   }
   if (cmd === 'setacl') {
   if (cmd === 'setacl') {
@@ -82,7 +216,19 @@ function main() {
     console.log(JSON.stringify({ ok: r.ok }, null, 2));
     console.log(JSON.stringify({ ok: r.ok }, null, 2));
     return;
     return;
   }
   }
-  if (cmd === 'config') { console.log(JSON.stringify({ ...cfg }, null, 2)); return; }
+  if (cmd === 'config') {
+    // 诊断输出:绝不包含 AK/SK/SecurityToken/sessionToken 任何密钥本体
+    console.log(JSON.stringify({
+      via: cfg.via,
+      bucket: cfg.bucket,
+      endpoint: cfg.endpoint,
+      cdnDomain: cfg.cdnDomain,
+      stsBootstrapped: Boolean(cfg.sts),
+      stsPrefix: cfg.sts ? cfg.sts.prefix : null,
+      stsExpiresInMemoryOnly: Boolean(cfg.sts),
+    }, null, 2));
+    return;
+  }
   console.log('用法: put | setacl | config');
   console.log('用法: put | setacl | config');
 }
 }