Procházet zdrojové kódy

v0.3.0 凭据链诚实化: 端点404真因修复+4级链重写+init向导+test自检

真因(实证):
- POST /api/storage/credentials 从未上线(探测404, 04-API设计.md status:规划中), 旧第0级sessionToken自举是伪自举
- 真实身份字段是 user.json 的 fmodeApiToken(sk-开头), 不是 sessionToken
- 部分环境'能跑'只是 obsutil 遗产手工配置存在(回落级生效), 其他机器全链死

修复:
- 凭据链重写诚实4级: env(OBS_AK/SK) → obsutil config(OBSUTIL_CONFIG/~/.obsutilconfig/getpwuid变体, bucket自动ls探测) → fmodeApiToken→平台签发(HEAD探测+.sts-probe.json缓存1h, 404跳过并提示) → 项目.fmode/config.json
- 全失败: 打印初始化向导+exit 2, 不再伪装成功
- 旧自举保留为 --experimental-sts(端点探测200才启用, 默认关闭)
- init 向导: --ak/--sk/--endpoint/--bucket或交互 → 写obsutil config(600)+技能config段 → 自动test(1KB探针上传→删除)
- test 自检命令
- 顺修: chattri失败文案'Set the acl'被误判成功; OBSUTIL_CONFIG_FILE env不被obsutil识别改用-config=; obsutil不在PATH时候选路径定位
- README三分钟初始化(含申请邮件模板); SKILL.md凭据章节重写; .sts-probe.json gitignore

回归3场景: ①本容器真文件上传+setacl+公开URL HTTP200 ②空HOME新用户→向导+exit2非假成功(fmodeApiToken用户诚实提示端点404) ③隔离HOME init真实AK走通(600权限+后续put成功)

Co-Authored-By: Claude Code <noreply@anthropic.com>
liuyuyang před 1 týdnem
rodič
revize
773dac5d2d
5 změnil soubory, kde provedl 451 přidání a 141 odebrání
  1. 1 0
      .gitignore
  2. 78 18
      README.md
  3. 3 3
      package.json
  4. 33 24
      skills/fmode-storage/SKILL.md
  5. 336 96
      skills/fmode-storage/scripts/uploader.mjs

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ node_modules/
 *.tgz
 .env
 output/
+.sts-probe.json

+ 78 - 18
README.md

@@ -4,11 +4,46 @@
 
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
 
+## 三分钟初始化(新用户必读)
+
+**当前版本(0.3.0)需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台端点上线**(设计文档 `fmode-studio/docs/obs-cdn/04-API设计.md` 中 `/api/storage/credentials` 状态为"规划中",本技能启动时会自动探测:404=未上线则跳过该级并提示,端点上线后 `--experimental-sts` 一行启用)。
+
+### 步骤 1:向平台/管理员申请 OBS 子账号(约 1 分钟)
+
+给管理员发一封邮件/消息(模板直接用):
+
+> 主题:申请 OBS 子账号(skill-storage 上传用)
+>
+> 你好,我需要用 skill-storage 技能把报告/素材上传到对象存储并生成公开链接。请按最小权限为我开通一个 OBS 子账号:
+> 1. AK/SK(仅授予桶 `<bucket>` 的 PutObject / DeleteObject / PutObjectAcl 权限,建议限定前缀 `skill-storage/`)
+> 2. 桶名与终端节点(endpoint,如 obs.cn-north-4.myhuaweicloud.com)
+> 3. 如有 CDN 域名请一并提供
+
+### 步骤 2:执行 init 向导(约 1 分钟)
+
+```bash
+node skills/fmode-storage/scripts/uploader.mjs init \
+  --ak <你的AK> --sk <你的SK> \
+  --endpoint obs.cn-north-4.myhuaweicloud.com \
+  --bucket <bucket>
+# 省略参数则进入交互模式逐项询问
+```
+
+写入 `~/.obsutilconfig`(600 权限)+ `~/.fmode/config.json` 技能 config 段(不含密钥本体),随后自动执行 test。
+
+### 步骤 3:验证(约 30 秒)
+
+```bash
+node skills/fmode-storage/scripts/uploader.mjs test
+# → 上传 1KB 探针文件 → 删除 → { "ok": true, ... }
+```
+
 ## 能力
 
 - 📤 大文件上传对象存储(华为云 OBS / S3 协议),本地磁盘零长期占用
 - 🔗 生成公开分享链接(`https://<域名>/<key>` 形态,可配 CDN)
 - 📄 报告/课件 HTML 发布即分享(传完即得可转发 URL)
+- 🧪 自检:`test` 命令上传→删除探针文件全链验证
 - 🧹 生命周期管理(前缀用量统计/批量清理/ACL 设置)
 
 ## 各工具安装
