diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index e27b49aa8160..ef930d27c6fa 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -14,6 +14,7 @@ import type { OpenClawPluginToolContext, OpenClawPluginToolFactory, } from "openclaw/plugin-sdk/plugin-entry"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { isBrowserMachineOutput } from "./cli-output-mode.js"; import { BROWSER_REQUEST_GATEWAY_METHOD, @@ -34,10 +35,6 @@ const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule( () => import("./register.runtime.js"), ); -function isTruthyEnvValue(value: string | undefined): boolean { - return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? ""); -} - function deriveChatTypeFromSessionKey( sessionKey: string | undefined, ): "direct" | "group" | "channel" | undefined { diff --git a/extensions/browser/src/browser-tool-binding.ts b/extensions/browser/src/browser-tool-binding.ts index a9cf73d45d7f..4c79f889a4c3 100644 --- a/extensions/browser/src/browser-tool-binding.ts +++ b/extensions/browser/src/browser-tool-binding.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; + type BrowserTabToolBinding = { kind: "tab"; tabId: number; @@ -9,10 +11,6 @@ type BrowserTabToolBinding = { type BindingResult = { ok: true; binding: BrowserTabToolBinding } | { ok: false; error: string }; -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - /** Validate the plugin-owned run binding before any browser route is resolved. */ export function parseBrowserTabToolBinding(value: unknown): BindingResult { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -20,9 +18,9 @@ export function parseBrowserTabToolBinding(value: unknown): BindingResult { } const record = value as Record; const target = record.target === "host" || record.target === "node" ? record.target : undefined; - const node = nonEmptyString(record.node); - const profile = nonEmptyString(record.profile); - const targetId = nonEmptyString(record.targetId); + const node = normalizeOptionalString(record.node); + const profile = normalizeOptionalString(record.profile); + const targetId = normalizeOptionalString(record.targetId); if (record.kind !== "tab") { return { ok: false, error: 'browser tool binding kind must be "tab"' }; } @@ -65,7 +63,7 @@ const TAB_BOUND_ACTIONS = new Set([ ]); function bindTargetId(record: Record, targetId: string): Record { - const requestedTargetId = nonEmptyString(record.targetId); + const requestedTargetId = normalizeOptionalString(record.targetId); if (requestedTargetId && requestedTargetId !== targetId) { throw new Error("browser action cannot override its run-bound tab target"); } @@ -84,13 +82,13 @@ export function applyBrowserTabToolBinding( input: Record, binding: BrowserTabToolBinding, ): Record { - const action = nonEmptyString(input.action); + const action = normalizeOptionalString(input.action); if (!action || !TAB_BOUND_ACTIONS.has(action)) { throw new Error(`browser action ${JSON.stringify(action)} is unavailable in a tab-bound run`); } - const requestedTarget = nonEmptyString(input.target); - const requestedNode = nonEmptyString(input.node); - const requestedProfile = nonEmptyString(input.profile); + const requestedTarget = normalizeOptionalString(input.target); + const requestedNode = normalizeOptionalString(input.node); + const requestedProfile = normalizeOptionalString(input.profile); if (requestedTarget && requestedTarget !== binding.target) { throw new Error("browser action cannot override its run-bound target"); } diff --git a/extensions/browser/src/plugin-service.ts b/extensions/browser/src/plugin-service.ts index c306efddbe43..dd359ea4b965 100644 --- a/extensions/browser/src/plugin-service.ts +++ b/extensions/browser/src/plugin-service.ts @@ -1,6 +1,7 @@ /** * Browser plugin service factory that lazily starts the control server. */ +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { startLazyPluginServiceModule, type LazyPluginServiceHandle, @@ -11,10 +12,6 @@ type BrowserControlHandle = LazyPluginServiceHandle | null; const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER"; const UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER = /^(?:data|http|https|node):/i; -function isTruthyEnvValue(value: string | undefined): boolean { - return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? ""); -} - function validateBrowserControlOverrideSpecifier(specifier: string): string { const trimmed = specifier.trim(); if (UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER.test(trimmed)) { diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts index c49d02ab8394..4626c2e283d1 100644 --- a/extensions/discord/src/doctor.ts +++ b/extensions/discord/src/doctor.ts @@ -2,7 +2,11 @@ import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract" import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; // Discord plugin module implements doctor behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor"; +import { + asObjectRecord, + collectChannelAccountScopes, + collectProviderDangerousNameMatchingScopes, +} from "openclaw/plugin-sdk/runtime-doctor"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { inspectDiscordAccount } from "./account-inspect.js"; import { resolveDefaultDiscordAccountId } from "./accounts.js"; @@ -18,39 +22,10 @@ type DiscordIdListRef = { key: string; }; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function sanitizeForLog(value: string): string { return value.replace(/\p{Cc}+/gu, " ").trim(); } -function collectDiscordAccountScopes( - cfg: OpenClawConfig, -): Array<{ prefix: string; account: Record }> { - const scopes: Array<{ prefix: string; account: Record }> = []; - const discord = asObjectRecord(cfg.channels?.discord); - if (!discord) { - return scopes; - } - - scopes.push({ prefix: "channels.discord", account: discord }); - const accounts = asObjectRecord(discord.accounts); - if (!accounts) { - return scopes; - } - for (const key of Object.keys(accounts)) { - const account = asObjectRecord(accounts[key]); - if (account) { - scopes.push({ prefix: `channels.discord.accounts.${key}`, account }); - } - } - return scopes; -} - function collectDiscordIdLists( prefix: string, account: Record, @@ -124,7 +99,7 @@ export function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumeric } }; - for (const scope of collectDiscordAccountScopes(cfg)) { + for (const scope of collectChannelAccountScopes({ cfg, channelId: "discord" })) { for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { scanList(ref.pathLabel, ref.holder[ref.key]); } @@ -216,7 +191,7 @@ export function maybeRepairDiscordNumericIds( } }; - for (const scope of collectDiscordAccountScopes(next)) { + for (const scope of collectChannelAccountScopes({ cfg: next, channelId: "discord" })) { for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { repairList(ref.pathLabel, ref.holder, ref.key); } diff --git a/extensions/discord/src/monitor/ingress.ts b/extensions/discord/src/monitor/ingress.ts index aa1fe95c4d74..a23a9555ca81 100644 --- a/extensions/discord/src/monitor/ingress.ts +++ b/extensions/discord/src/monitor/ingress.ts @@ -7,6 +7,7 @@ import { type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; import { danger, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Client } from "../internal/discord.js"; import { mapGatewayDispatchData } from "../internal/gateway-dispatch.js"; import { getDiscordRuntime } from "../runtime.js"; @@ -49,10 +50,6 @@ class DiscordIngressPayloadError extends Error { } } -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function inspectDiscordMessage(rawMessage: unknown): { eventId: string; laneKey: string } { if (!rawMessage || typeof rawMessage !== "object" || Array.isArray(rawMessage)) { throw new DiscordIngressPayloadError("Discord MESSAGE_CREATE payload must be an object"); diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index 91574b3bd350..a048b9da7e71 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -29,7 +29,10 @@ import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + parseBooleanValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js"; import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js"; import { elevenLabsTTS, elevenLabsTTSStream } from "./tts.js"; @@ -80,17 +83,6 @@ type ElevenLabsProviderConfig = { }; }; -function parseBooleanValue(value: string): boolean | undefined { - const normalized = normalizeLowercaseStringOrEmpty(value); - if (["true", "1", "yes", "on"].includes(normalized)) { - return true; - } - if (["false", "0", "no", "off"].includes(normalized)) { - return false; - } - return undefined; -} - function parseNumberValue(value: string): number | undefined { return parseStrictFiniteNumber(value); } diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index b30f4fc398f4..0649dd415f92 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -2,6 +2,7 @@ import type { Agent } from "node:https"; import { createRequire } from "node:module"; import * as Lark from "@larksuiteoapi/node-sdk"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { readPluginPackageVersion, resolveAmbientNodeProxyAgent, @@ -85,10 +86,6 @@ type FeishuHttpInstanceLike = Pick< "request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options" >; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readHeader(headers: unknown, name: string): string | undefined { if (!isRecord(headers)) { return undefined; diff --git a/extensions/feishu/src/feishu-ingress.ts b/extensions/feishu/src/feishu-ingress.ts index 7a37cdd24b37..3ed0acaef64b 100644 --- a/extensions/feishu/src/feishu-ingress.ts +++ b/extensions/feishu/src/feishu-ingress.ts @@ -8,6 +8,7 @@ import { type ChannelIngressMonitorLifecycle, type ChannelIngressQueue, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ChannelReplayClaimHandle } from "openclaw/plugin-sdk/persistent-dedupe"; import { getFeishuRuntime } from "./runtime.js"; @@ -78,10 +79,6 @@ export class FeishuIngressPermanentError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/extensions/googlechat/src/monitor-event.ts b/extensions/googlechat/src/monitor-event.ts index def4ad0e47b1..87686793a8d4 100644 --- a/extensions/googlechat/src/monitor-event.ts +++ b/extensions/googlechat/src/monitor-event.ts @@ -1,4 +1,5 @@ // Googlechat plugin module parses standard and Workspace Add-on webhook envelopes. +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { GoogleChatAction, GoogleChatActionParameter, @@ -20,10 +21,6 @@ type ParsedGoogleChatInboundPayload = { addOnBearerToken: string; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function recordParamsToActionParameters( params?: Record, ): GoogleChatActionParameter[] | undefined { diff --git a/extensions/googlechat/src/monitor-ingress.ts b/extensions/googlechat/src/monitor-ingress.ts index 138910d97954..b4bb7736e702 100644 --- a/extensions/googlechat/src/monitor-ingress.ts +++ b/extensions/googlechat/src/monitor-ingress.ts @@ -5,6 +5,7 @@ import { type ChannelIngressMonitorDeliveryResult, type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { GoogleChatEventPayloadError, parseGoogleChatInboundPayload } from "./monitor-event.js"; import { getGoogleChatRuntime } from "./runtime.js"; @@ -49,10 +50,6 @@ class GoogleChatIngressPermanentError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function requiredString(value: unknown, field: string): string { if (typeof value === "string" && value.trim()) { return value.trim(); diff --git a/extensions/googlechat/src/monitor-webhook.ts b/extensions/googlechat/src/monitor-webhook.ts index 2af8677af081..b1260780be58 100644 --- a/extensions/googlechat/src/monitor-webhook.ts +++ b/extensions/googlechat/src/monitor-webhook.ts @@ -1,5 +1,6 @@ // Googlechat plugin module implements monitor webhook behavior. import type { IncomingMessage, ServerResponse } from "node:http"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeWebhookPath, @@ -41,10 +42,6 @@ type ParsedGoogleChatInboundSuccess = { addOnBearerToken: string; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseGoogleChatInboundPayloadOrReject( raw: unknown, res: ServerResponse, diff --git a/extensions/imessage/src/monitor/conversation-repair.ts b/extensions/imessage/src/monitor/conversation-repair.ts index aa184b78e633..11c9fe16ffe3 100644 --- a/extensions/imessage/src/monitor/conversation-repair.ts +++ b/extensions/imessage/src/monitor/conversation-repair.ts @@ -1,4 +1,5 @@ // Imessage plugin module implements conversation repair behavior. +import { hasNonEmptyString as isNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { IMessageRpcClient } from "../client.js"; import type { IMessagePayload } from "./types.js"; @@ -40,10 +41,6 @@ type AuthoritativeRecoveryProjection = { is_from_me: boolean; }; -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - function hasPositiveChatId(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } diff --git a/extensions/imessage/src/monitor/ingress.ts b/extensions/imessage/src/monitor/ingress.ts index 7345f40d62c7..5dc428f6cb8f 100644 --- a/extensions/imessage/src/monitor/ingress.ts +++ b/extensions/imessage/src/monitor/ingress.ts @@ -5,6 +5,7 @@ import { type ChannelIngressMonitorDeliveryResult, type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -63,10 +64,6 @@ class IMessageIngressPayloadError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function rawMessageRecord(raw: unknown): Record | null { if (!isRecord(raw)) { return null; diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index a66718bc9e64..d477ff927937 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -4,12 +4,10 @@ import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/channel-core"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; type CredentialUnavailableDiagnostic = Extract< @@ -17,8 +15,6 @@ type CredentialUnavailableDiagnostic = Extract< { status: "configured_unavailable" } >["diagnostic"]; -const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); - export type ResolvedIrcAccount = { accountId: string; enabled: boolean; @@ -37,13 +33,6 @@ export type ResolvedIrcAccount = { config: IrcAccountConfig; }; -function parseTruthy(value?: string): boolean { - if (!value) { - return false; - } - return TRUTHY_ENV.has(normalizeLowercaseStringOrEmpty(value)); -} - function parseIntEnv(value?: string): number | undefined { if (!value?.trim()) { return undefined; @@ -167,7 +156,7 @@ export function resolveIrcAccount(params: { typeof merged.tls === "boolean" ? merged.tls : accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS - ? parseTruthy(process.env.IRC_TLS) + ? isTruthyEnvValue(process.env.IRC_TLS) : true; const envPort = diff --git a/extensions/line/src/actions.ts b/extensions/line/src/actions.ts index b26ed538cc5d..29165df1a60b 100644 --- a/extensions/line/src/actions.ts +++ b/extensions/line/src/actions.ts @@ -1,5 +1,6 @@ // Line plugin module implements actions behavior. import type { messagingApi } from "@line/bot-sdk"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; export type Action = messagingApi.Action; @@ -67,10 +68,6 @@ const actionTypes = new Set([ "uri", ]); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isLineAction(value: unknown): value is Action { return isRecord(value) && typeof value.type === "string" && actionTypes.has(value.type); } diff --git a/extensions/line/src/webhook-spool.ts b/extensions/line/src/webhook-spool.ts index 3702a5cbadef..72c5a1ca4cbc 100644 --- a/extensions/line/src/webhook-spool.ts +++ b/extensions/line/src/webhook-spool.ts @@ -8,6 +8,7 @@ import { type ChannelIngressQueue, } from "openclaw/plugin-sdk/channel-outbound"; import { danger, type RuntimeEnv, warn } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards"; import { getLineRuntime } from "./runtime.js"; @@ -72,10 +73,6 @@ type LineWebhookSpool = { stop: () => Promise; }; -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - /** Message ids preserve the shipped replay-guard keyspace; other events use LINE's delivery id. */ function eventIdFor(event: unknown): string { if (!event || typeof event !== "object") { diff --git a/extensions/linux-canvas/src/ipc-client.ts b/extensions/linux-canvas/src/ipc-client.ts index e8219dc8736d..956bf13cb0e6 100644 --- a/extensions/linux-canvas/src/ipc-client.ts +++ b/extensions/linux-canvas/src/ipc-client.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import net from "node:net"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; // A2UI may stop a load, wait up to 6 seconds for the renderer, then evaluate. // Keep the outer IPC deadline above the app's complete 22-second phase budget. @@ -42,10 +43,6 @@ function parseFrame(line: string): unknown { } } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export class LinuxCanvasIpcClient implements LinuxCanvasIpcTransport { private socket: net.Socket | undefined; private connecting: Promise | undefined; diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts index 8baa6a9fa5d1..20d082738f8d 100644 --- a/extensions/lmstudio/src/setup.ts +++ b/extensions/lmstudio/src/setup.ts @@ -26,6 +26,7 @@ import { type ProviderPrepareDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/provider-setup"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { @@ -76,10 +77,6 @@ type LmstudioSetupDiscovery = { defaultModelId: string | undefined; }; -function isTruthyEnvValue(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); -} - function resolveLmstudioSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string { return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP) ? LMSTUDIO_DOCKER_HOST_BASE_URL diff --git a/extensions/mattermost/src/mattermost/monitor-ingress.ts b/extensions/mattermost/src/mattermost/monitor-ingress.ts index 1b7f30f2b73b..410ee50eea18 100644 --- a/extensions/mattermost/src/mattermost/monitor-ingress.ts +++ b/extensions/mattermost/src/mattermost/monitor-ingress.ts @@ -4,6 +4,7 @@ import { type ChannelIngressQueue, type ChannelIngressMonitorDeliveryResult, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { getMattermostRuntime } from "../runtime.js"; @@ -55,10 +56,6 @@ class MattermostIngressPermanentError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseRawObject(raw: string, subject: string): Record { let parsed: unknown; try { diff --git a/extensions/migrate-hermes/config-mcp.ts b/extensions/migrate-hermes/config-mcp.ts index f6fc6d7684d5..4804d2b9c036 100644 --- a/extensions/migrate-hermes/config-mcp.ts +++ b/extensions/migrate-hermes/config-mcp.ts @@ -1,6 +1,7 @@ // Hermes MCP config mapping and manual follow-up planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; +import { parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { mcpValueHasEnvReferences, resolveMcpEnvReferences } from "./config-env.js"; import { readPositiveNumber } from "./config-provider-contract.js"; import { isRecord, readString, sanitizeName } from "./helpers.js"; @@ -12,20 +13,6 @@ function readBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } -function readBooleanish(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (["true", "1", "yes", "on"].includes(normalized)) { - return true; - } - return ["false", "0", "no", "off"].includes(normalized) ? false : undefined; -} - function readPositiveNumeric(value: unknown): number | undefined { if (typeof value === "number") { return readPositiveNumber(value); @@ -68,8 +55,8 @@ function mapHermesToolFilter(value: Record): Record 0) { @@ -344,8 +331,8 @@ export function mcpManualItems(params: { ) || (tools.include !== undefined && !readToolFilterList(tools.include)) || (tools.exclude !== undefined && !readToolFilterList(tools.exclude)) || - (tools.resources !== undefined && readBooleanish(tools.resources) === undefined) || - (tools.prompts !== undefined && readBooleanish(tools.prompts) === undefined)) + (tools.resources !== undefined && parseBooleanValue(tools.resources) === undefined) || + (tools.prompts !== undefined && parseBooleanValue(tools.prompts) === undefined)) ) { add( "tool-policy", diff --git a/extensions/msteams/src/msteams-ingress.ts b/extensions/msteams/src/msteams-ingress.ts index 1dbda5d6506e..244d629e1abe 100644 --- a/extensions/msteams/src/msteams-ingress.ts +++ b/extensions/msteams/src/msteams-ingress.ts @@ -6,6 +6,7 @@ import { type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { classifyMSTeamsSendError } from "./errors.js"; import { MSTEAMS_REQUEST_TIMEOUT_MS } from "./request-timeout.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -59,10 +60,6 @@ class MSTeamsIngressPayloadError extends Error { } } -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function isDispatchableActivity(activity: MSTeamsIngressActivity): boolean { return ( activity.type === "message" || diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index e17a040990ab..21bfbd6bee37 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -12,6 +12,7 @@ import { resolveTextChunksWithFallback, sendPayloadMediaSequence, } from "openclaw/plugin-sdk/reply-payload"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chunkTextForOutbound, normalizeStringEntries, @@ -21,12 +22,6 @@ import { createMSTeamsPollStoreState } from "./polls.js"; import { buildMSTeamsPresentationCard, MSTEAMS_PRESENTATION_CAPABILITIES } from "./presentation.js"; import { sendAdaptiveCardMSTeams, sendMessageMSTeams, sendPollMSTeams } from "./send.js"; -function asObjectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - const MSTEAMS_TEXT_CHUNK_LIMIT = 4000; type MSTeamsSendConfig = Parameters[0]["cfg"]; @@ -94,7 +89,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { presentation, text: payload.text, }); - const msteamsData = asObjectRecord(payload.channelData?.msteams) ?? {}; + const msteamsData = asOptionalRecord(payload.channelData?.msteams) ?? {}; return { ...payload, channelData: { @@ -117,7 +112,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { deps, onDeliveryResult, }) => { - const msteamsData = asObjectRecord(payload.channelData?.msteams); + const msteamsData = asOptionalRecord(payload.channelData?.msteams); const presentationCard = msteamsData?.presentationCard; if ( presentationCard && diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index fb28e589ebf3..5ad78fcb944c 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -6,11 +6,9 @@ import { resolveAccountWithDefaultFallback, } from "openclaw/plugin-sdk/account-core"; import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveNextcloudTalkApiCredentialsResult, type NextcloudTalkCredentialUnavailableDiagnostic, @@ -18,11 +16,6 @@ import { import { normalizeResolvedSecretInputString } from "./secret-input.js"; import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; -function isTruthyEnvValue(value?: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(value); - return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on"; -} - const debugAccounts = (...args: unknown[]) => { if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) { console.warn("[nextcloud-talk:accounts]", ...args); diff --git a/extensions/nextcloud-talk/src/webhook-spool-state.ts b/extensions/nextcloud-talk/src/webhook-spool-state.ts index e2e790cfb902..eb3a6d34634c 100644 --- a/extensions/nextcloud-talk/src/webhook-spool-state.ts +++ b/extensions/nextcloud-talk/src/webhook-spool-state.ts @@ -1,5 +1,6 @@ // Nextcloud Talk plugin module owns webhook ingress identity and legacy-state migration. import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; export const NEXTCLOUD_TALK_INGRESS_PAYLOAD_VERSION = 1; @@ -26,10 +27,6 @@ export class NextcloudTalkWebhookPayloadError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function parseRawObject(rawEvent: string): Record { let parsed: unknown; try { diff --git a/extensions/onepassword/src/config.ts b/extensions/onepassword/src/config.ts index 73a83f550c51..1f1faf496fed 100644 --- a/extensions/onepassword/src/config.ts +++ b/extensions/onepassword/src/config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export const MAX_REGISTERED_ITEMS = 32; @@ -24,10 +25,6 @@ export type OnePasswordConfig = { items: Record; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function requiredString(record: Record, key: string): string { const value = record[key]; if (typeof value !== "string" || !value.trim()) { diff --git a/extensions/onepassword/src/secret-ref-cli.ts b/extensions/onepassword/src/secret-ref-cli.ts index 03b9ec714d71..560cdfe6f001 100644 --- a/extensions/onepassword/src/secret-ref-cli.ts +++ b/extensions/onepassword/src/secret-ref-cli.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { createInterface } from "node:readline/promises"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { DEFAULT_SECRET_FILE_MAX_BYTES, @@ -109,10 +110,6 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } diff --git a/extensions/onepassword/src/tool.ts b/extensions/onepassword/src/tool.ts index bd528ba0021c..f91155cc75dd 100644 --- a/extensions/onepassword/src/tool.ts +++ b/extensions/onepassword/src/tool.ts @@ -1,3 +1,4 @@ +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { jsonResult } from "openclaw/plugin-sdk/tool-results"; import type { @@ -47,10 +48,6 @@ function errorResult(error: unknown) { return jsonResult({ ok: false, error: { code, message } }); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export function redactPersistedOnePasswordResult( event: PluginHookToolResultPersistEvent, ): PluginHookToolResultPersistResult | undefined { diff --git a/extensions/policy/src/doctor/automatic-repairs.ts b/extensions/policy/src/doctor/automatic-repairs.ts index d810c15c3a93..85c4332d0d41 100644 --- a/extensions/policy/src/doctor/automatic-repairs.ts +++ b/extensions/policy/src/doctor/automatic-repairs.ts @@ -1,4 +1,5 @@ // Policy automatic repairs apply only deterministic narrowing config changes. +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { HealthFinding, HealthRepairContext, @@ -447,10 +448,6 @@ function ensureRecord(parent: ConfigRecord, key: string): ConfigRecord { return next; } -function isRecord(value: unknown): value is ConfigRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function uniqueStrings(values: readonly string[]): readonly string[] { return [...new Set(values)]; } diff --git a/extensions/policy/src/doctor/routing-shapes.ts b/extensions/policy/src/doctor/routing-shapes.ts index f1647a74dd12..f598b6492932 100644 --- a/extensions/policy/src/doctor/routing-shapes.ts +++ b/extensions/policy/src/doctor/routing-shapes.ts @@ -1,5 +1,8 @@ import type { HealthFinding } from "openclaw/plugin-sdk/health"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + hasNonEmptyString as nonEmptyString, + isRecord, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { ROUTING_MATCH_KINDS } from "../policy-routing.js"; import { policyShapeFinding, unsupportedPolicyKey } from "./shape-helpers.js"; import { ocPathSegment } from "./utils.js"; @@ -225,10 +228,6 @@ function expectShapeFinding( return undefined; } -function nonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - function invalid(ctx: ShapeContext, target: string, message: string): HealthFinding { return policyShapeFinding( ctx.policyPath, diff --git a/extensions/policy/src/doctor/scopes/gateway.ts b/extensions/policy/src/doctor/scopes/gateway.ts index 071052abfe17..8418e9143b38 100644 --- a/extensions/policy/src/doctor/scopes/gateway.ts +++ b/extensions/policy/src/doctor/scopes/gateway.ts @@ -1,4 +1,5 @@ // Policy doctor checks and findings for gateway exposure policy. +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import type { PolicyEvidence } from "../../policy-state.js"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; @@ -422,7 +423,3 @@ function hasValidOptionalStringList(policy: unknown, path: readonly string[]): b current.every((entry) => typeof entry === "string" && entry.trim() !== "")) ); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/extensions/policy/src/doctor/strictness.ts b/extensions/policy/src/doctor/strictness.ts index 902c3419a771..a9690da259dc 100644 --- a/extensions/policy/src/doctor/strictness.ts +++ b/extensions/policy/src/doctor/strictness.ts @@ -2,6 +2,7 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeAccountId, normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { + hasNonEmptyString as nonEmptyString, isRecord, normalizeLowercaseStringOrEmpty, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -171,10 +172,6 @@ function normalizeRoutingId(value: unknown): string | undefined { return typeof value === "string" ? value.trim() : undefined; } -function nonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - function isPolicyOrderedStringAtLeastAsStrict( metadata: PolicyRuleMetadata, candidate: unknown, diff --git a/test/scripts/code-mode-model-matrix.test.ts b/extensions/qa-lab/src/code-mode-model-matrix.test.ts similarity index 98% rename from test/scripts/code-mode-model-matrix.test.ts rename to extensions/qa-lab/src/code-mode-model-matrix.test.ts index 724848a6ca8d..9e01db9d7bf4 100644 --- a/test/scripts/code-mode-model-matrix.test.ts +++ b/extensions/qa-lab/src/code-mode-model-matrix.test.ts @@ -11,9 +11,9 @@ import { reserveCodeModeMatrixOutputDir, resolveCodeModeMatrixOutputDir, runCodeModeModelMatrix, + validateQaEvidenceSummaryJson, type CodeModeMatrixCellResult, -} from "../../scripts/code-mode-model-matrix.ts"; -import type { AgentExecEnvelope } from "../../src/commands/agent-exec.ts"; +} from "../../../scripts/code-mode-model-matrix.ts"; describe("Code Mode model matrix options", () => { it("defaults to the complete bounded matrix", () => { @@ -135,7 +135,7 @@ describe("Code Mode model matrix classification", () => { model: "qwen3.5:9b", provider: "ollama", sessionId: "session", - } satisfies AgentExecEnvelope; + } satisfies Parameters[0]["envelope"]; it("requires engagement, tool execution, effect, and exact final text", () => { expect( @@ -610,9 +610,9 @@ describe("Code Mode model matrix artifacts", () => { failureCategory: "harness_error", error: { kind: "harness_error", message: "fixture exploded" }, }); - const evidence = JSON.parse( - await fs.readFile(path.join(repoRoot, "artifacts", "qa-evidence.json"), "utf8"), - ) as { entries: unknown[] }; + const evidence = validateQaEvidenceSummaryJson( + JSON.parse(await fs.readFile(path.join(repoRoot, "artifacts", "qa-evidence.json"), "utf8")), + ); expect(evidence.entries).toHaveLength(2); expect(evidence.entries[0]).toMatchObject({ test: { diff --git a/extensions/qa-lab/src/gateway-process-boundary.ts b/extensions/qa-lab/src/gateway-process-boundary.ts index 68d900689bd8..8bb7dfc92f12 100644 --- a/extensions/qa-lab/src/gateway-process-boundary.ts +++ b/extensions/qa-lab/src/gateway-process-boundary.ts @@ -4,6 +4,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; const PROCESS_BOUNDARY_VERSION = 1; @@ -117,10 +118,6 @@ type QaGatewayProcessBoundaryEvidenceLaunch = { terminalState?: "failed-before-ready" | "ready-exited"; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function sha256(value: Buffer | string) { return createHash("sha256").update(value).digest("hex"); } diff --git a/extensions/qqbot/src/engine/gateway/ingress-envelope.ts b/extensions/qqbot/src/engine/gateway/ingress-envelope.ts index fb44f68f0682..2e179e00448f 100644 --- a/extensions/qqbot/src/engine/gateway/ingress-envelope.ts +++ b/extensions/qqbot/src/engine/gateway/ingress-envelope.ts @@ -1,4 +1,5 @@ // QQBot plugin module validates raw gateway envelopes for durable ingress. +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { GatewayEvent, GatewayOp } from "./constants.js"; import type { WSPayload } from "./types.js"; @@ -24,10 +25,6 @@ type QQBotIngressEnvelopeFacts = { payload: WSPayload; }; -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function record(value: unknown, field: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new QQBotIngressPayloadError(`QQBot gateway event is missing ${field}.`); diff --git a/extensions/reef/protocol/guard-adapters.ts b/extensions/reef/protocol/guard-adapters.ts index a28773780742..a930164129ea 100644 --- a/extensions/reef/protocol/guard-adapters.ts +++ b/extensions/reef/protocol/guard-adapters.ts @@ -1,3 +1,4 @@ +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http"; import { admitGuardAdapter, @@ -200,7 +201,3 @@ function hasDuplicateKeys(text: string): boolean { } return false; } - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/extensions/signal/doctor-contract-api.ts b/extensions/signal/doctor-contract-api.ts index 69de515c5666..2a407954d327 100644 --- a/extensions/signal/doctor-contract-api.ts +++ b/extensions/signal/doctor-contract-api.ts @@ -3,6 +3,7 @@ import type { ChannelDoctorConfigMutation, ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; import { migrateLegacySignalTransportConfigSync } from "./src/config-compat.js"; @@ -19,10 +20,6 @@ const RETIRED_SIGNAL_ACCOUNT_TRANSPORT_FIELDS = [ "ignoreStories", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function hasRetiredSignalAccountTransportFields(value: unknown): boolean { return ( isRecord(value) && diff --git a/extensions/signal/src/config-compat.ts b/extensions/signal/src/config-compat.ts index 2a3e7f70311b..fd365d9b70b5 100644 --- a/extensions/signal/src/config-compat.ts +++ b/extensions/signal/src/config-compat.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; // Signal compatibility migration moves shipped flat transport config into account ownership. import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { SignalTransportConfig } from "./account-types.js"; import { @@ -41,10 +42,6 @@ type DetectTransport = (params: { account?: string; }) => Promise; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isSignalTransportConfig(value: unknown): value is SignalTransportConfig { if (!isRecord(value)) { return false; diff --git a/extensions/signal/src/config-schema.ts b/extensions/signal/src/config-schema.ts index 0461540311cf..cc885500132b 100644 --- a/extensions/signal/src/config-schema.ts +++ b/extensions/signal/src/config-schema.ts @@ -16,6 +16,7 @@ import { requireAllowlistAllowFrom, requireOpenAllowFrom, } from "openclaw/plugin-sdk/channel-config-schema"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; import { signalChannelConfigUiHints } from "./config-ui-hints.js"; @@ -44,10 +45,6 @@ const SignalTransportUrlSchema = z "Expected http:// or https:// URL without embedded credentials", ); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function projectSignalConfigForUpdateValidation(value: unknown): unknown { if (process.env.OPENCLAW_UPDATE_IN_PROGRESS !== "1" || !isRecord(value)) { return value; diff --git a/extensions/signal/src/signal-ingress.ts b/extensions/signal/src/signal-ingress.ts index 4ef204a07291..569654c14a95 100644 --- a/extensions/signal/src/signal-ingress.ts +++ b/extensions/signal/src/signal-ingress.ts @@ -5,7 +5,9 @@ import { type ChannelIngressMonitorDeliveryResult, type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeNullableString as normalizeRawString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SignalSseEvent } from "./client-adapter.js"; import { getOptionalSignalRuntime } from "./runtime.js"; @@ -58,14 +60,6 @@ class SignalIngressPermanentError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRawString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function normalizeTimestamp(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null; } diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index ae96ff4fb5ea..a4dd71fe939f 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -5,6 +5,7 @@ import { createDangerousNameMatchingMutableAllowlistWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, mergeSlackAccountConfig } from "./accounts.js"; import { @@ -14,12 +15,6 @@ import { import { probeSlack } from "./probe.js"; import { isSlackMutableAllowEntry } from "./security-doctor.js"; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - const collectSlackMutableAllowlistWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({ channel: "slack", diff --git a/extensions/synology-chat/src/webhook-ingress.ts b/extensions/synology-chat/src/webhook-ingress.ts index 051caf0f63eb..e7308c4de706 100644 --- a/extensions/synology-chat/src/webhook-ingress.ts +++ b/extensions/synology-chat/src/webhook-ingress.ts @@ -5,6 +5,7 @@ import { type ChannelIngressMonitorDeliveryResult, type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { getSynologyRuntime } from "./runtime.js"; @@ -98,10 +99,6 @@ function inspectSynologyIngressEvent(event: SynologyWebhookRawEvent): { }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function deserializeSynologyIngressEvent( rawEvent: string, claimedId: string, diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index d108807534c7..8b96089be1f9 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { asObjectRecord, collectChannelAccountScopes } from "openclaw/plugin-sdk/runtime-doctor"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { inspectTelegramAccount } from "./account-inspect.js"; import { @@ -44,12 +45,6 @@ type TelegramAllowFromListRef = { key: "allowFrom" | "groupAllowFrom"; }; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function sanitizeForLog(value: string): string { return value.replace(/\p{Cc}+/gu, " ").trim(); } @@ -58,40 +53,6 @@ function hasAllowFromEntries(values?: DoctorAllowFromList): boolean { return Array.isArray(values) && values.some((entry) => normalizeOptionalString(String(entry))); } -function collectTelegramAccountScopes( - cfg: OpenClawConfig, -): Array<{ prefix: string; pathSegments: string[]; account: Record }> { - const scopes: Array<{ - prefix: string; - pathSegments: string[]; - account: Record; - }> = []; - const telegram = asObjectRecord((cfg.channels as Record | undefined)?.telegram); - if (!telegram) { - return scopes; - } - scopes.push({ - prefix: "channels.telegram", - pathSegments: ["channels", "telegram"], - account: telegram, - }); - const accounts = asObjectRecord(telegram.accounts); - if (!accounts) { - return scopes; - } - for (const key of Object.keys(accounts)) { - const account = asObjectRecord(accounts[key]); - if (account) { - scopes.push({ - prefix: `channels.telegram.accounts.${key}`, - pathSegments: ["channels", "telegram", "accounts", key], - account, - }); - } - } - return scopes; -} - function collectTelegramAllowFromLists( prefix: string, account: Record, @@ -145,7 +106,7 @@ function describeConfigValueType(value: unknown): string { function scanTelegramMalformedGroupsConfig(cfg: OpenClawConfig): TelegramMalformedGroupsHit[] { const hits: TelegramMalformedGroupsHit[] = []; - for (const scope of collectTelegramAccountScopes(cfg)) { + for (const scope of collectChannelAccountScopes({ cfg, channelId: "telegram" })) { if (!Object.hasOwn(scope.account, "groups")) { continue; } @@ -193,7 +154,7 @@ function scanTelegramInvalidAllowFromEntries(cfg: OpenClawConfig): TelegramAllow } }; - for (const scope of collectTelegramAccountScopes(cfg)) { + for (const scope of collectChannelAccountScopes({ cfg, channelId: "telegram" })) { for (const ref of collectTelegramAllowFromLists(scope.prefix, scope.account)) { scanList(ref.pathLabel, ref.holder[ref.key]); } @@ -217,7 +178,7 @@ function collectTelegramInvalidAllowFromWarnings(params: { function scanTelegramBotEndpointApiRoots(cfg: OpenClawConfig): TelegramApiRootBotEndpointHit[] { const hits: TelegramApiRootBotEndpointHit[] = []; - for (const scope of collectTelegramAccountScopes(cfg)) { + for (const scope of collectChannelAccountScopes({ cfg, channelId: "telegram" })) { const value = scope.account.apiRoot; if (typeof value !== "string" || !hasTelegramBotEndpointApiRoot(value)) { continue; @@ -521,7 +482,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi } }; - for (const scope of collectTelegramAccountScopes(next)) { + for (const scope of collectChannelAccountScopes({ cfg: next, channelId: "telegram" })) { for (const ref of collectTelegramAllowFromLists(scope.prefix, scope.account)) { await repairList(ref.pathLabel, ref.holder, ref.key); } diff --git a/extensions/telegram/src/target-writeback.ts b/extensions/telegram/src/target-writeback.ts index 3a2ebe1b911c..74f94e72d3bc 100644 --- a/extensions/telegram/src/target-writeback.ts +++ b/extensions/telegram/src/target-writeback.ts @@ -9,6 +9,7 @@ import { resolveCronStorePath, saveCronStore, } from "openclaw/plugin-sdk/cron-store-runtime"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty, @@ -23,13 +24,6 @@ import { const writebackLogger = createSubsystemLogger("telegram/target-writeback"); const TELEGRAM_ADMIN_SCOPE = "operator.admin"; -function asObjectRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} - function normalizeTelegramLookupTargetForMatch(raw: string): string | undefined { const normalized = normalizeTelegramLookupTarget(raw); if (!normalized) { diff --git a/extensions/tlon/src/monitor/ingress.ts b/extensions/tlon/src/monitor/ingress.ts index a965bcff4b0f..99d3c4c0bea8 100644 --- a/extensions/tlon/src/monitor/ingress.ts +++ b/extensions/tlon/src/monitor/ingress.ts @@ -5,8 +5,10 @@ import { type ChannelIngressMonitorDeliveryResult, type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getTlonRuntime } from "../runtime.js"; import { UrbitAuthError, UrbitHttpError } from "../urbit/errors.js"; @@ -58,14 +60,6 @@ class TlonIngressShutdownError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function inspectChannelsEvent(event: unknown): { eventId: string; laneKey: string } | null { const envelope = isRecord(event) ? event : null; const nest = nonEmptyString(envelope?.nest); diff --git a/extensions/twitch/src/twitch-ingress.ts b/extensions/twitch/src/twitch-ingress.ts index 5ec34b4ddf79..1f943b911cf4 100644 --- a/extensions/twitch/src/twitch-ingress.ts +++ b/extensions/twitch/src/twitch-ingress.ts @@ -6,6 +6,7 @@ import { type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getTwitchRuntime } from "./runtime.js"; import type { TwitchChatMessage } from "./types.js"; import { normalizeTwitchChannel } from "./utils/twitch.js"; @@ -40,10 +41,6 @@ class TwitchIngressPermanentError extends Error { } } -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function inspectTwitchIngressEvent(event: unknown): { eventId: string; laneKey: string } { if (!event || typeof event !== "object" || Array.isArray(event)) { throw new TwitchIngressPermanentError("Twitch ingress event must be an object."); diff --git a/extensions/vault/src/cli.ts b/extensions/vault/src/cli.ts index a777936a9e7d..7eac6d67dea5 100644 --- a/extensions/vault/src/cli.ts +++ b/extensions/vault/src/cli.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { pluginSecretRefSetup } from "openclaw/plugin-sdk/secret-ref-runtime"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; @@ -78,10 +79,6 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } diff --git a/extensions/zalo/src/webhook-spool.ts b/extensions/zalo/src/webhook-spool.ts index 2e145d414e5d..533e2093b5b5 100644 --- a/extensions/zalo/src/webhook-spool.ts +++ b/extensions/zalo/src/webhook-spool.ts @@ -6,6 +6,8 @@ import { DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS, type ChannelIngressQueue, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards"; import { ZaloApiError, type ZaloUpdate } from "./api.js"; import type { ZaloRuntimeEnv } from "./monitor.types.js"; @@ -43,14 +45,6 @@ type ZaloWebhookIngress = { stop: () => Promise; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function parseRawRecord(rawEvent: string): Record { let parsed: unknown; try { diff --git a/extensions/zalouser/src/doctor.ts b/extensions/zalouser/src/doctor.ts index 90e509374c5d..1a274735d2a7 100644 --- a/extensions/zalouser/src/doctor.ts +++ b/extensions/zalouser/src/doctor.ts @@ -1,15 +1,10 @@ // Zalouser plugin module implements doctor behavior. import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js"; import { isZalouserMutableGroupEntry } from "./security-audit.js"; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - const collectZalouserMutableAllowlistWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({ channel: "zalouser", diff --git a/extensions/zalouser/src/ingress.ts b/extensions/zalouser/src/ingress.ts index b44be76fbab1..6e2254e1e107 100644 --- a/extensions/zalouser/src/ingress.ts +++ b/extensions/zalouser/src/ingress.ts @@ -5,8 +5,10 @@ import { DEFAULT_INGRESS_ADOPTION_STALL_MS, type ChannelIngressQueue, } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, extractErrorCode } from "openclaw/plugin-sdk/error-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getZalouserRuntime } from "./runtime.js"; import type { ZaloInboundMessage } from "./types.js"; import { normalizeZaloInboundMessage } from "./zalo-js.js"; @@ -50,14 +52,6 @@ class ZalouserIngressPayloadError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function inspectZalouserIngressMessage(message: unknown): { eventId: string; laneKey: string; diff --git a/src/plugin-sdk/runtime-doctor.test.ts b/src/plugin-sdk/runtime-doctor.test.ts index 4f9ca0079209..58e50c72cb64 100644 --- a/src/plugin-sdk/runtime-doctor.test.ts +++ b/src/plugin-sdk/runtime-doctor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "./config-contracts.js"; import { + collectChannelAccountScopes, defineKeyMoveMigration, normalizeChannelConfigEntries, stripRetiredChannelKeys, @@ -11,6 +12,26 @@ function cfgWith(entry: Record): OpenClawConfig { } describe("runtime-doctor channel helpers", () => { + it("collects the channel root and object-shaped accounts in config order", () => { + expect( + collectChannelAccountScopes({ + cfg: cfgWith({ accounts: { work: { enabled: true }, invalid: "skip" } }), + channelId: "sample", + }), + ).toEqual([ + { + prefix: "channels.sample", + pathSegments: ["channels", "sample"], + account: { accounts: { work: { enabled: true }, invalid: "skip" } }, + }, + { + prefix: "channels.sample.accounts.work", + pathSegments: ["channels", "sample", "accounts", "work"], + account: { enabled: true }, + }, + ]); + }); + it("moves nested keys across wildcard entries and preserves canonical values", () => { const migration = defineKeyMoveMigration({ scope: ["groups", "*"], diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index 61a91704b8ed..adaf907be053 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -3,6 +3,7 @@ */ 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"; export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; export { defineChannelAliasMigration } from "../config/channel-alias-migration.js"; @@ -66,6 +67,45 @@ type KeyMoveChangeContext = { mappedValue: unknown; }; +/** Collects a channel's root config and object-shaped account overrides in config order. */ +export function collectChannelAccountScopes(params: { + cfg: OpenClawConfig; + channelId: string; +}): Array<{ + prefix: string; + pathSegments: string[]; + account: Record; +}> { + const scopes: Array<{ + prefix: string; + pathSegments: string[]; + account: Record; + }> = []; + const pathSegments = ["channels", params.channelId]; + const channels = asObjectRecord(params.cfg.channels); + const channel = asObjectRecord(channels?.[params.channelId]); + if (!channel) { + return scopes; + } + scopes.push({ prefix: pathSegments.join("."), pathSegments, account: channel }); + const accounts = asObjectRecord(channel.accounts); + if (!accounts) { + return scopes; + } + for (const [accountId, value] of Object.entries(accounts)) { + const account = asObjectRecord(value); + if (account) { + const accountPathSegments = [...pathSegments, "accounts", accountId]; + scopes.push({ + prefix: accountPathSegments.join("."), + pathSegments: accountPathSegments, + account, + }); + } + } + return scopes; +} + function readKeyMovePath(entry: Record, path: readonly string[], own = true) { let current = entry; for (const segment of path.slice(0, -1)) {