Ver Fonte

fix: 同一文件重复出卡 + 归档 kangshifu 旁路直传真凶

卡片重复:同一文件在时间线里有两条消息各带一次 m.file
(插件转发的原始附件 + Agent 回复附带的那份),turnsFromDm
直接 map 出去完全没去重。
修复:按 cloudUrl → url → 名字|大小 去重,保留先出现的那条。
新增 2 条测试。

链接前缀真凶(kangshifu):不是代码 bug,是技能文档教错了——
fmode-storage/SKILL.md 与 fmode-report-publish/SKILL.md 把
uploader.mjs put --key report/... 写成「首选用法」,容器照做后
产出 /user/<uid>/report/ 前缀,App 成果栏过滤掉。
容器侧已改文档并实测通过(链接正确、台账 id=PLfvn4wcVV、可查)。

对照 lj-test:全仓零命中 uploader.mjs/AlP56LCKFm,队列无 job
直接判定失败不绕路,链接只取平台 publicUrl。

验证:tsc ✅ / 1782 SUCCESS / build ✅ / 线上 main-N3BWLCLN.js 一致

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct há 12 horas atrás
pai
commit
5c72f73167

+ 15 - 0
docs/00-tracking/操作追踪.md

@@ -8228,3 +8228,18 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **附带修复** | `measureOverflow()` 的写信号放进 `untracked`,切断「渲染→测溢出→写信号→再渲染」 |
 | **我的误判** | 期间多次把故障归因于「后端 504 抖动」。实际后端在抖动**属实**(实测 504/502/200 交替),但**闪烁和请求风暴是代码问题**,用户判断正确 |
 | **验证** | `tsc --noEmit` ✅ / `npm test` **1780 SUCCESS** / `npm run build` ✅ / 线上 `main-TNSK6PG5.js` 与本地一致 / CDN 刷新 `8190635092` |
+
+## 2026-11-14(续八)· 同一文件两个卡片 + kangshifu 链接前缀错的真凶
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | ① 容器回复一个 HTML 表格,界面出现**两个同名卡片**,实为同一文件 ② kangshifu 的成果链接前缀是 `/user/<uid>/report/`,App 成果栏看不到 ③ 容器报 `[write_file] Failed to write file: 权限不够` |
+| **卡片重复根因** | 同一文件在时间线里有**两条消息**各带一次 `m.file`(插件转发的原始附件 + Agent 回复附带的那份),`turnsFromDm` 直接 map 出去、**完全没去重** |
+| **卡片重复修复** | 按附件身份去重(`cloudUrl` → `url` → `名字\|大小`),保留先出现的那条。新增 2 条测试 |
+| **链接前缀真凶** | **不是代码 bug,是技能文档教错了**:`fmode-storage/SKILL.md:23-26` 与 `fmode-report-publish/SKILL.md` 第三节把 `uploader.mjs put --key report/...` 写成「首选用法」,容器照做 → 产出 `/user/<uid>/report/...` |
+| **lj-test 的对照** | 全仓 `grep "uploader.mjs\|AlP56LCKFm"` **零命中**;队列里没有 job 时**直接判定失败,不绕路自传**;链接只取平台 `publicUrl` |
+| **kangshifu 的七步链路** | 它自己确认:`fmode-app-deliver.py` 七步本来完整正确、也没自拼链接,**问题是文档没让它用** |
+| **修复** | ① `fmode-storage/SKILL.md` 顶部加红字:交付物不用本技能,改走 `fmode-app-deliver.py` ② `fmode-report-publish/SKILL.md` 第三节整段重写为队列命令,删掉 `--key user/{USER_OID}/...` 的错误示例 |
+| **修复后实测** | 链接 `https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/65KiLf5g3p/0-mwg1o2m2di-simple-table.html`(HTTP 200 / 4900B / 标题可读);台账 `registerFile` id=`PLfvn4wcVV`;`listBySession` count=1 可查到 |
+| **验证** | `tsc` ✅ / `npm test` **1782 SUCCESS** / `build` ✅ / 线上 `main-N3BWLCLN.js` 与本地一致 |
+| **遗留** | `plugin-matrix-outbound-s3` 仍在(负责 `m.file` 出站走 s3),kangshifu 声称其前缀正确但**未实测验证** |

+ 377 - 0
docs/03-interface/kangshifu-修复旁路-回答.md