@@ -29,41 +64,66 @@ git clone https://git.fmode.cn/fmode/skill-storage.git
 cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storage
 ```
 
-## 凭据(第0级 sessionToken 自举 + 4 级回落,零密钥入库)
+## 凭据(诚实 4 级,零密钥入库)
 
-凭据解析链(命中即用,检测不到就换下一级,绝不空转):
+凭据解析链(命中即用,检测不到就换下一级;**全失败打印初始化向导并退出码 2,绝不伪装成功**):
 
 ```
-第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)
+第1级: 环境变量 OBS_AK/OBS_SK(可选 OBS_ENDPOINT/OBS_BUCKET)
+第2级: obsutil config 文件(OBSUTIL_CONFIG 环境变量 或 ~/.obsutilconfig,
+       含 getpwuid home 变体)——解析出 AK/SK/endpoint,bucket 缺失时用
+       `obsutil ls` 自动探测
+第3级: ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
+       (启动时 HEAD 探测 ${FMODE_API_BASE}/api/storage/credentials 并缓存
+       .sts-probe.json 1 小时:404=未上线 → 打印"平台 STS 签发端点未上线
+       (设计文档 04-API设计.md), 暂用 obsutil 配置模式"并跳过,不空转)
+第4级: 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain)
 ```
 
-- **STS 临时凭证只在内存持有**:不写文件(除一次性临时配置目录,命令结束即删)、不打日志、不进错误信息与诊断输出。
-- 第0级换取失败时明确报错:`sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN`,随后自动回落第1-4级——已配置的自建 OBS 用户完全不受影响。
+- **平台 STS 签发端点目前未上线(探测 404)**。旧版"sessionToken→STS 自举"声称"登录即可上传"属伪自举,0.3.0 已诚实化:代码保留,仅在显式传 `--experimental-sts` 且端点探测 200 时启用(面向未来上线)。B 节接口上线后一行配置恢复。
+- **AK/SK 长期密钥只存在于 obsutil config / 环境变量**(用户自己配的);`config` 命令诊断输出绝不含任何密钥本体。
+- **当前版本需一次性配置 AK/SK**(见顶部"三分钟初始化");sessionToken 免配置自举待平台端点上线。
 
-> 自建 OBS 配置方法(一次性,第1-4级回落):`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> 自建 OBS 配置方法(一次性,第2级):`obsutil config -i=<AK> -k=<SK> -e=obs.cn-north-4.myhuaweicloud.com`
 > **不要把 AK/SK、sessionToken、STS 写进本仓库或任何对话。**
 
 ## 用法示例
 
 ```bash
+# 初始化(一次性)
+node skills/fmode-storage/scripts/uploader.mjs init --ak <AK> --sk <SK> --endpoint ... --bucket ...
+
+# 自检
+node skills/fmode-storage/scripts/uploader.mjs test
+
 # 上传并输出公开链接
-node skills/fmode-storage/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
-# → https://fmode.cn/reports/20260911/report.html
+node skills/fmode-storage/scripts/uploader.mjs put ./report.html --key reports/20260912/report.html
+# → https://<bucket>.<endpoint>/reports/20260912/report.html
 
 # 设置公开读
 node skills/fmode-storage/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
+
+# 诊断(不含任何密钥本体)
+node skills/fmode-storage/scripts/uploader.mjs config
 ```
 
+## Changelog
+
+### 0.3.0(凭据链语义变更)
+- **真因修复**:旧版第0级调用 `POST /api/storage/credentials` 换 STS —— 该端点**从未上线(404)**(设计文档 `fmode-studio/docs/obs-cdn/04-API设计.md`,status:规划中),"登录即可上传"是伪自举
+- 字段名纠错:真实身份字段是 `~/.fmode/config/user.json` 的 **fmodeApiToken**(sk- 开头),不是 sessionToken
+- "能跑通"假象纠偏:部分环境"能跑"只是因为历史遗留的手工 obsutilconfig 存在(第1/2级回落生效),其他机器无此文件即全链死——现在全链失败时明确打印初始化向导并退出码 2,不再伪装成功
+- 凭据链重写为诚实 4 级(env → obsutil config → 平台签发(端点探测) → 项目 config),旧自举降级为 `--experimental-sts`(端点 200 才启用)
+- 新增 `init` 向导(写 obsutil config 600 权限 + 技能 config 段 + 自动 test)与 `test` 自检命令
+- 端点探测结果缓存 `.sts-probe.json`(1 小时有效,已 gitignore)
+- 修复:失败判定误报(旧版 `chattri` 失败文案含 "Set the acl" 被误判成功;`OBSUTIL_CONFIG_FILE` 环境变量 obsutil 并不识别,改用 `-config=` 显式传递);obsutil 不在 PATH 时自动按 `~/.local/bin`、`~/bin`、getpwuid home 等候选定位
+
+### 0.2.1
+- 修复 mjs 双 shebang 语法错误
+
+### 0.1.0
+- 首版:对象存储上传/公开链接/ACL,4 级凭据解析,多工具指南
+
 ## License
 
 MIT

