Преглед на файлове

feat(0.4.0): 新增免密钥首选通道(云函数预签名直传)

- uploader.mjs 新增第0级 putViaCloudFunction:sessionToken → 云函数 fmodeagent-upload-url
  签发 5 分钟预签名 PUT;对象键 user/<id>/<ns>/<YYYYMM>/...,公网 https://s3.fmode.cn/<key>
- put/test 优先走第0级;setacl 在第0级下为 no-op(上传即 public-read)
- 第1-4级(env/obsutil/config/deploy-STS)保留为兜底(平台桶场景)
- SKILL.md/README 改为『零配置用法』优先;CHANGELOG 记 0.4.0
- 真因:1-4 级都要求各容器自备密钥/storageProjectId,34 台容器无一具备 →
  报告生成成功却因 exit 2 发不出去
fmode-ops преди 16 часа
родител
ревизия
ad4b9c3b36
променени са 4 файла, в които са добавени 209 реда и са изтрити 65 реда
  1. 37 21
      README.md
  2. 2 2
      package.json
  3. 72 39
      skills/fmode-storage/SKILL.md
  4. 98 3
      skills/fmode-storage/scripts/uploader.mjs

+ 37 - 21
README.md

@@ -4,22 +4,32 @@
 
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
 
-## 三分钟初始化(新用户必读
+## 零配置用法(0 步,推荐
 
-**当前版本(0.3.0)需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台端点上线**(设计文档 `fmode-studio/docs/obs-cdn/04-API设计.md` 中 `/api/storage/credentials` 状态为"规划中",本技能启动时会自动探测:404=未上线则跳过该级并提示,端点上线后 `--experimental-sts` 一行启用)
+**当前版本(0.4.0)默认通道免密钥**:只要容器/环境里有登录态 `sessionToken`(数字生命容器默认就有),直接上传即可拿到公开链接——不用申请 AK/SK、不用 init
 
-### 步骤 1:向平台/管理员申请 OBS 子账号(约 1 分钟)
+```bash
+node skills/fmode-storage/scripts/uploader.mjs put ./report.html --key report/202609/report.html
+# → { "ok": true,
+#     "key": "user/<你的id>/report/202609/report.html",
+#     "url": "https://s3.fmode.cn/user/<你的id>/report/202609/report.html",
+#     "via": "level0:cloudfunc(AlP56LCKFm)" }
+```
+
+自检:
+
+```bash
+node skills/fmode-storage/scripts/uploader.mjs test
+# → { "ok": true, "via": "level0:cloudfunc(...)", "message": "云函数免密钥通道自检成功(本机无需任何 AK/SK)" }
+```
+
+原理:平台云函数 `fmodeagent-upload-url` 持有真实 AK/SK,按调用者身份签发 5 分钟有效的 OBS 预签名 PUT 地址;**密钥永不下发到容器**。对象键强制挂在 `user/<你自己的id>/` 前缀下(服务端校验,写不了别人的);上传即 `public-read`,公网地址 `https://s3.fmode.cn/<key>`。
 
-给管理员发一封邮件/消息(模板直接用):
+## (兜底)申请 OBS 子账号 + init 向导
 
-> 主题:申请 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 域名请一并提供
+需要写**平台桶**(如 `obs://www-fmode.cn/`)而不是个人空间时,才走这条 4 级兜底链。
 
-### 步骤 2:执行 init 向导(约 1 分钟)
+### 执行 init 向导(约 1 分钟)
 
 ```bash
 node skills/fmode-storage/scripts/uploader.mjs init \
@@ -64,7 +74,7 @@ git clone https://git.fmode.cn/fmode/skill-storage.git
 cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storage
 ```
 
-## 凭据(诚实 4 级,零密钥入库)
+## 凭据(诚实 5 级,零密钥入库)
 
 凭据解析链(命中即用,检测不到就换下一级;**全失败打印初始化向导并退出码 2,绝不伪装成功**):
 
