diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index ac4480f69b2..f51f6c4147c 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -41,6 +41,19 @@ Examples: - `agent:main:telegram:group:-1001234567890:topic:42` - `agent:main:discord:channel:123456:thread:987654` +## Main DM route pinning + +When `session.dmScope` is `main`, direct messages may share one main session. +To prevent the session’s `lastRoute` from being overwritten by non-owner DMs, +OpenClaw infers a pinned owner from `allowFrom` when all of these are true: + +- `allowFrom` has exactly one non-wildcard entry. +- The entry can be normalized to a concrete sender ID for that channel. +- The inbound DM sender does not match that pinned owner. + +In that mismatch case, OpenClaw still records inbound session metadata, but it +skips updating the main session `lastRoute`. + ## 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 429985efd90..b1415bbb53d 100644 --- a/src/channels/session.test.ts +++ b/src/channels/session.test.ts @@ -103,4 +103,32 @@ describe("recordInboundSession", () => { }), ); }); + + it("skips last-route updates when main DM owner pin mismatches sender", async () => { + const { recordInboundSession } = await import("./session.js"); + const onSkip = vi.fn(); + + await recordInboundSession({ + storePath: "/tmp/openclaw-session-store.json", + sessionKey: "agent:main:telegram:1234:thread:42", + ctx, + updateLastRoute: { + sessionKey: "agent:main:main", + channel: "telegram", + to: "telegram:1234", + mainDmOwnerPin: { + ownerRecipient: "1234", + senderRecipient: "9999", + onSkip, + }, + }, + onRecordError: vi.fn(), + }); + + expect(updateLastRouteMock).not.toHaveBeenCalled(); + expect(onSkip).toHaveBeenCalledWith({ + ownerRecipient: "1234", + senderRecipient: "9999", + }); + }); }); diff --git a/src/channels/session.ts b/src/channels/session.ts index 6a56638cdff..f71ef024a5f 100644 --- a/src/channels/session.ts +++ b/src/channels/session.ts @@ -16,8 +16,28 @@ export type InboundLastRouteUpdate = { to: string; accountId?: string; threadId?: string | number; + mainDmOwnerPin?: { + ownerRecipient: string; + senderRecipient: string; + onSkip?: (params: { ownerRecipient: string; senderRecipient: string }) => void; + }; }; +function shouldSkipPinnedMainDmRouteUpdate( + pin: InboundLastRouteUpdate["mainDmOwnerPin"] | undefined, +): boolean { + if (!pin) { + return false; + } + const owner = pin.ownerRecipient.trim().toLowerCase(); + const sender = pin.senderRecipient.trim().toLowerCase(); + if (!owner || !sender || owner === sender) { + return false; + } + pin.onSkip?.({ ownerRecipient: pin.ownerRecipient, senderRecipient: pin.senderRecipient }); + return true; +} + export async function recordInboundSession(params: { storePath: string; sessionKey: string; @@ -41,6 +61,9 @@ export async function recordInboundSession(params: { if (!update) { return; } + if (shouldSkipPinnedMainDmRouteUpdate(update.mainDmOwnerPin)) { + return; + } const targetSessionKey = normalizeSessionStoreKey(update.sessionKey); await updateLastRoute({ storePath, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index c8126d9ce49..67b65d903e5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -6,13 +6,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; -import { - type BundledPluginSource, - findBundledPluginByNpmSpec, - findBundledPluginByPluginId, -} from "../plugins/bundled-sources.js"; +import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; +import { + installPluginFromNpmSpec, + installPluginFromPath, + PLUGIN_INSTALL_ERROR_CODE, +} from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import type { PluginRecord } from "../plugins/registry.js"; @@ -153,16 +153,6 @@ function logSlotWarnings(warnings: string[]) { } } -function isPackageNotFoundInstallError(message: string): boolean { - const lower = message.toLowerCase(); - return ( - lower.includes("npm pack failed:") && - (lower.includes("e404") || - lower.includes("404 not found") || - lower.includes("could not be found")) - ); -} - function isBareNpmPackageName(spec: string): boolean { const trimmed = spec.trim(); return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); @@ -210,6 +200,174 @@ async function installBundledPluginSource(params: { defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); } + +async function runPluginInstallCommand(params: { + raw: string; + opts: { link?: boolean; pin?: boolean }; +}) { + const { raw, opts } = params; + const fileSpec = resolveFileNpmSpecToLocalPath(raw); + if (fileSpec && !fileSpec.ok) { + defaultRuntime.error(fileSpec.error); + process.exit(1); + } + const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; + const resolved = resolveUserPath(normalized); + const cfg = loadConfig(); + + if (fs.existsSync(resolved)) { + if (opts.link) { + const existing = cfg.plugins?.load?.paths ?? []; + const merged = Array.from(new Set([...existing, resolved])); + const probe = await installPluginFromPath({ path: resolved, dryRun: true }); + if (!probe.ok) { + defaultRuntime.error(probe.error); + process.exit(1); + } + + let next: OpenClawConfig = enablePluginInConfig( + { + ...cfg, + plugins: { + ...cfg.plugins, + load: { + ...cfg.plugins?.load, + paths: merged, + }, + }, + }, + probe.pluginId, + ).config; + next = recordPluginInstall(next, { + pluginId: probe.pluginId, + source: "path", + sourcePath: resolved, + installPath: resolved, + version: probe.version, + }); + const slotResult = applySlotSelectionForPlugin(next, probe.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; + } + + const result = await installPluginFromPath({ + path: resolved, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + // Plugin CLI registrars may have warmed the manifest registry cache before install; + // force a rescan so config validation sees the freshly installed plugin. + clearPluginManifestRegistryCache(); + + let next = enablePluginInConfig(cfg, result.pluginId).config; + const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source, + sourcePath: resolved, + installPath: result.targetDir, + version: result.version, + }); + const slotResult = applySlotSelectionForPlugin(next, result.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(`Installed plugin: ${result.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; + } + + if (opts.link) { + defaultRuntime.error("`--link` requires a local path."); + process.exit(1); + } + + if ( + looksLikeLocalInstallSpec(raw, [ + ".ts", + ".js", + ".mjs", + ".cjs", + ".tgz", + ".tar.gz", + ".tar", + ".zip", + ]) + ) { + defaultRuntime.error(`Path not found: ${resolved}`); + process.exit(1); + } + + const bundledByPluginId = isBareNpmPackageName(raw) + ? findBundledPluginSource({ + lookup: { kind: "pluginId", value: raw }, + }) + : undefined; + if (bundledByPluginId) { + await installBundledPluginSource({ + config: cfg, + rawSpec: raw, + bundledSource: bundledByPluginId, + warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`, + }); + return; + } + + const result = await installPluginFromNpmSpec({ + spec: raw, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + const bundledFallback = + result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND + ? findBundledPluginSource({ + lookup: { kind: "npmSpec", value: raw }, + }) + : undefined; + if (!bundledFallback) { + defaultRuntime.error(result.error); + process.exit(1); + } + + await installBundledPluginSource({ + config: cfg, + rawSpec: raw, + bundledSource: bundledFallback, + warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, + }); + return; + } + // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. + clearPluginManifestRegistryCache(); + + let next = enablePluginInConfig(cfg, result.pluginId).config; + const installRecord = resolvePinnedNpmInstallRecordForCli( + raw, + Boolean(opts.pin), + result.targetDir, + result.version, + result.npmResolution, + defaultRuntime.log, + theme.warn, + ); + next = recordPluginInstall(next, { + pluginId: result.pluginId, + ...installRecord, + }); + const slotResult = applySlotSelectionForPlugin(next, result.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(`Installed plugin: ${result.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); +} export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -572,162 +730,7 @@ export function registerPluginsCli(program: Command) { .option("-l, --link", "Link a local path instead of copying", false) .option("--pin", "Record npm installs as exact resolved @", false) .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { - const fileSpec = resolveFileNpmSpecToLocalPath(raw); - if (fileSpec && !fileSpec.ok) { - defaultRuntime.error(fileSpec.error); - process.exit(1); - } - const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; - const resolved = resolveUserPath(normalized); - const cfg = loadConfig(); - - if (fs.existsSync(resolved)) { - if (opts.link) { - const existing = cfg.plugins?.load?.paths ?? []; - const merged = Array.from(new Set([...existing, resolved])); - const probe = await installPluginFromPath({ path: resolved, dryRun: true }); - if (!probe.ok) { - defaultRuntime.error(probe.error); - process.exit(1); - } - - let next: OpenClawConfig = enablePluginInConfig( - { - ...cfg, - plugins: { - ...cfg.plugins, - load: { - ...cfg.plugins?.load, - paths: merged, - }, - }, - }, - probe.pluginId, - ).config; - next = recordPluginInstall(next, { - pluginId: probe.pluginId, - source: "path", - sourcePath: resolved, - installPath: resolved, - version: probe.version, - }); - const slotResult = applySlotSelectionForPlugin(next, probe.pluginId); - next = slotResult.config; - await writeConfigFile(next); - logSlotWarnings(slotResult.warnings); - defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`); - defaultRuntime.log(`Restart the gateway to load plugins.`); - return; - } - - const result = await installPluginFromPath({ - path: resolved, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - defaultRuntime.error(result.error); - process.exit(1); - } - // Plugin CLI registrars may have warmed the manifest registry cache before install; - // force a rescan so config validation sees the freshly installed plugin. - clearPluginManifestRegistryCache(); - - let next = enablePluginInConfig(cfg, result.pluginId).config; - const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; - next = recordPluginInstall(next, { - pluginId: result.pluginId, - source, - sourcePath: resolved, - installPath: result.targetDir, - version: result.version, - }); - const slotResult = applySlotSelectionForPlugin(next, result.pluginId); - next = slotResult.config; - await writeConfigFile(next); - logSlotWarnings(slotResult.warnings); - defaultRuntime.log(`Installed plugin: ${result.pluginId}`); - defaultRuntime.log(`Restart the gateway to load plugins.`); - return; - } - - if (opts.link) { - defaultRuntime.error("`--link` requires a local path."); - process.exit(1); - } - - if ( - looksLikeLocalInstallSpec(raw, [ - ".ts", - ".js", - ".mjs", - ".cjs", - ".tgz", - ".tar.gz", - ".tar", - ".zip", - ]) - ) { - defaultRuntime.error(`Path not found: ${resolved}`); - process.exit(1); - } - - const bundledByPluginId = isBareNpmPackageName(raw) - ? findBundledPluginByPluginId({ pluginId: raw }) - : undefined; - if (bundledByPluginId) { - await installBundledPluginSource({ - config: cfg, - rawSpec: raw, - bundledSource: bundledByPluginId, - warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`, - }); - return; - } - - const result = await installPluginFromNpmSpec({ - spec: raw, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - const bundledFallback = isPackageNotFoundInstallError(result.error) - ? findBundledPluginByNpmSpec({ spec: raw }) - : undefined; - if (!bundledFallback) { - defaultRuntime.error(result.error); - process.exit(1); - } - - await installBundledPluginSource({ - config: cfg, - rawSpec: raw, - bundledSource: bundledFallback, - warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, - }); - return; - } - // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. - clearPluginManifestRegistryCache(); - - let next = enablePluginInConfig(cfg, result.pluginId).config; - const installRecord = resolvePinnedNpmInstallRecordForCli( - raw, - Boolean(opts.pin), - result.targetDir, - result.version, - result.npmResolution, - defaultRuntime.log, - theme.warn, - ); - next = recordPluginInstall(next, { - pluginId: result.pluginId, - ...installRecord, - }); - const slotResult = applySlotSelectionForPlugin(next, result.pluginId); - next = slotResult.config; - await writeConfigFile(next); - logSlotWarnings(slotResult.warnings); - defaultRuntime.log(`Installed plugin: ${result.pluginId}`); - defaultRuntime.log(`Restart the gateway to load plugins.`); + await runPluginInstallCommand({ raw, opts }); }); plugins diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 38edd43deb3..f30382a42a8 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -38,7 +38,10 @@ import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; -import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js"; +import { + readStoreAllowFromForDmPolicy, + resolvePinnedMainDmOwnerFromAllowlist, +} from "../../security/dm-policy-shared.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { createDiscordFormModal, @@ -861,6 +864,17 @@ async function dispatchDiscordComponentEvent(params: { sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, allowNameMatching, }); + const pinnedMainDmOwner = interactionCtx.isDirectMessage + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: ctx.cfg.session?.dmScope, + allowFrom: channelConfig?.users ?? guildInfo?.users, + normalizeEntry: (entry) => { + const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]); + const candidate = normalized?.[0]; + return candidate && /^\d+$/.test(candidate) ? candidate : undefined; + }, + }) + : null; const commandAuthorized = resolveComponentCommandAuthorized({ ctx, interactionCtx, @@ -929,6 +943,17 @@ async function dispatchDiscordComponentEvent(params: { channel: "discord", to: `user:${interactionCtx.userId}`, accountId, + mainDmOwnerPin: pinnedMainDmOwner + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: interactionCtx.userId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => { diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 838e840f558..13bb6bac8ae 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -36,12 +36,14 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../security/dm-policy-shared.js"; import { truncateUtf16Safe } from "../../utils.js"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; import { probeIMessage } from "../probe.js"; import { sendMessageIMessage } from "../send.js"; +import { normalizeIMessageHandle } from "../targets.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; import { deliverReplies } from "./deliver.js"; import { createSentMessageCache } from "./echo-cache.js"; @@ -320,6 +322,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); const updateTarget = chatTarget || decision.sender; + const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom, + normalizeEntry: normalizeIMessageHandle, + }); await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey, @@ -331,6 +338,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P channel: "imessage", to: updateTarget, accountId: decision.route.accountId, + mainDmOwnerPin: + pinnedMainDmOwner && decision.senderNormalized + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: decision.senderNormalized, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => { diff --git a/src/line/bot-message-context.ts b/src/line/bot-message-context.ts index 255aa34bfc7..65c055a6d4b 100644 --- a/src/line/bot-message-context.ts +++ b/src/line/bot-message-context.ts @@ -3,11 +3,13 @@ import { formatInboundEnvelope } from "../auto-reply/envelope.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; +import { recordInboundSession } from "../channels/session.js"; import type { OpenClawConfig } from "../config/config.js"; -import { recordSessionMetaFromInbound, updateLastRoute } from "../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; +import { normalizeAllowFrom } from "./bot-access.js"; import type { ResolvedLineAccount } from "./types.js"; interface MediaRef { @@ -288,27 +290,42 @@ async function finalizeLineInboundContext(params: { OriginatingTo: originatingTo, }); - void recordSessionMetaFromInbound({ + const pinnedMainDmOwner = !params.source.isGroup + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: params.cfg.session?.dmScope, + allowFrom: params.account.config.allowFrom, + normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], + }) + : null; + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`line: failed updating session meta: ${String(err)}`); + updateLastRoute: !params.source.isGroup + ? { + sessionKey: params.route.mainSessionKey, + channel: "line", + to: params.source.userId ?? params.source.peerId, + accountId: params.route.accountId, + mainDmOwnerPin: + pinnedMainDmOwner && params.source.userId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: params.source.userId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err) => { + logVerbose(`line: failed updating session meta: ${String(err)}`); + }, }); - if (!params.source.isGroup) { - await updateLastRoute({ - storePath, - sessionKey: params.route.mainSessionKey, - deliveryContext: { - channel: "line", - to: params.source.userId ?? params.source.peerId, - accountId: params.route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 2cbb3b2bb76..7aace6f6278 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -1,9 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - findBundledPluginByNpmSpec, - findBundledPluginByPluginId, - resolveBundledPluginSources, -} from "./bundled-sources.js"; +import { findBundledPluginSource, resolveBundledPluginSources } from "./bundled-sources.js"; const discoverOpenClawPluginsMock = vi.fn(); const loadPluginManifestMock = vi.fn(); @@ -91,8 +87,12 @@ describe("bundled plugin sources", () => { }); loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } }); - const resolved = findBundledPluginByNpmSpec({ spec: "@openclaw/feishu" }); - const missing = findBundledPluginByNpmSpec({ spec: "@openclaw/not-found" }); + const resolved = findBundledPluginSource({ + lookup: { kind: "npmSpec", value: "@openclaw/feishu" }, + }); + const missing = findBundledPluginSource({ + lookup: { kind: "npmSpec", value: "@openclaw/not-found" }, + }); expect(resolved?.pluginId).toBe("feishu"); expect(resolved?.localPath).toBe("/app/extensions/feishu"); @@ -113,8 +113,12 @@ describe("bundled plugin sources", () => { }); loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } }); - const resolved = findBundledPluginByPluginId({ pluginId: "diffs" }); - const missing = findBundledPluginByPluginId({ pluginId: "not-found" }); + const resolved = findBundledPluginSource({ + lookup: { kind: "pluginId", value: "diffs" }, + }); + const missing = findBundledPluginSource({ + lookup: { kind: "pluginId", value: "not-found" }, + }); expect(resolved?.pluginId).toBe("diffs"); expect(resolved?.localPath).toBe("/app/extensions/diffs"); diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index b855ce99c2d..3457b7ba7ec 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -7,6 +7,10 @@ export type BundledPluginSource = { npmSpec?: string; }; +export type BundledPluginLookup = + | { kind: "npmSpec"; value: string } + | { kind: "pluginId"; value: string }; + export function resolveBundledPluginSources(params: { workspaceDir?: string; }): Map { @@ -41,38 +45,22 @@ export function resolveBundledPluginSources(params: { return bundled; } -export function findBundledPluginByNpmSpec(params: { - spec: string; +export function findBundledPluginSource(params: { + lookup: BundledPluginLookup; workspaceDir?: string; }): BundledPluginSource | undefined { - const targetSpec = params.spec.trim(); - if (!targetSpec) { + const targetValue = params.lookup.value.trim(); + if (!targetValue) { return undefined; } const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); + if (params.lookup.kind === "pluginId") { + return bundled.get(targetValue); + } for (const source of bundled.values()) { - if (source.npmSpec === targetSpec) { - return source; - } - // Also match by plugin id so that e.g. `openclaw plugins install diffs` - // resolves to the bundled @openclaw/diffs plugin when the unscoped npm - // package `diffs` is not a valid OpenClaw plugin. - // See: https://github.com/openclaw/openclaw/issues/32019 - if (source.pluginId === targetSpec) { + if (source.npmSpec === targetValue) { return source; } } return undefined; } - -export function findBundledPluginByPluginId(params: { - pluginId: string; - workspaceDir?: string; -}): BundledPluginSource | undefined { - const targetPluginId = params.pluginId.trim(); - if (!targetPluginId) { - return undefined; - } - const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); - return bundled.get(targetPluginId); -} diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 31611e739c0..7751e0b1e38 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -8,7 +8,6 @@ import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-asserti import { expectInstallUsesIgnoreScripts, expectIntegrityDriftRejected, - expectUnsupportedNpmSpec, mockNpmPackMetadataResult, } from "../test-utils/npm-spec-install-test-helpers.js"; @@ -20,6 +19,7 @@ let installPluginFromArchive: typeof import("./install.js").installPluginFromArc let installPluginFromDir: typeof import("./install.js").installPluginFromDir; let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec; let installPluginFromPath: typeof import("./install.js").installPluginFromPath; +let PLUGIN_INSTALL_ERROR_CODE: typeof import("./install.js").PLUGIN_INSTALL_ERROR_CODE; let runCommandWithTimeout: typeof import("../process/exec.js").runCommandWithTimeout; let suiteTempRoot = ""; let tempDirCounter = 0; @@ -255,6 +255,7 @@ beforeAll(async () => { installPluginFromDir, installPluginFromNpmSpec, installPluginFromPath, + PLUGIN_INSTALL_ERROR_CODE, } = await import("./install.js")); ({ runCommandWithTimeout } = await import("../process/exec.js")); }); @@ -372,6 +373,7 @@ describe("installPluginFromArchive", () => { return; } expect(result.error).toContain("openclaw.extensions"); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.MISSING_OPENCLAW_EXTENSIONS); }); it("rejects legacy plugin package shape when openclaw.extensions is missing", async () => { @@ -403,6 +405,7 @@ describe("installPluginFromArchive", () => { if (!result.ok) { expect(result.error).toContain("package.json missing openclaw.extensions"); expect(result.error).toContain("update the plugin package"); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.MISSING_OPENCLAW_EXTENSIONS); return; } expect.unreachable("expected install to fail without openclaw.extensions"); @@ -668,7 +671,12 @@ describe("installPluginFromNpmSpec", () => { }); it("rejects non-registry npm specs", async () => { - await expectUnsupportedNpmSpec((spec) => installPluginFromNpmSpec({ spec })); + const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("unsupported npm spec"); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC); + } }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { @@ -695,4 +703,25 @@ describe("installPluginFromNpmSpec", () => { actualIntegrity: "sha512-new", }); }); + + it("classifies npm package-not-found errors with a stable error code", async () => { + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ + code: 1, + stdout: "", + stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/nope", + signal: null, + killed: false, + termination: "exit", + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/not-found", + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND); + } + }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index ab391548d1b..155fb5a0ab0 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -48,6 +48,17 @@ type PackageManifest = PluginPackageManifest & { const MISSING_EXTENSIONS_ERROR = 'package.json missing openclaw.extensions; update the plugin package to include openclaw.extensions (for example ["./dist/index.js"]). See https://docs.openclaw.ai/help/troubleshooting#plugin-install-fails-with-missing-openclaw-extensions'; +export const PLUGIN_INSTALL_ERROR_CODE = { + INVALID_NPM_SPEC: "invalid_npm_spec", + MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions", + EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions", + NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", + PLUGIN_ID_MISMATCH: "plugin_id_mismatch", +} as const; + +export type PluginInstallErrorCode = + (typeof PLUGIN_INSTALL_ERROR_CODE)[keyof typeof PLUGIN_INSTALL_ERROR_CODE]; + export type InstallPluginResult = | { ok: true; @@ -59,7 +70,7 @@ export type InstallPluginResult = npmResolution?: NpmSpecResolution; integrityDrift?: NpmIntegrityDrift; } - | { ok: false; error: string }; + | { ok: false; error: string; code?: PluginInstallErrorCode }; export type PluginNpmIntegrityDriftParams = { spec: string; @@ -86,15 +97,43 @@ function validatePluginId(pluginId: string): string | null { return null; } -function ensureOpenClawExtensions(params: { manifest: PackageManifest }): string[] { +function ensureOpenClawExtensions(params: { manifest: PackageManifest }): + | { + ok: true; + entries: string[]; + } + | { + ok: false; + error: string; + code: PluginInstallErrorCode; + } { const resolved = resolvePackageExtensionEntries(params.manifest); if (resolved.status === "missing") { - throw new Error(MISSING_EXTENSIONS_ERROR); + return { + ok: false, + error: MISSING_EXTENSIONS_ERROR, + code: PLUGIN_INSTALL_ERROR_CODE.MISSING_OPENCLAW_EXTENSIONS, + }; } if (resolved.status === "empty") { - throw new Error("package.json openclaw.extensions is empty"); + return { + ok: false, + error: "package.json openclaw.extensions is empty", + code: PLUGIN_INSTALL_ERROR_CODE.EMPTY_OPENCLAW_EXTENSIONS, + }; } - return resolved.entries; + return { + ok: true, + entries: resolved.entries, + }; +} + +function isNpmPackageNotFoundMessage(error: string): boolean { + const normalized = error.trim(); + if (normalized.startsWith("Package not found on npm:")) { + return true; + } + return /E404|404 not found|not in this registry/i.test(normalized); } function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { @@ -150,14 +189,17 @@ async function installPluginFromPackageDir(params: { return { ok: false, error: `invalid package.json: ${String(err)}` }; } - let extensions: string[]; - try { - extensions = ensureOpenClawExtensions({ - manifest, - }); - } catch (err) { - return { ok: false, error: String(err) }; + const extensionsResult = ensureOpenClawExtensions({ + manifest, + }); + if (!extensionsResult.ok) { + return { + ok: false, + error: extensionsResult.error, + code: extensionsResult.code, + }; } + const extensions = extensionsResult.entries; const pkgName = typeof manifest.name === "string" ? manifest.name : ""; const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; @@ -181,6 +223,7 @@ async function installPluginFromPackageDir(params: { return { ok: false, error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`, + code: PLUGIN_INSTALL_ERROR_CODE.PLUGIN_ID_MISMATCH, }; } @@ -436,7 +479,11 @@ export async function installPluginFromNpmSpec(params: { const spec = params.spec.trim(); const specError = validateRegistryNpmSpec(spec); if (specError) { - return { ok: false, error: specError }; + return { + ok: false, + error: specError, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC, + }; } logger.info?.(`Downloading ${spec}…`); @@ -459,7 +506,15 @@ export async function installPluginFromNpmSpec(params: { expectedPluginId, }, }); - return finalizeNpmSpecArchiveInstall(flowResult); + const finalized = finalizeNpmSpecArchiveInstall(flowResult); + if (!finalized.ok && isNpmPackageNotFoundMessage(finalized.error)) { + return { + ok: false, + error: finalized.error, + code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND, + }; + } + return finalized; } export async function installPluginFromPath(params: { diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts new file mode 100644 index 00000000000..6219376a37b --- /dev/null +++ b/src/plugins/update.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const installPluginFromNpmSpecMock = vi.fn(); + +vi.mock("./install.js", () => ({ + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args), + resolvePluginInstallDir: (pluginId: string) => `/tmp/${pluginId}`, + PLUGIN_INSTALL_ERROR_CODE: { + NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", + }, +})); + +describe("updateNpmInstalledPlugins", () => { + beforeEach(() => { + installPluginFromNpmSpecMock.mockReset(); + }); + + it("formats package-not-found updates with a stable message", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + code: "npm_package_not_found", + error: "Package not found on npm: @openclaw/missing.", + }); + + const { updateNpmInstalledPlugins } = await import("./update.js"); + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + missing: { + source: "npm", + spec: "@openclaw/missing", + installPath: "/tmp/missing", + }, + }, + }, + }, + pluginIds: ["missing"], + dryRun: true, + }); + + expect(result.outcomes).toEqual([ + { + pluginId: "missing", + status: "error", + message: "Failed to check missing: npm package not found for @openclaw/missing.", + }, + ]); + }); + + it("falls back to raw installer error for unknown error codes", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + code: "invalid_npm_spec", + error: "unsupported npm spec: github:evil/evil", + }); + + const { updateNpmInstalledPlugins } = await import("./update.js"); + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + bad: { + source: "npm", + spec: "github:evil/evil", + installPath: "/tmp/bad", + }, + }, + }, + }, + pluginIds: ["bad"], + dryRun: true, + }); + + expect(result.outcomes).toEqual([ + { + pluginId: "bad", + status: "error", + message: "Failed to check bad: unsupported npm spec: github:evil/evil", + }, + ]); + }); +}); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 2ba71158065..622d0e97616 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -5,7 +5,12 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; -import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js"; +import { + installPluginFromNpmSpec, + PLUGIN_INSTALL_ERROR_CODE, + type InstallPluginResult, + resolvePluginInstallDir, +} from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js"; export type PluginUpdateLogger = { @@ -53,6 +58,18 @@ export type PluginChannelSyncResult = { summary: PluginChannelSyncSummary; }; +function formatNpmInstallFailure(params: { + pluginId: string; + spec: string; + phase: "check" | "update"; + result: Extract; +}): string { + if (params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { + return `Failed to ${params.phase} ${params.pluginId}: npm package not found for ${params.spec}.`; + } + return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`; +} + type InstallIntegrityDrift = { spec: string; expectedIntegrity: string; @@ -250,7 +267,12 @@ export async function updateNpmInstalledPlugins(params: { outcomes.push({ pluginId, status: "error", - message: `Failed to check ${pluginId}: ${probe.error}`, + message: formatNpmInstallFailure({ + pluginId, + spec: record.spec, + phase: "check", + result: probe, + }), }); continue; } @@ -304,7 +326,12 @@ export async function updateNpmInstalledPlugins(params: { outcomes.push({ pluginId, status: "error", - message: `Failed to update ${pluginId}: ${result.error}`, + message: formatNpmInstallFailure({ + pluginId, + spec: record.spec, + phase: "update", + result: result, + }), }); continue; } diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index c28225ab71f..042b646003f 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -7,6 +7,7 @@ import { resolveDmGroupAccessDecision, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, + resolvePinnedMainDmOwnerFromAllowlist, } from "./dm-policy-shared.js"; describe("security/dm-policy-shared", () => { @@ -106,6 +107,43 @@ describe("security/dm-policy-shared", () => { expect(lists.effectiveGroupAllowFrom).toEqual([]); }); + it("infers pinned main DM owner from a single configured allowlist entry", () => { + const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: "main", + allowFrom: [" line:user:U123 "], + normalizeEntry: (entry) => + entry + .trim() + .toLowerCase() + .replace(/^line:(?:user:)?/, ""), + }); + expect(pinnedOwner).toBe("u123"); + }); + + it("does not infer pinned owner for wildcard/multi-owner/non-main scope", () => { + expect( + resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: "main", + allowFrom: ["*"], + normalizeEntry: (entry) => entry.trim(), + }), + ).toBeNull(); + expect( + resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: "main", + allowFrom: ["u123", "u456"], + normalizeEntry: (entry) => entry.trim(), + }), + ).toBeNull(); + expect( + resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: "per-channel-peer", + allowFrom: ["u123"], + normalizeEntry: (entry) => entry.trim(), + }), + ).toBeNull(); + }); + it("excludes storeAllowFrom when dmPolicy is allowlist", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: ["+1111"], diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index 27325e985b3..2b400734a2a 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -4,6 +4,28 @@ import type { ChannelId } from "../channels/plugins/types.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; +export function resolvePinnedMainDmOwnerFromAllowlist(params: { + dmScope?: string | null; + allowFrom?: Array | null; + normalizeEntry: (entry: string) => string | undefined; +}): string | null { + if ((params.dmScope ?? "main") !== "main") { + return null; + } + const rawAllowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : []; + if (rawAllowFrom.some((entry) => String(entry).trim() === "*")) { + return null; + } + const normalizedOwners = Array.from( + new Set( + rawAllowFrom + .map((entry) => params.normalizeEntry(String(entry))) + .filter((entry): entry is string => Boolean(entry)), + ), + ); + return normalizedOwners.length === 1 ? normalizedOwners[0] : null; +} + export function resolveEffectiveAllowFromLists(params: { allowFrom?: Array | null; groupAllowFrom?: Array | null; diff --git a/src/signal/identity.ts b/src/signal/identity.ts index ca8f9812644..244ebc2f61f 100644 --- a/src/signal/identity.ts +++ b/src/signal/identity.ts @@ -95,6 +95,14 @@ function parseSignalAllowEntry(entry: string): SignalAllowEntry | null { return { kind: "phone", e164: normalizeE164(stripped) }; } +export function normalizeSignalAllowRecipient(entry: string): string | undefined { + const parsed = parseSignalAllowEntry(entry); + if (!parsed || parsed.kind === "any") { + return undefined; + } + return parsed.kind === "phone" ? parsed.e164 : parsed.raw; +} + export function isSignalSenderAllowed(sender: SignalSender, allowFrom: string[]): boolean { if (allowFrom.length === 0) { return false; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 1c233b6b12e..c94a7b77a85 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -31,13 +31,17 @@ import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { DM_GROUP_ACCESS_REASON } from "../../security/dm-policy-shared.js"; +import { + DM_GROUP_ACCESS_REASON, + resolvePinnedMainDmOwnerFromAllowlist, +} from "../../security/dm-policy-shared.js"; import { normalizeE164 } from "../../utils.js"; import { formatSignalPairingIdLine, formatSignalSenderDisplay, formatSignalSenderId, isSignalSenderAllowed, + normalizeSignalAllowRecipient, resolveSignalPeerId, resolveSignalRecipient, resolveSignalSender, @@ -184,6 +188,25 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { channel: "signal", to: entry.senderRecipient, accountId: route.accountId, + mainDmOwnerPin: (() => { + const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: deps.cfg.session?.dmScope, + allowFrom: deps.allowFrom, + normalizeEntry: normalizeSignalAllowRecipient, + }); + if (!pinnedOwner) { + return undefined; + } + return { + ownerRecipient: pinnedOwner, + senderRecipient: entry.senderRecipient, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + }; + })(), } : undefined, onRecordError: (err) => { diff --git a/src/slack/monitor/allow-list.ts b/src/slack/monitor/allow-list.ts index 6b87acd6528..c8b1bfe6586 100644 --- a/src/slack/monitor/allow-list.ts +++ b/src/slack/monitor/allow-list.ts @@ -20,6 +20,15 @@ export function normalizeAllowListLower(list?: Array) { return normalizeStringEntriesLower(list); } +export function normalizeSlackAllowOwnerEntry(entry: string): string | undefined { + const trimmed = entry.trim().toLowerCase(); + if (!trimmed || trimmed === "*") { + return undefined; + } + const withoutPrefix = trimmed.replace(/^(slack:|user:)/, ""); + return /^u[a-z0-9]+$/.test(withoutPrefix) ? withoutPrefix : undefined; +} + export type SlackAllowListMatch = AllowlistMatch< "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "prefixed-name" | "slug" >; diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index e847b2446ee..147d8fa6bfb 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -10,6 +10,7 @@ import { createTypingCallbacks } from "../../../channels/typing.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js"; import { removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; @@ -22,6 +23,7 @@ import { import type { SlackStreamSession } from "../../streaming.js"; import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js"; import { resolveSlackThreadTargets } from "../../threading.js"; +import { normalizeSlackAllowOwnerEntry } from "../allow-list.js"; import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -88,17 +90,33 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const storePath = resolveStorePath(sessionCfg?.store, { agentId: route.agentId, }); - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "slack", - to: `user:${message.user}`, - accountId: route.accountId, - threadId: prepared.ctxPayload.MessageThreadId, - }, - ctx: prepared.ctxPayload, + const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: ctx.allowFrom, + normalizeEntry: normalizeSlackAllowOwnerEntry, }); + const senderRecipient = message.user?.trim().toLowerCase(); + const skipMainUpdate = + pinnedMainDmOwner && + senderRecipient && + pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient; + if (skipMainUpdate) { + logVerbose( + `slack: skip main-session last route for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`, + ); + } else { + await updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + deliveryContext: { + channel: "slack", + to: `user:${message.user}`, + accountId: route.accountId, + threadId: prepared.ctxPayload.MessageThreadId, + }, + ctx: prepared.ctxPayload, + }); + } } const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({ diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 0369b267689..247dac92edc 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -29,13 +29,18 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js"; import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { sendMessageSlack } from "../../send.js"; import { hasSlackThreadParticipation } from "../../sent-thread-cache.js"; import { resolveSlackThreadContext } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.js"; -import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js"; +import { + normalizeSlackAllowOwnerEntry, + resolveSlackAllowListMatch, + resolveSlackUserAllowed, +} from "../allow-list.js"; import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { stripSlackMentionsForCommandDetection } from "../commands.js"; @@ -701,6 +706,13 @@ export async function prepareSlackMessage(params: { OriginatingChannel: "slack" as const, OriginatingTo: slackTo, }) satisfies FinalizedMsgContext; + const pinnedMainDmOwner = isDirectMessage + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: ctx.allowFrom, + normalizeEntry: normalizeSlackAllowOwnerEntry, + }) + : null; await recordInboundSession({ storePath, @@ -713,6 +725,18 @@ export async function prepareSlackMessage(params: { to: `user:${message.user}`, accountId: route.accountId, threadId: threadContext.messageThreadId, + mainDmOwnerPin: + pinnedMainDmOwner && message.user + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: message.user.toLowerCase(), + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => { diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 7db6f7838fa..9e4205fcc23 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -40,6 +40,7 @@ import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, @@ -754,6 +755,14 @@ export const buildTelegramMessageContext = async ({ OriginatingTo: `telegram:${chatId}`, }); + const pinnedMainDmOwner = !isGroup + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: dmAllowFrom, + normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], + }) + : null; + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? sessionKey, @@ -766,6 +775,18 @@ export const buildTelegramMessageContext = async ({ accountId: route.accountId, // Preserve DM topic threadId for replies (fixes #8891) threadId: dmThreadId != null ? String(dmThreadId) : undefined, + mainDmOwnerPin: + pinnedMainDmOwner && senderId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: senderId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => { diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index aa0b597d754..e092922a770 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -22,6 +22,7 @@ import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { readStoreAllowFromForDmPolicy, + resolvePinnedMainDmOwnerFromAllowlist, resolveDmGroupAccessWithCommandGate, } from "../../../security/dm-policy-shared.js"; import { jidToE164, normalizeE164 } from "../../../utils.js"; @@ -111,22 +112,12 @@ function resolvePinnedMainDmRecipient(params: { cfg: ReturnType; msg: WebInboundMsg; }): string | null { - if ((params.cfg.session?.dmScope ?? "main") !== "main") { - return null; - } const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId }); - const rawAllowFrom = account.allowFrom ?? []; - if (rawAllowFrom.includes("*")) { - return null; - } - const normalizedOwners = Array.from( - new Set( - rawAllowFrom - .map((entry) => normalizeE164(String(entry))) - .filter((entry): entry is string => Boolean(entry)), - ), - ); - return normalizedOwners.length === 1 ? normalizedOwners[0] : null; + return resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: params.cfg.session?.dmScope, + allowFrom: account.allowFrom, + normalizeEntry: (entry) => normalizeE164(entry), + }); } export async function processMessage(params: {