+ 3 - 3
package.json

@@ -1,10 +1,10 @@
 {
   "name": "fmode-storage",
-  "version": "0.2.1",
-  "description": "Claude Code skill: 录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。提供 `npx fmode-listen transcribe` 直连命令 + Claude Code 技能安装器。",
+  "version": "0.3.0",
+  "description": "Claude Code skill: 对象存储上传/公开链接/ACL(华为云 OBS / S3 兼容)。诚实 4 级凭据链(env → obsutil config → 平台签发(端点上线后) → 项目 config)+ init 初始化向导 + test 自检。零依赖 Node ≥18。",
   "license": "MIT",
   "repository": {
     "type": "git",
     "url": "git+ssh://git@github.com/fmodecn/skill-storage.git"
   }
-}
+}

+ 33 - 24
skills/fmode-storage/SKILL.md

@@ -1,47 +1,56 @@
 ---
 name: fmode-storage
-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."
+description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储(OBS/S3),本地零长期占用,生成公开分享链接。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+ACL 设置 (4) 需要公开 URL 供转发或嵌入。首次使用先跑 init 向导一次性配置 OBS 子账号 AK/SK。"
+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. Run the init wizard once to configure an OBS sub-account AK/SK."
 ---
 
 # Fmode Storage — 对象存储与公开分享技能
 
 ## Overview
 
-本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——STS 临时凭证仅内存持有,AK/SK 长期密钥只在 obsutil/环境配置里。
+本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——AK/SK 长期密钥只在 obsutil/环境配置里,诊断输出绝不含密钥本体;**诚实失败**——凭据链全失败时打印初始化向导并退出码 2,绝不伪装成功。
+
+## 三分钟初始化(新用户)
+
+```bash
+# 1. 向 Fmode 平台/管理员申请 OBS 子账号 AK/SK(邮件模板见 README.md)
+node <skill_dir>/scripts/uploader.mjs init --ak <AK> --sk <SK> \
+  --endpoint obs.cn-north-4.myhuaweicloud.com --bucket <bucket>
+#    (省略参数则交互询问)写 obsutil config(600)+技能 config 段,自动 test
+
+# 2. 验证
+node <skill_dir>/scripts/uploader.mjs test
+# → 上传 1KB 探针文件→删除→{ "ok": true, ... }
+```
 
 ## 快速用
 
 ```bash
-node <skill_dir>/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
-# → { "url": "https://fmode.cn/reports/20260911/report.html", "via": "level0:sessionToken->STS", ... }
+node <skill_dir>/scripts/uploader.mjs put ./report.html --key reports/20260912/report.html
+# → { "url": "https://<bucket>.<endpoint>/reports/20260912/report.html", "via": "level2:obsutilconfig(...)", ... }
 
 node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
 ```
 
-## 凭据(第0级自举 + 4 级回落)
+## 凭据(诚实 4 级 + 端点探测
 
 ```
-┌─ 第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)       │
-└───────────────────────────────────────────────────────────────┘
+┌─ 诚实 4 级(命中即用,全失败→打印初始化向导并 exit 2)────────────────────┐
+│ 1. 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)                       │
+│ 2. obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig,含 getpwuid   │
+│    home 变体)→ AK/SK/endpoint;bucket 缺失时 `obsutil ls` 自动探测       │
+│ 3. ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口              │
+│    (HEAD 探测 /api/storage/credentials,缓存 .sts-probe.json 1 小时:    │
+│     404=未上线→打印提示并跳过,不空转)                                    │
+│ 4. 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain)         │
+└───────────────────────────────────────────────────────────────────────────┘
 ```
 
-- 第0级命中时**无需任何 OBS 密钥预配置**——登录 FMODE Studio 即可上传。
-- STS 临时凭证:**内存持有,禁落盘、禁日志**;`runWithSts()` 用临时目录(0700)承载配置,命令结束立即删除。
-- 换取失败报错:`sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN`,随后自动回落第1-4级。
-- 自建 OBS 用户保持原链路不变(第1-4级不受影响)。
+- **平台 STS 签发端点目前未上线(探测 404,设计文档 04-API设计.md 状态"规划中")**。旧版"sessionToken→STS 免配置自举"是伪自举(端点 404 从未通过),0.3.0 已诚实化:代码保留为 `--experimental-sts`,仅端点探测 200 时启用,默认关闭。端点上线后一行配置恢复。
+- **当前版本需一次性配置 AK/SK**(init 向导);sessionToken 免配置自举待平台端点上线。
+- AK/SK 只存在于 obsutil config / 环境变量(用户自己配的);`config` 命令输出不含任何密钥本体。
 
