diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 800bf71..4ebcbc5 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -381,6 +381,13 @@ interface PluginConfig { export function apply(ctx: any, config: PluginConfig): void { const GW = config.gateway.url || 'http://127.0.0.1:8180'; + + /* + * 默认落点必须与 handler.DefaultFallbackWorkspace 一致: + * 不一致 = 桥问的是���个 key、服务端认的是另一个 ⇒ 永远问不到。 + * 选 /tmp 的理由见服务端那个常量的注释(非邮件轮次没有真实工作目录)。 + */ + const DEFAULT_FALLBACK_WORKSPACE = '/tmp'; const AGENT_NAME = config.gateway.agentName || 'dsh'; let AGENT_KEY = config.gateway.agentKey || ''; const AGENT_SECRET = config.gateway.agentSecret || ''; @@ -393,9 +400,36 @@ export function apply(ctx: any, config: PluginConfig): void { const client = new GatewayClient(GW, AGENT_NAME, AGENT_KEY, AGENT_SECRET); + /* + * ★ 2026-10-02:装配期问一次「我的默认会话」(非邮件轮次的 session_id 出口)。 + * + * withScope 在 `mailSessionOf(exec)` 为空时用它兜底 —— 那发生在**界面里直接 + * 对话**时(reverseMap 只在邮件投递时填)。服务端对未声明 session_id 的读信 + * 一律 403(AgentMayReadSession 收严),所以不留这个兜底就等于 + * 「在 dsh 界面里读不到信」。 + * + * 那个端点是**纯只读**的:默认工作区一封都没通过时返回 session_id=null, + * 不建会话。所以这里拿到空是正常形状,不是错误 —— 后果只是那次读信 403, + * 而不是被静默放行成越权。 + */ + let defaultSessionId = ''; + const loadDefaultSession = async (): Promise => { + try { + const r = await client.get(`/agent/session/default?workspace=${encodeURIComponent(DEFAULT_FALLBACK_WORKSPACE)}`); + defaultSessionId = String(r?.session_id ?? ''); + if (defaultSessionId) { + ctx.logger.info(`[dsh-mail-bridge] 默认会话(/tmp): ${defaultSessionId}`); + } + } catch (e: any) { + // 问不到就当没有:不带 session_id,服务端会 403(可见)而不是静默放行。 + ctx.logger.error(`[dsh-mail-bridge] 取默认会话失败(本次不带 session_id): ${e?.message || e}`); + } + }; + // 注册 Agent (async () => { try { + await loadDefaultSession(); await client.register(); ctx.logger.info(`[dsh-mail-bridge] 已接入 ${GW},身份 ${AGENT_NAME}`); } catch (e: any) { @@ -984,7 +1018,13 @@ export function apply(ctx: any, config: PluginConfig): void { * 也不猜一个 —— 猜错会拼出投不到或投到别处的地址。 */ function withScope(path: string, exec: any): string { - const sid = mailSessionOf(exec); + // ★ 2026-10-02:取不到邮件会话时回落到**默认会话**(/tmp),不再原样返回。 + // + // 旧写法「原样返回」在 2026-09-15 之前是服务端放行的兜底(migration 期语义); + // 那个放行已作废(AgentMayReadSession 对未声明 session_id 一律 403), + // 而 dsh 的 `reverseMap` 只在邮件投递时才填 ⇒ 在界面里直接对话时 + // `mailSessionOf(exec)` 为空 ⇒ 读信 403。 + const sid = mailSessionOf(exec) || defaultSessionId; if (!sid) return path; const sep = path.includes('?') ? '&' : '?'; return `${path}${sep}session_id=${encodeURIComponent(sid)}`; diff --git a/plugins/dsh-mail-bridge/test/default-session.test.mjs b/plugins/dsh-mail-bridge/test/default-session.test.mjs new file mode 100644 index 0000000..ce8b7c8 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/default-session.test.mjs @@ -0,0 +1,67 @@ +/** + * dsh:`withScope` 在**非邮件轮次**也必须声明一个合法 session_id(2026-10-02)。 + * + * ## 为什么 + * + * 服务端 `AgentMayReadSession` 收严后(`15e4fe9`),未声明 session_id 的读信 + * 一律 403。而 dsh 的 `reverseMap` **只在邮件投递时**才填 ⇒ 在界面里直接对话时 + * `mailSessionOf(exec)` 为空。 + * + * 旧写法是「取不到就原样返回 path」,那在 2026-09-15 之前是服务端的迁移期兜底; + * 那个放行已作废。所以不改 = **在 dsh 界面里读不到任何信**。 + * + * 回落答案只能向服务端问:`session_id` 是 AgentMail 会话的 UUID, + * dsh 侧的目录/会话 id 给不出它(这一点与 opencode 不同 —— 它有宿主 session id 可反查)。 + * + * ## 为什么默认落点是 /tmp + * + * 与服务端 `handler.DefaultFallbackWorkspace` 必须一致,不一致 = 永远问不到。 + */ +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, '..', 'src', 'index.ts'), 'utf8'); + +test('★ withScope 在 mailSessionOf 为空时回落到默认会话', () => { + assert.ok( + /const sid = mailSessionOf\(exec\) \|\| defaultSessionId;/.test(src), + 'withScope 必须有默认会话回落(否则界面里直接对话时读信一律 403)'); + assert.ok( + !/function withScope\(path: string, exec: any\): string \{\s*\n\s*const sid = mailSessionOf\(exec\);\s*\n\s*if \(!sid\) return path;/.test(src), + 'withScope 仍是「取不到就原样返回」—— 那是 2026-09-15 前的迁移期兜底,服务端已不再放行'); +}); + +test('默认会话在装配期问一次(不是每次 withScope 都问)', () => { + assert.ok(/let defaultSessionId = '';/.test(src), '缺少 defaultSessionId 缓存'); + assert.ok(/await loadDefaultSession\(\);/.test(src), '装配期应调 loadDefaultSession'); + assert.ok( + !/withScope[\s\S]{0,400}await\s+(client\.get|fetch)/.test(src), + 'withScope 不该自己发请求:它是取值函数,IO 混进来会让「拼 URL 时顺带发 HTTP」,' + + '也让判据无法用裸对象驱动'); +}); + +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(那是把「不知道」记成「知道」)', () => { + const m = src.match(/const loadDefaultSession = async[\s\S]*?\n \};/); + assert.ok(m, '未找到 loadDefaultSession'); + const body = m[0]; + assert.ok( + /String\(r\?\.session_id \?\? ''\)/.test(body), + '应把缺失的 session_id 当空串(端点是纯只读的,没通信过就没有)'); + assert.ok( + !/defaultSessionId\s*=\s*['"`][0-9a-f-]{36}['"`]/.test(body), + '不得硬编码或伪造一个 session_id'); + assert.ok( + /catch[\s\S]*?ctx\.logger\.error/.test(body), + '问不到要留日志(否则「默认会话一直拿不到」这件事不可见)'); +}); \ No newline at end of file