mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:20: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:
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid `max_tokens` values no longer reach the provider API. (#66664) thanks @jalehman
|
- Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid `max_tokens` values no longer reach the provider API. (#66664) thanks @jalehman
|
||||||
- Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.
|
- Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.
|
||||||
- BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.
|
- BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.
|
||||||
|
- Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant.
|
||||||
|
|
||||||
## 2026.4.14
|
## 2026.4.14
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
cd06d41c9302b068d2d998e478a4cca5e0bdd0b165e381cc68740698a5921d21 plugin-sdk-api-baseline.json
|
effb6ee16d16bc1b1e76ec293868910f887a168d9b756449928c703fe4c9e81a plugin-sdk-api-baseline.json
|
||||||
8131372bd1fb433d24de85c94e3fe58368579abed10ec80f39370c6f6fee6373 plugin-sdk-api-baseline.jsonl
|
16eb8ac91b10b3ee62d856bf16c25c1ba3ba9fa7303500af2947a6e532b0c222 plugin-sdk-api-baseline.jsonl
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Scope intent:
|
|||||||
- `messages.tts.providers.*.apiKey`
|
- `messages.tts.providers.*.apiKey`
|
||||||
- `tools.web.fetch.firecrawl.apiKey`
|
- `tools.web.fetch.firecrawl.apiKey`
|
||||||
- `plugins.entries.brave.config.webSearch.apiKey`
|
- `plugins.entries.brave.config.webSearch.apiKey`
|
||||||
|
- `plugins.entries.exa.config.webSearch.apiKey`
|
||||||
- `plugins.entries.google.config.webSearch.apiKey`
|
- `plugins.entries.google.config.webSearch.apiKey`
|
||||||
- `plugins.entries.xai.config.webSearch.apiKey`
|
- `plugins.entries.xai.config.webSearch.apiKey`
|
||||||
- `plugins.entries.moonshot.config.webSearch.apiKey`
|
- `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||||
|
|||||||
@@ -526,6 +526,13 @@
|
|||||||
"secretShape": "secret_input",
|
"secretShape": "secret_input",
|
||||||
"optIn": true
|
"optIn": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "plugins.entries.exa.config.webSearch.apiKey",
|
||||||
|
"configFile": "openclaw.json",
|
||||||
|
"path": "plugins.entries.exa.config.webSearch.apiKey",
|
||||||
|
"secretShape": "secret_input",
|
||||||
|
"optIn": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
|
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||||
"configFile": "openclaw.json",
|
"configFile": "openclaw.json",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||||
normalizeResolvedSecretInputString,
|
import { resolveSecretInputString, normalizeSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||||
normalizeSecretInput,
|
|
||||||
} from "openclaw/plugin-sdk/secret-input";
|
|
||||||
|
|
||||||
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||||
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
|
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
|
||||||
export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60;
|
export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60;
|
||||||
export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||||
|
const FIRECRAWL_API_KEY_ENV_VAR = "FIRECRAWL_API_KEY";
|
||||||
|
|
||||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||||
? Web extends { search?: infer Search }
|
? Web extends { search?: infer Search }
|
||||||
@@ -104,33 +103,101 @@ export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetc
|
|||||||
return firecrawl as FirecrawlFetchConfig;
|
return firecrawl as FirecrawlFetchConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
|
type ConfiguredSecretResolution =
|
||||||
return normalizeSecretInput(
|
| { status: "available"; value: string }
|
||||||
normalizeResolvedSecretInputString({
|
| { status: "missing" }
|
||||||
value,
|
| { status: "blocked" };
|
||||||
path,
|
|
||||||
}),
|
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||||
);
|
cfg?: OpenClawConfig;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredSecret(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
cfg?: OpenClawConfig,
|
||||||
|
): ConfiguredSecretResolution {
|
||||||
|
const resolved = resolveSecretInputString({
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
defaults: cfg?.secrets?.defaults,
|
||||||
|
mode: "inspect",
|
||||||
|
});
|
||||||
|
if (resolved.status === "available") {
|
||||||
|
const normalized = normalizeSecretInput(resolved.value);
|
||||||
|
return normalized ? { status: "available", value: normalized } : { status: "missing" };
|
||||||
|
}
|
||||||
|
if (resolved.status === "missing") {
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
if (resolved.ref.source !== "env") {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
const envVarName = resolved.ref.id.trim();
|
||||||
|
if (envVarName !== FIRECRAWL_API_KEY_ENV_VAR) {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!canResolveEnvSecretRefInReadOnlyPath({
|
||||||
|
cfg,
|
||||||
|
provider: resolved.ref.provider,
|
||||||
|
id: envVarName,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
const envValue = normalizeSecretInput(process.env[envVarName]);
|
||||||
|
return envValue ? { status: "available", value: envValue } : { status: "missing" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined {
|
export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||||
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
|
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
|
||||||
const search = resolveFirecrawlSearchConfig(cfg);
|
const search = resolveFirecrawlSearchConfig(cfg);
|
||||||
const fetch = resolveFirecrawlFetchConfig(cfg);
|
const fetch = resolveFirecrawlFetchConfig(cfg);
|
||||||
return (
|
const configuredCandidates: Array<{ value: unknown; path: string }> = [
|
||||||
normalizeConfiguredSecret(
|
{
|
||||||
pluginConfig?.webFetch?.apiKey,
|
value: pluginConfig?.webFetch?.apiKey,
|
||||||
"plugins.entries.firecrawl.config.webFetch.apiKey",
|
path: "plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||||
) ||
|
},
|
||||||
normalizeConfiguredSecret(
|
{
|
||||||
search?.apiKey,
|
value: search?.apiKey,
|
||||||
"plugins.entries.firecrawl.config.webSearch.apiKey",
|
path: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||||
) ||
|
},
|
||||||
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
|
{
|
||||||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
|
value: search?.apiKey,
|
||||||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
|
path: "tools.web.search.firecrawl.apiKey",
|
||||||
undefined
|
},
|
||||||
);
|
{
|
||||||
|
value: fetch?.apiKey,
|
||||||
|
path: "tools.web.fetch.firecrawl.apiKey",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let blockedConfiguredSecret = false;
|
||||||
|
for (const candidate of configuredCandidates) {
|
||||||
|
const resolved = resolveConfiguredSecret(candidate.value, candidate.path, cfg);
|
||||||
|
if (resolved.status === "available") {
|
||||||
|
return resolved.value;
|
||||||
|
}
|
||||||
|
if (resolved.status === "blocked") {
|
||||||
|
blockedConfiguredSecret = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blockedConfiguredSecret) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return normalizeSecretInput(process.env[FIRECRAWL_API_KEY_ENV_VAR]) || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string {
|
export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string {
|
||||||
|
|||||||
@@ -474,6 +474,137 @@ describe("firecrawl tools", () => {
|
|||||||
expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL);
|
expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves env SecretRefs for Firecrawl API key without requiring a runtime snapshot", () => {
|
||||||
|
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||||
|
const cfg = {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
firecrawl: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "FIRECRAWL_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveFirecrawlApiKey(cfg)).toBe("firecrawl-env-ref-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
|
||||||
|
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-fallback");
|
||||||
|
const cfg = {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
firecrawl: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "file",
|
||||||
|
provider: "vault",
|
||||||
|
id: "/firecrawl/api-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read arbitrary env SecretRef ids for Firecrawl API key resolution", () => {
|
||||||
|
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
|
||||||
|
const cfg = {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
firecrawl: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "UNRELATED_SECRET",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve env SecretRefs when provider allowlist excludes FIRECRAWL_API_KEY", () => {
|
||||||
|
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||||
|
const cfg = {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"firecrawl-env": {
|
||||||
|
source: "env",
|
||||||
|
allowlist: ["OTHER_FIRECRAWL_API_KEY"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
firecrawl: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "firecrawl-env",
|
||||||
|
id: "FIRECRAWL_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||||
|
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||||
|
const cfg = {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"firecrawl-env": {
|
||||||
|
source: "file",
|
||||||
|
path: "/tmp/secrets.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
firecrawl: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "firecrawl-env",
|
||||||
|
id: "FIRECRAWL_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("only allows the official Firecrawl API host for fetch endpoints", () => {
|
it("only allows the official Firecrawl API host for fetch endpoints", () => {
|
||||||
expect(firecrawlClientTesting.resolveEndpoint("https://api.firecrawl.dev", "/v2/scrape")).toBe(
|
expect(firecrawlClientTesting.resolveEndpoint("https://api.firecrawl.dev", "/v2/scrape")).toBe(
|
||||||
"https://api.firecrawl.dev/v2/scrape",
|
"https://api.firecrawl.dev/v2/scrape",
|
||||||
|
|||||||
62
extensions/slack/src/http/plugin-routes.test.ts
Normal file
62
extensions/slack/src/http/plugin-routes.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestPluginApi } from "../../../../test/helpers/plugins/plugin-api.js";
|
||||||
|
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
|
||||||
|
import { registerSlackPluginHttpRoutes } from "./plugin-routes.js";
|
||||||
|
|
||||||
|
function createApi(config: OpenClawConfig, registerHttpRoute = vi.fn()): OpenClawPluginApi {
|
||||||
|
return createTestPluginApi({
|
||||||
|
id: "slack",
|
||||||
|
config,
|
||||||
|
registerHttpRoute,
|
||||||
|
}) as OpenClawPluginApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("registerSlackPluginHttpRoutes", () => {
|
||||||
|
it("registers account webhook paths without resolving unresolved token refs", () => {
|
||||||
|
const registerHttpRoute = vi.fn();
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
webhookPath: "/hooks/default",
|
||||||
|
botToken: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "SLACK_BOT_TOKEN",
|
||||||
|
} as unknown as string,
|
||||||
|
},
|
||||||
|
ops: {
|
||||||
|
webhookPath: "hooks/ops",
|
||||||
|
botToken: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "SLACK_OPS_BOT_TOKEN",
|
||||||
|
} as unknown as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const api = createApi(cfg, registerHttpRoute);
|
||||||
|
|
||||||
|
expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow();
|
||||||
|
|
||||||
|
const paths = registerHttpRoute.mock.calls
|
||||||
|
.map((call) => (call[0] as { path: string }).path)
|
||||||
|
.toSorted();
|
||||||
|
expect(paths).toEqual(["/hooks/default", "/hooks/ops"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default slack webhook path", () => {
|
||||||
|
const registerHttpRoute = vi.fn();
|
||||||
|
const api = createApi({}, registerHttpRoute);
|
||||||
|
|
||||||
|
registerSlackPluginHttpRoutes(api);
|
||||||
|
|
||||||
|
const paths = registerHttpRoute.mock.calls
|
||||||
|
.map((call) => (call[0] as { path: string }).path)
|
||||||
|
.toSorted();
|
||||||
|
expect(paths).toEqual(["/slack/events"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||||
import { listSlackAccountIds, resolveSlackAccount } from "../accounts.js";
|
import { listSlackAccountIds, mergeSlackAccountConfig } from "../accounts.js";
|
||||||
import { normalizeSlackWebhookPath } from "./paths.js";
|
import { normalizeSlackWebhookPath } from "./paths.js";
|
||||||
|
|
||||||
let slackHttpHandlerRuntimePromise: Promise<typeof import("./handler.runtime.js")> | null = null;
|
let slackHttpHandlerRuntimePromise: Promise<typeof import("./handler.runtime.js")> | null = null;
|
||||||
@@ -14,8 +14,9 @@ export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
|
|||||||
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
|
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
|
||||||
const registeredPaths = new Set<string>();
|
const registeredPaths = new Set<string>();
|
||||||
for (const accountId of accountIds) {
|
for (const accountId of accountIds) {
|
||||||
const account = resolveSlackAccount({ cfg: api.config, accountId });
|
// Route registration must remain config-only and should not resolve tokens.
|
||||||
registeredPaths.add(normalizeSlackWebhookPath(account.config.webhookPath));
|
const accountConfig = mergeSlackAccountConfig(api.config, accountId);
|
||||||
|
registeredPaths.add(normalizeSlackWebhookPath(accountConfig.webhookPath));
|
||||||
}
|
}
|
||||||
if (registeredPaths.size === 0) {
|
if (registeredPaths.size === 0) {
|
||||||
registeredPaths.add(normalizeSlackWebhookPath());
|
registeredPaths.add(normalizeSlackWebhookPath());
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
expectNoTokenForUnknownAccount(createUnknownAccountConfig());
|
expectNoTokenForUnknownAccount(createUnknownAccountConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when botToken is an unresolved SecretRef object", () => {
|
it("resolves env-backed SecretRefs from process.env", () => {
|
||||||
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "secretref-env-token");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
telegram: {
|
telegram: {
|
||||||
@@ -238,6 +239,148 @@ describe("resolveTelegramToken", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as OpenClawConfig;
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveTelegramToken(cfg)).toEqual({
|
||||||
|
token: "secretref-env-token",
|
||||||
|
source: "config",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to TELEGRAM_BOT_TOKEN when an explicit env SecretRef is configured but unavailable", () => {
|
||||||
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "fallback-env-token");
|
||||||
|
vi.stubEnv("TELEGRAM_REF_TOKEN", "");
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: { source: "env", provider: "default", id: "TELEGRAM_REF_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveTelegramToken(cfg)).toEqual({
|
||||||
|
token: "",
|
||||||
|
source: "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall through when account-level env SecretRef is configured but unavailable", () => {
|
||||||
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "fallback-env-token");
|
||||||
|
vi.stubEnv("TELEGRAM_ACCOUNT_REF_TOKEN", "");
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: "channel-token",
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
botToken: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "TELEGRAM_ACCOUNT_REF_TOKEN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveTelegramToken(cfg)).toEqual({
|
||||||
|
token: "",
|
||||||
|
source: "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bypass env provider allowlists for env-backed SecretRefs", () => {
|
||||||
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "secretref-env-token");
|
||||||
|
const cfg = {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"telegram-env": {
|
||||||
|
source: "env",
|
||||||
|
allowlist: ["OTHER_TELEGRAM_BOT_TOKEN"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: { source: "env", provider: "telegram-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||||
|
/not allowlisted in secrets\.providers\.telegram-env\.allowlist/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when an env SecretRef points at a provider configured with another source", () => {
|
||||||
|
const cfg = {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"telegram-env": {
|
||||||
|
source: "file",
|
||||||
|
path: "/tmp/secrets.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: { source: "env", provider: "telegram-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||||
|
/Secret provider "telegram-env" has source "file" but ref requests "env"/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when an env SecretRef provider is not configured and not the default env alias", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: { source: "env", provider: "ops-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||||
|
/Secret provider "ops-env" is not configured \(ref: env:ops-env:TELEGRAM_BOT_TOKEN\)/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts env SecretRefs that use the configured default env provider alias", () => {
|
||||||
|
vi.stubEnv("TELEGRAM_RUNTIME_TOKEN", "secretref-env-token");
|
||||||
|
const cfg = {
|
||||||
|
secrets: {
|
||||||
|
defaults: {
|
||||||
|
env: "telegram-runtime",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: {
|
||||||
|
source: "env",
|
||||||
|
provider: "telegram-runtime",
|
||||||
|
id: "TELEGRAM_RUNTIME_TOKEN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
expect(resolveTelegramToken(cfg)).toEqual({
|
||||||
|
token: "secretref-env-token",
|
||||||
|
source: "config",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps strict runtime behavior for unresolved non-env SecretRefs", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken: { source: "file", provider: "vault", id: "/telegram/bot-token" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
expect(() => resolveTelegramToken(cfg)).toThrow(
|
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||||
/channels\.telegram\.botToken: unresolved SecretRef/i,
|
/channels\.telegram\.botToken: unresolved SecretRef/i,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
|
|||||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
|
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
import {
|
||||||
|
normalizeSecretInputString,
|
||||||
|
resolveSecretInputString,
|
||||||
|
} from "openclaw/plugin-sdk/secret-input";
|
||||||
|
|
||||||
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
||||||
|
|
||||||
@@ -12,6 +16,83 @@ export type TelegramTokenResolution = BaseTokenResolution & {
|
|||||||
source: TelegramTokenSource;
|
source: TelegramTokenSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuntimeTokenValueResolution =
|
||||||
|
| { status: "available"; value: string }
|
||||||
|
| { status: "configured_unavailable" }
|
||||||
|
| { status: "missing" };
|
||||||
|
|
||||||
|
function resolveEnvSecretRefValue(params: {
|
||||||
|
cfg?: Pick<OpenClawConfig, "secrets">;
|
||||||
|
provider: string;
|
||||||
|
id: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): string | undefined {
|
||||||
|
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
|
||||||
|
if (providerConfig) {
|
||||||
|
if (providerConfig.source !== "env") {
|
||||||
|
throw new Error(
|
||||||
|
`Secret provider "${params.provider}" has source "${providerConfig.source}" but ref requests "env".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerConfig.allowlist && !providerConfig.allowlist.includes(params.id)) {
|
||||||
|
throw new Error(
|
||||||
|
`Environment variable "${params.id}" is not allowlisted in secrets.providers.${params.provider}.allowlist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
params.provider !== resolveDefaultSecretProviderAlias({ secrets: params.cfg?.secrets }, "env")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Secret provider "${params.provider}" is not configured (ref: env:${params.provider}:${params.id}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalizeSecretInputString((params.env ?? process.env)[params.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeTokenValue(params: {
|
||||||
|
cfg?: Pick<OpenClawConfig, "secrets">;
|
||||||
|
value: unknown;
|
||||||
|
path: string;
|
||||||
|
}): RuntimeTokenValueResolution {
|
||||||
|
const resolved = resolveSecretInputString({
|
||||||
|
value: params.value,
|
||||||
|
path: params.path,
|
||||||
|
defaults: params.cfg?.secrets?.defaults,
|
||||||
|
mode: "inspect",
|
||||||
|
});
|
||||||
|
if (resolved.status === "available") {
|
||||||
|
return {
|
||||||
|
status: "available",
|
||||||
|
value: resolved.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (resolved.status === "missing") {
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
if (resolved.ref.source === "env") {
|
||||||
|
const envValue = resolveEnvSecretRefValue({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: resolved.ref.provider,
|
||||||
|
id: resolved.ref.id,
|
||||||
|
});
|
||||||
|
if (envValue) {
|
||||||
|
return {
|
||||||
|
status: "available",
|
||||||
|
value: envValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: "configured_unavailable" };
|
||||||
|
}
|
||||||
|
// Runtime resolution stays strict for non-env SecretRefs.
|
||||||
|
resolveSecretInputString({
|
||||||
|
value: params.value,
|
||||||
|
path: params.path,
|
||||||
|
defaults: params.cfg?.secrets?.defaults,
|
||||||
|
mode: "strict",
|
||||||
|
});
|
||||||
|
return { status: "configured_unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
type ResolveTelegramTokenOpts = {
|
type ResolveTelegramTokenOpts = {
|
||||||
envToken?: string | null;
|
envToken?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
@@ -79,12 +160,16 @@ export function resolveTelegramToken(
|
|||||||
return { token: "", source: "none" };
|
return { token: "", source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountToken = normalizeResolvedSecretInputString({
|
const accountToken = resolveRuntimeTokenValue({
|
||||||
|
cfg,
|
||||||
value: accountCfg?.botToken,
|
value: accountCfg?.botToken,
|
||||||
path: `channels.telegram.accounts.${accountId}.botToken`,
|
path: `channels.telegram.accounts.${accountId}.botToken`,
|
||||||
});
|
});
|
||||||
if (accountToken) {
|
if (accountToken.status === "available") {
|
||||||
return { token: accountToken, source: "config" };
|
return { token: accountToken.value, source: "config" };
|
||||||
|
}
|
||||||
|
if (accountToken.status === "configured_unavailable") {
|
||||||
|
return { token: "", source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
@@ -100,12 +185,16 @@ export function resolveTelegramToken(
|
|||||||
return { token: "", source: "none" };
|
return { token: "", source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const configToken = normalizeResolvedSecretInputString({
|
const configToken = resolveRuntimeTokenValue({
|
||||||
|
cfg,
|
||||||
value: telegramCfg?.botToken,
|
value: telegramCfg?.botToken,
|
||||||
path: "channels.telegram.botToken",
|
path: "channels.telegram.botToken",
|
||||||
});
|
});
|
||||||
if (configToken) {
|
if (configToken.status === "available") {
|
||||||
return { token: configToken, source: "config" };
|
return { token: configToken.value, source: "config" };
|
||||||
|
}
|
||||||
|
if (configToken.status === "configured_unavailable") {
|
||||||
|
return { token: "", source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const envToken = allowEnv ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
|
const envToken = allowEnv ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
|
||||||
|
|||||||
@@ -137,4 +137,150 @@ describe("xai tool auth helpers", () => {
|
|||||||
expect(isXaiToolEnabled({ enabled: false })).toBe(false);
|
expect(isXaiToolEnabled({ enabled: false })).toBe(false);
|
||||||
expect(isXaiToolEnabled({ enabled: true })).toBe(true);
|
expect(isXaiToolEnabled({ enabled: true })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
|
||||||
|
vi.stubEnv("XAI_API_KEY", "env-key");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveXaiToolApiKey({
|
||||||
|
sourceConfig: {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
xai: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "file",
|
||||||
|
provider: "vault",
|
||||||
|
id: "/xai/tool-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves env SecretRefs from source config when runtime snapshot is unavailable", () => {
|
||||||
|
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveXaiToolApiKey({
|
||||||
|
sourceConfig: {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
xai: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "XAI_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("xai-secretref-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read arbitrary env SecretRef ids for xAI tool auth", () => {
|
||||||
|
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveXaiToolApiKey({
|
||||||
|
sourceConfig: {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
xai: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "UNRELATED_SECRET",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve env SecretRefs when provider allowlist excludes XAI_API_KEY", () => {
|
||||||
|
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveXaiToolApiKey({
|
||||||
|
sourceConfig: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"xai-env": {
|
||||||
|
source: "env",
|
||||||
|
allowlist: ["OTHER_XAI_API_KEY"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
xai: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "xai-env",
|
||||||
|
id: "XAI_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||||
|
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveXaiToolApiKey({
|
||||||
|
sourceConfig: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
"xai-env": {
|
||||||
|
source: "file",
|
||||||
|
path: "/tmp/secrets.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
xai: {
|
||||||
|
config: {
|
||||||
|
webSearch: {
|
||||||
|
apiKey: {
|
||||||
|
source: "env",
|
||||||
|
provider: "xai-env",
|
||||||
|
id: "XAI_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import {
|
||||||
coerceSecretRef,
|
coerceSecretRef,
|
||||||
|
resolveDefaultSecretProviderAlias,
|
||||||
resolveNonEnvSecretRefApiKeyMarker,
|
resolveNonEnvSecretRefApiKeyMarker,
|
||||||
} from "openclaw/plugin-sdk/provider-auth";
|
} from "openclaw/plugin-sdk/provider-auth";
|
||||||
import {
|
import {
|
||||||
readProviderEnvValue,
|
readProviderEnvValue,
|
||||||
readConfiguredSecretString,
|
|
||||||
resolveProviderWebSearchPluginConfig,
|
resolveProviderWebSearchPluginConfig,
|
||||||
} from "openclaw/plugin-sdk/provider-web-search";
|
} from "openclaw/plugin-sdk/provider-web-search";
|
||||||
import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
import {
|
||||||
|
normalizeSecretInputString,
|
||||||
|
resolveSecretInputString,
|
||||||
|
} from "openclaw/plugin-sdk/secret-input";
|
||||||
|
|
||||||
export type XaiFallbackAuth = {
|
export type XaiFallbackAuth = {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
const XAI_API_KEY_ENV_VAR = "XAI_API_KEY";
|
||||||
|
|
||||||
|
type ConfiguredRuntimeApiKeyResolution =
|
||||||
|
| { status: "available"; value: string }
|
||||||
|
| { status: "missing" }
|
||||||
|
| { status: "blocked" };
|
||||||
|
|
||||||
|
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
function readConfiguredOrManagedApiKey(value: unknown): string | undefined {
|
function readConfiguredOrManagedApiKey(value: unknown): string | undefined {
|
||||||
const literal = normalizeSecretInputString(value);
|
const literal = normalizeSecretInputString(value);
|
||||||
@@ -36,23 +61,74 @@ function readLegacyGrokFallbackAuth(cfg?: OpenClawConfig): XaiFallbackAuth | und
|
|||||||
return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined;
|
return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
|
function readConfiguredRuntimeApiKey(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
cfg?: OpenClawConfig,
|
||||||
|
): ConfiguredRuntimeApiKeyResolution {
|
||||||
|
const resolved = resolveSecretInputString({
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
defaults: cfg?.secrets?.defaults,
|
||||||
|
mode: "inspect",
|
||||||
|
});
|
||||||
|
if (resolved.status === "available") {
|
||||||
|
return { status: "available", value: resolved.value };
|
||||||
|
}
|
||||||
|
if (resolved.status === "missing") {
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
if (resolved.ref.source !== "env") {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
const envVarName = resolved.ref.id.trim();
|
||||||
|
if (envVarName !== XAI_API_KEY_ENV_VAR) {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!canResolveEnvSecretRefInReadOnlyPath({
|
||||||
|
cfg,
|
||||||
|
provider: resolved.ref.provider,
|
||||||
|
id: envVarName,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { status: "blocked" };
|
||||||
|
}
|
||||||
|
const envValue = normalizeSecretInputString(process.env[envVarName]);
|
||||||
|
return envValue ? { status: "available", value: envValue } : { status: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyGrokApiKeyResult(cfg?: OpenClawConfig): ConfiguredRuntimeApiKeyResolution {
|
||||||
const search = cfg?.tools?.web?.search;
|
const search = cfg?.tools?.web?.search;
|
||||||
if (!search || typeof search !== "object") {
|
if (!search || typeof search !== "object") {
|
||||||
return undefined;
|
return { status: "missing" };
|
||||||
}
|
}
|
||||||
const grok = (search as Record<string, unknown>).grok;
|
const grok = (search as Record<string, unknown>).grok;
|
||||||
return readConfiguredSecretString(
|
return readConfiguredRuntimeApiKey(
|
||||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
|
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
|
||||||
"tools.web.search.grok.apiKey",
|
"tools.web.search.grok.apiKey",
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||||
|
const resolved = readLegacyGrokApiKeyResult(cfg);
|
||||||
|
return resolved.status === "available" ? resolved.value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPluginXaiWebSearchApiKeyResult(
|
||||||
|
cfg?: OpenClawConfig,
|
||||||
|
): ConfiguredRuntimeApiKeyResolution {
|
||||||
|
return readConfiguredRuntimeApiKey(
|
||||||
|
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
|
||||||
|
"plugins.entries.xai.config.webSearch.apiKey",
|
||||||
|
cfg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
|
export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||||
return readConfiguredSecretString(
|
const resolved = readPluginXaiWebSearchApiKeyResult(cfg);
|
||||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
|
return resolved.status === "available" ? resolved.value : undefined;
|
||||||
"plugins.entries.xai.config.webSearch.apiKey",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
|
export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
|
||||||
@@ -69,18 +145,50 @@ export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
|
export function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||||
return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg);
|
const plugin = readPluginXaiWebSearchApiKeyResult(cfg);
|
||||||
|
if (plugin.status === "available") {
|
||||||
|
return plugin.value;
|
||||||
|
}
|
||||||
|
if (plugin.status === "blocked") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const legacy = readLegacyGrokApiKeyResult(cfg);
|
||||||
|
return legacy.status === "available" ? legacy.value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveXaiToolApiKey(params: {
|
export function resolveXaiToolApiKey(params: {
|
||||||
runtimeConfig?: OpenClawConfig;
|
runtimeConfig?: OpenClawConfig;
|
||||||
sourceConfig?: OpenClawConfig;
|
sourceConfig?: OpenClawConfig;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
return (
|
const runtimePlugin = readPluginXaiWebSearchApiKeyResult(params.runtimeConfig);
|
||||||
resolveFallbackXaiApiKey(params.runtimeConfig) ??
|
if (runtimePlugin.status === "available") {
|
||||||
resolveFallbackXaiApiKey(params.sourceConfig) ??
|
return runtimePlugin.value;
|
||||||
readProviderEnvValue(["XAI_API_KEY"])
|
}
|
||||||
);
|
if (runtimePlugin.status === "blocked") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const runtimeLegacy = readLegacyGrokApiKeyResult(params.runtimeConfig);
|
||||||
|
if (runtimeLegacy.status === "available") {
|
||||||
|
return runtimeLegacy.value;
|
||||||
|
}
|
||||||
|
if (runtimeLegacy.status === "blocked") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const sourcePlugin = readPluginXaiWebSearchApiKeyResult(params.sourceConfig);
|
||||||
|
if (sourcePlugin.status === "available") {
|
||||||
|
return sourcePlugin.value;
|
||||||
|
}
|
||||||
|
if (sourcePlugin.status === "blocked") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const sourceLegacy = readLegacyGrokApiKeyResult(params.sourceConfig);
|
||||||
|
if (sourceLegacy.status === "available") {
|
||||||
|
return sourceLegacy.value;
|
||||||
|
}
|
||||||
|
if (sourceLegacy.status === "blocked") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return readProviderEnvValue([XAI_API_KEY_ENV_VAR]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isXaiToolEnabled(params: {
|
export function isXaiToolEnabled(params: {
|
||||||
|
|||||||
@@ -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", () => {
|
it("does not treat known env marker names as usable when env value is missing", () => {
|
||||||
const previous = process.env.OPENAI_API_KEY;
|
const previous = process.env.OPENAI_API_KEY;
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
shouldDeferProviderSyntheticProfileAuthWithPlugin,
|
shouldDeferProviderSyntheticProfileAuthWithPlugin,
|
||||||
} from "../plugins/provider-runtime.js";
|
} from "../plugins/provider-runtime.js";
|
||||||
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
|
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
|
||||||
|
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
|
||||||
import {
|
import {
|
||||||
normalizeLowercaseStringOrEmpty,
|
normalizeLowercaseStringOrEmpty,
|
||||||
normalizeOptionalLowercaseString,
|
normalizeOptionalLowercaseString,
|
||||||
@@ -73,7 +74,19 @@ export function getCustomProviderApiKey(
|
|||||||
provider: string,
|
provider: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const entry = resolveProviderConfig(cfg, provider);
|
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 = {
|
type ResolvedCustomProviderApiKey = {
|
||||||
@@ -81,11 +94,61 @@ type ResolvedCustomProviderApiKey = {
|
|||||||
source: string;
|
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: {
|
export function resolveUsableCustomProviderApiKey(params: {
|
||||||
cfg: OpenClawConfig | undefined;
|
cfg: OpenClawConfig | undefined;
|
||||||
provider: string;
|
provider: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): ResolvedCustomProviderApiKey | null {
|
}): 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);
|
const customKey = getCustomProviderApiKey(params.cfg, params.provider);
|
||||||
if (!customKey) {
|
if (!customKey) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const REGISTRY_IDS = [
|
|||||||
"models.providers.openai.apiKey",
|
"models.providers.openai.apiKey",
|
||||||
"messages.tts.providers.openai.apiKey",
|
"messages.tts.providers.openai.apiKey",
|
||||||
"plugins.entries.firecrawl.config.webFetch.apiKey",
|
"plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||||
|
"plugins.entries.exa.config.webSearch.apiKey",
|
||||||
"skills.entries.demo.apiKey",
|
"skills.entries.demo.apiKey",
|
||||||
"tools.web.search.apiKey",
|
"tools.web.search.apiKey",
|
||||||
] as const;
|
] 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.defaults.memorySearch.remote.apiKey")).toBe(true);
|
||||||
expect(ids.has("agents.list[].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.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);
|
expect(ids.has("channels.discord.token")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const STATIC_AGENT_RUNTIME_BASE_TARGET_IDS = [
|
|||||||
"tools.web.search.apiKey",
|
"tools.web.search.apiKey",
|
||||||
"plugins.entries.brave.config.webSearch.apiKey",
|
"plugins.entries.brave.config.webSearch.apiKey",
|
||||||
"plugins.entries.google.config.webSearch.apiKey",
|
"plugins.entries.google.config.webSearch.apiKey",
|
||||||
|
"plugins.entries.exa.config.webSearch.apiKey",
|
||||||
"plugins.entries.xai.config.webSearch.apiKey",
|
"plugins.entries.xai.config.webSearch.apiKey",
|
||||||
"plugins.entries.moonshot.config.webSearch.apiKey",
|
"plugins.entries.moonshot.config.webSearch.apiKey",
|
||||||
"plugins.entries.perplexity.config.webSearch.apiKey",
|
"plugins.entries.perplexity.config.webSearch.apiKey",
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ describe("plugin-registry-loader", () => {
|
|||||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
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", () => {
|
it("maps command paths to plugin registry scopes", () => {
|
||||||
expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels");
|
expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels");
|
||||||
expect(resolvePluginRegistryScopeForCommandPath(["health"])).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 { loggingState } from "../logging/state.js";
|
||||||
import type { PluginRegistryScope } from "./plugin-registry.js";
|
import type { PluginRegistryScope } from "./plugin-registry.js";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export function resolvePluginRegistryScopeForCommandPath(
|
|||||||
export async function ensureCliPluginRegistryLoaded(params: {
|
export async function ensureCliPluginRegistryLoaded(params: {
|
||||||
scope: PluginRegistryScope;
|
scope: PluginRegistryScope;
|
||||||
routeLogsToStderr?: boolean;
|
routeLogsToStderr?: boolean;
|
||||||
|
config?: OpenClawConfig;
|
||||||
|
activationSourceConfig?: OpenClawConfig;
|
||||||
}) {
|
}) {
|
||||||
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
|
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
|
||||||
const previousForceStderr = loggingState.forceConsoleToStderr;
|
const previousForceStderr = loggingState.forceConsoleToStderr;
|
||||||
@@ -24,7 +27,13 @@ export async function ensureCliPluginRegistryLoaded(params: {
|
|||||||
loggingState.forceConsoleToStderr = true;
|
loggingState.forceConsoleToStderr = true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ensurePluginRegistryLoaded({ scope: params.scope });
|
ensurePluginRegistryLoaded({
|
||||||
|
scope: params.scope,
|
||||||
|
...(params.config ? { config: params.config } : {}),
|
||||||
|
...(params.activationSourceConfig
|
||||||
|
? { activationSourceConfig: params.activationSourceConfig }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loggingState.forceConsoleToStderr = previousForceStderr;
|
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}`;
|
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: {
|
function formatChannelAccountLabel(params: {
|
||||||
provider: ChannelId;
|
provider: ChannelId;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -43,6 +51,17 @@ function formatProviderState(entry: ProviderAccountStatus): string {
|
|||||||
return parts.join(", ");
|
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(
|
export async function buildProviderStatusIndex(
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
): Promise<Map<string, ProviderAccountStatus>> {
|
): Promise<Map<string, ProviderAccountStatus>> {
|
||||||
@@ -51,7 +70,24 @@ export async function buildProviderStatusIndex(
|
|||||||
for (const plugin of listChannelPlugins()) {
|
for (const plugin of listChannelPlugins()) {
|
||||||
const accountIds = plugin.config.listAccountIds(cfg);
|
const accountIds = plugin.config.listAccountIds(cfg);
|
||||||
for (const accountId of accountIds) {
|
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 snapshot = plugin.config.describeAccount?.(account, cfg);
|
||||||
const enabled = plugin.config.isEnabled
|
const enabled = plugin.config.isEnabled
|
||||||
? plugin.config.isEnabled(account, cfg)
|
? plugin.config.isEnabled(account, cfg)
|
||||||
|
|||||||
@@ -57,6 +57,51 @@ describe("scanStatusJsonFast", () => {
|
|||||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
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 () => {
|
it("skips plugin compatibility loading even when configured channels are present", async () => {
|
||||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export async function scanStatusJsonWithPolicy(
|
|||||||
await ensureCliPluginRegistryLoaded({
|
await ensureCliPluginRegistryLoaded({
|
||||||
scope: "configured-channels",
|
scope: "configured-channels",
|
||||||
routeLogsToStderr: true,
|
routeLogsToStderr: true,
|
||||||
|
config: overview.cfg,
|
||||||
|
activationSourceConfig: overview.sourceConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,10 +186,12 @@ describe("scanStatus", () => {
|
|||||||
configureScanStatus({
|
configureScanStatus({
|
||||||
hasConfiguredChannels: true,
|
hasConfiguredChannels: true,
|
||||||
sourceConfig: createStatusScanConfig({
|
sourceConfig: createStatusScanConfig({
|
||||||
|
marker: "source-preload",
|
||||||
plugins: { enabled: false },
|
plugins: { enabled: false },
|
||||||
channels: { telegram: { enabled: false } },
|
channels: { telegram: { enabled: false } },
|
||||||
}),
|
}),
|
||||||
resolvedConfig: createStatusScanConfig({
|
resolvedConfig: createStatusScanConfig({
|
||||||
|
marker: "resolved-preload",
|
||||||
plugins: { enabled: false },
|
plugins: { enabled: false },
|
||||||
channels: { telegram: { enabled: false } },
|
channels: { telegram: { enabled: false } },
|
||||||
}),
|
}),
|
||||||
@@ -198,9 +200,13 @@ describe("scanStatus", () => {
|
|||||||
|
|
||||||
await scanStatus({ json: true }, {} as never);
|
await scanStatus({ json: true }, {} as never);
|
||||||
|
|
||||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
|
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||||
scope: "configured-channels",
|
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
|
// Verify plugin logs were routed to stderr during loading and restored after
|
||||||
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
expect(loggingStateRef.forceConsoleToStderr).toBe(false);
|
||||||
expect(mocks.probeGateway).toHaveBeenCalledWith(
|
expect(mocks.probeGateway).toHaveBeenCalledWith(
|
||||||
@@ -215,9 +221,11 @@ describe("scanStatus", () => {
|
|||||||
configureScanStatus({
|
configureScanStatus({
|
||||||
hasConfiguredChannels: true,
|
hasConfiguredChannels: true,
|
||||||
sourceConfig: createStatusScanConfig({
|
sourceConfig: createStatusScanConfig({
|
||||||
|
marker: "source-env-only",
|
||||||
plugins: { enabled: false },
|
plugins: { enabled: false },
|
||||||
}),
|
}),
|
||||||
resolvedConfig: createStatusScanConfig({
|
resolvedConfig: createStatusScanConfig({
|
||||||
|
marker: "resolved-env-only",
|
||||||
plugins: { enabled: false },
|
plugins: { enabled: false },
|
||||||
}),
|
}),
|
||||||
summary: createStatusSummary({ linkChannel: { linked: false } }),
|
summary: createStatusSummary({ linkChannel: { linked: false } }),
|
||||||
@@ -227,8 +235,12 @@ describe("scanStatus", () => {
|
|||||||
await scanStatus({ json: true }, {} as never);
|
await scanStatus({ json: true }, {} as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
|
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||||
scope: "configured-channels",
|
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 DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret
|
||||||
export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
|
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})\}$/;
|
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 = {
|
type SecretDefaults = {
|
||||||
env?: string;
|
env?: string;
|
||||||
file?: string;
|
file?: string;
|
||||||
@@ -120,6 +125,12 @@ function formatSecretRefLabel(ref: SecretRef): string {
|
|||||||
return `${ref.source}:${ref.provider}:${ref.id}`;
|
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: {
|
export function assertSecretInputResolved(params: {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
refValue?: unknown;
|
refValue?: unknown;
|
||||||
@@ -134,9 +145,44 @@ export function assertSecretInputResolved(params: {
|
|||||||
if (!ref) {
|
if (!ref) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw createUnresolvedSecretInputError({ path: params.path, ref });
|
||||||
`${params.path}: unresolved SecretRef "${formatSecretRefLabel(ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
}
|
||||||
);
|
|
||||||
|
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: {
|
export function normalizeResolvedSecretInputString(params: {
|
||||||
@@ -145,11 +191,13 @@ export function normalizeResolvedSecretInputString(params: {
|
|||||||
defaults?: SecretDefaults;
|
defaults?: SecretDefaults;
|
||||||
path: string;
|
path: string;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const normalized = normalizeSecretInputString(params.value);
|
const resolved = resolveSecretInputString({
|
||||||
if (normalized) {
|
...params,
|
||||||
return normalized;
|
mode: "strict",
|
||||||
|
});
|
||||||
|
if (resolved.status === "available") {
|
||||||
|
return resolved.value;
|
||||||
}
|
}
|
||||||
assertSecretInputResolved(params);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
isSecretRef,
|
isSecretRef,
|
||||||
|
resolveSecretInputString,
|
||||||
normalizeResolvedSecretInputString,
|
normalizeResolvedSecretInputString,
|
||||||
normalizeSecretInputString,
|
normalizeSecretInputString,
|
||||||
} from "../config/types.secrets.js";
|
} from "../config/types.secrets.js";
|
||||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||||
import { buildSecretInputSchema } from "./secret-input-schema.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 {
|
export {
|
||||||
buildSecretInputSchema,
|
buildSecretInputSchema,
|
||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
isSecretRef,
|
isSecretRef,
|
||||||
|
resolveSecretInputString,
|
||||||
normalizeResolvedSecretInputString,
|
normalizeResolvedSecretInputString,
|
||||||
normalizeSecretInput,
|
normalizeSecretInput,
|
||||||
normalizeSecretInputString,
|
normalizeSecretInputString,
|
||||||
|
|||||||
@@ -369,6 +369,17 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
|
|||||||
includeInConfigure: true,
|
includeInConfigure: true,
|
||||||
includeInAudit: 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",
|
id: "plugins.entries.xai.config.webSearch.apiKey",
|
||||||
targetType: "plugins.entries.xai.config.webSearch.apiKey",
|
targetType: "plugins.entries.xai.config.webSearch.apiKey",
|
||||||
|
|||||||
@@ -42,4 +42,18 @@ describe("secret target registry", () => {
|
|||||||
|
|
||||||
expect(target).toBeNull();
|
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