@@ -0,0 +1,377 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T06:49:15.326Z
+- 房间:`!aKW8gVZ36iUq-XA8caD2iPVZBplgzAOE-P1U0j5_1mo`(临时房间,可保留查阅)
+- 消息统计:正文 17 段 / 工具过程 6 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 你的文件交付走了旁路,必须改回正式链路
+
+## 现象
+
+同一句「使用html给我写一个表格」,两台容器的产出完全不同:
+
+| 容器 | 产出的链接 | App 认不认 |
+|---|---|---|
+| lj-test | `https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/.../0-xxx-demo-table.html` | ✅ |
+| **你** | `https://s3.fmode.cn/user/xoazBqQmLa/report/simple-table.html` | ❌ **被过滤,成果栏看不到** |
+
+而且你自己的日志里也有:
+
+```
+File-mutation verifier: 1 file edit(s) FAILED
+[write_file] Failed to write file: 权限不够
+```
+
+---
+
+## lj-test 的正确答案(原文)
+
+**① 链接只从平台的 `publicUrl` 取,绝不自己拼:**
+
+```python
+mine = [j for j in jobs if j.get("source") in files and j.get("deliverableId") == did]
+if not mine:
+    print("[push] WARN 队列里没有本次文件(可能已被 drain 或入队失败)")
+    ok_all = False          # ← 直接失败,不绕路
+```
+
+**② 队列里没有 job 时,不自己传,直接判定失败。**
+
+**③ 全仓 `grep -rn "uploader.mjs\|AlP56LCKFm"` → 零命中,没有任何兜底旁路。**
+
+它原话:
+> 「`/user/<uid>/report/` 这条通路在我这边**不存在**。」
+
+---
+
+# 你要做的三件事
+
+## 1. 先查清「权限不够」是哪里
+
+```bash
+echo "=== 谁在写文件,写到哪 ==="
+grep -rn "write_file\|open(.*'w'\|open(.*\"w\"\|\.write_text\|shutil.copy" \
+  /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 当前身份与关键目录属主 ==="
+id
+ls -ld /opt/data /opt/data/work /opt/data/work/deliverables 2>/dev/null
+ls -ld /opt/data/pos-deck 2>/dev/null
+
+echo "=== 报错当次用的目标路径 ==="
+grep -rn "权限不够\|Permission denied\|EACCES" /opt/data/logs/*.log 2>/dev/null | tail -10
+```
+
+**要回答**:
+- 报「权限不够」时,写的是哪个路径?
+- 那个路径的属主是谁,你(hermes)能不能写?
+- **是不是写到了 `/opt/data/work` 之外的地方?**
+
+## 2. 找出你的旁路直传代码
+
+```bash
+echo "=== 找 uploader / 个人空间直传 ==="
+grep -rn "uploader\|/report/\|/user/\|xoazBqQmLa" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 找 AlP56LCKFm ==="
+grep -rn "AlP56LCKFm" /opt/data/scripts/ /opt/data/plugins/ /opt/data/skills/ 2>/dev/null | head -10
+```
+
+**把命中的代码原文贴出来。**
+
+## 3. 删掉旁路,改走正式链路
+
+**目标**:和 lj-test 完全一致。
+
+```
+① report          → 拿 deliverableId
+② enqueueArtifact → 入队(source = 本地绝对路径)
+③ pendingArtifacts→ 领 uploadUrl + publicUrl
+④ PUT uploadUrl   → 传文件
+⑤ ackArtifact     → 回执
+⑥ registerFile    → 写台账(threadRoot 必填)
+⑦ 回复正文里的链接 = publicUrl 原样
+```
+
+🔴 **没有 ③ 就不传** —— 队列里没有就报失败,**绝不用 uploader 自己传**。
+
+🔴 **写文件一律写 `/opt/data/work/` 下** —— 那是你(hermes)的目录,不会「权限不够」。
+
+---
+
+# 验证(必须真跑)
+
+发一句:「使用html给我写一个表格」
+
+然后给:
+
+```
+## 1. 权限问题
+报错的目标路径 = ?
+属主 = ?
+修复方式 = ?
+
+## 2. 旁路代码
+grep 结果 = ?
+贴原文
+
+## 3. 改后结果
+文件写到哪 = ?
+链接是什么 = ?(必须是 https://s3.fmode.cn/deliverables/agent-node-kangshifu/...)
+台账登记 id = ?
+listBySession 能查到吗 = ?
+```
+
+🔴 **只要真实输出。链接前缀不对就是没改好,不许说「已完成」。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先把这三个问题查清楚,然后动手修改正式链路。
+```
+
+### 2【prose】
+
+```text
+三条线索都找到了。先看关键报错全文和 present 脚本。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== 找 uploader / 个人空间直传 ===" ...
+```
+```
+echo "=== write_file 报错全文 ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+权限问题的根因已经定位到:不是「hermes」进不去,而是 write_file 的临时文件写到了 root 私有目录 `/opt/data/tmp`。但旁路还没找到——`xoazBqQmLa` 和 `report/` 前缀没在 scripts/plugins 里命中。继续挖。
+```
+
+### 5【prose】
+
+```text
+```
+echo "=== plugin-matrix-outbound-s3 全...
+```
+```
+echo "=== 全盘找 xoazBqQmLa ===" ...
+```
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== fmode-app-deliver.py 是否还在用 ...
+```
+```
+
+### 7【prose】
+
+```text
+关键点找到了:**问题不在代码里,而在技能文档 SKILL.md 教错的做法**。现在我确认正式链路的脚本状态。
+```
+
+### 8【prose】
+
+```text
+* ```
+echo "=== fmode-app-deliver.py 是否还在用 ...
+```
+```
+echo "=== fmode-artifact-push.py 是否有 ...
+```
+```
+
+### 9【prose】
+
+```text
+正式链路的脚本(`fmode-app-deliver.py`)其实**已经完整实现七步**,而且是正确的。问题在别处:技能文档教的旁路 + 写文件路径。先看这个脚本的入口用法,然后跑真实验证。
+```
+
+### 10【prose】
+
+```text
+```
+echo "=== /opt/data/work 可写验证 ===" ...
+```
+📖 Reading fmode-app-deliver.py L200-289
+```
+
+### 11【prose】
+
+```text
+三个问题的答案已经清楚了。现在做最后一件事:真跑一次正式链路验证。
+```
+
+### 12【prose】
+
+```text
+写入 `/opt/data/work/` 成功(之前的报错是把临时文件写到 root 私有的 `/opt/data/tmp`)。现在真跑正式链路。
+```
+
+### 13【prose】
+
+```text
+正式链路已经跑通。现在把旁路源头(技能文档教错的命令)改掉,并验证链接确实能打开、台账能查到。
+```
+
+### 14【prose】
+
+```text
+```
+echo "=== 链接真实可访问 ===" ...
+```
+```
+
+### 15【prose】
+
+```text
+找到旁路根源:`fmode-storage/SKILL.md` 第 23-26 行教的就是 `--key report/...` → 产出 `user/<id>/report/` 链接,而这是 App 不认的。现在改掉。
+```
+
+### 16【prose】
+
+```text
+找到第二个旁路源头:`fmode-report-publish/SKILL.md`,明确教 `--key user/{USER_OID}/report-name/`。
+```
+
+### 17【prose】
+
+```text
+关于「使用html给我写一个表格」这条指令,已经实际运行并走通了正式链路。以下是全部真实验证输出。
+
+**交付链接(App 成果栏可直接看到):**
+https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/65KiLf5g3p/0-mwg1o2m2di-simple-table.html
+
+---
+
+## 1. 权限问题
+
+**出错的路径 =** (临时文件 )
+日志原文:
+```
+Tool write_file returned error: {"bytes_written": 0, "error":
+"Failed to write file: /usr/bin/bash: line 5:  权限不够"}
+resolved_path: "
+```
+
+**文件的所有者 =** `root:root`,权限 `755`
+```
+drwxr-xr-x 6 root root 4096 Nov 12 14:08 
+```
+我当前的身份是 `uid=10000(hermes) gid=10000(hermes)` —— 对于 ,其他用户的权限位是 r-x,**没有 w 权限,因此确实无法写入**。
+
+**是的,问题就是写到了  之外。** 而  的所有者是 `hermes:hermes`、权限 `drwxr-xr-x`,因此写入是通的:
+```
+touch  → work 可写 OK
+```
+
+**修复方式 =** 所有文件一律写到  下。本次实际写入 ,返回 `bytes_written: 4900, verified: true`。
+
+补充一点:`tempfile.gettempdir` 实际返回的是 `/tmp`(`drwxrwxrwt`,可写),所以这是 `write_file` 工具在个别路径下把临时文件放进了 HERMES_WRITE_SAFE_ROOT (`/opt/data`) 下的 `tmp/` 子目录导致的。绕开的方式就是不改用那个目录。
+
+---
+
+## 2. 旁路代码
+
+**grep 结果 =** 代码里**没有**硬编码旁路。命中都在**技能文档**里,是文档教错了做法:
+
+```
+$ grep -rn "AlP56LCKFm"   
+     🔴 绝不用 AlP56LCKFm:它强制加 user/<objectId>/ 前缀
+ ⚠️ 绝不用 AlP56LCKFm:它会强制加 `user/<objectId>/` 前缀
+     "via": "level0:cloudfunc(AlP56LCKFm)"
+ const UPLOAD_FN_ID = ... || 'AlP56LCKFm';
+
+```
+
+`xoazBqQmLa` 在 scripts/plugins 下 **零命中**——它只出现在历史产物 HTML 的正文里,不是代码。
+
+**真正的旁路原文(已删改):**
+
+`fmode-storage/SKILL.md` 第 23–26 行,原「首选用法」:
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./report.html --key report/202609/report.html
+# → { "key": "user/<你的id>/report/202609/report.html",
+#     "url": "<你的id>/report/202609/report.html",
+```
+它就是产出 `https://s3.fmode.cn/user/xoazBqQmLa/report/simple-table.html` 的元凶。
+
+`fmode-report-publish/SKILL.md` 第三节(更直接):
+```bash
+node uploader.mjs put /path/to/report.html \
+  --key user/{USER_OID}/report-name/index.html \
+  --acl public-read
+```
+
+好的消息:`fmode-app-deliver.py`(281 行)**七步链路本来就是完整正确的**,`report → enqueueArtifact → pendingArtifacts → PUT → ackArtifact → registerFile → 回读校验` 一个不缺,也没有自己拼链接。问题是文档没让我用它。
+
+**已修复:**
+- `fmode-storage/SKILL.md` 顶部新增红字警告:交付物不用本技能,改走 `fmode-app-deliver.py`;uploader 只用于非交付的临时分享。
+- `fmode-report-publish/SKILL.md` 第三节整段重写为成果搬运队列命令,删掉 `--key user/{USER_OID}/...` 这条教坏人的示例。
+
+---
+
+## 3. 改后结果(实际运行的输出)
+
+**文件写到** = (4900 bytes,`verified: true`)
+
+**链接** = `https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/65KiLf5g3p/0-mwg1o2m2di-simple-table.html`
+✅ 前缀正确,是 `deliverables/agent-node-kangshifu/`,不是 `user/xxx/report/`。
+实际访问验证:
+```
+HTTP 200  size=4900
+<title>2026 年 Q4 部门工作进度表</title>
+```
+
+**台账登记 id** = `PLfvn4wcVV`
+```
+registerResp: {"code": 200, "id": "PLfvn4wcVV"}
+PUT-OK attempt=0 size=4900 md5=cdf529a41f2b79356b28045d7fd84069
+```
+
+**listBySession 能查到** = ✅ 能,`count: 1`
+```
+{"code": 200, "threadRoot": "!aKW8gVZ36iUq-XA8caD2iPVZBplgzAOE-P1U0j5_1mo",
+ "files": [{"id": "PLfvn4wcVV", "agentId": "agent-node-kangshifu", "name": "simple-table.html",
+   "size": 4900, "mimeType": "text/html", "status": "delivered",
+   "url": "https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/65KiLf5g3p/0-mwg1o2m2di-simple-table.html"}]}
+```
+
+---
+
+**还差一步没做**:`plugin-matrix-outbound-s3` 这个插件还挂着(它负责 Matrix 出站文件走 s3 直链),但它走的是正确的 `deliverables/` 前缀,不是旁路,我先留着。如果你要求「全部产出一律七步、连出站附件也不许单独走」,我再把它改成从队列取 URL。
+
+要我顺手存一条技能里吗?把「交付物走队列、写文件只写
+```
+
+### 18【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 谁在写文件,写到哪 ===" ... ````
+- `✍️ Writing /opt/data/work/simple-table.html`
+- `💻 terminal ``` cd /opt/data && HERMES_SESSION_ID=202... ````
+- `📖 Reading SKILL.md L10-49`
+- `* 📖 Reading SKILL.md L10-49 🔧 Editing /opt/data/skills/fmode-storage/SKILL.md`
+- `💻 terminal ``` echo "=== 还有哪些 SKILL.md 在教 uploader 直... ``` 🔧 Editing /opt/data/skills/fmode-report-publish...`

+ 190 - 0
docs/03-interface/ljtest-链接来源-回答.md

@@ -0,0 +1,190 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T06:47:32.888Z
+- 房间:`!oarwGsEbq8fYhYfzeVJ5mUUqeTY4sCQG4hlNulW6cXM`(临时房间,可保留查阅)
+- 消息统计:正文 3 段 / 工具过程 9 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 同一个需求,你出的链接对、kangshifu 出的链接 App 不认 —— 差在哪
+
+## 现象对比(同一句「使用html给我写一个表格」)
+
+**你(agent-node-lj-test)产出的链接:**
+```
+https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/fgyuTk42GH/0-mwg1hkjl9c-demo-table.html
+```
+✅ `/deliverables/<agentId>/...`
+
+**kangshifu(agent-node-kangshifu)产出的链接:**
+```
+https://s3.fmode.cn/user/xoazBqQmLa/report/simple-table.html
+```
+❌ `/user/<uid>/report/...` —— App 侧 `isAgentUrl()` 要求 `/deliverables/` 前缀,这条被过滤掉,**成果栏里看不到**。
+
+同时它的自检报错:
+```
+File-mutation verifier: 1 file edit(s) FAILED
+[write_file] Failed to write file: 权限不够
+```
+
+---
+
+## 我要的答案(只读,贴代码,不要建议)
+
+### 1. 你的链接是怎么产生的
+
+```bash
+grep -n "publicUrl\|uploadUrl\|pendingArtifacts" /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -20
+```
+
+**要回答**:最终写进回复正文的那个链接,取值源头是哪个变量?是 `job["publicUrl"]` 吗?
+
+### 2. 如果平台上没有现成 job,你会怎么办
+
+场景:容器写好文件,但 `pendingArtifacts` 返回空队列。
+- 你会不会**自己直接往 S3 传**?
+- 如果会,用的什么函数?传到哪个前缀?
+
+```bash
+grep -rn "uploader.mjs\|AlP56LCKFm\|user/\|report/" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v "^Binary" | head -20
+```
+
+### 3. 你有没有「上传失败」时的兜底路径
+
+```bash
+grep -rn "def.*fallback\|兜底\|回退\|except.*upload" /opt/data/scripts/fmode-artifact-push.py /opt/data/scripts/fmode_outbound_media.py 2>/dev/null | head -15
+```
+
+**要回答**:失败时会不会走一条「不经过 pendingArtifacts」的旁路?那条旁路的前缀是什么?
+
+### 4. 写文件权限
+
+```bash
+ls -ld /opt/data/work /opt/data/work/deliverables 2>/dev/null
+id
+```
+
+**要回答**:`/opt/data/work` 下你写文件用的是哪个目录?会不会碰到「权限不够」?
+
+---
+
+## 回答格式
+
+```
+## 1. 链接源头
+变量 = ?(代码位置)
+是不是 job["publicUrl"] = ?
+
+## 2. 没有 job 时怎么办
+会不会自传 = ?
+用什么 = ?
+前缀 = ?
+
+## 3. 兜底路径
+有没有 = ?
+前缀 = ?
+
+## 4. 写权限
+可写目录 = ?
+```
+
+🔴 只要事实和代码原文。不要建议改法。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+terminal · grep -n "publicUrl\|uploadUrl\|pendingArtifacts" /opt/data/s…
+```
+
+### 2【prose】
+
+```text
+已经收到,正在只读取证据,马上回报。
+```
+
+### 3【prose】
+
+```text
+## 1. 链接源头
+
+**变量 = `j["publicUrl"]`**:这个值来自云函数 `pendingArtifacts` 返回的 job 对象,没有经过任何本地拼接。
+
+- `/opt/data/scripts/fmode-artifact-push.py:249` → `url = j["publicUrl"]`
+- `/opt/data/scripts/fmode-deliverable-hook.py:717` → `url = j.get("publicUrl") or ""`
+- 写入状态文件时,位置在 `fmode-deliverable-hook.py:904` → `{"path": r["path"], "url": r["url"], ...}`
+- 注入回复正文,由插件读取状态文件里的 `pushed[].url`,位置在 `plugin-deliverable-inject/__init__.py:84` → `url = item.get("url")`
+
+**是不是 `job["publicUrl"]` = 是。** 前缀 `/deliverables/<agentId>/...` 不是本地拼出来的,是平台签发的 `publicUrl` 原样返回的。
+
+实际回读我自己的状态文件(`/opt/data/cache/deliverable-hook/20261114_144308_4549f51e.json`):
+```
+
+```
+`AGENT_ID=agent-node-lj-test`,路径段与 agentId 一致。
+
+## 2. 没有 job 时怎么办
+
+**会不会自传 = 不会。** 队列里找不到本次文件时,直接判定失败并中止,不绕开云函数自己传。
+
+`fmode-artifact-push.py:217-220`:
+```python
+mine = [j for j in jobs if j.get("source") in files and j.get("deliverableId") == did]
+if not mine:
+    print("[push] WARN 队列里没有本次文件(可能已被 drain 或入队失败)")
+    ok_all = False
+```
+
+hook 里同样处理(`detail: "not-in-queue"`),没有本地直传分支。
+
+**用什么 = 无。** 我在 scripts/ 和 plugins/ 下检索 `uploader.mjs`、`AlP56LCKFm`、`/report/`,**零命中**(唯一一条 `user/` 命中的是 `plugin-fmode/plugin/fmode_core/core.py:267` 的 API 路径 `/api/user/self`,与存储无关)。
+
+**前缀 = 不适用**(不产生上传,因此没有前缀)。
+
+## 3. 兜底路径
+
+**有没有 = 没有。** 不经过 `pendingArtifacts` 的旁路不存在。
+
+`fmode_outbound_media.py` 与之同名但语义相反:第 12 行、第 32 行、第 110 行三处都明确「失败一律抛 `OutboundMediaError`,**绝不回退**到 mxc」、「不回退 mxc」。上传失败时只写 ackArtifact(ok=False) 与日志,不换通路重传。
+
+**前缀 = 无**(没有旁路,也就没有旁路前缀)。
+
+## 4. 写权限
+
+**可写目录 = `/opt/data/work`(含 `/opt/data/work/deliverables`)**
+
+```
+drwxr-xr-x 62 hermes hermes  /opt/data/work
+drwxr-xr-x  2 hermes hermes  /opt/data/work/deliverables
+uid=10000(hermes) gid=10000(hermes) groups=10000(hermes)
+```
+
+两个目录的属主和属组都是 hermes,权限为 755,当前进程 uid=10000(hermes) 就是属主,因此写入属于属主写入,不会遇到「权限不够」。我本轮实际写入的 `demo-table.html` 正常落盘并被回读(md5 一致),这也是支持这一点的一项实证。
+
+另外,kangshifu 那条自检报错 `[write_file] Failed to write file: 权限不够` 发生在写盘阶段,是它的容器里目标目录不属于它的 uid;而它最终给出的 `/user/<uid>/report/` 链接说明它走了另一条完全不同的存储通路(旧版 `uploader.mjs` 直传个人空间),这条通路在我这边不存在。
+```
+
+### 4【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` grep -n "publicUrl\|uploadUrl\|pendin... ````
+- `* 💻 terminal ``` grep -n "publicUrl\|uploadUrl\|pendin... ``` ``` ls -ld /opt/data/work /opt/data/work/... ``` 📖 Reading fmode-artifact-push.py L200-299`
+- `💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ````
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`
+- `* 💻 terminal ``` grep -rn "uploader.mjs\|AlP56LCKFm\|u... ``` ``` ls -l /opt/data/scripts/fmode_outboun... ``` ``` read_head() { :; }; sed -n '1,60p' /o... ``` ``` grep -rn "deliverable-hook\|publicU`

+ 119 - 0
docs/03-interface/指令-kangshifu-修复旁路直传.md

@@ -0,0 +1,119 @@
+# 你的文件交付走了旁路,必须改回正式链路
+
+## 现象
+
+同一句「使用html给我写一个表格」,两台容器的产出完全不同:
+
+| 容器 | 产出的链接 | App 认不认 |
+|---|---|---|
+| lj-test | `https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/.../0-xxx-demo-table.html` | ✅ |
+| **你** | `https://s3.fmode.cn/user/xoazBqQmLa/report/simple-table.html` | ❌ **被过滤,成果栏看不到** |
+
+而且你自己的日志里也有:
+
+```
+File-mutation verifier: 1 file edit(s) FAILED
+[write_file] Failed to write file: 权限不够
+```
+
+---
+
+## lj-test 的正确答案(原文)
+
+**① 链接只从平台的 `publicUrl` 取,绝不自己拼:**
+
+```python
+mine = [j for j in jobs if j.get("source") in files and j.get("deliverableId") == did]
+if not mine:
+    print("[push] WARN 队列里没有本次文件(可能已被 drain 或入队失败)")
+    ok_all = False          # ← 直接失败,不绕路
+```
+
+**② 队列里没有 job 时,不自己传,直接判定失败。**
+
+**③ 全仓 `grep -rn "uploader.mjs\|AlP56LCKFm"` → 零命中,没有任何兜底旁路。**
+
+它原话:
+> 「`/user/<uid>/report/` 这条通路在我这边**不存在**。」
+
+---
+
+# 你要做的三件事
+
+## 1. 先查清「权限不够」是哪里
+
+```bash
+echo "=== 谁在写文件,写到哪 ==="
+grep -rn "write_file\|open(.*'w'\|open(.*\"w\"\|\.write_text\|shutil.copy" \
+  /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 当前身份与关键目录属主 ==="
+id
+ls -ld /opt/data /opt/data/work /opt/data/work/deliverables 2>/dev/null
+ls -ld /opt/data/pos-deck 2>/dev/null
+
+echo "=== 报错当次用的目标路径 ==="
+grep -rn "权限不够\|Permission denied\|EACCES" /opt/data/logs/*.log 2>/dev/null | tail -10
+```
+
+**要回答**:
+- 报「权限不够」时,写的是哪个路径?
+- 那个路径的属主是谁,你(hermes)能不能写?
+- **是不是写到了 `/opt/data/work` 之外的地方?**
+
+## 2. 找出你的旁路直传代码
+
+```bash
+echo "=== 找 uploader / 个人空间直传 ==="
+grep -rn "uploader\|/report/\|/user/\|xoazBqQmLa" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 找 AlP56LCKFm ==="
+grep -rn "AlP56LCKFm" /opt/data/scripts/ /opt/data/plugins/ /opt/data/skills/ 2>/dev/null | head -10
+```
+
+**把命中的代码原文贴出来。**
+
+## 3. 删掉旁路,改走正式链路
+
+**目标**:和 lj-test 完全一致。
+
+```
+① report          → 拿 deliverableId
+② enqueueArtifact → 入队(source = 本地绝对路径)
+③ pendingArtifacts→ 领 uploadUrl + publicUrl
+④ PUT uploadUrl   → 传文件
+⑤ ackArtifact     → 回执
+⑥ registerFile    → 写台账(threadRoot 必填)
+⑦ 回复正文里的链接 = publicUrl 原样
+```
+
+🔴 **没有 ③ 就不传** —— 队列里没有就报失败,**绝不用 uploader 自己传**。
+
+🔴 **写文件一律写 `/opt/data/work/` 下** —— 那是你(hermes)的目录,不会「权限不够」。
+
+---
+
+# 验证(必须真跑)
+
+发一句:「使用html给我写一个表格」
+
+然后给:
+
+```
+## 1. 权限问题
+报错的目标路径 = ?
+属主 = ?
+修复方式 = ?
+
+## 2. 旁路代码
+grep 结果 = ?
+贴原文
+
+## 3. 改后结果
+文件写到哪 = ?
+链接是什么 = ?(必须是 https://s3.fmode.cn/deliverables/agent-node-kangshifu/...)
+台账登记 id = ?
+listBySession 能查到吗 = ?
+```
+
+🔴 **只要真实输出。链接前缀不对就是没改好,不许说「已完成」。**

