SKILL.md 14 KB


name: fmode-storage description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储并拿到公开分享链接,本地零长期占用。默认通道免配置密钥:只要有登录 sessionToken,平台云函数就签发上传地址(对象键 user/<你的id>/...,公网 https://s3.fmode.cn/)。适用:(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//..., public https://s3.fmode.cn/). Use for publishing reports/courseware, hosting media assets, batch upload with public-read, or any scenario needing public URLs." schema_version: 1.2.0 level: system category: publishing keywords:

  • storage
  • obs
  • s3
  • upload
  • cdn
  • share-link
  • bigfile
  • gateway
  • presigned
  • sigv4 version: 1.2.0 author: Yuyang001 (FmodeAgent) license: MPL-2.0 copyright: "Copyright (c) 2026 未来飞马 Fmode" tags: [未来飞马, 智能体技能, 超级技能, 服务级, 数据管理, FmodeAgent, Hermes Agent, FmodeCode, Claude Code, storage, obs, s3, cdn, upload] ---

Fmode Storage — 对象存储与公开分享

Overview

把本地二进制文件交给对象存储,生成公开 URL 供分享、转发或嵌入。

设计原则:本地零长期占用;公开即所得;凭据零入库——密钥永不落盘、永不进日志、永不进对话;诚实失败——通道全失败时打印初始化向导并 exit 2,绝不伪装成功。

首选用法(免配置,0 步)

不需要申请 AK/SK、不需要 init。只要容器里有登录态 sessionToken(数字生命容器默认就有),直接:

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)" }

自检(验证这条通道是否可用):

node <skill_dir>/scripts/uploader.mjs test
# → { "ok": true, "via": "level0:cloudfunc(...)", "message": "云函数免密钥通道自检成功(本机无需任何 AK/SK)" }

--key 给相对路径即可,脚本/云函数会强制挂到 user/<你的id>/ 前缀下——写不进别人的前缀。 省略 --key 则自动生成 user/<你的id>/report/<YYYYMM>/<uuid>-<文件名>。 上传即 public-read,不需要再调 setacl。

项目级 Storage 正确姿势(2026-10-12 实测修复)

