Skip to content

Commit 1769fb2

Browse files
authored
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)
1 parent 4491bda commit 1769fb2

28 files changed

Lines changed: 1495 additions & 68 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- 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
3535
- 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.
3636
- 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.
37+
- 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.
3738

3839
## 2026.4.14
3940

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
cd06d41c9302b068d2d998e478a4cca5e0bdd0b165e381cc68740698a5921d21 plugin-sdk-api-baseline.json
2-
8131372bd1fb433d24de85c94e3fe58368579abed10ec80f39370c6f6fee6373 plugin-sdk-api-baseline.jsonl
1+
effb6ee16d16bc1b1e76ec293868910f887a168d9b756449928c703fe4c9e81a plugin-sdk-api-baseline.json
2+
16eb8ac91b10b3ee62d856bf16c25c1ba3ba9fa7303500af2947a6e532b0c222 plugin-sdk-api-baseline.jsonl

docs/reference/secretref-credential-surface.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Scope intent:
4242
- `messages.tts.providers.*.apiKey`
4343
- `tools.web.fetch.firecrawl.apiKey`
4444
- `plugins.entries.brave.config.webSearch.apiKey`
45+
- `plugins.entries.exa.config.webSearch.apiKey`
4546
- `plugins.entries.google.config.webSearch.apiKey`
4647
- `plugins.entries.xai.config.webSearch.apiKey`
4748
- `plugins.entries.moonshot.config.webSearch.apiKey`

docs/reference/secretref-user-supplied-credentials-matrix.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,13 @@
526526
"secretShape": "secret_input",
527527
"optIn": true
528528
},
529+
{
530+
"id": "plugins.entries.exa.config.webSearch.apiKey",
531+
"configFile": "openclaw.json",
532+
"path": "plugins.entries.exa.config.webSearch.apiKey",
533+
"secretShape": "secret_input",
534+
"optIn": true
535+
},
529536
{
530537
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
531538
"configFile": "openclaw.json",

extensions/firecrawl/src/config.ts

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
2-
import {
3-
normalizeResolvedSecretInputString,
4-
normalizeSecretInput,
5-
} from "openclaw/plugin-sdk/secret-input";
2+
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
3+
import { resolveSecretInputString, normalizeSecretInput } from "openclaw/plugin-sdk/secret-input";
64

75
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
86
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
97
export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60;
108
export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
9+
const FIRECRAWL_API_KEY_ENV_VAR = "FIRECRAWL_API_KEY";
1110

1211
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
1312
? Web extends { search?: infer Search }
@@ -104,33 +103,101 @@ export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetc
104103
return firecrawl as FirecrawlFetchConfig;
105104
}
106105

107-
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
108-
return normalizeSecretInput(
109-
normalizeResolvedSecretInputString({
110-
value,
111-
path,
112-
}),
113-
);
106+
type ConfiguredSecretResolution =
107+
| { status: "available"; value: string }
108+
| { status: "missing" }
109+
| { status: "blocked" };
110+
111+
function canResolveEnvSecretRefInReadOnlyPath(params: {
112+
cfg?: OpenClawConfig;
113+
provider: string;
114+
id: string;
115+
}): boolean {
116+
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
117+
if (!providerConfig) {
118+
return params.provider === resolveDefaultSecretProviderAlias(params.cfg ?? {}, "env");
119+
}
120+
if (providerConfig.source !== "env") {
121+
return false;
122+
}
123+
const allowlist = providerConfig.allowlist;
124+
return !allowlist || allowlist.includes(params.id);
125+
}
126+
127+
function resolveConfiguredSecret(
128+
value: unknown,
129+
path: string,
130+
cfg?: OpenClawConfig,
131+
): ConfiguredSecretResolution {
132+
const resolved = resolveSecretInputString({
133+
value,
134+
path,
135+
defaults: cfg?.secrets?.defaults,
136+
mode: "inspect",
137+
});
138+
if (resolved.status === "available") {
139+
const normalized = normalizeSecretInput(resolved.value);
140+
return normalized ? { status: "available", value: normalized } : { status: "missing" };
141+
}
142+
if (resolved.status === "missing") {
143+
return { status: "missing" };
144+
}
145+
if (resolved.ref.source !== "env") {
146+
return { status: "blocked" };
147+
}
148+
const envVarName = resolved.ref.id.trim();
149+
if (envVarName !== FIRECRAWL_API_KEY_ENV_VAR) {
150+
return { status: "blocked" };
151+
}
152+
if (
153+
!canResolveEnvSecretRefInReadOnlyPath({
154+
cfg,
155+
provider: resolved.ref.provider,
156+
id: envVarName,
157+
})
158+
) {
159+
return { status: "blocked" };
160+
}
161+
const envValue = normalizeSecretInput(process.env[envVarName]);
162+
return envValue ? { status: "available", value: envValue } : { status: "missing" };
114163
}
115164

