From 40f970a13dcfbc306f47d421f50770741dc67505 Mon Sep 17 00:00:00 2001
From: xialonglee
Date: Thu, 30 Apr 2026 10:58:01 +0800
Subject: [PATCH] feat(qqbot): resolve clientSecret SecretRefs and add secret
contract
---
docs/channels/qqbot.md | 14 ++++
.../reference/secretref-credential-surface.md | 2 +
...tref-user-supplied-credentials-matrix.json | 14 ++++
extensions/qqbot/index.ts | 4 +
extensions/qqbot/secret-contract-api.ts | 5 ++
extensions/qqbot/setup-entry.ts | 4 +
extensions/qqbot/src/bridge/config.ts | 55 ++++++++++++-
extensions/qqbot/src/config.test.ts | 34 +++++++-
extensions/qqbot/src/secret-contract.ts | 82 +++++++++++++++++++
9 files changed, 211 insertions(+), 3 deletions(-)
create mode 100644 extensions/qqbot/secret-contract-api.ts
create mode 100644 extensions/qqbot/src/secret-contract.ts
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,
+};