From 8d75a496bf5aaab1755c56cf48502d967c75a1d0 Mon Sep 17 00:00:00 2001 From: max <40643627+quotentiroler@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:02:55 -0800 Subject: [PATCH] refactor: centralize isPlainObject, isRecord, isErrno, isLoopbackHost utilities (#12926) --- src/agents/cli-runner/helpers.ts | 6 +----- src/agents/minimax-vlm.ts | 5 +---- src/agents/models-config.ts | 5 +---- src/agents/pi-tool-definition-adapter.ts | 5 +---- src/agents/pi-tools.before-tool-call.ts | 5 +---- src/agents/tools/cron-tool.ts | 6 +----- src/browser/cdp.helpers.ts | 16 +++------------ src/browser/config.ts | 14 +------------ src/browser/extension-relay.ts | 14 +------------ src/channels/plugins/catalog.ts | 6 +----- src/channels/plugins/status-issues/shared.ts | 7 +++---- src/commands/doctor-config-flow.ts | 6 +----- src/config/config-paths.ts | 11 ++-------- src/config/env-substitution.ts | 11 ++-------- src/config/includes.ts | 10 +--------- src/config/legacy.shared.ts | 4 ++-- src/config/merge-patch.ts | 6 ++---- src/config/normalize-paths.ts | 6 +----- src/config/plugin-auto-enable.ts | 5 +---- src/config/runtime-overrides.ts | 10 +--------- src/config/validation.ts | 5 +---- src/cron/normalize.ts | 5 +---- src/discord/audit.ts | 5 +---- src/gateway/config-reload.ts | 10 +--------- src/gateway/net.ts | 16 ++++++++++++++- src/gateway/origin-check.ts | 18 ++--------------- src/infra/canvas-host-url.ts | 19 ++---------------- src/infra/errors.ts | 14 +++++++++++++ src/infra/ports-inspect.ts | 5 +---- src/infra/ports.ts | 5 +---- src/infra/provider-usage.fetch.minimax.ts | 5 +---- src/infra/ssh-tunnel.ts | 5 +---- src/plugins/manifest.ts | 5 +---- src/security/skill-scanner.ts | 17 ++++------------ src/slack/scopes.ts | 5 +---- src/telegram/audit.ts | 5 +---- src/utils.ts | 21 ++++++++++++++++++++ 37 files changed, 97 insertions(+), 226 deletions(-) diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 0066681a67a..3674d8f2ed9 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -10,7 +10,7 @@ import type { CliBackendConfig } from "../../config/types.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { runExec } from "../../process/exec.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; -import { escapeRegExp } from "../../utils.js"; +import { escapeRegExp, isRecord } from "../../utils.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { buildSystemPromptParams } from "../system-prompt-params.js"; @@ -280,10 +280,6 @@ function toUsage(raw: Record): CliUsage | undefined { return { input, output, cacheRead, cacheWrite, total }; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function collectText(value: unknown): string { if (!value) { return ""; diff --git a/src/agents/minimax-vlm.ts b/src/agents/minimax-vlm.ts index 121ae52beae..c167936189e 100644 --- a/src/agents/minimax-vlm.ts +++ b/src/agents/minimax-vlm.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../utils.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; type MinimaxBaseResp = { @@ -30,10 +31,6 @@ function coerceApiHost(params: { } } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function pickString(rec: Record, key: string): string { const v = rec[key]; return typeof v === "string" ? v : ""; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b322f7d6111..6664905ff4b 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { normalizeProviders, @@ -14,10 +15,6 @@ type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig { const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 3d4a3ca25eb..3aad24d793d 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -6,6 +6,7 @@ import type { import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; import { logDebug, logError } from "../logger.js"; +import { isPlainObject } from "../utils.js"; import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; import { normalizeToolName } from "./tool-policy.js"; import { jsonResult } from "./tools/common.js"; @@ -32,10 +33,6 @@ type ToolExecuteArgs = ToolDefinition["execute"] extends (...args: infer P) => u : ToolExecuteArgsCurrent; type ToolExecuteArgsAny = ToolExecuteArgs | ToolExecuteArgsLegacy | ToolExecuteArgsCurrent; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isAbortSignal(value: unknown): value is AbortSignal { return typeof value === "object" && value !== null && "aborted" in value; } diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index d310c4dae46..50b3a428952 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -1,6 +1,7 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { isPlainObject } from "../utils.js"; import { normalizeToolName } from "./tool-policy.js"; type HookContext = { @@ -12,10 +13,6 @@ type HookOutcome = { blocked: true; reason: string } | { blocked: false; params: const log = createSubsystemLogger("agents/tools"); -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export async function runBeforeToolCallHook(args: { toolName: string; params: unknown; diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index e01f7fcddbc..29c86e646ed 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -3,7 +3,7 @@ import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; -import { truncateUtf16Safe } from "../../utils.js"; +import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; @@ -157,10 +157,6 @@ async function buildReminderContextLines(params: { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function stripThreadSuffixFromSessionKey(sessionKey: string): string { const normalized = sessionKey.toLowerCase(); const idx = normalized.lastIndexOf(":thread:"); diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 05458c9a3ec..78f73fc8573 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -1,7 +1,10 @@ import WebSocket from "ws"; +import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; +export { isLoopbackHost }; + type CdpResponse = { id: number; result?: unknown; @@ -15,19 +18,6 @@ type Pending = { export type CdpSendFn = (method: string, params?: Record) => Promise; -export function isLoopbackHost(host: string) { - const h = host.trim().toLowerCase(); - return ( - h === "localhost" || - h === "127.0.0.1" || - h === "0.0.0.0" || - h === "[::1]" || - h === "::1" || - h === "[::]" || - h === "::" - ); -} - export function getHeadersWithAuth(url: string, headers: Record = {}) { const relayHeaders = getChromeExtensionRelayAuthHeaders(url); const mergedHeaders = { ...relayHeaders, ...headers }; diff --git a/src/browser/config.ts b/src/browser/config.ts index ec8572acf35..52a8bfd3bc3 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -5,6 +5,7 @@ import { deriveDefaultBrowserControlPort, DEFAULT_BROWSER_CONTROL_PORT, } from "../config/port-defaults.js"; +import { isLoopbackHost } from "../gateway/net.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, @@ -42,19 +43,6 @@ export type ResolvedBrowserProfile = { driver: "openclaw" | "extension"; }; -function isLoopbackHost(host: string) { - const h = host.trim().toLowerCase(); - return ( - h === "localhost" || - h === "127.0.0.1" || - h === "0.0.0.0" || - h === "[::1]" || - h === "::1" || - h === "[::]" || - h === "::" - ); -} - function normalizeHexColor(raw: string | undefined) { const value = (raw ?? "").trim(); if (!value) { diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 9919b7f103c..6f6f32e2a1e 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -4,6 +4,7 @@ import type { Duplex } from "node:stream"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import WebSocket, { WebSocketServer } from "ws"; +import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; type CdpCommand = { @@ -101,19 +102,6 @@ export type ChromeExtensionRelayServer = { stop: () => Promise; }; -function isLoopbackHost(host: string) { - const h = host.trim().toLowerCase(); - return ( - h === "localhost" || - h === "127.0.0.1" || - h === "0.0.0.0" || - h === "[::1]" || - h === "::1" || - h === "[::]" || - h === "::" - ); -} - function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) { return false; diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index e5774fba724..a07438b2795 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -5,7 +5,7 @@ import type { PluginOrigin } from "../../plugins/types.js"; import type { ChannelMeta } from "./types.js"; import { MANIFEST_KEY } from "../../compat/legacy-names.js"; import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; -import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; +import { CONFIG_DIR, isRecord, resolveUserPath } from "../../utils.js"; export type ChannelUiMetaEntry = { id: string; @@ -61,10 +61,6 @@ const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALO type ManifestKey = typeof MANIFEST_KEY; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { if (Array.isArray(raw)) { return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index 85cedd3be9d..da3606c2e9f 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -1,11 +1,10 @@ +import { isRecord } from "../../../utils.js"; +export { isRecord }; + export function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -export function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export function formatMatchMetadata(params: { matchKey?: unknown; matchSource?: unknown; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index fa53910df40..da60c297488 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -12,14 +12,10 @@ import { } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { note } from "../terminal/note.js"; -import { resolveHomeDir } from "../utils.js"; +import { isRecord, resolveHomeDir } from "../utils.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - type UnrecognizedKeysIssue = ZodIssue & { code: "unrecognized_keys"; keys: PropertyKey[]; diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts index 24e8095dc0a..899b89706ec 100644 --- a/src/config/config-paths.ts +++ b/src/config/config-paths.ts @@ -1,3 +1,5 @@ +import { isPlainObject } from "../utils.js"; + type PathNode = Record; const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]); @@ -79,12 +81,3 @@ export function getConfigValueAtPath(root: PathNode, path: string[]): unknown { } return cursor; } - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} diff --git a/src/config/env-substitution.ts b/src/config/env-substitution.ts index f2f670d77a2..97668a744b1 100644 --- a/src/config/env-substitution.ts +++ b/src/config/env-substitution.ts @@ -22,6 +22,8 @@ // Pattern for valid uppercase env var names: starts with letter or underscore, // followed by letters, numbers, or underscores (all uppercase) +import { isPlainObject } from "../utils.js"; + const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; export class MissingEnvVarError extends Error { @@ -34,15 +36,6 @@ export class MissingEnvVarError extends Error { } } -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - function substituteString(value: string, env: NodeJS.ProcessEnv, configPath: string): string { if (!value.includes("$")) { return value; diff --git a/src/config/includes.ts b/src/config/includes.ts index 5f7982b337a..9f55803b4b6 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -13,6 +13,7 @@ import JSON5 from "json5"; import fs from "node:fs"; import path from "node:path"; +import { isPlainObject } from "../utils.js"; export const INCLUDE_KEY = "$include"; export const MAX_INCLUDE_DEPTH = 10; @@ -52,15 +53,6 @@ export class CircularIncludeError extends ConfigIncludeError { // Utilities // ============================================================================ -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ export function deepMerge(target: unknown, source: unknown): unknown { if (Array.isArray(target) && Array.isArray(source)) { diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts index bd978b2287c..211e65459a0 100644 --- a/src/config/legacy.shared.ts +++ b/src/config/legacy.shared.ts @@ -10,8 +10,8 @@ export type LegacyConfigMigration = { apply: (raw: Record, changes: string[]) => void; }; -export const isRecord = (value: unknown): value is Record => - Boolean(value && typeof value === "object" && !Array.isArray(value)); +import { isRecord } from "../utils.js"; +export { isRecord }; export const getRecord = (value: unknown): Record | null => isRecord(value) ? value : null; diff --git a/src/config/merge-patch.ts b/src/config/merge-patch.ts index 6b66d15ed2d..982ccf44d18 100644 --- a/src/config/merge-patch.ts +++ b/src/config/merge-patch.ts @@ -1,8 +1,6 @@ -type PlainObject = Record; +import { isPlainObject } from "../utils.js"; -function isPlainObject(value: unknown): value is PlainObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +type PlainObject = Record; export function applyMergePatch(base: unknown, patch: unknown): unknown { if (!isPlainObject(patch)) { diff --git a/src/config/normalize-paths.ts b/src/config/normalize-paths.ts index 165c715a947..2178f96afbe 100644 --- a/src/config/normalize-paths.ts +++ b/src/config/normalize-paths.ts @@ -1,15 +1,11 @@ import type { OpenClawConfig } from "./types.js"; -import { resolveUserPath } from "../utils.js"; +import { isPlainObject, resolveUserPath } from "../utils.js"; const PATH_VALUE_RE = /^~(?=$|[\\/])/; const PATH_KEY_RE = /(dir|path|paths|file|root|workspace)$/i; const PATH_LIST_KEYS = new Set(["paths", "pathPrepend"]); -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function normalizeStringValue(key: string | undefined, value: string): string { if (!PATH_VALUE_RE.test(value.trim())) { return value; diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 32944cea3a1..99f034aa368 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -9,6 +9,7 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; +import { isRecord } from "../utils.js"; import { hasAnyWhatsAppAuth } from "../web/accounts.js"; type PluginEnableChange = { @@ -36,10 +37,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, ]; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function hasNonEmptyString(value: unknown): boolean { return typeof value === "string" && value.trim().length > 0; } diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts index fb3fe585a4c..5c4ba076a06 100644 --- a/src/config/runtime-overrides.ts +++ b/src/config/runtime-overrides.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "./types.js"; +import { isPlainObject } from "../utils.js"; import { parseConfigPath, setConfigValueAtPath, unsetConfigValueAtPath } from "./config-paths.js"; type OverrideTree = Record; @@ -19,15 +20,6 @@ function mergeOverrides(base: unknown, override: unknown): unknown { return next; } -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - export function getConfigOverrides(): OverrideTree { return overrides; } diff --git a/src/config/validation.ts b/src/config/validation.ts index 2ad57e6d0dc..0879ddf2d6f 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -9,6 +9,7 @@ import { } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; +import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; @@ -129,10 +130,6 @@ export function validateConfigObject( }; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - export function validateConfigObjectWithPlugins(raw: unknown): | { ok: true; diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index a41044b3632..f4afc4fc048 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -1,5 +1,6 @@ import type { CronJobCreate, CronJobPatch } from "./types.js"; import { sanitizeAgentId } from "../routing/session-key.js"; +import { isRecord } from "../utils.js"; import { parseAbsoluteTimeMs } from "./parse.js"; import { migrateLegacyCronPayload } from "./payload-migration.js"; import { inferLegacyName } from "./service/normalize.js"; @@ -14,10 +15,6 @@ const DEFAULT_OPTIONS: NormalizeOptions = { applyDefaults: false, }; -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function coerceSchedule(schedule: UnknownRecord) { const next: UnknownRecord = { ...schedule }; const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : ""; diff --git a/src/discord/audit.ts b/src/discord/audit.ts index 9dfd1986cac..58b3142c6a4 100644 --- a/src/discord/audit.ts +++ b/src/discord/audit.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; +import { isRecord } from "../utils.js"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; @@ -22,10 +23,6 @@ export type DiscordChannelPermissionsAudit = { const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) { if (!config) { return true; diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 5bfd6c57535..ce228405469 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -2,6 +2,7 @@ import chokidar from "chokidar"; import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { isPlainObject } from "../utils.js"; export type GatewayReloadSettings = { mode: GatewayReloadMode; @@ -126,15 +127,6 @@ function matchRule(path: string): ReloadRule | null { return null; } -function isPlainObject(value: unknown): value is Record { - return Boolean( - value && - typeof value === "object" && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]", - ); -} - export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] { if (prev === next) { return []; diff --git a/src/gateway/net.ts b/src/gateway/net.ts index e292aec2563..ea497898970 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -255,6 +255,20 @@ function isValidIPv4(host: string): boolean { }); } +/** + * Check if a hostname or IP refers to the local machine. + * Handles: localhost, 127.x.x.x, ::1, [::1], ::ffff:127.x.x.x + * Note: 0.0.0.0 and :: are NOT loopback - they bind to all interfaces. + */ export function isLoopbackHost(host: string): boolean { - return isLoopbackAddress(host); + if (!host) { + return false; + } + const h = host.trim().toLowerCase(); + if (h === "localhost") { + return true; + } + // Handle bracketed IPv6 addresses like [::1] + const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h; + return isLoopbackAddress(unbracket); } diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index a115eb85714..0648bd7393e 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -1,3 +1,5 @@ +import { isLoopbackHost } from "./net.js"; + type OriginCheckResult = { ok: true } | { ok: false; reason: string }; function normalizeHostHeader(hostHeader?: string): string { @@ -38,22 +40,6 @@ function parseOrigin( } } -function isLoopbackHost(hostname: string): boolean { - if (!hostname) { - return false; - } - if (hostname === "localhost") { - return true; - } - if (hostname === "::1") { - return true; - } - if (hostname === "127.0.0.1" || hostname.startsWith("127.")) { - return true; - } - return false; -} - export function checkBrowserOrigin(params: { requestHost?: string; origin?: string; diff --git a/src/infra/canvas-host-url.ts b/src/infra/canvas-host-url.ts index fe537bb8ede..b8272c58539 100644 --- a/src/infra/canvas-host-url.ts +++ b/src/infra/canvas-host-url.ts @@ -1,3 +1,5 @@ +import { isLoopbackHost } from "../gateway/net.js"; + type HostSource = string | null | undefined; type CanvasHostUrlParams = { @@ -9,23 +11,6 @@ type CanvasHostUrlParams = { scheme?: "http" | "https"; }; -const isLoopbackHost = (value: string) => { - const normalized = value.trim().toLowerCase(); - if (!normalized) { - return false; - } - if (normalized === "localhost") { - return true; - } - if (normalized === "::1") { - return true; - } - if (normalized === "0.0.0.0" || normalized === "::") { - return true; - } - return normalized.startsWith("127."); -}; - const normalizeHost = (value: HostSource, rejectLoopback: boolean) => { if (!value) { return ""; diff --git a/src/infra/errors.ts b/src/infra/errors.ts index 9f41ee4e577..1ea7950c2b6 100644 --- a/src/infra/errors.ts +++ b/src/infra/errors.ts @@ -12,6 +12,20 @@ export function extractErrorCode(err: unknown): string | undefined { return undefined; } +/** + * Type guard for NodeJS.ErrnoException (any error with a `code` property). + */ +export function isErrno(err: unknown): err is NodeJS.ErrnoException { + return Boolean(err && typeof err === "object" && "code" in err); +} + +/** + * Check if an error has a specific errno code. + */ +export function hasErrnoCode(err: unknown, code: string): boolean { + return isErrno(err) && err.code === code; +} + export function formatErrorMessage(err: unknown): string { if (err instanceof Error) { return err.message || err.name || "Error"; diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts index 970a1c11cea..33ad3823c5c 100644 --- a/src/infra/ports-inspect.ts +++ b/src/infra/ports-inspect.ts @@ -1,6 +1,7 @@ import net from "node:net"; import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js"; import { runCommandWithTimeout } from "../process/exec.js"; +import { isErrno } from "./errors.js"; import { buildPortHints } from "./ports-format.js"; import { resolveLsofCommand } from "./ports-lsof.js"; @@ -11,10 +12,6 @@ type CommandResult = { error?: string; }; -function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); -} - async function runCommandSafe(argv: string[], timeoutMs = 5_000): Promise { try { const res = await runCommandWithTimeout(argv, { timeoutMs }); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index cdbc395fe53..f8bc799c578 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -4,6 +4,7 @@ import type { PortListener, PortListenerKind, PortUsage, PortUsageStatus } from import { danger, info, shouldLogVerbose, warn } from "../globals.js"; import { logDebug } from "../logger.js"; import { defaultRuntime } from "../runtime.js"; +import { isErrno } from "./errors.js"; import { formatPortDiagnostics } from "./ports-format.js"; import { inspectPortUsage } from "./ports-inspect.js"; @@ -19,10 +20,6 @@ class PortInUseError extends Error { } } -function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); -} - export async function describePortOwner(port: number): Promise { const diagnostics = await inspectPortUsage(port); if (diagnostics.listeners.length === 0) { diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index 0ff4c680ec7..a2cc1106d45 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -1,4 +1,5 @@ import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +import { isRecord } from "../utils.js"; import { fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; @@ -148,10 +149,6 @@ const WINDOW_MINUTE_KEYS = [ "minutes", ] as const; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function pickNumber(record: Record, keys: readonly string[]): number | undefined { for (const key of keys) { const value = record[key]; diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts index a86169c8b6c..391bf2bcd3c 100644 --- a/src/infra/ssh-tunnel.ts +++ b/src/infra/ssh-tunnel.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import net from "node:net"; +import { isErrno } from "./errors.js"; import { ensurePortAvailable } from "./ports.js"; export type SshParsedTarget = { @@ -17,10 +18,6 @@ export type SshTunnel = { stop: () => Promise; }; -function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); -} - export function parseSshTarget(raw: string): SshParsedTarget | null { const trimmed = raw.trim().replace(/^ssh\s+/, ""); if (!trimmed) { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 023dc28d4dd..ed76e188b44 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { PluginConfigUiHint, PluginKind } from "./types.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { isRecord } from "../utils.js"; export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; @@ -30,10 +31,6 @@ function normalizeStringList(value: unknown): string[] { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); diff --git a/src/security/skill-scanner.ts b/src/security/skill-scanner.ts index 34e83bfe9cc..de14f7e57b6 100644 --- a/src/security/skill-scanner.ts +++ b/src/security/skill-scanner.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { hasErrnoCode } from "../infra/errors.js"; // --------------------------------------------------------------------------- // Types @@ -52,16 +53,6 @@ export function isScannable(filePath: string): boolean { return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } -function isErrno(err: unknown, code: string): boolean { - if (!err || typeof err !== "object") { - return false; - } - if (!("code" in err)) { - return false; - } - return (err as { code?: unknown }).code === code; -} - // --------------------------------------------------------------------------- // Rule definitions // --------------------------------------------------------------------------- @@ -327,7 +318,7 @@ async function resolveForcedFiles(params: { try { st = await fs.stat(includePath); } catch (err) { - if (isErrno(err, "ENOENT")) { + if (hasErrnoCode(err, "ENOENT")) { continue; } throw err; @@ -374,7 +365,7 @@ async function readScannableSource(filePath: string, maxFileBytes: number): Prom try { st = await fs.stat(filePath); } catch (err) { - if (isErrno(err, "ENOENT")) { + if (hasErrnoCode(err, "ENOENT")) { return null; } throw err; @@ -385,7 +376,7 @@ async function readScannableSource(filePath: string, maxFileBytes: number): Prom try { return await fs.readFile(filePath, "utf-8"); } catch (err) { - if (isErrno(err, "ENOENT")) { + if (hasErrnoCode(err, "ENOENT")) { return null; } throw err; diff --git a/src/slack/scopes.ts b/src/slack/scopes.ts index 7c49ff3059c..2cea7aaa7ea 100644 --- a/src/slack/scopes.ts +++ b/src/slack/scopes.ts @@ -1,4 +1,5 @@ import type { WebClient } from "@slack/web-api"; +import { isRecord } from "../utils.js"; import { createSlackWebClient } from "./client.js"; export type SlackScopesResult = { @@ -10,10 +11,6 @@ export type SlackScopesResult = { type SlackScopesSource = "auth.scopes" | "apps.permissions.info"; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function collectScopes(value: unknown, into: string[]) { if (!value) { return; diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts index 54a51c6b284..7910ff180b3 100644 --- a/src/telegram/audit.ts +++ b/src/telegram/audit.ts @@ -1,4 +1,5 @@ import type { TelegramGroupConfig } from "../config/types.js"; +import { isRecord } from "../utils.js"; import { makeProxyFetch } from "./proxy.js"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -38,10 +39,6 @@ async function fetchWithTimeout( } } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export function collectTelegramUnmentionedGroupIds( groups: Record | undefined, ) { diff --git a/src/utils.ts b/src/utils.ts index dbbdb402695..30d54762501 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -42,6 +42,27 @@ export function safeParseJson(raw: string): T | null { } } +/** + * Type guard for plain objects (not arrays, null, Date, RegExp, etc.). + * Uses Object.prototype.toString for maximum safety. + */ +export function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +/** + * Type guard for Record (less strict than isPlainObject). + * Accepts any non-null object that isn't an array. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export type WebChannel = "web"; export function assertWebChannel(input: string): asserts input is WebChannel {