refactor(plugins): shared legacy-state doctor migration and simple secret contracts (#120346)

* refactor(plugins): share legacy JSON doctor migration

* refactor(discord): share account token inspection cascade

* refactor(plugins): share simple channel secret contracts

* refactor(discord): keep token inspector private
This commit is contained in:
Peter Steinberger
2026-08-07 13:55:31 -07:00
committed by GitHub
parent 3a0216ce29
commit 10e60fa0ce
16 changed files with 404 additions and 448 deletions
@@ -41,7 +41,7 @@ ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel
94ef57c8f6087fcaa56e59e493c391a04377ed03f23c681edd8d8f6e2d64e0da module/channel-policy
cc0a77137b304b27a313791aa30e43efb4acc254da7590bd47493d81e7104fba module/channel-reply-pipeline
bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel-runtime-context
17cec26bc71fc43a066049ef63f95bf29737113c26ab13689ceff602b9aa11d6 module/channel-secret-basic-runtime
f3cee48527f5cddde81912a588e72947d40443322b716871a8d9e5dc0c37c542 module/channel-secret-basic-runtime
0ceb4378709eb2d92a62a275f87fa04e18f77df8f942a9a0acef81019ebc1e24 module/channel-secret-runtime
7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result
fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup
+43 -84
View File
@@ -3,10 +3,9 @@
* toggle JSON into the plugin state keyed store used by current runtimes.
*/
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
archiveLegacyStateSource,
defineLegacyJsonStateMigration,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
@@ -32,94 +31,54 @@ function normalizeLegacyUpdatedAt(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : Date.now();
}
async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryToggleEntry[]> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object") {
return [];
}
const sessions = (parsed as { sessions?: unknown }).sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return [];
}
const entries: ActiveMemoryToggleEntry[] = [];
for (const [sessionKey, value] of Object.entries(sessions)) {
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
if ((value as { disabled?: unknown }).disabled !== true) {
continue;
}
const updatedAt = normalizeLegacyUpdatedAt((value as { updatedAt?: unknown }).updatedAt);
entries.push({ sessionKey, disabled: true, updatedAt });
}
return entries;
} catch {
return [];
function parseLegacyToggleEntries(parsed: unknown): ActiveMemoryToggleEntry[] | null {
if (!parsed || typeof parsed !== "object") {
return null;
}
const sessions = (parsed as { sessions?: unknown }).sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return null;
}
const entries: ActiveMemoryToggleEntry[] = [];
for (const [sessionKey, value] of Object.entries(sessions)) {
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
if ((value as { disabled?: unknown }).disabled !== true) {
continue;
}
const updatedAt = normalizeLegacyUpdatedAt((value as { updatedAt?: unknown }).updatedAt);
entries.push({ sessionKey, disabled: true, updatedAt });
}
return entries;
}
/** State migrations exposed to OpenClaw doctor for Active Memory. */
export const stateMigrations: PluginDoctorStateMigration[] = [
{
defineLegacyJsonStateMigration<ActiveMemoryToggleEntry[]>({
id: "active-memory-session-toggles-json-to-plugin-state",
label: "Active Memory session toggles",
async detectLegacyState(params) {
const filePath = resolveToggleStatePath(params.stateDir);
const entries = await readLegacyToggleEntries(filePath);
if (entries.length === 0) {
return null;
}
return {
preview: [
`- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`,
],
};
resolvePath: resolveToggleStatePath,
parse: parseLegacyToggleEntries,
namespace: SESSION_TOGGLES_NAMESPACE,
maxEntries: MAX_TOGGLE_ENTRIES,
capacityPrecheck: {
warning: ({ available, missing }) =>
`Skipped Active Memory session toggle migration because plugin state has room for ${available} of ${missing} missing entries; left legacy source in place`,
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveToggleStatePath(params.stateDir);
const entries = await readLegacyToggleEntries(filePath);
if (entries.length === 0) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<ActiveMemoryToggleEntry>({
namespace: SESSION_TOGGLES_NAMESPACE,
maxEntries: MAX_TOGGLE_ENTRIES,
});
const existingKeys = new Set((await store.entries()).map((entry) => entry.key));
const missingEntries = entries.filter(
(entry) => !existingKeys.has(activeMemoryToggleKey(entry.sessionKey)),
);
if (missingEntries.length > MAX_TOGGLE_ENTRIES - existingKeys.size) {
warnings.push(
`Skipped Active Memory session toggle migration because plugin state has room for ${MAX_TOGGLE_ENTRIES - existingKeys.size} of ${missingEntries.length} missing entries; left legacy source in place`,
);
return { changes, warnings };
}
let imported = 0;
for (const entry of entries) {
const key = activeMemoryToggleKey(entry.sessionKey);
if (existingKeys.has(key)) {
continue;
}
await store.register(key, entry);
existingKeys.add(key);
imported++;
}
if (imported > 0) {
changes.push(
`Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
await archiveLegacyStateSource({
filePath,
label: "Active Memory session toggles",
changes,
warnings,
});
return { changes, warnings };
},
},
describeEntries: (entries) => ({
preview: [
`- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`,
],
change: ({ imported }) =>
imported > 0
? `Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`
: null,
}),
toRows: (entries) =>
entries.map((entry) => ({
key: activeMemoryToggleKey(entry.sessionKey),
value: entry,
})),
}),
];
+7 -37
View File
@@ -1,41 +1,11 @@
import {
collectSimpleChannelFieldAssignments,
createChannelSecretTargetRegistryEntries,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import { createSimpleChannelSecretContract } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
export const channelSecrets = createSimpleChannelSecretContract({
channelKey: "buzz",
channel: ["privateKey", "authTag"],
label: "Buzz",
accountFields: [],
channelFields: ["privateKey", "authTag"],
mode: "channel-surface",
});
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "buzz");
if (!resolved) {
return;
}
const { channel, surface } = resolved;
for (const field of ["privateKey", "authTag"]) {
collectSimpleChannelFieldAssignments({
channelKey: "buzz",
field,
channel,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "Buzz channel is disabled.",
accountInactiveReason: "Buzz channel is disabled.",
});
}
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};
export const { secretTargetRegistryEntries, collectRuntimeConfigAssignments } = channelSecrets;
+19 -62
View File
@@ -1,8 +1,7 @@
// Device Pair doctor contract migrates shipped plugin-owned state.
import fs from "node:fs/promises";
import path from "node:path";
import {
archiveLegacyStateSource,
defineLegacyJsonStateMigration,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
@@ -12,74 +11,32 @@ import {
normalizeLegacyNotifyState,
notifySubscriberStoreKey,
type LegacyNotifyStateFile,
type NotifySubscription,
} from "./notify-state.js";
function resolveLegacyNotifyStatePath(stateDir: string): string {
return path.join(stateDir, DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE);
}
async function readLegacyNotifyState(filePath: string): Promise<LegacyNotifyStateFile | null> {
try {
return normalizeLegacyNotifyState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
} catch {
return null;
}
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
defineLegacyJsonStateMigration<LegacyNotifyStateFile>({
id: "device-pair-notify-json-to-plugin-state",
label: "Device Pair notify subscribers",
async detectLegacyState(params) {
const filePath = resolveLegacyNotifyStatePath(params.stateDir);
const state = await readLegacyNotifyState(filePath);
if (!state || state.subscribers.length === 0) {
return null;
}
return {
preview: [
`- Device Pair notify subscribers: ${filePath} -> plugin state (${DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE}, ${state.subscribers.length} subscriber(s))`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveLegacyNotifyStatePath(params.stateDir);
const state = await readLegacyNotifyState(filePath);
if (!state || state.subscribers.length === 0) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<NotifySubscription>({
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
});
let imported = 0;
let alreadyPresent = 0;
for (const subscriber of state.subscribers) {
const inserted = await store.registerIfAbsent(
notifySubscriberStoreKey(subscriber),
subscriber,
);
if (inserted) {
imported++;
} else {
alreadyPresent++;
}
}
changes.push(
resolvePath: resolveLegacyNotifyStatePath,
parse: normalizeLegacyNotifyState,
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
archiveLabel: "Device Pair notify-state",
describeEntries: (state, { filePath }) => ({
preview: [
`- Device Pair notify subscribers: ${filePath} -> plugin state (${DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE}, ${state.subscribers.length} subscriber(s))`,
],
change: ({ imported, alreadyPresent }) =>
`Migrated Device Pair notify subscribers -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
);
await archiveLegacyStateSource({
filePath,
label: "Device Pair notify-state",
changes,
warnings,
});
return { changes, warnings };
},
},
}),
toRows: (state) =>
state.subscribers.map((subscriber) => ({
key: notifySubscriberStoreKey(subscriber),
value: subscriber,
})),
}),
];
+19 -64
View File
@@ -2,7 +2,7 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { inspectDiscordConfiguredToken } from "./account-token-inspect.js";
import { inspectDiscordAccountTokenState } from "./account-token-inspect.js";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
@@ -36,71 +36,26 @@ export function inspectDiscordAccount(params: {
const hasAccountToken = Boolean(
accountConfig && Object.hasOwn(accountConfig as Record<string, unknown>, "token"),
);
const accountToken = inspectDiscordConfiguredToken(accountConfig?.token);
if (accountToken) {
return {
return inspectDiscordAccountTokenState({
base: {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: true,
config: merged,
};
}
if (hasAccountToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}
const channelToken = inspectDiscordConfiguredToken(params.cfg.channels?.discord?.token);
if (channelToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: true,
config: merged,
};
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN)
: undefined;
if (envToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: envToken.replace(/^Bot\s+/i, ""),
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
},
config: merged,
};
accountToken: accountConfig?.token,
hasAccountToken,
channelToken: params.cfg.channels?.discord?.token,
// Known divergence: doctor inspection must use its injected environment snapshot.
resolveFallbackToken: () => {
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN)
: undefined;
return {
token: envToken?.replace(/^Bot\s+/i, "") ?? "",
source: envToken ? ("env" as const) : ("none" as const),
};
},
});
}
@@ -11,9 +11,14 @@ type InspectedDiscordConfiguredToken = {
tokenStatus: Exclude<DiscordCredentialStatus, "missing">;
};
export function inspectDiscordConfiguredToken(
value: unknown,
): InspectedDiscordConfiguredToken | null {
type DiscordAccountTokenState = {
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: DiscordCredentialStatus;
configured: boolean;
};
function inspectDiscordConfiguredToken(value: unknown): InspectedDiscordConfiguredToken | null {
const normalized = normalizeSecretInputString(value);
if (normalized) {
return {
@@ -31,3 +36,50 @@ export function inspectDiscordConfiguredToken(
}
return null;
}
export function inspectDiscordAccountTokenState<TBase extends object, TConfig>(params: {
base: TBase;
config: TConfig;
accountToken: unknown;
hasAccountToken: boolean;
channelToken: unknown;
resolveFallbackToken: () => { token: string; source: "env" | "config" | "none" };
}): TBase & DiscordAccountTokenState & { config: TConfig } {
const accountToken = inspectDiscordConfiguredToken(params.accountToken);
if (accountToken) {
return { ...params.base, ...accountToken, configured: true, config: params.config };
}
if (params.hasAccountToken) {
return {
...params.base,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: params.config,
};
}
const channelToken = inspectDiscordConfiguredToken(params.channelToken);
if (channelToken) {
return { ...params.base, ...channelToken, configured: true, config: params.config };
}
const fallback = params.resolveFallbackToken();
if (fallback.token) {
return {
...params.base,
token: fallback.token,
tokenSource: fallback.source,
tokenStatus: "available",
configured: true,
config: params.config,
};
}
return {
...params.base,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: params.config,
};
}
@@ -1,5 +1,5 @@
// Discord tests cover setup account state plugin behavior.
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
inspectDiscordSetupAccount,
resolveDefaultDiscordSetupAccountId,
@@ -7,6 +7,8 @@ import {
} from "./setup-account-state.js";
describe("discord setup account state", () => {
afterEach(() => vi.unstubAllEnvs());
it("resolves setup account config when account key casing differs from normalized id", () => {
const resolved = resolveDiscordSetupAccountConfig({
cfg: {
@@ -95,4 +97,20 @@ describe("discord setup account state", () => {
expect(inspected.tokenStatus).toBe("configured_unavailable");
expect(inspected.configured).toBe(true);
});
it("keeps the runtime resolver's default-account-only environment fallback", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "Bot setup-token");
expect(inspectDiscordSetupAccount({ cfg: {}, accountId: "default" })).toMatchObject({
token: "setup-token",
tokenSource: "env",
configured: true,
});
expect(
inspectDiscordSetupAccount({
cfg: { channels: { discord: { accounts: { work: {} } } } },
accountId: "work",
}),
).toMatchObject({ token: "", tokenSource: "none", configured: false });
});
});
+10 -57
View File
@@ -1,7 +1,7 @@
// Discord plugin module implements setup account state behavior.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { inspectDiscordConfiguredToken } from "./account-token-inspect.js";
import { inspectDiscordAccountTokenState } from "./account-token-inspect.js";
import { resolveDefaultDiscordAccountId } from "./accounts.js";
import { mergeDiscordAccountConfig, resolveDiscordAccountConfig } from "./accounts.js";
import type { DiscordAccountConfig } from "./runtime-api.js";
@@ -44,63 +44,16 @@ export function inspectDiscordSetupAccount(params: {
const hasAccountToken = Boolean(
accountConfig && Object.hasOwn(accountConfig as Record<string, unknown>, "token"),
);
const accountToken = inspectDiscordConfiguredToken(accountConfig?.token);
if (accountToken) {
return {
return inspectDiscordAccountTokenState({
base: {
accountId,
enabled,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: true,
config,
};
}
if (hasAccountToken) {
return {
accountId,
enabled,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config,
};
}
const channelToken = inspectDiscordConfiguredToken(params.cfg.channels?.discord?.token);
if (channelToken) {
return {
accountId,
enabled,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: true,
config,
};
}
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
if (tokenResolution.token) {
return {
accountId,
enabled,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
tokenStatus: "available",
configured: true,
config,
};
}
return {
accountId,
enabled,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
},
config,
};
accountToken: accountConfig?.token,
hasAccountToken,
channelToken: params.cfg.channels?.discord?.token,
// Known divergence: setup keeps the runtime-aware resolver for its final branch.
resolveFallbackToken: () => resolveDiscordToken(params.cfg, { accountId }),
});
}
+7 -36
View File
@@ -1,41 +1,12 @@
// Mattermost plugin module implements secret contract behavior.
import {
collectSimpleChannelFieldAssignments,
createChannelSecretTargetRegistryEntries,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import { createSimpleChannelSecretContract } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
export const channelSecrets = createSimpleChannelSecretContract({
channelKey: "mattermost",
account: ["botToken"],
channel: ["botToken"],
label: "Mattermost",
accountFields: ["botToken"],
channelFields: ["botToken"],
mode: "account-inheritance",
});
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "mattermost");
if (!resolved) {
return;
}
const { channel: mattermost, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "mattermost",
field: "botToken",
channel: mattermost,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.",
accountInactiveReason: "Mattermost account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};
export const { secretTargetRegistryEntries, collectRuntimeConfigAssignments } = channelSecrets;
+7 -43
View File
@@ -1,48 +1,12 @@
// Msteams plugin module implements secret contract behavior.
import {
collectSecretInputAssignment,
createChannelSecretTargetRegistryEntries,
getChannelRecord,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import { createSimpleChannelSecretContract } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
export const channelSecrets = createSimpleChannelSecretContract({
channelKey: "msteams",
channel: ["appPassword"],
label: "Microsoft Teams",
accountFields: [],
channelFields: ["appPassword"],
mode: "channel-only",
});
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const msteams = getChannelRecord(params.config, "msteams");
if (!msteams) {
return;
}
collectSecretInputAssignment({
value: msteams.appPassword,
path: "channels.msteams.appPassword",
expected: "string",
defaults: params.defaults,
context: params.context,
active: msteams.enabled !== false,
inactiveReason: "Microsoft Teams channel is disabled.",
owner: {
ownerKind: "account",
ownerId: "msteams:default",
requiredForGateway: false,
disposition: "isolate",
contract: msteams,
},
apply: (value) => {
msteams.appPassword = value;
},
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};
export const { secretTargetRegistryEntries, collectRuntimeConfigAssignments } = channelSecrets;
@@ -1,63 +1,16 @@
// Nextcloud Talk plugin module implements secret contract behavior.
import {
collectConditionalChannelFieldAssignments,
createChannelSecretTargetRegistryEntries,
getChannelSurface,
hasOwnProperty,
type ChannelAccountEntry,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import { createSimpleChannelSecretContract } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
export const channelSecrets = createSimpleChannelSecretContract({
channelKey: "nextcloud-talk",
account: ["apiPassword", "botSecret"],
channel: ["apiPassword", "botSecret"],
label: "Nextcloud Talk",
accountFields: ["apiPassword", "botSecret"],
channelFields: ["apiPassword", "botSecret"],
mode: {
kind: "surface-inheritance",
// Runtime collection historically reports botSecret before apiPassword.
collectionFields: ["botSecret", "apiPassword"],
},
});
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "nextcloud-talk");
if (!resolved) {
return;
}
const { channel: nextcloudTalk, surface } = resolved;
const inheritsField =
(field: string) =>
({ account, enabled }: ChannelAccountEntry) =>
enabled && !hasOwnProperty(account, field);
collectConditionalChannelFieldAssignments({
channelKey: "nextcloud-talk",
field: "botSecret",
channel: nextcloudTalk,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: inheritsField("botSecret"),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level botSecret.",
accountInactiveReason: "Nextcloud Talk account is disabled.",
});
collectConditionalChannelFieldAssignments({
channelKey: "nextcloud-talk",
field: "apiPassword",
channel: nextcloudTalk,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: inheritsField("apiPassword"),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level apiPassword.",
accountInactiveReason: "Nextcloud Talk account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};
export const { secretTargetRegistryEntries, collectRuntimeConfigAssignments } = channelSecrets;
+4 -2
View File
@@ -222,7 +222,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: auth-profile preservation decision for native model pickers.
// +2: shared channel question-reaction store and preflight-audio factories.
// +1: shared channel interactive dispatcher with canonical binding authorization.
4834,
// +1: simple channel secret contract factory replacing repeated collectors.
4835,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -272,7 +273,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: auth-profile preservation decision for native model pickers.
// +2: shared channel question-reaction store and preflight-audio factories.
// +1: shared channel interactive dispatcher with canonical binding authorization.
2911,
// +1: simple channel secret contract factory replacing repeated collectors.
2912,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -5,6 +5,7 @@ export {
collectNestedChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
createChannelSecretTargetRegistryEntries,
createSimpleChannelSecretContract,
getChannelRecord,
getChannelSurface,
hasConfiguredSecretInputValue,
+105
View File
@@ -1,9 +1,13 @@
/**
* Runtime SDK subpath for plugin doctor migrations, compat checks, and uninstall helpers.
*/
import fs from "node:fs/promises";
import { asObjectRecord } from "../config/channel-compat-normalization.js";
import type { CompatMutationResult } from "../config/channel-compat-normalization.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { OpenKeyedStoreOptions } from "../plugin-state/plugin-state-store.js";
import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js";
import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js";
export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
export { defineChannelAliasMigration } from "../config/channel-alias-migration.js";
@@ -293,3 +297,104 @@ export function defineKeyMoveMigration(params: {
normalizeScopes(entry, params.scope ?? [], pathPrefix, changes),
};
}
/** Defines a single-file legacy JSON import into one keyed plugin-state namespace. */
export function defineLegacyJsonStateMigration<TSource>(params: {
id: string;
label: string;
resolvePath: (stateDir: string) => string;
parse: (value: unknown) => TSource | null;
namespace: string;
maxEntries: number;
overflowPolicy?: OpenKeyedStoreOptions["overflowPolicy"];
archiveLabel?: string;
capacityPrecheck?: {
warning: (stats: { available: number; missing: number }) => string;
};
describeEntries: (
source: TSource,
context: { filePath: string; namespace: string },
) => {
preview: string[];
change: (stats: { imported: number; alreadyPresent: number }) => string | null;
};
toRows: (source: TSource) => readonly { key: string; value: unknown }[];
}): PluginDoctorStateMigration {
const readSource = async (filePath: string): Promise<TSource | null> => {
try {
return params.parse(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
} catch {
return null;
}
};
const describe = (source: TSource, filePath: string) =>
params.describeEntries(source, { filePath, namespace: params.namespace });
return {
id: params.id,
label: params.label,
async detectLegacyState({ stateDir }) {
const filePath = params.resolvePath(stateDir);
const source = await readSource(filePath);
if (!source) {
return null;
}
const rows = params.toRows(source);
if (rows.length === 0) {
return null;
}
const description = describe(source, filePath);
return { preview: description.preview };
},
async migrateLegacyState({ stateDir, context }) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = params.resolvePath(stateDir);
const source = await readSource(filePath);
if (!source) {
return { changes, warnings };
}
const rows = params.toRows(source);
if (rows.length === 0) {
return { changes, warnings };
}
const description = describe(source, filePath);
const store = context.openPluginStateKeyedStore<unknown>({
namespace: params.namespace,
maxEntries: params.maxEntries,
...(params.overflowPolicy ? { overflowPolicy: params.overflowPolicy } : {}),
});
if (params.capacityPrecheck) {
const existingKeys = new Set((await store.entries()).map((entry) => entry.key));
const missingKeys = new Set(
rows.map((row) => row.key).filter((key) => !existingKeys.has(key)),
);
const available = params.maxEntries - existingKeys.size;
if (missingKeys.size > available) {
warnings.push(params.capacityPrecheck.warning({ available, missing: missingKeys.size }));
return { changes, warnings };
}
}
let imported = 0;
for (const row of rows) {
if (await store.registerIfAbsent(row.key, row.value)) {
imported++;
}
}
const change = description.change({
imported,
alreadyPresent: rows.length - imported,
});
if (change) {
changes.push(change);
}
await archiveLegacyStateSource({
filePath,
label: params.archiveLabel ?? params.label,
changes,
warnings,
});
return { changes, warnings };
},
};
}
@@ -672,6 +672,7 @@ describe("plugin-sdk subpath exports", () => {
expectSourceMentions("channel-actions", ["optionalStringEnum", "stringEnum"]);
expectSourceContract("channel-secret-basic-runtime", {
mentions: [
"createSimpleChannelSecretContract",
"collectSimpleChannelFieldAssignments",
"collectConditionalChannelFieldAssignments",
"collectSecretInputAssignment",
@@ -80,6 +80,101 @@ export function createChannelSecretTargetRegistryEntries(params: {
];
}
/** Builds the common registry and runtime collector used by simple channel secrets. */
export function createSimpleChannelSecretContract(params: {
channelKey: string;
label: string;
accountFields: readonly string[];
channelFields: readonly string[];
mode:
| "account-inheritance"
| "channel-surface"
| "channel-only"
| { kind: "surface-inheritance"; collectionFields: readonly string[] };
}): {
secretTargetRegistryEntries: SecretTargetRegistryEntry[];
collectRuntimeConfigAssignments: (params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}) => void;
} {
const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
channelKey: params.channelKey,
account: params.accountFields,
channel: params.channelFields,
});
const collectionFields =
typeof params.mode === "object"
? params.mode.collectionFields
: [...new Set([...params.accountFields, ...params.channelFields])];
const collectRuntimeConfigAssignments = (collectorParams: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void => {
if (params.mode === "channel-only") {
const channel = getChannelRecord(collectorParams.config, params.channelKey);
if (!channel) {
return;
}
for (const field of collectionFields) {
collectSecretInputAssignment({
value: channel[field],
path: `channels.${params.channelKey}.${field}`,
expected: "string",
defaults: collectorParams.defaults,
context: collectorParams.context,
active: channel.enabled !== false,
inactiveReason: `${params.label} channel is disabled.`,
// Direct-record channels bind the full config as one atomic owner contract.
owner: {
ownerKind: "account",
ownerId: `${params.channelKey}:default`,
requiredForGateway: false,
disposition: "isolate",
contract: channel,
},
apply: (value) => {
channel[field] = value;
},
});
}
return;
}
const resolved = getChannelSurface(collectorParams.config, params.channelKey);
if (!resolved) {
return;
}
const { channel, surface } = resolved;
for (const field of collectionFields) {
const topInactiveReason =
params.mode === "channel-surface"
? `${params.label} channel is disabled.`
: params.mode === "account-inheritance"
? `no enabled account inherits this top-level ${params.label} ${field}.`
: `no enabled ${params.label} surface inherits this top-level ${field}.`;
collectSimpleChannelFieldAssignments({
channelKey: params.channelKey,
field,
channel,
surface,
defaults: collectorParams.defaults,
context: collectorParams.context,
topInactiveReason,
accountInactiveReason:
params.mode === "channel-surface"
? `${params.label} channel is disabled.`
: `${params.label} account is disabled.`,
});
}
};
return { secretTargetRegistryEntries, collectRuntimeConfigAssignments };
}
export type ChannelAccountEntry = {
accountId: string;
account: Record<string, unknown>;