From ca9688b5cc73a8587c3b7f888efa826a43048015 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 15 Jan 2026 10:57:00 +0000 Subject: [PATCH] feat(session): add dmScope for multi-user DM isolation Co-authored-by: Alphonse-arianee --- CHANGELOG.md | 1 + README.md | 18 ++++----- docs/cli/security.md | 1 + docs/concepts/session.md | 13 ++++++- docs/gateway/configuration.md | 5 +++ docs/gateway/security.md | 12 ++++++ scripts/clawtributors-map.json | 1 + src/commands/doctor-security.ts | 11 +++++- src/commands/onboard-channels.ts | 2 + src/config/schema.ts | 3 ++ src/config/types.base.ts | 3 ++ src/config/zod-schema.session.ts | 5 +++ src/routing/resolve-route.test.ts | 26 +++++++++++++ src/routing/resolve-route.ts | 6 +++ src/routing/session-key.ts | 11 ++++++ src/security/audit.test.ts | 49 +++++++++++++++++++++++++ src/security/audit.ts | 30 ++++++++++++++- src/web/auto-reply/monitor/broadcast.ts | 1 + 18 files changed, 184 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed76e0ac2..f43e8e76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`. - CLI: set process titles to `clawdbot-` for clearer process listings. - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. +- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. - Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - TUI: show provider/model labels for the active session and default model. diff --git a/README.md b/README.md index 4a37dc1f4..c288d6858 100644 --- a/README.md +++ b/README.md @@ -484,13 +484,13 @@ Thanks to all clawtributors: antons austinm911 blacksmith-sh[bot] grp06 HeimdallStrategy imfing jalehman jarvis-medmatic mahmoudashraf93 petter-b pkrmf RandyVentures dan-dr erikpr1994 jonasjancarik Keith the Silly Goose kkarimi L36 Server mitschabaude-bot neist chrisrodz Friederike Seiler gabriel-trigo iamadig Kit koala73 manmal ngutman ogulcancelik pasogott - petradonka VACInc zats Chris Taylor Django Navarro pcty-nextgen-service-account rubyrunsstuff Syhids Aaron Konyer erik-agens - evalexpr fcatuhe henrino3 jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan mjrussell - oswalpalash p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker alejandro maza andrewting19 Asleep123 - bolismauro cash-echo-bot Clawd conhecendocontato ThomsenDrake gtsifrikas HazAT hrdwdmrbl hugobarauna Jarvis - Jefferson Nunn kitze levifig Lloyd loukotal Marc martinpucik Miles mrdbstn MSch - Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha siraht - snopoke The Admiral voidserf wes-davis wstock YuriNachos Zach Knickerbocker Azade carlulsoe cpojer - ddyo Erik latitudeki5223 longmaba Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres ronak-guliani - thesash William Stock + petradonka VACInc zats Chris Taylor Django Navarro pcty-nextgen-service-account rubyrunsstuff Syhids Aaron Konyer adam91holt + erik-agens evalexpr fcatuhe henrino3 jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan + mjrussell oswalpalash p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker alejandro maza andrewting19 + Asleep123 bolismauro cash-echo-bot Clawd conhecendocontato Drake Thomsen gtsifrikas HazAT hrdwdmrbl hugobarauna + Jarvis Jefferson Nunn kitze levifig Lloyd loukotal Marc martinpucik Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha + siraht snopoke The Admiral voidserf wes-davis wstock YuriNachos Zach Knickerbocker Alphonse-arianee Azade + carlulsoe cpojer ddyo Erik latitudeki5223 longmaba Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin + Randy Torres ronak-guliani thesash William Stock

diff --git a/docs/cli/security.md b/docs/cli/security.md index 7295b2e99..29afac6f0 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -20,3 +20,4 @@ clawdbot security audit --deep clawdbot security audit --fix ``` +The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index e1b5fc348..005edf7be 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -7,6 +7,11 @@ read_when: Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent::` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored. +Use `session.dmScope` to control how **direct messages** are grouped: +- `main` (default): all DMs share the main session for continuity. +- `per-peer`: isolate by sender id across channels. +- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes). + ## Gateway is the source of truth All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. @@ -32,8 +37,11 @@ the workspace is writable. See [Memory](/concepts/memory) and [Compaction](/concepts/compaction). ## Mapping transports → session keys -- Direct chats collapse to the per-agent primary key: `agent::`. - - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. +- Direct chats follow `session.dmScope` (default `main`). + - `main`: `agent::` (continuity across devices/channels). + - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. + - `per-peer`: `agent::dm:`. + - `per-channel-peer`: `agent:::dm:`. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. - Legacy `group:` keys are still recognized for migration. @@ -77,6 +85,7 @@ Send these as standalone messages so they register. { session: { scope: "per-sender", // keep group keys separate + dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes) idleMinutes: 120, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a7efb838e..4fcb02090 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2261,6 +2261,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto { session: { scope: "per-sender", + dmScope: "main", idleMinutes: 60, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json @@ -2285,6 +2286,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto Fields: - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. +- `dmScope`: how DM sessions are grouped (default: `"main"`). + - `main`: all DMs share the main session for continuity. + - `per-peer`: isolate DMs by sender id across channels. + - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 8d32b2b44..22552d9f6 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -123,6 +123,18 @@ clawdbot pairing approve Details + files on disk: [Pairing](/start/pairing) +## DM session isolation (multi-user mode) + +By default, Clawdbot routes **all DMs into the main session** so your assistant has continuity across devices and channels. If **multiple people** can DM the bot (open DMs or a multi-person allowlist), consider isolating DM sessions: + +```json5 +{ + session: { dmScope: "per-channel-peer" } +} +``` + +This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). + ## Allowlists (DM + groups) — terminology Clawdbot has two separate “who can trigger me?” layers: diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index 160b7e03f..319ce2339 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,5 +1,6 @@ { "ensureLogins": [ + "alphonse-arianee", "ronak-guliani", "cpojer", "carlulsoe", diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 73890941a..565575f5c 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -34,6 +34,8 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { .map((v) => v.trim()) .filter(Boolean); const allowCount = Array.from(new Set([...normalizedCfg, ...normalizedStore])).length; + const dmScope = cfg.session?.dmScope ?? "main"; + const isMultiUserDm = hasWildcard || allowCount > 1; if (dmPolicy === "open") { const allowFromPath = `${params.allowFromPath}allowFrom`; @@ -43,7 +45,6 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, ); } - return; } if (dmPolicy === "disabled") { @@ -51,12 +52,18 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { return; } - if (allowCount === 0) { + if (dmPolicy !== "open" && allowCount === 0) { warnings.push( `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, ); warnings.push(` ${params.approveHint}`); } + + if (dmScope === "main" && isMultiUserDm) { + warnings.push( + `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`, + ); + } }; for (const plugin of listChannelPlugins()) { diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 135c10941..63658d7fb 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -167,6 +167,7 @@ async function noteChannelPrimer( "DM security: default is pairing; unknown DMs get a pairing code.", "Approve with: clawdbot pairing approve ", 'Public DMs require dmPolicy="open" + allowFrom=["*"].', + 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, "", ...channelLines, @@ -212,6 +213,7 @@ async function maybeConfigureDmPolicies(params: { "Default: pairing (unknown DMs get a pairing code).", `Approve: clawdbot pairing approve ${policy.channel} `, `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, + 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, ].join("\n"), `${policy.label} DM access`, diff --git a/src/config/schema.ts b/src/config/schema.ts index 74dd93504..b62e7850e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -167,6 +167,7 @@ const FIELD_LABELS: Record = { "commands.useAccessGroups": "Use Access Groups", "ui.seamColor": "Accent Color", "browser.controlUrl": "Browser Control URL", + "session.dmScope": "DM Session Scope", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "messages.ackReaction": "Ack Reaction Emoji", "messages.ackReactionScope": "Ack Reaction Scope", @@ -311,6 +312,8 @@ const FIELD_HELP: Record = { "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "session.dmScope": + 'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).', "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.slack.configWrites": diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 22dada20a..64a503926 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -1,6 +1,7 @@ export type ReplyMode = "text" | "command"; export type TypingMode = "never" | "instant" | "thinking" | "message"; export type SessionScope = "per-sender" | "global"; +export type DmScope = "main" | "per-peer" | "per-channel-peer"; export type ReplyToMode = "off" | "first" | "all"; export type GroupPolicy = "open" | "disabled" | "allowlist"; export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; @@ -54,6 +55,8 @@ export type SessionSendPolicyConfig = { export type SessionConfig = { scope?: SessionScope; + /** DM session scoping (default: "main"). */ + dmScope?: DmScope; resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 3a9efe2fc..781ef3235 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -10,6 +10,11 @@ import { export const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), + dmScope: z.union([ + z.literal("main"), + z.literal("per-peer"), + z.literal("per-channel-peer"), + ]).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 1e24ef61b..a526ee234 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -18,6 +18,32 @@ describe("resolveAgentRoute", () => { expect(route.matchedBy).toBe("default"); }); + test("dmScope=per-peer isolates DM sessions by sender id", () => { + const cfg: ClawdbotConfig = { + session: { dmScope: "per-peer" }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+15551234567" }, + }); + expect(route.sessionKey).toBe("agent:main:dm:+15551234567"); + }); + + test("dmScope=per-channel-peer isolates DM sessions per channel and sender", () => { + const cfg: ClawdbotConfig = { + session: { dmScope: "per-channel-peer" }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+15551234567" }, + }); + expect(route.sessionKey).toBe("agent:main:whatsapp:dm:+15551234567"); + }); + test("peer binding wins over account binding", () => { const cfg: ClawdbotConfig = { bindings: [ diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index f074339bb..318c9ee0c 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -68,6 +68,8 @@ export function buildAgentSessionKey(params: { agentId: string; channel: string; peer?: RoutePeer | null; + /** DM session scope. */ + dmScope?: "main" | "per-peer" | "per-channel-peer"; }): string { const channel = normalizeToken(params.channel) || "unknown"; const peer = params.peer; @@ -77,6 +79,7 @@ export function buildAgentSessionKey(params: { channel, peerKind: peer?.kind ?? "dm", peerId: peer ? normalizeId(peer.id) || "unknown" : null, + dmScope: params.dmScope, }); } @@ -149,6 +152,8 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR return matchesAccountId(binding.match?.accountId, accountId); }); + const dmScope = input.cfg.session?.dmScope ?? "main"; + const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); return { @@ -159,6 +164,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR agentId: resolvedAgentId, channel, peer, + dmScope, }), mainSessionKey: buildAgentMainSessionKey({ agentId: resolvedAgentId, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index d3fbfb512..5bb10fbb8 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -88,9 +88,20 @@ export function buildAgentPeerSessionKey(params: { channel: string; peerKind?: "dm" | "group" | "channel" | null; peerId?: string | null; + /** DM session scope. */ + dmScope?: "main" | "per-peer" | "per-channel-peer"; }): string { const peerKind = params.peerKind ?? "dm"; if (peerKind === "dm") { + const dmScope = params.dmScope ?? "main"; + const peerId = (params.peerId ?? "").trim(); + if (dmScope === "per-channel-peer" && peerId) { + const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; + return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`; + } + if (dmScope === "per-peer" && peerId) { + return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}`; + } return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 379cc3ed4..7ff7f349a 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import { runSecurityAudit } from "./audit.js"; import fs from "node:fs/promises"; import os from "node:os"; @@ -173,6 +174,54 @@ describe("security audit", () => { } }); + it("warns when multiple DM senders share the main session", async () => { + const cfg: ClawdbotConfig = { session: { dmScope: "main" } }; + const plugins: ChannelPlugin[] = [ + { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "Test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isEnabled: () => true, + isConfigured: () => true, + }, + security: { + resolveDmPolicy: () => ({ + policy: "allowlist", + allowFrom: ["user-a", "user-b"], + policyPath: "channels.whatsapp.dmPolicy", + allowFromPath: "channels.whatsapp.", + approveHint: "approve", + }), + }, + }, + ]; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.whatsapp.dm.scope_main_multiuser", + severity: "warn", + }), + ]), + ); + }); + it("adds a warning when deep probe fails", async () => { const cfg: ClawdbotConfig = { gateway: { mode: "local" } }; diff --git a/src/security/audit.ts b/src/security/audit.ts index 9c5397fa8..88a594b87 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -19,6 +19,7 @@ import { collectSyncedFolderFindings, readConfigSnapshotForAudit, } from "./audit-extra.js"; +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { formatOctal, isGroupReadable, @@ -386,10 +387,25 @@ async function collectChannelSecurityFindings(params: { allowFrom?: Array | null; policyPath?: string; allowFromPath: string; + normalizeEntry?: (raw: string) => string; }) => { const policyPath = input.policyPath ?? `${input.allowFromPath}policy`; const configAllowFrom = normalizeAllowFromList(input.allowFrom); const hasWildcard = configAllowFrom.includes("*"); + const dmScope = params.cfg.session?.dmScope ?? "main"; + const storeAllowFrom = await readChannelAllowFromStore(input.provider).catch(() => []); + const normalizeEntry = input.normalizeEntry ?? ((value: string) => value); + const normalizedCfg = configAllowFrom + .filter((value) => value !== "*") + .map((value) => normalizeEntry(value)) + .map((value) => value.trim()) + .filter(Boolean); + const normalizedStore = storeAllowFrom + .map((value) => normalizeEntry(value)) + .map((value) => value.trim()) + .filter(Boolean); + const allowCount = Array.from(new Set([...normalizedCfg, ...normalizedStore])).length; + const isMultiUserDm = hasWildcard || allowCount > 1; if (input.dmPolicy === "open") { const allowFromKey = `${input.allowFromPath}allowFrom`; @@ -408,7 +424,6 @@ async function collectChannelSecurityFindings(params: { detail: `"open" requires ${allowFromKey} to include "*".`, }); } - return; } if (input.dmPolicy === "disabled") { @@ -418,6 +433,18 @@ async function collectChannelSecurityFindings(params: { title: `${input.label} DMs are disabled`, detail: `${policyPath}="disabled" ignores inbound DMs.`, }); + return; + } + + if (dmScope === "main" && isMultiUserDm) { + findings.push({ + checkId: `channels.${input.provider}.dm.scope_main_multiuser`, + severity: "warn", + title: `${input.label} DMs share the main session`, + detail: + "Multiple DM senders currently share the main session, which can leak context across users.", + remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.', + }); } }; @@ -450,6 +477,7 @@ async function collectChannelSecurityFindings(params: { allowFrom: dmPolicy.allowFrom, policyPath: dmPolicy.policyPath, allowFromPath: dmPolicy.allowFromPath, + normalizeEntry: dmPolicy.normalizeEntry, }); } diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts index 8520de80c..1013d50d4 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/src/web/auto-reply/monitor/broadcast.ts @@ -58,6 +58,7 @@ export async function maybeBroadcastMessage(params: { kind: params.msg.chatType === "group" ? "group" : "dm", id: params.peerId, }, + dmScope: params.cfg.session?.dmScope, }), mainSessionKey: buildAgentMainSessionKey({ agentId: normalizedAgentId,