116165
export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined {
117166
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
118167
const search = resolveFirecrawlSearchConfig(cfg);
119168
const fetch = resolveFirecrawlFetchConfig(cfg);
120-
return (
121-
normalizeConfiguredSecret(
122-
pluginConfig?.webFetch?.apiKey,
123-
"plugins.entries.firecrawl.config.webFetch.apiKey",
124-
) ||
125-
normalizeConfiguredSecret(
126-
search?.apiKey,
127-
"plugins.entries.firecrawl.config.webSearch.apiKey",
128-
) ||
129-
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
130-
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
131-
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
132-
undefined
133-
);
169+
const configuredCandidates: Array<{ value: unknown; path: string }> = [
170+
{
171+
value: pluginConfig?.webFetch?.apiKey,
172+
path: "plugins.entries.firecrawl.config.webFetch.apiKey",
173+
},
174+
{
175+
value: search?.apiKey,
176+
path: "plugins.entries.firecrawl.config.webSearch.apiKey",
177+
},
178+
{
179+
value: search?.apiKey,
180+
path: "tools.web.search.firecrawl.apiKey",
181+
},
182+
{
183+
value: fetch?.apiKey,
184+
path: "tools.web.fetch.firecrawl.apiKey",
185+
},
186+
];
187+
let blockedConfiguredSecret = false;
188+
for (const candidate of configuredCandidates) {
189+
const resolved = resolveConfiguredSecret(candidate.value, candidate.path, cfg);
190+
if (resolved.status === "available") {
191+
return resolved.value;
192+
}
193+
if (resolved.status === "blocked") {
194+
blockedConfiguredSecret = true;
195+
}
196+
}
197+
if (blockedConfiguredSecret) {
198+
return undefined;
199+
}
200+
return normalizeSecretInput(process.env[FIRECRAWL_API_KEY_ENV_VAR]) || undefined;
134201
}
135202

136203
export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string {

extensions/firecrawl/src/firecrawl-tools.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,137 @@ describe("firecrawl tools", () => {
474474
expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL);
475475
});
476476