-> 自建 OBS 一次性配置:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> 自建 OBS 一次性配置:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-north-4.myhuaweicloud.com`
 > **禁止把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志或对话。**
 
 ## 何时用
@@ -52,4 +61,4 @@ node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
 
 ## 详细文档
 
-见仓库根 `README.md`(多工具安装:Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes)。
+见仓库根 `README.md`(三分钟初始化 + 多工具安装:Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes + 完整 changelog)。

+ 336 - 96
skills/fmode-storage/scripts/uploader.mjs

@@ -1,34 +1,102 @@
 #!/usr/bin/env node
 /**
  * fmode-storage uploader — 对象存储上传/公开链接/ACL
- * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
+ * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
  *
- * 第0级(自举):sessionToken → POST /api/storage/credentials 换 STS 临时凭证,
- *   内存持有直传 OBS(不落盘、不进日志)。后续级为回落链(自建 OBS / 已有
- *   obsutil config 的用户不受影响)。
+ * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
+ *   旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
+ *   设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态为"规划中"),
+ *   "登录即可上传"是伪自举。本版改为:
+ *
+ *   第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+ *   第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
+ *   第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
+ *         (启动时 HEAD 探测该端点:404=未上线→跳过并提示;200=启用自举)
+ *   第4级 项目级 ./.fmode/config.json
+ *   全失败:打印初始化向导指引并退出码 2,绝不伪装成功。
+ *
+ *   旧 sessionToken 自举保留为 --experimental-sts:仅当显式传入该参数
+ *   且端点探测返回 200 时启用(面向未来端点上线,默认关闭)。
  *
  * 用法:
+ *   node uploader.mjs init [--ak ... --sk ... --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 test
  *   node uploader.mjs config
  */
 import fs from 'fs';
 import os from 'os';
 import path from 'path';
 import { execSync, spawnSync } from 'child_process';
+import { fileURLToPath } from 'url';
 
 const HOME = os.homedir();
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-// fmode 网关基址(签发 STS / 下载 URL
+// fmode 网关基址(平台 STS 签发端点所在
 const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
 const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
+const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
+const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
+const PROBE_TTL_MS = 60 * 60 * 1000;
+
+/** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
+function printWizard() {
+  console.error(`
+┌─ fmode-storage 初始化向导 ─────────────────────────────────────────────┐
+│ 当前版本需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台    │
+│ 端点上线(设计文档 fmode-studio/docs/obs-cdn/04-API设计.md,规划中)。  │
+├────────────────────────────────────────────────────────────────────────┤
+│ 步骤 1  向 Fmode 平台/管理员申请 OBS 子账号(邮件模板见 README.md       │
+│         "三分钟初始化"章节),拿到 AK/SK 及所属桶/终端节点。            │
+│ 步骤 2  执行初始化(任选其一):                                        │
+│           node uploader.mjs init --ak <AK> --sk <SK> \\                 │
+│               --endpoint obs.cn-north-4.myhuaweicloud.com \\           │
+│               --bucket <bucket>                                        │
+│         (省略参数则进入交互模式逐项询问)                              │
+│ 步骤 3  验证:node uploader.mjs test                                    │
+│         (上传 1KB 探针文件→删除→报告成功)                             │
+└────────────────────────────────────────────────────────────────────────┘`);
+}
+
+// ============================================================================
+// 平台签发端点探测(B 节:诚实探测 + 1 小时缓存)
+// ============================================================================
 
 /**
- * 第0级:解析 sessionToken。
- * 来源(优先级):FMODE_SESSION_TOKEN 环境变量
- *   → ~/.fmode/config.json 的 sessionToken / user.sessionToken
- *   → ~/.fmode/config/user.json 的 sessionToken
- * 找到即返回字符串,找不到返回 null(不报错——交给上层决定是否回落)。
+ * HEAD 探测平台 STS 签发端点是否上线。结果缓存本文件 .sts-probe.json(1 小时)。
+ * @returns {Promise<{online: boolean, checkedAt: number, status: number|null}>}
+ */
+export async function probeStsEndpoint() {
+  // 读缓存
+  try {
+    const j = JSON.parse(fs.readFileSync(PROBE_CACHE_FILE, 'utf8'));
+    if (j.checkedAt && Date.now() - j.checkedAt < PROBE_TTL_MS) return j;
+  } catch { /* 无缓存或损坏 → 重新探测 */ }
+  let status = null;
+  try {
+    const res = await fetch(STORAGE_CREDENTIALS_URL, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
+    status = res.status;
+  } catch { /* 网络失败视为未上线,不给假希望 */ }
+  const result = { online: status === 200, checkedAt: Date.now(), status };
+  try { fs.writeFileSync(PROBE_CACHE_FILE, JSON.stringify(result)); } catch { /* 只读环境忽略 */ }
+  return result;
+}
+
+/** 探测缓存是否可以强制刷新(--refresh-probe / init / test 命令场景) */
+function invalidateProbeCache() {
+  try { fs.rmSync(PROBE_CACHE_FILE, { force: true }); } catch { /* ignore */ }
+}
+
+// ============================================================================
+// 旧第0级自举(--experimental-sts 专用,端点上线前是死代码路径)
+// ============================================================================
+
+/**
+ * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → ~/.fmode config 文件)。
+ * 注意:真实身份字段是 fmodeApiToken(sk- 开头),本函数仅在
+ * --experimental-sts 且端点探测 200 时被调用,面向未来端点上线。
  */
 export function resolveSessionToken() {
   if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
@@ -49,29 +117,15 @@ export function resolveSessionToken() {
 }
 
 /**
- * 第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级)。
+ * sessionToken → STS 临时凭证。⚠️ 端点未上线(404)前必然返回 null。
+ * 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  */
 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}`,
