feat(opencode桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
与 dsh 同一形状:`reverseMap` 只在邮件投递时填 ⇒ 用户在界面里新开一轮对话时 查表必空 ⇒ withScope「取不到就原样返回」⇒ 不带 session_id ⇒ 服务端 403。 实测它至今只裸奔过 1 次(2026-09-28),但**形状在**,且旧兜底的服务端放行已作废。 一致性比实测频率重要:留着一个已知失效的分支,下次有人读它会以为那是可用的。 回落答案只能问服务端 —— opencode 自己的 session id 与 AgentMail 会话 uuid 没有映射。★ 这与 `workspace` 那一维**不同**:那一维可以从 `Session.directory` 问出来(见 workspace-probe.test.mjs,同一天修的),所以这一维只能走默认会话。 装配期 fire-and-forget 问一次(不 await):它的失败后果只是那次读信 403, 不该拖住注册流程(而 dsh 那边是 await,因为 dsh 的注册路径本来就顺序执行)。 这个差异是有意的,不是抄漏。 判据 5 格。三个变异各红 3 格(交叉命中,说明各自在钉不同东西)。 全量 364/364 绿(359 + 5)。
This commit is contained in:
@ -457,7 +457,17 @@ const downloadAttachmentTool = {
|
|||||||
* (服务端会记警告),也不猜一个。
|
* (服务端会记警告),也不猜一个。
|
||||||
*/
|
*/
|
||||||
function withScope(path, context) {
|
function withScope(path, context) {
|
||||||
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
|
// ★ 2026-10-02:取不到邮件会话时回落到**默认会话**(/tmp),不再原样返回。
|
||||||
|
//
|
||||||
|
// 旧写法「原样返回」是 2026-09-15 之前的迁移期兜底;那个放行已作废
|
||||||
|
// (AgentMayReadSession 对未声明 session_id 一律 403)。
|
||||||
|
// `reverseMap` 只在邮件投递时填 ⇒ 用户在 opencode 界面里新开一轮对话时
|
||||||
|
// 查表必空 ⇒ 读信 403。
|
||||||
|
//
|
||||||
|
// 回落答案只能问服务端:opencode 侧的 session id 是它自己的,
|
||||||
|
// 与 AgentMail 会话 uuid 没有映射(不像 workspace 能从 Session.directory 问出来)。
|
||||||
|
const mailSessionID =
|
||||||
|
reverseMap.get(String(context?.sessionID ?? "")) || defaultSessionId || "";
|
||||||
if (!mailSessionID) return path;
|
if (!mailSessionID) return path;
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
return `${path}${sep}session_id=${encodeURIComponent(mailSessionID)}`;
|
return `${path}${sep}session_id=${encodeURIComponent(mailSessionID)}`;
|
||||||
@ -718,6 +728,42 @@ const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode sessi
|
|||||||
还有宿主句柄 —— 以前没这个需求,所以没有通道。
|
还有宿主句柄 —— 以前没这个需求,所以没有通道。
|
||||||
*/
|
*/
|
||||||
let hostClient = null;
|
let hostClient = null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
★ 2026-10-02:非邮件轮次的 session_id 兜底(/tmp 默认会话)。
|
||||||
|
|
||||||
|
`reverseMap` 只在邮件投递时填 ⇒ 用户在界面里直接对话时查表为空。
|
||||||
|
withScope 旧写法是「取不到就原样返回」,那是 2026-09-15 前的迁移期放行;
|
||||||
|
服务端已收严为「未声明 session_id 一律 403」⇒ 不加兜底就是界面里读不到信。
|
||||||
|
|
||||||
|
端点是**纯只读**的(默认工作区没通信过就返回 session_id=null,不建会话),
|
||||||
|
所以这里拿到空是正常形状;后果只是那次读信 403,而非静默放行成越权。
|
||||||
|
|
||||||
|
装配期问一次而不是每次 withScope 都问:后者会给服务端添无谓压力。
|
||||||
|
*/
|
||||||
|
const DEFAULT_FALLBACK_WORKSPACE = "/tmp"; // 必须与 handler.DefaultFallbackWorkspace 一致
|
||||||
|
let defaultSessionId = "";
|
||||||
|
let defaultSessionProbed = false;
|
||||||
|
|
||||||
|
async function loadDefaultSession(log) {
|
||||||
|
if (defaultSessionProbed || !hostClient?.session?.get) return;
|
||||||
|
defaultSessionProbed = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${GATEWAY_URL}/api/v1/agent/session/default?workspace=${encodeURIComponent(DEFAULT_FALLBACK_WORKSPACE)}`,
|
||||||
|
{ headers: authHeaders() },
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
defaultSessionId = String(data?.session_id ?? "");
|
||||||
|
if (defaultSessionId) {
|
||||||
|
log?.(`[mail-bridge] 默认会话(/tmp): ${defaultSessionId}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 问不到就当没有:不带 session_id ⇒ 服务端 403(可见)而非静默放行。
|
||||||
|
log?.(`[mail-bridge] 取默认会话失败(本次不带 session_id): ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||||
|
|
||||||
// 权限询问的双向定位。
|
// 权限询问的双向定位。
|
||||||
@ -1258,6 +1304,10 @@ export default async function mailBridge(input) {
|
|||||||
// 供模块级工具对象(read_inbox)问工作区用,见 hostClient 的注释。
|
// 供模块级工具对象(read_inbox)问工作区用,见 hostClient 的注释。
|
||||||
if (client) hostClient = client;
|
if (client) hostClient = client;
|
||||||
|
|
||||||
|
// ★ 2026-10-02:装配期问一次默认会话(非邮件轮次的 session_id 兜底)。
|
||||||
|
// 不 await:注册流程不该被它拖住(问不到的后果只是那次读信 403)。
|
||||||
|
loadDefaultSession((m) => console.log(m));
|
||||||
|
|
||||||
// 注册 Agent。无密钥也无 secret 时先本地生成一把密钥,
|
// 注册 Agent。无密钥也无 secret 时先本地生成一把密钥,
|
||||||
// 等管理员在后台登记后即可接入(无需重装插件)。
|
// 等管理员在后台登记后即可接入(无需重装插件)。
|
||||||
if (!AGENT_KEY && !AGENT_SECRET) {
|
if (!AGENT_KEY && !AGENT_SECRET) {
|
||||||
|
|||||||
85
plugins/opencode-mail-bridge/test/default-session.test.mjs
Normal file
85
plugins/opencode-mail-bridge/test/default-session.test.mjs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* opencode:`withScope` 在**非邮件轮次**也必须声明一个合法 session_id(2026-10-02)。
|
||||||
|
*
|
||||||
|
* ## 为什么
|
||||||
|
*
|
||||||
|
* 服务端 `AgentMayReadSession` 收严后(`15e4fe9`),未声明 session_id 的读信一律
|
||||||
|
* 403。而 `reverseMap` **只在邮件投递时**填 ⇒ 用户在 opencode 界面里新开一轮
|
||||||
|
* 对话时查表必空 ⇒ 读信 403。
|
||||||
|
*
|
||||||
|
* 旧写法「取不到就原样返回」是 2026-09-15 之前的迁移期放行,服务端已不再放行。
|
||||||
|
*
|
||||||
|
* 回落答案只能问服务端:opencode 自己的 session id 与 AgentMail 会话 uuid
|
||||||
|
* 没有映射 —— 与 `workspace` 那一维不同(那一维可以从 `Session.directory` 问出来,
|
||||||
|
* 见 workspace-probe.test.mjs)。所以这一维只能走默认会话。
|
||||||
|
*
|
||||||
|
* ## 形状要求
|
||||||
|
*
|
||||||
|
* 装配期问一次(不塞进 withScope):withScope 是取值函数,
|
||||||
|
* IO 混进来就变成「拼 URL 时顺带发 HTTP」。
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const src = readFileSync(join(HERE, '..', 'index.js'), 'utf8');
|
||||||
|
|
||||||
|
test('★ withScope 在 reverseMap 未命中时回落到默认会话', () => {
|
||||||
|
assert.ok(
|
||||||
|
/reverseMap\.get\(String\(context\?\.sessionID \?\? ""\)\) \|\| defaultSessionId \|\| ""/.test(src),
|
||||||
|
'withScope 必须有默认会话回落(否则界面里直接对话时读信一律 403)');
|
||||||
|
assert.ok(
|
||||||
|
!/function withScope\(path, context\) \{[\s\S]{0,200}?if \(!mailSessionID\) return path;/.test(src)
|
||||||
|
|| /defaultSessionId/.test(src.match(/function withScope[\s\S]{0,400}/)[0]),
|
||||||
|
'withScope 仍是「取不到就原样返回」—— 那是迁移期兜底,服务端已不再放行');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('默认会话在装配期问一次,而不是每次 withScope 都问', () => {
|
||||||
|
assert.ok(/let defaultSessionId = "";/.test(src), '缺少 defaultSessionId 缓存');
|
||||||
|
assert.ok(/let defaultSessionProbed = false;/.test(src), '缺少「已问过」标记');
|
||||||
|
assert.ok(/await loadDefaultSession|loadDefaultSession\(/.test(src), '装配期应调 loadDefaultSession');
|
||||||
|
assert.ok(
|
||||||
|
!/function withScope[\s\S]{0,500}await\s+fetch/.test(src),
|
||||||
|
'withScope 不该自己发请求:它是取值函数');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('★ 默认落点与服务端一致(不一致 = 永远问不到)', () => {
|
||||||
|
const m = src.match(/const DEFAULT_FALLBACK_WORKSPACE = "([^"]+)"/);
|
||||||
|
assert.ok(m, '缺少 DEFAULT_FALLBACK_WORKSPACE 常量');
|
||||||
|
assert.equal(m[1], '/tmp',
|
||||||
|
'必须与 handler.DefaultFallbackWorkspace 一致;不一致则桥问的 key 与服务端认的 key 不同');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('问不到默认会话时不得编一个 id(那是把「不知道」记成「知道」)', () => {
|
||||||
|
// ⚠ 定位方式踩过两次坑:
|
||||||
|
// ① 正则 `/\n}/` 收尾会截到 catch 块里那个独占一行的 `}`;
|
||||||
|
// ② 改用「下一个顶层声明」作边界也不行(后面紧跟注释块)。
|
||||||
|
// 改成按行切:函数头到**行首**的 `}` 为止(缩进 0 的闭括号)。
|
||||||
|
// 失败时是这一格红,不会伪装成「实现有 bug」。
|
||||||
|
const lines = src.split('\n');
|
||||||
|
const start = lines.findIndex((l) => l.includes('async function loadDefaultSession'));
|
||||||
|
assert.ok(start > 0, '未找到 loadDefaultSession');
|
||||||
|
let end = start;
|
||||||
|
while (end < lines.length && lines[end] !== '}') end++;
|
||||||
|
const body = lines.slice(start, end + 1).join('\n');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
/String\(data\?\.session_id \?\? ""\)/.test(body),
|
||||||
|
'应把缺失的 session_id 当空串(端点是纯只读的,没通信过就没有)');
|
||||||
|
assert.ok(
|
||||||
|
!/defaultSessionId\s*=\s*["'`][0-9a-f-]{36}["'`]/.test(body),
|
||||||
|
'不得硬编码或伪造一个 session_id');
|
||||||
|
assert.ok(
|
||||||
|
/catch[\s\S]*?log\?\./.test(body),
|
||||||
|
'问不到要留日志(否则「默认会话一直拿不到」这件事不可见)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('装配期调用不 await(问不到不该拖住注册流程)', () => {
|
||||||
|
assert.ok(
|
||||||
|
/(?:^|[^a-zA-Z])loadDefaultSession\(/.test(src)
|
||||||
|
&& !/await\s+loadDefaultSession\(/.test(src),
|
||||||
|
'应 fire-and-forget:它的失败后果只是那次读信 403,不该阻塞注册');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user