feat(qqbot): resolve clientSecret SecretRefs and add secret contract

This commit is contained in:
xialonglee
2026-04-30 10:58:01 +08:00
committed by Peter Steinberger
parent 153e4c59cf
commit 40f970a13d
9 changed files with 211 additions and 3 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -6,4 +6,8 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "qqbotSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View File

@@ -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<string, unknown>);
@@ -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,
}) ?? "");

View File

@@ -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"',
);
});

View File

@@ -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<string, unknown> };
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<string, unknown>) =>
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,
};