Просмотр исходного кода

feat: 同公司成员可共用容器 + 容器接入文档收敛

## 云函数:provision v0.1.6(已上线)
- 授权从「主人/超管」放开到「同公司成员」
- 新增 _isSameCompany():FmodeAgent.user.company == 调用者.company
- 跨公司仍 403(多租户隔离底线)
- 保守策略:任何一环取不到 company 都返回 false
- 实测:本人 200 / 跨公司 11 个全 403 / 同公司同事 200

## 部署脚本
- 新增 --only= 参数,避免批量部署误覆盖其他函数
- (线上源码曾与本地备份不一致,必须先拉线上再改)

## App
- 403 错误提示改成「容器暂时无法使用(平台侧授权未开放)」

## 文档
- 新增「多用户共用容器-完整规范」:完整链路 + 准入判定规则 + 踩坑
- 新增「README-容器接入文档索引」:收敛 20+ 份文档,明确 3 份权威
- SOP v3 新增「第 3.0 步:多用户准入」
- 提示词 v2.1 补充实测标准

## 关键发现(已写入文档)
1. 非空白名单优先于 ALLOW_ALL —— 与直觉相反
2. .env 改了不等于生效,必须回读 /proc/<pid>/environ
3. reaction 的 sender 不能当证据(App 用容器身份登录)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 13 часов назад
Родитель
Сommit
8da8746ad1

+ 473 - 0
cloud/backup/RP5Ht55NeX-v0.1.5-before-company-share-20261112.js

