mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
fix(secrets): align SecretRef inspect/strict behavior across preload/runtime paths (#66818)
* Config: add inspect/strict SecretRef string resolver * CLI: pass resolved/source config snapshots to plugin preload * Slack: keep HTTP route registration config-only * Providers: normalize SecretRef handling for auth and web tools * Secrets: add Exa web search target to registry and docs * Telegram: resolve env SecretRef tokens at runtime * Agents: resolve custom provider env SecretRef ids * Providers: fail closed on blocked SecretRef fallback * Telegram: enforce env SecretRef policy for runtime token refs * Status/Providers/Telegram: tighten SecretRef preload and fallback handling * Providers: enforce env SecretRef policy checks in fallback auth paths * fix: add SecretRef lifecycle changelog entry (#66818) (thanks @joshavant)
This commit is contained in:
@@ -394,6 +394,207 @@ describe("resolveUsableCustomProviderApiKey", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs from process env for custom providers", () => {
|
||||
const previous = process.env.OPENAI_API_KEY;
|
||||
process.env.OPENAI_API_KEY = "sk-secretref-env"; // pragma: allowlist secret
|
||||
try {
|
||||
const resolved = resolveUsableCustomProviderApiKey({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "custom",
|
||||
});
|
||||
expect(resolved?.apiKey).toBe("sk-secretref-env");
|
||||
expect(resolved?.source).toContain("OPENAI_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs with unknown env IDs from process env for custom providers", () => {
|
||||
const previous = process.env.MY_CUSTOM_KEY;
|
||||
process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret
|
||||
try {
|
||||
const resolved = resolveUsableCustomProviderApiKey({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MY_CUSTOM_KEY",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "custom",
|
||||
});
|
||||
expect(resolved?.apiKey).toBe("sk-custom-secretref-env");
|
||||
expect(resolved?.source).toContain("MY_CUSTOM_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MY_CUSTOM_KEY;
|
||||
} else {
|
||||
process.env.MY_CUSTOM_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider allowlist excludes the env id", () => {
|
||||
const previous = process.env.MY_CUSTOM_KEY;
|
||||
process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret
|
||||
try {
|
||||
const resolved = resolveUsableCustomProviderApiKey({
|
||||
cfg: {
|
||||
secrets: {
|
||||
providers: {
|
||||
"custom-env": {
|
||||
source: "env",
|
||||
allowlist: ["OPENAI_API_KEY"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "custom-env",
|
||||
id: "MY_CUSTOM_KEY",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "custom",
|
||||
});
|
||||
expect(resolved).toBeNull();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MY_CUSTOM_KEY;
|
||||
} else {
|
||||
process.env.MY_CUSTOM_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||
const previous = process.env.MY_CUSTOM_KEY;
|
||||
process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret
|
||||
try {
|
||||
const resolved = resolveUsableCustomProviderApiKey({
|
||||
cfg: {
|
||||
secrets: {
|
||||
providers: {
|
||||
"custom-env": {
|
||||
source: "file",
|
||||
path: "/tmp/secrets.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "custom-env",
|
||||
id: "MY_CUSTOM_KEY",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "custom",
|
||||
});
|
||||
expect(resolved).toBeNull();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MY_CUSTOM_KEY;
|
||||
} else {
|
||||
process.env.MY_CUSTOM_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat env SecretRefs with missing unknown env IDs as usable", () => {
|
||||
const previous = process.env.MY_CUSTOM_KEY;
|
||||
delete process.env.MY_CUSTOM_KEY;
|
||||
try {
|
||||
expect(
|
||||
hasUsableCustomProviderApiKey(
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MY_CUSTOM_KEY",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"custom",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MY_CUSTOM_KEY;
|
||||
} else {
|
||||
process.env.MY_CUSTOM_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat non-env SecretRefs as usable models.json credentials", () => {
|
||||
const resolved = resolveUsableCustomProviderApiKey({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "custom-provider-key",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "custom",
|
||||
});
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat known env marker names as usable when env value is missing", () => {
|
||||
const previous = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
shouldDeferProviderSyntheticProfileAuthWithPlugin,
|
||||
} from "../plugins/provider-runtime.js";
|
||||
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
|
||||
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -73,7 +74,19 @@ export function getCustomProviderApiKey(
|
||||
provider: string,
|
||||
): string | undefined {
|
||||
const entry = resolveProviderConfig(cfg, provider);
|
||||
return normalizeOptionalSecretInput(entry?.apiKey);
|
||||
const literal = normalizeOptionalSecretInput(entry?.apiKey);
|
||||
if (literal) {
|
||||
return literal;
|
||||
}
|
||||
const ref = coerceSecretRef(entry?.apiKey);
|
||||
if (!ref) {
|
||||
return undefined;
|
||||
}
|
||||
if (ref.source === "env") {
|
||||
const envId = ref.id.trim();
|
||||
return envId || NON_ENV_SECRETREF_MARKER;
|
||||
}
|
||||
return NON_ENV_SECRETREF_MARKER;
|
||||
}
|
||||
|
||||
type ResolvedCustomProviderApiKey = {
|
||||
@@ -81,11 +94,61 @@ type ResolvedCustomProviderApiKey = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
id: string;
|
||||
}): boolean {
|
||||
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
|
||||
if (!providerConfig) {
|
||||
return params.provider === resolveDefaultSecretProviderAlias(params.cfg ?? {}, "env");
|
||||
}
|
||||
if (providerConfig.source !== "env") {
|
||||
return false;
|
||||
}
|
||||
const allowlist = providerConfig.allowlist;
|
||||
return !allowlist || allowlist.includes(params.id);
|
||||
}
|
||||
|
||||
export function resolveUsableCustomProviderApiKey(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ResolvedCustomProviderApiKey | null {
|
||||
const customProviderConfig = resolveProviderConfig(params.cfg, params.provider);
|
||||
const apiKeyRef = coerceSecretRef(customProviderConfig?.apiKey);
|
||||
if (apiKeyRef) {
|
||||
if (apiKeyRef.source !== "env") {
|
||||
return null;
|
||||
}
|
||||
const envVarName = apiKeyRef.id.trim();
|
||||
if (!envVarName) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!canResolveEnvSecretRefInReadOnlyPath({
|
||||
cfg: params.cfg,
|
||||
provider: apiKeyRef.provider,
|
||||
id: envVarName,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[envVarName]);
|
||||
if (!envValue) {
|
||||
return null;
|
||||
}
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
return {
|
||||
apiKey: envValue,
|
||||
source: resolveEnvSourceLabel({
|
||||
applied,
|
||||
envVars: [envVarName],
|
||||
label: `${envVarName} (models.json secretref)`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const customKey = getCustomProviderApiKey(params.cfg, params.provider);
|
||||
if (!customKey) {
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,7 @@ const REGISTRY_IDS = [
|
||||
"models.providers.openai.apiKey",
|
||||
"messages.tts.providers.openai.apiKey",
|
||||
"plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||
"plugins.entries.exa.config.webSearch.apiKey",
|
||||
"skills.entries.demo.apiKey",
|
||||
"tools.web.search.apiKey",
|
||||
] as const;
|
||||
@@ -77,6 +78,7 @@ describe("command secret target ids", () => {
|
||||
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("plugins.entries.firecrawl.config.webFetch.apiKey")).toBe(true);
|
||||
expect(ids.has("plugins.entries.exa.config.webSearch.apiKey")).toBe(true);
|
||||
expect(ids.has("channels.discord.token")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const STATIC_AGENT_RUNTIME_BASE_TARGET_IDS = [
|
||||
"tools.web.search.apiKey",
|
||||
"plugins.entries.brave.config.webSearch.apiKey",
|
||||
"plugins.entries.google.config.webSearch.apiKey",
|
||||
"plugins.entries.exa.config.webSearch.apiKey",
|
||||
"plugins.entries.xai.config.webSearch.apiKey",
|
||||
"plugins.entries.moonshot.config.webSearch.apiKey",
|
||||
"plugins.entries.perplexity.config.webSearch.apiKey",
|
||||
|
||||
@@ -60,6 +60,23 @@ describe("plugin-registry-loader", () => {
|
||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards explicit config snapshots to plugin loading", async () => {
|
||||
const config = { channels: { telegram: { enabled: true } } } as never;
|
||||
const activationSourceConfig = { channels: { telegram: { enabled: true } } } as never;
|
||||
|
||||
await ensureCliPluginRegistryLoaded({
|
||||
scope: "configured-channels",
|
||||
config,
|
||||
activationSourceConfig,
|
||||
});
|
||||
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
|
||||
scope: "configured-channels",
|
||||
config,
|
||||
activationSourceConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps command paths to plugin registry scopes", () => {
|
||||
expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels");
|
||||
expect(resolvePluginRegistryScopeForCommandPath(["health"])).toBe("channels");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import type { PluginRegistryScope } from "./plugin-registry.js";
|
||||
|
||||
@@ -17,6 +18,8 @@ export function resolvePluginRegistryScopeForCommandPath(
|
||||
export async function ensureCliPluginRegistryLoaded(params: {
|
||||
scope: PluginRegistryScope;
|
||||
routeLogsToStderr?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
}) {
|
||||
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
|
||||
const previousForceStderr = loggingState.forceConsoleToStderr;
|
||||
@@ -24,7 +27,13 @@ export async function ensureCliPluginRegistryLoaded(params: {
|
||||
loggingState.forceConsoleToStderr = true;
|
||||
}
|
||||
try {
|
||||
ensurePluginRegistryLoaded({ scope: params.scope });
|
||||
ensurePluginRegistryLoaded({
|
||||
scope: params.scope,
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
...(params.activationSourceConfig
|
||||
? { activationSourceConfig: params.activationSourceConfig }
|
||||
: {}),
|
||||
});
|
||||
} finally {
|
||||
loggingState.forceConsoleToStderr = previousForceStderr;
|
||||
}
|
||||
|
||||
124
src/commands/agents.providers.test.ts
Normal file
124
src/commands/agents.providers.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildProviderStatusIndex } from "./agents.providers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
normalizeChannelId: vi.fn((value: unknown) =>
|
||||
typeof value === "string" && value.trim().length > 0 ? value : null,
|
||||
),
|
||||
resolveChannelDefaultAccountId: vi.fn(() => "default"),
|
||||
isChannelVisibleInConfiguredLists: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: (...args: Parameters<typeof mocks.listChannelPlugins>) =>
|
||||
mocks.listChannelPlugins(...args),
|
||||
getChannelPlugin: (...args: Parameters<typeof mocks.getChannelPlugin>) =>
|
||||
mocks.getChannelPlugin(...args),
|
||||
normalizeChannelId: (...args: Parameters<typeof mocks.normalizeChannelId>) =>
|
||||
mocks.normalizeChannelId(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/helpers.js", () => ({
|
||||
resolveChannelDefaultAccountId: (
|
||||
...args: Parameters<typeof mocks.resolveChannelDefaultAccountId>
|
||||
) => mocks.resolveChannelDefaultAccountId(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/exposure.js", () => ({
|
||||
isChannelVisibleInConfiguredLists: (
|
||||
...args: Parameters<typeof mocks.isChannelVisibleInConfiguredLists>
|
||||
) => mocks.isChannelVisibleInConfiguredLists(...args),
|
||||
}));
|
||||
|
||||
describe("buildProviderStatusIndex", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prefers inspectAccount for read-only status surfaces", async () => {
|
||||
const inspectAccount = vi.fn(() => ({ enabled: true, configured: true, name: "Work" }));
|
||||
const resolveAccount = vi.fn(() => {
|
||||
throw new Error("should not be used when inspectAccount exists");
|
||||
});
|
||||
const plugin = {
|
||||
id: "slack",
|
||||
meta: { label: "Slack" },
|
||||
config: {
|
||||
listAccountIds: () => ["work"],
|
||||
inspectAccount,
|
||||
resolveAccount,
|
||||
describeAccount: () => ({ configured: true, enabled: true, linked: true, name: "Work" }),
|
||||
},
|
||||
status: {},
|
||||
} as never;
|
||||
|
||||
mocks.listChannelPlugins.mockReturnValue([plugin]);
|
||||
mocks.getChannelPlugin.mockReturnValue(plugin);
|
||||
|
||||
const map = await buildProviderStatusIndex({} as OpenClawConfig);
|
||||
|
||||
expect(resolveAccount).not.toHaveBeenCalled();
|
||||
expect(inspectAccount).toHaveBeenCalledWith({}, "work");
|
||||
expect(map.get("slack:work")).toMatchObject({
|
||||
provider: "slack",
|
||||
accountId: "work",
|
||||
state: "linked",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
name: "Work",
|
||||
});
|
||||
});
|
||||
|
||||
it("records accounts that throw during read-only resolution as not configured", async () => {
|
||||
const plugin = {
|
||||
id: "telegram",
|
||||
meta: { label: "Telegram" },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("unresolved SecretRef");
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
} as never;
|
||||
|
||||
mocks.listChannelPlugins.mockReturnValue([plugin]);
|
||||
mocks.getChannelPlugin.mockReturnValue(plugin);
|
||||
|
||||
await expect(buildProviderStatusIndex({} as OpenClawConfig)).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
"telegram:default",
|
||||
{
|
||||
provider: "telegram",
|
||||
accountId: "default",
|
||||
state: "not configured",
|
||||
configured: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rethrows unexpected read-only account resolution errors", async () => {
|
||||
const plugin = {
|
||||
id: "telegram",
|
||||
meta: { label: "Telegram" },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => {
|
||||
throw new Error("plugin crash");
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
} as never;
|
||||
|
||||
mocks.listChannelPlugins.mockReturnValue([plugin]);
|
||||
mocks.getChannelPlugin.mockReturnValue(plugin);
|
||||
|
||||
await expect(buildProviderStatusIndex({} as OpenClawConfig)).rejects.toThrow("plugin crash");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,14 @@ function providerAccountKey(provider: ChannelId, accountId?: string) {
|
||||
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
|
||||
}
|
||||
|
||||
function isUnresolvedSecretRefResolutionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
typeof error.message === "string" &&
|
||||
/unresolved SecretRef/i.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
function formatChannelAccountLabel(params: {
|
||||
provider: ChannelId;
|
||||
accountId: string;
|
||||
@@ -43,6 +51,17 @@ function formatProviderState(entry: ProviderAccountStatus): string {
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async function resolveReadOnlyAccount(params: {
|
||||
plugin: ReturnType<typeof listChannelPlugins>[number];
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): Promise<unknown> {
|
||||
if (params.plugin.config.inspectAccount) {
|
||||
return await Promise.resolve(params.plugin.config.inspectAccount(params.cfg, params.accountId));
|
||||
}
|
||||
return params.plugin.config.resolveAccount(params.cfg, params.accountId);
|
||||
}
|
||||
|
||||
export async function buildProviderStatusIndex(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<Map<string, ProviderAccountStatus>> {
|
||||
@@ -51,7 +70,24 @@ export async function buildProviderStatusIndex(
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
for (const accountId of accountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
let account: unknown;
|
||||
try {
|
||||
account = await resolveReadOnlyAccount({ plugin, cfg, accountId });
|
||||
} catch (error) {
|
||||
if (!isUnresolvedSecretRefResolutionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
map.set(providerAccountKey(plugin.id, accountId), {
|
||||
provider: plugin.id,
|
||||
accountId,
|
||||
state: "not configured",
|
||||
configured: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const snapshot = plugin.config.describeAccount?.(account, cfg);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
|
||||
@@ -57,6 +57,51 @@ describe("scanStatusJsonFast", () => {
|
||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
||||
});
|
||||
|
||||
it("preloads configured channel plugins from the resolved snapshot while preserving source activation config", async () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
applyStatusScanDefaults(mocks, {
|
||||
hasConfiguredChannels: true,
|
||||
sourceConfig: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "/telegram/bot-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
resolvedConfig: {
|
||||
marker: "resolved-snapshot",
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
await scanStatusJsonFast({}, {} as never);
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-snapshot" }),
|
||||
activationSourceConfig: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
telegram: expect.objectContaining({
|
||||
botToken: expect.objectContaining({
|
||||
source: "file",
|
||||
id: "/telegram/bot-token",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips plugin compatibility loading even when configured channels are present", async () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function scanStatusJsonWithPolicy(
|
||||
await ensureCliPluginRegistryLoaded({
|
||||
scope: "configured-channels",
|
||||
routeLogsToStderr: true,
|
||||
config: overview.cfg,
|
||||
activationSourceConfig: overview.sourceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -186,10 +186,12 @@ describe("scanStatus", () => {
|
||||
configureScanStatus({
|
||||
hasConfiguredChannels: true,
|
||||
sourceConfig: createStatusScanConfig({
|
||||
marker: "source-preload",
|
||||
plugins: { enabled: false },
|
||||
channels: { telegram: { enabled: false } },
|
||||
}),
|
||||
resolvedConfig: createStatusScanConfig({
|
||||
marker: "resolved-preload",
|
||||
plugins: { enabled: false },
|
||||
channels: { telegram: { enabled: false } },
|
||||
}),
|
||||
@@ -198,9 +200,13 @@ describe("scanStatus", () => {
|
||||
|
||||
await scanStatus({ json: true }, {} as never);
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
|
||||
scope: "configured-channels",
|
||||
});
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-preload" }),
|
||||
activationSourceConfig: expect.objectContaining({ marker: "source-preload" }),
|
||||
}),
|
||||
);
|
||||
// Verify plugin logs were routed to stderr during loading and restored after
|
||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
||||
expect(mocks.probeGateway).toHaveBeenCalledWith(
|
||||
@@ -215,9 +221,11 @@ describe("scanStatus", () => {
|
||||
configureScanStatus({
|
||||
hasConfiguredChannels: true,
|
||||
sourceConfig: createStatusScanConfig({
|
||||
marker: "source-env-only",
|
||||
plugins: { enabled: false },
|
||||
}),
|
||||
resolvedConfig: createStatusScanConfig({
|
||||
marker: "resolved-env-only",
|
||||
plugins: { enabled: false },
|
||||
}),
|
||||
summary: createStatusSummary({ linkChannel: { linked: false } }),
|
||||
@@ -227,8 +235,12 @@ describe("scanStatus", () => {
|
||||
await scanStatus({ json: true }, {} as never);
|
||||
});
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
|
||||
scope: "configured-channels",
|
||||
});
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "configured-channels",
|
||||
config: expect.objectContaining({ marker: "resolved-env-only" }),
|
||||
activationSourceConfig: expect.objectContaining({ marker: "source-env-only" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
80
src/config/types.secrets.resolution.test.ts
Normal file
80
src/config/types.secrets.resolution.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeResolvedSecretInputString, resolveSecretInputString } from "./types.secrets.js";
|
||||
|
||||
describe("resolveSecretInputString", () => {
|
||||
it("returns available for non-empty string values", () => {
|
||||
expect(
|
||||
resolveSecretInputString({
|
||||
value: " abc123 ",
|
||||
path: "models.providers.openai.apiKey",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "available",
|
||||
value: "abc123",
|
||||
ref: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns configured_unavailable for unresolved refs in inspect mode", () => {
|
||||
expect(
|
||||
resolveSecretInputString({
|
||||
value: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
path: "models.providers.openai.apiKey",
|
||||
mode: "inspect",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "configured_unavailable",
|
||||
value: undefined,
|
||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit refValue in inspect mode", () => {
|
||||
expect(
|
||||
resolveSecretInputString({
|
||||
value: "",
|
||||
refValue: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
path: "profiles.default.key",
|
||||
mode: "inspect",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "configured_unavailable",
|
||||
value: undefined,
|
||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing when no value or ref is configured", () => {
|
||||
expect(
|
||||
resolveSecretInputString({
|
||||
value: "",
|
||||
path: "models.providers.openai.apiKey",
|
||||
mode: "inspect",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "missing",
|
||||
value: undefined,
|
||||
ref: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for unresolved refs in strict mode", () => {
|
||||
expect(() =>
|
||||
resolveSecretInputString({
|
||||
value: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
path: "models.providers.openai.apiKey",
|
||||
}),
|
||||
).toThrow(/unresolved SecretRef/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeResolvedSecretInputString", () => {
|
||||
it("keeps strict unresolved-ref behavior", () => {
|
||||
expect(() =>
|
||||
normalizeResolvedSecretInputString({
|
||||
value: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
path: "models.providers.openai.apiKey",
|
||||
}),
|
||||
).toThrow(/unresolved SecretRef/);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,11 @@ export type SecretInput = string | SecretRef;
|
||||
export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret
|
||||
export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
|
||||
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
|
||||
export type SecretInputStringResolutionMode = "strict" | "inspect";
|
||||
export type SecretInputStringResolution =
|
||||
| { status: "available"; value: string; ref: null }
|
||||
| { status: "configured_unavailable"; value: undefined; ref: SecretRef }
|
||||
| { status: "missing"; value: undefined; ref: null };
|
||||
type SecretDefaults = {
|
||||
env?: string;
|
||||
file?: string;
|
||||
@@ -120,6 +125,12 @@ function formatSecretRefLabel(ref: SecretRef): string {
|
||||
return `${ref.source}:${ref.provider}:${ref.id}`;
|
||||
}
|
||||
|
||||
function createUnresolvedSecretInputError(params: { path: string; ref: SecretRef }): Error {
|
||||
return new Error(
|
||||
`${params.path}: unresolved SecretRef "${formatSecretRefLabel(params.ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertSecretInputResolved(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
@@ -134,9 +145,44 @@ export function assertSecretInputResolved(params: {
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${params.path}: unresolved SecretRef "${formatSecretRefLabel(ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
throw createUnresolvedSecretInputError({ path: params.path, ref });
|
||||
}
|
||||
|
||||
export function resolveSecretInputString(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
defaults?: SecretDefaults;
|
||||
path: string;
|
||||
mode?: SecretInputStringResolutionMode;
|
||||
}): SecretInputStringResolution {
|
||||
const normalized = normalizeSecretInputString(params.value);
|
||||
if (normalized) {
|
||||
return {
|
||||
status: "available",
|
||||
value: normalized,
|
||||
ref: null,
|
||||
};
|
||||
}
|
||||
const { ref } = resolveSecretInputRef({
|
||||
value: params.value,
|
||||
refValue: params.refValue,
|
||||
defaults: params.defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return {
|
||||
status: "missing",
|
||||
value: undefined,
|
||||
ref: null,
|
||||
};
|
||||
}
|
||||
if ((params.mode ?? "strict") === "strict") {
|
||||
throw createUnresolvedSecretInputError({ path: params.path, ref });
|
||||
}
|
||||
return {
|
||||
status: "configured_unavailable",
|
||||
value: undefined,
|
||||
ref,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResolvedSecretInputString(params: {
|
||||
@@ -145,11 +191,13 @@ export function normalizeResolvedSecretInputString(params: {
|
||||
defaults?: SecretDefaults;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
const normalized = normalizeSecretInputString(params.value);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
const resolved = resolveSecretInputString({
|
||||
...params,
|
||||
mode: "strict",
|
||||
});
|
||||
if (resolved.status === "available") {
|
||||
return resolved.value;
|
||||
}
|
||||
assertSecretInputResolved(params);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ import { z } from "zod";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
isSecretRef,
|
||||
resolveSecretInputString,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import { buildSecretInputSchema } from "./secret-input-schema.js";
|
||||
|
||||
export type { SecretInput } from "../config/types.secrets.js";
|
||||
export type {
|
||||
SecretInput,
|
||||
SecretInputStringResolution,
|
||||
SecretInputStringResolutionMode,
|
||||
} from "../config/types.secrets.js";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
isSecretRef,
|
||||
resolveSecretInputString,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
normalizeSecretInputString,
|
||||
|
||||
@@ -369,6 +369,17 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "plugins.entries.exa.config.webSearch.apiKey",
|
||||
targetType: "plugins.entries.exa.config.webSearch.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "plugins.entries.exa.config.webSearch.apiKey",
|
||||
secretShape: SECRET_INPUT_SHAPE,
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
targetType: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
|
||||
@@ -42,4 +42,18 @@ describe("secret target registry", () => {
|
||||
|
||||
expect(target).toBeNull();
|
||||
});
|
||||
|
||||
it("includes exa webSearch api key target path", () => {
|
||||
const target = resolveConfigSecretTargetByPath([
|
||||
"plugins",
|
||||
"entries",
|
||||
"exa",
|
||||
"config",
|
||||
"webSearch",
|
||||
"apiKey",
|
||||
]);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.entry?.id).toBe("plugins.entries.exa.config.webSearch.apiKey");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user