mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:08:38 +00:00
refactor: harden plugin install flow and main DM route pinning
This commit is contained in:
@@ -41,6 +41,19 @@ Examples:
|
|||||||
- `agent:main:telegram:group:-1001234567890:topic:42`
|
- `agent:main:telegram:group:-1001234567890:topic:42`
|
||||||
- `agent:main:discord:channel:123456:thread:987654`
|
- `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 rules (how an agent is chosen)
|
||||||
|
|
||||||
Routing picks **one agent** for each inbound message:
|
Routing picks **one agent** for each inbound message:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,28 @@ export type InboundLastRouteUpdate = {
|
|||||||
to: string;
|
to: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
threadId?: string | number;
|
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: {
|
export async function recordInboundSession(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@@ -41,6 +61,9 @@ export async function recordInboundSession(params: {
|
|||||||
if (!update) {
|
if (!update) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (shouldSkipPinnedMainDmRouteUpdate(update.mainDmOwnerPin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const targetSessionKey = normalizeSessionStoreKey(update.sessionKey);
|
const targetSessionKey = normalizeSessionStoreKey(update.sessionKey);
|
||||||
await updateLastRoute({
|
await updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { resolveArchiveKind } from "../infra/archive.js";
|
import { resolveArchiveKind } from "../infra/archive.js";
|
||||||
import {
|
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
|
||||||
type BundledPluginSource,
|
|
||||||
findBundledPluginByNpmSpec,
|
|
||||||
findBundledPluginByPluginId,
|
|
||||||
} from "../plugins/bundled-sources.js";
|
|
||||||
import { enablePluginInConfig } from "../plugins/enable.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 { recordPluginInstall } from "../plugins/installs.js";
|
||||||
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
||||||
import type { PluginRecord } from "../plugins/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 {
|
function isBareNpmPackageName(spec: string): boolean {
|
||||||
const trimmed = spec.trim();
|
const trimmed = spec.trim();
|
||||||
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
|
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(`Installed plugin: ${params.bundledSource.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
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) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -572,162 +730,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.option("-l, --link", "Link a local path instead of copying", false)
|
.option("-l, --link", "Link a local path instead of copying", false)
|
||||||
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
|
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
|
||||||
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
|
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
|
||||||
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
|
await runPluginInstallCommand({ raw, opts });
|
||||||
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.`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
plugins
|
plugins
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
|||||||
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
|
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.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 { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
||||||
import {
|
import {
|
||||||
createDiscordFormModal,
|
createDiscordFormModal,
|
||||||
@@ -861,6 +864,17 @@ async function dispatchDiscordComponentEvent(params: {
|
|||||||
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
||||||
allowNameMatching,
|
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({
|
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||||
ctx,
|
ctx,
|
||||||
interactionCtx,
|
interactionCtx,
|
||||||
@@ -929,6 +943,17 @@ async function dispatchDiscordComponentEvent(params: {
|
|||||||
channel: "discord",
|
channel: "discord",
|
||||||
to: `user:${interactionCtx.userId}`,
|
to: `user:${interactionCtx.userId}`,
|
||||||
accountId,
|
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,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ import {
|
|||||||
readChannelAllowFromStore,
|
readChannelAllowFromStore,
|
||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../security/dm-policy-shared.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { resolveIMessageAccount } from "../accounts.js";
|
import { resolveIMessageAccount } from "../accounts.js";
|
||||||
import { createIMessageRpcClient } from "../client.js";
|
import { createIMessageRpcClient } from "../client.js";
|
||||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||||
import { probeIMessage } from "../probe.js";
|
import { probeIMessage } from "../probe.js";
|
||||||
import { sendMessageIMessage } from "../send.js";
|
import { sendMessageIMessage } from "../send.js";
|
||||||
|
import { normalizeIMessageHandle } from "../targets.js";
|
||||||
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
||||||
import { deliverReplies } from "./deliver.js";
|
import { deliverReplies } from "./deliver.js";
|
||||||
import { createSentMessageCache } from "./echo-cache.js";
|
import { createSentMessageCache } from "./echo-cache.js";
|
||||||
@@ -320,6 +322,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateTarget = chatTarget || decision.sender;
|
const updateTarget = chatTarget || decision.sender;
|
||||||
|
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
|
dmScope: cfg.session?.dmScope,
|
||||||
|
allowFrom,
|
||||||
|
normalizeEntry: normalizeIMessageHandle,
|
||||||
|
});
|
||||||
await recordInboundSession({
|
await recordInboundSession({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
|
||||||
@@ -331,6 +338,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
to: updateTarget,
|
to: updateTarget,
|
||||||
accountId: decision.route.accountId,
|
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,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { formatInboundEnvelope } from "../auto-reply/envelope.js";
|
|||||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||||
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||||
|
import { recordInboundSession } from "../channels/session.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { recordSessionMetaFromInbound, updateLastRoute } from "../config/sessions.js";
|
|
||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.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";
|
import type { ResolvedLineAccount } from "./types.js";
|
||||||
|
|
||||||
interface MediaRef {
|
interface MediaRef {
|
||||||
@@ -288,27 +290,42 @@ async function finalizeLineInboundContext(params: {
|
|||||||
OriginatingTo: originatingTo,
|
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,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
}).catch((err) => {
|
updateLastRoute: !params.source.isGroup
|
||||||
logVerbose(`line: failed updating session meta: ${String(err)}`);
|
? {
|
||||||
|
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()) {
|
if (shouldLogVerbose()) {
|
||||||
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
const mediaInfo =
|
const mediaInfo =
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import { findBundledPluginSource, resolveBundledPluginSources } from "./bundled-sources.js";
|
||||||
findBundledPluginByNpmSpec,
|
|
||||||
findBundledPluginByPluginId,
|
|
||||||
resolveBundledPluginSources,
|
|
||||||
} from "./bundled-sources.js";
|
|
||||||
|
|
||||||
const discoverOpenClawPluginsMock = vi.fn();
|
const discoverOpenClawPluginsMock = vi.fn();
|
||||||
const loadPluginManifestMock = vi.fn();
|
const loadPluginManifestMock = vi.fn();
|
||||||
@@ -91,8 +87,12 @@ describe("bundled plugin sources", () => {
|
|||||||
});
|
});
|
||||||
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
|
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
|
||||||
|
|
||||||
const resolved = findBundledPluginByNpmSpec({ spec: "@openclaw/feishu" });
|
const resolved = findBundledPluginSource({
|
||||||
const missing = findBundledPluginByNpmSpec({ spec: "@openclaw/not-found" });
|
lookup: { kind: "npmSpec", value: "@openclaw/feishu" },
|
||||||
|
});
|
||||||
|
const missing = findBundledPluginSource({
|
||||||
|
lookup: { kind: "npmSpec", value: "@openclaw/not-found" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(resolved?.pluginId).toBe("feishu");
|
expect(resolved?.pluginId).toBe("feishu");
|
||||||
expect(resolved?.localPath).toBe("/app/extensions/feishu");
|
expect(resolved?.localPath).toBe("/app/extensions/feishu");
|
||||||
@@ -113,8 +113,12 @@ describe("bundled plugin sources", () => {
|
|||||||
});
|
});
|
||||||
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } });
|
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } });
|
||||||
|
|
||||||
const resolved = findBundledPluginByPluginId({ pluginId: "diffs" });
|
const resolved = findBundledPluginSource({
|
||||||
const missing = findBundledPluginByPluginId({ pluginId: "not-found" });
|
lookup: { kind: "pluginId", value: "diffs" },
|
||||||
|
});
|
||||||
|
const missing = findBundledPluginSource({
|
||||||
|
lookup: { kind: "pluginId", value: "not-found" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(resolved?.pluginId).toBe("diffs");
|
expect(resolved?.pluginId).toBe("diffs");
|
||||||
expect(resolved?.localPath).toBe("/app/extensions/diffs");
|
expect(resolved?.localPath).toBe("/app/extensions/diffs");
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export type BundledPluginSource = {
|
|||||||
npmSpec?: string;
|
npmSpec?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BundledPluginLookup =
|
||||||
|
| { kind: "npmSpec"; value: string }
|
||||||
|
| { kind: "pluginId"; value: string };
|
||||||
|
|
||||||
export function resolveBundledPluginSources(params: {
|
export function resolveBundledPluginSources(params: {
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
}): Map<string, BundledPluginSource> {
|
}): Map<string, BundledPluginSource> {
|
||||||
@@ -41,38 +45,22 @@ export function resolveBundledPluginSources(params: {
|
|||||||
return bundled;
|
return bundled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findBundledPluginByNpmSpec(params: {
|
export function findBundledPluginSource(params: {
|
||||||
spec: string;
|
lookup: BundledPluginLookup;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
}): BundledPluginSource | undefined {
|
}): BundledPluginSource | undefined {
|
||||||
const targetSpec = params.spec.trim();
|
const targetValue = params.lookup.value.trim();
|
||||||
if (!targetSpec) {
|
if (!targetValue) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
||||||
|
if (params.lookup.kind === "pluginId") {
|
||||||
|
return bundled.get(targetValue);
|
||||||
|
}
|
||||||
for (const source of bundled.values()) {
|
for (const source of bundled.values()) {
|
||||||
if (source.npmSpec === targetSpec) {
|
if (source.npmSpec === targetValue) {
|
||||||
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) {
|
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-asserti
|
|||||||
import {
|
import {
|
||||||
expectInstallUsesIgnoreScripts,
|
expectInstallUsesIgnoreScripts,
|
||||||
expectIntegrityDriftRejected,
|
expectIntegrityDriftRejected,
|
||||||
expectUnsupportedNpmSpec,
|
|
||||||
mockNpmPackMetadataResult,
|
mockNpmPackMetadataResult,
|
||||||
} from "../test-utils/npm-spec-install-test-helpers.js";
|
} 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 installPluginFromDir: typeof import("./install.js").installPluginFromDir;
|
||||||
let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec;
|
let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec;
|
||||||
let installPluginFromPath: typeof import("./install.js").installPluginFromPath;
|
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 runCommandWithTimeout: typeof import("../process/exec.js").runCommandWithTimeout;
|
||||||
let suiteTempRoot = "";
|
let suiteTempRoot = "";
|
||||||
let tempDirCounter = 0;
|
let tempDirCounter = 0;
|
||||||
@@ -255,6 +255,7 @@ beforeAll(async () => {
|
|||||||
installPluginFromDir,
|
installPluginFromDir,
|
||||||
installPluginFromNpmSpec,
|
installPluginFromNpmSpec,
|
||||||
installPluginFromPath,
|
installPluginFromPath,
|
||||||
|
PLUGIN_INSTALL_ERROR_CODE,
|
||||||
} = await import("./install.js"));
|
} = await import("./install.js"));
|
||||||
({ runCommandWithTimeout } = await import("../process/exec.js"));
|
({ runCommandWithTimeout } = await import("../process/exec.js"));
|
||||||
});
|
});
|
||||||
@@ -372,6 +373,7 @@ describe("installPluginFromArchive", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(result.error).toContain("openclaw.extensions");
|
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 () => {
|
it("rejects legacy plugin package shape when openclaw.extensions is missing", async () => {
|
||||||
@@ -403,6 +405,7 @@ describe("installPluginFromArchive", () => {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error).toContain("package.json missing openclaw.extensions");
|
expect(result.error).toContain("package.json missing openclaw.extensions");
|
||||||
expect(result.error).toContain("update the plugin package");
|
expect(result.error).toContain("update the plugin package");
|
||||||
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.MISSING_OPENCLAW_EXTENSIONS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect.unreachable("expected install to fail without openclaw.extensions");
|
expect.unreachable("expected install to fail without openclaw.extensions");
|
||||||
@@ -668,7 +671,12 @@ describe("installPluginFromNpmSpec", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-registry npm specs", async () => {
|
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 () => {
|
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
|
||||||
@@ -695,4 +703,25 @@ describe("installPluginFromNpmSpec", () => {
|
|||||||
actualIntegrity: "sha512-new",
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ type PackageManifest = PluginPackageManifest & {
|
|||||||
const MISSING_EXTENSIONS_ERROR =
|
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';
|
'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 =
|
export type InstallPluginResult =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -59,7 +70,7 @@ export type InstallPluginResult =
|
|||||||
npmResolution?: NpmSpecResolution;
|
npmResolution?: NpmSpecResolution;
|
||||||
integrityDrift?: NpmIntegrityDrift;
|
integrityDrift?: NpmIntegrityDrift;
|
||||||
}
|
}
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string; code?: PluginInstallErrorCode };
|
||||||
|
|
||||||
export type PluginNpmIntegrityDriftParams = {
|
export type PluginNpmIntegrityDriftParams = {
|
||||||
spec: string;
|
spec: string;
|
||||||
@@ -86,15 +97,43 @@ function validatePluginId(pluginId: string): string | null {
|
|||||||
return 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);
|
const resolved = resolvePackageExtensionEntries(params.manifest);
|
||||||
if (resolved.status === "missing") {
|
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") {
|
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 {
|
function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult {
|
||||||
@@ -150,14 +189,17 @@ async function installPluginFromPackageDir(params: {
|
|||||||
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
let extensions: string[];
|
const extensionsResult = ensureOpenClawExtensions({
|
||||||
try {
|
manifest,
|
||||||
extensions = ensureOpenClawExtensions({
|
});
|
||||||
manifest,
|
if (!extensionsResult.ok) {
|
||||||
});
|
return {
|
||||||
} catch (err) {
|
ok: false,
|
||||||
return { ok: false, error: String(err) };
|
error: extensionsResult.error,
|
||||||
|
code: extensionsResult.code,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const extensions = extensionsResult.entries;
|
||||||
|
|
||||||
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
||||||
const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
||||||
@@ -181,6 +223,7 @@ async function installPluginFromPackageDir(params: {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
|
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 spec = params.spec.trim();
|
||||||
const specError = validateRegistryNpmSpec(spec);
|
const specError = validateRegistryNpmSpec(spec);
|
||||||
if (specError) {
|
if (specError) {
|
||||||
return { ok: false, error: specError };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: specError,
|
||||||
|
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info?.(`Downloading ${spec}…`);
|
logger.info?.(`Downloading ${spec}…`);
|
||||||
@@ -459,7 +506,15 @@ export async function installPluginFromNpmSpec(params: {
|
|||||||
expectedPluginId,
|
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: {
|
export async function installPluginFromPath(params: {
|
||||||
|
|||||||
83
src/plugins/update.test.ts
Normal file
83
src/plugins/update.test.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,12 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
|||||||
import type { UpdateChannel } from "../infra/update-channels.js";
|
import type { UpdateChannel } from "../infra/update-channels.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { resolveBundledPluginSources } from "./bundled-sources.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";
|
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
|
||||||
|
|
||||||
export type PluginUpdateLogger = {
|
export type PluginUpdateLogger = {
|
||||||
@@ -53,6 +58,18 @@ export type PluginChannelSyncResult = {
|
|||||||
summary: PluginChannelSyncSummary;
|
summary: PluginChannelSyncSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatNpmInstallFailure(params: {
|
||||||
|
pluginId: string;
|
||||||
|
spec: string;
|
||||||
|
phase: "check" | "update";
|
||||||
|
result: Extract<InstallPluginResult, { ok: false }>;
|
||||||
|
}): 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 = {
|
type InstallIntegrityDrift = {
|
||||||
spec: string;
|
spec: string;
|
||||||
expectedIntegrity: string;
|
expectedIntegrity: string;
|
||||||
@@ -250,7 +267,12 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `Failed to check ${pluginId}: ${probe.error}`,
|
message: formatNpmInstallFailure({
|
||||||
|
pluginId,
|
||||||
|
spec: record.spec,
|
||||||
|
phase: "check",
|
||||||
|
result: probe,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -304,7 +326,12 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `Failed to update ${pluginId}: ${result.error}`,
|
message: formatNpmInstallFailure({
|
||||||
|
pluginId,
|
||||||
|
spec: record.spec,
|
||||||
|
phase: "update",
|
||||||
|
result: result,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveDmGroupAccessDecision,
|
resolveDmGroupAccessDecision,
|
||||||
resolveDmGroupAccessWithLists,
|
resolveDmGroupAccessWithLists,
|
||||||
resolveEffectiveAllowFromLists,
|
resolveEffectiveAllowFromLists,
|
||||||
|
resolvePinnedMainDmOwnerFromAllowlist,
|
||||||
} from "./dm-policy-shared.js";
|
} from "./dm-policy-shared.js";
|
||||||
|
|
||||||
describe("security/dm-policy-shared", () => {
|
describe("security/dm-policy-shared", () => {
|
||||||
@@ -106,6 +107,43 @@ describe("security/dm-policy-shared", () => {
|
|||||||
expect(lists.effectiveGroupAllowFrom).toEqual([]);
|
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", () => {
|
it("excludes storeAllowFrom when dmPolicy is allowlist", () => {
|
||||||
const lists = resolveEffectiveAllowFromLists({
|
const lists = resolveEffectiveAllowFromLists({
|
||||||
allowFrom: ["+1111"],
|
allowFrom: ["+1111"],
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ import type { ChannelId } from "../channels/plugins/types.js";
|
|||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||||
|
|
||||||
|
export function resolvePinnedMainDmOwnerFromAllowlist(params: {
|
||||||
|
dmScope?: string | null;
|
||||||
|
allowFrom?: Array<string | number> | 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: {
|
export function resolveEffectiveAllowFromLists(params: {
|
||||||
allowFrom?: Array<string | number> | null;
|
allowFrom?: Array<string | number> | null;
|
||||||
groupAllowFrom?: Array<string | number> | null;
|
groupAllowFrom?: Array<string | number> | null;
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
|
|||||||
return { kind: "phone", e164: normalizeE164(stripped) };
|
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 {
|
export function isSignalSenderAllowed(sender: SignalSender, allowFrom: string[]): boolean {
|
||||||
if (allowFrom.length === 0) {
|
if (allowFrom.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -31,13 +31,17 @@ import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
|||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { mediaKindFromMime } from "../../media/constants.js";
|
import { mediaKindFromMime } from "../../media/constants.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.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 { normalizeE164 } from "../../utils.js";
|
||||||
import {
|
import {
|
||||||
formatSignalPairingIdLine,
|
formatSignalPairingIdLine,
|
||||||
formatSignalSenderDisplay,
|
formatSignalSenderDisplay,
|
||||||
formatSignalSenderId,
|
formatSignalSenderId,
|
||||||
isSignalSenderAllowed,
|
isSignalSenderAllowed,
|
||||||
|
normalizeSignalAllowRecipient,
|
||||||
resolveSignalPeerId,
|
resolveSignalPeerId,
|
||||||
resolveSignalRecipient,
|
resolveSignalRecipient,
|
||||||
resolveSignalSender,
|
resolveSignalSender,
|
||||||
@@ -184,6 +188,25 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
channel: "signal",
|
channel: "signal",
|
||||||
to: entry.senderRecipient,
|
to: entry.senderRecipient,
|
||||||
accountId: route.accountId,
|
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,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export function normalizeAllowListLower(list?: Array<string | number>) {
|
|||||||
return normalizeStringEntriesLower(list);
|
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<
|
export type SlackAllowListMatch = AllowlistMatch<
|
||||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "prefixed-name" | "slug"
|
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "prefixed-name" | "slug"
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createTypingCallbacks } from "../../../channels/typing.js";
|
|||||||
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||||
import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js";
|
import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js";
|
||||||
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js";
|
||||||
import { removeSlackReaction } from "../../actions.js";
|
import { removeSlackReaction } from "../../actions.js";
|
||||||
import { createSlackDraftStream } from "../../draft-stream.js";
|
import { createSlackDraftStream } from "../../draft-stream.js";
|
||||||
import { normalizeSlackOutboundText } from "../../format.js";
|
import { normalizeSlackOutboundText } from "../../format.js";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import type { SlackStreamSession } from "../../streaming.js";
|
import type { SlackStreamSession } from "../../streaming.js";
|
||||||
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
||||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||||
|
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
||||||
import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js";
|
import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js";
|
||||||
import type { PreparedSlackMessage } from "./types.js";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
|
|
||||||
@@ -88,17 +90,33 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
await updateLastRoute({
|
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
storePath,
|
dmScope: cfg.session?.dmScope,
|
||||||
sessionKey: route.mainSessionKey,
|
allowFrom: ctx.allowFrom,
|
||||||
deliveryContext: {
|
normalizeEntry: normalizeSlackAllowOwnerEntry,
|
||||||
channel: "slack",
|
|
||||||
to: `user:${message.user}`,
|
|
||||||
accountId: route.accountId,
|
|
||||||
threadId: prepared.ctxPayload.MessageThreadId,
|
|
||||||
},
|
|
||||||
ctx: prepared.ctxPayload,
|
|
||||||
});
|
});
|
||||||
|
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({
|
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
|
||||||
|
|||||||
@@ -29,13 +29,18 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
|||||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
||||||
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js";
|
||||||
import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js";
|
import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js";
|
||||||
import { reactSlackMessage } from "../../actions.js";
|
import { reactSlackMessage } from "../../actions.js";
|
||||||
import { sendMessageSlack } from "../../send.js";
|
import { sendMessageSlack } from "../../send.js";
|
||||||
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
|
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
|
||||||
import { resolveSlackThreadContext } from "../../threading.js";
|
import { resolveSlackThreadContext } from "../../threading.js";
|
||||||
import type { SlackMessageEvent } from "../../types.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 { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||||
import { stripSlackMentionsForCommandDetection } from "../commands.js";
|
import { stripSlackMentionsForCommandDetection } from "../commands.js";
|
||||||
@@ -701,6 +706,13 @@ export async function prepareSlackMessage(params: {
|
|||||||
OriginatingChannel: "slack" as const,
|
OriginatingChannel: "slack" as const,
|
||||||
OriginatingTo: slackTo,
|
OriginatingTo: slackTo,
|
||||||
}) satisfies FinalizedMsgContext;
|
}) satisfies FinalizedMsgContext;
|
||||||
|
const pinnedMainDmOwner = isDirectMessage
|
||||||
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
|
dmScope: cfg.session?.dmScope,
|
||||||
|
allowFrom: ctx.allowFrom,
|
||||||
|
normalizeEntry: normalizeSlackAllowOwnerEntry,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
await recordInboundSession({
|
await recordInboundSession({
|
||||||
storePath,
|
storePath,
|
||||||
@@ -713,6 +725,18 @@ export async function prepareSlackMessage(params: {
|
|||||||
to: `user:${message.user}`,
|
to: `user:${message.user}`,
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
threadId: threadContext.messageThreadId,
|
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,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { logVerbose, shouldLogVerbose } from "../globals.js";
|
|||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.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 { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||||
import {
|
import {
|
||||||
firstDefined,
|
firstDefined,
|
||||||
@@ -754,6 +755,14 @@ export const buildTelegramMessageContext = async ({
|
|||||||
OriginatingTo: `telegram:${chatId}`,
|
OriginatingTo: `telegram:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pinnedMainDmOwner = !isGroup
|
||||||
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
|
dmScope: cfg.session?.dmScope,
|
||||||
|
allowFrom: dmAllowFrom,
|
||||||
|
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
await recordInboundSession({
|
await recordInboundSession({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
||||||
@@ -766,6 +775,18 @@ export const buildTelegramMessageContext = async ({
|
|||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
// Preserve DM topic threadId for replies (fixes #8891)
|
// Preserve DM topic threadId for replies (fixes #8891)
|
||||||
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
|
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,
|
: undefined,
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
|
|||||||
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||||
import {
|
import {
|
||||||
readStoreAllowFromForDmPolicy,
|
readStoreAllowFromForDmPolicy,
|
||||||
|
resolvePinnedMainDmOwnerFromAllowlist,
|
||||||
resolveDmGroupAccessWithCommandGate,
|
resolveDmGroupAccessWithCommandGate,
|
||||||
} from "../../../security/dm-policy-shared.js";
|
} from "../../../security/dm-policy-shared.js";
|
||||||
import { jidToE164, normalizeE164 } from "../../../utils.js";
|
import { jidToE164, normalizeE164 } from "../../../utils.js";
|
||||||
@@ -111,22 +112,12 @@ function resolvePinnedMainDmRecipient(params: {
|
|||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
msg: WebInboundMsg;
|
msg: WebInboundMsg;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if ((params.cfg.session?.dmScope ?? "main") !== "main") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
|
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
|
||||||
const rawAllowFrom = account.allowFrom ?? [];
|
return resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
if (rawAllowFrom.includes("*")) {
|
dmScope: params.cfg.session?.dmScope,
|
||||||
return null;
|
allowFrom: account.allowFrom,
|
||||||
}
|
normalizeEntry: (entry) => normalizeE164(entry),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processMessage(params: {
|
export async function processMessage(params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user