fix(secretrefs): resolve external channel contracts (#76449)

This commit is contained in:
Josh Avant
2026-05-02 23:48:11 -05:00
committed by GitHub
parent 8f4eaa9c00
commit b1f8172867
18 changed files with 517 additions and 38 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. (#76449) Thanks @joshavant.
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.

View File

@@ -23,6 +23,7 @@ import {
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
@@ -193,7 +194,7 @@ export async function handleBlueBubblesWebhookRequest(
targets,
res,
isMatch: (target) => {
const token = target.account.config.password?.trim() ?? "";
const token = normalizeSecretInputString(target.account.config.password) ?? "";
return safeEqualAuthToken(guid, token);
},
});

View File

@@ -432,6 +432,16 @@ describe("BlueBubbles webhook monitor", () => {
);
});
it("rejects unresolved SecretRef webhook passwords without crashing", async () => {
setupWebhookTarget({
account: createMockAccount({
password: { source: "exec", provider: "vault", id: "bluebubbles/webhook" } as never,
}),
});
await expectProtectedPasswordQueryRequestStatus(401);
});
it("rate limits repeated invalid password guesses from the same client", async () => {
setupWebhookTarget({
account: createMockAccount({

View File

@@ -88,4 +88,26 @@ describe("discord setup account state", () => {
expect(inspected.tokenStatus).toBe("missing");
expect(inspected.configured).toBe(false);
});
it("reports unresolved SecretRef account tokens as configured but unavailable", () => {
const inspected = inspectDiscordSetupAccount({
cfg: {
channels: {
discord: {
accounts: {
work: {
token: { source: "exec", provider: "vault", id: "discord/work" },
},
},
},
},
},
accountId: "work",
});
expect(inspected.token).toBe("");
expect(inspected.tokenSource).toBe("config");
expect(inspected.tokenStatus).toBe("configured_unavailable");
expect(inspected.configured).toBe(true);
});
});

View File

@@ -4,6 +4,7 @@ import { withEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createTelegramActionGate,
listEnabledTelegramAccounts,
listTelegramAccountIds,
mergeTelegramAccountConfig,
resolveTelegramMediaRuntimeOptions,
@@ -123,6 +124,27 @@ describe("resolveTelegramAccount", () => {
expect(lines).toContain("listTelegramAccountIds [ 'work' ]");
expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }");
});
it("does not resolve disabled account tokens when listing enabled accounts", () => {
const cfg = {
channels: {
telegram: {
accounts: {
disabled: {
enabled: false,
botToken: { source: "exec", provider: "vault", id: "telegram/disabled" },
},
work: { botToken: "tok-work" },
},
},
},
} as unknown as OpenClawConfig;
const accounts = listEnabledTelegramAccounts(cfg);
expect(accounts.map((account) => account.accountId)).toEqual(["work"]);
expect(accounts[0]?.token).toBe("tok-work");
});
});
describe("resolveDefaultTelegramAccountId", () => {

View File

@@ -177,7 +177,11 @@ export function resolveTelegramAccount(params: {
}
export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] {
const baseEnabled = cfg.channels?.telegram?.enabled !== false;
if (!baseEnabled) {
return [];
}
return listTelegramAccountIds(cfg)
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
.filter((account) => account.enabled);
.filter((accountId) => mergeTelegramAccountConfig(cfg, accountId).enabled !== false)
.map((accountId) => resolveTelegramAccount({ cfg, accountId }));
}

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
import { asToolParamsRecord, jsonResult } from "./common.js";
@@ -92,7 +93,10 @@ export function createWebSearchTool(options?: {
: options?.runtimeWebSearch;
const runtimeProviderId =
runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured;
const config = options?.lateBindRuntimeConfig === true ? undefined : options?.config;
const config =
options?.lateBindRuntimeConfig === true
? (getActiveSecretsRuntimeSnapshot()?.config ?? options?.config)
: options?.config;
const preferRuntimeProviders =
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({

View File

@@ -10,6 +10,13 @@ import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
const runWebSearchCalls = vi.hoisted(
() => [] as Array<{ config?: unknown; runtimeWebSearch?: unknown }>,
);
const activeSecretsRuntimeSnapshot = vi.hoisted(() => ({
current: null as null | { config: unknown },
}));
vi.mock("../../secrets/runtime.js", () => ({
getActiveSecretsRuntimeSnapshot: () => activeSecretsRuntimeSnapshot.current,
}));
vi.mock("../../web-search/runtime.js", async () => {
const { getActivePluginRegistry } = await import("../../plugins/runtime.js");
@@ -68,12 +75,14 @@ vi.mock("../../web-search/runtime.js", async () => {
beforeEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
clearActiveRuntimeWebToolsMetadata();
activeSecretsRuntimeSnapshot.current = null;
runWebSearchCalls.length = 0;
});
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
clearActiveRuntimeWebToolsMetadata();
activeSecretsRuntimeSnapshot.current = null;
});
describe("web tools defaults", () => {
@@ -196,6 +205,10 @@ describe("web tools defaults", () => {
},
diagnostics: [],
});
const runtimeConfig = {
tools: { web: { search: { provider: "fresh", fresh: { apiKey: "runtime-key" } } } },
};
activeSecretsRuntimeSnapshot.current = { config: runtimeConfig };
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "stale" } } } },
@@ -214,7 +227,7 @@ describe("web tools defaults", () => {
expect(result?.details).toMatchObject({ provider: "fresh" });
expect(runWebSearchCalls).toHaveLength(1);
expect(runWebSearchCalls[0]?.config).toBeUndefined();
expect(runWebSearchCalls[0]?.config).toBe(runtimeConfig);
expect(runWebSearchCalls[0]?.runtimeWebSearch).toMatchObject({
selectedProvider: "fresh",
});

View File

@@ -1,6 +1,7 @@
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import { loadChannelSecretContractApi } from "../secrets/channel-contract-api.js";
import {
discoverConfigSecretTargetsByIds,
listSecretTargetRegistryEntries,
@@ -115,6 +116,20 @@ function getConfiguredChannelSecretTargetIds(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const targetIds = new Set<string>();
const channels = config.channels;
if (channels && typeof channels === "object" && !Array.isArray(channels)) {
for (const channelId of Object.keys(channels)) {
if (channelId === "defaults") {
continue;
}
const contract = loadChannelSecretContractApi({ channelId, config, env });
for (const entry of contract?.secretTargetRegistryEntries ?? []) {
if (isScopedChannelSecretTargetEntry({ entry, pluginChannelId: channelId })) {
targetIds.add(entry.id);
}
}
}
}
for (const plugin of listReadOnlyChannelPluginsForConfig(config, {
env,
includePersistedAuthState: false,

View File

@@ -0,0 +1,116 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
const { loadPluginMetadataSnapshotMock, loadBundledPluginPublicArtifactModuleSyncMock } =
vi.hoisted(() => ({
loadPluginMetadataSnapshotMock: vi.fn(),
loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => {
throw new Error(
"Unable to resolve bundled plugin public surface discord/secret-contract-api.js",
);
}),
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
vi.mock("../plugins/public-surface-loader.js", () => ({
loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock,
}));
import { loadChannelSecretContractApi } from "./channel-contract-api.js";
function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) {
const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs);
fs.writeFileSync(
path.join(rootDir, "secret-contract-api.cjs"),
`
module.exports = {
secretTargetRegistryEntries: [
{
id: "channels.${params.channelId}.token",
targetType: "channels.${params.channelId}.token",
configFile: "openclaw.json",
pathPattern: "channels.${params.channelId}.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true
}
],
collectRuntimeConfigAssignments(params) {
params.context.assignments.push({
path: "channels.${params.channelId}.token",
ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
expected: "string",
apply() {}
});
}
};
`,
"utf8",
);
return {
id: params.pluginId,
origin: "global",
channels: [params.channelId],
channelConfigs: {},
rootDir,
};
}
describe("external channel secret contract api", () => {
beforeEach(() => {
loadPluginMetadataSnapshotMock.mockReset();
loadBundledPluginPublicArtifactModuleSyncMock.mockClear();
});
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
it("loads root secret-contract-api sidecars for external channel plugins", () => {
const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" });
loadPluginMetadataSnapshotMock.mockReturnValue({
plugins: [record],
});
const api = loadChannelSecretContractApi({
channelId: "discord",
config: { channels: { discord: {} } },
env: {},
loadablePluginOrigins: new Map([["discord", "global"]]),
});
expect(api?.secretTargetRegistryEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "channels.discord.token",
}),
]),
);
expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function");
});
it("skips external channel records outside the loadable plugin origin set", () => {
const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" });
loadPluginMetadataSnapshotMock.mockReturnValue({
plugins: [record],
});
const api = loadChannelSecretContractApi({
channelId: "discord",
config: { channels: { discord: {} } },
env: {},
loadablePluginOrigins: new Map([["other", "global"]]),
});
expect(api).toBeUndefined();
});
});

View File

@@ -1,4 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "../plugins/plugin-module-loader-cache.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js";
import type { ResolverContext, SecretDefaults } from "./runtime-shared.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
@@ -21,6 +34,13 @@ type BundledChannelContractApi = {
) => UnsupportedSecretRefConfigCandidate[];
};
const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const RUNNING_FROM_BUILT_ARTIFACT =
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
function loadBundledChannelPublicArtifact(
channelId: string,
artifactBasenames: readonly string[],
@@ -60,6 +80,147 @@ export function loadBundledChannelSecretContractApi(
return loadBundledChannelPublicArtifact(channelId, ["secret-contract-api.js", "contract-api.js"]);
}
function orderedContractApiExtensions(): readonly string[] {
return RUNNING_FROM_BUILT_ARTIFACT
? CONTRACT_API_EXTENSIONS
: ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const);
}
function resolvePluginContractApiPath(rootDir: string): string | null {
for (const extension of orderedContractApiExtensions()) {
const candidate = path.join(rootDir, `secret-contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of orderedContractApiExtensions()) {
const candidate = path.join(rootDir, `contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function loadPluginContractModule(modulePath: string): BundledChannelContractApi {
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
})(modulePath) as BundledChannelContractApi;
}
function loadExternalChannelSecretContractFromRecord(
record: PluginManifestRecord,
): BundledChannelSecretContractApi | undefined {
const contractPath = resolvePluginContractApiPath(record.rootDir);
if (!contractPath) {
return undefined;
}
const opened = openBoundaryFileSync({
absolutePath: contractPath,
rootPath: record.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: record.origin !== "bundled",
skipLexicalRootCheck: true,
});
if (!opened.ok) {
return undefined;
}
const safePath = opened.path;
fs.closeSync(opened.fd);
try {
const mod = loadPluginContractModule(safePath);
if (mod.collectRuntimeConfigAssignments || mod.secretTargetRegistryEntries) {
return mod;
}
} catch (error) {
if (process.env.OPENCLAW_DEBUG_CHANNEL_CONTRACT_API === "1") {
const detail = error instanceof Error ? error.message : String(error);
process.stderr.write(
`[channel-contract-api] failed to load ${record.id} contract ${safePath}: ${detail}\n`,
);
}
}
return undefined;
}
function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean {
return (
record.channels.includes(channelId) ||
Object.prototype.hasOwnProperty.call(record.channelConfigs ?? {}, channelId) ||
record.channelCatalogMeta?.id === channelId ||
record.packageChannel?.id === channelId
);
}
function listChannelSecretContractRecords(params: {
channelId: string;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): PluginManifestRecord[] {
const workspaceDir = resolveAgentWorkspaceDir(
params.config,
resolveDefaultAgentId(params.config),
params.env,
);
const snapshot = loadPluginMetadataSnapshot({
config: params.config,
workspaceDir,
env: params.env,
});
return snapshot.plugins
.filter((record) => record.origin !== "bundled")
.filter((record) => recordOwnsChannel(record, params.channelId))
.filter(
(record) => !params.loadablePluginOrigins || params.loadablePluginOrigins.has(record.id),
)
.toSorted((left, right) => {
if (left.id === params.channelId && right.id !== params.channelId) {
return -1;
}
if (right.id === params.channelId && left.id !== params.channelId) {
return 1;
}
return left.id.localeCompare(right.id);
});
}
export function loadChannelSecretContractApi(params: {
channelId: string;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): BundledChannelSecretContractApi | undefined {
const bundled = loadBundledChannelSecretContractApi(params.channelId);
if (bundled) {
return bundled;
}
const env = params.env ?? process.env;
for (const record of listChannelSecretContractRecords({
channelId: params.channelId,
config: params.config,
env,
loadablePluginOrigins: params.loadablePluginOrigins,
})) {
const contract = loadExternalChannelSecretContractFromRecord(record);
if (contract) {
return contract;
}
}
return undefined;
}
export function loadChannelSecretContractApiForRecord(
record: PluginManifestRecord,
): BundledChannelSecretContractApi | undefined {
if (record.origin === "bundled") {
return loadBundledChannelSecretContractApi(record.id);
}
return loadExternalChannelSecretContractFromRecord(record);
}
export type BundledChannelSecurityContractApi = Pick<
BundledChannelContractApi,
"unsupportedSecretRefSurfacePatterns" | "collectUnsupportedSecretRefConfigCandidates"

View File

@@ -3,27 +3,27 @@ import type { OpenClawConfig } from "../config/config.js";
import type { ResolverContext } from "./runtime-shared.js";
const getBootstrapChannelSecrets = vi.fn();
const loadBundledChannelSecretContractApi = vi.fn();
const loadChannelSecretContractApi = vi.fn();
vi.mock("../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelSecrets,
}));
vi.mock("./channel-contract-api.js", () => ({
loadBundledChannelSecretContractApi,
loadChannelSecretContractApi,
}));
describe("runtime channel config collectors", () => {
beforeEach(() => {
getBootstrapChannelSecrets.mockReset();
loadBundledChannelSecretContractApi.mockReset();
loadChannelSecretContractApi.mockReset();
});
it("uses the bundled channel contract-api collector when bootstrap secrets are unavailable", async () => {
const { collectChannelConfigAssignments } =
await import("./runtime-config-collectors-channels.js");
const collectRuntimeConfigAssignments = vi.fn();
loadBundledChannelSecretContractApi.mockReturnValue({
loadChannelSecretContractApi.mockReturnValue({
collectRuntimeConfigAssignments,
});
getBootstrapChannelSecrets.mockReturnValue(undefined);
@@ -42,7 +42,12 @@ describe("runtime channel config collectors", () => {
context: {} as ResolverContext,
});
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("bluebubbles");
expect(loadChannelSecretContractApi).toHaveBeenCalledWith({
channelId: "bluebubbles",
config: expect.any(Object),
env: undefined,
loadablePluginOrigins: undefined,
});
expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce();
expect(getBootstrapChannelSecrets).not.toHaveBeenCalled();
});
@@ -51,7 +56,7 @@ describe("runtime channel config collectors", () => {
const { collectChannelConfigAssignments } =
await import("./runtime-config-collectors-channels.js");
const collectRuntimeConfigAssignments = vi.fn();
loadBundledChannelSecretContractApi.mockReturnValue(undefined);
loadChannelSecretContractApi.mockReturnValue(undefined);
getBootstrapChannelSecrets.mockReturnValue({
collectRuntimeConfigAssignments,
});
@@ -66,7 +71,12 @@ describe("runtime channel config collectors", () => {
context: {} as ResolverContext,
});
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("legacy");
expect(loadChannelSecretContractApi).toHaveBeenCalledWith({
channelId: "legacy",
config: expect.any(Object),
env: undefined,
loadablePluginOrigins: undefined,
});
expect(getBootstrapChannelSecrets).toHaveBeenCalledWith("legacy");
expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce();
});

View File

@@ -1,19 +1,26 @@
import { getBootstrapChannelSecrets } from "../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { loadChannelSecretContractApi } from "./channel-contract-api.js";
import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js";
export function collectChannelConfigAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): void {
const channelIds = Object.keys(params.config.channels ?? {});
if (channelIds.length === 0) {
return;
}
for (const channelId of channelIds) {
const contract = loadBundledChannelSecretContractApi(channelId);
const contract = loadChannelSecretContractApi({
channelId,
config: params.config,
env: params.context.env,
loadablePluginOrigins: params.loadablePluginOrigins,
});
const collectRuntimeConfigAssignments =
contract?.collectRuntimeConfigAssignments ??
getBootstrapChannelSecrets(channelId)?.collectRuntimeConfigAssignments;

View File

@@ -22,6 +22,7 @@ export function collectConfigAssignments(params: {
config: params.config,
defaults,
context: params.context,
loadablePluginOrigins: params.loadablePluginOrigins,
});
collectPluginConfigAssignments({

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
const { loadPluginMetadataSnapshotMock, loadChannelSecretContractApiMock } = vi.hoisted(() => ({
loadPluginMetadataSnapshotMock: vi.fn(),
loadChannelSecretContractApiMock: vi.fn(),
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
listPluginOriginsFromMetadataSnapshot: (snapshot: {
plugins: Array<{ id: string; origin: string }>;
}) => new Map(snapshot.plugins.map((record) => [record.id, record.origin])),
}));
vi.mock("./channel-contract-api.js", () => ({
loadChannelSecretContractApi: loadChannelSecretContractApiMock,
}));
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
describe("secrets runtime external channel origin discovery", () => {
it("discovers loadable plugins for channel SecretRefs when plugins.entries is absent", async () => {
loadPluginMetadataSnapshotMock.mockReturnValue({
plugins: [{ id: "discord", origin: "global" }],
});
loadChannelSecretContractApiMock.mockReturnValue({
collectRuntimeConfigAssignments: (params: {
config: { channels?: { discord?: { token?: unknown } } };
context: {
assignments: Array<{
ref: { source: "env"; provider: "default"; id: string };
path: string;
expected: "string";
apply: (value: unknown) => void;
}>;
};
}) => {
const token = params.config.channels?.discord?.token;
if (!token || typeof token !== "object" || Array.isArray(token)) {
return;
}
params.context.assignments.push({
ref: token as { source: "env"; provider: "default"; id: string },
path: "channels.discord.token",
expected: "string",
apply: (value) => {
if (params.config.channels?.discord) {
params.config.channels.discord.token = value;
}
},
});
},
});
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
}),
env: {
DISCORD_BOT_TOKEN: "resolved-discord-token",
},
includeAuthStoreRefs: false,
});
expect(snapshot.config.channels?.discord?.token).toBe("resolved-discord-token");
expect(loadPluginMetadataSnapshotMock).toHaveBeenCalled();
expect(loadChannelSecretContractApiMock).toHaveBeenCalledWith(
expect.objectContaining({
channelId: "discord",
loadablePluginOrigins: new Map([["discord", "global"]]),
}),
);
});
});

View File

@@ -176,6 +176,16 @@ function hasConfiguredPluginEntries(config: OpenClawConfig): boolean {
);
}
function hasConfiguredChannelEntries(config: OpenClawConfig): boolean {
const channels = config.channels;
return (
!!channels &&
typeof channels === "object" &&
!Array.isArray(channels) &&
Object.keys(channels).some((channelId) => channelId !== "defaults")
);
}
function createEmptyRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata {
return {
search: {
@@ -365,7 +375,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
} = await loadRuntimePrepareHelpers();
const loadablePluginOrigins =
params.loadablePluginOrigins ??
(hasConfiguredPluginEntries(sourceConfig)
(hasConfiguredPluginEntries(sourceConfig) || hasConfiguredChannelEntries(sourceConfig)
? await resolveLoadablePluginOrigins({ config: sourceConfig, env: runtimeEnv })
: new Map<string, PluginOrigin>());
const context = createResolverContext({

View File

@@ -1,6 +1,6 @@
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
@@ -85,20 +85,20 @@ function listBundledPluginConfigSecretTargetRegistryEntries(
}
function listChannelSecretTargetRegistryEntries(
bundledPlugins: readonly PluginManifestRecord[],
channelPlugins: readonly PluginManifestRecord[],
): SecretTargetRegistryEntry[] {
const entries: SecretTargetRegistryEntry[] = [];
for (const record of bundledPlugins) {
for (const record of channelPlugins) {
const channelIds = record.channels;
if (channelIds.length === 0) {
continue;
}
try {
const contractApi = loadBundledChannelSecretContractApi(record.id);
const contractApi = loadChannelSecretContractApiForRecord(record);
entries.push(...(contractApi?.secretTargetRegistryEntries ?? []));
} catch {
// Ignore bundled channels that do not expose a usable secret contract artifact.
// Ignore channels that do not expose a usable secret contract artifact.
}
}
return entries;
@@ -449,15 +449,17 @@ export function getSecretTargetRegistry(): SecretTargetRegistryEntry[] {
if (cachedSecretTargetRegistry) {
return cachedSecretTargetRegistry;
}
const bundledPlugins = loadPluginMetadataSnapshot({
const plugins = loadPluginMetadataSnapshot({
config: {},
env: process.env,
}).plugins.filter((record) => record.origin === "bundled");
}).plugins;
const bundledPlugins = plugins.filter((record) => record.origin === "bundled");
const channelPlugins = plugins.filter((record) => record.channels.length > 0);
cachedSecretTargetRegistry = [
...CORE_SECRET_TARGET_REGISTRY,
...listBundledWebProviderSecretTargetRegistryEntries(bundledPlugins),
...listBundledPluginConfigSecretTargetRegistryEntries(bundledPlugins),
...listChannelSecretTargetRegistryEntries(bundledPlugins),
...listChannelSecretTargetRegistryEntries(channelPlugins),
];
return cachedSecretTargetRegistry;
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
import { loadChannelSecretContractApi } from "./channel-contract-api.js";
import { getPath } from "./path-utils.js";
import { getCoreSecretTargetRegistry, getSecretTargetRegistry } from "./target-registry-data.js";
import {
@@ -32,10 +32,7 @@ let compiledCoreOpenClawTargetState: {
targetsByType: Map<string, CompiledTargetRegistryEntry[]>;
} | null = null;
const compiledBundledChannelOpenClawTargets = new Map<
string,
CompiledTargetRegistryEntry[] | null
>();
const compiledChannelOpenClawTargets = new Map<string, CompiledTargetRegistryEntry[] | null>();
function buildTargetTypeIndex(
compiledSecretTargetRegistry: CompiledTargetRegistryEntry[],
@@ -112,21 +109,25 @@ function getCompiledCoreOpenClawTargetState() {
return compiledCoreOpenClawTargetState;
}
function getCompiledBundledChannelOpenClawTargets(
function getCompiledChannelOpenClawTargets(
channelId: string,
): CompiledTargetRegistryEntry[] | null {
const normalizedChannelId = channelId.trim();
if (!normalizedChannelId) {
return null;
}
if (compiledBundledChannelOpenClawTargets.has(normalizedChannelId)) {
return compiledBundledChannelOpenClawTargets.get(normalizedChannelId) ?? null;
if (compiledChannelOpenClawTargets.has(normalizedChannelId)) {
return compiledChannelOpenClawTargets.get(normalizedChannelId) ?? null;
}
const compiledEntries =
loadBundledChannelSecretContractApi(normalizedChannelId)
loadChannelSecretContractApi({
channelId: normalizedChannelId,
config: {} as OpenClawConfig,
env: process.env,
})
?.secretTargetRegistryEntries?.filter((entry) => entry.configFile === "openclaw.json")
.map(compileTargetRegistryEntry) ?? null;
compiledBundledChannelOpenClawTargets.set(normalizedChannelId, compiledEntries);
compiledChannelOpenClawTargets.set(normalizedChannelId, compiledEntries);
return compiledEntries;
}
@@ -327,12 +328,11 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve
return resolved;
}
const explicitBundledChannelId =
pathSegments[0] === "channels" ? (pathSegments[1]?.trim() ?? "") : "";
const explicitBundledChannelEntries = explicitBundledChannelId
? getCompiledBundledChannelOpenClawTargets(explicitBundledChannelId)
const explicitChannelId = pathSegments[0] === "channels" ? (pathSegments[1]?.trim() ?? "") : "";
const explicitChannelEntries = explicitChannelId
? getCompiledChannelOpenClawTargets(explicitChannelId)
: null;
for (const entry of explicitBundledChannelEntries ?? []) {
for (const entry of explicitChannelEntries ?? []) {
if (!entry.includeInPlan) {
continue;
}