diff --git a/CHANGELOG.md b/CHANGELOG.md index 2510d5aab25..cf2669d1704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable. - CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125. - Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9. -- Telegram/sandbox: keep Telegram bot DMs on per-account sender session keys even when `session.dmScope=main`, so sandbox/tool policy can distinguish Telegram-originated direct chats from the agent main session. +- Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs. - Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. - Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo. - Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 1178502165f..1b71e87d07b 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -23,15 +23,13 @@ host configuration. ## Session key shapes (examples) -Most direct messages collapse to the agent’s **main** session: +Direct messages collapse to the agent’s **main** session by default: - `agent::` (default: `agent:main:main`) -Telegram bot direct messages are isolated per bot account and sender even when -`session.dmScope` is `main`, so sandbox and tool policy decisions can distinguish -channel-originated DMs from the agent main session: - -- `agent::telegram::direct:` +Even when direct-message conversation history is shared with main, sandbox and +tool policy use a derived per-account direct-chat runtime key for external DMs +so channel-originated messages are not treated like local main-session runs. Groups and channels remain isolated per channel: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 335c77f33fc..37fab1f1695 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -72,13 +72,6 @@ openclaw pairing approve telegram Token resolution order is account-aware. In practice, config values win over env fallback, and `TELEGRAM_BOT_TOKEN` only applies to the default account. -## Session isolation - -Telegram bot DMs use per-account sender session keys, for example -`agent:main:telegram:default:direct:814912386`. This keeps Telegram-originated -tool and sandbox policy distinct from the agent main session even when the -global `session.dmScope` setting is `main`. - ## Telegram side settings diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index f2f1f519013..41bdbd89f2e 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -76,12 +76,10 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); - expect(ctx?.ctxPayload?.SessionKey).toBe( - "agent:main:telegram:default:direct:42:thread:1234:42", - ); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); - it("uses the Telegram direct session key when no thread id", async () => { + it("uses the main session key when no thread id", async () => { const ctx = await buildContext({ message_id: 2, chat: { id: 1234, type: "private" }, @@ -92,7 +90,7 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); - expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:default:direct:42"); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); }); diff --git a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts index 13a22b17a4d..c36b60f2ea6 100644 --- a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts +++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts @@ -140,7 +140,7 @@ describe("buildTelegramMessageContext named-account DM fallback", () => { expect(ctx).toBeNull(); }); - it("uses a per-account session key for default-account DMs", async () => { + it("uses the main session key for default-account DMs", async () => { setRuntimeConfigSnapshot(baseCfg); const ctx = await buildTelegramMessageContextForTest({ @@ -154,7 +154,7 @@ describe("buildTelegramMessageContext named-account DM fallback", () => { }, }); - expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:default:direct:42"); - expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:default:direct:42"); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); + expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:main"); }); }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 35267c21ace..75c2d979530 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -6,8 +6,9 @@ import { import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; -import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveDefaultTelegramAccountId } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js"; import { resolveTelegramInboundBody } from "./bot-message-context.body.js"; @@ -234,7 +235,10 @@ export const buildTelegramMessageContext = async ({ }); const requiresExplicitAccountBinding = ( candidate: ReturnType["route"], - ): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; + ): boolean => + normalizeAccountId(candidate.accountId) !== + normalizeAccountId(resolveDefaultTelegramAccountId(freshCfg)) && + candidate.matchedBy === "default"; const isNamedAccountFallback = requiresExplicitAccountBinding(route); // Named-account groups still require an explicit binding; DMs get a // per-account fallback session key below to preserve isolation. diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 85396bd4a52..5ae87c83074 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -1697,7 +1697,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:telegram:opie:direct:999"); + expect(payload.SessionKey).toBe("agent:opie:main"); }); it("reloads DM routing bindings between messages without recreating the bot", async () => { @@ -1705,7 +1705,12 @@ describe("createTelegramBot", () => { const configForAgent = (agentId: string) => ({ channels: { telegram: { + defaultAccount: "work", accounts: { + work: { + botToken: "tok-work", + dmPolicy: "open", + }, opie: { botToken: "tok-opie", dmPolicy: "open", @@ -1809,7 +1814,12 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ channels: { telegram: { + defaultAccount: "work", accounts: { + work: { + botToken: "tok-work", + dmPolicy: "open", + }, opie: { botToken: "tok-opie", dmPolicy: "open", diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 854df5e15bd..69c9c12748f 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -2190,9 +2190,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.CommandTargetSessionKey).toBe( - "agent:main:telegram:default:direct:12345:thread:12345:99", - ); + expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99"); }); it("allows native DM commands for paired users", async () => { diff --git a/extensions/telegram/src/conversation-route.base-session-key.test.ts b/extensions/telegram/src/conversation-route.base-session-key.test.ts index a9ddef83515..b369947e09d 100644 --- a/extensions/telegram/src/conversation-route.base-session-key.test.ts +++ b/extensions/telegram/src/conversation-route.base-session-key.test.ts @@ -6,7 +6,7 @@ import { resolveTelegramConversationBaseSessionKey } from "./conversation-route. describe("resolveTelegramConversationBaseSessionKey", () => { const cfg: OpenClawConfig = {}; - it("uses a per-account key for default-account DMs", () => { + it("keeps default-account DMs on the route session key", () => { expect( resolveTelegramConversationBaseSessionKey({ cfg, @@ -20,7 +20,34 @@ describe("resolveTelegramConversationBaseSessionKey", () => { isGroup: false, senderId: 12345, }), - ).toBe("agent:main:telegram:default:direct:12345"); + ).toBe("agent:main:main"); + }); + + it("keeps configured default-account DMs on the route session key", () => { + expect( + resolveTelegramConversationBaseSessionKey({ + cfg: { + channels: { + telegram: { + defaultAccount: "work", + accounts: { + work: {}, + personal: {}, + }, + }, + }, + }, + route: { + agentId: "main", + accountId: "work", + matchedBy: "default", + sessionKey: "agent:main:main", + }, + chatId: 12345, + isGroup: false, + senderId: 12345, + }), + ).toBe("agent:main:main"); }); it("uses the per-account fallback key for named-account DMs without an explicit binding", () => { diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 290b515128e..f8249e4cc7b 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -7,11 +7,13 @@ import { import { buildAgentSessionKey, deriveLastRoutePolicy, + normalizeAccountId, resolveAgentRoute, } from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, sanitizeAgentId } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { resolveDefaultTelegramAccountId } from "./accounts.js"; import { buildTelegramGroupPeerId, buildTelegramParentPeer, @@ -147,10 +149,13 @@ export function resolveTelegramConversationBaseSessionKey(params: { isGroup: boolean; senderId?: string | number | null; }): string { - if (params.isGroup || params.route.matchedBy === "binding.channel") { + const routeAccountId = normalizeAccountId(params.route.accountId); + const defaultAccountId = normalizeAccountId(resolveDefaultTelegramAccountId(params.cfg)); + const isNamedAccountFallback = + routeAccountId !== defaultAccountId && params.route.matchedBy === "default"; + if (!isNamedAccountFallback || params.isGroup) { return params.route.sessionKey; } - const configuredDmScope = params.cfg.session?.dmScope; return normalizeLowercaseStringOrEmpty( buildAgentSessionKey({ agentId: params.route.agentId, @@ -163,10 +168,7 @@ export function resolveTelegramConversationBaseSessionKey(params: { senderId: params.senderId, }), }, - dmScope: - configuredDmScope && configuredDmScope !== "main" - ? configuredDmScope - : "per-account-channel-peer", + dmScope: "per-account-channel-peer", identityLinks: params.cfg.session?.identityLinks, }), ); diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 70c8f19cc0d..cb777f33fb8 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -95,6 +95,7 @@ export const registerProviderStreamForModelMock: Mock<(params?: unknown) => unkn export const applyExtraParamsToAgentMock = vi.fn(() => ({ effectiveExtraParams: {} })); export const resolveAgentTransportOverrideMock: Mock<(params?: unknown) => string | undefined> = vi.fn(() => undefined); +export const resolveSandboxContextMock = vi.fn(async () => null); export function resetCompactSessionStateMocks(): void { sanitizeSessionHistoryMock.mockReset(); @@ -131,6 +132,8 @@ export function resetCompactSessionStateMocks(): void { applyExtraParamsToAgentMock.mockReturnValue({ effectiveExtraParams: {} }); resolveAgentTransportOverrideMock.mockReset(); resolveAgentTransportOverrideMock.mockReturnValue(undefined); + resolveSandboxContextMock.mockReset(); + resolveSandboxContextMock.mockResolvedValue(null); } export function resetCompactHooksHarnessMocks(): void { @@ -296,7 +299,7 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../sandbox.js", () => ({ - resolveSandboxContext: vi.fn(async () => null), + resolveSandboxContext: resolveSandboxContextMock, })); vi.doMock("../session-file-repair.js", () => ({ diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 263c131f8d5..4d408b67851 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -14,6 +14,7 @@ import { resolveEmbeddedAgentStreamFnMock, resolveMemorySearchConfigMock, resolveModelMock, + resolveSandboxContextMock, resolveSessionAgentIdMock, resetCompactHooksHarnessMocks, resetCompactSessionStateMocks, @@ -210,6 +211,22 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); }); + it("uses sandboxSessionKey only for compaction sandbox resolution", async () => { + await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:main", + sandboxSessionKey: "agent:main:telegram:default:direct:12345", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + }); + + expect(resolveSandboxContextMock).toHaveBeenCalledWith({ + config: undefined, + sessionKey: "agent:main:telegram:default:direct:12345", + workspaceDir: "/tmp/workspace", + }); + }); + it("routes compaction through shared stream resolution and extra params", async () => { const resolvedStreamFn = vi.fn(); resolveEmbeddedAgentStreamFnMock.mockReturnValue(resolvedStreamFn); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index f0abbb46bf4..c5eed580ec1 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -412,7 +412,8 @@ export async function compactEmbeddedPiSessionDirect( } await fs.mkdir(resolvedWorkspace, { recursive: true }); - const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandboxSessionKey = + params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId; const sandbox = await resolveSandboxContext({ config: params.config, sessionKey: sandboxSessionKey, diff --git a/src/agents/pi-embedded-runner/compact.types.ts b/src/agents/pi-embedded-runner/compact.types.ts index e0dbd86b2cc..e056a62edee 100644 --- a/src/agents/pi-embedded-runner/compact.types.ts +++ b/src/agents/pi-embedded-runner/compact.types.ts @@ -8,6 +8,8 @@ export type CompactEmbeddedPiSessionParams = { sessionId: string; runId?: string; sessionKey?: string; + /** Session key used only for runtime policy/sandbox resolution. Defaults to sessionKey. */ + sandboxSessionKey?: string; messageChannel?: string; messageProvider?: string; agentAccountId?: string; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1a169a5f6fc..11e313e94e5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -689,6 +689,7 @@ export async function runEmbeddedPiAgent( const attempt = await runEmbeddedAttemptWithBackend({ sessionId: params.sessionId, sessionKey: resolvedSessionKey, + sandboxSessionKey: params.sandboxSessionKey, trigger: params.trigger, memoryFlushWritePath: params.memoryFlushWritePath, messageChannel: params.messageChannel, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 90255855a1e..fe61895fd86 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -438,7 +438,8 @@ export async function runEmbeddedAttempt( await fs.mkdir(resolvedWorkspace, { recursive: true }); - const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandboxSessionKey = + params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId; const sandbox = await resolveSandboxContext({ config: params.config, sessionKey: sandboxSessionKey, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 1b0382fd4f1..522eafaab2b 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -22,6 +22,8 @@ export type EmbeddedRunTrigger = "cron" | "heartbeat" | "manual" | "memory" | "o export type RunEmbeddedPiAgentParams = { sessionId: string; sessionKey?: string; + /** Session-like key for sandbox and tool-policy resolution. Defaults to sessionKey. */ + sandboxSessionKey?: string; agentId?: string; messageChannel?: string; messageProvider?: string; diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index c60e7ee5399..e95c171ac0f 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -172,6 +172,35 @@ describe("runReplyAgent runtime config", () => { ); }); + it("passes the derived runtime-policy key to pre-run maintenance", async () => { + const { followupRun, replyParams } = createDirectRuntimeReplyParams({ + shouldFollowup: false, + isActive: false, + }); + const runtimePolicySessionKey = "agent:main:telegram:default:direct:test"; + followupRun.run.sessionKey = "agent:main:main"; + followupRun.run.runtimePolicySessionKey = runtimePolicySessionKey; + replyParams.sessionKey = "agent:main:main"; + replyParams.runtimePolicySessionKey = runtimePolicySessionKey; + runPreflightCompactionIfNeededMock.mockResolvedValue(undefined); + runMemoryFlushIfNeededMock.mockRejectedValue(sentinelError); + + await expect(runReplyAgent(replyParams)).rejects.toBe(sentinelError); + + expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + runtimePolicySessionKey, + }), + ); + expect(runMemoryFlushIfNeededMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + runtimePolicySessionKey, + }), + ); + }); + it("does not resolve secrets before the enqueue-followup queue path", async () => { const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({ shouldFollowup: true, diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index cde84333288..8a391b976e4 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -582,6 +582,7 @@ export async function runAgentTurnWithFallback(params: { resetSessionAfterRoleOrderingConflict: (reason: string) => Promise; isHeartbeat: boolean; sessionKey?: string; + runtimePolicySessionKey?: string; getActiveSessionEntry: () => SessionEntry | undefined; activeSessionStore?: Record; storePath?: string; @@ -1018,6 +1019,7 @@ export async function runAgentTurnWithFallback(params: { groupSpace: normalizeOptionalString(params.sessionCtx.GroupSpace), ...senderContext, ...runBaseParams, + sandboxSessionKey: params.runtimePolicySessionKey, prompt: params.commandBody, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, toolResultFormat: (() => { diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index b9a09f8e654..55c7e6f67e2 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -8,9 +8,14 @@ import { registerMemoryFlushPlanResolver, } from "../../plugins/memory-state.js"; import type { TemplateContext } from "../templating.js"; -import { runMemoryFlushIfNeeded, setAgentRunnerMemoryTestDeps } from "./agent-runner-memory.js"; +import { + runMemoryFlushIfNeeded, + runPreflightCompactionIfNeeded, + setAgentRunnerMemoryTestDeps, +} from "./agent-runner-memory.js"; import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; +const compactEmbeddedPiSessionMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); @@ -43,6 +48,11 @@ describe("runMemoryFlushIfNeeded", () => { model, attempts: [], })); + compactEmbeddedPiSessionMock.mockReset().mockResolvedValue({ + ok: true, + compacted: true, + result: { tokensAfter: 42 }, + }); runEmbeddedPiAgentMock.mockReset().mockResolvedValue({ payloads: [], meta: {} }); refreshQueuedFollowupSessionMock.mockReset(); incrementCompactionCountMock.mockReset().mockImplementation(async (params) => { @@ -67,6 +77,7 @@ describe("runMemoryFlushIfNeeded", () => { return nextEntry.compactionCount; }); setAgentRunnerMemoryTestDeps({ + compactEmbeddedPiSession: compactEmbeddedPiSessionMock as never, runWithModelFallback: runWithModelFallbackMock as never, runEmbeddedPiAgent: runEmbeddedPiAgentMock as never, refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock as never, @@ -184,6 +195,98 @@ describe("runMemoryFlushIfNeeded", () => { expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); + it("uses runtime policy session key when checking memory-flush sandbox writability", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + const entry = await runMemoryFlushIfNeeded({ + cfg: { + agents: { + defaults: { + sandbox: { + mode: "non-main", + scope: "agent", + workspaceAccess: "ro", + }, + compaction: { + memoryFlush: {}, + }, + }, + }, + }, + followupRun: createTestFollowupRun({ + sessionKey: "agent:main:main", + runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", + }), + sessionCtx: { Provider: "telegram" } as unknown as TemplateContext, + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + sessionEntry, + sessionStore: { "agent:main:main": sessionEntry }, + sessionKey: "agent:main:main", + runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect(entry).toBe(sessionEntry); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + + it("passes runtime policy session key to preflight compaction sandbox resolution", async () => { + const sessionFile = path.join(rootDir, "session.jsonl"); + await fs.writeFile( + sessionFile, + `${JSON.stringify({ message: { role: "user", content: "x".repeat(5_000) } })}\n`, + "utf8", + ); + registerMemoryFlushPlanResolver(() => ({ + softThresholdTokens: 1, + forceFlushTranscriptBytes: 1_000_000_000, + reserveTokensFloor: 0, + prompt: "Pre-compaction memory flush.\nNO_REPLY", + systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", + relativePath: "memory/2023-11-14.md", + })); + const sessionEntry: SessionEntry = { + sessionId: "session", + sessionFile, + updatedAt: Date.now(), + totalTokensFresh: false, + }; + + await runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + sessionId: "session", + sessionFile, + sessionKey: "agent:main:main", + runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100, + sessionEntry, + sessionStore: { "agent:main:main": sessionEntry }, + sessionKey: "agent:main:main", + runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", + storePath: path.join(rootDir, "sessions.json"), + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect(compactEmbeddedPiSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + sandboxSessionKey: "agent:main:telegram:default:direct:12345", + }), + ); + }); + it("uses configured prompts and stored bootstrap warning signatures", async () => { const sessionEntry: SessionEntry = { sessionId: "session", diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 289aa8275a9..c1f3476b690 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -361,6 +361,7 @@ export async function runPreflightCompactionIfNeeded(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; + runtimePolicySessionKey?: string; storePath?: string; isHeartbeat: boolean; replyOperation: ReplyOperation; @@ -463,6 +464,7 @@ export async function runPreflightCompactionIfNeeded(params: { const result = await memoryDeps.compactEmbeddedPiSession({ sessionId: entry.sessionId, sessionKey: params.sessionKey, + sandboxSessionKey: params.runtimePolicySessionKey, allowGatewaySubagentBinding: true, messageChannel: params.followupRun.run.messageProvider, groupId: entry.groupId ?? params.followupRun.run.groupId, @@ -523,6 +525,7 @@ export async function runMemoryFlushIfNeeded(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; + runtimePolicySessionKey?: string; storePath?: string; isHeartbeat: boolean; replyOperation: ReplyOperation; @@ -538,7 +541,7 @@ export async function runMemoryFlushIfNeeded(params: { } const runtime = resolveSandboxRuntimeStatus({ cfg: params.cfg, - sessionKey: params.sessionKey, + sessionKey: params.runtimePolicySessionKey ?? params.sessionKey, }); if (!runtime.sandboxed) { return true; @@ -762,6 +765,7 @@ export async function runMemoryFlushIfNeeded(params: { ...embeddedContext, ...senderContext, ...runBaseParams, + sandboxSessionKey: params.runtimePolicySessionKey, allowGatewaySubagentBinding: true, silentExpected: true, trigger: "memory", diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 94c03031247..e635926abd2 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -227,6 +227,7 @@ export function buildEmbeddedContextFromTemplate(params: { return { sessionId: params.run.sessionId, sessionKey: params.run.sessionKey, + sandboxSessionKey: params.run.runtimePolicySessionKey, agentId: params.run.agentId, messageProvider: resolveOriginMessageProvider({ originatingChannel: params.sessionCtx.OriginatingChannel, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index adbd411a075..f7d3fae21c9 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -873,6 +873,7 @@ export async function runReplyAgent(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; + runtimePolicySessionKey?: string; storePath?: string; defaultModel: string; agentCfgContextTokens?: number; @@ -907,6 +908,7 @@ export async function runReplyAgent(params: { sessionEntry, sessionStore, sessionKey, + runtimePolicySessionKey, storePath, defaultModel, agentCfgContextTokens, @@ -1105,6 +1107,7 @@ export async function runReplyAgent(params: { sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, sessionKey, + runtimePolicySessionKey, storePath, isHeartbeat, replyOperation, @@ -1124,6 +1127,7 @@ export async function runReplyAgent(params: { sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, sessionKey, + runtimePolicySessionKey, storePath, isHeartbeat, replyOperation, @@ -1208,6 +1212,7 @@ export async function runReplyAgent(params: { resetSessionAfterRoleOrderingConflict, isHeartbeat, sessionKey, + runtimePolicySessionKey, getActiveSessionEntry: () => activeSessionEntry, activeSessionStore, storePath, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts index 93825991e51..5fff6ffe662 100644 --- a/src/auto-reply/reply/bash-command.ts +++ b/src/auto-reply/reply/bash-command.ts @@ -16,6 +16,7 @@ import type { ReplyPayload } from "../types.js"; import { buildDisabledCommandReply } from "./command-gates.js"; import { formatElevatedUnavailableMessage } from "./elevated-unavailable.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; const CHAT_BASH_SCOPE_KEY = "chat:bash"; const DEFAULT_FOREGROUND_MS = 2000; @@ -210,7 +211,11 @@ export async function handleBashChatCommand(params: { if (!params.elevated.enabled || !params.elevated.allowed) { const runtimeSandboxed = resolveSandboxRuntimeStatus({ cfg: params.cfg, - sessionKey: params.sessionKey, + sessionKey: resolveRuntimePolicySessionKey({ + cfg: params.cfg, + ctx: params.ctx, + sessionKey: params.sessionKey, + }), }).sandboxed; return { text: formatElevatedUnavailableMessage({ diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 6612ff00673..2ca45e3c950 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -15,6 +15,7 @@ import type { WorkspaceBootstrapFile } from "../../agents/workspace.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import type { HandleCommandsParams } from "./commands-types.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; export type CommandsSystemPromptBundle = { systemPrompt: string; @@ -43,7 +44,16 @@ export async function resolveCommandsSystemPromptBundle( }); const sandboxRuntime = resolveSandboxRuntimeStatus({ cfg: params.cfg, - sessionKey: params.sessionKey ?? params.ctx.SessionKey, + sessionKey: resolveRuntimePolicySessionKey({ + cfg: params.cfg, + ctx: params.ctx, + sessionKey: params.sessionKey ?? params.ctx.SessionKey, + }), + }); + const toolPolicySessionKey = resolveRuntimePolicySessionKey({ + cfg: params.cfg, + ctx: params.ctx, + sessionKey: params.sessionKey, }); const skillsSnapshot = (() => { try { @@ -73,7 +83,7 @@ export async function resolveCommandsSystemPromptBundle( config: params.cfg, agentId: sessionAgentId, workspaceDir, - sessionKey: params.sessionKey, + sessionKey: toolPolicySessionKey, allowGatewaySubagentBinding: true, messageProvider: params.command.channel, groupId: targetSessionEntry?.groupId ?? undefined, diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index de503c3ef9b..8dc73e6d224 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -88,6 +88,7 @@ export async function applyInlineDirectivesFastLane( currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, + ctx, surface: ctx.Surface, gatewayClientScopes: ctx.GatewayClientScopes, senderIsOwner: params.senderIsOwner, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index c6f1cf175d5..df2feb014b4 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -32,6 +32,7 @@ import { } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js"; import { refreshQueuedFollowupSession } from "./queue.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; export async function handleDirectiveOnly( params: HandleDirectiveOnlyParams, @@ -73,7 +74,11 @@ export async function handleDirectiveOnly( const agentDir = resolveAgentDir(params.cfg, activeAgentId); const runtimeIsSandboxed = resolveSandboxRuntimeStatus({ cfg: params.cfg, - sessionKey: params.sessionKey, + sessionKey: resolveRuntimePolicySessionKey({ + cfg: params.cfg, + ctx: params.ctx, + sessionKey: params.sessionKey, + }), }).sandboxed; const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed; const allowInternalExecPersistence = canPersistInternalExecDirective({ diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index fbecb9d203b..49db786769c 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -31,6 +31,7 @@ export type HandleDirectiveOnlyCoreParams = { }; export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & { + ctx?: MsgContext; messageProvider?: string; currentThinkLevel?: ThinkLevel; currentFastMode?: boolean; diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 593adfa98f7..f7121372c6f 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -317,6 +317,7 @@ export async function applyInlineDirectiveOverrides(params: { currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, + ctx, messageProvider: ctx.Provider, surface: ctx.Surface, gatewayClientScopes: ctx.GatewayClientScopes, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 9eb3096f7d8..1bbf2218d47 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -35,6 +35,7 @@ import { } from "./model-selection.js"; import { formatElevatedUnavailableMessage, resolveElevatedPermissions } from "./reply-elevated.js"; import { stripInlineStatus } from "./reply-inline.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; import type { TypingController } from "./typing.js"; type AgentDefaults = NonNullable["defaults"]; @@ -386,7 +387,7 @@ export async function resolveReplyDirectives(params: { typing.cleanup(); const runtimeSandboxed = resolveSandboxRuntimeStatus({ cfg, - sessionKey: ctx.SessionKey, + sessionKey: resolveRuntimePolicySessionKey({ cfg, ctx, sessionKey: ctx.SessionKey }), }).sandboxed; return { kind: "reply", diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 95e66f601b3..29a9d6910b9 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -50,6 +50,7 @@ import { resolveOriginMessageProvider } from "./origin-routing.js"; import { buildReplyPromptBodies } from "./prompt-prelude.js"; import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { resolveQueueSettings } from "./queue/settings-runtime.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; import { resolveBareSessionResetPromptState } from "./session-reset-prompt.js"; import { resolveBareResetBootstrapFileAccess } from "./session-reset-prompt.js"; import { drainFormattedSystemEvents } from "./session-system-events.js"; @@ -233,6 +234,11 @@ export async function runPreparedReply( workspaceDir, sessionStore, } = params; + const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ + cfg, + ctx, + sessionKey, + }); let { sessionEntry, resolvedThinkLevel, @@ -689,6 +695,7 @@ export async function runPreparedReply( agentDir, sessionId: preparedSessionState.sessionId, sessionKey, + runtimePolicySessionKey, messageProvider: resolveOriginMessageProvider({ originatingChannel: ctx.OriginatingChannel ?? sessionCtx.OriginatingChannel, // Prefer Provider over Surface for fallback channel identity. @@ -779,6 +786,7 @@ export async function runPreparedReply( sessionEntry: preparedSessionState.sessionEntry, sessionStore, sessionKey, + runtimePolicySessionKey, storePath, defaultModel, agentCfgContextTokens: agentCfg?.contextTokens, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index f064a75c1d9..27835de1ce1 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -47,6 +47,7 @@ export type FollowupRun = { agentDir: string; sessionId: string; sessionKey?: string; + runtimePolicySessionKey?: string; messageProvider?: string; agentAccountId?: string; groupId?: string; diff --git a/src/auto-reply/reply/runtime-policy-session-key.test.ts b/src/auto-reply/reply/runtime-policy-session-key.test.ts new file mode 100644 index 00000000000..b3505305a1b --- /dev/null +++ b/src/auto-reply/reply/runtime-policy-session-key.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { MsgContext } from "../templating.js"; +import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; + +describe("resolveRuntimePolicySessionKey", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { mode: "non-main", scope: "agent" }, + }, + list: [{ id: "main" }], + }, + }; + + it("derives an external direct-chat policy key when the conversation uses main", () => { + const sessionKey = resolveRuntimePolicySessionKey({ + cfg, + sessionKey: "agent:main:main", + ctx: { + SessionKey: "agent:main:main", + OriginatingChannel: "whatsapp" as MsgContext["OriginatingChannel"], + AccountId: "personal", + ChatType: "direct", + SenderId: "15555550123", + }, + }); + + expect(sessionKey).toBe("agent:main:whatsapp:personal:direct:15555550123"); + expect(resolveSandboxRuntimeStatus({ cfg, sessionKey }).sandboxed).toBe(true); + }); + + it("normalizes dm chat type aliases", () => { + expect( + resolveRuntimePolicySessionKey({ + cfg, + sessionKey: "agent:main:main", + ctx: { + SessionKey: "agent:main:main", + OriginatingChannel: "slack" as MsgContext["OriginatingChannel"], + ChatType: "dm", + SenderId: "U123", + }, + }), + ).toBe("agent:main:slack:default:direct:u123"); + }); + + it("leaves local main-session runs unsandboxed in non-main mode", () => { + const sessionKey = resolveRuntimePolicySessionKey({ + cfg, + sessionKey: "agent:main:main", + ctx: { + SessionKey: "agent:main:main", + Provider: "webchat", + ChatType: "direct", + SenderId: "operator", + }, + }); + + expect(sessionKey).toBe("agent:main:main"); + expect(resolveSandboxRuntimeStatus({ cfg, sessionKey }).sandboxed).toBe(false); + }); + + it("keeps already-isolated sessions unchanged", () => { + expect( + resolveRuntimePolicySessionKey({ + cfg, + sessionKey: "agent:main:discord:channel:123:thread:456", + ctx: { + SessionKey: "agent:main:discord:channel:123:thread:456", + OriginatingChannel: "discord" as MsgContext["OriginatingChannel"], + ChatType: "channel", + SenderId: "u1", + }, + }), + ).toBe("agent:main:discord:channel:123:thread:456"); + }); + + it("uses native command target sessions as the policy base", () => { + expect( + resolveRuntimePolicySessionKey({ + cfg, + sessionKey: "agent:main:main", + ctx: { + SessionKey: "telegram:slash:status", + CommandTargetSessionKey: "agent:main:main", + OriginatingChannel: "telegram" as MsgContext["OriginatingChannel"], + AccountId: "default", + ChatType: "direct", + NativeDirectUserId: "42", + }, + }), + ).toBe("agent:main:telegram:default:direct:42"); + }); + + it("applies identity links for derived direct-chat policy keys", () => { + expect( + resolveRuntimePolicySessionKey({ + cfg: { + ...cfg, + session: { + identityLinks: { + alice: ["telegram:42"], + }, + }, + }, + sessionKey: "agent:main:main", + ctx: { + SessionKey: "agent:main:main", + OriginatingChannel: "telegram" as MsgContext["OriginatingChannel"], + AccountId: "default", + ChatType: "direct", + SenderId: "42", + }, + }), + ).toBe("agent:main:telegram:default:direct:alice"); + }); +}); diff --git a/src/auto-reply/reply/runtime-policy-session-key.ts b/src/auto-reply/reply/runtime-policy-session-key.ts new file mode 100644 index 00000000000..0bcd4c05b9b --- /dev/null +++ b/src/auto-reply/reply/runtime-policy-session-key.ts @@ -0,0 +1,125 @@ +import { normalizeChatType } from "../../channels/chat-type.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + buildAgentMainSessionKey, + buildAgentPeerSessionKey, + normalizeAgentId, + normalizeMainKey, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; +import type { MsgContext } from "../templating.js"; + +type RuntimePolicyContext = Pick< + MsgContext, + | "AccountId" + | "ChatType" + | "CommandTargetSessionKey" + | "From" + | "NativeDirectUserId" + | "OriginatingChannel" + | "OriginatingTo" + | "Provider" + | "RuntimePolicySessionKey" + | "SenderE164" + | "SenderId" + | "SenderUsername" + | "SessionKey" + | "Surface" + | "To" +>; + +function resolvePolicyChannel(ctx?: RuntimePolicyContext): string | undefined { + const raw = normalizeOptionalString(ctx?.OriginatingChannel ?? ctx?.Provider ?? ctx?.Surface); + if (!raw) { + return undefined; + } + const channel = normalizeLowercaseStringOrEmpty(raw); + return channel && channel !== "webchat" ? channel : undefined; +} + +function resolvePolicyDirectPeerId(ctx?: RuntimePolicyContext): string | undefined { + return normalizeOptionalString( + ctx?.NativeDirectUserId ?? + ctx?.SenderId ?? + ctx?.SenderE164 ?? + ctx?.SenderUsername ?? + ctx?.OriginatingTo ?? + ctx?.From ?? + ctx?.To, + ); +} + +function isMainSessionAlias(params: { + cfg?: OpenClawConfig; + agentId: string; + sessionKey: string; +}): boolean { + const raw = normalizeLowercaseStringOrEmpty(params.sessionKey); + if (!raw) { + return false; + } + const agentId = normalizeAgentId(params.agentId); + const mainKey = normalizeMainKey(params.cfg?.session?.mainKey); + const agentMainSessionKey = buildAgentMainSessionKey({ + agentId, + mainKey, + }); + const agentMainAliasKey = buildAgentMainSessionKey({ + agentId, + mainKey: "main", + }); + return ( + raw === "main" || + raw === mainKey || + raw === agentMainSessionKey || + raw === agentMainAliasKey || + raw === buildAgentMainSessionKey({ agentId: "main", mainKey }) || + raw === buildAgentMainSessionKey({ agentId: "main", mainKey: "main" }) || + (params.cfg?.session?.scope === "global" && raw === "global") + ); +} + +export function resolveRuntimePolicySessionKey(params: { + cfg?: OpenClawConfig; + ctx?: RuntimePolicyContext; + sessionKey?: string | null; +}): string | undefined { + const explicitPolicySessionKey = normalizeOptionalString(params.ctx?.RuntimePolicySessionKey); + if (explicitPolicySessionKey) { + return explicitPolicySessionKey; + } + const sessionKey = normalizeOptionalString( + params.sessionKey ?? params.ctx?.CommandTargetSessionKey ?? params.ctx?.SessionKey, + ); + if (!sessionKey) { + return undefined; + } + + const agentId = resolveAgentIdFromSessionKey(sessionKey); + if (!isMainSessionAlias({ cfg: params.cfg, agentId, sessionKey })) { + return sessionKey; + } + + if (normalizeChatType(params.ctx?.ChatType) !== "direct") { + return sessionKey; + } + const channel = resolvePolicyChannel(params.ctx); + const peerId = resolvePolicyDirectPeerId(params.ctx); + if (!channel || !peerId) { + return sessionKey; + } + + return buildAgentPeerSessionKey({ + agentId, + channel, + accountId: params.ctx?.AccountId, + peerKind: "direct", + peerId, + dmScope: "per-account-channel-peer", + identityLinks: params.cfg?.session?.identityLinks, + }); +} diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4fc31eaedb8..a497475041c 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -55,6 +55,11 @@ export type MsgContext = { From?: string; To?: string; SessionKey?: string; + /** + * Session-like key used for runtime policy (sandbox/tool policy) when the + * conversation key intentionally remains broader, such as a main-session DM. + */ + RuntimePolicySessionKey?: string; /** Provider account id (multi-account). */ AccountId?: string; ParentSessionKey?: string;