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:
2026-10-02 01:12:35 +08:00
parent de6b91516a
commit 86e05e7027
2 changed files with 108 additions and 1 deletions

View File

@ -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)}`;

View 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),
'问不到要留日志(否则「默认会话一直拿不到」这件事不可见)');
});