diff --git a/CHANGELOG.md b/CHANGELOG.md index 177e0f8f259..e5e0b8f6073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Channels/sessions: skip last-route writes when inbound session recording explicitly disables creation, so plugin-owned guarded inbound paths cannot create route-only phantom sessions. Carries forward #73009. Thanks @jzakirov. +- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov. - Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. - Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg. - CLI/message: resolve targeted `openclaw message` channels to their owning plugin before loading the registry, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans without assuming channel ids match plugin ids. Fixes #73006. Thanks @jasonftl. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index c42cc6c3bac..c74944b462c 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -59,6 +59,13 @@ OpenClaw infers a pinned owner from `allowFrom` when all of these are true: In that mismatch case, OpenClaw still records inbound session metadata, but it skips updating the main session `lastRoute`. +## Guarded inbound recording + +Channel plugins can mark an inbound session record as `createIfMissing: false` +when a guarded path must not create a new OpenClaw session. In that mode, +OpenClaw may update metadata and `lastRoute` for an existing session, but it +does not create a route-only session entry just because a message was observed. + ## Routing rules (how an agent is chosen) Routing picks **one agent** for each inbound message: diff --git a/src/channels/session.test.ts b/src/channels/session.test.ts index c75a44c7e59..b18087a8675 100644 --- a/src/channels/session.test.ts +++ b/src/channels/session.test.ts @@ -133,7 +133,7 @@ describe("recordInboundSession", () => { }); }); - it("skips last-route updates when session creation is disabled", async () => { + it("forwards session creation policy to last-route updates", async () => { await recordInboundSession({ storePath: "/tmp/openclaw-session-store.json", sessionKey: "agent:main:demo-channel:1234:thread:42", @@ -152,6 +152,11 @@ describe("recordInboundSession", () => { createIfMissing: false, }), ); - expect(updateLastRouteMock).not.toHaveBeenCalled(); + expect(updateLastRouteMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + createIfMissing: false, + }), + ); }); }); diff --git a/src/channels/session.ts b/src/channels/session.ts index 54ebe996ab6..41428905af9 100644 --- a/src/channels/session.ts +++ b/src/channels/session.ts @@ -51,7 +51,7 @@ export async function recordInboundSession(params: { .catch(params.onRecordError); const update = params.updateLastRoute; - if (!update || createIfMissing === false) { + if (!update) { return; } if (shouldSkipPinnedMainDmRouteUpdate(update.mainDmOwnerPin)) { @@ -70,5 +70,6 @@ export async function recordInboundSession(params: { // Avoid leaking inbound origin metadata into a different target session. ctx: targetSessionKey === canonicalSessionKey ? ctx : undefined, groupResolution, + createIfMissing, }); } diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 965f1d20bdf..638a8be8c20 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -356,6 +356,52 @@ describe("sessions", () => { expect(store[sessionKey]?.origin?.chatType).toBe("group"); }); + it("updateLastRoute skips missing sessions when creation is disabled", async () => { + const sessionKey = "agent:main:demo-chat:group:room-123"; + const { storePath } = await createSessionStoreFixture({ + prefix: "updateLastRoute-no-create", + entries: {}, + }); + + const result = await updateLastRoute({ + storePath, + sessionKey, + deliveryContext: { + channel: "demo-chat", + to: "room-123", + }, + createIfMissing: false, + }); + + const store = loadSessionStore(storePath); + expect(result).toBeNull(); + expect(store[sessionKey]).toBeUndefined(); + }); + + it("updateLastRoute updates existing sessions when creation is disabled", async () => { + const sessionKey = "agent:main:demo-chat:group:room-123"; + const { storePath } = await createSessionStoreFixture({ + prefix: "updateLastRoute-existing-no-create", + entries: { + [sessionKey]: buildMainSessionEntry(), + }, + }); + + await updateLastRoute({ + storePath, + sessionKey, + deliveryContext: { + channel: "demo-chat", + to: "room-123", + }, + createIfMissing: false, + }); + + const store = loadSessionStore(storePath); + expect(store[sessionKey]?.lastChannel).toBe("demo-chat"); + expect(store[sessionKey]?.lastTo).toBe("room-123"); + }); + it("updateLastRoute does not bump updatedAt on existing sessions (#49515)", async () => { const mainSessionKey = "agent:main:main"; const frozenUpdatedAt = 1000; diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 030f65f35f8..2ff64f06245 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -70,4 +70,5 @@ export type UpdateLastRoute = (params: { deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: GroupKeyResolution | null; -}) => Promise; + createIfMissing?: boolean; +}) => Promise; diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 4da81b18da1..149a8a20cd7 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -757,12 +757,17 @@ export async function updateLastRoute(params: { deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: import("./types.js").GroupKeyResolution | null; -}) { + createIfMissing?: boolean; +}): Promise { const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params; + const createIfMissing = params.createIfMissing ?? true; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); const resolved = resolveSessionStoreEntry({ store, sessionKey }); const existing = resolved.existing; + if (!existing && !createIfMissing) { + return null; + } const explicitContext = normalizeDeliveryContext(params.deliveryContext); const inlineContext = normalizeDeliveryContext({ channel,