fix(channel): harden local setup trust (#92175)

Summary:
- The PR extends channel setup trust enforcement and trusted catalog fallback from workspace-origin plugins to ... nfigured load paths into catalog discovery, and adds focused regression plus Docker/package proof coverage.
- PR surface: Source +190, Tests +892, Other +324. Total +1406 across 13 files.
- Reproducibility: yes. The source PR provides a concrete clean-main Docker/package path where an explicitly t ... ns unresolved, while the patched package resolves it and still blocks untrusted module and setup execution.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(channel): stabilize trusted catalog dts typing
- PR branch already contained follow-up commit before automerge: fix(channel): repair trusted catalog exclusions typing
- PR branch already contained follow-up commit before automerge: test(channel): cover local channel plugin trust
- PR branch already contained follow-up commit before automerge: chore(deps): refresh plugin shrinkwraps
- PR branch already contained follow-up commit before automerge: test(channel): route trust regression in command shard
- PR branch already contained follow-up commit before automerge: test(channel): remove e2e-named trust regression

Validation:
- ClawSweeper review passed for head eabee04d54.
- Required merge gates passed before the squash merge.

Prepared head SHA: eabee04d54
Review: https://github.com/openclaw/openclaw/pull/92175#issuecomment-4680798117

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-06-11 13:48:41 +00:00
committed by GitHub
co-authored by Mason Huang Copilot clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> hxy91819
parent 047785eb30
commit 2bec2caf0c
13 changed files with 1623 additions and 217 deletions
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
# Definition:
# Docker/package E2E proof for local channel plugin trust gating. The host
# mode builds or reuses the functional Docker image, then runs the container
# mode against the installed OpenClaw package.
#
# Parameters:
# --container: run the in-container scenario. Host mode is the default.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE: override the Docker image name.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1: reuse/pull the image.
#
# Outputs:
# stdout logs each case and prints "Channel plugin trust Docker E2E passed."
# Exit 0 means both representative package-environment cases passed.
# Exit non-zero means the package build, Docker run, or trust assertion failed.
usage() {
cat <<'EOF'
Usage:
bash scripts/e2e/channel-plugin-trust-docker.sh [--container]
Description:
Proves the packaged OpenClaw CLI enforces local channel plugin trust for
plugins.load.paths entries in a clean Docker/package environment.
Options:
--container Run the in-container scenario. Used by the host wrapper.
-h, --help Show this help.
Environment:
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE Override Docker image name.
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD Reuse/pull image instead of building.
OPENCLAW_TEST_STATE_SCRIPT_B64 Required in --container mode.
Outputs:
Prints case progress and PASS lines to stdout. Exits non-zero on assertion
failure and leaves the failing command output in the container log.
Examples:
bash scripts/e2e/channel-plugin-trust-docker.sh
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1 bash scripts/e2e/channel-plugin-trust-docker.sh
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
run_openclaw() {
if command -v openclaw >/dev/null 2>&1; then
openclaw "$@"
return
fi
if [ -f /app/openclaw.mjs ]; then
node /app/openclaw.mjs "$@"
return
fi
echo "openclaw CLI not found in Docker image" >&2
exit 1
}
write_load_paths_fixture() {
local plugin_dir="${1:?missing plugin dir}"
local origin="${2:?missing origin}"
local plugin_id="e2e-load-paths-shadow"
local channel_id="e2e-load-paths"
mkdir -p "$plugin_dir"
cat >"$plugin_dir/package.json" <<EOF
{
"name": "@openclaw-e2e/$plugin_id",
"version": "0.0.0-e2e",
"private": true,
"openclaw": {
"extensions": ["./index.cjs"],
"setupEntry": "./setup-entry.cjs",
"channel": {
"id": "$channel_id",
"label": "E2E Load Paths",
"selectionLabel": "E2E Load Paths",
"docsPath": "/channels/$channel_id",
"blurb": "Docker E2E local trust fixture."
}
}
}
EOF
cat >"$plugin_dir/openclaw.plugin.json" <<EOF
{
"id": "$plugin_id",
"name": "E2E load-paths Shadow",
"description": "Docker E2E local trust fixture.",
"activation": { "onStartup": false },
"channels": ["$channel_id"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
EOF
cat >"$plugin_dir/index.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
id: "$plugin_id",
register(api) {
writeMarker(registerMarker, "registered|origin=$origin|canary=" + canary + "\\n");
api.registerChannel({
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};
EOF
cat >"$plugin_dir/setup-entry.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_SETUP_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_SETUP_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "setup-imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths setup",
selectionLabel: "E2E Load Paths setup",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust setup fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
setup: {
validateInput: ({ input }) => {
writeMarker(
registerMarker,
"setup-registered|origin=$origin|canary=" + canary + "|token=" + (input?.token ?? "<no-token>") + "\\n",
);
return null;
},
applyAccountConfig: ({ cfg }) => cfg,
},
},
};
EOF
}
write_case_config() {
local plugin_dir="${1:?missing plugin dir}"
local trusted="${2:?missing trusted flag}"
local plugin_id="e2e-load-paths-shadow"
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
if [ "$trusted" = "1" ]; then
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"allow": ["$plugin_id"],
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
else
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
fi
}
run_case() {
local case_id="${1:?missing case id}"
local trusted="${2:?missing trusted flag}"
local scratch
scratch="$(mktemp -d "/tmp/openclaw-channel-plugin-trust-$case_id.XXXXXX")"
local plugin_dir="$scratch/e2e-load-paths-shadow"
local marker_dir="$scratch/markers"
local stdout_file="$scratch/stdout.log"
local stderr_file="$scratch/stderr.log"
local canary="$case_id-canary"
mkdir -p "$marker_dir"
write_load_paths_fixture "$plugin_dir" "config"
write_case_config "$plugin_dir" "$trusted"
echo "[CASE $case_id] plugins.load.paths trusted=$trusted"
set +e
PLUGINTRUST_IMPORT_MARKER="$marker_dir/import.marker" \
PLUGINTRUST_REGISTER_MARKER="$marker_dir/register.marker" \
PLUGINTRUST_SETUP_IMPORT_MARKER="$marker_dir/setup-import.marker" \
PLUGINTRUST_SETUP_REGISTER_MARKER="$marker_dir/setup-register.marker" \
PLUGINTRUST_CANARY="$canary" \
run_openclaw channels add --channel e2e-load-paths --token "$canary" \
>"$stdout_file" 2>"$stderr_file"
local status=$?
set -e
if [ "$trusted" = "1" ] && [ "$status" -ne 0 ]; then
echo "Expected trusted case to succeed; exit=$status" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if [ "$trusted" = "1" ]; then
for marker in setup-import setup-register; do
local marker_path="$marker_dir/$marker.marker"
if [ ! -f "$marker_path" ]; then
echo "Expected $marker marker for trusted case" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if ! grep -qF "canary=$canary" "$marker_path"; then
echo "$marker marker did not include canary $canary" >&2
cat "$marker_path" >&2 || true
exit 1
fi
done
echo "PASS: $case_id trusted load-paths setup entry executed"
else
for marker in setup-import setup-register import register; do
if [ -e "$marker_dir/$marker.marker" ]; then
echo "Expected $marker marker to be absent for untrusted case" >&2
cat "$marker_dir/$marker.marker" >&2 || true
exit 1
fi
done
echo "PASS: $case_id untrusted load-paths setup entry blocked"
fi
}
run_container() {
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
run_openclaw --version
run_case untrusted-load-paths 0
run_case trusted-load-paths 1
echo "Channel plugin trust Docker E2E passed."
}
run_host() {
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
local image_name
image_name="$(
docker_e2e_resolve_image \
"openclaw-channel-plugin-trust-e2e:local" \
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE
)"
local skip_build="${OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD:-0}"
docker_e2e_build_or_reuse "$image_name" channel-plugin-trust "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$skip_build"
local state_script_b64
state_script_b64="$(docker_e2e_test_state_shell_b64 channel-plugin-trust minimal)"
echo "Running channel plugin trust Docker E2E..."
docker_e2e_run_logged_print_with_harness \
channel-plugin-trust \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \
"$image_name" \
bash scripts/e2e/channel-plugin-trust-docker.sh --container
}
case "${1:-}" in
-h | --help)
usage
;;
--container)
run_container
;;
"")
run_host
;;
*)
echo "Unknown argument: $1" >&2
echo >&2
usage >&2
exit 1
;;
esac
+50 -2
View File
@@ -1,12 +1,21 @@
// Channel plugin catalog tests cover plugin catalog entries and metadata normalization.
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginChannelCatalogEntry } from "../../plugins/channel-catalog-registry.js";
const listChannelCatalogEntriesMock = vi.hoisted(() =>
vi.fn<() => PluginChannelCatalogEntry[]>(() => []),
);
vi.mock("../../plugins/channel-catalog-registry.js", () => ({
listChannelCatalogEntries: () => [],
listChannelCatalogEntries: listChannelCatalogEntriesMock,
}));
import { getChannelPluginCatalogEntry } from "./catalog.js";
beforeEach(() => {
listChannelCatalogEntriesMock.mockReset().mockReturnValue([]);
});
describe("channel plugin catalog", () => {
it("keeps third-party channel ids mapped with catalog install trust", () => {
const options = {
@@ -26,4 +35,43 @@ describe("channel plugin catalog", () => {
expect(yuanbao?.trustedSourceLinkedOfficialInstall).toBe(true);
expect(yuanbao?.install?.npmSpec).toBe("openclaw-plugin-yuanbao@2.13.1");
});
it("excludes only the rejected origin/plugin pair when resolving fallback copies", () => {
listChannelCatalogEntriesMock.mockReturnValue([
{
pluginId: "telegram",
origin: "config",
rootDir: "/tmp/config-telegram",
packageName: "telegram-shadow",
channel: {
id: "telegram",
label: "Telegram Shadow",
selectionLabel: "Telegram Shadow",
docsPath: "/channels/telegram",
blurb: "shadow",
},
install: { localPath: "/tmp/config-telegram" },
},
{
pluginId: "telegram",
origin: "bundled",
rootDir: "/tmp/bundled-telegram",
packageName: "@openclaw/telegram",
channel: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bundled",
},
install: { npmSpec: "@openclaw/telegram@1.0.0" },
},
] satisfies PluginChannelCatalogEntry[]);
expect(
getChannelPluginCatalogEntry("telegram", {
excludePluginRefs: [{ pluginId: "telegram", origin: "config" }],
})?.origin,
).toBe("bundled");
});
});
+33 -1
View File
@@ -62,7 +62,10 @@ type CatalogOptions = {
catalogPaths?: string[];
officialCatalogPaths?: string[];
env?: NodeJS.ProcessEnv;
extraPaths?: string[];
excludeWorkspace?: boolean;
excludeOrigins?: PluginOrigin[];
excludePluginRefs?: Array<{ pluginId: string; origin?: PluginOrigin }>;
installRecords?: Record<string, PluginInstallRecord>;
discovery?: PluginDiscoveryResult;
};
@@ -74,6 +77,31 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
bundled: 3,
};
function shouldExcludeCatalogOrigin(options: CatalogOptions, origin: PluginOrigin): boolean {
if (options.excludeWorkspace && origin === "workspace") {
return true;
}
return options.excludeOrigins?.includes(origin) ?? false;
}
function shouldExcludeCatalogPlugin(
options: CatalogOptions,
pluginId?: string,
origin?: PluginOrigin,
): boolean {
const normalizedPluginId = normalizeOptionalString(pluginId);
if (!normalizedPluginId) {
return false;
}
return (
options.excludePluginRefs?.some(
(entry) =>
entry.pluginId === normalizedPluginId &&
(entry.origin === undefined || entry.origin === origin),
) ?? false
);
}
const EXTERNAL_CATALOG_PRIORITY = ORIGIN_PRIORITY.bundled + 1;
const FALLBACK_CATALOG_PRIORITY = EXTERNAL_CATALOG_PRIORITY + 1;
@@ -438,13 +466,17 @@ export function listRawChannelPluginCatalogEntries(
const manifestEntries = listChannelCatalogEntries({
workspaceDir: options.workspaceDir,
env: options.env,
extraPaths: options.extraPaths,
installRecords: options.installRecords,
discovery: options.discovery,
});
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
for (const candidate of manifestEntries) {
if (options.excludeWorkspace && candidate.origin === "workspace") {
if (
shouldExcludeCatalogOrigin(options, candidate.origin) ||
shouldExcludeCatalogPlugin(options, candidate.pluginId, candidate.origin)
) {
continue;
}
const entry = buildCatalogEntryFromManifest({
@@ -80,6 +80,7 @@ describe("resolveInstallableChannelPlugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getChannelPlugin.mockReturnValue(undefined);
mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined);
mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
cfg: {},
installed: false,
@@ -99,9 +100,12 @@ describe("resolveInstallableChannelPlugin", () => {
});
const bundledPlugin = createPlugin("telegram");
mocks.listChannelPluginCatalogEntries.mockImplementation(
({ excludeWorkspace }: { excludeWorkspace?: boolean }) =>
excludeWorkspace ? [bundledEntry] : [workspaceEntry],
mocks.listChannelPluginCatalogEntries.mockImplementation(() => [workspaceEntry]);
mocks.getChannelPluginCatalogEntry.mockImplementation(
(_channel: string, opts?: { excludePluginRefs?: Array<{ pluginId: string }> }) =>
opts?.excludePluginRefs?.some((entry) => entry.pluginId === "evil-telegram-shadow")
? bundledEntry
: undefined,
);
mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockImplementation(
({ pluginId }: { pluginId?: string }) => ({
@@ -915,7 +915,8 @@ describe("ensureChannelSetupPluginInstalled", () => {
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "external-chat", {
workspaceDir: "/tmp/openclaw-workspace",
excludeWorkspace: true,
env: undefined,
excludePluginRefs: [{ pluginId: "evil-external-chat-shadow", origin: "workspace" }],
});
});
+601 -166
View File
@@ -1,30 +1,14 @@
// Trusted channel catalog tests cover workspace shadow filtering and plugin auto-enable trust resolution.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
const listRawChannelPluginCatalogEntries = vi.hoisted(() =>
vi.fn((_opts?: unknown): ChannelPluginCatalogEntry[] => []),
);
const getChannelPluginCatalogEntry = vi.hoisted(() =>
vi.fn((_id?: unknown, _opts?: unknown): ChannelPluginCatalogEntry | undefined => undefined),
);
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn(({ config }: { config: unknown }) => ({
config: config as never,
changes: [] as string[],
autoEnabledReasons: {},
})),
);
const getChannelPluginCatalogEntry = vi.hoisted(() => vi.fn());
const listRawChannelPluginCatalogEntries = vi.hoisted(() => vi.fn());
vi.mock("../../channels/plugins/catalog.js", () => ({
listRawChannelPluginCatalogEntries: (opts?: unknown) => listRawChannelPluginCatalogEntries(opts),
getChannelPluginCatalogEntry: (id?: unknown, opts?: unknown) =>
getChannelPluginCatalogEntry(id, opts),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (args: unknown) =>
applyPluginAutoEnable(args as { config: unknown; env?: NodeJS.ProcessEnv }),
getChannelPluginCatalogEntry: (...args: unknown[]) =>
getChannelPluginCatalogEntry(...(args as [string, Record<string, unknown>])),
listRawChannelPluginCatalogEntries: (options?: unknown) =>
listRawChannelPluginCatalogEntries(options),
}));
import {
@@ -33,166 +17,617 @@ import {
listTrustedChannelPluginCatalogEntries,
} from "./trusted-catalog.js";
function createCatalogEntry(params: {
id: string;
pluginId: string;
origin?: "workspace" | "bundled";
}): ChannelPluginCatalogEntry {
return {
id: params.id,
pluginId: params.pluginId,
origin: params.origin,
meta: {
id: params.id,
label: params.id,
selectionLabel: params.id,
docsPath: `/channels/${params.id}`,
blurb: `${params.id} channel`,
},
install: {
npmSpec: params.pluginId,
},
};
}
describe("trusted catalog helpers", () => {
describe("trusted-catalog load-path discovery", () => {
beforeEach(() => {
vi.clearAllMocks();
applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({
config: config as never,
changes: [] as string[],
autoEnabledReasons: {},
}));
});
it("falls back to the bundled entry for an untrusted workspace shadow", () => {
const workspaceEntry = createCatalogEntry({
id: "telegram",
pluginId: "evil-telegram-shadow",
origin: "workspace",
it("passes normalized load paths into trusted single-entry resolution", () => {
getChannelPluginCatalogEntry.mockReturnValue({
id: "e2e-load-paths",
pluginId: "e2e-load-paths-shadow",
origin: "config",
meta: {
id: "e2e-load-paths",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/e2e-load-paths",
blurb: "load-paths entry",
},
install: { localPath: "./plugins/e2e-load-paths", defaultChoice: "local" },
});
const bundledEntry = createCatalogEntry({
expect(
getTrustedChannelPluginCatalogEntry("e2e-load-paths", {
cfg: {
plugins: {
allow: ["e2e-load-paths-shadow"],
load: {
paths: [" /tmp/load-path-a ", "", "/tmp/load-path-b"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "e2e-load-paths",
pluginId: "e2e-load-paths-shadow",
});
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("e2e-load-paths", {
env: undefined,
extraPaths: ["/tmp/load-path-a", "/tmp/load-path-b"],
workspaceDir: "/tmp/workspace",
});
});
it("passes normalized load paths into trusted catalog listing and fallback lookup", () => {
listRawChannelPluginCatalogEntries.mockImplementation(
(options?: { excludeWorkspace?: boolean; extraPaths?: string[] }) => {
expect(options?.extraPaths).toEqual(["/tmp/load-path-a", "/tmp/load-path-b"]);
return [];
},
);
expect(
listTrustedChannelPluginCatalogEntries({
cfg: {
plugins: {
load: {
paths: [" /tmp/load-path-a ", "", "/tmp/load-path-b"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toStrictEqual([]);
expect(listRawChannelPluginCatalogEntries).toHaveBeenNthCalledWith(1, {
env: undefined,
extraPaths: ["/tmp/load-path-a", "/tmp/load-path-b"],
workspaceDir: "/tmp/workspace",
});
});
it("passes normalized load paths into setup discovery listing", () => {
listRawChannelPluginCatalogEntries.mockImplementation(
(options?: { excludeWorkspace?: boolean; extraPaths?: string[] }) => {
expect(options?.extraPaths).toEqual(["/tmp/load-path-a", "/tmp/load-path-b"]);
return [];
},
);
expect(
listSetupDiscoveryChannelPluginCatalogEntries({
cfg: {
plugins: {
load: {
paths: [" /tmp/load-path-a ", "", "/tmp/load-path-b"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toStrictEqual([]);
expect(listRawChannelPluginCatalogEntries).toHaveBeenNthCalledWith(1, {
env: undefined,
extraPaths: ["/tmp/load-path-a", "/tmp/load-path-b"],
workspaceDir: "/tmp/workspace",
});
});
it("falls back past an untrusted config-origin shadow to the bundled entry", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
extraPaths?: string[];
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) => {
expect(options?.extraPaths).toEqual(["/tmp/load-path-a"]);
if (
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "config-shadow" && entry.origin === "config",
)
) {
return {
id: "msteams",
pluginId: "bundled-msteams",
origin: "bundled",
meta: {
id: "msteams",
label: "Bundled Teams",
selectionLabel: "Bundled Teams",
docsPath: "/channels/msteams",
blurb: "bundled entry",
},
install: { localPath: "./bundled/msteams", defaultChoice: "local" },
};
}
return {
id: "msteams",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "msteams",
label: "Shadow Teams",
selectionLabel: "Shadow Teams",
docsPath: "/channels/msteams",
blurb: "config shadow",
},
install: { localPath: "./plugins/msteams-shadow", defaultChoice: "local" },
};
},
);
expect(
getTrustedChannelPluginCatalogEntry("msteams", {
cfg: {
plugins: {
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "msteams",
pluginId: "bundled-msteams",
origin: "bundled",
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "msteams", {
env: undefined,
extraPaths: ["/tmp/load-path-a"],
workspaceDir: "/tmp/workspace",
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "msteams", {
excludePluginRefs: [{ pluginId: "config-shadow", origin: "config" }],
env: undefined,
extraPaths: ["/tmp/load-path-a"],
workspaceDir: "/tmp/workspace",
});
});
it("keeps origin-specific fallback when local and bundled entries share a plugin id", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) => {
if (
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "telegram" && entry.origin === "config",
)
) {
return {
id: "telegram",
pluginId: "telegram",
origin: "bundled",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bundled entry",
},
install: { localPath: "./bundled/telegram", defaultChoice: "local" },
};
}
return {
id: "telegram",
pluginId: "telegram",
origin: "config",
meta: {
id: "telegram",
label: "Telegram Shadow",
selectionLabel: "Telegram Shadow",
docsPath: "/channels/telegram",
blurb: "config shadow",
},
install: { localPath: "./plugins/telegram-shadow", defaultChoice: "local" },
};
},
);
expect(
getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
plugins: {
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "telegram",
pluginId: "telegram",
origin: "bundled",
});
getChannelPluginCatalogEntry
.mockReturnValueOnce(workspaceEntry)
.mockReturnValueOnce(bundledEntry);
const result = getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {} as never,
workspaceDir: "/tmp/workspace",
env: process.env,
});
expect(result?.pluginId).toBe("telegram");
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "telegram", {
workspaceDir: "/tmp/workspace",
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "telegram", {
excludePluginRefs: [{ pluginId: "telegram", origin: "config" }],
env: undefined,
extraPaths: ["/tmp/load-path-a"],
workspaceDir: "/tmp/workspace",
excludeWorkspace: true,
});
});
it("keeps trusted workspace overrides eligible", () => {
const workspaceEntry = createCatalogEntry({
id: "telegram",
pluginId: "trusted-telegram-shadow",
origin: "workspace",
});
getChannelPluginCatalogEntry.mockReturnValue(workspaceEntry);
const result = getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
} as never,
workspaceDir: "/tmp/workspace",
env: process.env,
});
expect(result?.pluginId).toBe("trusted-telegram-shadow");
expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1);
});
it("omits untrusted workspace-only entries from the trusted list", () => {
const workspaceOnlyEntry = createCatalogEntry({
id: "my-cool-plugin",
pluginId: "my-cool-plugin",
origin: "workspace",
});
listRawChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? []
: [workspaceOnlyEntry],
);
const result = listTrustedChannelPluginCatalogEntries({
cfg: {} as never,
workspaceDir: "/tmp/workspace",
env: process.env,
});
expect(result).toEqual([]);
});
it("keeps workspace-only install candidates visible in discovery", () => {
const workspaceOnlyEntry = createCatalogEntry({
id: "my-cool-plugin",
pluginId: "my-cool-plugin",
origin: "workspace",
});
listRawChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? []
: [workspaceOnlyEntry],
);
const result = listSetupDiscoveryChannelPluginCatalogEntries({
cfg: {} as never,
workspaceDir: "/tmp/workspace",
env: process.env,
});
expect(result.map((entry) => entry.pluginId)).toEqual(["my-cool-plugin"]);
});
it("treats auto-enabled workspace plugins as trusted", () => {
const workspaceEntry = createCatalogEntry({
id: "telegram",
pluginId: "trusted-telegram-shadow",
origin: "workspace",
});
getChannelPluginCatalogEntry.mockReturnValue(workspaceEntry);
applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({
config: {
...(config as Record<string, unknown>),
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
} as never,
changes: ["trusted-telegram-shadow"] as string[],
autoEnabledReasons: {
"trusted-telegram-shadow": ["channel configured"],
it("stops when fallback lookup resurfaces the same untrusted local entry", () => {
getChannelPluginCatalogEntry.mockReturnValue({
id: "msteams",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "msteams",
label: "Shadow Teams",
selectionLabel: "Shadow Teams",
docsPath: "/channels/msteams",
blurb: "config shadow",
},
}));
const result = getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
channels: {
telegram: { token: "existing-token" },
},
} as never,
workspaceDir: "/tmp/workspace",
env: process.env,
install: { localPath: "./plugins/msteams-shadow", defaultChoice: "local" },
});
expect(result?.pluginId).toBe("trusted-telegram-shadow");
expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1);
expect(
getTrustedChannelPluginCatalogEntry("msteams", {
cfg: {
plugins: {
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toBeUndefined();
expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(2);
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "msteams", {
excludePluginRefs: [{ pluginId: "config-shadow", origin: "config" }],
env: undefined,
extraPaths: ["/tmp/load-path-a"],
workspaceDir: "/tmp/workspace",
});
});
it("keeps setup discovery visible when an untrusted config-origin entry has no fallback", () => {
listRawChannelPluginCatalogEntries.mockReturnValue([
{
id: "e2e-load-paths",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "e2e-load-paths",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/e2e-load-paths",
blurb: "config shadow",
},
install: { localPath: "./plugins/e2e-load-paths", defaultChoice: "local" },
},
]);
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
extraPaths?: string[];
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) => {
expect(options?.extraPaths).toEqual(["/tmp/load-path-a"]);
return options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "config-shadow" && entry.origin === "config",
)
? undefined
: {
id: "e2e-load-paths",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "e2e-load-paths",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/e2e-load-paths",
blurb: "config shadow",
},
install: { localPath: "./plugins/e2e-load-paths", defaultChoice: "local" },
};
},
);
expect(
listSetupDiscoveryChannelPluginCatalogEntries({
cfg: {
plugins: {
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toHaveLength(1);
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "e2e-load-paths", {
excludePluginRefs: [{ pluginId: "config-shadow", origin: "config" }],
env: undefined,
extraPaths: ["/tmp/load-path-a"],
workspaceDir: "/tmp/workspace",
});
});
it("falls back past a denylisted config-origin shadow even when it is explicitly allowed", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
extraPaths?: string[];
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) => {
expect(options?.extraPaths).toEqual(["/tmp/load-path-a"]);
if (
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "config-shadow" && entry.origin === "config",
)
) {
return {
id: "msteams",
pluginId: "bundled-msteams",
origin: "bundled",
meta: {
id: "msteams",
label: "Bundled Teams",
selectionLabel: "Bundled Teams",
docsPath: "/channels/msteams",
blurb: "bundled entry",
},
install: { localPath: "./bundled/msteams", defaultChoice: "local" },
};
}
return {
id: "msteams",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "msteams",
label: "Shadow Teams",
selectionLabel: "Shadow Teams",
docsPath: "/channels/msteams",
blurb: "config shadow",
},
install: { localPath: "./plugins/msteams-shadow", defaultChoice: "local" },
};
},
);
expect(
getTrustedChannelPluginCatalogEntry("msteams", {
cfg: {
plugins: {
allow: ["config-shadow"],
deny: ["config-shadow"],
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "msteams",
pluginId: "bundled-msteams",
origin: "bundled",
});
});
it("falls back past an auto-enabled config-origin shadow", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) =>
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "config-shadow" && entry.origin === "config",
)
? {
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bundled entry",
},
install: { localPath: "./bundled/telegram", defaultChoice: "local" },
}
: {
id: "telegram",
pluginId: "config-shadow",
origin: "config",
meta: {
id: "telegram",
label: "Telegram Shadow",
selectionLabel: "Telegram Shadow",
docsPath: "/channels/telegram",
blurb: "config shadow",
},
install: { localPath: "./plugins/telegram-shadow", defaultChoice: "local" },
},
);
expect(
getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
channels: {
telegram: {
enabled: true,
},
},
plugins: {
load: {
paths: ["/tmp/load-path-a"],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
});
});
it("falls back past an untrusted global shadow to the bundled entry", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) =>
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "global-shadow" && entry.origin === "global",
)
? {
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bundled entry",
},
install: { localPath: "./bundled/telegram", defaultChoice: "local" },
}
: {
id: "telegram",
pluginId: "global-shadow",
origin: "global",
meta: {
id: "telegram",
label: "Telegram Shadow",
selectionLabel: "Telegram Shadow",
docsPath: "/channels/telegram",
blurb: "global shadow",
},
install: { localPath: "./state/extensions/telegram", defaultChoice: "local" },
},
);
expect(
getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
plugins: {
enabled: true,
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
});
});
it("falls back past an explicitly disabled workspace shadow", () => {
getChannelPluginCatalogEntry.mockImplementation(
(
_channelId: string,
options?: {
excludePluginRefs?: Array<{ pluginId: string; origin?: string }>;
workspaceDir?: string;
},
) =>
options?.excludePluginRefs?.some(
(entry) => entry.pluginId === "workspace-shadow" && entry.origin === "workspace",
)
? {
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bundled entry",
},
install: { localPath: "./bundled/telegram", defaultChoice: "local" },
}
: {
id: "telegram",
pluginId: "workspace-shadow",
origin: "workspace",
meta: {
id: "telegram",
label: "Telegram Shadow",
selectionLabel: "Telegram Shadow",
docsPath: "/channels/telegram",
blurb: "workspace shadow",
},
install: { localPath: "./workspace/telegram", defaultChoice: "local" },
},
);
expect(
getTrustedChannelPluginCatalogEntry("telegram", {
cfg: {
plugins: {
allow: ["workspace-shadow"],
entries: {
"workspace-shadow": { enabled: false },
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toMatchObject({
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
});
});
it("forwards caller env when resolving load paths", () => {
getChannelPluginCatalogEntry.mockReturnValue(undefined);
getTrustedChannelPluginCatalogEntry("e2e-load-paths", {
cfg: {
plugins: {
load: {
paths: ["$OPENCLAW_HOME/custom-plugin"],
},
},
},
env: {
...process.env,
OPENCLAW_HOME: "/tmp/custom-home",
},
workspaceDir: "/tmp/workspace",
});
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("e2e-load-paths", {
env: expect.objectContaining({
OPENCLAW_HOME: "/tmp/custom-home",
}),
extraPaths: ["$OPENCLAW_HOME/custom-plugin"],
workspaceDir: "/tmp/workspace",
});
});
});
+144 -26
View File
@@ -6,7 +6,33 @@ import {
} from "../../channels/plugins/catalog.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js";
import {
normalizePluginsConfig,
resolveEffectivePluginActivationState,
} from "../../plugins/config-state.js";
import {
hasExplicitManifestOwnerTrust,
resolveManifestOwnerBasePolicyBlock,
} from "../../plugins/manifest-owner-policy.js";
import type { PluginOrigin } from "../../plugins/plugin-origin.types.js";
const LOCAL_CHANNEL_PLUGIN_ORIGINS = ["workspace", "config", "global"] as const;
type LocalChannelPluginOrigin = (typeof LOCAL_CHANNEL_PLUGIN_ORIGINS)[number];
type TrustedCatalogLookupExclusions = {
excludeOrigins?: PluginOrigin[];
excludePluginRefs?: Array<{ pluginId: string; origin?: PluginOrigin }>;
};
const LOCAL_CHANNEL_PLUGIN_ORIGIN_SET = new Set<PluginOrigin>(LOCAL_CHANNEL_PLUGIN_ORIGINS);
const MAX_TRUSTED_CATALOG_FALLBACKS = 16;
function isLocalChannelPluginOrigin(
origin: PluginOrigin | undefined,
): origin is LocalChannelPluginOrigin {
return origin !== undefined && LOCAL_CHANNEL_PLUGIN_ORIGIN_SET.has(origin);
}
function resolveEffectiveTrustConfig(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): OpenClawConfig {
return applyPluginAutoEnable({
@@ -15,23 +41,127 @@ function resolveEffectiveTrustConfig(cfg: OpenClawConfig, env?: NodeJS.ProcessEn
}).config;
}
function isTrustedWorkspaceChannelCatalogEntry(
function resolveTrustedCatalogExtraPaths(cfg: OpenClawConfig): string[] | undefined {
const extraPaths = normalizePluginsConfig(cfg.plugins).loadPaths;
return extraPaths.length > 0 ? extraPaths : undefined;
}
function isTrustedLocalChannelCatalogEntry(
entry: ChannelPluginCatalogEntry | undefined,
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): boolean {
if (entry?.origin !== "workspace") {
if (!isLocalChannelPluginOrigin(entry?.origin)) {
return true;
}
if (!entry.pluginId) {
return false;
}
const effectiveConfig = resolveEffectiveTrustConfig(cfg, env);
return resolveEnableState(
entry.pluginId,
"workspace",
normalizePluginsConfig(effectiveConfig.plugins),
).enabled;
const normalizedPlugins = normalizePluginsConfig(effectiveConfig.plugins);
if (
resolveManifestOwnerBasePolicyBlock({
plugin: { id: entry.pluginId },
normalizedConfig: normalizedPlugins,
}) !== null
) {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: entry.pluginId,
origin: entry.origin,
config: normalizedPlugins,
rootConfig: effectiveConfig,
});
return (
hasExplicitManifestOwnerTrust({
plugin: { id: entry.pluginId },
normalizedConfig: normalizedPlugins,
}) ||
(entry.origin === "workspace" && activationState.source === "auto")
);
}
function resolveRejectedCatalogLookup(
rejected: ChannelPluginCatalogEntry[],
): TrustedCatalogLookupExclusions {
const excludePluginRefs: NonNullable<TrustedCatalogLookupExclusions["excludePluginRefs"]> =
rejected.flatMap((entry) =>
entry.pluginId?.trim()
? [
{
pluginId: entry.pluginId.trim(),
...(entry.origin ? { origin: entry.origin } : {}),
},
]
: [],
);
const excludeOrigins: NonNullable<TrustedCatalogLookupExclusions["excludeOrigins"]> =
rejected.flatMap((entry) =>
isLocalChannelPluginOrigin(entry.origin) && !entry.pluginId ? [entry.origin] : [],
);
const lookup: TrustedCatalogLookupExclusions = {};
if (excludeOrigins.length > 0) {
lookup.excludeOrigins = excludeOrigins;
}
if (excludePluginRefs.length > 0) {
lookup.excludePluginRefs = excludePluginRefs;
}
return lookup;
}
function resolveRejectedCatalogEntryKey(entry: ChannelPluginCatalogEntry): string | null {
const pluginId = entry.pluginId?.trim();
if (pluginId) {
return `plugin:${entry.origin ?? ""}:${pluginId}`;
}
return isLocalChannelPluginOrigin(entry.origin) ? `origin:${entry.origin}` : null;
}
function resolveTrustedCatalogEntry(
channelId: string,
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
},
rejected: ChannelPluginCatalogEntry[] = [],
): ChannelPluginCatalogEntry | undefined {
const extraPaths = resolveTrustedCatalogExtraPaths(params.cfg);
const rejectedEntries = [...rejected];
const seenRejectedKeys = new Set(
rejectedEntries.flatMap((entry) => {
const key = resolveRejectedCatalogEntryKey(entry);
return key ? [key] : [];
}),
);
for (let attempts = 0; attempts <= MAX_TRUSTED_CATALOG_FALLBACKS; attempts += 1) {
const candidate = getChannelPluginCatalogEntry(channelId, {
workspaceDir: params.workspaceDir,
env: params.env,
...(extraPaths ? { extraPaths } : {}),
...resolveRejectedCatalogLookup(rejectedEntries),
});
if (!candidate) {
return undefined;
}
if (isTrustedLocalChannelCatalogEntry(candidate, params.cfg, params.env)) {
return candidate;
}
// Malformed discovery can ignore exclusions and resurface the same untrusted
// local entry. Stop instead of looping forever while searching for fallback metadata.
const rejectedKey = resolveRejectedCatalogEntryKey(candidate);
if (rejectedKey && seenRejectedKeys.has(rejectedKey)) {
return undefined;
}
if (rejectedKey) {
seenRejectedKeys.add(rejectedKey);
}
rejectedEntries.push(candidate);
}
return undefined;
}
/** Resolve a catalog entry, falling back to non-workspace metadata when workspace entry is untrusted. */
@@ -43,16 +173,7 @@ export function getTrustedChannelPluginCatalogEntry(
env?: NodeJS.ProcessEnv;
},
): ChannelPluginCatalogEntry | undefined {
const candidate = getChannelPluginCatalogEntry(channelId, {
workspaceDir: params.workspaceDir,
});
if (isTrustedWorkspaceChannelCatalogEntry(candidate, params.cfg, params.env)) {
return candidate;
}
return getChannelPluginCatalogEntry(channelId, {
workspaceDir: params.workspaceDir,
excludeWorkspace: true,
});
return resolveTrustedCatalogEntry(channelId, params);
}
function listChannelPluginCatalogEntriesWithTrustedFallback(
@@ -63,20 +184,17 @@ function listChannelPluginCatalogEntriesWithTrustedFallback(
},
onMissingFallback: (entry: ChannelPluginCatalogEntry) => ChannelPluginCatalogEntry[],
): ChannelPluginCatalogEntry[] {
const extraPaths = resolveTrustedCatalogExtraPaths(params.cfg);
const unfiltered = listRawChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
env: params.env,
...(extraPaths ? { extraPaths } : {}),
});
const fallbackById = new Map(
listRawChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
excludeWorkspace: true,
}).map((entry) => [entry.id, entry]),
);
return unfiltered.flatMap((entry) => {
if (isTrustedWorkspaceChannelCatalogEntry(entry, params.cfg, params.env)) {
if (isTrustedLocalChannelCatalogEntry(entry, params.cfg, params.env)) {
return [entry];
}
const fallback = fallbackById.get(entry.id);
const fallback = resolveTrustedCatalogEntry(entry.id, params, [entry]);
return fallback ? [fallback] : onMissingFallback(entry);
});
}
@@ -151,10 +151,12 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-
meta: workspaceEntry.meta,
install: { npmSpec: "@openclaw/telegram" },
};
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? [bundledEntry]
: [workspaceEntry],
listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]);
getChannelPluginCatalogEntry.mockImplementation(
(_channel: string, opts?: { excludePluginRefs?: Array<{ pluginId: string }> }) =>
opts?.excludePluginRefs?.some((entry) => entry.pluginId === "evil-telegram-shadow")
? bundledEntry
: undefined,
);
resolveChannelSetupEntries({
@@ -163,12 +165,15 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-
installedPlugins: [],
});
const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find(
([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true,
const fallbackCall = getChannelPluginCatalogEntry.mock.calls.find(
([, opts]) =>
(
opts as { excludePluginRefs?: Array<{ pluginId: string; origin?: string }> } | undefined
)?.excludePluginRefs?.some(
(entry) => entry.pluginId === "evil-telegram-shadow" && entry.origin === "workspace",
) === true,
);
expect(
(fallbackCall?.[0] as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace,
).toBe(true);
expect(fallbackCall).toBeTruthy();
});
it("still returns bundled-origin entries", () => {
+6 -3
View File
@@ -938,9 +938,12 @@ describe("channelsAddCommand", () => {
aliases: ["ext"],
},
};
catalogMocks.listChannelPluginCatalogEntries.mockImplementation(
({ excludeWorkspace }: { excludeWorkspace?: boolean } = {}) =>
excludeWorkspace ? [trustedEntry] : [workspaceEntry],
catalogMocks.listChannelPluginCatalogEntries.mockImplementation(() => [workspaceEntry]);
catalogMocks.getChannelPluginCatalogEntry.mockImplementation(
(_channel: string, opts?: { excludePluginRefs?: Array<{ pluginId: string }> }) =>
opts?.excludePluginRefs?.some((entry) => entry.pluginId === "evil-external-chat-shadow")
? trustedEntry
: undefined,
);
registerExternalChatSetupPlugin("@vendor/external-chat-plugin");
@@ -101,6 +101,7 @@ describe("listChannelCatalogEntries", () => {
expect(discoverSpy).toHaveBeenCalledTimes(1);
expect(firstDiscoverOptions(discoverSpy)).toStrictEqual({
env: ENV,
extraPaths: undefined,
installRecords: RECORDS,
workspaceDir: undefined,
});
@@ -130,6 +131,7 @@ describe("listChannelCatalogEntries", () => {
expect(loadRecordsSpy).not.toHaveBeenCalled();
expect(firstDiscoverOptions(discoverSpy)).toStrictEqual({
env: ENV,
extraPaths: undefined,
installRecords: supplied,
workspaceDir: undefined,
});
@@ -146,6 +148,22 @@ describe("listChannelCatalogEntries", () => {
expect(firstDiscoverOptions(discoverSpy)).not.toHaveProperty("installRecords");
});
it("forwards caller-supplied extraPaths to discovery", async () => {
const { module, discoverSpy } = await loadWithMocks({});
module.listChannelCatalogEntries({
env: ENV,
extraPaths: ["/tmp/plugins/a", "/tmp/plugins/b"],
});
expect(firstDiscoverOptions(discoverSpy)).toStrictEqual({
env: ENV,
extraPaths: ["/tmp/plugins/a", "/tmp/plugins/b"],
installRecords: RECORDS,
workspaceDir: undefined,
});
});
it("treats ledger read errors as a soft fallback (no installRecords propagated)", async () => {
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({
loadRecords: () => {
+2
View File
@@ -21,6 +21,7 @@ export function listChannelCatalogEntries(
origin?: PluginOrigin;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
extraPaths?: string[];
/**
* Optional override. When omitted and `origin !== "bundled"`, the persisted
* plugin install ledger is loaded synchronously so that npm-installed
@@ -37,6 +38,7 @@ export function listChannelCatalogEntries(
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
extraPaths: params.extraPaths,
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
});
return discovery.candidates.flatMap((candidate) => {
+384 -6
View File
@@ -5488,7 +5488,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
expect(registry.plugins.map((entry) => entry.id)).toEqual(["target-plugin"]);
});
it("only setup-loads a disabled channel plugin when the caller scopes to the selected plugin", () => {
it("does not setup-load an explicitly disabled channel plugin even when the caller scopes to it", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "lazy-channel-imported.txt");
const plugin = writePlugin({
@@ -5573,8 +5573,8 @@ module.exports = {
onlyPluginIds: ["lazy-channel-plugin"],
});
expect(fs.existsSync(marker)).toBe(true);
expect(scopedSetupRegistry.channelSetups).toHaveLength(1);
expect(fs.existsSync(marker)).toBe(false);
expect(scopedSetupRegistry.channelSetups).toHaveLength(0);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "lazy-channel-plugin")?.status,
@@ -5715,6 +5715,381 @@ module.exports = {
);
});
it("does not setup-load an untrusted config-origin channel plugin when the caller scopes to it", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "untrusted-load-path-channel-imported.txt");
const plugin = writePlugin({
id: "untrusted-load-path-channel",
filename: "untrusted-load-path-channel.cjs",
body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "untrusted-load-path-channel",
register(api) {
api.registerChannel({
plugin: {
id: "untrusted-load-path-channel",
meta: {
id: "untrusted-load-path-channel",
label: "Untrusted Load Path Channel",
selectionLabel: "Untrusted Load Path Channel",
docsPath: "/channels/untrusted-load-path-channel",
blurb: "untrusted load-path setup gate",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};`,
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "untrusted-load-path-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["untrusted-load-path-channel"],
},
null,
2,
),
"utf-8",
);
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [plugin.file] },
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["untrusted-load-path-channel"],
});
expect(fs.existsSync(marker)).toBe(false);
expect(scopedSetupRegistry.channelSetups).toHaveLength(0);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "untrusted-load-path-channel")
?.status,
).toBe("disabled");
});
it("does not setup-load a denylisted config-origin channel plugin even when explicitly allowed", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "denylisted-load-path-channel-imported.txt");
const plugin = writePlugin({
id: "denylisted-load-path-channel",
filename: "denylisted-load-path-channel.cjs",
body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "denylisted-load-path-channel",
register(api) {
api.registerChannel({
plugin: {
id: "denylisted-load-path-channel",
meta: {
id: "denylisted-load-path-channel",
label: "Denylisted Load Path Channel",
selectionLabel: "Denylisted Load Path Channel",
docsPath: "/channels/denylisted-load-path-channel",
blurb: "denylisted load-path setup gate",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};`,
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "denylisted-load-path-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["denylisted-load-path-channel"],
},
null,
2,
),
"utf-8",
);
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["denylisted-load-path-channel"],
deny: ["denylisted-load-path-channel"],
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["denylisted-load-path-channel"],
});
expect(fs.existsSync(marker)).toBe(false);
expect(scopedSetupRegistry.channelSetups).toHaveLength(0);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "denylisted-load-path-channel")
?.status,
).toBe("disabled");
});
it("does not setup-load an untrusted global channel plugin when the caller scopes to it", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "untrusted-global-channel-imported.txt");
withStateDir((stateDir) => {
const globalDir = path.join(stateDir, "extensions", "untrusted-global-channel");
mkdirSafe(globalDir);
fs.writeFileSync(
path.join(globalDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "untrusted-global-channel",
register(api) {
api.registerChannel({
plugin: {
id: "untrusted-global-channel",
meta: {
id: "untrusted-global-channel",
label: "Untrusted Global Channel",
selectionLabel: "Untrusted Global Channel",
docsPath: "/channels/untrusted-global-channel",
blurb: "untrusted global setup gate",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};`,
"utf-8",
);
fs.writeFileSync(
path.join(globalDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "untrusted-global-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["untrusted-global-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(globalDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/untrusted-global-channel",
version: "0.0.0-test",
main: "./index.cjs",
openclaw: {
extensions: ["./index.cjs"],
},
},
null,
2,
),
"utf-8",
);
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["untrusted-global-channel"],
});
expect(fs.existsSync(marker)).toBe(false);
expect(scopedSetupRegistry.channelSetups).toHaveLength(0);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "untrusted-global-channel")
?.status,
).toBe("disabled");
});
});
it("setup-loads a trusted global channel plugin when the caller scopes to it", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "trusted-global-channel-imported.txt");
withStateDir((stateDir) => {
const globalDir = path.join(stateDir, "extensions", "trusted-global-channel");
mkdirSafe(globalDir);
fs.writeFileSync(
path.join(globalDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "trusted-global-channel",
register(api) {
api.registerChannel({
plugin: {
id: "trusted-global-channel",
meta: {
id: "trusted-global-channel",
label: "Trusted Global Channel",
selectionLabel: "Trusted Global Channel",
docsPath: "/channels/trusted-global-channel",
blurb: "trusted global setup gate",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};`,
"utf-8",
);
fs.writeFileSync(
path.join(globalDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "trusted-global-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["trusted-global-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(globalDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/trusted-global-channel",
version: "0.0.0-test",
main: "./index.cjs",
openclaw: {
extensions: ["./index.cjs"],
},
},
null,
2,
),
"utf-8",
);
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
allow: ["trusted-global-channel"],
},
},
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins: true,
onlyPluginIds: ["trusted-global-channel"],
});
expect(fs.existsSync(marker)).toBe(true);
expect(scopedSetupRegistry.channelSetups.map((entry) => entry.plugin.meta.label)).toEqual([
"Trusted Global Channel",
]);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "trusted-global-channel")?.status,
).toBe("loaded");
});
});
it("does not setup-load an auto-enabled config-origin channel plugin without explicit trust", () => {
useNoBundledPlugins();
const marker = path.join(makeTempDir(), "auto-enabled-load-path-channel-imported.txt");
const plugin = writePlugin({
id: "auto-enabled-load-path-channel",
filename: "auto-enabled-load-path-channel.cjs",
body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8");
module.exports = {
id: "auto-enabled-load-path-channel",
register(api) {
api.registerChannel({
plugin: {
id: "auto-enabled-load-path-channel",
meta: {
id: "auto-enabled-load-path-channel",
label: "Auto Enabled Load Path Channel",
selectionLabel: "Auto Enabled Load Path Channel",
docsPath: "/channels/auto-enabled-load-path-channel",
blurb: "auto-enabled load-path setup gate",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};`,
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "auto-enabled-load-path-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["auto-enabled-load-path-channel"],
},
null,
2,
),
"utf-8",
);
const scopedSetupRegistry = loadOpenClawPlugins({
cache: false,
config: {
channels: {
"auto-enabled-load-path-channel": {
enabled: true,
},
},
plugins: {
load: { paths: [plugin.file] },
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["auto-enabled-load-path-channel"],
});
expect(fs.existsSync(marker)).toBe(false);
expect(scopedSetupRegistry.channelSetups).toHaveLength(0);
expect(scopedSetupRegistry.channels).toHaveLength(0);
expect(
scopedSetupRegistry.plugins.find((entry) => entry.id === "auto-enabled-load-path-channel")
?.status,
).toBe("disabled");
});
it.each([
{
name: "uses package setupEntry for selected setup-only channel loads",
@@ -5742,7 +6117,8 @@ module.exports = {
onlyPluginIds: ["setup-entry-test"],
}),
expectFullLoaded: false,
expectSetupLoaded: true,
expectSetupLoaded: false,
expectedChannelSetups: 0,
expectedChannels: 0,
},
{
@@ -5772,7 +6148,8 @@ module.exports = {
onlyPluginIds: ["setup-only-bundled-contract-test"],
}),
expectFullLoaded: false,
expectSetupLoaded: true,
expectSetupLoaded: false,
expectedChannelSetups: 0,
expectedChannels: 0,
},
{
@@ -6013,6 +6390,7 @@ module.exports = {
load,
expectFullLoaded,
expectSetupLoaded,
expectedChannelSetups,
expectedChannels,
expectedSetupSecretId,
expectSetupRuntimeLoaded,
@@ -6024,7 +6402,7 @@ module.exports = {
expect(fs.existsSync(built.fullMarker)).toBe(expectFullLoaded);
expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded);
expect(registry.channelSetups).toHaveLength(1);
expect(registry.channelSetups).toHaveLength(expectedChannelSetups ?? 1);
expect(registry.channels).toHaveLength(expectedChannels);
if (fixture.bundledSetupRuntimeMarker) {
expect(fs.existsSync(fixture.bundledSetupRuntimeMarker)).toBe(
+38
View File
@@ -98,6 +98,10 @@ import {
markPluginActivationDisabled,
recordPluginError,
} from "./loader-records.js";
import {
hasExplicitManifestOwnerTrust,
resolveManifestOwnerBasePolicyBlock,
} from "./manifest-owner-policy.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
@@ -2070,6 +2074,27 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
const localSetupBasePolicyBlock = resolveManifestOwnerBasePolicyBlock({
plugin: { id: pluginId },
normalizedConfig: normalized,
});
const trustedLocalScopedChannelSetupImport =
localSetupBasePolicyBlock === null &&
(hasExplicitManifestOwnerTrust({
plugin: { id: pluginId },
normalizedConfig: normalized,
}) ||
(candidate.origin === "workspace" && activationState.source === "auto"));
// Scoped setup-only loads intentionally bypass normal activation so setup
// can reach explicitly trusted local plugins. Reapply the non-bundled
// trust boundary here so default-only workspace/config/global plugins never import.
const blockUntrustedLocalScopedChannelSetupImport =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
Boolean(onlyPluginIdSet) &&
manifestRecord.channels.length > 0 &&
candidate.origin !== "bundled" &&
!trustedLocalScopedChannelSetupImport;
const pushPluginLoadError = (message: string) => {
record.status = "error";
record.error = message;
@@ -2084,6 +2109,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
message: record.error,
});
};
if (blockUntrustedLocalScopedChannelSetupImport) {
record.status = "disabled";
record.error =
activationState.reason ??
enableState.reason ??
"local plugin requires explicit trust for setup";
markPluginActivationDisabled(record, record.error);
// Manifest-registry duplicate resolution already canonicalizes same-id
// plugin shadows before this load loop. Do not claim `seenIds` here:
// different-id trusted fallbacks still need a chance to load later.
registry.plugins.push(record);
continue;
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const runtimeCandidateEntry = resolvePreferredBuiltRuntimeArtifact({
source: candidate.source,