From 10e60fa0ce64328a31a4e7dc36ec62ec887e73a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Aug 2026 13:55:31 -0700 Subject: [PATCH] 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 --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- .../active-memory/doctor-contract-api.ts | 127 ++++++------------ extensions/buzz/src/secret-contract.ts | 44 +----- extensions/device-pair/doctor-contract-api.ts | 81 +++-------- extensions/discord/src/account-inspect.ts | 83 +++--------- .../discord/src/account-token-inspect.ts | 58 +++++++- .../discord/src/setup-account-state.test.ts | 20 ++- extensions/discord/src/setup-account-state.ts | 67 ++------- extensions/mattermost/src/secret-contract.ts | 43 +----- extensions/msteams/src/secret-contract.ts | 50 +------ .../nextcloud-talk/src/secret-contract.ts | 69 ++-------- scripts/plugin-sdk-surface-report.mjs | 6 +- .../channel-secret-basic-runtime.ts | 1 + src/plugin-sdk/runtime-doctor.ts | 105 +++++++++++++++ .../contracts/plugin-sdk-subpaths.test.ts | 1 + src/secrets/channel-secret-basic-runtime.ts | 95 +++++++++++++ 16 files changed, 404 insertions(+), 448 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 260a3ce16dc4..03352c38294b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/active-memory/doctor-contract-api.ts b/extensions/active-memory/doctor-contract-api.ts index b2949fa5a22b..e259758c9076 100644 --- a/extensions/active-memory/doctor-contract-api.ts +++ b/extensions/active-memory/doctor-contract-api.ts @@ -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 { - 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({ 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({ - 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, + })), + }), ]; diff --git a/extensions/buzz/src/secret-contract.ts b/extensions/buzz/src/secret-contract.ts index 18cdaf11e8e9..a6a99ec19758 100644 --- a/extensions/buzz/src/secret-contract.ts +++ b/extensions/buzz/src/secret-contract.ts @@ -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 }; - 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; diff --git a/extensions/device-pair/doctor-contract-api.ts b/extensions/device-pair/doctor-contract-api.ts index 06cbe65eb67d..072d1d16e8d1 100644 --- a/extensions/device-pair/doctor-contract-api.ts +++ b/extensions/device-pair/doctor-contract-api.ts @@ -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 { - try { - return normalizeLegacyNotifyState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown); - } catch { - return null; - } -} - export const stateMigrations: PluginDoctorStateMigration[] = [ - { + defineLegacyJsonStateMigration({ 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({ - 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, + })), + }), ]; diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 31065c011fb3..4a935a4156f9 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -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, "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), + }; + }, + }); } diff --git a/extensions/discord/src/account-token-inspect.ts b/extensions/discord/src/account-token-inspect.ts index 9e3d6859d6b6..bab0eefb3cac 100644 --- a/extensions/discord/src/account-token-inspect.ts +++ b/extensions/discord/src/account-token-inspect.ts @@ -11,9 +11,14 @@ type InspectedDiscordConfiguredToken = { tokenStatus: Exclude; }; -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(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, + }; +} diff --git a/extensions/discord/src/setup-account-state.test.ts b/extensions/discord/src/setup-account-state.test.ts index 74cf8dc7f640..ed8d9388e88d 100644 --- a/extensions/discord/src/setup-account-state.test.ts +++ b/extensions/discord/src/setup-account-state.test.ts @@ -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 }); + }); }); diff --git a/extensions/discord/src/setup-account-state.ts b/extensions/discord/src/setup-account-state.ts index 4e4fc3f0e017..b3da6b2895be 100644 --- a/extensions/discord/src/setup-account-state.ts +++ b/extensions/discord/src/setup-account-state.ts @@ -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, "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 }), + }); } diff --git a/extensions/mattermost/src/secret-contract.ts b/extensions/mattermost/src/secret-contract.ts index 28bccd4b40d2..13ef8b8c0304 100644 --- a/extensions/mattermost/src/secret-contract.ts +++ b/extensions/mattermost/src/secret-contract.ts @@ -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 }; - 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; diff --git a/extensions/msteams/src/secret-contract.ts b/extensions/msteams/src/secret-contract.ts index ec95ad029869..3be886bf5737 100644 --- a/extensions/msteams/src/secret-contract.ts +++ b/extensions/msteams/src/secret-contract.ts @@ -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 }; - 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; diff --git a/extensions/nextcloud-talk/src/secret-contract.ts b/extensions/nextcloud-talk/src/secret-contract.ts index d3b9aa304c08..162f22cc133e 100644 --- a/extensions/nextcloud-talk/src/secret-contract.ts +++ b/extensions/nextcloud-talk/src/secret-contract.ts @@ -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 }; - 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; diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 606dbc2166f5..2f0532310dd4 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -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( diff --git a/src/plugin-sdk/channel-secret-basic-runtime.ts b/src/plugin-sdk/channel-secret-basic-runtime.ts index 2587f3c3b5c0..086ff3e5079b 100644 --- a/src/plugin-sdk/channel-secret-basic-runtime.ts +++ b/src/plugin-sdk/channel-secret-basic-runtime.ts @@ -5,6 +5,7 @@ export { collectNestedChannelFieldAssignments, collectSimpleChannelFieldAssignments, createChannelSecretTargetRegistryEntries, + createSimpleChannelSecretContract, getChannelRecord, getChannelSurface, hasConfiguredSecretInputValue, diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index adaf907be053..987567ef7b78 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -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(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 => { + 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({ + 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 }; + }, + }; +} diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 816bb26db3b2..453f0b8568bc 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -672,6 +672,7 @@ describe("plugin-sdk subpath exports", () => { expectSourceMentions("channel-actions", ["optionalStringEnum", "stringEnum"]); expectSourceContract("channel-secret-basic-runtime", { mentions: [ + "createSimpleChannelSecretContract", "collectSimpleChannelFieldAssignments", "collectConditionalChannelFieldAssignments", "collectSecretInputAssignment", diff --git a/src/secrets/channel-secret-basic-runtime.ts b/src/secrets/channel-secret-basic-runtime.ts index b517d84265cb..28b0a65b7fe8 100644 --- a/src/secrets/channel-secret-basic-runtime.ts +++ b/src/secrets/channel-secret-basic-runtime.ts @@ -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 }; + 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 }; + 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;