mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
fix(secretrefs): resolve external channel contracts (#76449)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
src/secrets/channel-contract-api.external.test.ts
Normal file
116
src/secrets/channel-contract-api.external.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export function collectConfigAssignments(params: {
|
||||
config: params.config,
|
||||
defaults,
|
||||
context: params.context,
|
||||
loadablePluginOrigins: params.loadablePluginOrigins,
|
||||
});
|
||||
|
||||
collectPluginConfigAssignments({
|
||||
|
||||
@@ -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"]]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user