Skip to content

Commit b1f8172

Browse files
authored
fix(secretrefs): resolve external channel contracts (#76449)
1 parent 8f4eaa9 commit b1f8172

18 files changed

Lines changed: 517 additions & 38 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
### Fixes
2020

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

extensions/bluebubbles/src/monitor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "./monitor-shared.js";
2424
import { fetchBlueBubblesServerInfo } from "./probe.js";
2525
import { getBlueBubblesRuntime } from "./runtime.js";
26+
import { normalizeSecretInputString } from "./secret-input.js";
2627
import {
2728
WEBHOOK_RATE_LIMIT_DEFAULTS,
2829
createFixedWindowRateLimiter,
@@ -193,7 +194,7 @@ export async function handleBlueBubblesWebhookRequest(
193194
targets,
194195
res,
195196
isMatch: (target) => {
196-
const token = target.account.config.password?.trim() ?? "";
197+
const token = normalizeSecretInputString(target.account.config.password) ?? "";
197198
return safeEqualAuthToken(guid, token);
198199
},
199200
});

extensions/bluebubbles/src/monitor.webhook-auth.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,16 @@ describe("BlueBubbles webhook monitor", () => {
432432
);
433433
});
434434

435+
it("rejects unresolved SecretRef webhook passwords without crashing", async () => {
436+
setupWebhookTarget({
437+
account: createMockAccount({
438+
password: { source: "exec", provider: "vault", id: "bluebubbles/webhook" } as never,
439+
}),
440+
});
441+
442+
await expectProtectedPasswordQueryRequestStatus(401);
443+
});
444+
435445
it("rate limits repeated invalid password guesses from the same client", async () => {
436446
setupWebhookTarget({
437447
account: createMockAccount({

extensions/discord/src/setup-account-state.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,26 @@ describe("discord setup account state", () => {
8888
expect(inspected.tokenStatus).toBe("missing");
8989
expect(inspected.configured).toBe(false);
9090
});
91+
92+
it("reports unresolved SecretRef account tokens as configured but unavailable", () => {
93+
const inspected = inspectDiscordSetupAccount({
94+
cfg: {
95+
channels: {
96+
discord: {
97+
accounts: {
98+
work: {
99+
token: { source: "exec", provider: "vault", id: "discord/work" },
100+
},
101+
},
102+
},
103+
},
104+
},
105+
accountId: "work",
106+
});
107+
108+
expect(inspected.token).toBe("");
109+
expect(inspected.tokenSource).toBe("config");
110+
expect(inspected.tokenStatus).toBe("configured_unavailable");
111+
expect(inspected.configured).toBe(true);
112+
});
91113
});

extensions/telegram/src/accounts.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { withEnv } from "openclaw/plugin-sdk/test-env";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import {
66
createTelegramActionGate,
7+
listEnabledTelegramAccounts,
78
listTelegramAccountIds,
89
mergeTelegramAccountConfig,
910
resolveTelegramMediaRuntimeOptions,
@@ -123,6 +124,27 @@ describe("resolveTelegramAccount", () => {
123124
expect(lines).toContain("listTelegramAccountIds [ 'work' ]");
124125
expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }");
125126
});
127+
128+
it("does not resolve disabled account tokens when listing enabled accounts", () => {
129+
const cfg = {
130+
channels: {
131+
telegram: {
132+
accounts: {
133+
disabled: {
134+
enabled: false,
135+
botToken: { source: "exec", provider: "vault", id: "telegram/disabled" },
136+
},
137+
work: { botToken: "tok-work" },
138+
},
139+
},
140+
},
141+
} as unknown as OpenClawConfig;
142+
143+
const accounts = listEnabledTelegramAccounts(cfg);
144+
145+
expect(accounts.map((account) => account.accountId)).toEqual(["work"]);
146+
expect(accounts[0]?.token).toBe("tok-work");
147+
});
126148
});
127149

128150
describe("resolveDefaultTelegramAccountId", () => {

extensions/telegram/src/accounts.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,11 @@ export function resolveTelegramAccount(params: {
177177
}
178178

179179
export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] {
180+
const baseEnabled = cfg.channels?.telegram?.enabled !== false;
181+
if (!baseEnabled) {
182+
return [];
183+
}
180184
return listTelegramAccountIds(cfg)
181-
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
182-
.filter((account) => account.enabled);
185+
.filter((accountId) => mergeTelegramAccountConfig(cfg, accountId).enabled !== false)
186+
.map((accountId) => resolveTelegramAccount({ cfg, accountId }));
183187
}

src/agents/tools/web-search.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
22
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
33
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
44
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
5+
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
56
import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js";
67
import type { AnyAgentTool } from "./common.js";
78
import { asToolParamsRecord, jsonResult } from "./common.js";
@@ -92,7 +93,10 @@ export function createWebSearchTool(options?: {
9293
: options?.runtimeWebSearch;
9394
const runtimeProviderId =
9495
runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured;
95-
const config = options?.lateBindRuntimeConfig === true ? undefined : options?.config;
96+
const config =
97+
options?.lateBindRuntimeConfig === true
98+
? (getActiveSecretsRuntimeSnapshot()?.config ?? options?.config)
99+
: options?.config;
96100
const preferRuntimeProviders =
97101
Boolean(runtimeProviderId) &&
98102
!resolveManifestContractOwnerPluginId({

src/agents/tools/web-tools.enabled-defaults.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
1010
const runWebSearchCalls = vi.hoisted(
1111
() => [] as Array<{ config?: unknown; runtimeWebSearch?: unknown }>,
1212
);
13+
const activeSecretsRuntimeSnapshot = vi.hoisted(() => ({
14+
current: null as null | { config: unknown },
15+
}));
16+
17+
vi.mock("../../secrets/runtime.js", () => ({
18+
getActiveSecretsRuntimeSnapshot: () => activeSecretsRuntimeSnapshot.current,
19+
}));
1320

1421
vi.mock("../../web-search/runtime.js", async () => {
1522
const { getActivePluginRegistry } = await import("../../plugins/runtime.js");
@@ -68,12 +75,14 @@ vi.mock("../../web-search/runtime.js", async () => {
6875
beforeEach(() => {
6976
setActivePluginRegistry(createEmptyPluginRegistry());
7077
clearActiveRuntimeWebToolsMetadata();
78+
activeSecretsRuntimeSnapshot.current = null;
7179
runWebSearchCalls.length = 0;
7280
});
7381

7482
afterEach(() => {
7583
setActivePluginRegistry(createEmptyPluginRegistry());
7684
clearActiveRuntimeWebToolsMetadata();
85+
activeSecretsRuntimeSnapshot.current = null;
7786
});
7887

7988
describe("web tools defaults", () => {
@@ -196,6 +205,10 @@ describe("web tools defaults", () => {
196205
},
197206
diagnostics: [],
198207
});
208+
const runtimeConfig = {
209+
tools: { web: { search: { provider: "fresh", fresh: { apiKey: "runtime-key" } } } },
210+
};
211+
activeSecretsRuntimeSnapshot.current = { config: runtimeConfig };
199212

200213
const tool = createWebSearchTool({
201214
config: { tools: { web: { search: { provider: "stale" } } } },
@@ -214,7 +227,7 @@ describe("web tools defaults", () => {
214227

215228
expect(result?.details).toMatchObject({ provider: "fresh" });
216229
expect(runWebSearchCalls).toHaveLength(1);
217-
expect(runWebSearchCalls[0]?.config).toBeUndefined();
230+
expect(runWebSearchCalls[0]?.config).toBe(runtimeConfig);
218231
expect(runWebSearchCalls[0]?.runtimeWebSearch).toMatchObject({
219232
selectedProvider: "fresh",
220233
});

src/cli/command-secret-targets.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
22
import type { OpenClawConfig } from "../config/types.openclaw.js";
33
import { normalizeOptionalAccountId } from "../routing/session-key.js";
4+
import { loadChannelSecretContractApi } from "../secrets/channel-contract-api.js";
45
import {
56
discoverConfigSecretTargetsByIds,
67
listSecretTargetRegistryEntries,
@@ -115,6 +116,20 @@ function getConfiguredChannelSecretTargetIds(
115116
env: NodeJS.ProcessEnv = process.env,
116117
): string[] {
117118
const targetIds = new Set<string>();
119+
const channels = config.channels;
120+
if (channels && typeof channels === "object" && !Array.isArray(channels)) {
121+
for (const channelId of Object.keys(channels)) {
122+
if (channelId === "defaults") {
123+
continue;
124+
}
125+
const contract = loadChannelSecretContractApi({ channelId, config, env });
126+
for (const entry of contract?.secretTargetRegistryEntries ?? []) {
127+
if (isScopedChannelSecretTargetEntry({ entry, pluginChannelId: channelId })) {
128+
targetIds.add(entry.id);
129+
}
130+
}
131+
}
132+
}
118133
for (const plugin of listReadOnlyChannelPluginsForConfig(config, {
119134
env,
120135
includePersistedAuthState: false,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
5+
6+
const tempDirs: string[] = [];
7+
8+
const { loadPluginMetadataSnapshotMock, loadBundledPluginPublicArtifactModuleSyncMock } =
9+
vi.hoisted(() => ({
10+
loadPluginMetadataSnapshotMock: vi.fn(),
11+
loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => {
12+
throw new Error(
13+
"Unable to resolve bundled plugin public surface discord/secret-contract-api.js",
14+
);
15+
}),
16+
}));
17+
18+
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
19+
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
20+
}));
21+
22+
vi.mock("../plugins/public-surface-loader.js", () => ({
23+
loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock,
24+
}));
25+
26+
import { loadChannelSecretContractApi } from "./channel-contract-api.js";
27+
28+
function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) {
29+
const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs);
30+
fs.writeFileSync(
31+
path.join(rootDir, "secret-contract-api.cjs"),
32+
`
33+
module.exports = {
34+
secretTargetRegistryEntries: [
35+
{
36+
id: "channels.${params.channelId}.token",
37+
targetType: "channels.${params.channelId}.token",
38+
configFile: "openclaw.json",
39+
pathPattern: "channels.${params.channelId}.token",
40+
secretShape: "secret_input",
41+
expectedResolvedValue: "string",
42+
includeInPlan: true,
43+
includeInConfigure: true,
44+
includeInAudit: true
45+
}
46+
],
47+
collectRuntimeConfigAssignments(params) {
48+
params.context.assignments.push({
49+
path: "channels.${params.channelId}.token",
50+
ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
51+
expected: "string",
52+
apply() {}
53+
});
54+
}
55+
};
56+
`,
57+
"utf8",
58+
);
59+
return {
60+
id: params.pluginId,
61+
origin: "global",
62+
channels: [params.channelId],
63+
channelConfigs: {},
64+
rootDir,
65+
};
66+
}
67+
68+
describe("external channel secret contract api", () => {
69+
beforeEach(() => {
70+
loadPluginMetadataSnapshotMock.mockReset();
71+
loadBundledPluginPublicArtifactModuleSyncMock.mockClear();
72+
});
73+
74+
afterEach(() => {
75+
cleanupTrackedTempDirs(tempDirs);
76+
});
77+
78+
it("loads root secret-contract-api sidecars for external channel plugins", () => {
79+
const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" });
80+
loadPluginMetadataSnapshotMock.mockReturnValue({
81+
plugins: [record],
82+
});
83+
84+
const api = loadChannelSecretContractApi({
85+
channelId: "discord",
86+
config: { channels: { discord: {} } },
87+
env: {},
88+
loadablePluginOrigins: new Map([["discord", "global"]]),
89+
});
90+
91+
expect(api?.secretTargetRegistryEntries).toEqual(
92+
expect.arrayContaining([
93+
expect.objectContaining({
94+
id: "channels.discord.token",
95+
}),
96+
]),
97+
);
98+
expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function");
99+
});
100+
101+
it("skips external channel records outside the loadable plugin origin set", () => {
102+
const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" });
103+
loadPluginMetadataSnapshotMock.mockReturnValue({
104+
plugins: [record],
105+
});
106+
107+
const api = loadChannelSecretContractApi({
108+
channelId: "discord",
109+
config: { channels: { discord: {} } },
110+
env: {},
111+
loadablePluginOrigins: new Map([["other", "global"]]),
112+
});
113+
114+
expect(api).toBeUndefined();
115+
});
116+
});

0 commit comments

Comments
 (0)