feat(dsh桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
`reverseMap` 只在邮件投递时填 ⇒ 在 dsh 界面里直接对话时 `mailSessionOf(exec)` 为空 ⇒ withScope「取不到就原样返回」⇒ 请求不带 session_id ⇒ 服务端 403 (AgentMayReadSession 收严后,2026-09-15 的迁移期放行已作废)。 不改的后果很具体:**在 dsh 界面里读不到任何信**。 回落答案只能向服务端问 —— dsh 侧给不出 session_id: opencode 有宿主 session id 可反查(`sessionWorkspace`),pi 有 worker 闭包, 而 dsh 的 exec 里只有 dsh 自己的会话 id,与 AgentMail 会话 uuid 无映射。 这与 workspace 不同(workspace 能从目录/cwd 得到)。 装配期问一次(`loadDefaultSession`),不塞进 withScope: withScope 是取值函数,IO 混进来就变成「拼 URL 时顺带发 HTTP」, 判据也无法用裸对象驱动(同 homeagent 那次的教训)。 问不到就当没有:不带 session_id → 服务端 403(可见的错误), 而不是静默放行成越权。 判据 4 格(接线形状 + 常量一致性 + 不得伪造 id + 失败要留日志)。 三个变异各红 3 格(判据之间交叉命中,说明它们各自都在钉不同的东西)。 tsc --noEmit 通过;桥全量 429/429 绿。
This commit is contained in:
@ -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 一致:
|
||||
* 不一致 = 桥问的是<E79A84><E698AF><EFBFBD>个 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<void> => {
|
||||
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)}`;
|
||||
|
||||
67
plugins/dsh-mail-bridge/test/default-session.test.mjs
Normal file
67
plugins/dsh-mail-bridge/test/default-session.test.mjs
Normal file
@ -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),
|
||||
'问不到要留日志(否则「默认会话一直拿不到」这件事不可见)');
|
||||
});
|
||||
Reference in New Issue
Block a user