refactor: consolidate coercion contracts (#122458)

* refactor: consolidate coercion contracts

Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics.

Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit.

* fix: preserve standalone script coercions

Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
This commit is contained in:
Peter Steinberger
2026-08-11 23:26:37 -07:00
committed by GitHub
parent 66fe424590
commit b080dd1e76
276 changed files with 1685 additions and 1663 deletions
@@ -1,6 +1,6 @@
/** Tests live model switching behavior in active agent command sessions. */
import { expectDefined } from "@openclaw/normalization-core";
import { expectDefined, toStringifiedError } from "@openclaw/normalization-core";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
@@ -274,8 +274,7 @@ vi.mock("../acp/policy.js", () => ({
}));
vi.mock("../acp/runtime/errors.js", () => ({
toAcpRuntimeError: ({ error }: { error: unknown }) =>
error instanceof Error ? error : new Error(String(error)),
toAcpRuntimeError: ({ error }: { error: unknown }) => toStringifiedError(error),
}));
vi.mock("@openclaw/acp-core/runtime/session-identifiers", () => ({
+2 -5
View File
@@ -5,6 +5,7 @@
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { coerceSecretRef } from "../../config/types.secrets.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
@@ -56,11 +57,7 @@ function isRetainedUsageStatsId(
// Persisted credential normalization accepts old field names and SecretRef-ish
// values, then emits the current credential discriminated union.
function normalizeOptionalCredentialString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? value : undefined;
return readNonBlankString(value);
}
function normalizeExpiryField(value: unknown): number | undefined {
@@ -1,4 +1,5 @@
/** Prepares exec workdir and environment facts before policy and host dispatch. */
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatChannelId } from "../channels/ids.js";
import type { ExecHost } from "../infra/exec-approvals.js";
@@ -96,7 +97,7 @@ function buildChannelContextEnv(
}
function isExecToolArgsObject(value: unknown): value is ExecToolArgs {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isRecord(value);
}
function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, string> | undefined {
+2 -5
View File
@@ -1,3 +1,4 @@
import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
createReasoningTagTextPartitioner,
@@ -470,11 +471,7 @@ function readThinkingProgressTokens(delta: Record<string, unknown>): number | un
if (delta.type !== "thinking_delta" || delta.thinking !== "") {
return undefined;
}
const estimatedTokens = delta.estimated_tokens;
if (typeof estimatedTokens !== "number" || !Number.isFinite(estimatedTokens)) {
return undefined;
}
return estimatedTokens > 0 ? estimatedTokens : undefined;
return asPositiveFiniteNumber(delta.estimated_tokens);
}
function emitClaudeThinkingProgress(
@@ -11,6 +11,7 @@ import { stableStringify } from "@openclaw/normalization-core";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
parseDateStringTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
@@ -203,10 +204,7 @@ async function appendGooglePromptCacheEntry(
}
function parseExpireTimeMs(expireTime: string | undefined): number | null {
if (!expireTime) {
return null;
}
return asDateTimestampMs(Date.parse(expireTime)) ?? null;
return parseDateStringTimestampMs(expireTime) ?? null;
}
function convertManagedGoogleTools(tools: NonNullable<GooglePromptCacheContext["tools"]>) {
@@ -1,6 +1,7 @@
/**
* Reads normalized context-token metadata from resolved model definitions.
*/
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import type { Model } from "../../llm/types.js";
/**
@@ -14,5 +15,5 @@ type AgentModelWithOptionalContextTokens = Model & {
/** Prefer contextTokens, then contextWindow, when present on model metadata. */
export function readAgentModelContextTokens(model: Model | null | undefined): number | undefined {
const value = (model as AgentModelWithOptionalContextTokens | null | undefined)?.contextTokens;
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
return asFiniteNumber(value);
}
@@ -1,4 +1,5 @@
import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { Api, Model } from "../../llm/types.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
@@ -272,11 +273,7 @@ export function resolveProviderTransport(params: {
}
export function normalizeTransportBaseUrl(baseUrl: unknown): string | undefined {
if (typeof baseUrl !== "string") {
return undefined;
}
const trimmed = baseUrl.trim();
return trimmed ? trimmed : undefined;
return normalizeOptionalString(baseUrl);
}
export function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined {
@@ -71,7 +71,7 @@ function hasNonEmptyAssistantText(texts: string[]): boolean {
return texts.some((text) => text.trim().length > 0);
}
function hasNonEmptyString(values: string[]): boolean {
function hasAnyNonBlankString(values: string[]): boolean {
return values.some((value) => value.trim().length > 0);
}
@@ -82,8 +82,8 @@ function hasCommittedMessagingDeliveryEvidence(
>,
): boolean {
return (
hasNonEmptyString(params.messagingToolSentTexts) ||
hasNonEmptyString(params.messagingToolSentMediaUrls) ||
hasAnyNonBlankString(params.messagingToolSentTexts) ||
hasAnyNonBlankString(params.messagingToolSentMediaUrls) ||
params.messagingToolSentTargets.length > 0
);
}
+2 -1
View File
@@ -5,6 +5,7 @@
* before-finalize retry/finalize decisions with bounded retry accounting.
*/
import { createHash } from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -240,5 +241,5 @@ function readBeforeAgentFinalizeRetryCandidates(
function isBeforeAgentFinalizeRetry(
value: unknown,
): value is NonNullable<PluginHookBeforeAgentFinalizeResult["retry"]> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
return isRecord(value);
}
@@ -1,4 +1,5 @@
import path from "node:path";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { resolveStateDir } from "../../config/paths.js";
import {
listConfiguredSessionStoreAgentIds,
@@ -64,9 +65,7 @@ export function normalizeStringSet(values: Iterable<string> | undefined): Set<st
return normalized;
}
export function normalizeFiniteTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export const normalizeFiniteTimestamp = asFiniteNumber;
export function hasCurrentProcessOwner(params: {
activeSessionIds: Set<string>;
+3 -2
View File
@@ -2,6 +2,7 @@
* Resolves MCP transport command, environment, and timeout configuration.
*/
import {
asPositiveFiniteNumber,
clampPositiveTimerTimeoutMs,
resolvePositiveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
@@ -68,8 +69,8 @@ function getPositiveNumber(rawServer: unknown, keys: readonly string[]): number
}
const record = rawServer as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
const value = asPositiveFiniteNumber(record[key]);
if (value !== undefined) {
return value;
}
}
+3 -1
View File
@@ -1,7 +1,9 @@
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
export class PreparedModelRuntimeOwnerNotPublishedError extends Error {}
export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {}
export function toPreparedModelRuntimeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
return toStringifiedError(error);
}
+11 -10
View File
@@ -3,6 +3,7 @@
*/
import type { KeyId } from "@earendil-works/pi-tui";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import type { ImageContent, Model } from "../../../llm/types.js";
import { interactiveAgentTheme as theme, type Theme } from "../../modes/interactive/theme/theme.js";
import type { AgentMessage } from "../../runtime/index.js";
@@ -335,7 +336,7 @@ export class ExtensionRunner {
this.emitError({
extensionPath,
event: "register_provider",
error: err instanceof Error ? err.message : String(err),
error: coerceErrorMessage(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
@@ -734,7 +735,7 @@ export class ExtensionRunner {
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -782,7 +783,7 @@ export class ExtensionRunner {
currentMessage = handlerResult.message;
modified = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -830,7 +831,7 @@ export class ExtensionRunner {
modified = true;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -894,7 +895,7 @@ export class ExtensionRunner {
return handlerResult as UserBashEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -934,7 +935,7 @@ export class ExtensionRunner {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -970,7 +971,7 @@ export class ExtensionRunner {
currentPayload = handlerResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1031,7 +1032,7 @@ export class ExtensionRunner {
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1094,7 +1095,7 @@ export class ExtensionRunner {
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1140,7 +1141,7 @@ export class ExtensionRunner {
this.emitError({
extensionPath: ext.path,
event: "input",
error: err instanceof Error ? err.message : String(err),
error: coerceErrorMessage(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
+2 -3
View File
@@ -9,6 +9,7 @@ import {
asSafeIntegerInRange,
parseStrictFiniteNumber,
} from "@openclaw/normalization-core/number-coercion";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import type { TSchema } from "typebox";
import { readLocalFileSafely } from "../../infra/fs-safe.js";
@@ -70,9 +71,7 @@ export type AnyAgentTool = Omit<AgentTool, "execute"> &
};
export function asToolParamsRecord(params: unknown): Record<string, unknown> {
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
return asNonArrayRecord(params);
}
type StringParamOptions = {
+2 -1
View File
@@ -3,6 +3,7 @@
*
* Implements only the Gateway calls needed by session tools and rejects unsupported methods.
*/
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { normalizeFastMode, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type {
SessionsListParams,
@@ -145,7 +146,7 @@ function readChatHistoryMessageSeq(message: unknown): number | undefined {
return undefined;
}
const seq = (metadata as Record<string, unknown>).seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
return asPositiveSafeInteger(seq);
}
function resolveChatHistoryNextOffset(params: {
@@ -3,7 +3,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { coerceErrorMessage as formatLiveError, expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import type { ModelApi } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -110,10 +110,6 @@ function readJpegDimensions(buffer: Buffer): { width: number; height: number } {
throw new Error("JPEG dimensions not found");
}
function formatLiveError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isSkippableLiveError(error: unknown): boolean {
const message = formatLiveError(error);
return (
+2 -1
View File
@@ -3,6 +3,7 @@
*
* Reads bounded, redacted session transcript history after session visibility filtering.
*/
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -216,7 +217,7 @@ function readHistoryMessageSeq(message: unknown): number | undefined {
return undefined;
}
const seq = (meta as Record<string, unknown>).seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
return asPositiveSafeInteger(seq);
}
function readHistoryMessageId(message: unknown): string | undefined {
+10 -17
View File
@@ -1,4 +1,5 @@
/** Auto-reply dispatch orchestration, hook composition, and foreground delivery fencing. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatType } from "../channels/chat-type.js";
import { isChannelPartialDeliveryError } from "../channels/turn/delivery-result.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -76,25 +77,17 @@ function applyRuntimeToolsAllow(
};
}
function normalizeForegroundReplyFencePart(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveForegroundReplyFenceKey(finalized: FinalizedMsgContext): string | undefined {
const sessionKey = normalizeForegroundReplyFencePart(finalized.SessionKey);
const sessionKey = normalizeOptionalString(finalized.SessionKey);
const channel =
normalizeForegroundReplyFencePart(finalized.OriginatingChannel) ??
normalizeForegroundReplyFencePart(finalized.Surface) ??
normalizeForegroundReplyFencePart(finalized.Provider);
normalizeOptionalString(finalized.OriginatingChannel) ??
normalizeOptionalString(finalized.Surface) ??
normalizeOptionalString(finalized.Provider);
const target =
normalizeForegroundReplyFencePart(finalized.OriginatingTo) ??
normalizeForegroundReplyFencePart(finalized.NativeChannelId) ??
normalizeForegroundReplyFencePart(finalized.From) ??
normalizeForegroundReplyFencePart(finalized.To);
normalizeOptionalString(finalized.OriginatingTo) ??
normalizeOptionalString(finalized.NativeChannelId) ??
normalizeOptionalString(finalized.From) ??
normalizeOptionalString(finalized.To);
if (!sessionKey || !channel || !target) {
return undefined;
@@ -104,7 +97,7 @@ function resolveForegroundReplyFenceKey(finalized: FinalizedMsgContext): string
return JSON.stringify([
"foreground",
channel,
normalizeForegroundReplyFencePart(finalized.AccountId) ?? "default",
normalizeOptionalString(finalized.AccountId) ?? "default",
sessionKey,
normalizeChatType(finalized.ChatType) ?? "unknown",
target,
+5 -6
View File
@@ -1,5 +1,8 @@
import { asPositiveFiniteNumber as normalizePairingQrExpiresAtMs } from "@openclaw/normalization-core/number-coercion";
import { readNonBlankString as normalizeTtsSupplementSpokenText } from "@openclaw/normalization-core/string-coerce";
import {
readNonBlankString,
readNonBlankString as normalizeTtsSupplementSpokenText,
} from "@openclaw/normalization-core/string-coerce";
import type { OutboundLocation } from "../channels/location.js";
/** Reply payload contracts and metadata helpers shared by dispatch and channel renderers. */
import type { ReplyToMode } from "../config/types.base.js";
@@ -117,10 +120,6 @@ type PairingQrReplyChannelData = {
expiresAtMs: number;
};
function normalizePairingQrSetupCode(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function readPairingQrReplyChannelData(
payload: Pick<ReplyPayload, "channelData">,
): PairingQrReplyChannelData | undefined {
@@ -129,7 +128,7 @@ export function readPairingQrReplyChannelData(
return undefined;
}
const record = raw as Record<string, unknown>;
const setupCode = normalizePairingQrSetupCode(record.setupCode);
const setupCode = readNonBlankString(record.setupCode);
const expiresAtMs = normalizePairingQrExpiresAtMs(record.expiresAtMs);
return setupCode && expiresAtMs ? { setupCode, expiresAtMs } : undefined;
}
+4 -2
View File
@@ -5,7 +5,10 @@
*/
import fs from "node:fs";
import os from "node:os";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import {
hasNonEmptyString,
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import {
hasBundledChannelPersistedAuthState,
@@ -13,7 +16,6 @@ import {
} from "../channels/plugins/persisted-auth-state.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { hasNonEmptyString } from "../infra/outbound/channel-target.js";
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
import { listOfficialExternalChannelEnvVars } from "../plugins/official-external-plugin-catalog.js";
import { isRecord } from "../utils.js";
@@ -1,4 +1,5 @@
// Retry policy: backoff, attempt floor + age gate for dead-letter.
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { describe, expect, it } from "vitest";
import {
DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
@@ -141,7 +142,7 @@ describe("ingress retry policy", () => {
receivedAt,
attempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS - 1,
},
formatError: (err) => (err instanceof Error ? err.message : String(err)),
formatError: coerceErrorMessage,
now: receivedAt + DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS - 1,
});
expect(young.kind).toBe("release");
@@ -153,7 +154,7 @@ describe("ingress retry policy", () => {
receivedAt,
attempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS - 1,
},
formatError: (err) => (err instanceof Error ? err.message : String(err)),
formatError: coerceErrorMessage,
now: receivedAt + DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
});
expect(aged).toMatchObject({
+5 -8
View File
@@ -1,5 +1,8 @@
// Thread-binding policy resolution for channel/account session spawning.
import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion";
import {
asNonNegativeFiniteNumber,
MAX_DATE_TIMESTAMP_MS,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAccountId } from "../routing/session-key.js";
@@ -89,13 +92,7 @@ function normalizeBoolean(value: unknown): boolean | undefined {
}
function normalizeThreadBindingHours(raw: unknown): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
if (raw < 0) {
return undefined;
}
return raw;
return asNonNegativeFiniteNumber(raw);
}
function resolveThreadBindingHoursMs(raw: unknown, fallbackHours: number): number {
+8 -20
View File
@@ -2,7 +2,7 @@
import type { Stats } from "node:fs";
import { lstat, mkdir, rmdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
import { listAgentEntries } from "../agents/agent-scope.js";
import { transformConfigFileWithRetry } from "../config/config.js";
@@ -312,7 +312,7 @@ export async function applyClawAddPlan(
? error
: new ClawPackageInstallError(
"package_install_failed",
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
packages,
);
const installStatus = preserveRecordedPhaseOrMarkPartial();
@@ -435,7 +435,7 @@ export async function applyClawAddPlan(
installStatus,
error: {
code: error instanceof ClawBootstrapWriteError ? error.code : "bootstrap_write_failed",
message: error instanceof Error ? error.message : String(error),
message: coerceErrorMessage(error),
},
nowMs: options.nowMs,
});
@@ -523,7 +523,7 @@ export async function applyClawAddPlan(
installStatus,
error: {
code: error instanceof ClawAddMutationError ? error.code : "config_commit_failed",
message: error instanceof Error ? error.message : String(error),
message: coerceErrorMessage(error),
},
nowMs: options.nowMs,
});
@@ -544,7 +544,7 @@ export async function applyClawAddPlan(
code: "workspace_file_io_error",
phase: "mutation",
path: "$.workspace",
message: error instanceof Error ? error.message : String(error),
message: coerceErrorMessage(error),
},
],
workspaceFiles,
@@ -597,11 +597,7 @@ export async function applyClawAddPlan(
const packageError =
error instanceof ClawPackageInstallError
? error
: new ClawPackageInstallError(
"package_install_failed",
error instanceof Error ? error.message : String(error),
[],
);
: new ClawPackageInstallError("package_install_failed", coerceErrorMessage(error), []);
return partialResult({
plan,
installRecord,
@@ -623,11 +619,7 @@ export async function applyClawAddPlan(
const mcpError =
error instanceof ClawMcpInstallError
? error
: new ClawMcpInstallError(
"mcp_install_failed",
error instanceof Error ? error.message : String(error),
mcpServers,
);
: new ClawMcpInstallError("mcp_install_failed", coerceErrorMessage(error), mcpServers);
markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options);
return partialResult({
plan,
@@ -650,11 +642,7 @@ export async function applyClawAddPlan(
const cronError =
error instanceof ClawCronInstallError
? error
: new ClawCronInstallError(
"cron_install_failed",
error instanceof Error ? error.message : String(error),
cronJobs,
);
: new ClawCronInstallError("cron_install_failed", coerceErrorMessage(error), cronJobs);
markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options);
return partialResult({
plan,
+7 -10
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import {
CLAW_CRON_REF_SCHEMA_VERSION,
@@ -94,7 +94,7 @@ export async function applyClawCronUpdate(
try {
raw = await gateway.add(clawCronGatewayInput(updatePlan.agentId, ref));
} catch (error) {
throw new ClawCronUpdateError(error instanceof Error ? error.message : String(error), true);
throw new ClawCronUpdateError(coerceErrorMessage(error), true);
}
const result = clawCronSchedulerJobFromResult(raw);
if (!result) {
@@ -108,7 +108,7 @@ export async function applyClawCronUpdate(
try {
await revert();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
failures.push(coerceErrorMessage(error));
}
}
if (failures.length > 0) {
@@ -142,10 +142,7 @@ export async function applyClawCronUpdate(
try {
await gateway.remove(previous.schedulerJobId);
} catch (error) {
throw new ClawCronUpdateError(
error instanceof Error ? error.message : String(error),
true,
);
throw new ClawCronUpdateError(coerceErrorMessage(error), true);
}
undo.push(async () => {
const restoredId = await add(previous);
@@ -174,7 +171,7 @@ export async function applyClawCronUpdate(
}
} catch (error) {
throw new ClawCronUpdateError(
`cron.add did not converge and cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
`cron.add did not converge and cleanup failed: ${coerceErrorMessage(error)}`,
true,
);
}
@@ -200,12 +197,12 @@ export async function applyClawCronUpdate(
await rollback();
} catch (rollbackError) {
throw new ClawCronUpdateError(
`${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`,
true,
);
}
throw new ClawCronUpdateError(
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
error instanceof ClawCronUpdateError && error.partial,
);
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { resolveCronJobConfigRevision } from "../cron/config-revision.js";
import { normalizeCronJobCreate } from "../cron/normalize.js";
import { createTrustedCronScheduledToolPolicy } from "../cron/scheduled-tool-policy.js";
@@ -340,7 +341,7 @@ export async function installClawCronJobs(
throw new Error("cron.add returned no scheduler job id");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
refs[refs.length - 1] = updateRef(pending, { status: "pending", error: message }, options);
throw new ClawCronInstallError("cron_install_failed", message, refs);
}
@@ -351,7 +352,7 @@ export async function installClawCronJobs(
options,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
throw new ClawCronInstallError(
"cron_provenance_failed",
`cron.add succeeded, but its scheduler id could not be persisted: ${message}`,
+3 -3
View File
@@ -1,7 +1,7 @@
// Claw doctor diagnostics project the lifecycle ownership ledger into health findings.
import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveDefaultCronStaggerMs } from "../cron/stagger.js";
@@ -369,7 +369,7 @@ export async function collectClawStateHealthFindings(
} catch (error) {
cronInventory = {
ok: false,
error: error instanceof Error ? error.message : String(error),
error: coerceErrorMessage(error),
};
}
}
@@ -384,7 +384,7 @@ export async function collectClawStateHealthFindings(
return [
finding({
severity: "error",
message: `Could not inspect Claw lifecycle state: ${error instanceof Error ? error.message : String(error)}`,
message: `Could not inspect Claw lifecycle state: ${coerceErrorMessage(error)}`,
requirement: "Claw doctor diagnostics require readable lifecycle state",
}),
];
+2 -4
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { closeSync } from "node:fs";
import { mkdir, realpath, rm } from "node:fs/promises";
import { basename, dirname, relative, resolve, sep } from "node:path";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { stringify as stringifyYaml } from "yaml";
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js";
@@ -606,10 +607,7 @@ export async function exportClawAgent(
if (error instanceof ClawExportError) {
throw error;
}
throw new ClawExportError(
"export_write_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawExportError("export_write_failed", coerceErrorMessage(error));
}
return {
schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION,
+3 -2
View File
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
import { listAgentEntries, resolveAgentDir } from "../agents/agent-scope.js";
import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES } from "../agents/workspace-bootstrap-read.js";
@@ -268,7 +269,7 @@ export async function cleanupClawAgentFilesystem(params: {
}
deleteWorkspaceState(statePlan);
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
errors.push(coerceErrorMessage(error));
}
} else {
errors.push(`Could not trash workspace ${params.targets.workspaceDir}.`);
@@ -347,7 +348,7 @@ async function inspectDigestOwnedWorkspaceFile(
}
return {
state: "unsafe",
message: error instanceof Error ? error.message : String(error),
message: coerceErrorMessage(error),
};
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
@@ -75,7 +76,7 @@ export async function removeClawMcpServers(params: {
deleteClawMcpServerRef(params.agentId, server.name, params.options);
mcpServers.push({ name: server.name, action: result.removed ? "removed" : "missing" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
mcpServers.push({ name: server.name, action: "error", message });
return { mcpServers, error: message };
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js";
import { getRuntimeConfig } from "../config/config.js";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
@@ -565,7 +565,7 @@ export async function applyClawRemovePlan(
action: "removed",
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
cronJobs.push({
manifestId: cron.manifestId,
schedulerJobId: cron.schedulerJobId,
+4 -3
View File
@@ -1,3 +1,4 @@
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { setConfiguredMcpServer, unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -72,7 +73,7 @@ export async function applyClawMcpUpdate(
try {
await revert();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
failures.push(coerceErrorMessage(error));
}
}
if (failures.length > 0) {
@@ -201,12 +202,12 @@ export async function applyClawMcpUpdate(
await rollback();
} catch (rollbackError) {
throw new ClawMcpUpdateError(
`${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`,
true,
);
}
throw new ClawMcpUpdateError(
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
configMutationUncertain || (error instanceof ClawMcpUpdateError && error.partial),
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { setConfiguredMcpServer } from "../agents/mcp-config-mutation.js";
import { canonicalizeConfiguredMcpServer } from "../config/mcp-config-normalize.js";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
@@ -257,7 +257,7 @@ export async function installClawMcpServers(
recordIndependentOwner: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
throw new ClawMcpInstallError("mcp_install_uncertain", message, refs);
}
if (!result.ok) {
@@ -271,7 +271,7 @@ export async function installClawMcpServers(
try {
refs[refs.length - 1] = updateRef(pending, { status: "complete" }, options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
throw new ClawMcpInstallError(
"mcp_provenance_failed",
`MCP server was configured, but ownership could not be persisted: ${message}`,
+2 -1
View File
@@ -1,3 +1,4 @@
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub-artifacts.js";
import { resolveInstalledClawHubPlugin } from "../plugins/plugin-install-preflight.js";
@@ -578,7 +579,7 @@ async function applyClawPackageRemovalsUnlocked(
results.push({
...base,
action: "error",
reason: error instanceof Error ? error.message : String(error),
reason: coerceErrorMessage(error),
});
} finally {
try {
+5 -5
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { preflightPluginInstall } from "../plugins/plugin-install-preflight.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import {
@@ -77,7 +77,7 @@ export async function applyClawPackageUpdate(
try {
await revert();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
failures.push(coerceErrorMessage(error));
}
}
if (externalMutations.length > 0) {
@@ -271,7 +271,7 @@ export async function applyClawPackageUpdate(
} catch (error) {
if (externalMutations.length > 0) {
throw new ClawPackageUpdateError(
`${error instanceof Error ? error.message : String(error)}; package artifact outcome requires reconciliation`,
`${coerceErrorMessage(error)}; package artifact outcome requires reconciliation`,
true,
);
}
@@ -279,12 +279,12 @@ export async function applyClawPackageUpdate(
await rollback();
} catch (rollbackError) {
throw new ClawPackageUpdateError(
`${error instanceof Error ? error.message : String(error)}; rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`${coerceErrorMessage(error)}; rollback incomplete: ${coerceErrorMessage(rollbackError)}`,
externalMutations.length > 0,
);
}
throw new ClawPackageUpdateError(
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
error instanceof ClawPackageUpdateError ? error.partial : false,
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { runPluginInstallCommand } from "../cli/plugins-install-command.js";
import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub-artifacts.js";
@@ -674,7 +674,7 @@ async function installClawPackagesUnlocked(
);
} catch (rollbackError) {
rollbackErrors.push(
`could not remove plugin ${installedPlugin.installId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`could not remove plugin ${installedPlugin.installId}: ${coerceErrorMessage(rollbackError)}`,
);
continue;
} finally {
@@ -685,7 +685,7 @@ async function installClawPackagesUnlocked(
}
}
}
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
if (rollbackErrors.length > 0) {
throw new ClawPackageInstallError(
"package_rollback_failed",
+3 -2
View File
@@ -1,5 +1,6 @@
import { lstat, mkdir, readdir, realpath, rmdir, unlink, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import { readClawManifestFile } from "./reader.js";
import { isCanonicalClawHubPackageName, portableClawPathKey } from "./schema-portability.js";
@@ -282,7 +283,7 @@ export async function validateClawProject(
diagnostic(
error instanceof ClawProjectError ? error.code : "project_discovery_failed",
"$",
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
),
],
};
@@ -434,7 +435,7 @@ export async function validateClawProject(
diagnostic(
error instanceof ClawProjectError ? error.code : "project_enumeration_failed",
"$",
error instanceof Error ? error.message : String(error),
coerceErrorMessage(error),
),
],
};
+35 -94
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core";
import { listAgentEntries } from "../agents/agent-scope.js";
import { transformConfigFileWithRetry } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
@@ -288,10 +288,7 @@ export async function applyClawUpdatePlan(
if (error instanceof ClawPackageUpdateError && error.partial) {
throw partialMutation(error.message);
}
throw new ClawUpdateMutationError(
"package_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("package_update_failed", coerceErrorMessage(error));
}
const retainedRequirementMutation = requirementExecution.appliedIds.length > 0;
@@ -305,13 +302,10 @@ export async function applyClawUpdatePlan(
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
throw new ClawUpdateMutationError(
"workspace_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("workspace_update_failed", coerceErrorMessage(error));
}
const applyMcp = options.applyMcp ?? applyClawMcpUpdate;
@@ -324,7 +318,7 @@ export async function applyClawUpdatePlan(
await workspaceExecution.rollback();
} catch (rollbackError) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`${coerceErrorMessage(error)}; workspace rollback failed: ${coerceErrorMessage(rollbackError)}`,
);
}
if (partial) {
@@ -332,13 +326,10 @@ export async function applyClawUpdatePlan(
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
throw new ClawUpdateMutationError(
"mcp_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("mcp_update_failed", coerceErrorMessage(error));
}
let packageExecution: ClawPackageUpdateExecution;
@@ -349,34 +340,25 @@ export async function applyClawUpdatePlan(
try {
await mcpExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await workspaceExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
if (error instanceof ClawPackageUpdateError && error.partial) {
rollbackFailures.unshift("package artifact rollback is unavailable");
}
if (rollbackFailures.length > 0) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`,
);
throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`);
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
throw new ClawUpdateMutationError(
"package_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("package_update_failed", coerceErrorMessage(error));
}
const agentAction = fresh.actions.find((action) => action.kind === "agent");
@@ -445,48 +427,35 @@ export async function applyClawUpdatePlan(
try {
await rollbackAgent();
} catch (rollbackError) {
rollbackFailures.push(
`agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await packageExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`);
}
try {
await mcpExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await workspaceExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
if (rollbackFailures.length > 0) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`,
);
throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`);
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
if (error instanceof ClawUpdateMutationError) {
throw error;
}
throw new ClawUpdateMutationError(
"agent_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("agent_update_failed", coerceErrorMessage(error));
}
}
@@ -505,7 +474,7 @@ export async function applyClawUpdatePlan(
});
} catch (persistError) {
throw partialMutation(
`${error.message}; cron gateway mutation outcome is uncertain; provenance update failed: ${persistError instanceof Error ? persistError.message : String(persistError)}`,
`${error.message}; cron gateway mutation outcome is uncertain; provenance update failed: ${coerceErrorMessage(persistError)}`,
);
}
throw partialMutation(`${error.message}; cron gateway mutation outcome is uncertain`);
@@ -514,45 +483,32 @@ export async function applyClawUpdatePlan(
try {
await rollbackAgent();
} catch (rollbackError) {
rollbackFailures.push(
`agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await packageExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`);
}
try {
await mcpExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await workspaceExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
if (rollbackFailures.length > 0) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`,
);
throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`);
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
throw new ClawUpdateMutationError(
"cron_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("cron_update_failed", coerceErrorMessage(error));
}
let installRecord: PersistedClawInstall;
@@ -566,52 +522,37 @@ export async function applyClawUpdatePlan(
try {
await rollbackAgent();
} catch (rollbackError) {
rollbackFailures.push(
`agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await packageExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`);
}
try {
await cronExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`cron rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`cron rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await mcpExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
try {
await workspaceExecution.rollback();
} catch (rollbackError) {
rollbackFailures.push(
`workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
);
rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`);
}
if (rollbackFailures.length > 0) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`,
);
throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`);
}
if (retainedRequirementMutation) {
throw partialMutation(
`${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`,
`${coerceErrorMessage(error)}; successfully realized shared requirements were retained`,
);
}
throw new ClawUpdateMutationError(
"provenance_update_failed",
error instanceof Error ? error.message : String(error),
);
throw new ClawUpdateMutationError("provenance_update_failed", coerceErrorMessage(error));
}
return {
schemaVersion: CLAW_UPDATE_RESULT_SCHEMA_VERSION,
+3 -2
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { resolve, sep } from "node:path";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import type { ClawAddPlan } from "./types.js";
@@ -74,7 +75,7 @@ export async function applyClawWorkspaceUpdate(
try {
await revert();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
failures.push(coerceErrorMessage(error));
}
}
if (failures.length > 0) {
@@ -194,7 +195,7 @@ export async function applyClawWorkspaceUpdate(
await rollback();
} catch (rollbackError) {
throw new ClawWorkspaceUpdateError(
`${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
`${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`,
true,
);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import { realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { root as fsSafeRoot, FsSafeError, type Root } from "../infra/fs-safe.js";
import {
openOpenClawStateDatabase,
@@ -499,7 +500,7 @@ export async function createClawWorkspaceFiles(
? `workspace_file_${error.code}`
: "workspace_file_io_error";
throw new ClawWorkspaceWriteError(
[diagnostic(action, code, error instanceof Error ? error.message : String(error))],
[diagnostic(action, code, coerceErrorMessage(error))],
createdFiles,
);
}
+1 -1
View File
@@ -223,7 +223,7 @@ export function formatConfigUnsetMissingPathMessage(params: {
}
function isSchemaRecord(value: unknown): value is JsonSchemaRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
return isPlainRecord(value);
}
function schemaTypes(schema: JsonSchemaRecord): Set<string> {
+2 -4
View File
@@ -1,5 +1,6 @@
// Collects daemon status from service files, config snapshots, ports, probes, and plugin drift.
import fs from "node:fs/promises";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import JSON5 from "json5";
import type { classifyGatewayConnectFailure } from "../../../packages/gateway-protocol/src/connect-error-details.js";
@@ -174,10 +175,7 @@ function resolveSnapshotRuntimeConfig(snapshot: ConfigFileSnapshot | null): Open
}
function coerceStatusConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
return asNonArrayRecord(value) as OpenClawConfig;
}
function hasOwnKey(value: unknown, key: string): boolean {
+7 -8
View File
@@ -1,7 +1,10 @@
// Gateway logs CLI with RPC tailing, local file fallback, and systemd journal fallback.
import { setTimeout as delay } from "node:timers/promises";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { coerceErrorMessage as normalizeErrorMessage } from "@openclaw/normalization-core/error-coercion";
import {
coerceErrorMessage as normalizeErrorMessage,
toStringifiedError,
} from "@openclaw/normalization-core/error-coercion";
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
@@ -205,10 +208,6 @@ async function fetchLogs(
}
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boolean {
// Fallback reads local files only for implicit loopback Gateway RPC failures.
if (!isLocalGatewayRpcUnavailableError(error)) {
@@ -612,9 +611,9 @@ export function registerLogsCli(program: Command) {
return { payload: result.payload, gatewayPollStartedAt: result.startedAt };
}
if (!shouldUseLocalLogsFallback(opts, result.error)) {
throw normalizeError(result.error);
throw toStringifiedError(result.error);
}
fallbackError = normalizeError(result.error);
fallbackError = toStringifiedError(result.error);
}
const activeProbe = gatewayRecovery.kind === "probing" ? gatewayRecovery.promise : undefined;
@@ -633,7 +632,7 @@ export function registerLogsCli(program: Command) {
if (result.ok) {
return { payload: result.payload, gatewayPollStartedAt: result.startedAt };
}
throw normalizeError(result.error);
throw toStringifiedError(result.error);
}
throw fallbackError ?? new Error("Active systemd journal unavailable for logs follow");
};
-2
View File
@@ -273,8 +273,6 @@ vi.mock("./one-shot-exit.js", () => ({
vi.mock("../infra/env.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/env.js")>()),
isTruthyEnvValue: (value?: string) =>
typeof value === "string" && ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()),
normalizeEnv: normalizeEnvMock,
}));
+2 -3
View File
@@ -42,9 +42,8 @@ vi.mock("./dotenv.js", () => ({
loadCliDotEnv: dotenvState.loadDotEnv,
}));
vi.mock("../infra/env.js", () => ({
isTruthyEnvValue: (value?: string) =>
typeof value === "string" && ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()),
vi.mock("../infra/env.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/env.js")>()),
normalizeEnv: vi.fn(),
}));
+5 -4
View File
@@ -272,13 +272,14 @@ vi.mock("../process/exec.js", () => ({
}));
vi.mock("../utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils.js")>();
const isMockRecord = (value: unknown) =>
typeof value === "object" && value !== null && !Array.isArray(value);
const [actual, { isRecord }] = await Promise.all([
importOriginal<typeof import("../utils.js")>(),
import("@openclaw/normalization-core/record-coerce"),
]);
return {
...actual,
displayString: (input: string) => input,
isRecord: isMockRecord,
isRecord,
pathExists: (...args: unknown[]) => pathExists(...args),
resolveConfigDir: () => "/tmp/openclaw-config",
sleep: vi.fn(async () => undefined),
+2 -5
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import * as tar from "tar";
import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/engine-storage.js";
@@ -236,11 +237,7 @@ async function extractManifest(params: {
manifestContentPromise =
entry.size > MAX_MANIFEST_BYTES
? Promise.resolve(limitError)
: entry
.concat()
.catch((error: unknown) =>
error instanceof Error ? error : new Error(String(error)),
);
: entry.concat().catch((error: unknown) => toStringifiedError(error));
},
});
+15 -31
View File
@@ -32,12 +32,9 @@ const noteImplicitFallbackClobberWarningsMock = vi.hoisted(() =>
}
}),
);
const legacyConfigMigrationForTest = vi.hoisted(() => {
function readNullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
const legacyConfigMigrationForTest = await vi.hoisted(async () => {
const { asNullableRecord: readNullableRecord } =
await import("@openclaw/normalization-core/record-coerce");
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const current = readNullableRecord(parent[key]);
@@ -341,7 +338,9 @@ vi.mock("../config/validation.js", () => ({
validateConfigObjectWithPlugins: vi.fn((config: unknown) => ({ ok: true, config })),
}));
vi.mock("../config/legacy.js", () => {
vi.mock("../config/legacy.js", async () => {
const { asNullableRecord: readNullableRecord } =
await import("@openclaw/normalization-core/record-coerce");
type LegacyRule = {
path: string[];
message: string;
@@ -349,12 +348,6 @@ vi.mock("../config/legacy.js", () => {
requireSourceLiteral?: boolean;
};
function readNullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getPathValue(root: Record<string, unknown>, pathParts: readonly string[]): unknown {
let cursor: unknown = root;
for (const part of pathParts) {
@@ -867,12 +860,9 @@ vi.mock("./doctor/channel-capabilities.js", () => {
};
});
vi.mock("../plugins/doctor-contract-registry.js", () => {
function readNullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
vi.mock("../plugins/doctor-contract-registry.js", async () => {
const { asNullableRecord: readNullableRecord } =
await import("@openclaw/normalization-core/record-coerce");
function hasLegacyTalkFields(value: unknown): boolean {
const talk = readNullableRecord(value);
@@ -1068,12 +1058,9 @@ vi.mock("../plugins/setup-registry.js", () => ({
})),
}));
vi.mock("./doctor/shared/channel-doctor.js", () => {
function readNullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
vi.mock("./doctor/shared/channel-doctor.js", async () => {
const { asNullableRecord: readNullableRecord } =
await import("@openclaw/normalization-core/record-coerce");
function hasOwnStringArray(value: unknown): boolean {
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry);
@@ -1258,12 +1245,9 @@ vi.mock("./doctor/shared/channel-doctor.js", () => {
};
});
vi.mock("./doctor/shared/preview-warnings.js", () => {
function readNullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
vi.mock("./doctor/shared/preview-warnings.js", async () => {
const { asNullableRecord: readNullableRecord } =
await import("@openclaw/normalization-core/record-coerce");
function hasStringEntries(value: unknown): boolean {
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry);
+2 -2
View File
@@ -3,6 +3,7 @@
// (multi-hundred-MB stores, blocking vacuums) surfaced only after user harm.
import fs from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { note } from "../../packages/terminal-core/src/note.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
@@ -58,8 +59,7 @@ function readPragmaNumber(
pragma: string,
): number | null {
const row = db.prepare(`PRAGMA ${pragma}`).get() as Record<string, unknown> | undefined;
const value = row?.[pragma];
return typeof value === "number" && Number.isFinite(value) ? value : null;
return asFiniteNumber(row?.[pragma]) ?? null;
}
function describeBloat(label: string, stats: SqliteBloatStats): string | null {
@@ -78,7 +78,9 @@ vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({
}),
}));
vi.mock("../secrets/target-registry.js", () => {
vi.mock("../secrets/target-registry.js", async () => {
const { asNullableRecord: readRecord } =
await import("@openclaw/normalization-core/record-coerce");
const entry = {
id: "channels.discord.token",
targetType: "channels.discord.token",
@@ -91,11 +93,6 @@ vi.mock("../secrets/target-registry.js", () => {
includeInAudit: true,
};
const readRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
return {
discoverConfigSecretTargets: (cfg: OpenClawConfig) => {
const targets: Array<{
+2 -4
View File
@@ -4,6 +4,7 @@ import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { replaceFileAtomicSync } from "../infra/replace-file.js";
@@ -184,10 +185,7 @@ function normalizeEnumValues(values: unknown[] | undefined): JsonValue[] | undef
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
return asNullableRecord(value) as JsonSchemaObject | null;
}
function splitHintLookupPath(pathResult: string): string[] {
+2 -1
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { replaceFileAtomic } from "../infra/replace-file.js";
import { isRecord } from "../utils.js";
import { stampConfigWriteMetadata } from "./io.meta.js";
@@ -111,7 +112,7 @@ export async function rollbackConfigFileWriteIfUnchanged(params: {
}
function normalizeStatNumber(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
return asFiniteNumber(value) ?? null;
}
function normalizeStatId(value: number | bigint | null | undefined): string | null {
@@ -1,4 +1,5 @@
/** Widens official external channel schemas for host-resolved SecretRef fields. */
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import {
getOfficialExternalChannelHostSchemaAllOf,
getOfficialExternalChannelSecretContract,
@@ -20,9 +21,7 @@ const SECRET_REF_SCHEMA = SecretRefSchema.toJSONSchema({
}) as JsonSchemaObject;
function asSchemaObject(value: unknown): JsonSchemaObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonSchemaObject)
: undefined;
return asOptionalRecord(value) as JsonSchemaObject | undefined;
}
function widenProperties(
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
import { syncDirectoryBestEffortSync } from "../../infra/directory-durability.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import {
@@ -205,10 +206,6 @@ function resolveSourceWorkerExecArgv(): string[] {
return ["--import", `data:text/javascript,${encodeURIComponent(registerTsx)}`];
}
function normalizeArchiveWorkerError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function spawnSqliteTranscriptArchiveWorker(
plans: readonly TranscriptArchiveWorkerPlan[],
): Promise<TranscriptArchiveWorkerResult[]> {
@@ -223,7 +220,7 @@ function spawnSqliteTranscriptArchiveWorker(
execArgv: sourceWorkerExecArgv,
});
} catch (error) {
return Promise.reject(normalizeArchiveWorkerError(error));
return Promise.reject(toStringifiedError(error));
}
return new Promise((resolve, reject) => {
@@ -235,7 +232,7 @@ function spawnSqliteTranscriptArchiveWorker(
worker.once("error", (error) => {
// An uncaught Worker error is followed by exit. Wait for that event so
// callers never race the Worker's SQLite/file handles on Windows.
workerError = normalizeArchiveWorkerError(error);
workerError = toStringifiedError(error);
});
worker.once("exit", (code) => {
worker.removeAllListeners();
@@ -1,3 +1,4 @@
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import {
deliveryContextFromSession,
sessionDeliveryChannel,
@@ -164,7 +165,7 @@ function resolveSqliteSessionCreatedAt(entry: SessionEntry, updatedAt: number):
}
function finiteSqliteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
return asFiniteNumber(value) ?? null;
}
function resolveSqliteSessionChannel(entry: SessionEntry): string | null {
@@ -5,6 +5,7 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker, type WorkerOptions } from "node:worker_threads";
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
openOpenClawAgentDatabase,
@@ -92,10 +93,6 @@ function nextProjectionClaimId(): number {
return -randomInt(1, 2 ** 47);
}
function normalizeReconcileError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
// Node Worker messages take a transfer list, unlike Window.postMessage.
// Keep the empty list explicit so the platform contract stays unambiguous.
function continueProjectionWorker(worker: Worker, accepted: boolean): void {
@@ -242,7 +239,7 @@ export async function reconcileSessionTranscriptIndexes(
{ workerData: input, execArgv: sourceWorkerExecArgv },
);
} catch (error) {
throw normalizeReconcileError(error);
throw toStringifiedError(error);
}
return new Promise<SessionTranscriptReconcileResult>((resolve, reject) => {
@@ -282,7 +279,7 @@ export async function reconcileSessionTranscriptIndexes(
(database) => deleteOrphanedTranscriptIndexRowsInTransaction(database.db),
);
} catch (error) {
settle(() => reject(normalizeReconcileError(error)), true);
settle(() => reject(toStringifiedError(error)), true);
return;
}
settle(() => resolve({ reconciledSessions }), false);
@@ -321,14 +318,14 @@ export async function reconcileSessionTranscriptIndexes(
}
continueProjectionWorker(worker, owned);
} catch (error) {
settle(() => reject(normalizeReconcileError(error)), true);
settle(() => reject(toStringifiedError(error)), true);
}
};
worker.on("message", (message: SessionTranscriptReconcileWorkerMessage) => {
void handleMessage(message);
});
worker.once("error", (error) => {
settle(() => reject(normalizeReconcileError(error)), true);
settle(() => reject(toStringifiedError(error)), true);
});
worker.once("exit", (code) => {
if (doneReceived && code === 0) {
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import type { AgentMessage } from "../../agents/runtime/index.js";
import { redactTranscriptMessage } from "../../agents/transcript-redact.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -118,7 +119,7 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo {
if (parsed.type === "session") {
return { isNonSessionEntry: false, hasParentLinkedEntry: false };
}
const entryId = normalizeEntryId(parsed.id);
const entryId = readNonBlankString(parsed.id);
if (!entryId) {
return { isNonSessionEntry: true, hasParentLinkedEntry: false };
}
@@ -134,13 +135,13 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo {
};
}
if (parsed.type === "leaf") {
const targetId = parsed.targetId === null ? null : normalizeEntryId(parsed.targetId);
const targetId = parsed.targetId === null ? null : readNonBlankString(parsed.targetId);
const appendParentId =
parsed.appendParentId === undefined
? undefined
: parsed.appendParentId === null
? null
: normalizeEntryId(parsed.appendParentId);
: readNonBlankString(parsed.appendParentId);
if (
(parsed.targetId !== null && targetId === undefined) ||
(parsed.appendParentId !== undefined && appendParentId === undefined) ||
@@ -179,10 +180,6 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo {
}
}
function normalizeEntryId(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function generateEntryId(existingIds: Set<string>): string {
for (let attempt = 0; attempt < 100; attempt += 1) {
const id = randomUUID().slice(0, 8);
@@ -390,7 +387,7 @@ async function migrateLinearTranscriptToParentLinked(transcriptPath: string): Pr
output.push(serializeJsonlLine({ ...record, version: CURRENT_SESSION_VERSION }));
continue;
}
const id = normalizeEntryId(record.id) ?? generateEntryId(existingIds);
const id = readNonBlankString(record.id) ?? generateEntryId(existingIds);
existingIds.add(id);
record.id = id;
if (!Object.hasOwn(record, "parentId")) {
@@ -1,6 +1,6 @@
export function normalizeTranscriptTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
export const normalizeTranscriptTimestamp = asFiniteNumber;
export function isWithinTranscriptWindow(
timestamp: number | undefined,
+2 -5
View File
@@ -5,6 +5,7 @@ import type {
SessionAcpIdentity,
SessionAcpMeta,
} from "@openclaw/acp-core/types";
import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
@@ -810,11 +811,7 @@ export function mergeSessionEntryPreserveActivity(
}
export function resolveSessionTotalTokens(entry?: Pick<SessionEntry, "totalTokens"> | null) {
const total = entry?.totalTokens;
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
return undefined;
}
return total;
return asNonNegativeFiniteNumber(entry?.totalTokens);
}
export function resolveFreshSessionTotalTokens(
+2 -5
View File
@@ -1,5 +1,6 @@
import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
// Defines secret reference and resolution configuration types.
/** Supported secret reference backends in config. */
@@ -204,11 +205,7 @@ export function hasConfiguredSecretInput(value: unknown, defaults?: SecretDefaul
/** Trim a literal secret input string while leaving non-string inputs unresolved. */
export function normalizeSecretInputString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
return normalizeOptionalString(value);
}
function formatSecretRefLabel(ref: SecretRef): string {
+3 -6
View File
@@ -1,3 +1,4 @@
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
import type { ContextEngineHostSupport } from "./host-compat.js";
import type {
@@ -25,10 +26,6 @@ const RUNTIME_REASON_PATTERNS: Array<[ContextEngineRuntimeReasonCode, RegExp]> =
["provider_unavailable", /provider|primary|unavailable/iu],
];
function normalizeNullableNumber(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeReasonCode(value: OptionalString): ContextEngineRuntimeReasonCode | null {
const normalized = normalizeNullableString(value);
if (!normalized) {
@@ -95,8 +92,8 @@ export function buildContextEngineRuntimeSettings(params: {
label: normalizeNullableString(params.contextEngineHost.label),
},
limits: {
promptTokenBudget: normalizeNullableNumber(params.promptTokenBudget),
maxOutputTokens: normalizeNullableNumber(params.maxOutputTokens),
promptTokenBudget: asFiniteNumber(params.promptTokenBudget) ?? null,
maxOutputTokens: asFiniteNumber(params.maxOutputTokens) ?? null,
},
diagnostics: {
fallbackReason,
+2 -4
View File
@@ -1,4 +1,5 @@
/** Dependency-light normalization helpers for stored cron run diagnostics. */
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
@@ -76,10 +77,7 @@ export function normalizeDiagnosticToolName(value: unknown): string | undefined
}
export function normalizeExitCode(value: unknown): number | null | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return value === null ? null : undefined;
return asFiniteNumber(value) ?? (value === null ? null : undefined);
}
export function tailText(value: string, maxChars: number): string {
+9 -12
View File
@@ -1,5 +1,8 @@
/** Resolves and emits cron failure-alert notifications. */
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { classifyOAuthRefreshFailure } from "../../agents/auth-profiles/oauth-refresh-failure.js";
import type { FailoverReason } from "../../agents/failover/signal.js";
@@ -62,14 +65,6 @@ function normalizeFailureAlertRecipient(channel: CronMessageChannel, to: string)
}
}
function normalizeTo(input: unknown): string | undefined {
if (typeof input !== "string") {
return undefined;
}
const to = input.trim();
return to ? to : undefined;
}
function clampPositiveInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
@@ -104,9 +99,11 @@ export function resolveFailureAlert(
const mode = jobConfig?.mode ?? globalConfig?.mode;
const inheritsGlobalMode =
!jobConfig?.mode || jobConfig.mode === (globalConfig?.mode ?? "announce");
const jobTo = normalizeTo(jobConfig?.to);
const jobTo = normalizeOptionalString(jobConfig?.to);
const jobChannel = resolveFailureAlertChannel(jobConfig?.channel, jobTo);
const configuredGlobalTo = inheritsGlobalMode ? normalizeTo(globalConfig?.to) : undefined;
const configuredGlobalTo = inheritsGlobalMode
? normalizeOptionalString(globalConfig?.to)
: undefined;
const globalChannel = inheritsGlobalMode
? resolveFailureAlertChannel(globalConfig?.channel, configuredGlobalTo)
: undefined;
@@ -115,7 +112,7 @@ export function resolveFailureAlert(
const inheritsGlobalRoute =
inheritsGlobalMode && (mode === "webhook" || !jobChannel || jobChannel === globalChannel);
const globalTo = inheritsGlobalRoute ? configuredGlobalTo : undefined;
const deliveryTo = normalizeTo(job.delivery?.to);
const deliveryTo = normalizeOptionalString(job.delivery?.to);
const deliveryChannel = resolveFailureAlertChannel(job.delivery?.channel, deliveryTo);
const channel = jobChannel ?? globalChannel ?? deliveryChannel ?? "last";
const inheritsDeliveryChannel =
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { expectDefined } from "@openclaw/normalization-core";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../agents/internal-runtime-context.js";
@@ -174,12 +175,7 @@ function isSubagentAnnounceInterSessionUserMessage(message: Record<string, unkno
function readChatHistoryRecordTimestampMs(message: unknown): number | undefined {
const meta = readRecord(readRecord(message)?.["__openclaw"]);
const value = meta?.recordTimestampMs;
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const timestamp = readRecord(message)?.timestamp;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : undefined;
return asFiniteNumber(meta?.recordTimestampMs) ?? asFiniteNumber(readRecord(message)?.timestamp);
}
function isSubagentAnnounceInterSessionUserChatHistoryMessage(message: unknown): boolean {
+2 -2
View File
@@ -2,6 +2,7 @@
// previews, session pull request chips): pinned origin, manual redirects,
// bounded bodies, and normalized upstream error statuses.
export { isRecord } from "@openclaw/normalization-core/record-coerce";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { readResponseWithLimit } from "../infra/http-body.js";
export const GITHUB_API_ORIGIN = "https://api.github.com";
@@ -37,8 +38,7 @@ export function readOptionalGitHubString(
}
export function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
return asFiniteNumber(record[key]);
}
export function githubApiToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
+2 -7
View File
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { describe, expect, it } from "vitest";
import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js";
import { getAcpRuntimeBackend } from "../acp/runtime/registry.js";
@@ -196,12 +197,6 @@ function resolveLiveParentModel(): string {
);
}
function resolveModelObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
async function prepareCodexHomeForLiveBindTest(tempRoot: string): Promise<void> {
const home = process.env.HOME?.trim();
const sourceCodexHome = process.env.CODEX_HOME?.trim() || (home ? path.join(home, ".codex") : "");
@@ -681,7 +676,7 @@ describeLive("gateway live (ACP bind)", () => {
defaults: {
...cfg.agents?.defaults,
model: {
...resolveModelObject(cfg.agents?.defaults?.model),
...asNonArrayRecord(cfg.agents?.defaults?.model),
primary: parentModel,
},
models: {
+2 -9
View File
@@ -8,6 +8,7 @@ import {
resolveTimestampMsToIsoString,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { isTruthyEnvValue } from "../infra/env.js";
import { runExec } from "../process/exec.js";
const LIVE_CRON_PROBE_DELAY_SECONDS = 7 * 24 * 60 * 60;
@@ -61,15 +62,7 @@ export function assertLiveImageProbeReply(text: string): void {
export function shouldRunLiveImageProbe(params: { agent: string; override?: string }): boolean {
const override = params.override?.trim();
if (override) {
switch (normalizeOptionalLowercaseString(override)) {
case "1":
case "on":
case "true":
case "yes":
return true;
default:
return false;
}
return isTruthyEnvValue(override);
}
return normalizeOptionalLowercaseString(params.agent) !== "opencode";
}
+2 -6
View File
@@ -1,3 +1,4 @@
import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
@@ -16,11 +17,6 @@ import {
} from "../mcp-http.loopback-runtime.js";
import type { GatewayRequestHandlers } from "./types.js";
function readPositiveNumber(params: Record<string, unknown>, key: string): number | undefined {
const value = params[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
export const attachHandlers: GatewayRequestHandlers = {
"attach.grant": async ({ params, respond, context }) => {
const grantParams = asRecord(params);
@@ -56,7 +52,7 @@ export const attachHandlers: GatewayRequestHandlers = {
const grant = mintAttachGrant({
sessionKey,
...(agentId ? { agentId } : {}),
ttlMs: readPositiveNumber(grantParams, "ttlMs"),
ttlMs: asPositiveFiniteNumber(grantParams.ttlMs),
});
respond(true, {
sessionKey: grant.sessionKey,
@@ -1,3 +1,4 @@
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveSessionTranscriptActiveLeafEntryId } from "../../config/sessions/session-accessor.js";
import {
@@ -30,8 +31,7 @@ export function readChatHistoryMessageId(message: unknown): string | undefined {
export function readChatHistoryMessageSeq(message: unknown): number | undefined {
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
const seq = metadata?.seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
return asPositiveSafeInteger(metadata?.seq);
}
type ChatHistoryPage = {
+2 -3
View File
@@ -1,6 +1,6 @@
// Plugin management Gateway handler tests cover DTO mapping, trust errors, and reload planning.
import { expectDefined } from "@openclaw/normalization-core";
import { coerceErrorMessage, expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const managementMocks = vi.hoisted(() => {
@@ -38,8 +38,7 @@ const searchMock = vi.hoisted(() => vi.fn());
vi.mock("../../plugins/management-service.js", () => ({
ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError,
formatManagedPluginLifecycleError: (error: unknown) =>
error instanceof Error ? error.message : String(error),
formatManagedPluginLifecycleError: coerceErrorMessage,
installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args),
listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args),
setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args),
+2 -11
View File
@@ -1,11 +1,2 @@
/**
* Small normalization helpers shared by gateway request handlers.
*/
/** Returns a non-empty trimmed string, or `undefined` for non-string input. */
export function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Non-empty trimmed-string normalization shared by gateway request handlers. */
export { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce";
+2 -1
View File
@@ -1,5 +1,6 @@
// Gateway RPC handlers for safe gateway restart requests and preflight state.
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js";
@@ -12,7 +13,7 @@ import { requestGatewayRestartWithSignalAdmission } from "../../infra/restart.js
import type { GatewayRequestHandlers } from "./types.js";
function isRestartRequestParams(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isRecord(value);
}
function normalizeReason(value: unknown): string | undefined {
+10 -18
View File
@@ -144,20 +144,12 @@ function sessionFilesError(type: string, message: string, details?: Record<strin
});
}
function normalizePathValue(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function readPathArg(args: Record<string, unknown>): string | undefined {
return (
normalizePathValue(args.path) ??
normalizePathValue(args.file_path) ??
normalizePathValue(args.filePath) ??
normalizePathValue(args.file)
normalizeOptionalString(args.path) ??
normalizeOptionalString(args.file_path) ??
normalizeOptionalString(args.filePath) ??
normalizeOptionalString(args.file)
);
}
@@ -196,11 +188,11 @@ function addStructuredPatchFiles(files: Map<string, TouchedFile>, changes: unkno
}
for (const changeValue of changes) {
const change = asOptionalObjectRecord(changeValue);
addTouchedFile(files, normalizePathValue(change?.path), "modified");
addTouchedFile(files, normalizeOptionalString(change?.path), "modified");
const kind = asOptionalObjectRecord(change?.kind);
addTouchedFile(
files,
normalizePathValue(kind?.move_path) ?? normalizePathValue(kind?.movePath),
normalizeOptionalString(kind?.move_path) ?? normalizeOptionalString(kind?.movePath),
"modified",
);
}
@@ -536,12 +528,12 @@ function loadSessionFileRoot(params: { sessionKey: string; agentId?: string }) {
parseAgentSessionKey(params.sessionKey)?.agentId ??
resolveDefaultAgentId(loaded.cfg),
);
const spawnedCwd = normalizePathValue(loaded.entry.spawnedCwd);
const spawnedWorkspaceDir = normalizePathValue(loaded.entry.spawnedWorkspaceDir);
const spawnedCwd = normalizeOptionalString(loaded.entry.spawnedCwd);
const spawnedWorkspaceDir = normalizeOptionalString(loaded.entry.spawnedWorkspaceDir);
const configuredWorkspaceDir =
spawnedCwd || spawnedWorkspaceDir
? undefined
: normalizePathValue(resolveAgentWorkspaceDir(loaded.cfg, agentId));
: normalizeOptionalString(resolveAgentWorkspaceDir(loaded.cfg, agentId));
// Keep this cwd precedence aligned with sessions.diff so the advertised
// checkout state cannot disagree with the panel's fallback result.
const diffCwd = spawnedCwd ?? spawnedWorkspaceDir ?? configuredWorkspaceDir;
@@ -669,7 +661,7 @@ async function buildBrowserResult(params: {
if (!params.root) {
return undefined;
}
const search = normalizePathValue(params.search);
const search = normalizeOptionalString(params.search);
const relevance = buildSessionRelevanceMap(params.files, params.root, params.fileRoot);
if (search) {
const result = await searchBrowserEntries({
@@ -70,11 +70,6 @@ vi.mock("../sessions/session-upstream-monitor.js", () => ({
startSessionUpstreamMonitor: hoisted.startSessionUpstreamMonitor,
}));
vi.mock("../infra/env.js", () => ({
isTruthyEnvValue: (value?: string) =>
["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""),
}));
vi.mock("../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: hoisted.deliverOutboundPayloads,
deliverOutboundPayloadsInternal: hoisted.deliverOutboundPayloads,
+1 -2
View File
@@ -119,8 +119,7 @@ function shouldCheckRestartSentinel(env: NodeJS.ProcessEnv = process.env): boole
}
function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[SKIP_STARTUP_MODEL_PREWARM_ENV]?.trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
return isTruthyEnvValue(env[SKIP_STARTUP_MODEL_PREWARM_ENV]);
}
function schedulePostAttachUpdateSentinelRefresh(params: {
+5 -2
View File
@@ -1,4 +1,7 @@
import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import {
asNonNegativeFiniteNumber,
asPositiveFiniteNumber,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
countActiveDescendantRuns,
@@ -79,7 +82,7 @@ export function deriveSessionTitle(
}
export function resolvePositiveNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
return asPositiveFiniteNumber(value);
}
export function deriveSessionUnread(
+2 -1
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import readline from "node:readline";
import { expectDefined } from "@openclaw/normalization-core";
import {
asNonNegativeFiniteNumber,
asPositiveFiniteNumber as resolvePositiveUsageNumber,
resolveIntegerOption,
resolveNonNegativeIntegerOption,
@@ -903,7 +904,7 @@ function extractTranscriptUsageCost(raw: unknown): number | undefined {
return undefined;
}
const total = (cost as { total?: unknown }).total;
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined;
return asNonNegativeFiniteNumber(total);
}
function extractTranscriptContentEstimatedChars(content: unknown): number {
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { retryClawHubRead } from "./clawhub-retry.js";
import { isTruthyEnvValue } from "./env.js";
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
import { parseStrictNonNegativeInteger } from "./parse-finite-number.js";
@@ -464,5 +465,5 @@ export function isClawHubTelemetryDisabled(): boolean {
if (!raw) {
return false;
}
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
return isTruthyEnvValue(raw);
}
+2 -2
View File
@@ -1,4 +1,5 @@
// Shared owner-qualified ClawHub security verdict resolution.
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readObject } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import pLimit from "p-limit";
@@ -82,8 +83,7 @@ function readOptionalStringField(value: unknown, field: string): string | undefi
}
function readOptionalNumberField(value: unknown, field: string): number | undefined {
const raw = readObject(value)?.[field];
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
return asFiniteNumber(readObject(value)?.[field]);
}
function normalizeReason(reason: string | null | undefined): string {
+2 -1
View File
@@ -2,6 +2,7 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
import type { Insertable, Selectable } from "kysely";
import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
@@ -109,7 +110,7 @@ function keyPairMatches(publicKeyPem: string, privateKeyPem: string): boolean {
}
function parseCreatedAtMs(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
return asSafeIntegerInRange(value, { min: 0 }) ?? null;
}
/** Validate persisted key material and return the canonical runtime shape. */
+5 -3
View File
@@ -43,7 +43,9 @@ type ApprovalRequestOriginTargetResolver<TTarget> = {
resolveFallbackTarget?: (request: ApprovalRequestLike) => TTarget | null;
};
function normalizeOptionalThreadValue(value?: string | number | null): string | number | undefined {
function normalizeExecApprovalThreadValue(
value?: string | number | null,
): string | number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
@@ -140,7 +142,7 @@ export function resolveExecApprovalSessionTarget(params: {
turnSourceChannel: normalizeOptionalString(params.turnSourceChannel),
turnSourceTo: normalizeOptionalString(params.turnSourceTo),
turnSourceAccountId: normalizeOptionalString(params.turnSourceAccountId),
turnSourceThreadId: normalizeOptionalThreadValue(params.turnSourceThreadId),
turnSourceThreadId: normalizeExecApprovalThreadValue(params.turnSourceThreadId),
});
if (!target.to) {
return null;
@@ -150,7 +152,7 @@ export function resolveExecApprovalSessionTarget(params: {
channel: normalizeOptionalString(target.channel),
to: target.to,
accountId: normalizeOptionalString(target.accountId),
threadId: normalizeOptionalThreadValue(target.threadId),
threadId: normalizeExecApprovalThreadValue(target.threadId),
};
}
+1 -4
View File
@@ -1,14 +1,11 @@
// Message-action target helpers bridge canonical `target` params into legacy
// per-action fields while rejecting mixed destination arguments.
import {
hasNonEmptyString as sharedHasNonEmptyString,
hasNonEmptyString,
normalizeOptionalString,
} from "../../../packages/normalization-core/src/string-coerce.js";
import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js";
/** Shared non-empty string guard for message-action target params. */
export const hasNonEmptyString = sharedHasNonEmptyString;
/** Human-readable description for a single message-action destination. */
export const CHANNEL_TARGET_DESCRIPTION =
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack/Mattermost <channelId|user:ID|channel:ID>, or iMessage handle/chat_id";
+2 -4
View File
@@ -1,5 +1,6 @@
// Shared JSON state helpers for pairing namespaces.
import path from "node:path";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveStateDir } from "../config/paths.js";
export { createAsyncLock, readJsonIfExists } from "./json-files.js";
@@ -17,10 +18,7 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string)
/** Coerce persisted pairing maps, treating malformed arrays/scalars as empty state. */
export function coercePairingStateRecord<T>(value: unknown): Record<string, T> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, T>;
return asNonArrayRecord(value) as Record<string, T>;
}
/** Remove pending requests older than the caller's pairing TTL. */
+2 -1
View File
@@ -1,6 +1,7 @@
// Persists short-lived gateway restart handoff metadata.
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
@@ -136,7 +137,7 @@ export function formatGatewayRestartHandoffDiagnostic(
}
function normalizePid(pid: number | undefined): number | null {
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
return asPositiveSafeInteger(pid) ?? null;
}
function normalizeText(value: unknown, maxLength: number): string | undefined {
+2 -1
View File
@@ -1,4 +1,5 @@
// Persists short-lived gateway restart intent for supervisor SIGTERM handoff.
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
@@ -34,7 +35,7 @@ export type GatewayRestartIntent = {
};
function normalizeRestartIntentPid(pid: number | undefined): number | null {
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
return asPositiveSafeInteger(pid) ?? null;
}
export function normalizeRestartIntentReason(reason: string | undefined): string | undefined {
+4 -3
View File
@@ -1,4 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
import { openNodeSqliteDatabase } from "./node-sqlite.js";
import {
readStableSqliteFileGeneration,
@@ -142,7 +143,7 @@ function bindSqliteIntegrityConfirmation(
}
function failedSqliteIntegrityConfirmation(error: unknown): UnboundSqliteIntegrityConfirmation {
const normalized = error instanceof Error ? error : new Error(String(error));
const normalized = toStringifiedError(error);
return {
status: "failed",
error: normalized,
@@ -151,7 +152,7 @@ function failedSqliteIntegrityConfirmation(error: unknown): UnboundSqliteIntegri
}
function unboundSqliteIntegrityFailure(error: unknown): SqliteIntegrityConfirmation {
const normalized = error instanceof Error ? error : new Error(String(error));
const normalized = toStringifiedError(error);
return { status: "failed", error: normalized, terminal: false };
}
@@ -160,7 +161,7 @@ function closeSqliteDatabase(database: DatabaseSync): Error | undefined {
database.close();
return undefined;
} catch (error) {
return error instanceof Error ? error : new Error(String(error));
return toStringifiedError(error);
}
}
@@ -2,6 +2,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readNonBlankString as optionalNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import {
@@ -132,7 +133,7 @@ function nullableNonNegativeInteger(value: unknown): number | null | undefined {
if (value === null) {
return null;
}
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
return asSafeIntegerInRange(value, { min: 0 });
}
function parseLegacyManagedImageRecord(params: {
+10 -11
View File
@@ -1,6 +1,7 @@
// Persists update-control-plane sentinel files used by updater coordination.
import fs from "node:fs/promises";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import {
markUpdateRestartSentinelFailure,
writeRestartSentinel,
@@ -57,24 +58,22 @@ export function isPendingControlPlaneUpdateRestartSentinel(
);
}
function normalizeText(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeMeta(value: unknown): UpdateRestartSentinelMeta | null {
if (!isRecord(value)) {
return null;
}
const sessionKey = normalizeText(value.sessionKey);
const threadId = normalizeText(value.threadId);
const handoffId = normalizeText(value.handoffId);
const root = normalizeText(value.root);
const sessionKey = readNonBlankString(value.sessionKey);
const threadId = readNonBlankString(value.threadId);
const handoffId = readNonBlankString(value.handoffId);
const root = readNonBlankString(value.root);
const channel = isRecord(value.deliveryContext)
? normalizeText(value.deliveryContext.channel)
? readNonBlankString(value.deliveryContext.channel)
: undefined;
const to = isRecord(value.deliveryContext)
? readNonBlankString(value.deliveryContext.to)
: undefined;
const to = isRecord(value.deliveryContext) ? normalizeText(value.deliveryContext.to) : undefined;
const accountId = isRecord(value.deliveryContext)
? normalizeText(value.deliveryContext.accountId)
? readNonBlankString(value.deliveryContext.accountId)
: undefined;
const deliveryContext =
channel || to || accountId
+6 -14
View File
@@ -10,6 +10,7 @@ import { buildConfigSchemaCore } from "../config/schema.js";
import { isMissingPathError } from "../infra/errors.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { readRegularFileSync } from "../infra/regular-file.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { VERSION } from "../version.js";
import {
readDiagnosticStabilityBundleFileSync,
@@ -209,24 +210,15 @@ function safeScalar(value: unknown): unknown {
function resolveBonjourEnvOverride(
env: NodeJS.ProcessEnv,
): NonNullable<ConfigShape["discovery"]>["bonjourEnvOverride"] {
const raw = env.OPENCLAW_DISABLE_BONJOUR?.trim().toLowerCase();
const raw = env.OPENCLAW_DISABLE_BONJOUR?.trim();
if (!raw) {
return "unset";
}
switch (raw) {
case "1":
case "true":
case "yes":
case "on":
return "force-disabled";
case "0":
case "false":
case "no":
case "off":
return "force-enabled";
default:
return "unrecognized";
const disabled = parseBooleanValue(raw);
if (disabled === true) {
return "force-disabled";
}
return disabled === false ? "force-enabled" : "unrecognized";
}
function sortedObjectKeys(value: unknown): string[] {
+2 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TranscriptsStore } from "../transcripts/store.js";
import { createMeetingSession } from "./session-factory.js";
@@ -134,7 +135,7 @@ function createTestRuntime(params: {
>({
logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
logScope: "[meeting-test]",
formatError: (error) => (error instanceof Error ? error.message : String(error)),
formatError: coerceErrorMessage,
messages: {
previousBrowserLeaveFailed: "previous leave failed",
reassignedSessionNote: "reassigned",
+7 -14
View File
@@ -10,6 +10,7 @@ import {
lowercasePreservingWhitespace,
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
@@ -176,14 +177,6 @@ const DEFAULT_MEMORY_DEEP_DREAMING_SOURCES: MemoryDeepDreamingSource[] = [
];
const DEFAULT_MEMORY_REM_DREAMING_SOURCES: MemoryRemDreamingSource[] = ["memory", "daily", "deep"];
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
// Config integers are decimal-only; Number() would accept hex/exponent forms.
return parseStrictNonNegativeInteger(value) ?? fallback;
@@ -283,7 +276,7 @@ function resolveExecutionConfig(
typeof temperatureRaw === "number" && Number.isFinite(temperatureRaw) && temperatureRaw >= 0
? Math.min(2, temperatureRaw)
: undefined;
const model = normalizeTrimmedString(record?.model) ?? fallback.model;
const model = normalizeOptionalString(record?.model) ?? fallback.model;
return {
speed: normalizeSpeed(record?.speed) ?? fallback.speed,
@@ -315,7 +308,7 @@ export function resolveMemoryDreamingPluginId(
const root = asNullableRecord(cfg);
const plugins = asNullableRecord(root?.plugins);
const slots = asNullableRecord(plugins?.slots);
const configuredSlot = normalizeTrimmedString(slots?.memory);
const configuredSlot = normalizeOptionalString(slots?.memory);
if (configuredSlot && normalizeLowercaseStringOrEmpty(configuredSlot) !== "none") {
return configuredSlot;
}
@@ -339,15 +332,15 @@ export function resolveMemoryDreamingConfig(params: {
}): MemoryDreamingConfig {
const dreaming = asNullableRecord(params.pluginConfig?.dreaming);
const frequency =
normalizeTrimmedString(dreaming?.frequency) ?? DEFAULT_MEMORY_DREAMING_FREQUENCY;
normalizeOptionalString(dreaming?.frequency) ?? DEFAULT_MEMORY_DREAMING_FREQUENCY;
const timezone =
normalizeTrimmedString(dreaming?.timezone) ??
normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone) ??
normalizeOptionalString(dreaming?.timezone) ??
normalizeOptionalString(params.cfg?.agents?.defaults?.userTimezone) ??
DEFAULT_MEMORY_DREAMING_TIMEZONE;
const storage = asNullableRecord(dreaming?.storage);
const execution = asNullableRecord(dreaming?.execution);
const phases = asNullableRecord(dreaming?.phases);
const topLevelModel = normalizeTrimmedString(dreaming?.model);
const topLevelModel = normalizeOptionalString(dreaming?.model);
const defaultExecution = resolveExecutionConfig(execution?.defaults, {
speed: DEFAULT_MEMORY_DREAMING_SPEED,
+2 -3
View File
@@ -6,6 +6,7 @@ import {
drainFileLockManagerForTest,
resetFileLockManagerForTest,
} from "@openclaw/fs-safe/file-lock";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import {
isLockOwnerDefinitelyStale,
shouldRemoveDeadOwnerOrExpiredLock,
@@ -86,9 +87,7 @@ function createCurrentProcessLockPayload(): Record<string, unknown> {
}
function asLockPayload(payload: unknown): Record<string, unknown> | null {
return payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
return asNullableRecord(payload);
}
function sameStatValue(left: number | bigint, right: number | bigint): boolean {
@@ -1,3 +1,4 @@
import { normalizeOptionalString as readLiveModelCatalogString } from "../../packages/normalization-core/src/string-coerce.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js";
import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js";
@@ -209,10 +210,6 @@ async function readLiveModelCatalogJson(response: Response, timeoutMs: number):
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer));
}
function readLiveModelCatalogString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readLiveModelCatalogNextUrl(body: unknown): string | undefined {
const record = readLiveModelCatalogRecord(body);
if (!record) {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
findNormalizedProviderKey,
normalizeProviderId,
} from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "../../packages/normalization-core/src/record-coerce.js";
import { resolvePrimaryStringValue } from "../../packages/normalization-core/src/string-coerce.js";
import { ensureStaticModelAllowlistEntry } from "../agents/model-allowlist-entry.js";
import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js";
@@ -294,7 +295,7 @@ export function createAliasOnlyPresetAppliers(params: {
function isMergeableProviderConfig(
value: ModelProviderConfig | undefined,
): value is ModelProviderConfig {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
return isRecord(value);
}
function mergeOnboardProviderRequest(
@@ -1,4 +1,6 @@
// Provider discovery contract helpers define reusable discovery tests for provider plugins.
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runProviderCatalog } from "../../plugins/provider-discovery.js";
import {
@@ -162,10 +164,7 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract
"Editor-Version": "vscode/1.96.2",
"User-Agent": "GitHubCopilotChat/0.26.7",
})),
coerceSecretRef: (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null,
coerceSecretRef: asNullableRecord,
ensureApiKeyFromOptionEnvOrPrompt: vi.fn(),
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
@@ -176,8 +175,7 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract
? trimmed
: "github.com";
},
normalizeOptionalSecretInput: (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined,
normalizeOptionalSecretInput: normalizeOptionalString,
resolveNonEnvSecretRefApiKeyMarker: (source: unknown) =>
typeof source === "string" ? source : "",
upsertAuthProfile: vi.fn(),
+5 -12
View File
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { describe, expect, it } from "vitest";
import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js";
import { listGitTrackedFiles, toRepoRelativePath } from "../test-utils/repo-files.js";
@@ -50,14 +51,6 @@ function readJsonFile(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function listBundledPluginDirs(): string[] {
const externalDirs = listExternalBundledPluginDirs();
if (externalDirs) {
@@ -146,8 +139,8 @@ function readBundledPluginRecords(): BundledPluginRecord[] {
const manifest = readJsonFile(manifestPath) as PluginManifestShape;
const pkg = readJsonFile(packagePath) as OpenClawPackageShape;
const manifestId = normalizeText(manifest.id);
const packageName = normalizeText(pkg.name);
const manifestId = normalizeOptionalString(manifest.id);
const packageName = normalizeOptionalString(pkg.name);
if (!manifestId || !packageName) {
return [];
}
@@ -157,8 +150,8 @@ function readBundledPluginRecords(): BundledPluginRecord[] {
dirName,
packageName,
manifestId,
installNpmSpec: normalizeText(pkg.openclaw?.install?.npmSpec),
channelId: normalizeText(pkg.openclaw?.channel?.id),
installNpmSpec: normalizeOptionalString(pkg.openclaw?.install?.npmSpec),
channelId: normalizeOptionalString(pkg.openclaw?.channel?.id),
},
];
});
@@ -14,7 +14,7 @@ import {
type PluginManifest,
} from "../../manifest.js";
import { resolveLoaderPackageRoot } from "../../sdk-alias.js";
import { uniqueStrings } from "../shared.js";
import { normalizeContractStringValues } from "../shared.js";
// Build/test inventory only.
// Runtime code should prefer manifest/runtime registry queries instead of these snapshots.
@@ -111,7 +111,7 @@ function normalizeSetupProviderEnvVars(setup: PluginManifest["setup"]): Record<s
(provider) =>
[
provider.id.trim(),
uniqueStrings(provider.envVars ?? [], (value) =>
normalizeContractStringValues(provider.envVars ?? [], (value) =>
typeof value === "string" ? value.trim() : "",
),
] as const,
@@ -126,57 +126,68 @@ function buildBundledPluginContractSnapshot(
): BundledPluginContractSnapshot {
return {
pluginId: manifest.id,
cliBackendIds: uniqueStrings(manifest.cliBackends, (value) => value.trim()),
providerIds: uniqueStrings(manifest.providers, (value) => value.trim()),
cliBackendIds: normalizeContractStringValues(manifest.cliBackends, (value) => value.trim()),
providerIds: normalizeContractStringValues(manifest.providers, (value) => value.trim()),
providerEnvVars: normalizeSetupProviderEnvVars(manifest.setup),
workerProviderIds: uniqueStrings(manifest.contracts?.workerProviders, (value) => value.trim()),
embeddingProviderIds: uniqueStrings(manifest.contracts?.embeddingProviders, (value) =>
workerProviderIds: normalizeContractStringValues(manifest.contracts?.workerProviders, (value) =>
value.trim(),
),
speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders, (value) => value.trim()),
realtimeTranscriptionProviderIds: uniqueStrings(
embeddingProviderIds: normalizeContractStringValues(
manifest.contracts?.embeddingProviders,
(value) => value.trim(),
),
speechProviderIds: normalizeContractStringValues(manifest.contracts?.speechProviders, (value) =>
value.trim(),
),
realtimeTranscriptionProviderIds: normalizeContractStringValues(
manifest.contracts?.realtimeTranscriptionProviders,
(value) => value.trim(),
),
realtimeVoiceProviderIds: uniqueStrings(manifest.contracts?.realtimeVoiceProviders, (value) =>
value.trim(),
realtimeVoiceProviderIds: normalizeContractStringValues(
manifest.contracts?.realtimeVoiceProviders,
(value) => value.trim(),
),
mediaUnderstandingProviderIds: uniqueStrings(
mediaUnderstandingProviderIds: normalizeContractStringValues(
manifest.contracts?.mediaUnderstandingProviders,
(value) => value.trim(),
),
transcriptSourceProviderIds: uniqueStrings(
transcriptSourceProviderIds: normalizeContractStringValues(
manifest.contracts?.transcriptSourceProviders,
(value) => value.trim(),
),
documentExtractorIds: uniqueStrings(manifest.contracts?.documentExtractors, (value) =>
value.trim(),
documentExtractorIds: normalizeContractStringValues(
manifest.contracts?.documentExtractors,
(value) => value.trim(),
),
imageGenerationProviderIds: uniqueStrings(
imageGenerationProviderIds: normalizeContractStringValues(
manifest.contracts?.imageGenerationProviders,
(value) => value.trim(),
),
videoGenerationProviderIds: uniqueStrings(
videoGenerationProviderIds: normalizeContractStringValues(
manifest.contracts?.videoGenerationProviders,
(value) => value.trim(),
),
musicGenerationProviderIds: uniqueStrings(
musicGenerationProviderIds: normalizeContractStringValues(
manifest.contracts?.musicGenerationProviders,
(value) => value.trim(),
),
webContentExtractorIds: uniqueStrings(manifest.contracts?.webContentExtractors, (value) =>
value.trim(),
webContentExtractorIds: normalizeContractStringValues(
manifest.contracts?.webContentExtractors,
(value) => value.trim(),
),
webFetchProviderIds: uniqueStrings(manifest.contracts?.webFetchProviders, (value) =>
value.trim(),
webFetchProviderIds: normalizeContractStringValues(
manifest.contracts?.webFetchProviders,
(value) => value.trim(),
),
webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders, (value) =>
value.trim(),
webSearchProviderIds: normalizeContractStringValues(
manifest.contracts?.webSearchProviders,
(value) => value.trim(),
),
migrationProviderIds: uniqueStrings(manifest.contracts?.migrationProviders, (value) =>
value.trim(),
migrationProviderIds: normalizeContractStringValues(
manifest.contracts?.migrationProviders,
(value) => value.trim(),
),
toolNames: uniqueStrings(manifest.contracts?.tools, (value) => value.trim()),
toolNames: normalizeContractStringValues(manifest.contracts?.tools, (value) => value.trim()),
};
}
+41 -21
View File
@@ -10,7 +10,7 @@ import {
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS,
type BundledPluginContractSnapshot,
} from "./inventory/bundled-capability-metadata.js";
import { uniqueStrings } from "./shared.js";
import { normalizeContractStringValues } from "./shared.js";
type BundledCapabilityRuntimeRegistry = ReturnType<typeof loadBundledCapabilityRuntimeRegistry>;
type CapabilityContractEntry<T> = {
@@ -34,7 +34,7 @@ function normalizeProviderEnvVars(
return Object.fromEntries(
Object.entries(providerEnvVars ?? {}).map(([providerId, envVars]) => [
providerId,
uniqueStrings(envVars),
normalizeContractStringValues(envVars),
]),
);
}
@@ -44,7 +44,7 @@ function resolvePluginProviderEnvVars(plugin: {
}): Record<string, string[]> {
const envVars: Record<string, string[]> = {};
for (const provider of plugin.setup?.providers ?? []) {
envVars[provider.id] = uniqueStrings(provider.envVars ?? []);
envVars[provider.id] = normalizeContractStringValues(provider.envVars ?? []);
}
return normalizeProviderEnvVars(envVars);
}
@@ -99,29 +99,49 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] {
)
.map((plugin) => ({
pluginId: plugin.id,
cliBackendIds: uniqueStrings(plugin.cliBackends),
providerIds: uniqueStrings(plugin.providers),
cliBackendIds: normalizeContractStringValues(plugin.cliBackends),
providerIds: normalizeContractStringValues(plugin.providers),
providerEnvVars: resolvePluginProviderEnvVars(plugin),
workerProviderIds: uniqueStrings(plugin.contracts?.workerProviders ?? []),
embeddingProviderIds: uniqueStrings(plugin.contracts?.embeddingProviders ?? []),
speechProviderIds: uniqueStrings(plugin.contracts?.speechProviders ?? []),
realtimeTranscriptionProviderIds: uniqueStrings(
workerProviderIds: normalizeContractStringValues(plugin.contracts?.workerProviders ?? []),
embeddingProviderIds: normalizeContractStringValues(
plugin.contracts?.embeddingProviders ?? [],
),
speechProviderIds: normalizeContractStringValues(plugin.contracts?.speechProviders ?? []),
realtimeTranscriptionProviderIds: normalizeContractStringValues(
plugin.contracts?.realtimeTranscriptionProviders ?? [],
),
realtimeVoiceProviderIds: uniqueStrings(plugin.contracts?.realtimeVoiceProviders ?? []),
mediaUnderstandingProviderIds: uniqueStrings(
realtimeVoiceProviderIds: normalizeContractStringValues(
plugin.contracts?.realtimeVoiceProviders ?? [],
),
mediaUnderstandingProviderIds: normalizeContractStringValues(
plugin.contracts?.mediaUnderstandingProviders ?? [],
),
transcriptSourceProviderIds: uniqueStrings(plugin.contracts?.transcriptSourceProviders ?? []),
documentExtractorIds: uniqueStrings(plugin.contracts?.documentExtractors ?? []),
imageGenerationProviderIds: uniqueStrings(plugin.contracts?.imageGenerationProviders ?? []),
videoGenerationProviderIds: uniqueStrings(plugin.contracts?.videoGenerationProviders ?? []),
musicGenerationProviderIds: uniqueStrings(plugin.contracts?.musicGenerationProviders ?? []),
webContentExtractorIds: uniqueStrings(plugin.contracts?.webContentExtractors ?? []),
webFetchProviderIds: uniqueStrings(plugin.contracts?.webFetchProviders ?? []),
webSearchProviderIds: uniqueStrings(plugin.contracts?.webSearchProviders ?? []),
migrationProviderIds: uniqueStrings(plugin.contracts?.migrationProviders ?? []),
toolNames: uniqueStrings(plugin.contracts?.tools ?? []),
transcriptSourceProviderIds: normalizeContractStringValues(
plugin.contracts?.transcriptSourceProviders ?? [],
),
documentExtractorIds: normalizeContractStringValues(
plugin.contracts?.documentExtractors ?? [],
),
imageGenerationProviderIds: normalizeContractStringValues(
plugin.contracts?.imageGenerationProviders ?? [],
),
videoGenerationProviderIds: normalizeContractStringValues(
plugin.contracts?.videoGenerationProviders ?? [],
),
musicGenerationProviderIds: normalizeContractStringValues(
plugin.contracts?.musicGenerationProviders ?? [],
),
webContentExtractorIds: normalizeContractStringValues(
plugin.contracts?.webContentExtractors ?? [],
),
webFetchProviderIds: normalizeContractStringValues(plugin.contracts?.webFetchProviders ?? []),
webSearchProviderIds: normalizeContractStringValues(
plugin.contracts?.webSearchProviders ?? [],
),
migrationProviderIds: normalizeContractStringValues(
plugin.contracts?.migrationProviders ?? [],
),
toolNames: normalizeContractStringValues(plugin.contracts?.tools ?? []),
}));
}
@@ -1,6 +1,7 @@
// Shared upstream model contract tests keep capability flags aligned across bundled catalogs.
import fs from "node:fs";
import path from "node:path";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { describe, expect, it } from "vitest";
import { listGitTrackedFiles } from "../../test-utils/repo-files.js";
@@ -57,12 +58,6 @@ function normalizeSharedModelId(modelId: string): string {
return (separator === -1 ? modelId : modelId.slice(separator + 1)).toLowerCase();
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function collectCatalogEntries(): CatalogEntry[] {
const entries: CatalogEntry[] = [];
for (const manifest of readBundledManifests()) {
+1 -1
View File
@@ -1,5 +1,5 @@
/** Returns unique normalized string values while preserving first-seen order. */
export function uniqueStrings(
export function normalizeContractStringValues(
values: readonly string[] | undefined,
normalize: (value: string) => string = (value) => value,
): string[] {
+2 -3
View File
@@ -1,5 +1,5 @@
/** Converts loaded plugin registries into stable plugin records for status and diagnostics. */
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { parseBooleanValue } from "../utils/boolean.js";
import type { PluginCompatCode } from "./compat/registry.js";
import type { PluginActivationState } from "./config-state.js";
import type { PluginBundleFormat, PluginDiagnosticCode, PluginFormat } from "./manifest-types.js";
@@ -204,8 +204,7 @@ export function formatPluginFailureSummary(failedPlugins: PluginRecord[]): strin
}
function isPluginLoadDebugEnabled(env: NodeJS.ProcessEnv): boolean {
const normalized = normalizeLowercaseStringOrEmpty(env.OPENCLAW_PLUGIN_LOAD_DEBUG);
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
return parseBooleanValue(env.OPENCLAW_PLUGIN_LOAD_DEBUG) === true;
}
function describePluginModuleExportShape(

Some files were not shown because too many files have changed in this diff Show More