477+
it("resolves env SecretRefs for Firecrawl API key without requiring a runtime snapshot", () => {
478+
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
479+
const cfg = {
480+
plugins: {
481+
entries: {
482+
firecrawl: {
483+
config: {
484+
webSearch: {
485+
apiKey: {
486+
source: "env",
487+
provider: "default",
488+
id: "FIRECRAWL_API_KEY",
489+
},
490+
},
491+
},
492+
},
493+
},
494+
},
495+
} as OpenClawConfig;
496+
497+
expect(resolveFirecrawlApiKey(cfg)).toBe("firecrawl-env-ref-key");
498+
});
499+
500+
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
501+
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-fallback");
502+
const cfg = {
503+
plugins: {
504+
entries: {
505+
firecrawl: {
506+
config: {
507+
webSearch: {
508+
apiKey: {
509+
source: "file",
510+
provider: "vault",
511+
id: "/firecrawl/api-key",
512+
},
513+
},
514+
},
515+
},
516+
},
517+
},
518+
} as OpenClawConfig;
519+
520+
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
521+
});
522+
523+
it("does not read arbitrary env SecretRef ids for Firecrawl API key resolution", () => {
524+
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
525+
const cfg = {
526+
plugins: {
527+
entries: {
528+
firecrawl: {
529+
config: {
530+
webSearch: {
531+
apiKey: {
532+
source: "env",
533+
provider: "default",
534+
id: "UNRELATED_SECRET",
535+
},
536+
},
537+
},
538+
},
539+
},
540+
},
541+
} as OpenClawConfig;
542+
543+
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
544+
});
545+
546+
it("does not resolve env SecretRefs when provider allowlist excludes FIRECRAWL_API_KEY", () => {
547+
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
548+
const cfg = {
549+
secrets: {
550+
providers: {
551+
"firecrawl-env": {
552+
source: "env",
553+
allowlist: ["OTHER_FIRECRAWL_API_KEY"],
554+
},
555+
},
556+
},
557+
plugins: {
558+
entries: {
559+
firecrawl: {
560+
config: {
561+
webSearch: {
562+
apiKey: {
563+
source: "env",
564+
provider: "firecrawl-env",
565+
id: "FIRECRAWL_API_KEY",
566+
},
567+
},
568+
},
569+
},
570+
},
571+
},
572+
} as OpenClawConfig;
573+
574+
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
575+
});
576+
577+
it("does not resolve env SecretRefs when provider source is not env", () => {
578+
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
579+
const cfg = {
580+
secrets: {
581+
providers: {
582+
"firecrawl-env": {
583+
source: "file",
584+
path: "/tmp/secrets.json",
585+
},
586+
},
587+
},
588+
plugins: {
589+
entries: {
590+
firecrawl: {
591+
config: {
592+
webSearch: {
593+
apiKey: {
594+
source: "env",
595+
provider: "firecrawl-env",
596+
id: "FIRECRAWL_API_KEY",
597+
},
598+
},
599+
},
600+
},
601+
},
602+
},
603+
} as OpenClawConfig;
604+
605+
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
606+
});
607+
477608
it("only allows the official Firecrawl API host for fetch endpoints", () => {
478609
expect(firecrawlClientTesting.resolveEndpoint("https://api.firecrawl.dev", "/v2/scrape")).toBe(
479610
"https://api.firecrawl.dev/v2/scrape",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createTestPluginApi } from "../../../../test/helpers/plugins/plugin-api.js";
3+
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
4+
import { registerSlackPluginHttpRoutes } from "./plugin-routes.js";
5+
6+
function createApi(config: OpenClawConfig, registerHttpRoute = vi.fn()): OpenClawPluginApi {
7+
return createTestPluginApi({
8+
id: "slack",
9+
config,
10+
registerHttpRoute,
11+
}) as OpenClawPluginApi;
12+
}
13+
14+
describe("registerSlackPluginHttpRoutes", () => {
15+
it("registers account webhook paths without resolving unresolved token refs", () => {
16+
const registerHttpRoute = vi.fn();
17+
const cfg: OpenClawConfig = {
18+
channels: {
19+
slack: {
20+
accounts: {
21+
default: {
22+
webhookPath: "/hooks/default",
23+
botToken: {
24+
source: "env",
25+
provider: "default",
26+
id: "SLACK_BOT_TOKEN",
27+
} as unknown as string,
28+
},
29+
ops: {
30+
webhookPath: "hooks/ops",
31+
botToken: {
32+
source: "env",
33+
provider: "default",
34+
id: "SLACK_OPS_BOT_TOKEN",
35+
} as unknown as string,
36+
},
37+
},
38+
},
39+
},
40+
};
41+
const api = createApi(cfg, registerHttpRoute);
42+
43+
expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow();
44+
45+
const paths = registerHttpRoute.mock.calls
46+
.map((call) => (call[0] as { path: string }).path)
47+
.toSorted();
48+
expect(paths).toEqual(["/hooks/default", "/hooks/ops"]);
49+
});
50+
51+
it("falls back to the default slack webhook path", () => {
52+
const registerHttpRoute = vi.fn();
53+
const api = createApi({}, registerHttpRoute);
54+
55+
registerSlackPluginHttpRoutes(api);
56+
57+
const paths = registerHttpRoute.mock.calls
58+
.map((call) => (call[0] as { path: string }).path)
59+
.toSorted();
60+
expect(paths).toEqual(["/slack/events"]);
61+
});
62+
});

extensions/slack/src/http/plugin-routes.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
22
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
3-
import { listSlackAccountIds, resolveSlackAccount } from "../accounts.js";
3+
import { listSlackAccountIds, mergeSlackAccountConfig } from "../accounts.js";
44
import { normalizeSlackWebhookPath } from "./paths.js";
55

66
let slackHttpHandlerRuntimePromise: Promise<typeof import("./handler.runtime.js")> | null = null;
@@ -14,8 +14,9 @@ export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
1414
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
1515
const registeredPaths = new Set<string>();
1616
for (const accountId of accountIds) {
17-
const account = resolveSlackAccount({ cfg: api.config, accountId });
18-
registeredPaths.add(normalizeSlackWebhookPath(account.config.webhookPath));
17+
// Route registration must remain config-only and should not resolve tokens.
18+
const accountConfig = mergeSlackAccountConfig(api.config, accountId);
19+
registeredPaths.add(normalizeSlackWebhookPath(accountConfig.webhookPath));
1920
}
2021
if (registeredPaths.size === 0) {
2122
registeredPaths.add(normalizeSlackWebhookPath());

0 commit comments

Comments
 (0)