fix: isolate external direct-message runtime policy

This commit is contained in:
Peter Steinberger
2026-04-23 01:39:35 +01:00
parent 67f09ea87a
commit 6b41ef311f
35 changed files with 529 additions and 46 deletions

View File

@@ -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.

View File

@@ -23,15 +23,13 @@ host configuration.
## Session key shapes (examples)
Most direct messages collapse to the agents **main** session:
Direct messages collapse to the agents **main** session by default:
- `agent:<agentId>:<mainKey>` (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:<agentId>:telegram:<accountId>:direct:<senderId>`
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:

View File

@@ -72,13 +72,6 @@ openclaw pairing approve telegram <CODE>
Token resolution order is account-aware. In practice, config values win over env fallback, and `TELEGRAM_BOT_TOKEN` only applies to the default account.
</Note>
## 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
<AccordionGroup>

View File

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

View File

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

View File

@@ -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<typeof resolveTelegramConversationRoute>["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.

View File

@@ -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",

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

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

View File

@@ -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", () => ({

View File

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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -582,6 +582,7 @@ export async function runAgentTurnWithFallback(params: {
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
isHeartbeat: boolean;
sessionKey?: string;
runtimePolicySessionKey?: string;
getActiveSessionEntry: () => SessionEntry | undefined;
activeSessionStore?: Record<string, SessionEntry>;
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: (() => {

View File

@@ -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",

View File

@@ -361,6 +361,7 @@ export async function runPreflightCompactionIfNeeded(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
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<string, SessionEntry>;
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",

View File

@@ -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,

View File

@@ -873,6 +873,7 @@ export async function runReplyAgent(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -88,6 +88,7 @@ export async function applyInlineDirectivesFastLane(
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
ctx,
surface: ctx.Surface,
gatewayClientScopes: ctx.GatewayClientScopes,
senderIsOwner: params.senderIsOwner,

View File

@@ -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({

View File

@@ -31,6 +31,7 @@ export type HandleDirectiveOnlyCoreParams = {
};
export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & {
ctx?: MsgContext;
messageProvider?: string;
currentThinkLevel?: ThinkLevel;
currentFastMode?: boolean;

View File

@@ -317,6 +317,7 @@ export async function applyInlineDirectiveOverrides(params: {
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
ctx,
messageProvider: ctx.Provider,
surface: ctx.Surface,
gatewayClientScopes: ctx.GatewayClientScopes,

View File

@@ -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<OpenClawConfig["agents"]>["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",

View File

@@ -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,

View File

@@ -47,6 +47,7 @@ export type FollowupRun = {
agentDir: string;
sessionId: string;
sessionKey?: string;
runtimePolicySessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
groupId?: string;

View File

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

View File

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

View File

@@ -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;