@@ -0,0 +1,473 @@
+// fmodeagent-matrix-provision (TASK-matrix-v2 ①): 用户侧 Matrix 账号自动开通
+// 调用: POST https://server.fmode.cn/api/functions {token, id:<Function.objectId>, params:{agentId?}}
+//
+// 流程 (聊天页进入时调用, 取代"该数字生命的飞马(Matrix)身份正在开通或未接入"笼统态):
+//   1. 校验调用者是注册用户 (sessionToken → Parse _User);
+//   2. 为该用户确保 Matrix 账号存在: mxid=@<用户objectId小写>:fmode.cn,
+//      密码=sha256("fmode-mx:"+sessionToken) 前 24 位 (与 fmode-app matrix-client.ts 派生一致,
+//      使 App 端无需云函数也能确定性登录);
+//      不存在 → admin API 注册 (Continuwuity: admin 账号登录 → admin room "!admin users create");
+//      已存在但密码不符 → reset-password 对齐派生密码 (幂等开通);
+//   3. 确认 Agent 账号存在 (params.agentId → FmodeAgent 记录; mxid=@<agentId去agent-node-前缀>:fmode.cn,
+//      如 @xinting:fmode.cn——TASK-matrix-v2 §4 账号保留, 消息接发全交官方 Hermes channel;
+//      Agent 密码不在此重置——官方 channel 正在使用该账号; 账号缺失报具体错误);
+//   4. 返回 {userMxId, agentMxId, homeserver, accessToken, deviceId, roomId?}
+//      accessToken 用 "!admin users issue-token" 签发 (绑定 device, 长期有效),
+//      并 ensureRoom: 已有共同房间复用, 否则 createRoom(invite agent, is_direct)。
+//
+// 失败返回具体环节: {code:4xx/5xx, mess:"账号注册失败|房间创建失败|SDK登录失败|..."}——
+// App 端按 mess 显示, 不再笼统"开通中"。
+//
+// 凭据策略: admin 密码放本文件常量 (Function 表 code 仅 masterKey 可读, 与 config.nova.json 同级保护);
+//           用户密码为派生值不落库; issue-token 结果只在响应里出现一次。
+// 版本 v0.3.0 · 2026-09-09 · TASK-matrix-v42 P3 (Agent 账号改 objectId 唯一体系)
+// ===== v0.3.0 (TASK-matrix-v42 P3) 变更说明 =====
+// Agent Matrix 账号由语义化拼音名 (@xinting:fmode.cn) 改为 FMODE_AGENT.objectId:
+//   @<objectId小写>:fmode.cn — objectId 全局唯一, 语义名不唯一且不利于批量接入。
+//   1. mxid 计算: agentLocal = agentRec.id 小写 (不再去 agent-node- 前缀)
+//   2. 账号缺失不再 502 拒绝 → _ensureAccount 自动注册 (密码 = sha256("fmode-agent-mx:"+objectId) 前 24,
+//      固定盐派生幂等可重置 — Agent 无 sessionToken, 不与用户派生式混用)
+//   3. DM 房间重建: 新 agentMxId → 绑定表 agentMxId/roomId 更新 (旧 roomId 不复用——旧账号房间);
+//      旧语义化账号归档不删 (Hermes channel 迁移完成前并存)
+//   4. 批量接入: scripts/provision-all-agents.sh 遍历 FMODE_AGENT 全表逐个 provision
+// 版本 v0.2.1 · 2026-09-08 · TASK-matrix-v41 (修复 _ensureAccount 形参 mxAdmin→mx ReferenceError)
+//
+// ===== v0.2.1 (TASK-matrix-v41) 变更说明 =====
+// _ensureAccount 形参名为 mxAdmin 而函数体引用 `mx` (第 224/228 行 _adminCmd(mx, …)) —
+// 新开绑定 (无 FmodeMatrixBinding 记录路径, agent-node-tuye/agent-node 首开即命中) 必然
+// ReferenceError "mx is not defined" → HTTP 500 "SDK登录失败: mx is not defined"。
+// 修复: 形参更名 mxAdmin→mx (与函数体一致); 线上 Function 表同改 (2026-09-08 实测三 agent 全通)。
+//
+// ===== v0.2.0 (TASK-matrix-v3 P1) 变更说明 (v0.1.5 流程保留为建绑定路径) =====
+// 根因: 每次进入聊天页都重新 provision (issue-token 新 device/新 token), 第二次进入
+//       慢 + 历史/通话随新 device/新房间丢失。修复: 绑定表 FmodeMatrixBinding 为唯一真相:
+//   1. 查表: {userPointer, agentId} 唯一, status=active → 直接返回表中凭据 (不注册/不签发/不建房),
+//      幂等 (连开两次聊天页第二次秒进); roomId 固定 → SDK 进入同房间自然拉到全部时间线 (P3)。
+//   2. 建绑定: 无记录才走 v0.1.5 注册/签发/ensureRoom 流程并写表 (bindingId=表 objectId)。
+//   3. 过期自愈: App 端 whoami 报 M_UNKNOWN_TOKEN / 本函数按 ?validate=1 探活失败 →
+//      走 admin reset-password+issue-token 刷新凭据并更新表 (status 保持 active)。
+//   4. 响应新增 {bindingId, cached:true|false, status:'active'}; 语义: cached=true 即绑定表命中。
+var CLASS_NAME = 'FmodeAgent';
+var BINDING_CLASS = 'FmodeMatrixBinding'; // TASK-matrix-v3 P1: Channel 绑定表 (user×agent 唯一)
+var MATRIX_HS = 'https://matrix.fmode.cn';
+var MATRIX_DOMAIN = 'fmode.cn';
+var ADMIN_USER = 'admin';
+var ADMIN_PASS = 'Fmode@2026';
+var SERVICE_BOT = '@conduit:fmode.cn';
+var ADMIN_ROOM = '!UcLbdbuAWs9FZkrFIjPWBAe4jfNM2r_sj0-A0vJQgOQ'; // admin+conduit 专用, 严禁加人
+
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
+
+function _sessionToken() {
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+}
+
+function _sha256Hex(s) {
+  // 纯 JS SHA-256 (executor 作用域无 crypto/node modules; 输入为 ASCII sessionToken)
+  function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
+  var K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
+           0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
+           0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
+           0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
+           0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
+           0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
+           0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
+           0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
+  var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
+  var bytes = [];
+  for (var i = 0; i < s.length; i++) {
+    var c = s.charCodeAt(i);
+    if (c < 128) bytes.push(c);
+    else if (c < 2048) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); }
+    else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); }
+  }
+  var bitLen = bytes.length * 8;
+  bytes.push(0x80);
+  while (bytes.length % 64 !== 56) bytes.push(0);
+  for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
+  for (var b = 0; b < bytes.length; b += 64) {
+    var w = new Array(64);
+    for (var t = 0; t < 16; t++) {
+      w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4 + 1] << 16) | (bytes[b + t*4 + 2] << 8) | bytes[b + t*4 + 3];
+    }
+    for (var t2 = 16; t2 < 64; t2++) {
+      var s0 = rr(w[t2-15], 7) ^ rr(w[t2-15], 18) ^ (w[t2-15] >>> 3);
+      var s1 = rr(w[t2-2], 17) ^ rr(w[t2-2], 19) ^ (w[t2-2] >>> 10);
+      w[t2] = (w[t2-16] + s0 + w[t2-7] + s1) | 0;
+    }
+    var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
+    for (var t3 = 0; t3 < 64; t3++) {
+      var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
+      var ch = (e & f) ^ (~e & g);
+      var temp1 = (h + S1 + ch + K[t3] + w[t3]) | 0;
+      var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
+      var maj = (a & bb) ^ (a & c) ^ (bb & c);
+      var temp2 = (S0 + maj) | 0;
+      h = g; g = f; f = e; e = (d + temp1) | 0;
+      d = c; c = bb; bb = a; a = (temp1 + temp2) | 0;
+    }
+    H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
+    H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
+  }
+  var out = '';
+  for (var k = 0; k < 8; k++) out += ('00000000' + (H[k] >>> 0).toString(16)).slice(-8);
+  return out;
+}
+
+function _mxPassword(sessionToken) {
+  return _sha256Hex('fmode-mx:' + sessionToken).slice(0, 24);
+}
+
+function _httpJson(url, opts) {
+  // Deno/Node 双兼容 fetch; 10s 超时
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve({ ok: false, err: 'no fetch' }); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 10000) : null;
+      var init = {
+        method: (opts && opts.method) || 'GET',
+        headers: (opts && opts.headers) || {},
+        signal: ctrl ? ctrl.signal : undefined,
+      };
+      if (opts && opts.body !== undefined) init.body = JSON.stringify(opts.body);
+      fetch(url, init)
+        .then(function (res) {
+          return res.text().then(function (txt) {
+            if (t) clearTimeout(t);
+            var data = null;
+            try { data = JSON.parse(txt); } catch (e) { data = { raw: txt }; }
+            resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data });
+          });
+        })
+        .catch(function (e) { if (t) clearTimeout(t); resolve({ ok: false, err: String(e) }); });
+    } catch (e) { resolve({ ok: false, err: String(e) }); }
+  });
+}
+
+// ---- Matrix 客户端 (access_token 方式,Continuwuity v1.18) ----
+function _mx(token) {
+  function call(path, method, body) {
+    var url = MATRIX_HS + path;
+    if (token) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(token);
+    return _httpJson(url, { method: method || 'GET', body: body,
+      headers: body !== undefined ? { 'Content-Type': 'application/json' } : {} });
+  }
+  return {
+    login: function (user, password) {
+      return call('/_matrix/client/v3/login', 'POST', {
+        type: 'm.login.password', identifier: { type: 'm.id.user', user: user }, password: password });
+    },
+    whoami: function () { return call('/_matrix/client/v3/account/whoami'); },
+    joinedRooms: function () { return call('/_matrix/client/v3/joined_rooms'); },
+    joinedMembers: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/joined_members');
+    },
+    sendAdminCmd: function (roomId, cmd) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) +
+        '/send/m.room.message/prov' + Date.now() + Math.floor(Math.random() * 900 + 100), 'PUT',
+        { msgtype: 'm.text', body: cmd });
+    },
+    lastBotMsg: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages?dir=b&limit=3');
+    },
+    createRoom: function (inviteMxid, name) {
+      return call('/_matrix/client/v3/createRoom', 'POST', {
+        invite: [inviteMxid], is_direct: true, preset: 'trusted_private_chat', name: name || undefined });
+    },
+    joinRoom: function (roomId) {
+      return call('/_matrix/client/v3/join/' + encodeURIComponent(roomId), 'POST', {});
+    },
+  };
+}
+
+function _sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
+
+/** admin room 命令: 发送后轮询最新 bot 回复 (机器人回复可能滞后一条) */
+async function _adminCmd(mx, token, cmd) {
+  var before = null;
+  var d0 = await mx.lastBotMsg(ADMIN_ROOM);
+  if (d0.ok && d0.data && d0.data.chunk) {
+    for (var i = 0; i < d0.data.chunk.length; i++) {
+      var ev = d0.data.chunk[i];
+      if (ev.sender === SERVICE_BOT && ev.type === 'm.room.message') { before = ev.content.body; break; }
+    }
+  }
+  var sent = await mx.sendAdminCmd(ADMIN_ROOM, cmd);
+  if (!sent.ok) return { ok: false, err: 'admin cmd 发送失败: ' + JSON.stringify(sent).slice(0, 120) };
+  var last = before;
+  for (var a = 0; a < 5; a++) {
+    await _sleep(1500);
+    var d = await mx.lastBotMsg(ADMIN_ROOM);
+    if (d.ok && d.data && d.data.chunk) {
+      for (var j = 0; j < d.data.chunk.length; j++) {
+        var ev2 = d.data.chunk[j];
+        if (ev2.sender === SERVICE_BOT && ev2.type === 'm.room.message') {
+          if (ev2.content.body !== before) last = ev2.content.body;
+          break;
+        }
+      }
+    }
+    if (last && last !== before && (last.indexOf('```') >= 0 || last.indexOf('|') >= 0)) break;
+  }
+  return { ok: true, reply: last || '' };
+}
+
+/** 确保本地 Matrix 账号存在并返回 access_token。幂等: 已存在则重置为派生密码再签 token。
+ *  v4.1 修复: 参数名 mxAdmin→mx — 原函数体引用 `mx` 而 `_ensureAccount(mxAdmin, …)` 形参叫
+ *  mxAdmin → 新开绑定 (无缓存路径) 必然 ReferenceError "mx is not defined" (HTTP 500 SDK登录失败)。 */
+async function _ensureAccount(mx, adminTok, local, password) {
+  var mx0 = _mx(''); // login 端点不能带他人 access_token, 探测/登录一律用无凭据客户端
+  var mxid = '@' + local + ':' + MATRIX_DOMAIN;
+  // 先探测: 用派生密码登录 (账号已存在且密码一致 → 直接成功)
+  var lg = await mx0.login(local, password);
+  if (lg.ok && lg.data && lg.data.access_token) {
+    return { mxid: mxid, accessToken: lg.data.access_token, deviceId: lg.data.device_id || '', created: false };
+  }
+  // 不存在/密码不符 → admin room create (已存在报错则 reset-password 对齐派生密码)
+  var cr = await _adminCmd(mx, adminTok, '!admin users create ' + local + ' ' + password);
+  var reply = cr.reply || '';
+  if (reply.indexOf('Created') < 0) {
+    // 已存在/不可用/任意报错 → reset-password 对齐派生密码后重试登录
+    var rs = await _adminCmd(mx, adminTok, '!admin users reset-password ' + local + ' ' + password);
+    reply = rs.reply || reply;
+  }
+  var lg2 = await mx0.login(local, password);
+  if (!lg2.ok || !lg2.data || !lg2.data.access_token) {
+    return null; // 账号注册失败
+  }
+  return { mxid: mxid, accessToken: lg2.data.access_token, deviceId: lg2.data.device_id || '', created: true };
+}
+
+// ---- TASK-matrix-v3 P1: FmodeMatrixBinding 绑定表 (user×agent 唯一, roomId 固定) ----
+
+/** 查绑定: {userPointer, agentId 唯一} → 记录或 null (agentId=null 时按 userPointer+agentMxId 空) */
+async function _findBinding(userPointer, agentIdStr) {
+  try {
+    var q = new Parse.Query(BINDING_CLASS);
+    q.equalTo('userPointer', userPointer);
+    q.equalTo('agentId', agentIdStr || '');
+    q.descending('updatedAt');
+    var rec = await q.first({ useMasterKey: true });
+    if (!rec) return null;
+    // 历史遗留多条 (v0.1.x 每次新建/极端并发): 只认最新一条, 其余标 revoked
+    var others = await q.find({ useMasterKey: true });
+    for (var i = 0; i < others.length; i++) {
+      if (others[i].id !== rec.id && others[i].get('status') !== 'revoked') {
+        others[i].set('status', 'revoked');
+        try { await others[i].save(null, { useMasterKey: true }); } catch (e) { /* 幂等尽力 */ }
+      }
+    }
+    return rec;
+  } catch (e) { return null; } // 表不存在/查询失败 → 按无绑定走新建
+}
+
+/** 绑定记录 → 响应凭据 */
+function _bindingPayload(rec) {
+  return {
+    userMxId: rec.get('userMxId'),
+    agentMxId: rec.get('agentMxId') || null,
+    homeserver: rec.get('homeserver') || MATRIX_HS,
+    accessToken: rec.get('accessToken'),
+    deviceId: rec.get('deviceId') || '',
+    roomId: rec.get('roomId') || null,
+    bindingId: rec.id,
+    status: rec.get('status') || 'active',
+    updatedAt: rec.get('updatedAt'),
+  };
+}
+
+/** 读/建 FmodeMatrixBinding 表 (schema 缺失时无法显式建表, Parse 无 schema 约束时首条 save 即建) */
+async function _upsertBinding(user, agentIdStr, cred) {
+  var B = Parse.Object.extend(BINDING_CLASS);
+  var rec = await _findBinding(user, agentIdStr);
+  if (!rec) rec = new B();
+  rec.set('userPointer', user);
+  rec.set('agentId', agentIdStr || '');
+  rec.set('userMxId', cred.userMxId);
+  if (cred.agentMxId) rec.set('agentMxId', cred.agentMxId);
+  rec.set('homeserver', cred.homeserver || MATRIX_HS);
+  if (cred.accessToken) rec.set('accessToken', cred.accessToken);
+  if (cred.deviceId) rec.set('deviceId', cred.deviceId);
+  if (cred.roomId) rec.set('roomId', cred.roomId);
+  rec.set('status', cred.status || 'active');
+  rec.set('updatedAt', new Date());
+  await rec.save(null, { useMasterKey: true });
+  return rec;
+}
+
+async function handler(request, response) {
+  try {
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, '账号注册失败: sessionToken 无效或已过期, 请重新登录 App');
+    var sessionToken = _sessionToken();
+
+    // ---- Agent 定位 (agentId 可传 objectId 或 agentId; 缺省只开通用户身份) ----
+    var agentRec = null;
+    var agentIdParam = params && params.agentId;
+    var agentIdStr = '';
+    if (agentIdParam) {
+      var rec = await (function () {
+        var q1 = new Parse.Query(CLASS_NAME); q1.equalTo('agentId', agentIdParam);
+        return q1.first({ useMasterKey: true }).then(function (r1) {
+          return r1 || new Parse.Query(CLASS_NAME).get(agentIdParam, { useMasterKey: true });
+        });
+      })();
+      if (!rec) return _fail(404, '房间创建失败: Agent 不存在(' + agentIdParam + ')');
+      // ACL 校验: 记录归属人或超管才可开通 (与 fmodeagent-status 同规则, 防越权开通)
+      var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+      var isSuper = false;
+      try {
+        var rq = new Parse.Query(Parse.Role); rq.equalTo('name', SUPER_ROLE);
+        var role = await rq.first({ useMasterKey: true });
+        if (role) {
+          var us = await role.getUsers().query().find({ useMasterKey: true });
+          for (var ui = 0; ui < us.length; ui++) { if (us[ui].id === user.id) { isSuper = true; break; } }
+        }
+      } catch (e) { /* 角色查询失败按非超管处理 */ }
+      var owner = rec.get('user');
+      if (!isSuper && (!owner || owner.id !== user.id)) {
+        return _fail(403, '房间创建失败: 该 Agent 不在你的可见范围内, 无权开通会话');
+      }
+      agentRec = rec;
+      agentIdStr = String(rec.get('agentId') || rec.id);
+    }
+
+    // ---- P1a 幂等: 查绑定表 → active 且 (validate 时探活通过) → 直接返回, 不重新生成 ----
+    var validate = !!(params && params.validate); // App 缓存校验/过期自愈探测时传 validate:1
+    var binding = await _findBinding(user, agentIdStr);
+    // v4.2 P3: 绑定里的 agentMxId 若还是旧语义化账号 (≠ @<objectId小写>) → 不命中缓存,
+    // 走下方重建流程 (新 objectId 账号 + 新 DM 房间 + 绑定表更新)
+    var bindingMxStale = false;
+    if (binding && agentRec) {
+      var expectedAgentMx = '@' + String(agentRec.id).toLowerCase() + ':' + MATRIX_DOMAIN;
+      bindingMxStale = binding.get('agentMxId') !== expectedAgentMx;
+    }
+    if (binding && !bindingMxStale && binding.get('status') === 'active' && binding.get('accessToken')) {
+      var bp = _bindingPayload(binding);
+      if (!validate) {
+        // 非校验请求: 直接命中绑定表返回 (秒进, roomId 固定 → 历史完整)
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // validate: 用绑定 token 探活 whoami → 200 即凭据仍有效 (幂等返回); 失效走下方刷新
+      var mxProbe = _mx(bp.accessToken);
+      var who = await mxProbe.whoami();
+      if (who.ok && who.data && who.data.user_id === bp.userMxId) {
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // M_UNKNOWN_TOKEN/任何失效 → 刷新凭据并更新表 (不换 device/roomId: admin 对齐派生密码后重签)
+    }
+
+    // ---- Matrix: admin 登录 → 确保用户/Agent 账号 ----
+    var mx0 = _mx('');
+    var adm = await mx0.login(ADMIN_USER, ADMIN_PASS);
+    if (!adm.ok || !adm.data || !adm.data.access_token) {
+      return _fail(502, '账号注册失败: Matrix homeserver 管理登录失败(' +
+        ((adm.data && (adm.data.errcode || adm.data.error)) || adm.err || '未知') + ')');
+    }
+    var adminTok = adm.data.access_token;
+    var mx = _mx(adminTok);
+
+    var password = _mxPassword(sessionToken);
+    var userLocal = String(user.id).toLowerCase();
+    var ua = await _ensureAccount(mx, adminTok, userLocal, password);
+    if (!ua) return _fail(502, '账号注册失败: 用户 Matrix 账号(' + userLocal + ')创建/登录失败');
+
+    var agentMxId = null;
+    if (agentRec) {
+      // v4.2 P3 (TASK-matrix-v42 2026-09-09): Agent Matrix 账号一律用 FMODE_AGENT.objectId
+      // —— @<objectId小写>:fmode.cn (objectId 全局唯一, 语义化拼音名不唯一且不利于批量)。
+      // displayname 仍用中文名 (由 Hermes channel 侧/房间名承载, 不改账号 localpart)。
+      // 兼容: FmodeAgent.channels.matrixConfig.userId 若已是 objectId 账号则直接用;
+      // 旧语义化账号 (@xinting 等) 由 provision-all-agents 批量迁移 (新建+绑定表更新+旧号归档)。
+      var agentLocal = String(agentRec.id).toLowerCase();
+      // Agent 账号存在性: admin room users list 探测; 缺失时走 _ensureAccount 自动注册
+      // (Agent 密码 = 与 App 用户同款派生式不适用——Agent 无 sessionToken,
+      //  用 objectId 固定盐派生: sha256("fmode-agent-mx:"+objectId) 前 24 位, 幂等可重置)
+      var ul = await _adminCmd(mx, adminTok, '!admin users list');
+      var found = ((ul.reply || '').indexOf('@' + agentLocal + ':') >= 0);
+      if (!found) {
+        // v4.2: 不再 502 拒绝 — 直接注册 objectId 账号 (通用插件自接入铺路)
+        var agentPass = _sha256Hex('fmode-agent-mx:' + agentRec.id).slice(0, 24);
+        var ag = await _ensureAccount(mx, adminTok, agentLocal, agentPass);
+        if (!ag) {
+          return _fail(502, '账号注册失败: Agent Matrix 账号(@' + agentLocal + ')创建失败');
+        }
+      }
+      agentMxId = '@' + agentLocal + ':' + MATRIX_DOMAIN;
+    }
+
+    // ---- 用户侧长效 token (issue-token, 绑定 device; App 端也可用派生密码自行登录) ----
+    var accessToken = ua.accessToken;
+    var deviceId = ua.deviceId;
+    var it = await _adminCmd(mx, adminTok, '!admin users issue-token ' + userLocal + ' ' + password);
+    var m = (it.reply || '').match(/device `([^`]+)` with access token `([A-Za-z0-9_\-\.]+)`/);
+    if (m) { deviceId = m[1]; accessToken = m[2]; }
+
+    // ---- DM 房间: 绑定表 roomId 优先 (P1b 历史恢复: 房间唯一不重建) → 已有共同房间探测 → createRoom ----
+    var roomId = null;
+    if (agentMxId) {
+      if (binding && binding.get('roomId')
+          // v4.2 P3: 旧绑定房间属于旧语义化账号 — agentMxId 变更后旧 roomId 不复用 (房间重建)
+          && (!binding.get('agentMxId') || binding.get('agentMxId') === agentMxId)) {
+        roomId = binding.get('roomId'); // 绑定表既定房间, 不新建
+      } else {
+        var myTok = accessToken;
+        var mxMe = _mx(myTok);
+        var jr = await mxMe.joinedRooms();
+        if (jr.ok && jr.data && jr.data.joined_rooms) {
+          for (var ri = 0; ri < jr.data.joined_rooms.length; ri++) {
+            var rid = jr.data.joined_rooms[ri];
+            var jm = await mxMe.joinedMembers(rid);
+            if (jm.ok && jm.data && jm.data.joined && jm.data.joined[agentMxId]) { roomId = rid; break; }
+          }
+        }
+        if (!roomId) {
+          var crr = await mxMe.createRoom(agentMxId, (agentRec && agentRec.get('name')) || undefined);
+          if (crr.ok && crr.data && crr.data.room_id) {
+            roomId = crr.data.room_id;
+            // Agent 侧由官方 Hermes channel 自动 accept 邀请 (MATRIX_REQUIRE_MENTION=false DM)
+          } else {
+            return _fail(502, '房间创建失败: createRoom(' + agentMxId + ') ' +
+              ((crr.data && (crr.data.errcode || crr.data.error)) || crr.err || '未知'));
+          }
+        }
+      }
+    }
+
+    // ---- P1a 写表/更新表 (首次建绑定 或 token 过期刷新) ----
+    var cred = {
+      userMxId: ua.mxid, agentMxId: agentMxId, homeserver: MATRIX_HS,
+      accessToken: accessToken, deviceId: deviceId, roomId: roomId, status: 'active',
+    };
+    var saved = await _upsertBinding(user, agentIdStr, cred);
+    return _ok({
+      cached: false,
+      userMxId: ua.mxid,
+      agentMxId: agentMxId,
+      homeserver: MATRIX_HS,
+      accessToken: accessToken,
+      deviceId: deviceId,
+      roomId: roomId,
+      bindingId: saved.id,
+      status: 'active',
+      userCreated: ua.created,
+    });
+  } catch (e) {
+    return _fail(500, 'SDK登录失败: ' + ((e && e.message) || String(e)));
+  }
+}

+ 554 - 0
cloud/functions/fmodeagent-matrix-provision.js

@@ -0,0 +1,554 @@
+// fmodeagent-matrix-provision (TASK-matrix-v2 ①): 用户侧 Matrix 账号自动开通
+// 调用: POST https://server.fmode.cn/api/functions {token, id:<Function.objectId>, params:{agentId?}}
+//
+// 流程 (聊天页进入时调用, 取代"该数字生命的飞马(Matrix)身份正在开通或未接入"笼统态):
+//   1. 校验调用者是注册用户 (sessionToken → Parse _User);
+//   2. 为该用户确保 Matrix 账号存在: mxid=@<用户objectId小写>:fmode.cn,
+//      密码=sha256("fmode-mx:"+sessionToken) 前 24 位 (与 fmode-app matrix-client.ts 派生一致,
+//      使 App 端无需云函数也能确定性登录);
+//      不存在 → admin API 注册 (Continuwuity: admin 账号登录 → admin room "!admin users create");
+//      已存在但密码不符 → reset-password 对齐派生密码 (幂等开通);
+//   3. 确认 Agent 账号存在 (params.agentId → FmodeAgent 记录; mxid=@<agentId去agent-node-前缀>:fmode.cn,
+//      如 @xinting:fmode.cn——TASK-matrix-v2 §4 账号保留, 消息接发全交官方 Hermes channel;
+//      Agent 密码不在此重置——官方 channel 正在使用该账号; 账号缺失报具体错误);
+//   4. 返回 {userMxId, agentMxId, homeserver, accessToken, deviceId, roomId?}
+//      accessToken 用 "!admin users issue-token" 签发 (绑定 device, 长期有效),
+//      并 ensureRoom: 已有共同房间复用, 否则 createRoom(invite agent, is_direct)。
+//
+// 失败返回具体环节: {code:4xx/5xx, mess:"账号注册失败|房间创建失败|SDK登录失败|..."}——
+// App 端按 mess 显示, 不再笼统"开通中"。
+//
+// 凭据策略: admin 密码放本文件常量 (Function 表 code 仅 masterKey 可读, 与 config.nova.json 同级保护);
+//           用户密码为派生值不落库; issue-token 结果只在响应里出现一次。
+// 版本 v0.3.0 · 2026-09-09 · TASK-matrix-v42 P3 (Agent 账号改 objectId 唯一体系)
+// ===== v0.3.0 (TASK-matrix-v42 P3) 变更说明 =====
+// Agent Matrix 账号由语义化拼音名 (@xinting:fmode.cn) 改为 FMODE_AGENT.objectId:
+//   @<objectId小写>:fmode.cn — objectId 全局唯一, 语义名不唯一且不利于批量接入。
+//   1. mxid 计算: agentLocal = agentRec.id 小写 (不再去 agent-node- 前缀)
+//   2. 账号缺失不再 502 拒绝 → _ensureAccount 自动注册 (密码 = sha256("fmode-agent-mx:"+objectId) 前 24,
+//      固定盐派生幂等可重置 — Agent 无 sessionToken, 不与用户派生式混用)
+//   3. DM 房间重建: 新 agentMxId → 绑定表 agentMxId/roomId 更新 (旧 roomId 不复用——旧账号房间);
+//      旧语义化账号归档不删 (Hermes channel 迁移完成前并存)
+//   4. 批量接入: scripts/provision-all-agents.sh 遍历 FMODE_AGENT 全表逐个 provision
+// 版本 v0.2.1 · 2026-09-08 · TASK-matrix-v41 (修复 _ensureAccount 形参 mxAdmin→mx ReferenceError)
+//
+// ===== v0.2.1 (TASK-matrix-v41) 变更说明 =====
+// _ensureAccount 形参名为 mxAdmin 而函数体引用 `mx` (第 224/228 行 _adminCmd(mx, …)) —
+// 新开绑定 (无 FmodeMatrixBinding 记录路径, agent-node-tuye/agent-node 首开即命中) 必然
+// ReferenceError "mx is not defined" → HTTP 500 "SDK登录失败: mx is not defined"。
+// 修复: 形参更名 mxAdmin→mx (与函数体一致); 线上 Function 表同改 (2026-09-08 实测三 agent 全通)。
+//
+// ===== v0.2.0 (TASK-matrix-v3 P1) 变更说明 (v0.1.5 流程保留为建绑定路径) =====
+// 根因: 每次进入聊天页都重新 provision (issue-token 新 device/新 token), 第二次进入
+//       慢 + 历史/通话随新 device/新房间丢失。修复: 绑定表 FmodeMatrixBinding 为唯一真相:
+//   1. 查表: {userPointer, agentId} 唯一, status=active → 直接返回表中凭据 (不注册/不签发/不建房),
+//      幂等 (连开两次聊天页第二次秒进); roomId 固定 → SDK 进入同房间自然拉到全部时间线 (P3)。
+//   2. 建绑定: 无记录才走 v0.1.5 注册/签发/ensureRoom 流程并写表 (bindingId=表 objectId)。
+//   3. 过期自愈: App 端 whoami 报 M_UNKNOWN_TOKEN / 本函数按 ?validate=1 探活失败 →
+//      走 admin reset-password+issue-token 刷新凭据并更新表 (status 保持 active)。
+//   4. 响应新增 {bindingId, cached:true|false, status:'active'}; 语义: cached=true 即绑定表命中。
+var CLASS_NAME = 'FmodeAgent';
+var BINDING_CLASS = 'FmodeMatrixBinding'; // TASK-matrix-v3 P1: Channel 绑定表 (user×agent 唯一)
+var MATRIX_HS = 'https://matrix.fmode.cn';
+var MATRIX_DOMAIN = 'fmode.cn';
+var ADMIN_USER = 'admin';
+var ADMIN_PASS = 'Fmode@2026';
+var SERVICE_BOT = '@conduit:fmode.cn';
+var ADMIN_ROOM = '!UcLbdbuAWs9FZkrFIjPWBAe4jfNM2r_sj0-A0vJQgOQ'; // admin+conduit 专用, 严禁加人
+
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
+
+/**
+ * 容器归属人与调用者是否属于**同一家公司**(2026-11-12 新增)。
+ *
+ * 用户裁决:「同公司的人,所有容器大家都可以一起使用」。
+ *
+ * ## 判定链路
+ *
+ * ```
+ * FmodeAgent.user → _User.company → Company.objectId
+ * 调用者 _User.company → Company.objectId
+ * 两者相等 = 同公司
+ * ```
+ *
+ * ## 安全边界(必须保留)
+ *
+ * 跨公司一律返回 false → 上层走 403。这是多租户隔离的底线。
+ *
+ * ## 保守策略
+ *
+ * 任何一环取不到 company(字段缺失 / 指针失效 / 查询异常)都返回 **false**。
+ * 宁可拒绝,也不因为数据不全而误放行 —— 越权开通比拒绝开通严重得多。
+ *
+ * @param {Parse.Object|null} owner FmodeAgent.user 的 Pointer(可能为空)
+ * @param {Parse.Object} user       当前调用者
+ * @returns {Promise<boolean>}
+ */
+async function _isSameCompany(owner, user) {
+  try {
+    if (!owner || !owner.id || !user || !user.id) return false;
+    var userCompany = user.get ? user.get('company') : null;
+    var userCompanyId = userCompany && (userCompany.id || userCompany.objectId);
+    if (!userCompanyId) return false;
+    var ownerCompany = owner.get ? owner.get('company') : null;
+    if (!ownerCompany) {
+      /* Pointer 上没带 company → 按 id 再查一次(带 masterKey) */
+      var ownerFull = await new Parse.Query(Parse.User).get(owner.id, { useMasterKey: true });
+      ownerCompany = ownerFull ? ownerFull.get('company') : null;
+    }
+    var ownerCompanyId = ownerCompany && (ownerCompany.id || ownerCompany.objectId);
+    if (!ownerCompanyId) return false;
+    return String(ownerCompanyId) === String(userCompanyId);
+  } catch (e) {
+    return false;
+  }
+}
+
+
+function _sessionToken() {
+  /*
+   * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
+   *    X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
+   *    request.body.token 都拿不到 → 恒返回 null → 所有请求 401
+   *    (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
+   *    这里加上请求头来源(只增不改,旧调用方式保持有效)。
+   */
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request && request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+}
+
+function _sha256Hex(s) {
+  // 纯 JS SHA-256 (executor 作用域无 crypto/node modules; 输入为 ASCII sessionToken)
+  function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
+  var K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
+           0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
+           0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
+           0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
+           0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
+           0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
+           0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
+           0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
+  var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
+  var bytes = [];
+  for (var i = 0; i < s.length; i++) {
+    var c = s.charCodeAt(i);
+    if (c < 128) bytes.push(c);
+    else if (c < 2048) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); }
+    else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); }
+  }
+  var bitLen = bytes.length * 8;
+  bytes.push(0x80);
+  while (bytes.length % 64 !== 56) bytes.push(0);
+  for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
+  for (var b = 0; b < bytes.length; b += 64) {
+    var w = new Array(64);
+    for (var t = 0; t < 16; t++) {
+      w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4 + 1] << 16) | (bytes[b + t*4 + 2] << 8) | bytes[b + t*4 + 3];
+    }
+    for (var t2 = 16; t2 < 64; t2++) {
+      var s0 = rr(w[t2-15], 7) ^ rr(w[t2-15], 18) ^ (w[t2-15] >>> 3);
+      var s1 = rr(w[t2-2], 17) ^ rr(w[t2-2], 19) ^ (w[t2-2] >>> 10);
+      w[t2] = (w[t2-16] + s0 + w[t2-7] + s1) | 0;
+    }
+    var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
+    for (var t3 = 0; t3 < 64; t3++) {
+      var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
+      var ch = (e & f) ^ (~e & g);
+      var temp1 = (h + S1 + ch + K[t3] + w[t3]) | 0;
+      var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
+      var maj = (a & bb) ^ (a & c) ^ (bb & c);
+      var temp2 = (S0 + maj) | 0;
+      h = g; g = f; f = e; e = (d + temp1) | 0;
+      d = c; c = bb; bb = a; a = (temp1 + temp2) | 0;
+    }
+    H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
+    H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
+  }
+  var out = '';
+  for (var k = 0; k < 8; k++) out += ('00000000' + (H[k] >>> 0).toString(16)).slice(-8);
+  return out;
+}
+
+function _mxPassword(sessionToken) {
+  return _sha256Hex('fmode-mx:' + sessionToken).slice(0, 24);
+}
+
+function _httpJson(url, opts) {
+  // Deno/Node 双兼容 fetch; 10s 超时
+  return new Promise(function (resolve) {
+    try {
+      if (typeof fetch !== 'function') { resolve({ ok: false, err: 'no fetch' }); return; }
+      var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+      var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 10000) : null;
+      var init = {
+        method: (opts && opts.method) || 'GET',
+        headers: (opts && opts.headers) || {},
+        signal: ctrl ? ctrl.signal : undefined,
+      };
+      if (opts && opts.body !== undefined) init.body = JSON.stringify(opts.body);
+      fetch(url, init)
+        .then(function (res) {
+          return res.text().then(function (txt) {
+            if (t) clearTimeout(t);
+            var data = null;
+            try { data = JSON.parse(txt); } catch (e) { data = { raw: txt }; }
+            resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data });
+          });
+        })
+        .catch(function (e) { if (t) clearTimeout(t); resolve({ ok: false, err: String(e) }); });
+    } catch (e) { resolve({ ok: false, err: String(e) }); }
+  });
+}
+
+// ---- Matrix 客户端 (access_token 方式,Continuwuity v1.18) ----
+function _mx(token) {
+  function call(path, method, body) {
+    var url = MATRIX_HS + path;
+    if (token) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(token);
+    return _httpJson(url, { method: method || 'GET', body: body,
+      headers: body !== undefined ? { 'Content-Type': 'application/json' } : {} });
+  }
+  return {
+    login: function (user, password) {
+      return call('/_matrix/client/v3/login', 'POST', {
+        type: 'm.login.password', identifier: { type: 'm.id.user', user: user }, password: password });
+    },
+    whoami: function () { return call('/_matrix/client/v3/account/whoami'); },
+    joinedRooms: function () { return call('/_matrix/client/v3/joined_rooms'); },
+    joinedMembers: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/joined_members');
+    },
+    sendAdminCmd: function (roomId, cmd) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) +
+        '/send/m.room.message/prov' + Date.now() + Math.floor(Math.random() * 900 + 100), 'PUT',
+        { msgtype: 'm.text', body: cmd });
+    },
+    lastBotMsg: function (roomId) {
+      return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages?dir=b&limit=3');
+    },
+    createRoom: function (inviteMxid, name) {
+      return call('/_matrix/client/v3/createRoom', 'POST', {
+        invite: [inviteMxid], is_direct: true, preset: 'trusted_private_chat', name: name || undefined });
+    },
+    joinRoom: function (roomId) {
+      return call('/_matrix/client/v3/join/' + encodeURIComponent(roomId), 'POST', {});
+    },
+  };
+}
+
+function _sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
+
+/** admin room 命令: 发送后轮询最新 bot 回复 (机器人回复可能滞后一条) */
+async function _adminCmd(mx, token, cmd) {
+  var before = null;
+  var d0 = await mx.lastBotMsg(ADMIN_ROOM);
+  if (d0.ok && d0.data && d0.data.chunk) {
+    for (var i = 0; i < d0.data.chunk.length; i++) {
+      var ev = d0.data.chunk[i];
+      if (ev.sender === SERVICE_BOT && ev.type === 'm.room.message') { before = ev.content.body; break; }
+    }
+  }
+  var sent = await mx.sendAdminCmd(ADMIN_ROOM, cmd);
+  if (!sent.ok) return { ok: false, err: 'admin cmd 发送失败: ' + JSON.stringify(sent).slice(0, 120) };
+  var last = before;
+  for (var a = 0; a < 5; a++) {
+    await _sleep(1500);
+    var d = await mx.lastBotMsg(ADMIN_ROOM);
+    if (d.ok && d.data && d.data.chunk) {
+      for (var j = 0; j < d.data.chunk.length; j++) {
+        var ev2 = d.data.chunk[j];
+        if (ev2.sender === SERVICE_BOT && ev2.type === 'm.room.message') {
+          if (ev2.content.body !== before) last = ev2.content.body;
+          break;
+        }
+      }
+    }
+    if (last && last !== before && (last.indexOf('```') >= 0 || last.indexOf('|') >= 0)) break;
+  }
+  return { ok: true, reply: last || '' };
+}
+
+/** 确保本地 Matrix 账号存在并返回 access_token。幂等: 已存在则重置为派生密码再签 token。
+ *  v4.1 修复: 参数名 mxAdmin→mx — 原函数体引用 `mx` 而 `_ensureAccount(mxAdmin, …)` 形参叫
+ *  mxAdmin → 新开绑定 (无缓存路径) 必然 ReferenceError "mx is not defined" (HTTP 500 SDK登录失败)。 */
+async function _ensureAccount(mx, adminTok, local, password) {
+  var mx0 = _mx(''); // login 端点不能带他人 access_token, 探测/登录一律用无凭据客户端
+  var mxid = '@' + local + ':' + MATRIX_DOMAIN;
+  // 先探测: 用派生密码登录 (账号已存在且密码一致 → 直接成功)
+  var lg = await mx0.login(local, password);
+  if (lg.ok && lg.data && lg.data.access_token) {
+    return { mxid: mxid, accessToken: lg.data.access_token, deviceId: lg.data.device_id || '', created: false };
+  }
+  // 不存在/密码不符 → admin room create (已存在报错则 reset-password 对齐派生密码)
+  var cr = await _adminCmd(mx, adminTok, '!admin users create ' + local + ' ' + password);
+  var reply = cr.reply || '';
+  if (reply.indexOf('Created') < 0) {
+    // 已存在/不可用/任意报错 → reset-password 对齐派生密码后重试登录
+    var rs = await _adminCmd(mx, adminTok, '!admin users reset-password ' + local + ' ' + password);
+    reply = rs.reply || reply;
+  }
+  var lg2 = await mx0.login(local, password);
+  if (!lg2.ok || !lg2.data || !lg2.data.access_token) {
+    return null; // 账号注册失败
+  }
+  return { mxid: mxid, accessToken: lg2.data.access_token, deviceId: lg2.data.device_id || '', created: true };
+}
+
+// ---- TASK-matrix-v3 P1: FmodeMatrixBinding 绑定表 (user×agent 唯一, roomId 固定) ----
+
+/** 查绑定: {userPointer, agentId 唯一} → 记录或 null (agentId=null 时按 userPointer+agentMxId 空) */
+async function _findBinding(userPointer, agentIdStr) {
+  try {
+    var q = new Parse.Query(BINDING_CLASS);
+    q.equalTo('userPointer', userPointer);
+    q.equalTo('agentId', agentIdStr || '');
+    q.descending('updatedAt');
+    var rec = await q.first({ useMasterKey: true });
+    if (!rec) return null;
+    // 历史遗留多条 (v0.1.x 每次新建/极端并发): 只认最新一条, 其余标 revoked
+    var others = await q.find({ useMasterKey: true });
+    for (var i = 0; i < others.length; i++) {
+      if (others[i].id !== rec.id && others[i].get('status') !== 'revoked') {
+        others[i].set('status', 'revoked');
+        try { await others[i].save(null, { useMasterKey: true }); } catch (e) { /* 幂等尽力 */ }
+      }
+    }
+    return rec;
+  } catch (e) { return null; } // 表不存在/查询失败 → 按无绑定走新建
+}
+
+/** 绑定记录 → 响应凭据 */
+function _bindingPayload(rec) {
+  return {
+    userMxId: rec.get('userMxId'),
+    agentMxId: rec.get('agentMxId') || null,
+    homeserver: rec.get('homeserver') || MATRIX_HS,
+    accessToken: rec.get('accessToken'),
+    deviceId: rec.get('deviceId') || '',
+    roomId: rec.get('roomId') || null,
+    bindingId: rec.id,
+    status: rec.get('status') || 'active',
+    updatedAt: rec.get('updatedAt'),
+  };
+}
+
+/** 读/建 FmodeMatrixBinding 表 (schema 缺失时无法显式建表, Parse 无 schema 约束时首条 save 即建) */
+async function _upsertBinding(user, agentIdStr, cred) {
+  var B = Parse.Object.extend(BINDING_CLASS);
+  var rec = await _findBinding(user, agentIdStr);
+  if (!rec) rec = new B();
+  rec.set('userPointer', user);
+  rec.set('agentId', agentIdStr || '');
+  rec.set('userMxId', cred.userMxId);
+  if (cred.agentMxId) rec.set('agentMxId', cred.agentMxId);
+  rec.set('homeserver', cred.homeserver || MATRIX_HS);
+  if (cred.accessToken) rec.set('accessToken', cred.accessToken);
+  if (cred.deviceId) rec.set('deviceId', cred.deviceId);
+  if (cred.roomId) rec.set('roomId', cred.roomId);
+  rec.set('status', cred.status || 'active');
+  rec.set('updatedAt', new Date());
+  await rec.save(null, { useMasterKey: true });
+  return rec;
+}
+
+async function handler(request, response) {
+  try {
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, '账号注册失败: sessionToken 无效或已过期, 请重新登录 App');
+    var sessionToken = _sessionToken();
+
+    // ---- Agent 定位 (agentId 可传 objectId 或 agentId; 缺省只开通用户身份) ----
+    var agentRec = null;
+    var agentIdParam = params && params.agentId;
+    var agentIdStr = '';
+    if (agentIdParam) {
+      var rec = await (function () {
+        var q1 = new Parse.Query(CLASS_NAME); q1.equalTo('agentId', agentIdParam);
+        return q1.first({ useMasterKey: true }).then(function (r1) {
+          return r1 || new Parse.Query(CLASS_NAME).get(agentIdParam, { useMasterKey: true });
+        });
+      })();
+      if (!rec) return _fail(404, '房间创建失败: Agent 不存在(' + agentIdParam + ')');
+      // ACL 校验: 记录归属人或超管才可开通 (与 fmodeagent-status 同规则, 防越权开通)
+      var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+      var isSuper = false;
+      try {
+        var rq = new Parse.Query(Parse.Role); rq.equalTo('name', SUPER_ROLE);
+        var role = await rq.first({ useMasterKey: true });
+        if (role) {
+          var us = await role.getUsers().query().find({ useMasterKey: true });
+          for (var ui = 0; ui < us.length; ui++) { if (us[ui].id === user.id) { isSuper = true; break; } }
+        }
+      } catch (e) { /* 角色查询失败按非超管处理 */ }
+      /*
+       * ACL 校验(2026-11-12 变更:同公司成员可共用)。
+       *
+       * ## 变更背景
+       *
+       * 用户裁决:「只要是从公司的人,所有的容器大家是可以一起使用的,
+       * 没有说容器归属个人这种东西。」
+       *
+       * 原规则只允许「记录归属人」或「超管」开通 —— 同公司同事点开容器直接 403,
+       * 而 `xlosi3m46q` 的 `scope:'company'` 又能查到它(看得见、进不去)。
+       *
+       * ## 新规则
+       *
+       * | 调用者 | 结果 |
+       * |---|---|
+       * | 超管 | ✅ 允许 |
+       * | 记录归属人 | ✅ 允许 |
+       * | **同 company 的其他成员** | ✅ **允许(本次放开)** |
+       * | 其他 company 的人 | ❌ 403(多租户隔离,必须保留) |
+       *
+       * 🔴 跨公司隔离是安全底线:容器归属公司取自 `agent.user.company`,
+       *    与调用者自己的 `user.company` 比对,不一致一律拒绝。
+       */
+      var owner = rec.get('user');
+      var isOwner = !!owner && owner.id === user.id;
+      var sameCompany = isOwner ? true : await _isSameCompany(owner, user);
+      if (!isSuper && !isOwner && !sameCompany) {
+        return _fail(403, '房间创建失败: 该 Agent 不属于你所在的公司, 无权开通会话');
+      }
+      agentRec = rec;
+      agentIdStr = String(rec.get('agentId') || rec.id);
+    }
+
+    // ---- P1a 幂等: 查绑定表 → active 且 (validate 时探活通过) → 直接返回, 不重新生成 ----
+    var validate = !!(params && params.validate); // App 缓存校验/过期自愈探测时传 validate:1
+    var binding = await _findBinding(user, agentIdStr);
+    // v4.2 P3: 绑定里的 agentMxId 若还是旧语义化账号 (≠ @<objectId小写>) → 不命中缓存,
+    // 走下方重建流程 (新 objectId 账号 + 新 DM 房间 + 绑定表更新)
+    var bindingMxStale = false;
+    if (binding && agentRec) {
+      var expectedAgentMx = '@' + String(agentRec.id).toLowerCase() + ':' + MATRIX_DOMAIN;
+      bindingMxStale = binding.get('agentMxId') !== expectedAgentMx;
+    }
+    if (binding && !bindingMxStale && binding.get('status') === 'active' && binding.get('accessToken')) {
+      var bp = _bindingPayload(binding);
+      if (!validate) {
+        // 非校验请求: 直接命中绑定表返回 (秒进, roomId 固定 → 历史完整)
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // validate: 用绑定 token 探活 whoami → 200 即凭据仍有效 (幂等返回); 失效走下方刷新
+      var mxProbe = _mx(bp.accessToken);
+      var who = await mxProbe.whoami();
+      if (who.ok && who.data && who.data.user_id === bp.userMxId) {
+        return _ok(Object.assign({ cached: true }, bp));
+      }
+      // M_UNKNOWN_TOKEN/任何失效 → 刷新凭据并更新表 (不换 device/roomId: admin 对齐派生密码后重签)
+    }
+
+    // ---- Matrix: admin 登录 → 确保用户/Agent 账号 ----
+    var mx0 = _mx('');
+    var adm = await mx0.login(ADMIN_USER, ADMIN_PASS);
+    if (!adm.ok || !adm.data || !adm.data.access_token) {
+      return _fail(502, '账号注册失败: Matrix homeserver 管理登录失败(' +
+        ((adm.data && (adm.data.errcode || adm.data.error)) || adm.err || '未知') + ')');
+    }
+    var adminTok = adm.data.access_token;
+    var mx = _mx(adminTok);
+
+    var password = _mxPassword(sessionToken);
+    var userLocal = String(user.id).toLowerCase();
+    var ua = await _ensureAccount(mx, adminTok, userLocal, password);
+    if (!ua) return _fail(502, '账号注册失败: 用户 Matrix 账号(' + userLocal + ')创建/登录失败');
+
+    var agentMxId = null;
+    if (agentRec) {
+      // v4.2 P3 (TASK-matrix-v42 2026-09-09): Agent Matrix 账号一律用 FMODE_AGENT.objectId
+      // —— @<objectId小写>:fmode.cn (objectId 全局唯一, 语义化拼音名不唯一且不利于批量)。
+      // displayname 仍用中文名 (由 Hermes channel 侧/房间名承载, 不改账号 localpart)。
+      // 兼容: FmodeAgent.channels.matrixConfig.userId 若已是 objectId 账号则直接用;
+      // 旧语义化账号 (@xinting 等) 由 provision-all-agents 批量迁移 (新建+绑定表更新+旧号归档)。
+      var agentLocal = String(agentRec.id).toLowerCase();
+      // Agent 账号存在性: admin room users list 探测; 缺失时走 _ensureAccount 自动注册
+      // (Agent 密码 = 与 App 用户同款派生式不适用——Agent 无 sessionToken,
+      //  用 objectId 固定盐派生: sha256("fmode-agent-mx:"+objectId) 前 24 位, 幂等可重置)
+      var ul = await _adminCmd(mx, adminTok, '!admin users list');
+      var found = ((ul.reply || '').indexOf('@' + agentLocal + ':') >= 0);
+      if (!found) {
+        // v4.2: 不再 502 拒绝 — 直接注册 objectId 账号 (通用插件自接入铺路)
+        var agentPass = _sha256Hex('fmode-agent-mx:' + agentRec.id).slice(0, 24);
+        var ag = await _ensureAccount(mx, adminTok, agentLocal, agentPass);
+        if (!ag) {
+          return _fail(502, '账号注册失败: Agent Matrix 账号(@' + agentLocal + ')创建失败');
+        }
+      }
+      agentMxId = '@' + agentLocal + ':' + MATRIX_DOMAIN;
+    }
+
+    // ---- 用户侧长效 token (issue-token, 绑定 device; App 端也可用派生密码自行登录) ----
+    var accessToken = ua.accessToken;
+    var deviceId = ua.deviceId;
+    var it = await _adminCmd(mx, adminTok, '!admin users issue-token ' + userLocal + ' ' + password);
+    var m = (it.reply || '').match(/device `([^`]+)` with access token `([A-Za-z0-9_\-\.]+)`/);
+    if (m) { deviceId = m[1]; accessToken = m[2]; }
+
+    // ---- DM 房间: 绑定表 roomId 优先 (P1b 历史恢复: 房间唯一不重建) → 已有共同房间探测 → createRoom ----
+    var roomId = null;
+    if (agentMxId) {
+      if (binding && binding.get('roomId')
+          // v4.2 P3: 旧绑定房间属于旧语义化账号 — agentMxId 变更后旧 roomId 不复用 (房间重建)
+          && (!binding.get('agentMxId') || binding.get('agentMxId') === agentMxId)) {
+        roomId = binding.get('roomId'); // 绑定表既定房间, 不新建
+      } else {
+        var myTok = accessToken;
+        var mxMe = _mx(myTok);
+        var jr = await mxMe.joinedRooms();
+        if (jr.ok && jr.data && jr.data.joined_rooms) {
+          for (var ri = 0; ri < jr.data.joined_rooms.length; ri++) {
+            var rid = jr.data.joined_rooms[ri];
+            var jm = await mxMe.joinedMembers(rid);
+            if (jm.ok && jm.data && jm.data.joined && jm.data.joined[agentMxId]) { roomId = rid; break; }
+          }
+        }
+        if (!roomId) {
+          var crr = await mxMe.createRoom(agentMxId, (agentRec && agentRec.get('name')) || undefined);
+          if (crr.ok && crr.data && crr.data.room_id) {
+            roomId = crr.data.room_id;
+            // Agent 侧由官方 Hermes channel 自动 accept 邀请 (MATRIX_REQUIRE_MENTION=false DM)
+          } else {
+            return _fail(502, '房间创建失败: createRoom(' + agentMxId + ') ' +
+              ((crr.data && (crr.data.errcode || crr.data.error)) || crr.err || '未知'));
+          }
+        }
+      }
+    }
+
+    // ---- P1a 写表/更新表 (首次建绑定 或 token 过期刷新) ----
+    var cred = {
+      userMxId: ua.mxid, agentMxId: agentMxId, homeserver: MATRIX_HS,
+      accessToken: accessToken, deviceId: deviceId, roomId: roomId, status: 'active',
+    };
+    var saved = await _upsertBinding(user, agentIdStr, cred);
+    return _ok({
+      cached: false,
+      userMxId: ua.mxid,
+      agentMxId: agentMxId,
+      homeserver: MATRIX_HS,
+      accessToken: accessToken,
+      deviceId: deviceId,
+      roomId: roomId,
+      bindingId: saved.id,
+      status: 'active',
+      userCreated: ua.created,
+    });
+  } catch (e) {
+    return _fail(500, 'SDK登录失败: ' + ((e && e.message) || String(e)));
+  }
+}

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

@@ -7812,3 +7812,73 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 1. **容器说我错了,它是对的**:我根据 `cat -A` 截断输出判断 homeserver 为空,容器指出 `.env` 里值存在。撤回。
 2. **我的验证方法错了两次**:用 `/messages` 查 `m.typing`(ephemeral 事件不进 timeline);以为「横线」是 `<hr>`(实际是 `~~删除线~~`)。
 3. **用户两次纠正我的设计**:90 秒兜底 → 保持到会话结束;超时不该改状态。
+
+## 2026-11-12 · 多用户共用容器(权限放开 + 准入打通)
+
+**目标**:同公司成员可共用同一容器(用户原话:「没有说容器归属个人这种东西」)
+
+### 一、定位过程(逐层排查)
+
+| 层 | 现象 | 结论 |
+|---|---|---|
+| 1 | 同事点「新建对话」选容器 → 403 | provision 只允许主人/超管 |
+| 2 | 放开权限后,能进会话但**不发消息** | 消息被收,回复没有 |
+| 3 | App 里看到 👀/✅,误以为容器收到了 | **实为 App 用容器身份登录**,是用户自己点的 emoji |
+| 4 | 日志铁证 | `WARNING Unauthorized user (ignored)` |
+| 5 | 加白名单 + 重启,仍不确定是否生效 | **`.env` 的白名单没加载进进程**,真正生效的是 `ALLOW_ALL` |
+
+### 二、平台侧改动
+
+**`RP5Ht55NeX` provision v0.1.5 → v0.1.6(已上线)**
+
+```diff
+- if (!isSuper && (!owner || owner.id !== user.id)) {
+-   return _fail(403, '...无权开通会话');
+- }
++ var isOwner = !!owner && owner.id === user.id;
++ var sameCompany = isOwner ? true : await _isSameCompany(owner, user);
++ if (!isSuper && !isOwner && !sameCompany) {
++   return _fail(403, '...该 Agent 不属于你所在的公司, 无权开通会话');
++ }
+```
+
+- 判定链路:`FmodeAgent.user.company` == `调用者.company`
+- 保守策略:任何一环取不到 company → false(宁可拒绝,不误放行)
+- 实测:本人 200 / 跨公司 11 个全部 403 / 同公司同事 200
+
+**部署注意**:线上源码与本地备份**不一致**(线上有 2026-10-31 的修复)。
+必须**先拉取线上源码再改**,否则会覆盖别人的改动。
+`deploy-cloud-fn.mjs` 新增 `--only=` 参数避免批量误部署。
+
+### 三、容器侧改动(lj-test)
+
+| 项 | 改动 |
+|---|---|
+| 准入 | `GATEWAY_ALLOW_ALL_USERS=true`(`.env` 第 420 行) |
+| 白名单 | 追加 `@pnfapmsbhs`(但**未加载进进程**,实际靠 ALLOW_ALL) |
+
+### 四、App 侧改动
+
+| 项 | 改动 |
+|---|---|
+| 403 错误提示 | 改成「这个容器暂时无法使用(平台侧授权未开放)」 |
+| 部署脚本 | 新增 `--only=` 参数 |
+
+### 五、lj 容器对齐
+
+| 项 | lj 改前 | 改后 | 与 lj-test 一致 |
+|---|---|---|---|
+| `heartbeatAt` | ❌ 缺 | ✅ | ✅ |
+| `usage` | ❌ 缺 | ✅ 276万tokens/947调用/83会话 | ✅ |
+| 心跳间隔 | 30 秒 | 3600s + 900s 抖动 | ✅ |
+| cron | 每 2 分钟 | `enabled=false` | ✅ |
+| 实测 | — | 10 分钟 0 次上报 | ✅ |
+
+### 六、教训
+
+1. **reaction 的 sender 不能当证据**:App 用容器身份登录,用户点的 emoji 会显示成容器发的。
+   要看 **target 和时机** —— 容器真正的反应紧跟 `👀`,且不同秒。
+2. **`.env` 改了不等于生效**:`os.environ` 在启动时读一次,且某些行可能根本没被加载。
+   必须回读 `/proc/<pid>/environ`。
+3. **非空白名单优先于 ALLOW_ALL**:这与直觉相反,配错会导致「以为放开了其实没放开」。
+4. **线上代码可能与本地备份不一致**:部署前必须先拉线上版本核对。

+ 84 - 0
docs/03-interface/README-容器接入文档索引.md

@@ -0,0 +1,84 @@
+# 容器接入文档索引(2026-11-12 梳理)
+
+> **⚠️ 先看这里**:容器接入相关文档一度分散在 20+ 份里,内容重叠、部分过期。
+> 本文给出**唯一权威清单**,避免看错版本。
+
+---
+
+## 一、三份必读(其他都可以不看)
+
+| # | 文档 | 用途 | 谁看 |
+|---|---|---|---|
+| **1** | **`容器接入标准流程-SOP-v3-20261112.md`** | **操作单**:体检 → 修改 → 自审 → 复验 → 交付 | **人**(照着做) |
+| **2** | **`提示词-容器自我优化-v2-20261111.md`** | **发给容器的提示词**,7 项自查 | **容器**(直接发) |
+| **3** | **`多用户共用容器-完整规范-20261112.md`** | 同公司多人共用容器的完整链路 | 人(特殊场景) |
+
+---
+
+## 二、权威源代码位置
+
+| 内容 | 位置 |
+|---|---|
+| provision 云函数 | `cloud/functions/fmodeagent-matrix-provision.js`(v0.1.6) |
+| 状态上报云函数 | `cloud/functions/fmodeagent-status.js`(`xlosi3m46q`) |
+| 容器侧实测基线 | `docs/03-interface/容器侧标准包-基线采集-20261111.md` |
+
+---
+
+## 三、已验证的容器(2026-11-12)
+
+| 容器 | agentId | 状态 | 说明 |
+|---|---|---|---|
+| **lj-test** | `agent-node-lj-test` | ✅ **标准参考** | 全部字段达标,DNS 已修复(21ms) |
+| **lj** | `agent-lj` | ✅ **已对齐** | 所有字段与 lj-test 一致 |
+
+### 两者实测对比(2026-11-12)
+
+| 字段 | lj-test | lj | 一致 |
+|---|---|---|---|
+| `heartbeatAt` | ✅ | ✅ | ✅ |
+| `usage` | ✅ | ✅ | ✅ |
+| `host` / `channels` / `balance` | ✅ | ✅ | ✅ |
+| `extra.currentSessions` | ✅ 6 字段 | ✅ 9 字段(更全) | ✅ |
+| 心跳间隔 | 3600s+900s | 3600s+900s | ✅ |
+| ✅ reaction | ✅ | ✅ | ✅ |
+| DNS 解析 | 21ms(已修) | 62ms(本来就快) | ✅ |
+
+---
+
+## 四、已废弃文档(**不要再看**)
+
+| 文档 | 废弃原因 | 替代 |
+|---|---|---|
+| `容器接入规范提示词-20261111.md` | 早期版本,被 SOP v3 + 提示词 v2 取代 | 见 §一 |
+| `容器侧改造-图文合并处理-20261110.md` | 图文合并插件已就位,待启用 | §容器接入指南 |
+| `容器侧改造-文件与成果统一上云-20261109.md` | 已被 file-hub 方案取代 | `2026-11-10-修改留痕-文件台账与按会话隔离.md` |
+| `reporter字段规范-20261020.md` | 字段已扩充 | `容器侧标准包-基线采集-20261111.md` |
+| `待问容器-*` / `待问Agent-*` 系列 | 一次性问答记录,已归档 | 容器取证-*.md |
+| `多容器协作-*` 系列 | 群聊方案已变 | 见群聊相关文档 |
+
+---
+
+## 五、当前有待确认的事项
+
+| # | 事项 | 状态 | 归属 |
+|---|---|---|---|
+| 1 | **图文合并插件未启用** | ⏳ `plugin-media-text-merge` 已就位但不在 `enabled` | 容器侧(需重启) |
+| 2 | **图片回复格式** | ⏳ 容器把工具过程行当正文发 | 容器侧 |
+| 3 | **`RES_OPTIONS` 软覆盖** | ⚠️ 写的是 `${RES_OPTIONS:-...}`,会被外部值顶掉 | 容器侧(可选) |
+| 4 | **白名单 vs ALLOW_ALL 行为不确定** | ⚠️ `.env` 有白名单但没加载进进程 | 见多用户规范 §3.4 |
+| 5 | **`usageSessions` 残留数据** | ⚠️ agent-lj 曾误用该字段,遗留 2 条 | 可忽略(App 不读) |
+| 6 | 群聊 NULL thread | 已评估:**不改**(风险大于收益) | 已闭环 |
+
+---
+
+## 六、文档维护约定
+
+- **新增容器接入相关文档**前,先看本索引,避免再产生重复
+- **权威文档只有 3 份**(§一),其余一律视为历史资料
+- 每次实测后,把新结论补进对应的权威文档,**不要新建文档**
+- 本索引随权威文档变更同步更新
+
+---
+
+*最后更新:2026-11-12*

+ 291 - 0
docs/03-interface/多用户共用容器-完整规范-20261112.md

@@ -0,0 +1,291 @@
+# 多用户共用容器 · 完整接入规范(2026-11-12 实测)
+
+| 字段 | 值 |
+|---|---|
+| 标准参考 | `agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`) |
+| 验证对象 | `agent-lj`(`@k0nrevcfik:fmode.cn`) |
+| 依据 | 全部来自实测(云函数回读 + Matrix 事件 + 容器日志),非推断 |
+
+> **本文解决一个具体场景**:同公司的多个成员,共用同一个容器。
+> 包括:权限怎么开、每人的房间怎么建、白名单怎么配、状态怎么隔离。
+
+---
+
+## 一、整体链路(一张图看懂)
+
+```
+同公司成员 A(@alice)  同公司成员 B(@bob)  同公司成员 C(@carol)
+        │                       │                       │
+        │ ① 点「新建对话」选容器  │                       │
+        ▼                       ▼                       ▼
+   ┌─────────────────────────────────────────────────────┐
+   │  provision 云函数 RP5Ht55NeX                        │
+   │  · 校验:同公司?→ 允许(跨公司 403)                │
+   │  · 为每人签发**自己的** Matrix 身份                  │
+   │  · 每人建**独立的** DM 房间                          │
+   └─────────────────────────────────────────────────────┘
+        │                       │                       │
+        ▼                       ▼                       ▼
+   房间 !roomA             房间 !roomB             房间 !roomC
+        └───────────┬───────────┴───────────┬───────────┘
+                    ▼                       ▼
+             ┌──────────────────────────────────┐
+             │        容器(1 个)               │
+             │  · sync 1 条连接监听全部房间      │
+             │  · 每个房间 = 独立 session        │
+             │  · currentSessions 带上 userId    │
+             └──────────────────────────────────┘
+```
+
+---
+
+## 二、平台侧:provision 授权(已改,2026-11-12 上线)
+
+### 2.1 改动内容
+
+**`RP5Ht55NeX` v0.1.5 → v0.1.6**
+
+```diff
+- // 只允许主人或超管
+- if (!isSuper && (!owner || owner.id !== user.id)) {
+-   return _fail(403, '...无权开通会话');
+- }
++ // 主人 / 超管 / 同公司成员
++ var isOwner = !!owner && owner.id === user.id;
++ var sameCompany = isOwner ? true : await _isSameCompany(owner, user);
++ if (!isSuper && !isOwner && !sameCompany) {
++   return _fail(403, '...该 Agent 不属于你所在的公司, 无权开通会话');
++ }
+```
+
+新增 `_isSameCompany()`:
+```
+FmodeAgent.user → _User.company → Company.objectId
+调用者 _User.company → Company.objectId
+两者相等 = 同公司
+```
+
+### 2.2 安全边界
+
+| 调用者 | 结果 |
+|---|---|
+| 超管 | ✅ 允许 |
+| 容器归属人 | ✅ 允许 |
+| 同 company 其他成员 | ✅ 允许 |
+| **其他 company** | ❌ 403(多租户隔离,不得放开) |
+
+**保守策略**:任何一环取不到 `company` → 返回 `false`(宁可拒绝,不误放行)。
+
+### 2.3 实测
+
+| 项 | 结果 |
+|---|---|
+| 本人账号 | ✅ 200 |
+| 跨公司(11 个) | ✅ 全部 403 |
+| 同公司同事 | ✅ 200(403 消失) |
+
+---
+
+## 三、容器侧:准入(**最关键的坑**)
+
+### 3.1 现象
+
+同事能进会话、能发消息,但**容器不回复**,App 里只显示「已送达,等待回复…」直到超时。
+
+### 3.2 日志特征
+
+```
+WARNING gateway.run: Unauthorized user (ignored): Dropped a message
+from unrecognized matrix user (@xxx:fmode.cn).
+If that is you or someone you trust, add the ID to MATRIX_ALLOWED_USERS …
+```
+
+**看到这条 = 准入被拦**(消息静默丢弃,App 完全无感知)。
+
+### 3.3 🔴 判定规则(实测,与直觉相反)
+
+容器的授权判定顺序:
+
+```
+① MATRIX_ALLOWED_USERS 非空?
+     是 → 只认名单,ALLOW_ALL 被完全忽略
+     否 → 看第 ②
+② GATEWAY_ALLOW_ALL_USERS = true?
+     是 → 全部放行
+     否 → 拒绝
+```
+
+**关键结论:非空白名单 优先于 ALLOW_ALL。**
+
+### 3.4 一个真实踩过的坑
+
+| 时间 | 做的事 | 实际结果 |
+|---|---|---|
+| 00:44 | 往 `.env` 追加 `MATRIX_ALLOWED_USERS=...,@pnfapmsbhs` | 文件改了 |
+| 00:45 | 重启 gateway | ✅ 重新上线 |
+| — | **但 `.env` 这行没被加载进进程** | 进程里 `MATRIX_ALLOWED_USERS=''` |
+| — | 于是判定走到 ② | `ALLOW_ALL=true` → 全放行 |
+
+**也就是说:白名单是一行「死配置」,真正生效的是 ALLOW_ALL。**
+
+⚠️ **危险点**:如果哪天有人把 `.env` 的白名单修好并加载进进程,**行为立刻反转** ——
+只有名单里的人能用,其他人全部被拒。
+
+### 3.5 推荐配置
+
+| 目标 | 配置 | 说明 |
+|---|---|---|
+| **所有人可用**(当前需求) | `GATEWAY_ALLOW_ALL_USERS=true`<br>**且** `MATRIX_ALLOWED_USERS` 留空 | 行为确定 |
+| 只允许特定人 | `MATRIX_ALLOWED_USERS=@a,@b,@c`<br>`GATEWAY_ALLOW_ALL_USERS=false` | 每加一人**必须重启** |
+
+**验证命令**:
+```bash
+PID=$(cat /opt/data/gateway.pid | tr -d '[:space:]')
+tr '\0' '\n' < /proc/$PID/environ | grep -E "ALLOW_ALL|MATRIX_ALLOWED"
+```
+
+### 3.6 为什么「每次加人」很麻烦
+
+| 环节 | 问题 |
+|---|---|
+| 容器怎么知道有新用户? | **没有组织变更事件通知**,只能轮询平台 |
+| 改了配置怎么生效? | `os.environ` 在进程启动时读一次,**必须重启 gateway** |
+| 重启的代价 | **打断正在进行的对话**,用户看到「连接已断开」 |
+
+**所以「自动同步白名单」实际做不到**,除非接受「改一次重启一次」。
+
+---
+
+## 四、容器侧:多房间监听
+
+### 4.1 事实
+
+| 问题 | 答案 |
+|---|---|
+| 每个用户是独立房间吗? | ✅ 是。`FmodeMatrixBinding` 按 `user × agent` 一条记录,各带 `roomId` |
+| 容器需要单独配置房间列表吗? | ❌ 不需要。收到 invite 自动加入 |
+| 多房间增加多少开销? | **几乎为零** —— sync 只有 1 条长连接,1 个房间和 130 个房间没区别 |
+
+**实测依据**:`adapter.py:1830-1845` 单 client 单循环长轮询 `timeout=30000`。
+
+### 4.2 真正的瓶颈
+
+不是房间数,而是**并发 turn**:N 个用户同时提问 = N 个并发 turn = N 倍 token 消耗。
+
+`max_concurrent_sessions` 当前为 `null`(不限)。
+
+---
+
+## 五、容器侧:会话状态上报(**新容器最容易漏**)
+
+### 5.1 必须上报的字段(以 lj-test 实测为准)
+
+| 字段 | 时机 | 格式 | 缺失后果 |
+|---|---|---|---|
+| **`heartbeatAt`** | 心跳 | `{"__type":"Date","iso":"..."}` | **App 判不出在线**,显示「状态未同步」 |
+| `status` | turn 开始/结束 | `running` / `idle` | 容器级状态,App 已弱化使用 |
+| `currentTask` | turn 开始/结束 | 字符串 | 同上 |
+| **`usage`** | 心跳 | JSON **字符串** | 工作台 Token 用量显示不出来 |
+| `host` | 心跳 | JSON **字符串** | 工作台 CPU/内存不显示 |
+| `channels` | 心跳 | JSON **字符串** | 通道状态不显示 |
+| `balance` | 心跳 | **对象**(不是字符串) | 余额不显示 |
+| **`extra`** | turn 开始/结束 + 心跳 | JSON **字符串** | 会话级状态全废 |
+
+> ⚠️ 注意格式差异:`usage`/`host`/`channels`/`extra` 是**字符串**,`balance` 是**对象**,
+> `heartbeatAt` 是 **Date 类型对象**。写错类型服务端会拒绝或存进去读不出来。
+
+### 5.2 `extra.currentSessions` 的精确结构
+
+```json
+{
+  "currentSessions": [
+    {
+      "sessionKey": "!roomId 或 $approot_xxx",
+      "userId": "@用户的mxid:fmode.cn",
+      "status": "running",
+      "task": "本轮提问首行,60 字内",
+      "turnStartAt": 1794405380428,
+      "updatedAt": 1794405433842,
+      "ttlMs": 600000
+    }
+  ],
+  "extraUpdatedAt": "2026-11-12T00:30:00+08:00"
+}
+```
+
+**🔴 必须放在 `extra` 字段里**,不能作为顶层字段(顶层会 HTTP 400)。
+
+| 字段 | 来源 | 作用 |
+|---|---|---|
+| `sessionKey` | `thread_id` → `chat_id` → 内部 id | App 匹配「是不是当前会话」 |
+| `userId` | `state.db.sessions.user_id` | **多人共用时区分谁在跑** |
+| `task` | `pre_llm_call` 的 `user_message` 首行 | 顶栏显示「本会话在做什么」 |
+| `updatedAt` | 写入时的 epoch ms | App 判断 running 是否过期(10 分钟) |
+
+### 5.3 心跳间隔(统一标准)
+
+| 项 | 标准值 |
+|---|---|
+| 间隔 | **3600 秒** |
+| 抖动 | **0~900 秒** |
+| 实际周期 | 1~1.25 小时 |
+| 状态变更 | **事件驱动**(turn 首尾各报一次) |
+| cron 定时上报 | **`enabled=false`** |
+
+**为什么是硬标准**:心跳越勤 → 服务器开销越大。App 的离线阈值是 80 分钟,1~1.25 小时有安全余量。
+
+**验收**:观察 10 分钟(无 turn 活动),上报请求应 **≤ 1 次**。
+
+---
+
+## 六、App 侧(已实现,无需容器配合)
+
+| 能力 | 实现 |
+|---|---|
+| 会话级状态隔离 | 只认本会话的 `currentSessions` 记录 |
+| 多人区分 | `sessionStateOf` 按 `userId` 过滤 |
+| 状态过期保护 | `running` 超 10 分钟未更新 → 视为过期 |
+| 结束信号 | ✅ reaction(晚于正文 0.1 秒) |
+| 三处同步 | 卡片 / 右上角 / 左侧共用同一裁决 |
+
+---
+
+## 七、接入检查清单
+
+### 平台侧
+- [ ] `RP5Ht55NeX` ≥ v0.1.6(同公司可开通)
+- [ ] 跨公司仍返回 403(多租户隔离)
+
+### 容器侧
+- [ ] 准入:`GATEWAY_ALLOW_ALL_USERS=true` **且** `MATRIX_ALLOWED_USERS` 为空(或按需配白名单)
+- [ ] 进程环境回读确认(`/proc/<pid>/environ`)
+- [ ] 日志无 `Unauthorized user` 
+- [ ] `heartbeatAt` 有值(Date 类型)
+- [ ] `usage` 有值(JSON 字符串)
+- [ ] `extra.currentSessions` 每条含 6 字段
+- [ ] 心跳 10 分钟内 ≤ 1 次
+- [ ] 能监听多个房间(默认支持,无需配置)
+
+### 验收
+- [ ] 同公司同事 A 发消息 → 容器回复
+- [ ] 同公司同事 B 发消息 → 容器回复
+- [ ] **一个全新的陌生用户**发消息 → 容器回复(全放开模式下)
+- [ ] 两人同时对话 → 状态互不干扰
+- [ ] 同事撤回消息后重发 → 正常
+
+---
+
+## 八、故障速查
+
+| 现象 | 原因 | 检查 |
+|---|---|---|
+| 同事发消息不回复 | 准入被拦 | 日志找 `Unauthorized user` |
+| 同事能进会话但看不到消息 | 房间没建 / 容器没入房 | `joined_members` 看容器在不在 |
+| 新加白名单后仍不生效 | **没重启** / `.env` 没加载进进程 | `tr '\0' '\n' < /proc/<pid>/environ` |
+| 工作台 Token 用量为空 | `usage` 没上报 | 回读 `xlosi3m46q` |
+| 显示「状态未同步」 | `heartbeatAt` 缺失 | 同上 |
+| 两人对话状态互相干扰 | `currentSessions` 没带 `userId` | 回读 `extra.currentSessions` |
+
+---
+
+*本文所有结论均来自 2026-11-12 的实测(云函数回读 / Matrix 事件 / 容器日志),非推断。*

+ 142 - 1
docs/03-interface/容器接入标准流程-SOP-v3-20261112.md

@@ -12,6 +12,18 @@
 
 ---
 
+## 使用前:先判断你的场景
+
+| 场景 | 看哪些章节 |
+|---|---|
+| **单用户使用**(只有主人用) | 第 0 ~ 5 步全走 |
+| **同公司多人共用**(推荐看 §3.0) | 第 0 ~ 5 步 **+ §3.0 多用户准入** |
+
+> 🔴 多人共用时,**最容易踩的坑是准入**(白名单 / ALLOW_ALL)。
+> 详见 `多用户共用容器-完整规范-20261112.md` 或本文 §3.0。
+
+---
+
 ## 第 0 步:先做这一项(10 秒,决定要不要继续)
 
 ```bash
@@ -28,6 +40,30 @@ python3 -c "import socket,time;t=time.time();socket.getaddrinfo('api.fmode.cn',4
 
 ---
 
+## ⚠️ 给接手者:这一路踩过的坑(先读,能省几小时)
+
+| # | 我的误判 | 真相 | 怎么发现的 |
+|---|---|---|---|
+| 1 | 以为是**模型慢** | DNS 解析卡 5 秒 | 测 `AF_INET` vs `AF_UNSPEC`,差 5007ms |
+| 2 | 以为是**上下文膨胀** | 17k~88k 上下文延迟**完全不变** | 拿多轮实测数据对比 |
+| 3 | 以为是**历史会话太多**(242 个/40MB) | 新会话只读自己的 `session_id`,历史**不进上下文** | 实测:数据多的那天首轮反而更小 |
+| 4 | 以为是**插件开销** | 4 个插件每轮合计 **2~3 ms** | 逐插件打点实测 |
+| 5 | 以为容器说「改完了」是真的 | 改的是**只读文件**,一个字没生效 | 自己 `/sync` 回读事件 |
+| 6 | 以为 `currentSessions` 是错的字段名 | 字段名对,**位置错了**(没放 `extra` 里) | 两种写法各发一次,一个 400 一个 200 |
+| 7 | 以为是「信号没收到」导致状态不同步 | **NG0600**:模板渲染期写 signal,渲染被异常中断 | **在浏览器控制台看到的**,读代码永远看不出来 |
+
+### 🔴 最重要的一条教训
+
+**单元测试测不出框架级错误。**
+
+App 侧 1683 条单测全过,但 NG0600 让消息区卡了 3.8 秒——因为单测跑的是纯函数,**不经过 Angular 模板渲染**。
+
+**所以:涉及界面表现的问题,必须先在真实浏览器里看控制台,不要只读代码推断。**
+
+现成脚本:`node scripts/repro-new-session-send.mjs <agentId>`
+
+---
+
 ## 第 1 步:体检(只读,不改任何东西)
 
 ### 1.1 DNS 解析(最优先)
@@ -111,6 +147,23 @@ grep -n "MATRIX_REACTIONS" /opt/data/.env || echo "(未设置,默认 true)"
 2. **心跳也要带上**(不能只在 turn 首尾写)
 3. `idle` 记录有 TTL 清理(建议 10 分钟)
 
+### 🔴 心跳间隔 —— 统一标准,不得偏离
+
+| 项 | **标准值** |
+|---|---|
+| 心跳间隔 | **3600 秒** |
+| 随机抖动 | **0~900 秒**(实际 1~1.25 小时) |
+| 状态变更 | **事件驱动**(turn 首尾各报一次),不靠定时轮询 |
+| cron 定时上报 | **`enabled=false`**(lj-test 就是这样) |
+
+**为什么是硬标准**:心跳越勤,**服务器开销越大**;多容器同时高频会形成请求尖峰。
+lj-test 的 1~1.25 小时是验证过的平衡点(App 离线阈值 80 分钟,仍有安全余量)。
+
+**实测反例**:曾有容器用「30 秒心跳 + 每 2 分钟 cron」,比标准快 **120 倍**,
+被要求改回 3600s + 抖动。
+
+**验收**:观察 10 分钟(无 turn 活动时),上报请求应 **≤ 1 次**。
+
 ### 1.4 文件交付
 
 ```bash
@@ -212,7 +265,7 @@ s6-svc -r /run/service/gateway-default
 | 1 | DNS | `python3 -c "...getaddrinfo..."` | **< 100 ms** |
 | 2 | ✅ reaction | 发一条消息,`/sync` 读事件 | ✅ 晚于正文 |
 | 3 | `currentSessions` | 调 `xlosi3m46q` 读 `extra` | 每条有 `sessionKey`/`userId`/`status`/`task`/`updatedAt` |
-| 4 | 心跳带会话 | 等一次心跳后回读 | `currentSessions` 仍在 |
+| 4 | 心跳间隔 **3600s + 0~900s 抖动** | 观察 10 分钟统计请求数 | **≤ 1 次**(无 turn 活动时);且心跳带 `currentSessions` |
 | 5 | 图文合并 | 发图+文字 | **只回一轮** |
 | 6 | 交付链接 | 让它产出一个文件 | 正文有 `**已上云(可直接下载)**` |
 | 7 | 表格 | 让它用表格回答 | 正常渲染成表格 |
@@ -223,6 +276,80 @@ s6-svc -r /run/service/gateway-default
 
 ---
 
+## 第 3.0 步:多用户准入(**同公司多人共用时必做**)
+
+### 3.0.1 现象
+
+同公司的同事能进会话、能发消息,但**容器不回复**,App 一直显示「已送达,等待回复…」。
+
+### 3.0.2 日志特征(一眼判定)
+
+```
+WARNING gateway.run: Unauthorized user (ignored): Dropped a message
+from unrecognized matrix user (@xxx:fmode.cn).
+```
+
+**看到这条 = 准入被拦**(消息静默丢弃,App 完全无感知)。
+
+### 3.0.3 🔴 判定规则(实测,与直觉相反)
+
+```
+① MATRIX_ALLOWED_USERS 非空?
+     是 → 只认名单,ALLOW_ALL 被完全忽略
+     否 → 看 ②
+② GATEWAY_ALLOW_ALL_USERS = true?
+     是 → 全部放行
+     否 → 拒绝
+```
+
+**关键:非空白名单 优先于 ALLOW_ALL。**
+
+### 3.0.4 推荐配置
+
+| 目标 | 配置 |
+|---|---|
+| **所有人可用** | `GATEWAY_ALLOW_ALL_USERS=true` **且** `MATRIX_ALLOWED_USERS` 留空 |
+| 只允许特定人 | `MATRIX_ALLOWED_USERS=@a,@b,@c` + `GATEWAY_ALLOW_ALL_USERS=false`(**每加一人必须重启**) |
+
+### 3.0.5 ⚠️ 一个真实的坑(2026-11-12 踩过)
+
+| 步骤 | 实际结果 |
+|---|---|
+| 往 `.env` 追加 `MATRIX_ALLOWED_USERS=...,@新用户` | 文件改了 |
+| 重启 gateway | ✅ 上线 |
+| **但 `.env` 这行没加载进进程** | 进程里是空串 → 走了 ALLOW_ALL 分支 |
+
+**也就是说白名单是「死配置」,真正生效的是 ALLOW_ALL。**
+
+⚠️ **危险点**:如果哪天 `.env` 的白名单被加载进进程,**行为立刻反转**。
+**所以配完必须回读进程环境确认:**
+
+```bash
+PID=$(cat /opt/data/gateway.pid | tr -d '[:space:]')
+tr '\0' '\n' < /proc/$PID/environ | grep -E "ALLOW_ALL|MATRIX_ALLOWED"
+```
+
+### 3.0.6 为什么「加白名单」很麻烦
+
+| 环节 | 问题 |
+|---|---|
+| 容器怎么知道有新用户? | **没有组织变更通知**,只能轮询 |
+| 改了配置怎么生效? | `os.environ` 启动时读一次,**必须重启** |
+| 重启代价 | **打断进行中的对话** |
+
+**→ 所以多人场景优先用 ALLOW_ALL + 白名单留空。**
+
+### 3.0.7 房间与会话隔离
+
+| 项 | 事实 |
+|---|---|
+| 每个用户独立房间? | ✅ 是(`FmodeMatrixBinding` 按 user × agent 一条) |
+| 容器需配置房间列表? | ❌ 不需要,收到 invite 自动加入 |
+| 多房间开销? | **几乎为零**(sync 只有 1 条长连接) |
+| 真正的瓶颈 | **并发 turn**(N 人同时问 = N 倍 token) |
+
+---
+
 ## 第 4 步:复验(App 侧独立回读,**不采信容器自述**)
 
 ### 4.1 为什么必须独立复验
@@ -259,6 +386,20 @@ node scripts/repro-new-session-send.mjs <agentId>
 
 ## 第 5 步:交付确认
 
+### 5.0 常见失败模式速查(出问题时先对这张表)
+
+| 你看到的 | 最可能的原因 | 怎么确认 |
+|---|---|---|
+| 回复要等 7~9 秒,**新会话也一样** | **DNS 卡 5 秒** | §1.1 自测命令 |
+| 正文输出完了,右上角还「处理中」 | ① ✅ reaction 没收到 ② 容器的 `currentSessions` 没翻 idle | `/sync` 看有没有 ✅;回读 `extra` |
+| 打开会话 A 看到会话 B 的进度 | 容器只报容器级 `status`,没报会话级 | 回读 `extra.currentSessions` 是否为空 |
+| 长任务中途突然「已完成」,正文后到 | 容器分条发送,中间静默超过阈值 | 看 `/sync` 里 ✅ 的到达时刻 |
+| 图文一起发出,回了两轮 | `plugin-media-text-merge` 没启用 | §1.5 |
+| 回复里出现一行灰字「Looking at the image…」 | 容器把工具过程行当正文发了,且 `question` 抄了用户原话 | 让她贴那轮的 content 原文 |
+| 文件回复里没有下载链接 | `plugin-deliverable-inject` 没装 / 没生效 | §1.4 |
+| 表格显示成一行文字 | 容器主动降级了(误以为不支持) | 让它直接输出标准 Markdown 表格 |
+| 改完还是老样子 | ① 没重启 gateway ② 改的是只读文件 | 贴重启后的实测输出,不要贴「已完成」 |
+
 ### 5.1 最终验收(App 里人工过一遍)
 
 - [ ] 新建会话 → 立刻发消息,**消息立即出现**

+ 45 - 9
docs/03-interface/提示词-容器自我优化-v2-20261111.md

@@ -155,18 +155,54 @@ for r in c.execute('SELECT id,thread_id,chat_id,user_id FROM sessions ORDER BY s
 
 ---
 
-## 检查项 3:心跳必须带 `currentSessions`
+## 检查项 3:心跳间隔 —— **必须与 lj-test 完全一致**
 
-### 背景
-只在 turn 首尾写 `currentSessions` 的话,turn 结束后状态会滞后,
-App 最长要等 15 秒轮询才纠正。
+### 🔴 这不是"建议值",是统一标准
 
-### 要求
-- [ ] 心跳(每 3600s + 0~900s 抖动)**也要带上** `currentSessions`
-- [ ] `idle` 记录有 TTL 清理(建议 10 分钟)
+| 项 | **标准值**(不得偏离) |
+|---|---|
+| 心跳间隔 | **3600 秒** |
+| 随机抖动 | **0~900 秒**(实际周期 1~1.25 小时) |
+| 心跳内容 | `heartbeatAt` + `extra`(含 `currentSessions`) |
+| 状态变更 | **事件驱动**(turn 开始/结束各报一次),不靠定时轮询 |
 
-### 自查
-回答:你的心跳上报函数在哪(文件:行号)?有没有带 `currentSessions`?
+**为什么是硬标准**:心跳越频繁,**服务器开销越大**。多个容器同时高频心跳会形成请求尖峰。
+lj-test 的 1~1.25 小时是经过验证的平衡点(App 离线阈值 80 分钟,仍有安全余量)。
+
+### ⚠️ 常见错误:比标准更勤
+
+| 错误做法 | 后果 |
+|---|---|
+| 心跳 30 秒 | 比标准快 **120 倍**,服务器开销大增 |
+| cron 每 2 分钟跑一次上报 | 同上;应像 lj-test 一样 `enabled=false` |
+| 用定时轮询代替事件驱动 | 空转请求,turn 不活跃时也在发 |
+
+> 实测基线:lj-test 的 cron `*/2` 是 `enabled=false`,靠 `pre_llm_call`/`post_llm_call` 事件驱动。
+> **如果你的 cron 还在跑,请评估关掉它**;关掉后 turn 首尾仍能正确更新 `currentSessions`。
+
+### 自查命令
+```bash
+# 1. 心跳间隔配置
+grep -rn "HEARTBEAT_SEC\|heartbeat_sec\|HEARTBEAT" /opt/data/scripts/ /opt/data/.env 2>/dev/null | head
+
+# 2. cron 是否在跑
+crontab -l 2>/dev/null | grep -i status
+ls /opt/data/cron/jobs.json 2>/dev/null && python3 -c "
+import json;d=json.load(open('/opt/data/cron/jobs.json'))
+for j in (d if isinstance(d,list) else d.get('jobs',[])): print(j.get('name'),j.get('schedule'),j.get('enabled'))
+"
+```
+
+### 验收标准
+**观察 10 分钟**,统计这段时间发出的上报请求数:
+
+| 结果 | 判定 |
+|---|---|
+| **≤ 1 次**(无 turn 活动时) | ✅ 符合标准 |
+| 2~5 次 | ⚠️ 偏勤,检查 cron |
+| **> 5 次** | ❌ 远超标准,必须改 |
+
+回答:你的心跳间隔是多少?cron 在不在跑?10 分钟内发了几次?
 
 ---
 

+ 232 - 0
docs/03-interface/需求-同公司共用容器-20261112.md

@@ -0,0 +1,232 @@
+# 需求:同公司成员应可共用全部容器(2026-11-12)
+
+| 字段 | 值 |
+|---|---|
+| 提出人 | 用户(2026-11-12) |
+| 影响范围 | 服务端云函数 `RP5Ht55NeX`(provision)+ 可能的 ACL |
+| 前端是否可解决 | ❌ 不能,需平台侧改 |
+
+---
+
+## 一、问题现象
+
+同公司的另一个成员,在 App 里点「新建对话」→ 选择 `lj-test` 容器时:
+
+```
+GET  https://ai.fmode.cn/desktop/chat/agent-node-lj-test/new   404
+POST https://server.fmode.cn/api/functions                     403 (Forbidden)
+连接已断开
+消息加载失败
+FMODE 开通失败:云函数调用失败(HTTP 403)
+```
+
+---
+
+## 二、用户明确的需求
+
+> **只要是从公司的人,所有的容器大家是可以一起使用的,没有说容器归属个人这种东西。**
+
+即:
+- 同公司(同一 `company`)的所有成员
+- 应当能**打开并使用**公司内**任意**容器
+- 不应存在「这个容器只有主人能用」的限制
+
+---
+
+## 三、根因定位(实测)
+
+### 3.1 两个能力的权限口径不一致
+
+| 操作 | 云函数 | 本人账号 | 同事账号 |
+|---|---|---|---|
+| **查询容器** | `xlosi3m46q`(`scope: 'company'`) | ✅ 200 | ✅ 200 |
+| **开通/进入容器** | **`RP5Ht55NeX`**(provision) | ✅ 200 | ❌ **403** |
+
+**「看得见」但「进不去」。**
+
+### 3.2 App 前端已经按「公司共享」设计了
+
+`src/app/core/acl.ts` 里,普通成员的 scope 就是 `company`:
+
+```ts
+case 'company-member':
+  // 普通成员**可以看全公司**(2026-11-04 用户裁决变更)
+  // 通讯录按「同事 → 其名下容器」组织,成员需要看到全公司的人与容器
+  return 'company';
+```
+
+注释里写得很清楚:
+> 2026-10-31 曾收紧为 `mine`(只看自己),但那导致通讯录形同虚设 ——
+> 看不到同事的容器,也就无法「与其他人的容器交流」,而这正是产品的核心场景。
+
+**所以前端早就按「公司内共享」在做了,卡点 100% 在服务端 `RP5Ht55NeX`。**
+
+### 3.3 App 侧无「容器归属个人」的假设
+
+已检查 `acl.ts` / `chat-session.service.ts`,没有基于「容器主人」的权限判断。
+前端**不做任何权限判断**(`parse-api.ts` 有明确注释:谁能调用由服务端决定)。
+
+---
+
+## 四、需要平台侧做什么
+
+### 4.1 核心诉求(用户已裁决)
+
+| 决策 | 结论 |
+|---|---|
+| **共享范围** | **仅同 company 共享** —— 同公司任意成员可用公司内任意容器;**跨公司仍严格隔离** |
+| **身份归属** | **各自用自己身份** —— 每个人用自己的 Matrix 身份与容器通信 |
+
+### 4.2 授权口径
+
+**`RP5Ht55NeX`(provision)的授权,应与 `xlosi3m46q` 的 `company` scope 一致**:
+
+| 调用者 | 期望 |
+|---|---|
+| 容器主人 | ✅ 允许 |
+| **同 company 的其他成员** | ✅ **允许**(当前被拒 403) |
+| 其他 company 的人 | ❌ 拒绝(多租户隔离,必须保留) |
+
+### 4.3 🔴 关键:签发调用者自己的 Matrix 身份
+
+用户明确裁决:**各自用自己身份**。
+
+`provision` 目前签发的 accessToken 代表的是**容器主人**。改为公司共享时,必须改成:
+
+```
+同公司成员 B 打开容器 X(主人是 A)
+  → provision 为 B 签发 B 自己的 Matrix 身份
+  → B 与容器通信时,容器看到的 sender 是 @B
+  → 容器上报的 currentSessions[].userId 是 @B
+```
+
+**为什么必须这样**:
+
+| 若不这样(统一用主人身份) | 后果 |
+|---|---|
+| 容器看到的永远是主人 | 无法区分「谁在对话」 |
+| `currentSessions[].userId` 全是主人 | **App 的多人隔离机制失效** |
+| 消息归属混在一起 | 同事之间看不到彼此的会话边界 |
+
+> App 侧已实现按 `userId` 区分「谁的会话在跑」(`sessionStateOf` 的 userId 过滤)。
+> **服务端必须签发真实身份,这套机制才有意义。**
+
+### 4.4 安全边界(必须保留)
+
+- 仍按 `company: Pointer<Company>` 强制隔离
+- 跨公司一律拒绝
+- **不得**变成「任何登录用户都能用」
+
+---
+
+## 五、App 侧已做的配合
+
+| 项 | 状态 |
+|---|---|
+| 错误提示改进 | ✅ 403 现在提示「没有权限使用这个容器。它归属其他用户,需要该用户或公司管理员授权后才能使用。」 |
+| 前端 scope | ✅ 已是 `company` |
+| 权限判断 | ✅ 前端不做(交给服务端) |
+
+> 提示文案目前按「归属个人」写。如果平台侧改成公司共享,
+> 这条文案要同步改成「该容器对你不可用,请联系管理员」。
+
+---
+
+## 六、临时绕行方案(在平台侧改之前)
+
+如果同事急需使用某个容器,可选:
+
+| 方案 | 做法 | 局限 |
+|---|---|---|
+| A. 用主人的账号登录 | 直接使用主人账号 | **服务端单点登录,会互相踢下线**,不可行 |
+| B. 各自用自己名下的容器 | 让同事自己开通容器 | 无法共用同一个容器的知识与成果 |
+| C. 群聊协作 | 把容器拉进群,通过群聊 @ 它 | 只能群里用,不能单独开对话 |
+
+**没有真正可用的绕行方案 —— 必须平台侧改。**
+
+---
+
+*本文档基于 2026-11-12 的实测。App 侧已确认无阻塞改动,等待平台侧处理。*
+
+---
+
+## 七、实施记录:云函数已放开(2026-11-12)
+
+### 7.1 `RP5Ht55NeX` provision v0.1.5 → v0.1.6(已上线)
+
+```diff
+- // 只允许主人或超管
+- if (!isSuper && (!owner || owner.id !== user.id)) {
+-   return _fail(403, '...无权开通会话');
+- }
++ // 主人 / 超管 / 同公司成员
++ var isOwner = !!owner && owner.id === user.id;
++ var sameCompany = isOwner ? true : await _isSameCompany(owner, user);
++ if (!isSuper && !isOwner && !sameCompany) {
++   return _fail(403, '...该 Agent 不属于你所在的公司, 无权开通会话');
++ }
+```
+
+新增 `_isSameCompany()`:`FmodeAgent.user.company` 与 `调用者.company` 比对。
+**保守策略**:任何一环取不到 company 都返回 `false`(宁可拒绝,不误放行)。
+
+**实测**:本人 200 ✅ / 跨公司 11 个全部 403 ✅
+
+### 7.2 但放开后仍不通 —— 卡在容器侧白名单
+
+现象:同事能进会话、能发消息,但**容器不回复**。
+
+**日志铁证**:
+```
+WARNING gateway.run: Unauthorized user (ignored): Dropped a message
+from unrecognized matrix user (@pnfapmsbhs:fmode.cn).
+If that is you or someone you trust, add the ID to MATRIX_ALLOWED_USERS …
+```
+
+**完整链路诊断**(逐层排查):
+
+| 层 | 状态 | 证据 |
+|---|---|---|
+| 1. 消息到达房间 | ✅ | `messages` API 能看到同事的两条消息 |
+| 2. **入站过滤** | ❌ **断在这里** | 日志 `Unauthorized user (ignored)` |
+| 3. session 创建 | ❌ 没建 | `state.db` 查不到 `chat_id LIKE '%kk7Q2sOk%'` |
+| 4. turn 执行 | ❌ 没跑 | `agent.log` 无该房间记录 |
+| 5. 回复发送 | ❌ 没发 | 无 `sent event` 到该房间 |
+
+**根因**:`@pnfapmsbhs:fmode.cn` 不在 `MATRIX_ALLOWED_USERS`。
+`GATEWAY_ALLOW_ALL_USERS` 是**注释掉的**(`# GATEWAY_ALLOW_ALL_USERS=false`),不生效。
+
+### 7.3 一个容易误判的现象
+
+同事发的消息旁边出现了 👀 和 ✅ reaction,看起来「容器收到了」。
+
+**实际不是容器打的** —— 是 **App 用容器身份登录**(provision 签发的 token 就是容器账号),
+用户在 App 里点的 emoji 反应,sender 显示为容器 mxid。
+
+> 教训:**看 reaction 的 sender 不够,要看 target 和时机**。
+> 容器真正的反应是紧跟 `👀`(处理中),而这里的两条 reaction 时间戳与用户消息**同一秒**。
+
+### 7.4 当前处理
+
+用户裁决:**白名单追加 + 全放开,一起开**。
+理由:「后续会有非常多的人进来,不可能每次都加」。
+
+| 配置 | 值 |
+|---|---|
+| `MATRIX_ALLOWED_USERS` | 追加 `@pnfapmsbhs:fmode.cn`(双保险) |
+| `GATEWAY_ALLOW_ALL_USERS` | `true` |
+
+**待验证**:重启后同事发消息能否收到回复。
+
+### 7.5 长期方案(待定)
+
+| 方案 | 可行性 | 说明 |
+|---|---|---|
+| 全放开 | ✅ 简单 | 任何联邦用户都能用,范围超出「同公司」 |
+| 自动同步白名单 | ❌ **做不到全自动** | 容器无组织变更事件通知;且 `.env` 改了**必须重启**才能生效 |
+| 平台侧下发白名单 | ? | 需要容器提供接口,由平台推送 |
+
+> 用户质疑「自动同步」时指出的关键问题是对的:
+> ① 容器怎么知道有新用户?只能轮询
+> ② 改了 `.env` 必须重启 gateway
+> ③ 重启会打断进行中的对话

+ 19 - 0
scripts/deploy-cloud-fn.mjs

@@ -38,6 +38,16 @@ const APP_ID = 'ncloudmaster';
 const BASE = 'https://server.fmode.cn/parse';
 const MK = process.env.PARSE_MASTER_KEY;
 const APPLY = process.argv.includes('--apply');
+/**
+ * 只部署指定函数(2026-11-12 新增)。
+ *
+ * 背景:本地保存的是各函数的源码副本,但不保证与线上随时同步 ——
+ * 别人可能在控制台改过线上版本。批量 `--apply` 会把本地较旧的副本推上去,
+ * 覆盖掉那些改动。
+ *
+ * 用法:`--only=fmodeagent-matrix-provision`(按 name 或 id 精确匹配)
+ */
+const ONLY = (process.argv.find((a) => a.startsWith('--only=')) ?? '').slice('--only='.length);
 
 /** 要部署的函数清单
  *
@@ -51,6 +61,12 @@ const TARGETS = [
     name: 'fmodeagent-chatthreads',
     version: '0.1.9',
   },
+  {
+    file: 'cloud/functions/fmodeagent-matrix-provision.js',
+    id: 'RP5Ht55NeX',
+    name: 'fmodeagent-matrix-provision',
+    version: '0.1.6',
+  },
   {
     file: 'cloud/functions/fmode-file-hub.js',
     id: null, // 新建前未知,部署时按 name 查
@@ -128,8 +144,11 @@ async function main() {
   }
 
   console.log(APPLY ? '🔴 APPLY:将热更线上云函数' : '🟡 DRY-RUN:只对比,不写入');
+  if (ONLY) console.log(`🎯 只处理:${ONLY}`);
 
   for (const t of TARGETS) {
+    /* `--only=` 指定时,跳过不匹配的(按 name 或 id) */
+    if (ONLY && t.name !== ONLY && t.id !== ONLY) continue;
     const code = readFileSync(resolve(root, t.file), 'utf8');
     console.log(`\n=== ${t.name} (${t.id ?? '待新建'}) ===`);
     console.log(`  本地源码:${t.file},${code.length} 字符,v${t.version}`);

+ 20 - 0
src/app/core/api/parse-api.ts

@@ -829,6 +829,26 @@ export class ParseApi extends FmodeApi {
       if (res.status === 503 || res.status === 502 || res.status === 504) {
         throw new Error(`平台暂时不可用(HTTP ${res.status}),请稍后重试`);
       }
+      /*
+       * 🔴 403 要翻译成人话(2026-11-12 用户实测)。
+       *
+       * 同公司同事打开某个容器时,provision 云函数会返回 403。
+       * 原本只抛「云函数调用失败(HTTP 403)」,用户不知道是网络、账号还是权限问题。
+       *
+       * ⚠️ 注意:产品目标是「同公司成员可共用全部容器」(见
+       *    `docs/03-interface/需求-同公司共用容器-20261112.md`),
+       *    当前 403 属于服务端尚未放开的限制,不是正常状态。
+       *    所以文案用「暂时不可用」而不是「你没有权限」——
+       *    避免让用户以为这是永久性的个人权限问题。
+       */
+      if (res.status === 403) {
+        throw new Error(
+          '这个容器暂时无法使用(平台侧授权未开放)。同公司成员共用容器需平台侧开通,请联系管理员。',
+        );
+      }
+      if (res.status === 401) {
+        throw new Error('登录已失效,请重新登录。');
+      }
       throw new Error(`云函数调用失败(HTTP ${res.status})`);
     }
     return (await res.json()) as T;