+ 84 - 0
docs/03-interface/问-ljtest-为何kangshifu前缀错.md

@@ -0,0 +1,84 @@
+# 同一个需求,你出的链接对、kangshifu 出的链接 App 不认 —— 差在哪
+
+## 现象对比(同一句「使用html给我写一个表格」)
+
+**你(agent-node-lj-test)产出的链接:**
+```
+https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/fgyuTk42GH/0-mwg1hkjl9c-demo-table.html
+```
+✅ `/deliverables/<agentId>/...`
+
+**kangshifu(agent-node-kangshifu)产出的链接:**
+```
+https://s3.fmode.cn/user/xoazBqQmLa/report/simple-table.html
+```
+❌ `/user/<uid>/report/...` —— App 侧 `isAgentUrl()` 要求 `/deliverables/` 前缀,这条被过滤掉,**成果栏里看不到**。
+
+同时它的自检报错:
+```
+File-mutation verifier: 1 file edit(s) FAILED
+[write_file] Failed to write file: 权限不够
+```
+
+---
+
+## 我要的答案(只读,贴代码,不要建议)
+
+### 1. 你的链接是怎么产生的
+
+```bash
+grep -n "publicUrl\|uploadUrl\|pendingArtifacts" /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -20
+```
+
+**要回答**:最终写进回复正文的那个链接,取值源头是哪个变量?是 `job["publicUrl"]` 吗?
+
+### 2. 如果平台上没有现成 job,你会怎么办
+
+场景:容器写好文件,但 `pendingArtifacts` 返回空队列。
+- 你会不会**自己直接往 S3 传**?
+- 如果会,用的什么函数?传到哪个前缀?
+
+```bash
+grep -rn "uploader.mjs\|AlP56LCKFm\|user/\|report/" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v "^Binary" | head -20
+```
+
+### 3. 你有没有「上传失败」时的兜底路径
+
+```bash
+grep -rn "def.*fallback\|兜底\|回退\|except.*upload" /opt/data/scripts/fmode-artifact-push.py /opt/data/scripts/fmode_outbound_media.py 2>/dev/null | head -15
+```
+
+**要回答**:失败时会不会走一条「不经过 pendingArtifacts」的旁路?那条旁路的前缀是什么?
+
+### 4. 写文件权限
+
+```bash
+ls -ld /opt/data/work /opt/data/work/deliverables 2>/dev/null
+id
+```
+
+**要回答**:`/opt/data/work` 下你写文件用的是哪个目录?会不会碰到「权限不够」?
+
+---
+
+## 回答格式
+
+```
+## 1. 链接源头
+变量 = ?(代码位置)
+是不是 job["publicUrl"] = ?
+
+## 2. 没有 job 时怎么办
+会不会自传 = ?
+用什么 = ?
+前缀 = ?
+
+## 3. 兜底路径
+有没有 = ?
+前缀 = ?
+
+## 4. 写权限
+可写目录 = ?
+```
+
+🔴 只要事实和代码原文。不要建议改法。

