From 85de9ee87b574e2b17d77c4d707366a5dbfffb3c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 01:15:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(opencode=E6=A1=A5):=20withScope=20?= =?UTF-8?q?=E5=9C=A8=E9=9D=9E=E9=82=AE=E4=BB=B6=E8=BD=AE=E6=AC=A1=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E5=88=B0=20/tmp=20=E9=BB=98=E8=AE=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=20=E2=80=94=E2=80=94=20=E9=85=8D=E5=A5=97=2015e4fe9?= =?UTF-8?q?=20=E7=9A=84=E6=94=B6=E4=B8=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 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)。 --- plugins/opencode-mail-bridge/index.js | 52 +++++++++++- .../test/default-session.test.mjs | 85 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 plugins/opencode-mail-bridge/test/default-session.test.mjs diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index ee79629..e31e8a7 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -457,7 +457,17 @@ const downloadAttachmentTool = { * (服务端会记警告),也不猜一个。 */ 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; const sep = path.includes("?") ? "&" : "?"; return `${path}${sep}session_id=${encodeURIComponent(mailSessionID)}`; @@ -718,6 +728,42 @@ const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode sessi 还有宿主句柄 —— 以前没这个需求,所以没有通道。 */ 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 刷屏) // 权限询问的双向定位。 @@ -1258,6 +1304,10 @@ export default async function mailBridge(input) { // 供模块级工具对象(read_inbox)问工作区用,见 hostClient 的注释。 if (client) hostClient = client; + // ★ 2026-10-02:装配期问一次默认会话(非邮件轮次的 session_id 兜底)。 + // 不 await:注册流程不该被它拖住(问不到的后果只是那次读信 403)。 + loadDefaultSession((m) => console.log(m)); + // 注册 Agent。无密钥也无 secret 时先本地生成一把密钥, // 等管理员在后台登记后即可接入(无需重装插件)。 if (!AGENT_KEY && !AGENT_SECRET) { diff --git a/plugins/opencode-mail-bridge/test/default-session.test.mjs b/plugins/opencode-mail-bridge/test/default-session.test.mjs new file mode 100644 index 0000000..3f864e5 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/default-session.test.mjs @@ -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,不该阻塞注册'); +}); \ No newline at end of file