refactor: centralize isPlainObject, isRecord, isErrno, isLoopbackHost utilities (#12926)

This commit is contained in:
max
2026-02-09 17:02:55 -08:00
committed by GitHub
parent 70f9edeec7
commit 8d75a496bf
37 changed files with 97 additions and 226 deletions

View File

@@ -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<string, unknown>): CliUsage | undefined {
return { input, output, cacheRead, cacheWrite, total };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function collectText(value: unknown): string {
if (!value) {
return "";

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function pickString(rec: Record<string, unknown>, key: string): string {
const v = rec[key];
return typeof v === "string" ? v : "";

View File

@@ -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<OpenClawConfig["models"]>;
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
function isRecord(value: unknown): value is Record<string, unknown> {
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 : [];

View File

@@ -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<string, unknown> {
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;
}

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function runBeforeToolCallHook(args: {
toolName: string;
params: unknown;

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stripThreadSuffixFromSessionKey(sessionKey: string): string {
const normalized = sessionKey.toLowerCase();
const idx = normalized.lastIndexOf(":thread:");

View File

@@ -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<string, unknown>) => Promise<unknown>;
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<string, string> = {}) {
const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
const mergedHeaders = { ...relayHeaders, ...headers };

View File

@@ -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) {

View File

@@ -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<void>;
};
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;

View File

@@ -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<string, unknown> {
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));

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function formatMatchMetadata(params: {
matchKey?: unknown;
matchSource?: unknown;

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
type UnrecognizedKeysIssue = ZodIssue & {
code: "unrecognized_keys";
keys: PropertyKey[];

View File

@@ -1,3 +1,5 @@
import { isPlainObject } from "../utils.js";
type PathNode = Record<string, unknown>;
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<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}

View File

@@ -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<string, unknown> {
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;

View File

@@ -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<string, unknown> {
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)) {

View File

@@ -10,8 +10,8 @@ export type LegacyConfigMigration = {
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
export const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
import { isRecord } from "../utils.js";
export { isRecord };
export const getRecord = (value: unknown): Record<string, unknown> | null =>
isRecord(value) ? value : null;

View File

@@ -1,8 +1,6 @@
type PlainObject = Record<string, unknown>;
import { isPlainObject } from "../utils.js";
function isPlainObject(value: unknown): value is PlainObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
type PlainObject = Record<string, unknown>;
export function applyMergePatch(base: unknown, patch: unknown): unknown {
if (!isPlainObject(patch)) {

View File

@@ -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<string, unknown> {
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;

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}

View File

@@ -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<string, unknown>;
@@ -19,15 +20,6 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
export function getConfigOverrides(): OverrideTree {
return overrides;
}

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function validateConfigObjectWithPlugins(raw: unknown):
| {
ok: true;

View File

@@ -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() : "";

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
if (!config) {
return true;

View File

@@ -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<string, unknown> {
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 [];

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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 "";

View File

@@ -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";

View File

@@ -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<CommandResult> {
try {
const res = await runCommandWithTimeout(argv, { timeoutMs });

View File

@@ -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<string | undefined> {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.listeners.length === 0) {

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function pickNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = record[key];

View File

@@ -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<void>;
};
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) {

View File

@@ -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<string, unknown> {
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);

View File

@@ -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;

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function collectScopes(value: unknown, into: string[]) {
if (!value) {
return;

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function collectTelegramUnmentionedGroupIds(
groups: Record<string, TelegramGroupConfig> | undefined,
) {

View File

@@ -42,6 +42,27 @@ export function safeParseJson<T>(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<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Type guard for Record<string, unknown> (less strict than isPlainObject).
* Accepts any non-null object that isn't an array.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export type WebChannel = "web";
export function assertWebChannel(input: string): asserts input is WebChannel {