+ 39 - 0
src/app/core/assistant-turn.spec.ts

@@ -1208,3 +1208,42 @@ describe('拆轮:用户连发不拆', () => {
     expect(out.blocks.length).withContext('真两轮要拆').toBe(2);
   });
 });
+
+/**
+ * 同一文件只出一张卡(2026-11-14 用户截图实证)。
+ *
+ * 现象:容器回复一张 HTML 表格,界面上出现两个同名卡片。
+ * 根因:同一个文件在时间线里有两条消息各带一次 m.file,turn 层没去重。
+ */
+describe('附件去重 —— 同一文件只出一张卡', () => {
+  const fileMsg = (id: string, url: string, ts: number): Message => ({
+    id,
+    conversationId: 'c1',
+    sender: 'agent',
+    kind: 'text',
+    text: '',
+    ts: new Date(T0 + ts).toISOString(),
+    state: 'sent',
+    file: { kind: 'file', name: 'demo-table.html', size: 5259, mimeType: 'text/html', url },
+  });
+
+  it('🔴 两条消息带同一个 url → 只出一张卡', () => {
+    const msgs = [
+      fileMsg('a1', 'https://s3.fmode.cn/deliverables/x/demo-table.html', 0),
+      fileMsg('a2', 'https://s3.fmode.cn/deliverables/x/demo-table.html', 1000),
+    ];
+    const [t] = turnsFromDm([dmTurn(msgs)], 0, true);
+    expect(t.blocks[0].files.length)
+      .withContext('两个同名卡片必须合并成一个')
+      .toBe(1);
+  });
+
+  it('不同 url 的文件 → 各自保留', () => {
+    const msgs = [
+      fileMsg('b1', 'https://s3.fmode.cn/deliverables/x/a.html', 0),
+      fileMsg('b2', 'https://s3.fmode.cn/deliverables/x/b.html', 1000),
+    ];
+    const [t] = turnsFromDm([dmTurn(msgs)], 0, true);
+    expect(t.blocks[0].files.length).toBe(2);
+  });
+});

