mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
feat(qqbot): resolve clientSecret SecretRefs and add secret contract
This commit is contained in:
committed by
Peter Steinberger
parent
153e4c59cf
commit
40f970a13d
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
extensions/qqbot/secret-contract-api.ts
Normal file
5
extensions/qqbot/secret-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -6,4 +6,8 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "qqbotSetupPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) ?? "");
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
82
extensions/qqbot/src/secret-contract.ts
Normal file
82
extensions/qqbot/src/secret-contract.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user