mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
3a0216ce29
commit
10e60fa0ce
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user