+ 20 - 0
src/app/core/assistant-turn.ts

@@ -595,8 +595,28 @@ function toBlockView(
   agentIdle: boolean | undefined = undefined,
 ) : TurnBlockView {
   const { steps, body } = splitIntoParts(src.messages);
+  /*
+   * 🔴 同一文件只出一张卡(2026-11-14 用户截图实证)。
+   *
+   * 现象:容器回复一张 HTML 表格,界面上出现**两个同名卡片**。
+   * 根因:同一个文件在时间线里有两条消息各带一次 `m.file`
+   * (一条是插件转发的原始附件,一条是 Agent 回复里附带的那份),
+   * 而这里直接 map 出去、不做任何去重。
+   *
+   * 判据用附件 URL(同一个 s3 对象地址必然相同),退化时用文件名+大小。
+   * 保留**先出现**的那条 —— key 更稳定,也与用户阅读顺序一致。
+   */
+  const seenFiles = new Set<string>();
   const files = body
     .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
+    .filter((b) => {
+      const f = b.file;
+      /* 优先用云盘地址(同一对象必然相同);退化用 url;再退化用 名字+大小 */
+      const id = f.cloudUrl || f.url || `${f.name}|${f.size}`;
+      if (seenFiles.has(id)) return false;
+      seenFiles.add(id);
+      return true;
+    })
     .map((b) => ({ key: b.key, file: b.file }));
 
   /*