diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index b25c7e1433e..eb5e1c70680 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -82,6 +82,20 @@ File-backed AppSecret: } ``` +Env SecretRef AppSecret: + +```json5 +{ + channels: { + qqbot: { + enabled: true, + appId: "YOUR_APP_ID", + clientSecret: { source: "env", provider: "default", id: "QQBOT_CLIENT_SECRET" }, + }, + }, +} +``` + Notes: - Env fallback applies to the default QQ Bot account only. diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 5e75dbeb51e..045ec2128f6 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -90,6 +90,8 @@ Scope intent: - `channels.feishu.accounts.*.appSecret` - `channels.feishu.accounts.*.encryptKey` - `channels.feishu.accounts.*.verificationToken` +- `channels.qqbot.clientSecret` +- `channels.qqbot.accounts.*.clientSecret` - `channels.msteams.appPassword` - `channels.mattermost.botToken` - `channels.mattermost.accounts.*.botToken` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 995094fcb93..da473149004 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -281,6 +281,20 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "channels.qqbot.accounts.*.clientSecret", + "configFile": "openclaw.json", + "path": "channels.qqbot.accounts.*.clientSecret", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "channels.qqbot.clientSecret", + "configFile": "openclaw.json", + "path": "channels.qqbot.clientSecret", + "secretShape": "secret_input", + "optIn": true + }, { "id": "channels.slack.accounts.*.appToken", "configFile": "openclaw.json", diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts index 5ec615f92f7..c9720ba50da 100644 --- a/extensions/qqbot/index.ts +++ b/extensions/qqbot/index.ts @@ -21,6 +21,10 @@ export default defineBundledChannelEntry({ specifier: "./channel-plugin-api.js", exportName: "qqbotPlugin", }, + secrets: { + specifier: "./secret-contract-api.js", + exportName: "channelSecrets", + }, runtime: { specifier: "./runtime-api.js", exportName: "setQQBotRuntime", diff --git a/extensions/qqbot/secret-contract-api.ts b/extensions/qqbot/secret-contract-api.ts new file mode 100644 index 00000000000..9f44ef28569 --- /dev/null +++ b/extensions/qqbot/secret-contract-api.ts @@ -0,0 +1,5 @@ +export { + channelSecrets, + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/qqbot/setup-entry.ts b/extensions/qqbot/setup-entry.ts index 74838a01a96..c230e007087 100644 --- a/extensions/qqbot/setup-entry.ts +++ b/extensions/qqbot/setup-entry.ts @@ -6,4 +6,8 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "qqbotSetupPlugin", }, + secrets: { + specifier: "./secret-contract-api.js", + exportName: "channelSecrets", + }, }); diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts index e9d979eb7b2..497832b0f85 100644 --- a/extensions/qqbot/src/bridge/config.ts +++ b/extensions/qqbot/src/bridge/config.ts @@ -1,5 +1,7 @@ import fs from "node:fs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; +import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { getPlatformAdapter } from "../engine/adapter/index.js"; import { DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID, @@ -17,6 +19,56 @@ interface QQBotChannelConfig extends QQBotAccountConfig { defaultAccount?: string; } +function resolveEnvSecretRefValue(params: { + cfg: OpenClawConfig; + value: unknown; + env?: NodeJS.ProcessEnv; +}): string | undefined { + const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults); + if (!ref || ref.source !== "env") { + return undefined; + } + + const providerConfig = params.cfg.secrets?.providers?.[ref.provider]; + if (providerConfig) { + if (providerConfig.source !== "env") { + throw new Error( + `Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`, + ); + } + if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) { + throw new Error( + `Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`, + ); + } + } else if (ref.provider !== resolveDefaultSecretProviderAlias(params.cfg, "env")) { + throw new Error( + `Secret provider "${ref.provider}" is not configured (ref: env:${ref.provider}:${ref.id}).`, + ); + } + + return normalizeSecretInputString((params.env ?? process.env)[ref.id]); +} + +function resolveQQBotClientSecretInput(params: { + cfg: OpenClawConfig; + value: unknown; + path: string; +}): string | undefined { + const envSecret = resolveEnvSecretRefValue({ + cfg: params.cfg, + value: params.value, + }); + if (envSecret) { + return envSecret; + } + + return getPlatformAdapter().resolveSecretInputString({ + value: params.value, + path: params.path, + }); +} + /** List all configured QQBot account IDs. */ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { return listAccountIds(cfg as unknown as Record); @@ -62,7 +114,8 @@ export function resolveQQBotAccount( if (adapter.hasConfiguredSecret(accountConfig.clientSecret)) { clientSecret = opts?.allowUnresolvedSecretRef ? (adapter.normalizeSecretInputString(accountConfig.clientSecret) ?? "") - : (adapter.resolveSecretInputString({ + : (resolveQQBotClientSecretInput({ + cfg, value: accountConfig.clientSecret, path: clientSecretPath, }) ?? ""); diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index cf7da2b4b29..de818feeb33 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -176,11 +176,41 @@ describe("qqbot config", () => { expect(resolved.name).toBe("Bot Two"); }); - it("rejects unresolved SecretRefs on runtime resolution", () => { + it("resolves env SecretRefs on runtime resolution", () => { const cfg = makeQqbotSecretRefConfig(); + const previous = process.env.QQBOT_CLIENT_SECRET; + + process.env.QQBOT_CLIENT_SECRET = "resolved-secret"; + try { + const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID); + + expect(resolved.clientSecret).toBe("resolved-secret"); + expect(resolved.secretSource).toBe("config"); + } finally { + if (previous === undefined) { + delete process.env.QQBOT_CLIENT_SECRET; + } else { + process.env.QQBOT_CLIENT_SECRET = previous; + } + } + }); + + it("rejects unresolved non-env SecretRefs on runtime resolution", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + clientSecret: { + source: "file", + provider: "default", + id: "/qqbot/clientSecret", + }, + }, + }, + } as OpenClawConfig; expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow( - 'channels.qqbot.clientSecret: unresolved SecretRef "env:default:QQBOT_CLIENT_SECRET"', + 'channels.qqbot.clientSecret: unresolved SecretRef "file:default:/qqbot/clientSecret"', ); }); diff --git a/extensions/qqbot/src/secret-contract.ts b/extensions/qqbot/src/secret-contract.ts new file mode 100644 index 00000000000..0aba4c60812 --- /dev/null +++ b/extensions/qqbot/src/secret-contract.ts @@ -0,0 +1,82 @@ +import { + collectConditionalChannelFieldAssignments, + getChannelSurface, + hasConfiguredSecretInputValue, + normalizeSecretStringValue, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/channel-secret-basic-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.qqbot.accounts.*.clientSecret", + targetType: "channels.qqbot.accounts.*.clientSecret", + configFile: "openclaw.json", + pathPattern: "channels.qqbot.accounts.*.clientSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.qqbot.clientSecret", + targetType: "channels.qqbot.clientSecret", + configFile: "openclaw.json", + pathPattern: "channels.qqbot.clientSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +function hasClientSecretFile(value: unknown): boolean { + return normalizeSecretStringValue(value).length > 0; +} + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults?: SecretDefaults; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "qqbot"); + if (!resolved) { + return; + } + + const { channel: qqbot, surface } = resolved; + const baseClientSecretFile = hasClientSecretFile(qqbot.clientSecretFile); + const accountClientSecretFile = (account: Record) => + hasClientSecretFile(account.clientSecretFile); + + collectConditionalChannelFieldAssignments({ + channelKey: "qqbot", + field: "clientSecret", + channel: qqbot, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: !baseClientSecretFile, + topLevelInheritedAccountActive: ({ account, enabled }) => { + if (!enabled || baseClientSecretFile) { + return false; + } + return ( + !hasConfiguredSecretInputValue(account.clientSecret, params.defaults) && + !accountClientSecretFile(account) + ); + }, + accountActive: ({ account, enabled }) => enabled && !accountClientSecretFile(account), + topInactiveReason: + "no enabled QQBot surface inherits this top-level clientSecret (clientSecretFile is configured).", + accountInactiveReason: "QQBot account is disabled or clientSecretFile is configured.", + }); +} + +export const channelSecrets = { + secretTargetRegistryEntries, + collectRuntimeConfigAssignments, +};