@@ -95,25 +105,31 @@ cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storag
 ## 用法示例
 
 ```bash
-# 初始化(一次性)
-node skills/fmode-storage/scripts/uploader.mjs init --ak <AK> --sk <SK> --endpoint ... --bucket ...
+# 首选:免配置(有 sessionToken 即可)
+node skills/fmode-storage/scripts/uploader.mjs put ./report.html --key report/202609/report.html
+# → https://s3.fmode.cn/user/<你的id>/report/202609/report.html
 
 # 自检
 node skills/fmode-storage/scripts/uploader.mjs test
 
-# 上传并输出公开链接
-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
+
+# 兜底:需要平台桶时才走 obsutil 4 级链
+node skills/fmode-storage/scripts/uploader.mjs init --ak .. --sk .. --bucket ..
 ```
 
 ## Changelog
 
+### 0.4.0(新增免密钥首选通道)
+- **新增第 0 级:云函数预签名直传**(`fmodeagent-upload-url`)。只需登录态 `sessionToken`,
+  平台云函数持有 AK/SK 并签发 5 分钟预签名 PUT 地址 → 对象键 `user/<id>/<ns>/<YYYYMM>/...`,
+  公网 `https://s3.fmode.cn/<key>`。**容器无需任何 OBS 凭据 / storageProjectId**。
+- `put` / `test` 优先走第 0 级;第 1–4 级保留为兜底(平台桶等场景)。
+- `setacl` 在第 0 级下为 no-op(上传即 `x-obs-acl:public-read`)。
+- 真因:第 1–4 级要求"每台容器各自配密钥 / 有 storageProjectId",实际 34 台容器无一具备,
+  导致技能只能打印向导并 exit 2 —— 报告生成成功却发不出去。
+
 ### 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(deploy STS 自举仍用 sessionToken,见下)

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "fmode-storage",
-  "version": "0.3.0",
-  "description": "Claude Code skill: 对象存储上传/公开链接/ACL(华为云 OBS / S3 兼容)。诚实 4 级凭据链(env → obsutil config → 平台签发(端点上线后) → 项目 config)+ init 初始化向导 + test 自检。零依赖 Node ≥18。",
+  "version": "0.4.0",
+  "description": "对象存储上传/公开分享技能(华为云 OBS / S3 兼容)。首选免密钥通道:sessionToken → 云函数 fmodeagent-upload-url 预签名直传(对象键 user/<id>/,公网 https://s3.fmode.cn/<key>);兜底 4 级凭据链(env → obsutil config → 平台签发 STS → 项目 config)。零依赖 Node ≥18。",
   "license": "MIT",
   "repository": {
     "type": "git",

+ 72 - 39
skills/fmode-storage/SKILL.md

@@ -1,66 +1,99 @@
 ---
 name: fmode-storage
-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."
+description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储并拿到公开分享链接,本地零长期占用。**默认通道免配置密钥**:只要有登录 sessionToken,平台云函数就签发上传地址(对象键 user/<你的id>/...,公网 https://s3.fmode.cn/<key>)。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+公开读 (4) 需要公开 URL 供转发或嵌入。"
+description_en: "Upload binary files (images/audio/video/HTML reports) to object storage and get public share URLs, keeping local disk clean. The default channel needs no credentials: with a login sessionToken the platform cloud function issues an upload URL (object key user/<your-id>/..., public https://s3.fmode.cn/<key>). Use for publishing reports/courseware, hosting media assets, batch upload with public-read, or any scenario needing public URLs."
+schema_version: 0.2.0
+level: system
+category: publishing
+keywords:
+  - storage
+  - obs
+  - s3
+  - upload
+  - cdn
+  - share-link
 ---
 
-# Fmode Storage — 对象存储与公开分享技能
+# Fmode Storage — 对象存储与公开分享
 
 ## Overview
 
-本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——AK/SK 长期密钥只在 obsutil/环境配置里,诊断输出绝不含密钥本体;**诚实失败**——凭据链全失败时打印初始化向导并退出码 2,绝不伪装成功。
+把本地二进制文件交给对象存储,生成公开 URL 供分享、转发或嵌入。
 
-## 三分钟初始化(新用户)
+设计原则:**本地零长期占用**;**公开即所得**;**凭据零入库**——密钥永不落盘、永不进日志、永不进对话;**诚实失败**——通道全失败时打印初始化向导并 `exit 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
+## 首选用法(免配置,0 步)
 
-# 2. 验证
-node <skill_dir>/scripts/uploader.mjs test
-# → 上传 1KB 探针文件→删除→{ "ok": true, ... }
+**不需要申请 AK/SK、不需要 init**。只要容器里有登录态 `sessionToken`(数字生命容器默认就有),直接:
+
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./report.html --key report/202609/report.html
+# → { "ok": true, "key": "user/<你的id>/report/202609/report.html",
+#     "url": "https://s3.fmode.cn/user/<你的id>/report/202609/report.html",
+#     "via": "level0:cloudfunc(AlP56LCKFm)" }
 ```
 
-## 快速用
+自检(验证这条通道是否可用):
 
 ```bash
-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
+node <skill_dir>/scripts/uploader.mjs test
+# → { "ok": true, "via": "level0:cloudfunc(...)", "message": "云函数免密钥通道自检成功(本机无需任何 AK/SK)" }
 ```
 
-## 凭据(诚实 4 级 + 端点探测)
+> **`--key` 给相对路径即可**,脚本/云函数会强制挂到 `user/<你的id>/` 前缀下——写不进别人的前缀。
+> 省略 `--key` 则自动生成 `user/<你的id>/report/<YYYYMM>/<uuid>-<文件名>`。
+> 上传即 `public-read`,**不需要**再调 `setacl`。
+
+## 凭据链(5 级,命中即用)
 
 ```
-┌─ 诚实 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. 平台签发 STS:sessionToken + storageProjectId                          │
-│    → POST /api/apig/deploy/huaweicloud(权威端点,生产实测 200)          │
-│    → 项目隔离 STS,key 强制限定 dev/<projectId>/ 前缀                     │
-│    (设计端点 /api/storage/credentials 从未上线——HEAD 探测 404,缓存      │
-│      .sts-probe.json 1 小时;上线后 --experimental-sts 启用备用路径)     │
-│ 4. 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain)         │
-└───────────────────────────────────────────────────────────────────────────┘
+┌─ 第0级【首选·免密钥】sessionToken → 云函数 fmodeagent-upload-url 签发预签名 PUT
+│    → AK/SK 由云函数保管,容器只拿 5 分钟有效的上传地址。无需任何本地配置。
+│  第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+│  第2级 obsutil config(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
+│  第3级 sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud
+│    (项目级 STS,作用域 obs://nova-cloud/dev/<projectId>/,非个人空间)
+│  第4级 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain)
+│  全失败 → 打印初始化向导并 exit 2
+└──────────────────────────────────────────────────────────────────────────┘
 ```
 
-- **旧版 0.2.x "sessionToken→/api/storage/credentials 免配置自举"是伪自举**(端点从未上线,探测 404),0.3.0 已诚实化:该路径降级为 `--experimental-sts`;第3级改用真实上线的 deploy STS 端点(sessionToken + projectId)。
-- **当前版本需一次性配置 AK/SK**(init 向导);deploy STS 自举需 sessionToken + storageProjectId(免 OBS 密钥)。
-- AK/SK 只存在于 obsutil config / 环境变量(用户自己配的);`config` 命令输出不含任何密钥本体;STS 仅内存持有(临时目录 0700,命令结束即删)。
+**为什么第 0 级是首选**:第 1/2 级要每台容器各自配 OBS 密钥、第 3 级要 `storageProjectId` 且作用域是项目目录、第 4 级只有 bucket 名没有凭据——都无法做到"开箱即用"。第 0 级只依赖登录态,是唯一零配置通道。
 
-> 自建 OBS 一次性配置:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-north-4.myhuaweicloud.com`
-> **禁止把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志或对话。**
+第 1–4 级作为**兜底**保留(例如需要写 `obs://www-fmode.cn/` 之类平台桶,而不是个人空间)。
 
 ## 何时用
 
-- 生成了 HTML 报告/课件要"发给别人看"→ put 后把 URL 发出
-- 图片/音频/视频素材需要稳定外链 → put + setacl public-read
-- 本地磁盘被大文件占满 → put 完成后可删本地原件
+- 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出
+- 图片/音频/视频素材需要稳定外链 → `put`
+- 本地磁盘被大文件占满 → `put` 完成后可删本地原件
+
+## 命令
+
+```bash
+node uploader.mjs test                        # 自检(优先走第0级)
+node uploader.mjs put <file> --key <相对key>  # 上传并返回公开 URL
+node uploader.mjs put <file>                  # 省略 --key:自动命名
+node uploader.mjs put <file> --key k --ns report
+node uploader.mjs setacl --key <key>          # 第0级下上传即 public-read,无需调用
+node uploader.mjs config                      # 诊断(输出绝不含密钥本体)
+node uploader.mjs init --ak .. --sk ..        # 仅第1/2级兜底场景需要
+```
+
+## 需要完整 SDK 时
+
+```js
+import { putViaCloudFunction } from './scripts/uploader.mjs';
+const r = await putViaCloudFunction('/path/report.html', 'report/202609/report.html', 'report');
+// r = { ok, key, url, bytes, via }
+```
 
 ## 详细文档
 
-见仓库根 `README.md`(三分钟初始化 + 多工具安装:Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes + 完整 changelog)。
+见仓库根 `README.md`(多工具安装:Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes + 完整 changelog)。
+
+## 铁律
+
+- ⛔ 绝不把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志、对话
+- ⛔ 绝不尝试从 git 仓库/代码/文档/聊天记录中寻找他人或公司凭证
+- ✅ 对象只能写自己 `user/<自己的id>/` 前缀(云函数在服务端强制)

+ 98 - 3
skills/fmode-storage/scripts/uploader.mjs

@@ -1,7 +1,13 @@
 #!/usr/bin/env node
 /**
  * fmode-storage uploader — 对象存储上传/公开链接/ACL
- * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
+ * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
+ *
+ * v0.4.0(2026-09-23):新增**第0级 云函数预签名直传**(首选)。
+ *   容器只需 FMODE_SESSION_TOKEN,平台云函数 fmodeagent-upload-url 持有 AK/SK 并
+ *   签发 5 分钟预签名 PUT URL;对象键 user/<userid>/<ns>/<YYYYMM>/...,公网
+ *   https://s3.fmode.cn/<key>。解决"每个容器都要配 OBS AK/SK / storageProjectId"
+ *   的落地缺口(此前 34 台容器无一具备,技能只能打印向导退出)。
  *
  * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
  *   旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
@@ -23,7 +29,7 @@
  *
  * 用法:
  *   node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
- *   node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
+ *   node uploader.mjs put <file> --key <objectKey> [--ns report] [--acl public-read] [--bucket ...]
  *   node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
  *   node uploader.mjs test
  *   node uploader.mjs config
@@ -46,6 +52,14 @@ 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;
 
+// ── 第0级(首选):云函数预签名直传 ─────────────────────────────────────────
+// fmodeagent-upload-url 云函数保管 AK/SK,按调用者身份签发 5 分钟有效的
+// OBS SigV2 预签名 PUT URL;容器只拿到 URL,密钥永不下发。
+// 对象键:user/<userid>/<namespace>/<YYYYMM>/<uuid>-<filename>,公网 https://s3.fmode.cn/<key>
+const UPLOAD_FN_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/functions`;
+const UPLOAD_FN_ID = process.env.FMODE_UPLOAD_FN_ID || process.env.FMODE_STORAGE_FN_ID || 'AlP56LCKFm';
+const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
+
 /** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
 function printWizard() {
   console.error(`
@@ -264,6 +278,44 @@ export function publicUrl(cfg, key) {
   return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
 }
 
+
+// ============================================================================
+// 第0级:云函数预签名直传(免 AK/SK,首选)
+// ============================================================================
+
+/**
+ * 申请预签名 URL 并直传(云函数持有真实 AK/SK)。
+ * @returns {{ok:true, key:string, url:string, bytes:number, via:string}|null}
+ *          null = 该通道不可用(无 sessionToken / 云函数未配置 / 网络失败),调用方回落下一级
+ */
+export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null) {
+  const token = resolveSessionToken();
+  if (!token) return null;
+  let buf;
+  try { buf = fs.readFileSync(file); } catch { return null; }
+  const filename = name || path.basename(file);
+  let res;
+  try {
+    res = await fetch(UPLOAD_FN_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        token, id: UPLOAD_FN_ID,
+        params: { filename, size: buf.length, namespace, key: objectKey || undefined },
+      }),
+      signal: AbortSignal.timeout(20000),
+    });
+  } catch { return null; }
+  if (!res.ok) return null;
+  const body = await res.json().catch(() => null);
+  if (!body || body.code !== 200 || !body.uploadUrl) return null;
+  try {
+    const put = await fetch(body.uploadUrl, { method: 'PUT', headers: body.headers || {}, body: buf });
+    if (!put.ok) return null;
+  } catch { return null; }
+  return { ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`, bytes: buf.length, via: `level0:cloudfunc(${UPLOAD_FN_ID})` };
+}
+
 // ============================================================================
 // 凭据链:诚实 4 级
 // ============================================================================
@@ -481,6 +533,19 @@ async function cmdInit(rest) {
 
 /** test:上传 1KB 探针文件→删除→报告成功 */
 async function cmdTest() {
+  // 第0级:云函数免密钥通道(首选),能通就直接成功
+  const probeTmp = path.join(os.tmpdir(), `fmode-storage-cfprobe-${process.pid}.txt`);
+  fs.writeFileSync(probeTmp, 'fmode-storage cloudfunc probe ' + new Date().toISOString() + '\n');
+  try {
+    const r = await putViaCloudFunction(probeTmp, null, 'selftest');
+    if (r) {
+      console.log(JSON.stringify({ ok: true, via: r.via, url: r.url, bytes: r.bytes, message: '云函数免密钥通道自检成功(本机无需任何 AK/SK)' }, null, 2));
+      return true;
+    }
+  } finally {
+    try { fs.rmSync(probeTmp, { force: true }); } catch { /* ignore */ }
+  }
+
   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); }
@@ -512,6 +577,29 @@ async function main() {
   if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
   if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
 
+  // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
+  // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
+  // 而这条路恰恰是唯一不需要任何本机凭据的通道。
+  if (cmd === 'put') {
+    const file0 = rest[0];
+    const key0 = arg('--key');
+    if (!file0 || !key0) { console.error('用法: put <file> --key <objectKey> [--acl public-read] [--ns report]'); process.exit(2); }
+    if (!fs.existsSync(file0)) { console.error('文件不存在: ' + file0); process.exit(2); }
+    const ns0 = arg('--ns') || 'report';
+    const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0));
+    if (cf0) {
+      console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes }, null, 2));
+      return;
+    }
+    // 云函数不可用(无 sessionToken / 云函数未配置)→ 落到下面的 obsutil 4 级兜底
+  }
+
+  // setacl:云函数通道下对象在 PUT 时已带 x-obs-acl:public-read,无需单独设 ACL
+  if (cmd === 'setacl' && resolveSessionToken()) {
+    console.log(JSON.stringify({ ok: true, via: 'level0:cloudfunc', message: '云函数通道:对象在上传时即带上 x-obs-acl:public-read,无需再单独设置 ACL' }, null, 2));
+    return;
+  }
+
   const cfg = await resolveStorageConfig({ experimentalSts });
   if (!cfg.ak && !cfg.sts) {
     if (cmd !== 'help') {
@@ -534,6 +622,13 @@ 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;
+    // 第0级(首选):云函数预签名直传 —— 容器不需要任何 AK/SK
+    const ns = arg('--ns') || 'report';
+    const cf = await putViaCloudFunction(file, key, ns, path.basename(key || file));
+    if (cf) {
+      console.log(JSON.stringify({ ok: true, key: cf.key, url: cf.url, bucket: 'storage-s3-nkkj', via: cf.via, bytes: cf.bytes }, null, 2));
+      return;
+    }
     key = scopedKey(cfg, key); // STS 签发时强制限定 dev/<projectId>/ 前缀,防越权路径
     const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
     if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
@@ -566,4 +661,4 @@ async function main() {
   console.log('用法: init | test | put | setacl | config');
 }
 
-main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });