فهرست منبع

docs: 容器接入规范补充实测结论(fmode.kind 受限于 /opt/hermes 只读)

- 实测 lj-test 的 adapter 改造受阻:HERMES_WRITE_SAFE_ROOT=/opt/data 只读
- 工具进度行不走 send() metadata,需另改 run_turn_runner.py
- 记录「口头声称已改完」不可信,必须回读 Matrix 事件验证
- 补 CORS 教训:服务端网关多实例导致 X-Parse-* 头间歇性被拒

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 5 ساعت پیش
والد
کامیت
64a3e19213
4فایلهای تغییر یافته به همراه101 افزوده شده و 64 حذف شده
  1. 53 0
      docs/00-tracking/操作追踪.md
  2. 48 5
      docs/03-interface/容器接入规范提示词-20261111.md
  3. 0 28
      scripts/ask-seq.mjs
  4. 0 31
      scripts/verify-fmode-kind.mjs

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

@@ -7695,3 +7695,56 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 并在过渡期给出硬要求(工具行独占一行、不与中文结论混排)。
 
 ---
+
+## 2026-11-11(续十一)· CORS 彻底修复 + fmode.kind 兼容(容器侧受阻)
+
+### 一、CORS 彻底修复(已上线验证)
+
+**上一轮的判断是错的**:我以为是「请求头大小写」,实际是**服务端网关多实例、CORS 配置不一致**。
+
+实测连续 10 次 OPTIONS 预检:
+
+| 返回 | 次数 |
+|---|---|
+| `Access-Control-Allow-Headers: *` | 4 |
+| 固定白名单(**无任何 `x-parse-*`**) | 6 |
+
+所以逐个删头是治标 —— 删掉 app-id 后问题立刻在 session-token 上重现。
+
+**修法**:云函数入口(`FUNCTION_API`)**不带任何 `X-Parse-*` 头**,
+鉴权靠 body 的 `params.token`(`fmode-file-hub.js:90` 是明确回退路径)。
+
+**验证**:线上连续 20 次调用 **20/20 成功**(修复前 10 次里 6 次被拒)。
+
+### 二、fmode.kind(App 侧已完成,容器侧受阻)
+
+**App 侧全链路已实现**:
+
+| 文件 | 改动 |
+|---|---|
+| `core/api/models.ts` | `Message.fmodeKind?: 'process' \| 'final'` |
+| `core/matrix-client.service.ts` | `extractFmodeKind()` + 两处解析点注入 |
+| `core/chat.service.ts` | `mapTimeline` 透传 |
+| `core/assistant-turn.ts` | `splitIntoParts` 优先按声明分流,缺失回退启发式 |
+
+单测:4 条(process / final / 字段缺失回退 / 混排各归各位)。
+
+**容器侧改造失败(如实记录)**:
+
+1. 容器先说「改完了」,**实测证明字段 0 次出现** —— 不可信
+2. 追问后它承认:`/opt/hermes` 是**只读**(`HERMES_WRITE_SAFE_ROOT=/opt/data`),
+   文件修改被系统拒绝
+3. 它做好的准备(仍可利用):
+   - 备份:`/opt/data/backups/fmode-kind-20261111-161903/`
+   - 补丁脚本:`/opt/data/work/tmp-apply-fmode-kind.py`
+4. **第二个障碍**:工具进度行不走 `send()` 的 metadata 路径,
+   即使能写文件也要额外改 `run_turn_runner.py`
+
+**用户裁决**:放弃容器改造,App 用启发式兜底。
+
+### 教训
+
+**「容器说改完了」不能当作事实**。必须自己回读 Matrix 事件验证字段是否真的在 `content` 里。
+这次如果我轻信,就会带着一个不存在的功能去发布和测试。
+
+---

+ 48 - 5
docs/03-interface/容器接入规范提示词-20261111.md

@@ -119,7 +119,38 @@
 | 1 | Matrix adapter 的 `send()` | `metadata.get("_interim_send")` 为真 → `content["fmode.kind"] = "process"`,否则 `"final"` |
 | 2 | 工具进度行的产出链路(`_progress_build_message` 之后) | 同样写入 `content["fmode.kind"] = "process"` |
 
-**改造纪律**:
+##### 🔴 实测结论:这个改造**不是所有容器都能做**(2026-11-11 在 lj-test 上验证)
+
+在 `agent-node-lj-test`(Hermes 架构)上实测,改造**受阻于两个硬约束**:
+
+**约束 1:`/opt/hermes` 是只读的**
+
+```
+HERMES_WRITE_SAFE_ROOT=/opt/data
+→ [patch] Write denied: '/opt/hermes/plugins/platforms/matrix/adapter.py'
+  is outside HERMES_WRITE_SAFE_ROOT
+```
+
+容器可以**备份**和**写补丁脚本**到 `/opt/data`,但**执行不了**。
+需要宿主机侧给 `/opt/hermes` 写权限,或由 root 执行补丁。
+
+**约束 2:工具进度行拿不到 `_interim_send`**
+
+进度行走 `_send_progress_text` → `adapter.send(metadata=ctx._progress_metadata)`,
+而 `_progress_metadata` 在非 Discord 平台**原样返回(可能 None)**,不带 `_interim_send`。
+需要在 `run_turn_runner.py` 里**额外显式塞标记键** —— 这是第二处改动,同样在只读区。
+
+插件层也绕不开:钩子白名单只有 `post_tool_call` / `transform_llm_output` /
+`pre_gateway_dispatch` 等**入站/工具/LLM** 钩子,**没有出站 send 钩子**。
+
+**因此**:
+
+| 情况 | 结论 |
+|---|---|
+| 容器有 `/opt/hermes` 写权限 | 可以两处都改,效果好 |
+| 容器只读(如 lj-test) | **改不了**,只能靠 App 侧启发式兜底 |
+
+**改造纪律**(若你有写权限,按此执行):
 1. 先备份目标文件(如 `adapter.py.bak-fmodekind-<ts>`)
 2. 改完**重启 gateway**(adapter 是进程内加载的)
 3. **用真实房间事件回读验证**:直读 `/_matrix/client/v3/rooms/{roomId}/messages`,
@@ -127,7 +158,15 @@
    —— **不能靠看日志或看自己发的文本判断**
 4. 如某处确实做不到,**如实说明原因,不要假装做了**
 
-##### 已知瑕疵(改造会暴露,需评估是否接受)
+> 🔴 **给接入方的强烈提醒**:lj-test 曾口头回答「改完了」,
+> 但实际回读发现 `fmode.kind` **出现 0 次** —— 它的文件修改被系统拒绝了。
+> **任何「已完成」的声称都必须用回读事件验证**,不能采信。
+>
+> 可复用的现成产物(lj-test 已备好,仍在该容器里):
+> - 备份:`/opt/data/backups/fmode-kind-20261111-161903/`
+> - 补丁脚本:`/opt/data/work/tmp-apply-fmode-kind.py`
+
+##### 已知瑕疵(若能改造会暴露)
 
 `create_handoff_thread()` 的种子消息会被标成 `final`(它没有 `_interim_send`),
 按语义它不算正文。可在该处显式跳过写入。
@@ -256,12 +295,16 @@ API_SERVER_KEY
 - [ ] 心跳周期 ≤ 80 分钟
 - [ ] 上报 `status` / `currentTask` / `channels` / `extra.currentSessions`
 
-### 结构化标记(强烈推荐,App 侧已就绪)
+### 结构化标记(可选 —— 取决于容器是否有写权限)
+- [ ] **先确认容器能写 `/opt/hermes`(或对应 adapter 目录)** —— 只读则本项无法做
 - [ ] Matrix adapter 的 `send()` 落 `fmode.kind: "process" | "final"`
-- [ ] **工具进度行的产出链路**也落 `fmode.kind: "process"`(不走 `send()`,需单独改)
+- [ ] **工具进度行的产出链路**也落 `fmode.kind: "process"`(不走 `send()`,需单独改 `run_turn_runner.py`)
 - [ ] 重启 gateway 后用真实房间事件回读,确认字段确实出现在 `content` 里
 
-> 只做第一处是不够的:`send()` 只覆盖旁白,**工具进度行包含「日志吞正文」的主体**。
+> 只做第一处是不够的:`send()` 只覆盖旁白,**工具进度行才是「日志吞正文」的主体**。
+>
+> **做不了也没关系**:App 侧已实现自动回退 —— 字段缺失时走 emoji 启发式,
+> 老容器照常可用。本项**不是接入的硬前提**。
 
 ---
 

+ 0 - 28
scripts/ask-seq.mjs

