session-confirm 服务
1. 定位
session-confirm 是 Aalis 的人在环(human-in-the-loop)确认协调器:当 authority 判定某个能力命中 confirm 轴(轴 B「意图确认」),需要在用户所在会话里发出一条「回复 Y 确认 / 其他取消」的提示并等待应答时,由本服务统一承担「待确认登记 / 串行排队 / 超时 / 解析 Y-YS / 文案组合」这套平台无关的协调逻辑。
它刻意不自己投递提示、也不自己拦截回复——这两件事因平台而异(OneBot/CLI 走 gateway 总线,WebUI 走 WS)。本服务把协调器做成一个工厂 createChannel(deliver):调用方注入「怎么把文案发给用户」的 deliver,拿回一条 { handler, feed, dispose } 通道;把 handler 注册到 authority.setConfirmHandler(platform, ...),在自己的拦截点调 feed。各平台经 DI 复用同一份协调器实现,零重复、零 plugin→plugin 依赖。
- 服务注册名:
session-confirm—— 即ctx.getService<SessionConfirmService>('session-confirm')里的字符串。 - 契约包:
@aalis/plugin-session-confirm-api(packages/plugin-session-confirm-api/src/index.ts)。 - 默认实现包:
@aalis/plugin-session-confirm(packages/plugin-session-confirm/src/index.ts,provides = ['session-confirm'],packages/plugin-session-confirm/src/index.ts)。
这是「轴 B」的传输/协调层。裁决仍在 authority:哪条能力需要确认、确认成功后授不授临时委托,都是 authority 的事(见 §6 与 docs/concepts/security-model.md)。本服务只负责把 authority 抛来的
AccessRequest变成一次会话内的一问一答。
2. 契约
来自 packages/plugin-session-confirm-api/src/index.ts。该 -api 既有运行时服务(SessionConfirmService),也复用 authority-api 的几个类型,无独立运行时实现(协调器实现在功能插件里)。
2.1 服务接口
// packages/plugin-session-confirm-api/src/index.ts
export interface SessionConfirmService {
/**
* 创建一条确认通道:用 deliver 投递提示文案到 request 所在会话;返回 { handler, feed }。
* 调用方把 handler 注册到 authority.setConfirmHandler(platform, ...),并在自己的拦截点调 feed。
*/
createChannel(deliver: (request: AccessRequest, text: string) => void): ConfirmChannel;
}2.2 ConfirmChannel —— 一条确认通道
// packages/plugin-session-confirm-api/src/index.ts
export interface ConfirmChannel {
/** authority 确认回调(注册到 setConfirmHandler(platform, ...))。 */
handler: AccessConfirmHandler;
/**
* 在平台自己的拦截点喂一条回复给该 session 的未决确认。
* 仅当 replyUserId === 发起确认的触发者 userId 时才消费(防群里第三方抢答);私聊/webui 天然同人。
* @returns 命中并消费 → true(调用方据此「吞掉」该输入);无未决 / 非本人 → false(放行)。
*/
feed(sessionId: string, replyText: string, replyUserId?: string): boolean;
/** 卸载/热重载时清理:清所有未决确认的超时定时器并把挂起 Promise 安全拒掉(resolve false)。 */
dispose(): void;
}服务类型经 declaration merging 注册到 core:ServiceTypeMap['session-confirm'] = SessionConfirmService(packages/plugin-session-confirm-api/src/index.ts)。
2.3 复用 authority-api 的类型
deliver、handler、决策的形状都来自 @aalis/plugin-authority-api,写 provider/consumer 时需要懂这几个:
// packages/plugin-authority-api/src/index.ts
export interface AccessRequest {
name: string; // 操作名(命令名 / 工具名)
type: 'command' | 'tool';
capability: CapabilityId; // 触达的(受限)能力
args?: Record<string, unknown>;
sessionId: string; // 会话标识(群=群 id;私聊=用户);feed 用它定位未决项
platform: string; // 用于挑 setConfirmHandler(platform) 的 handler
userId?: string; // 发起者;feed 用它防第三方抢答
confirm?: CapabilityConfirm; // 'session' | 'always';'always' 不接受会话记忆
}
// packages/plugin-authority-api/src/index.ts
export interface AccessDecision {
allowed: boolean;
grant?: TemporaryGrantSpec; // 批准后附带的临时委托范围(once / session)
}
// packages/plugin-authority-api/src/index.ts
export type AccessConfirmHandler = (request: AccessRequest) => Promise<boolean | AccessDecision>;
// packages/plugin-authority-api/src/index.ts
export type CapabilityConfirm = 'session' | 'always';AccessConfirmHandler 返回 boolean | AccessDecision:true/false 是最简允许/拒绝;返回对象可附带 grant(如「本会话 10 分钟内放行」)。authority 侧 normalizeDecision 把 boolean 归一为 { allowed },并在 grant.scope === 'session' 时建立临时委托(packages/plugin-authority/src/authority-manager.ts)。
3. 谁提供 / 谁消费
提供方(参考实现)
@aalis/plugin-session-confirm(packages/plugin-session-confirm/src/index.ts):apply里ctx.provide('session-confirm', { createChannel })(packages/plugin-session-confirm/src/index.ts)。- 同时它自用一条 bus 通道(
createChannel传入「投递走 gateway 总线」),并把这条通道的handler注册成 authority 的'*'fallback、把feed挂到inbound:confirm相位——从而覆盖 OneBot/CLI 等「仅靠消息总线」的会话型平台(packages/plugin-session-confirm/src/index.ts)。 inject:required: ['gateway']、optional: ['authority'](packages/plugin-session-confirm/src/index.ts,与package.json的aalis.service双源一致)。注意它依赖 gateway(投递/拦截通道),不依赖 authority 作为运行前提——authority 缺席时仍能 provide 服务、只是没人调它的 handler。
消费方(典型消费点)
@aalis/plugin-webui-server(packages/plugin-webui-server/src/index.ts):标准「另一种平台复用同一协调器」的范例。ctx.whenService<SessionConfirmService>('session-confirm', ...)里createChannel,注入WS 投递(type: 'confirm',兼作前端「确认模式」信号,抑制富客户端的「打字即打断」),并把handler注册到authority.setConfirmHandler('webui', ...)(packages/plugin-webui-server/src/index.ts)。- 在 WS
onmessage里调confirmChannel.feed(sessionId, trimmed, wsIdentity.userId),命中即return(吞掉,不当普通消息处理)(packages/plugin-webui-server/src/index.ts)。
@aalis/plugin-authority(packages/plugin-authority/src/authority-manager.ts):不是直接getService('session-confirm'),而是经setConfirmHandler反向持有 handler。requestAccess里取confirmHandlers.get(request.platform) ?? confirmHandlers.get('*')调用确认回调(packages/plugin-authority/src/authority-manager.ts)。这就是协调器handler被实际触发的入口。
4. 写一个 provider
绝大多数第三方不需要重写本服务——默认实现已是平台无关的成熟协调器。你更可能是要为一个新平台接一条确认通道(见 §5 的「按消费方姿势」),即复用现成 createChannel。
但如果你确实要替换/另写一个 session-confirm 实现(例如改文案、改超时、接入外部审批系统),契约只有一个方法:
最小必须 vs 可选
- 必须:
createChannel(deliver)返回一个ConfirmChannel,其handler是合法的AccessConfirmHandler(接AccessRequest、await用户应答、resolveboolean | AccessDecision),feed(sessionId, replyText, replyUserId?)在拦截点喂回复并返回是否命中,dispose()在卸载时清定时器、安全拒掉所有挂起 Promise。 - 可选但强烈建议:超时兜底(无人应答时 resolve
false—— 默认实现 60s,见packages/plugin-session-confirm/src/index.ts);同一 session 多个并行确认请求的串行 FIFO 排队(同一回合并行工具可触发多个确认,不能抢占式互相resolve(false),见packages/plugin-session-confirm/src/index.ts)。 - 必须遵守的语义:
feed必须做触发者校验head.userId === replyUserId才消费(见 §6);confirm === 'always'时不接受任何会话记忆(每次都问)。
注册(ctx.provide + 双源同步)
// packages/<your-plugin>/src/index.ts
import type { Context } from '@aalis/core';
import type {
AccessConfirmHandler,
AccessDecision,
AccessRequest,
} from '@aalis/plugin-authority-api';
import type { ConfirmChannel, SessionConfirmService } from '@aalis/plugin-session-confirm-api';
export const name = '@aalis/plugin-my-session-confirm';
export const provides = ['session-confirm']; // ← 双源之一:导出 provides
export const inject = { required: ['gateway'], optional: ['authority'] };
function createChannel(deliver: (request: AccessRequest, text: string) => void): ConfirmChannel {
const pending = new Map<string, {
request: AccessRequest;
resolve: (v: boolean | AccessDecision) => void;
timer: ReturnType<typeof setTimeout>;
userId?: string;
}>();
const handler: AccessConfirmHandler = request =>
new Promise(resolve => {
const timer = setTimeout(() => {
pending.delete(request.sessionId);
deliver(request, '⏰ 操作确认已超时,已自动取消。');
resolve(false); // 超时默认拒(无人在场即安全失败)
}, 60_000);
timer.unref?.(); // 待确认定时器不阻止进程优雅退出
pending.set(request.sessionId, { request, resolve, timer, userId: request.userId });
deliver(request, `⚠️ ${request.name} 是高危操作。回复 Y 允许;其他取消。`);
});
const feed = (sessionId: string, replyText: string, replyUserId?: string): boolean => {
const w = pending.get(sessionId);
if (!w) return false;
if (w.userId !== replyUserId) return false; // ← 仅触发者本人能应答(防第三方抢答)
clearTimeout(w.timer);
pending.delete(sessionId);
const ok = replyText.trim().toLowerCase() === 'y';
w.resolve(ok ? { allowed: true } : false);
return true; // 命中并消费 → 调用方吞掉该输入
};
const dispose = (): void => {
for (const w of pending.values()) {
clearTimeout(w.timer);
w.resolve(false);
}
pending.clear();
};
return { handler, feed, dispose };
}
export async function apply(ctx: Context): Promise<void> {
const service: SessionConfirmService = { createChannel };
ctx.provide('session-confirm', service);
// …(若也要自用 bus 通道覆盖会话型平台,参照默认实现 §3)
}上例是简化骨架,省略了 FIFO 排队与
parseConfirmReply的 Y/YS 语义;正式实现请直接对照packages/plugin-session-confirm/src/index.ts。
package.json 的 aalis.service 必须与导出的 provides/inject 保持双源一致(manifest 双源约定见 docs/concepts/manifest-metadata.md):
// package.json
{
"aalis": {
"service": {
"required": ["gateway"],
"optional": ["authority"],
"provides": ["session-confirm"]
}
}
}同名竞争 / 覆盖
DI 按名选胜者,顺序为 偏好 > 优先级(ServicePriority Backend 0 / Override 50 / System 200)> 注册顺序(见 docs/concepts/service-model.md、docs/core/service.md)。默认实现以 ctx.provide('session-confirm', service) 注册(未显式抬高优先级),第三方若要覆盖它,用更高优先级 provide 或让用户偏好选择即可。createChannel 无 entryId、非 per-entry,全局一个服务实例。
5. 标准消费姿势(接一个新平台的确认通道)
最常见的使用方式是作为一个新平台复用协调器,正如 webui-server 所做:
import type { AuthorityService } from '@aalis/plugin-authority-api';
import type { ConfirmChannel, SessionConfirmService } from '@aalis/plugin-session-confirm-api';
let confirmChannel: ConfirmChannel | undefined;
// session-confirm 可能晚于本插件上线 → 用 whenService 等它就绪
ctx.whenService<SessionConfirmService>('session-confirm', confirmSvc => {
// 注入「本平台怎么把文案发给用户」
confirmChannel = confirmSvc.createChannel((request, text) => {
sendToMyPlatform(request.sessionId, text);
});
// 把 handler 挂到 authority,键用本平台名(authorize 时按 request.platform 取)
// getService 每次现取,别缓存(provider 反弹会失效,见 lazy-service-access)
ctx.getService<AuthorityService>('authority')?.setConfirmHandler('myplatform', confirmChannel.handler);
});
// 在本平台的入站拦截点喂回复;命中即吞掉,别让它当普通消息进 agent
function onInbound(sessionId: string, text: string, userId?: string): void {
if (confirmChannel?.feed(sessionId, text, userId)) return; // 吞掉确认回复
// …正常处理
}
// 热重载/卸载时清理
ctx.onDispose(() => confirmChannel?.dispose());要点:
- lazy / whenService:
session-confirm与authority都可能晚上线或热重载反弹,故用whenService注册时机、getService每用现取——切勿把服务实例缓存进闭包(见 docs/concepts/lazy-service-access.md)。 - 服务缺失即降级:
session-confirm缺席时whenService不回调,没人注册 handler;authority 的requestAccess取不到 handler 时返回false(拒绝)(packages/plugin-authority/src/authority-manager.ts)——「无人在场即安全失败」。authority 缺席时setConfirmHandler那行的?.直接短路,不报错。 - 吞掉命中的回复:
feed返回true时务必中止后续处理(默认 bus 通道把feed放在inbound:confirm相位最前,命中即不调next(),避免回复触达 agent 触发对在途生成的 abort ——packages/plugin-session-confirm/src/index.ts、packages/plugin-gateway-api/src/index.ts)。 - 错误边界:handler 抛异常时 authority 侧 catch 并按拒绝处理(
packages/plugin-authority/src/authority-manager.ts),但你自己的deliver失败不应让 Promise 永挂——超时兜底是最后防线。
6. 能力 / 风险 → 影响
本服务直接卡在 authority 的 confirm 轴上,provider/consumer 必须遵守以下安全约束:
- 触发者绑定(防群里第三方抢答):
feed仅在head.userId === replyUserId时消费(packages/plugin-session-confirm/src/index.ts、契约注释packages/plugin-session-confirm-api/src/index.ts)。群聊里sessionId是群,若不绑发起者userId,任意群成员都能替授权方按下「Y」——等于帮别人静默提权。私聊/WebUI 触发者与应答者天然同人;两者皆undefined(系统注入等无 userId 场景)也视为同人。新平台的feed调用必须传入真实应答者userId(webui 传wsIdentity.userId,bus 通道传data.message.userId)。 confirm: 'always'不接受会话记忆:最高危能力每次都问,authorityrequestAccess在always时跳过临时委托记忆(packages/plugin-authority/src/authority-manager.ts),协调器文案也相应改为「每次都需确认」(packages/plugin-session-confirm/src/index.ts)。provider 不得擅自为always引入「记住本会话」。- confirm 与 visibility 正交:visibility 管「能不能」(授权),confirm 管「是不是你本人此刻要」(意图确认 / 防注入减速带)。即便 owner(
*),命中 confirm 的能力仍须确认——抵御 owner 会话内提示注入借权静默调高危(packages/plugin-authority-api/src/index.ts)。本服务是这条「减速带」的落地,不要为图省事让 owner 跳过确认。 - 超时默认拒:无人应答 60s 后 resolve
false(安全失败),不要默认放行。 - session 授予是临时委托:确认返回
{ allowed: true, grant: { scope: 'session', durationSeconds } }会让 authority 建立一条限时临时委托(YS 默认 600s,packages/plugin-session-confirm/src/index.ts;委托建立见packages/plugin-authority/src/authority-manager.ts,193+)。这条委托按(capability, sessionId, userId)三元组匹配消费(packages/plugin-authority/src/authority-manager.ts),不跨会话、不跨用户泄漏。
7. 边界与坑
- 审计旧账已结清:旧版审计曾标注「
feed仅按sessionId匹配、不绑userId,群里第三方可抢答」。当前代码已修复——feed签名加了replyUserId?,且消费前强制head.userId !== replyUserId → return false(packages/plugin-session-confirm/src/index.ts;webui 侧packages/plugin-webui-server/src/index.ts)。第三方自写 provider 时必须照此实现这道校验,否则会把这个洞重新引回来。 - 依赖的是 gateway,不是 authority:本插件
required: ['gateway'](投递与inbound:confirm拦截)、optional: ['authority']。这意味着不加载 gateway 它根本起不来;而 authority 缺席时它仍 provide 服务、仅没人来调 handler。别误以为它「依赖权限系统」。 - handler 注册是「后注册覆盖」语义:
setConfirmHandler(platform, handler)往Map里set,同platform后注册者覆盖前者(packages/plugin-authority/src/authority-manager.ts)。多个插件抢同一platform的 confirm 通道会互相覆盖——按平台分键('webui'/'*'/ 你的平台名),不要重复占用别人的键。'*'是 fallback,精确平台优先(packages/plugin-authority/src/authority-manager.ts)。 - per-session 单队列、无跨实例共享:协调器状态全在
createChannel闭包的Map里,进程内、不持久化。进程重启/热重载会丢掉所有未决确认(dispose把它们安全拒掉)——这是有意的「无人在场即取消」,不是 bug。 deliver必须可重入且不抛:协调器在present(发提示)和超时分支都会调deliver,且超时分支在setTimeout回调里——deliver抛异常无人接,请自行 try/catch(默认 bus 通道用void gateway?.dispatchOutbound(...)吞掉了 Promise 拒绝,packages/plugin-session-confirm/src/index.ts)。
8. 交叉链接
- docs/concepts/security-model.md —— 两轴鉴权(level + confirm)、risk → (visibility, confirm) 推导、owner 仍吃 confirm。
- docs/concepts/service-model.md、docs/core/service.md —— DI 按名选胜者、ServicePriority、provide/inject 双源。
- docs/concepts/lazy-service-access.md —— whenService 注册时机、getService 每用现取、provider 反弹失效。
- docs/concepts/manifest-metadata.md ——
package.json aalis.service与导出provides/inject双源一致。 - docs/concepts/message-llm-pipeline.md ——
inbound:confirm相位在入站管道里的位置(最前、命中即吞)。 - docs/core/authority.md —— authority 服务、临时能力委托、
requestAccess/setConfirmHandler。 - docs/services/gateway.md —— bus 投递(
dispatchOutbound)与INBOUND_PHASE相位常量来源。