-      },
+      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}` },
       body: JSON.stringify({ op: ['put', 'delete'] }),
       signal: AbortSignal.timeout(15000),
     });
@@ -83,26 +137,22 @@ export async function fetchStsCredentials(sessionToken) {
       ak: cred.accessKeyId,
       sk: cred.secretAccessKey,
       securityToken: cred.sessionToken || cred.securityToken || '',
-      endpoint: cred.endpoint || 'obs.cn-south-1.myhuaweicloud.com',
+      endpoint: cred.endpoint || DEFAULT_ENDPOINT,
       bucket: cred.bucket || null,
       prefix: cred.prefix || '',
     };
-  } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
-  return null;
+  } catch { return null; }
 }
 
 /**
  * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
- * 配置只写进临时目录(0700),命令结束立即删除——STS 不落盘留存、不进日志。
- *
- * @param {object} sts  fetchStsCredentials() 的返回值
- * @param {string[]} args obsutil 参数(不含 -e/-i/-k/-t)
+ * (配置经 -config= 显式传给 obsutil——旧版 OBSUTIL_CONFIG_FILE 环境变量
+ *  obsutil 并不识别,属无效传递,本版修正。)
  */
 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}`,
@@ -111,92 +161,281 @@ export function runWithSts(sts, args, timeout = 300000) {
     '',
   ].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 };
+    return runObsutil(cfg.obsUtilPath, args, { cfgFile, timeout });
   } finally {
     try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
   }
 }
 