@@ -1,28 +0,0 @@
-#!/usr/bin/env node
-import { readFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import { join } from 'node:path';
-const F='https://server.fmode.cn/api/functions', T='agent-node-lj-test';
-const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
-const t=JSON.parse(readFileSync(join(homedir(),'.fmode/config/user.json'),'utf8'))?.sessionToken;
-async function prov(id){const r=await fetch(F,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:'RP5Ht55NeX',token:t,params:{agentId:id}})});const j=await r.json();const d=j.result??j;if(!d?.accessToken)throw new Error('provision 失败');return d;}
-const QS=JSON.parse(process.argv[2]||'[]');
-const p1=await prov(T), agent=await prov(T);
-const mx=await import('matrix-js-sdk');
-const c=mx.createClient({baseUrl:p1.homeserver,accessToken:p1.accessToken,userId:p1.userMxId});
-const room=(await c.createRoom({invite:[agent.agentMxId],name:`seq-${Date.now()}`,preset:mx.Preset.TrustedPrivateChat})).room_id;
-const hs=p1.homeserver.replace(/\/$/,''), auth={Authorization:`Bearer ${p1.accessToken}`};
-for(let i=0;i<30;i++){await sleep(3000);const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,{headers:auth})).json().catch(()=>({}));if((j.joined??{})[agent.agentMxId])break;}
-const seen=new Set();
-for(const q of QS){
-  console.log(`\n===== 问 =====\n${q}\n`);
-  const s=await c.sendEvent(room,'m.room.message',{msgtype:'m.text',body:q,'m.mentions':{user_ids:[agent.agentMxId]}});
-  seen.add(s.event_id);
-  let buf='',lastNew=Date.now();const stop=Date.now()+420_000;
-  while(Date.now()<stop){await sleep(5000);let added=false;
-    try{const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=80`,{headers:auth})).json();
-      for(const ev of (j.chunk??[]).reverse()){if(ev.type!=='m.room.message'||seen.has(ev.event_id)||ev.sender===p1.userMxId)continue;seen.add(ev.event_id);buf+=String(ev.content?.body??'')+'\n';added=true;}}catch{}
-    if(added)lastNew=Date.now();else if(buf&&Date.now()-lastNew>30000)break;}
-  console.log(buf.trim()||'(无回复)');
-}
-console.log('\n房间:',room);c.stopClient();

+ 0 - 31
scripts/verify-fmode-kind.mjs

@@ -1,31 +0,0 @@
-#!/usr/bin/env node
-/** 直读 Matrix 事件,确认 content 里真的有 fmode.kind */
-import { readFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import { join } from 'node:path';
-const F='https://server.fmode.cn/api/functions', T='agent-node-lj-test';
-const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
-const t=JSON.parse(readFileSync(join(homedir(),'.fmode/config/user.json'),'utf8'))?.sessionToken;
-async function prov(id){const r=await fetch(F,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:'RP5Ht55NeX',token:t,params:{agentId:id}})});const j=await r.json();const d=j.result??j;if(!d?.accessToken)throw new Error('provision 失败');return d;}
-const p1=await prov(T), agent=await prov(T);
-const mx=await import('matrix-js-sdk');
-const c=mx.createClient({baseUrl:p1.homeserver,accessToken:p1.accessToken,userId:p1.userMxId});
-const room=(await c.createRoom({invite:[agent.agentMxId],name:`kind-${Date.now()}`,preset:mx.Preset.TrustedPrivateChat})).room_id;
-const hs=p1.homeserver.replace(/\/$/,''), auth={Authorization:`Bearer ${p1.accessToken}`};
-for(let i=0;i<30;i++){await sleep(3000);const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,{headers:auth})).json().catch(()=>({}));if((j.joined??{})[agent.agentMxId])break;}
-console.log('房间:', room);
-await c.sendEvent(room,'m.room.message',{msgtype:'m.text',body:'跑一条命令看看当前目录,然后告诉我结果。','m.mentions':{user_ids:[agent.agentMxId]}});
-console.log('已提问,等待 100 秒收集事件…');
-await sleep(100_000);
-const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=60`,{headers:auth})).json();
-let found=0, total=0;
-for(const ev of (j.chunk??[]).reverse()){
-  if(ev.type!=='m.room.message'||ev.sender===p1.userMxId) continue;
-  total++;
-  const k=ev.content?.['fmode.kind'];
-  if(k) found++;
-  const body=String(ev.content?.body??'').slice(0,60).replace(/\n/g,' ');
-  console.log(`  [${k ?? '∅'}] ${body}`);
-}
-console.log(`\n汇总:容器消息 ${total} 条,其中带 fmode.kind 的 ${found} 条`);
-c.stopClient();