不要用 uploader put 走个人级当"项目级"! uploader put 的首选通道是 level0 cloudfunc(个人空间 user//)。

项目级 = nova-cloud 桶 dev/<projectId>/ 前缀,公网 https://app.fmode.cn/dev//。正确流程:

  1. 项目未注册先注册:POST server.fmode.cn/api/functions {token, path:'/fmode/project-register', params:{name:'<slug>'}} → 返回 projectId(objectId,如 GvsWkfZb36)
  2. 签发项目级 STS:POST server.fmode.cn/api/apig/deploy/huaweicloud {token, projectId} → 200 返回 accessKey/secretKey/securityToken + obsPath obs://nova-cloud/dev/<PID>/
  3. obsutil ini(无[default]段头,endpoint=obs.cn-south-1配合nova-cloud 桶):

    endpoint=obs.cn-south-1.myhuaweicloud.com
    ak=...
    sk=...
    token=...
    
  4. 上传:obsutil cp <file> obs://nova-cloud/dev/<PID>/<file> -f -acl=public-read -config=<ini> (必须 -config= 指定 ini,不要 obsutil config 全局污染;不要用 obsutil ls 测试权限——直接 cp 才准,ls 可能因 STS policy 范围报 403)

  5. CDN 刷新 app.fmode.cn 域名组

脚本:/opt/data/scripts/upload-project.sh <sessionToken> <projectId> <file...>

认证区身份:sessionToken 用 ~/.fmode/config/user.json(真实用户,userId=liuyuyang),不是 ~/.fmode/config.json。

坑:①赖着 uploader put(level0 个人空间)当"项目级"= 错误;②obsutil 用 obsutil config -c 写临时 ini 会污染且报 not well-formed,用 -config= 参数指向自立 ini;③deploy STS endpoint 是 obs.cn-south-1(nova-cloud 桶在 south-1,north-4 会 NoSuchBucket/签名不匹配 403);④resolveStorageProjectId() 常命中一个不属于当前用户的陈旧 projectId(本机 ~/.fmode/config.json = GvsWkfZb36),此时 deploy STS 返回 {code:403,"没有权限"}——用 gateway 子命令会自动注册/复用自己的项目(bigfile-gateway),或显式 --project-id 传一个本人有权限的 objectId。

┌─ 第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 级是首选:第 1/2 级要每台容器各自配 OBS 密钥、第 3 级要 storageProjectId 且作用域是项目目录、第 4 级只有 bucket 名没有凭据——都无法做到"开箱即用"。第 0 级只依赖登录态,是唯一零配置通道。

第 1–4 级作为兜底保留(例如需要写 obs://www-fmode.cn/ 之类平台桶,而不是个人空间)。

Content-Type 与 MIME(v1.2.0 起)

put 会按扩展名自动推断并透传 Content-Type,HTML/CSS/JS/SVG/JSON 上传后可直接在浏览器渲染 (不再触发下载)。需要强制指定时用 --mime:

node <skill_dir>/scripts/uploader.mjs put ./data.bin --key x/data.bin --mime application/pdf

⚠️ --key 只写相对路径(如 about-me/index.html)。云函数会自动补 user/<你的id>/; 若 key 里已写了 user/<id>/,v1.2.0 会自动剥离并告警,但正确写法是不要带。

⚠️ 覆盖同名 key 后可能"改了没生效":CDN 缓存 TTL 为 30 天,且缓存的是整个响应(含 Content-Type 头)。 建议换新 key 路径发布(如 about-me-v2/index.html)。

大文件网关(bigfile-gateway)— 通道超限时生成接收页

何时用:主通道被不可协商的大小上限挡住时(典型:企微入站 20MB,用户发大文件报「超过 100M 无法发送」, 文件从未到达智能体)。此时不要再走聊天通道——生成一个接收页,用户浏览器直连对象存储上传, 再由 collect 取回。平时不生成,只在超限时触发。

为什么必须对象存储中转:容器跑在 NAT 内网,没有公网入口,不能自建接收服务(踩坑 #4)。 两段式「直传 + 取回」是唯一可行解。

命令

# 生成 + 部署接收页(默认 20 槽位、1 小时有效),输出页面 URL
node <skill_dir>/scripts/uploader.mjs gateway <outDir>
# 等价:node <skill_dir>/scripts/gateway.mjs mint <outDir>

# 常见参数
node scripts/gateway.mjs mint ./gw --slots 100 --hours 1 \
     --title "大文件接收网关 · 刘雨飏" \
     --channel "企微入站" --limit 20MB \
     --note "企微入站在 20MB 处直接拒收,文件在到达前就已不存在"
# --project-slug <slug>   复用/注册的项目名(默认 bigfile-gateway,幂等)
# --project-id <id>       直接指定已有项目 objectId
# --slot-prefix <p>       槽位键前缀(默认 gateway/<时间戳>)
# --no-deploy             只本地生成,不部署
# --json                  机器可读输出

# 取回(扫描到位文件 → 下载 → 台账)
node scripts/gateway.mjs collect --manifest <outDir>/mint.json            # 只列
node scripts/gateway.mjs collect --manifest <outDir>/mint.json --out ~/inbox  # 下载
# 生成的 <outDir>/collect.sh 已封装以上命令:bash collect.sh [--list|--out DIR]

流程(5 步)

  1. resolveSessionToken() 取登录态(复用 uploader 的会话语义)
  2. 云函数 /fmode/project-register 注册/复用项目 → projectId(幂等)
  3. POST /api/apig/deploy/huaweicloud {token, projectId} → 项目级 STS(真身 1h)
  4. 自己实现 SigV4 为 N 个槽位各签一条预签名 PUT(host-only 签名,UNSIGNED-PAYLOAD), 渲染 templates/gateway.html → <outDir>/index.html,再用同一 STS 把页面传回 dev/<pid>/bigfile/index.html
  5. 输出页面 URL(https://app.fmode.cn/dev/<pid>/bigfile/index.html)+ mint.json + collect.sh

用户传完 → collect 逐槽位 HEAD 探测 → 下载到本地 + 写 ledger.jsonl(只记 key/大小/路径)。

页面特性

单文件自包含、中文、移动端可用:拖拽/点选多文件、逐文件进度条、完成显示「上传成功 ✓」+ 查看链接; 不含任何 AK/SK/token(只内联当次预签名 URL);文件只写各自槽位(gateway/<ts>/0000.bin…),互不影响。

⚠️ 踩坑库(实测 2026-10-13,必守)

# 坑 / 现象 根因 本实现的做法
1 不存在对象的公网 URL 被 CDN 回落成 200 + text/html(占位页),全被误判「有文件」 只看状态码判「到位」 looksLikeObject():必须排除 content-type: text/html;实测该桶对缺失 key 返回 404,但仍严格按此判
2 签名标称 48h,用户第二天传全失败 签名带 X-Amz-Security-Token,STS 真身只 1h;可用窗口 = min(签名, 1h) 页面 --hours 默认 1、封顶 1h;用户过时须 重新 mint + 重新部署,不可沿用旧页
3 读 .obsutilconfig 直接失败 里面是占位符凭据(http://your-endpoint + 假 AK/SK) 不读它;凭据走项目 STS,签名自实现
4 想「起 http 服务让用户传」外部连不上 容器在 NAT 内网、无公网入口 放弃自建接收端,用对象存储中转
5 用云函数签的 URL 做上传页,用户点开已失效 云函数签名 expires/ttl/expiresIn 全被忽略,恒定 282s 不用云函数签名;只用 /api/functions 注册项目拿到项目 STS(1h),签名自实现
6 页面发出后用户传不上去,来回沟通成本极高 生成→部署间有多道环节(签名/CORS/key 前缀/CDN) 生成后必须先真传一个小文件实测再发用户(本工具不改这条铁律)
7 页面放进站点项目后 key 与预期不符、取回扫不到 站点 dist-deploy 重写规则给绝对路径加项目前缀 页面不塞独立站点项目,直接用同一 STS 写入 dev/<pid>/bigfile/,key 与预期一致
8 bucket NoSuchBucket nova-cloud 桶在 cn-south-1(north-4 无此桶) endpoint 固定 obs.cn-south-1.myhuaweicloud.com,region cn-south-1
9 逐槽位扫描想用 ListObjects STS 策略不允许 List(实测 403,只允许 prefix 内 GetObject/PutObject) 取回靠逐槽位 HEAD 探测(app.fmode.cn/<key>),不依赖列举

实测(2026-10-13,本机)

  • 页面:https://app.fmode.cn/dev/OtEAl5m4JP/bigfile/index.html(项目 bigfile-gateway→OtEAl5m4JP)
  • 真传:浏览器同一路径的预签名 PUT → slot 0000/0002 上传 200 → 公网 GET 回读 200 内容一致(含中文)
  • 取回:collect --list 命中 2/2;collect --out 下载 2 文件 + ledger.jsonl 全部成功
  • SigV4 预签名 PUT/GET、CORS 预检(回显 content-type,x-obs-acl)均为 200

安全边界

预签名 URL 只对单个 key 有 PUT 权限(不能读/列/删其他对象)——链接即使公开,别人也只能往那一个空槽位写。 页面 URL 本身公开:敏感资料用 --hours 1 短效并告知用户「用完即焚」。

何时用

  • 生成了 HTML 报告/课件要"发给别人看" → put 后把 URL 发出
  • 图片/音频/视频素材需要稳定外链 → put
  • 本地磁盘被大文件占满 → put 完成后可删本地原件
  • 用户发来的文件被通道上限挡住(如企微 20MB)→ gateway 生成接收页 + collect 取回

命令

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 时

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(多工具安装:FmodeCode / Claude Code / Codex / Gemini CLI / WorkBuddy / FmodeAgent / Hermes Agent + 完整 changelog)。

铁律

  • ⛔ 绝不把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志、对话
  • ⛔ 绝不尝试从 git 仓库/代码/文档/聊天记录中寻找他人或公司凭证
  • ✅ 对象只能写自己 user/<自己的id>/ 前缀(云函数在服务端强制)