-/** 5 级凭据/配置解析:第0级 sessionToken 自举 → env → ~/.fmode → ./.fmode → obsutil 默认链 */
-export async function resolveStorageConfig() {
+// ============================================================================
+// obsutil 执行层
+// ============================================================================
+
+/** 统一 obsutil 调用。成功判定用退出码 + 末行无 "failed",杜绝"failed"文案误判为成功。 */
+function runObsutil(obsUtilPath, args, { cfgFile = null, timeout = 120000 } = {}) {
+  const r = spawnSync(obsUtilPath, [...args, ...(cfgFile ? [`-config=${cfgFile}`] : [])], {
+    encoding: 'utf8',
+    timeout,
+  });
+  const out = (r.stdout || '') + (r.stderr || '');
+  const ok = r.status === 0 && !/failed/i.test(out.split('\n').filter(Boolean).pop() || '');
+  return { ok, out, status: r.status };
+}
+
+/** 公开 URL: cdnDomain 优先, 否则桶域名 */
+export function publicUrl(cfg, key) {
+  if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
+  return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
+}
+
+// ============================================================================
+// 凭据链:诚实 4 级
+// ============================================================================
+
+/** 解析 obsutil config 文件内容 → {endpoint, ak, sk, token, bucket}。密钥不外泄,仅内部使用。 */
+function parseObsutilConfig(text) {
+  const c = {};
+  for (const line of String(text).split('\n')) {
+    const m = line.match(/^(endpoint|ak|sk|token)=(.*)$/);
+    if (m) c[m[1]] = m[2].trim();
+  }
+  return (c.ak && c.sk) ? c : null;
+}
+
+/** obsutil config 文件候选路径(OBSUTIL_CONFIG 环境变量优先,其次 ~/.obsutilconfig) */
+function obsutilConfigPaths() {
+  const paths = [];
+  if (process.env.OBSUTIL_CONFIG) paths.push(process.env.OBSUTIL_CONFIG);
+  paths.push(path.join(HOME, '.obsutilconfig'));
+  // getpwuid 的 home 可能与 $HOME 不同(容器常见):obsutil 实际按它解析默认配置
+  try {
+    const pwHome = os.userInfo().homedir;
+    if (pwHome && pwHome !== HOME) paths.push(path.join(pwHome, '.obsutilconfig'));
+  } catch { /* ignore */ }
+  return paths;
+}
+
+/** 定位 obsutil 可执行文件:PATH → ~/.local/bin → ~/bin → getpwuid home/bin → /usr/local/bin */
+function findObsutil() {
+  const candidates = [];
+  for (const dir of (process.env.PATH || '').split(':')) {
+    if (dir) candidates.push(path.join(dir, 'obsutil'));
+  }
+  for (const base of [path.join(HOME, '.local', 'bin'), path.join(HOME, 'bin')]) candidates.push(path.join(base, 'obsutil'));
+  try {
+    const pwHome = os.userInfo().homedir;
+    if (pwHome && pwHome !== HOME) candidates.push(path.join(pwHome, 'bin', 'obsutil'));
+  } catch { /* ignore */ }
+  candidates.push('/usr/local/bin/obsutil', '/usr/bin/obsutil');
+  for (const p of candidates) {
+    try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
+  }
+  return 'obsutil'; // 交给 spawnSync 报"not found"
+}
+
+/**
+ * 诚实 4 级凭据解析(命中即用,检测不到就换下一级):
+ *   第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+ *   第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
+ *   第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
+ *         (先 HEAD 探测端点:404=未上线→跳过并提示,不空转)
+ *   第4级 项目级 ./.fmode/config.json
+ */
+export async function resolveStorageConfig({ experimentalSts = false } = {}) {
   const cfg = {
     obsUtilPath: 'obsutil',
-    endpoint: 'obs.cn-south-1.myhuaweicloud.com',
+    endpoint: DEFAULT_ENDPOINT,
     bucket: null,
     cdnDomain: null,
-    sts: null,       // 第0级自举得到的 STS(内存持有)
+    sts: null,
     via: null,       // 命中的解析级别(诊断用,不含任何密钥)
+    ak: null,        // 内部传递用,config 命令绝不输出
+    sk: null,
+    token: '',
   };
-  // ---- 第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; // 自举成功,无需回落
+  cfg.obsUtilPath = findObsutil();
+
+  // ---- 第1级:环境变量 ----
+  if (process.env.OBS_AK && process.env.OBS_SK) {
+    cfg.ak = process.env.OBS_AK.trim();
+    cfg.sk = process.env.OBS_SK.trim();
+    if (process.env.OBS_ENDPOINT) cfg.endpoint = process.env.OBS_ENDPOINT.trim();
+    if (process.env.OBS_BUCKET) cfg.bucket = process.env.OBS_BUCKET.trim();
+    cfg.via = 'level1:env(OBS_AK/OBS_SK)';
+  }
+
+  // ---- 第2级:obsutil config 文件 ----
+  if (!cfg.ak) {
+    for (const p of obsutilConfigPaths()) {
+      try {
+        if (!fs.existsSync(p)) continue;
+        const c = parseObsutilConfig(fs.readFileSync(p, 'utf8'));
+        if (c) {
+          cfg.ak = c.ak; cfg.sk = c.sk; cfg.token = c.token || '';
+          if (c.endpoint) cfg.endpoint = c.endpoint;
+          cfg.via = `level2:obsutilconfig(${p})`;
+          break;
+        }
+      } catch { /* try next path */ }
     }
-    // sessionToken 存在但换取失败:给出明确报错指向重新登录
-    console.error('sessionToken 存在但 STS 换取失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
-    console.error('(将继续尝试第1-4级回落配置)');
   }
-  // ---- 第1级:环境变量 ----
-  if (process.env.FMODE_OBS_CONF) {
-    try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); cfg.via = 'level1:FMODE_OBS_CONF'; } catch {}
+
+  // ---- 第3级:fmodeApiToken → 平台签发接口(端点未上线则跳过并提示)----
+  if (!cfg.ak) {
+    const userCfg = path.join(HOME, '.fmode', 'config', 'user.json');
+    const token = (() => {
+      try {
+        const j = JSON.parse(fs.readFileSync(userCfg, 'utf8').replace(/^/, ''));
+        return (j.fmodeApiToken || '').trim() || null;
+      } catch { return null; }
+    })();
+    if (token) {
+      const probe = await probeStsEndpoint();
+      if (!probe.online) {
+        console.error(`平台 STS 签发端点未上线(探测 ${STORAGE_CREDENTIALS_URL} → ${probe.status ?? '网络失败'};设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态"规划中"),暂用 obsutil 配置模式`);
+      } else if (experimentalSts) {
+        const sts = await fetchStsCredentials(token);
+        if (sts) {
+          cfg.sts = sts;
+          cfg.endpoint = sts.endpoint || cfg.endpoint;
+          cfg.bucket = sts.bucket || cfg.bucket;
+          cfg.via = 'level3:experimental-sts';
+          return cfg;
+        }
+        console.error('--experimental-sts 已启用且端点在线,但换取失败:token 缺失或失效');
+      } else {
+        console.error('平台 STS 端点已上线。加 --experimental-sts 启用 sessionToken 自举(或等待默认开启)。');
+      }
+    }
   }
-  // ---- 第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')]) {
+
+  // ---- 第4级:项目级 ./.fmode/config.json ----
+  if (!cfg.ak && !cfg.sts) {
     try {
+      const p = path.join(process.cwd(), '.fmode', 'config.json');
       const j = JSON.parse(fs.readFileSync(p, 'utf8'));
       if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
-      if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = cfg.via || `level:${p}`; }
+      if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = `level4:${p}`; }
       if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
       if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
-    } catch {}
+    } catch { /* ignore */ }
   }
