fix(channels): harden manifest read-only metadata

This commit is contained in:
Vincent Koc
2026-04-24 15:42:46 -07:00
parent 0c54254231
commit 2d53ad5cb6
3 changed files with 169 additions and 17 deletions

View File

@@ -18,6 +18,8 @@ function writeExternalSetupChannelPlugin(
channelId?: string;
manifestChannelIds?: string[];
manifestChannelConfig?: boolean;
manifestChannelDescription?: string;
manifestChannelLabel?: string;
setupRequiresRuntime?: boolean;
setupChannelId?: string;
} = {},
@@ -80,8 +82,8 @@ function writeExternalSetupChannelPlugin(
sensitive: true,
},
},
label: "External Chat Manifest",
description: "manifest config",
label: options.manifestChannelLabel ?? "External Chat Manifest",
description: options.manifestChannelDescription ?? "manifest config",
preferOver: ["legacy-external-chat"],
},
]),
@@ -569,6 +571,103 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("sanitizes terminal control sequences from manifest channel metadata", () => {
const { pluginDir } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
manifestChannelLabel: "External\u001b[31m Chat\u001b[0m",
manifestChannelDescription: "manifest\u001b[2K config",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.label).toBe("External Chat");
expect(plugin?.meta.selectionLabel).toBe("External Chat");
expect(plugin?.meta.blurb).toBe("manifest config");
});
it("ignores manifest channel configs with unsafe channel ids", () => {
const unsafeChannelId = "__proto__";
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: unsafeChannelId,
manifestChannelIds: [unsafeChannelId],
manifestChannelConfig: true,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: Object.fromEntries([[unsafeChannelId, { token: "configured" }]]),
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
expect(plugins.some((entry) => entry.id === unsafeChannelId)).toBe(false);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("uses own normalized account ids for manifest channel account config", () => {
const { pluginDir } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
});
const inheritedAccounts = Object.create({
inherited: { token: "prototype-token" },
}) as Record<string, unknown>;
inheritedAccounts.default = { token: "default-token" };
inheritedAccounts.named = { token: "named-token" };
const cfg = {
channels: {
"external-chat": {
accounts: inheritedAccounts,
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
}).find((entry) => entry.id === "external-chat");
expect(plugin?.config.listAccountIds(cfg)).toEqual(["default", "named"]);
expect(plugin?.config.resolveAccount(cfg, "__proto__")).toMatchObject({
accountId: "default",
config: { token: "default-token" },
});
expect(plugin?.config.resolveAccount(cfg, "inherited")).not.toMatchObject({
config: { token: "prototype-token" },
});
});
it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",

View File

@@ -1,5 +1,6 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
import {
hasExplicitChannelConfig,
listConfiguredChannelIdsForReadOnlyScope,
@@ -10,11 +11,14 @@ import {
loadPluginManifestRegistry,
type PluginManifestRecord,
} from "../../plugins/manifest-registry.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import { listChannelPlugins } from "./registry.js";
import type { ChannelPlugin } from "./types.plugin.js";
const SAFE_MANIFEST_CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
type ReadOnlyChannelPluginOptions = {
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
@@ -28,6 +32,7 @@ type ReadOnlyChannelPluginResolution = {
configuredChannelIds: string[];
missingConfiguredChannelIds: string[];
};
type ManifestChannelConfigRecord = NonNullable<PluginManifestRecord["channelConfigs"]>[string];
function addChannelPlugins(
byId: Map<string, ChannelPlugin>,
@@ -66,6 +71,21 @@ function rebindChannelScopedString(
return value;
}
function isSafeManifestChannelId(channelId: string): boolean {
return SAFE_MANIFEST_CHANNEL_ID_PATTERN.test(channelId) && !isBlockedObjectKey(channelId);
}
function readOwnRecordValue(record: Record<string, unknown>, key: string): unknown {
if (isBlockedObjectKey(key) || !Object.prototype.hasOwnProperty.call(record, key)) {
return undefined;
}
return record[key];
}
function normalizeManifestText(value: string | undefined, fallback: string): string {
return sanitizeForLog(value?.trim() || fallback).trim();
}
function rebindChannelConfig(
cfg: OpenClawConfig,
sourceChannelId: string,
@@ -113,11 +133,14 @@ function restoreReboundChannelConfig(params: {
}
function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record<string, unknown> {
if (!isSafeManifestChannelId(channelId)) {
return {};
}
const channels = cfg.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return {};
}
const entry = (channels as Record<string, unknown>)[channelId];
const entry = readOwnRecordValue(channels as Record<string, unknown>, channelId);
return entry && typeof entry === "object" && !Array.isArray(entry)
? (entry as Record<string, unknown>)
: {};
@@ -127,7 +150,14 @@ function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string):
const channelConfig = getChannelConfigRecord(cfg, channelId);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
return Object.keys(accounts).toSorted((left, right) => left.localeCompare(right));
return [
...new Set(
Object.keys(accounts)
.filter((accountId) => !isBlockedObjectKey(accountId))
.map((accountId) => normalizeAccountId(accountId))
.filter((accountId) => !isBlockedObjectKey(accountId)),
),
].toSorted((left, right) => left.localeCompare(right));
}
return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : [];
}
@@ -138,10 +168,13 @@ function resolveManifestChannelAccountConfig(params: {
accountId?: string | null;
}): Record<string, unknown> {
const channelConfig = getChannelConfigRecord(params.cfg, params.channelId);
const resolvedAccountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const resolvedAccountId = normalizeAccountId(params.accountId);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
const accountConfig = (accounts as Record<string, unknown>)[resolvedAccountId];
const accountConfig = readOwnRecordValue(
accounts as Record<string, unknown>,
resolvedAccountId,
);
if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) {
return accountConfig as Record<string, unknown>;
}
@@ -153,19 +186,31 @@ function buildManifestChannelPlugin(params: {
record: PluginManifestRecord;
channelId: string;
}): ChannelPlugin | undefined {
const channelConfig = params.record.channelConfigs?.[params.channelId];
if (!channelConfig) {
if (!isSafeManifestChannelId(params.channelId)) {
return undefined;
}
const label = channelConfig.label?.trim() || params.record.name || params.channelId;
const blurb = channelConfig.description?.trim() || params.record.description || "";
const channelConfigValue = params.record.channelConfigs
? readOwnRecordValue(params.record.channelConfigs as Record<string, unknown>, params.channelId)
: undefined;
if (
!channelConfigValue ||
typeof channelConfigValue !== "object" ||
Array.isArray(channelConfigValue)
) {
return undefined;
}
const channelConfig = channelConfigValue as ManifestChannelConfigRecord;
const label =
normalizeManifestText(channelConfig.label, params.record.name || params.channelId) ||
params.channelId;
const blurb = normalizeManifestText(channelConfig.description, params.record.description || "");
return {
id: params.channelId,
meta: {
id: params.channelId,
label,
selectionLabel: label,
docsPath: `/channels/${params.channelId}`,
docsPath: `/channels/${encodeURIComponent(params.channelId)}`,
blurb,
...(channelConfig.preferOver?.length ? { preferOver: channelConfig.preferOver } : {}),
},
@@ -179,7 +224,7 @@ function buildManifestChannelPlugin(params: {
listAccountIds: (cfg) => listManifestChannelAccountIds(cfg, params.channelId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount: (cfg, accountId) => ({
accountId: accountId?.trim() || DEFAULT_ACCOUNT_ID,
accountId: normalizeAccountId(accountId),
config: resolveManifestChannelAccountConfig({
cfg,
channelId: params.channelId,
@@ -340,7 +385,9 @@ function addSetupChannelPlugins(
},
): void {
for (const setup of setups) {
const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId.get(setup.pluginId);
const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId
.get(setup.pluginId)
?.filter(isSafeManifestChannelId);
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
continue;
}
@@ -361,7 +408,9 @@ function addSetupChannelPlugins(
);
continue;
}
const ownedChannelIds = options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? [];
const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter(
isSafeManifestChannelId,
);
if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) {
continue;
}
@@ -395,6 +444,9 @@ function addManifestChannelPlugins(
continue;
}
for (const channelId of record.channels) {
if (!isSafeManifestChannelId(channelId)) {
continue;
}
if (!channelIds.has(channelId)) {
continue;
}
@@ -487,7 +539,7 @@ export function resolveReadOnlyChannelPluginsForConfig(
manifestRecords,
}),
),
];
].filter(isSafeManifestChannelId);
const byId = new Map<string, ChannelPlugin>();
addChannelPlugins(byId, listChannelPlugins());

View File

@@ -9,6 +9,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { theme } from "../terminal/theme.js";
import { formatTimeAgo } from "./format-time/format-relative.ts";
@@ -199,7 +200,7 @@ export async function buildChannelSummary(
: status === "not linked" || status === "auth stabilizing"
? theme.error
: theme.muted;
const baseLabel = plugin.meta.label ?? plugin.id;
const baseLabel = sanitizeForLog(plugin.meta.label ?? plugin.id).trim() || plugin.id;
let line = `${baseLabel}: ${status}`;
const authAgeMs =