-  // ---- 第4级:obsutil 默认链(已配置则探测 bucket)----
-  if (!cfg.bucket) {
+
+  // ---- bucket 补全:第1/2级命中但缺 bucket → obsutil ls 探测 ----
+  if (cfg.ak && !cfg.bucket) {
     try {
-      const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
-      const m = out.match(/obs:\/\/([^/]+)/);
-      if (m) { cfg.bucket = m[1]; cfg.via = 'level4:obsutil-default'; }
-    } catch {}
+      const out = execSync(`${JSON.stringify(cfg.obsUtilPath)} ls -limit=1`, { encoding: 'utf8', timeout: 15000 });
+      const m = out.match(/obs:\/\/([^/\s]+)/);
+      if (m) { cfg.bucket = m[1]; cfg.via += '+bucket-probe'; }
+    } catch { /* 探测失败交给上层报错 */ }
   }
+
   return cfg;
 }
 
-/** 公开 URL: cdnDomain 优先, 否则桶域名 */
-export function publicUrl(cfg, key) {
-  if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
-  return `https://${cfg.bucket}.${cfg.endpoint}/${key}`.replace('.myhuaweicloud.com', '.myhuaweicloud.com');
+// ============================================================================
+// 初始化向导(C 节:新用户路径)
+// ============================================================================
+
+/**
+ * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
+ * 写入 obsutil config(600 权限)+ 本技能 config 段,然后立即 test。
+ */
+async function cmdInit(rest) {
+  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+  const ask = async (q) => {
+    process.stdout.write(q);
+    let buf = '';
+    for await (const chunk of process.stdin) { buf += chunk; break; }
+    return buf.trim();
+  };
+  const ak = arg('--ak') || await ask('AK: ');
+  const sk = arg('--sk') || await ask('SK: ');
+  const endpoint = arg('--endpoint') || await ask(`endpoint [${DEFAULT_ENDPOINT}]: `) || DEFAULT_ENDPOINT;
+  const bucket = arg('--bucket') || await ask('bucket: ');
+  if (!ak || !sk || !bucket) {
+    console.error('init 失败:AK/SK/bucket 均必填(--ak --sk --endpoint --bucket 或交互输入)');
+    process.exit(2);
+  }
+
+  // 1) 写 obsutil config(600)
+  const cfgPath = path.join(HOME, '.obsutilconfig');
+  fs.writeFileSync(cfgPath, [
+    `endpoint=${endpoint}`,
+    `ak=${ak}`,
+    `sk=${sk}`,
+    'token=',
+    'cname=false',
+    `endpointCrr=http://your-endpoint`,
+    `akCrr=*** Provide your Access Key ***`,
+    `skCrr=*** Provide your Secret Key ***`,
+    `tokenCrr=`,
+    '',
+  ].join('\n'), { mode: 0o600 });
+
+  // 2) 写本技能 config 段(~/.fmode/config.json,不含密钥本体)
+  const fmodeDir = path.join(HOME, '.fmode');
+  const fmodeCfg = path.join(fmodeDir, 'config.json');
+  fs.mkdirSync(fmodeDir, { recursive: true });
+  let j = {};
+  try { j = JSON.parse(fs.readFileSync(fmodeCfg, 'utf8')); } catch { /* 新文件 */ }
+  j.obsBucket = bucket;
+  j.obsEndpoint = endpoint;
+  fs.writeFileSync(fmodeCfg, JSON.stringify(j, null, 2) + '\n', { mode: 0o600 });
+  console.error(`已写入 ${cfgPath}(600)与 ${fmodeCfg}(技能 config 段,不含密钥本体)`);
+
+  // 3) 立即 test:上传 1KB 探针 → 删除 → 报告
+  return cmdTest();
 }
 
-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 out = (r.stdout || '') + (r.stderr || '');
-  return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
+/** test:上传 1KB 探针文件→删除→报告成功 */
+async function cmdTest() {
+  const cfg = await resolveStorageConfig();
+  if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
+  if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
+  const tmp = path.join(os.tmpdir(), `fmode-storage-probe-${process.pid}.txt`);
+  fs.writeFileSync(tmp, 'fmode-storage probe ' + new Date().toISOString() + '\n');
+  const key = `skill-storage-selftest/probe-${process.pid}.txt`;
+  try {
+    const up = runObsutil(cfg.obsUtilPath, ['cp', tmp, `obs://${cfg.bucket}/${key}`], { timeout: 120000 });
+    if (!up.ok) { console.error('test 失败(上传):', up.out.slice(-300)); process.exit(1); }
+    const del = runObsutil(cfg.obsUtilPath, ['rm', `obs://${cfg.bucket}/${key}`, '-f'], { timeout: 60000 });
+    if (!del.ok) { console.error('test 失败(清理探针文件,上传本身已成功):', del.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true, bucket: cfg.bucket, endpoint: cfg.endpoint, via: cfg.via, probeKey: key, message: '上传 1KB 探针→删除 全部成功' }, null, 2));
+    return true;
+  } finally {
+    try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
+  }
 }
 
+// ============================================================================
+// main
+// ============================================================================
+
 async function main() {
   const [cmd, ...rest] = process.argv.slice(2);
-  const cfg = await resolveStorageConfig();
-  if (!cfg.bucket && cmd !== 'help') {
-    console.error('未解析到 bucket:请登录 FMODE Studio(sessionToken 自举)或配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
-    console.error('(技能绝不内置密钥;自建 OBS 用户用 obsutil config 自行配置)');
+  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+  const experimentalSts = rest.includes('--experimental-sts');
+  if (rest.includes('--refresh-probe')) invalidateProbeCache();
+
+  if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
+  if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
+
+  const cfg = await resolveStorageConfig({ experimentalSts });
+  if (!cfg.ak && !cfg.sts) {
+    if (cmd !== 'help') {
+      console.error('凭据链 4 级全部未命中(env → obsutil config → 平台签发(未上线) → 项目 config)。');
+      printWizard();
+      process.exit(2);
+    }
+  }
+  if (cfg.ak && !cfg.sts && !cfg.bucket && ['put', 'setacl'].includes(cmd)) {
+    console.error(`凭据已命中(${cfg.via})但未解析到 bucket:用 --bucket 指定,或重新执行 init 写入完整配置。`);
     process.exit(2);
   }
-  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+  const obs = (args, timeout = 120000) => cfg.sts
+    ? runWithSts(cfg.sts, args, timeout)
+    : runObsutil(cfg.obsUtilPath, args, { timeout });
 
   if (cmd === 'put') {
     const file = rest[0];
@@ -204,7 +443,7 @@ async function main() {
     if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
     const acl = arg('--acl') || 'public-read';
     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}`, ...(acl ? ['-acl', acl] : [])]);
     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, via: cfg.via }, null, 2));
     return;
@@ -212,24 +451,25 @@ async function main() {
   if (cmd === 'setacl') {
     const key = arg('--key'); const acl = arg('--acl') || 'public-read';
     const recursive = rest.includes('-r');
-    const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])], cfg);
-    console.log(JSON.stringify({ ok: r.ok }, null, 2));
+    const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])]);
+    if (!r.ok) { console.error('setacl 失败:', r.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true }, null, 2));
     return;
   }
   if (cmd === 'config') {
-    // 诊断输出:绝不包含 AK/SK/SecurityToken/sessionToken 任何密钥本体
+    // 诊断输出:绝不包含 AK/SK/token/sessionToken 任何密钥本体
     console.log(JSON.stringify({
       via: cfg.via,
       bucket: cfg.bucket,
       endpoint: cfg.endpoint,
       cdnDomain: cfg.cdnDomain,
-      stsBootstrapped: Boolean(cfg.sts),
+      credentialsResolved: Boolean(cfg.ak || cfg.sts),
+      stsExperimental: Boolean(cfg.sts),
       stsPrefix: cfg.sts ? cfg.sts.prefix : null,
-      stsExpiresInMemoryOnly: Boolean(cfg.sts),
     }, null, 2));
     return;
   }
-  console.log('用法: put | setacl | config');
+  console.log('用法: init | test | put | setacl | config');
 }
 
-main();
+main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });